diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 9fed88801a..e33b829014 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1094,8 +1094,9 @@ function openDriverStoreForInstallError(errMsg: string, node: TreeNode = activeN async function loadMoreObjectGroupChildren() { const node = activeNode.value; + const searchFilter = node.loadMore?.parentId ? connectionStore.sidebarTableSearchQueries[node.loadMore.parentId]?.trim() || "" : ""; try { - await connectionStore.loadMoreObjectGroupChildren(node); + await connectionStore.loadMoreObjectGroupChildren(node, { searchFilter }); } catch (e: any) { toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000); } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 35edf1c93d..1e942c07d7 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -3244,6 +3244,88 @@ describe("connectionStore metadata loading", () => { expect(listTables).toHaveBeenCalledTimes(5); }); + it("pages table-scoped search results instead of truncating at the search budget", async () => { + const tables = Array.from({ length: 5 }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })); + const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number) => { + const matches = searchFilter ? tables.filter((table) => table.name.includes(searchFilter)) : tables; + const start = offset ?? 0; + return Promise.resolve(matches.slice(start, start + (limit ?? matches.length))); + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + settingsStore.desktopSettings.sidebar_table_page_size = 2; + + const connection = mysqlConnection(); + const tablesGroup: TreeNode = { + id: "mysql-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "mysql-1:app", + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [tablesGroup], + }, + ], + }, + ]; + + store.sidebarTableSearchQueries[tablesGroup.id] = "t"; + await store.loadObjectGroupChildren(tablesGroup, { + force: true, + searchFilter: "t", + sidebarTableSearchParentId: tablesGroup.id, + expectedSidebarTableSearchQuery: "t", + }); + + expect(tablesGroup.children?.map((child) => child.label)).toEqual(["t_0001", "t_0002", "tree.loadMore"]); + expect(tablesGroup.children?.at(-1)?.type).toBe("load-more"); + expect(listTables.mock.calls[0].slice(3, 6)).toEqual(["t", 3, 0]); + + await store.loadMoreObjectGroupChildren(tablesGroup.children!.at(-1)!, { searchFilter: "t" }); + + expect(tablesGroup.children?.map((child) => child.label)).toEqual(["t_0001", "t_0002", "t_0003", "t_0004", "tree.loadMore"]); + expect(tablesGroup.children?.at(-1)?.type).toBe("load-more"); + expect(listTables.mock.calls[1].slice(3, 6)).toEqual(["t", 3, 2]); + }); + it("keeps connection-name matches out of object metadata filters", async () => { const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string) => Promise.resolve(searchFilter === "orders" || !searchFilter ? [{ name: "orders", table_type: "TABLE" as const, comment: null }] : [])); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index cde3d0ec6e..e34f4cc391 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2087,6 +2087,7 @@ export const useConnectionStore = defineStore("connection", () => { offset: number; pageSize: number; searchFilter?: string; + pagedSearch?: boolean; force?: boolean; }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> { if (!options.node.connectionId || options.node.database == null) { @@ -2102,15 +2103,12 @@ export const useConnectionStore = defineStore("connection", () => { }); const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter); const sourceRevision = tableListSourceRevision(options.node.connectionId); - // A search must never truncate the fuzzy result set to the first page: the - // target table can sort beyond it (e.g. "T_Erp_Nc_SuPlan_List" for - // "erpncs" in a large ERP schema), which silently drops it from the first - // search even though later, narrower queries succeed. Results are bounded - // by SIDEBAR_TABLE_SEARCH_RESULT_BUDGET so a wide fuzzy query cannot push - // an unbounded result set through database → IPC → store → tree rendering. - // Unfiltered loads keep the page+1 probe used for load-more detection. - const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : options.pageSize + 1; - const fetchOffset = searchFilter ? undefined : options.offset; + // Table-scoped search must page through the full ordered result set, just + // like unfiltered loads, instead of silently stopping at the first budget + // window. Global sidebar search keeps the bounded single-shot fetch. + const paginate = options.pagedSearch || !searchFilter; + const fetchLimit = paginate ? options.pageSize + 1 : SIDEBAR_TABLE_SEARCH_RESULT_BUDGET; + const fetchOffset = paginate ? options.offset : undefined; const tables = await loadCachedMetadataListPage( metadataListCacheScope({ kind: "table-list-page", @@ -2128,7 +2126,7 @@ export const useConnectionStore = defineStore("connection", () => { () => listTablesWithOptionalTableNameFilter(options.node.connectionId!, options.node.database!, options.querySchema, searchFilter, fetchLimit, fetchOffset, options.objectTypes, options.node.catalog, tableNameFilter), { force: options.force }, ); - const hasMore = searchFilter ? false : tables.length > options.pageSize; + const hasMore = paginate ? tables.length > options.pageSize : false; const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables; if (tableListSourceRevision(options.node.connectionId) === sourceRevision) { indexCompletionTables(options.node.connectionId, options.node.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); @@ -2223,6 +2221,7 @@ export const useConnectionStore = defineStore("connection", () => { offset: number; pageSize: number; searchFilter?: string; + pagedSearch?: boolean; force?: boolean; }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> { const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined; @@ -2233,12 +2232,9 @@ export const useConnectionStore = defineStore("connection", () => { nodeKind: "simple-tables", }); const sourceRevision = tableListSourceRevision(options.connectionId); - // A search must never truncate the fuzzy result set to the first page (see - // loadPagedTableGroupChildren); results are bounded by - // SIDEBAR_TABLE_SEARCH_RESULT_BUDGET, and unfiltered loads keep the - // page+1 probe. - const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : options.pageSize + 1; - const fetchOffset = searchFilter ? undefined : options.offset; + const paginate = options.pagedSearch || !searchFilter; + const fetchLimit = paginate ? options.pageSize + 1 : SIDEBAR_TABLE_SEARCH_RESULT_BUDGET; + const fetchOffset = paginate ? options.offset : undefined; const tables = await loadCachedMetadataListPage( metadataListCacheScope({ kind: "table-list-page", @@ -2255,7 +2251,7 @@ export const useConnectionStore = defineStore("connection", () => { () => listTablesWithOptionalTableNameFilter(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, fetchOffset, undefined, undefined, tableNameFilter), { force: options.force }, ); - const hasMore = searchFilter ? false : tables.length > options.pageSize; + const hasMore = paginate ? tables.length > options.pageSize : false; const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables; if (tableListSourceRevision(options.connectionId) === sourceRevision) { indexCompletionTables(options.connectionId, options.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); @@ -5553,9 +5549,10 @@ export const useConnectionStore = defineStore("connection", () => { offset: 0, pageSize, searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined, + pagedSearch: isSidebarTableSearch, force: options?.force, }); - children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children; + children = page.hasMore && (!searchFilter || isSidebarTableSearch) ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children; nextObjectCount = page.objectCount; } else if (simpleObjectDisplay) { // The synthetic public scope contains no tables. Avoid issuing a @@ -5706,9 +5703,10 @@ export const useConnectionStore = defineStore("connection", () => { offset: 0, pageSize: sidebarObjectGroupPageSize(), searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined, + pagedSearch: isSidebarTableSearch, force: options?.force, }); - children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize()), page.loadMoreParent) : page.children; + children = page.hasMore && (!searchFilter || isSidebarTableSearch) ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize()), page.loadMoreParent) : page.children; nextObjectCount = page.objectCount; } else { const pageSize = sidebarObjectGroupPageSize(); @@ -5791,6 +5789,7 @@ export const useConnectionStore = defineStore("connection", () => { schema: parent.schema, nodeKind: parent.type, objectTypes: objectTypesForScope, + searchFilter: options?.searchFilter, limit: loadMore.pageSize + 1, offset: loadMore.offset, sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay, @@ -5819,6 +5818,8 @@ export const useConnectionStore = defineStore("connection", () => { nonTableObjectTypes: [], offset: loadMore.offset, pageSize: loadMore.pageSize, + searchFilter: options?.searchFilter, + pagedSearch: !!options?.searchFilter, force: false, }); const targetParent = treeNodeLoadRelatedTarget(load, parent); @@ -5828,7 +5829,9 @@ export const useConnectionStore = defineStore("connection", () => { const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren; targetParent.objectCount = mergedChildren.length; setChildren(targetParent, nextChildren); - await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v8")), nextChildren); + if (!options?.searchFilter) { + await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v8")), nextChildren); + } const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; return; @@ -5855,6 +5858,7 @@ export const useConnectionStore = defineStore("connection", () => { offset: loadMore.offset, pageSize: loadMore.pageSize, searchFilter: options?.searchFilter, + pagedSearch: !!options?.searchFilter, force: false, }); const targetParent = treeNodeLoadRelatedTarget(load, parent); @@ -5881,7 +5885,9 @@ export const useConnectionStore = defineStore("connection", () => { nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize)] : mergedChildren; targetParent.objectCount = mergedChildren.length; setChildren(targetParent, nextChildren); - await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); + if (!options?.searchFilter) { + await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); + } const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; return; @@ -5890,7 +5896,9 @@ export const useConnectionStore = defineStore("connection", () => { if (!targetParent || !parentEpoch.isCurrent()) return; targetParent.objectCount = mergedChildren.length; setChildren(targetParent, nextChildren); - await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); + if (!options?.searchFilter) { + await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); + } const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; } catch (e) { diff --git a/issue-assets/issue-7114-search-load-more.png b/issue-assets/issue-7114-search-load-more.png new file mode 100644 index 0000000000..b43d95598a Binary files /dev/null and b/issue-assets/issue-7114-search-load-more.png differ diff --git a/packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts b/packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts index 9c91c1a3e1..692de26d4d 100644 --- a/packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts +++ b/packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts @@ -8,19 +8,14 @@ // for the fuzzy query "erpncs". This test locks in the fixed behavior: the // first search builds the index so the complete table set is searchable. // -// Remote searches must stay bounded: every fuzzy match travels -// database → IPC → store → tree rendering, so the result set is capped by -// SIDEBAR_TABLE_SEARCH_RESULT_BUDGET (mirrored from connectionStore.ts). +// Table-scoped remote searches are paginated at the configured sidebar table +// page size, so a wide fuzzy query can still reach alphabetically-late matches +// by loading more instead of being silently truncated to one budget window. import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { matchSidebarLabel } from "../../apps/desktop/src/lib/sidebar/sidebarSearch.ts"; import type { ConnectionConfig, TableInfo, TreeNode } from "@/types/database"; -// Mirrors SIDEBAR_TABLE_SEARCH_RESULT_BUDGET in connectionStore.ts (4× the -// default sidebar_table_page_size of 500). Kept as a local constant because -// connectionStore must stay dynamically imported for vi.doMock isolation. -const SIDEBAR_TABLE_SEARCH_RESULT_BUDGET = 2000; - function installLocalStorage() { const data = new Map(); vi.stubGlobal("localStorage", { @@ -202,7 +197,7 @@ describe("sidebar local table search first-search index build (#6190)", () => { expect(cached?.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List"); }, 30000); - it("remote search returns the complete fuzzy result set within the result budget", async () => { + it("remote search pages through the complete fuzzy result set", async () => { const allTables = buildTables(); const listTables = listTablesFor(allTables); const { store, tablesGroup } = await installStore(listTables); @@ -219,29 +214,40 @@ describe("sidebar local table search first-search index build (#6190)", () => { await store.loadObjectGroupChildren(tablesGroup, searchOptions(searchFilter)); }; - // First remote search: the backend receives the result budget as the - // limit — 4× the page size, comfortably above the 801 fuzzy matches of - // this schema — so the alphabetically-late target is never truncated. + // The first remote search loads one 500-table page plus a load-more node. + // The alphabetically-late target is on the next page, so it must not be + // dropped from the first result set. await search("erpncs"); - const firstResults = (tablesGroup.children ?? []).map((node) => node.label); - expect(firstResults).toContain("T_Erp_Nc_SuPlan_List"); - expect(listTables.mock.calls.some((call) => call[3] === "erpncs" && call[4] === SIDEBAR_TABLE_SEARCH_RESULT_BUDGET)).toBe(true); + let children = tablesGroup.children ?? []; + expect(children).toHaveLength(501); + expect(children.at(-1)?.type).toBe("load-more"); + expect(children.map((node) => node.label)).not.toContain("T_Erp_Nc_SuPlan_List"); + expect(listTables.mock.calls.some((call) => call[3] === "erpncs" && call[4] === 501 && call[5] === 0)).toBe(true); + + await store.loadMoreObjectGroupChildren(children.at(-1)!, { searchFilter: "erpncs" }); + children = tablesGroup.children ?? []; + expect(children.map((node) => node.label)).toContain("T_Erp_Nc_SuPlan_List"); + expect(listTables.mock.calls.some((call) => call[3] === "erpncs" && call[4] === 501 && call[5] === 500)).toBe(true); // Repeated searches are order-independent. await search("terpncs"); expect((tablesGroup.children ?? []).map((node) => node.label)).toContain("T_Erp_Nc_SuPlan_List"); await search("erpncs"); + children = tablesGroup.children ?? []; + expect(children.at(-1)?.type).toBe("load-more"); + await store.loadMoreObjectGroupChildren(children.at(-1)!, { searchFilter: "erpncs" }); expect((tablesGroup.children ?? []).map((node) => node.label)).toContain("T_Erp_Nc_SuPlan_List"); }, 30000); - it("remote search results never exceed the result budget", async () => { - // 3000 tables whose names all contain "erpncs": an unbounded search would - // return all 3000; the budget caps a single result set at 2000. + it("remote search results remain paginated for very wide queries", async () => { + // 3000 tables whose names all contain "erpncs": pagination keeps every + // loaded page bounded by the configured sidebar table page size. const allTables: TableInfo[] = []; for (let i = 0; i < 3000; i++) allTables.push({ name: `ErpNcS${i}`, table_type: "TABLE", comment: null }); const listTables = listTablesFor(allTables); const { store, tablesGroup } = await installStore(listTables); + store.setSidebarTableSearchQuery(tablesGroup.id, "erpncs"); await store.loadObjectGroupChildren(tablesGroup, { force: true, searchFilter: "erpncs", @@ -250,8 +256,14 @@ describe("sidebar local table search first-search index build (#6190)", () => { }); const children = tablesGroup.children ?? []; - expect(children.length).toBeLessThanOrEqual(SIDEBAR_TABLE_SEARCH_RESULT_BUDGET); - expect(listTables.mock.calls.some((call) => call[3] === "erpncs" && call[4] === SIDEBAR_TABLE_SEARCH_RESULT_BUDGET)).toBe(true); + expect(children).toHaveLength(501); + expect(children.at(-1)?.type).toBe("load-more"); + expect(listTables.mock.calls.some((call) => call[3] === "erpncs" && call[4] === 501 && call[5] === 0)).toBe(true); + + await store.loadMoreObjectGroupChildren(children.at(-1)!, { searchFilter: "erpncs" }); + const loadedRows = (tablesGroup.children ?? []).filter((node) => node.type !== "load-more"); + expect(loadedRows).toHaveLength(1000); + expect((tablesGroup.children ?? []).at(-1)?.type).toBe("load-more"); }, 30000); it("an empty (cleared) query never issues a remote fuzzy search", async () => {