From ca367b62831c42e596d9453d9bc61c2390b52721 Mon Sep 17 00:00:00 2001 From: caiyq925-cell Date: Mon, 26 Oct 2026 12:40:51 +0800 Subject: [PATCH] fix(sidebar): respect table name filter during sidebar search Closes #13456 --- ...tionStore.sidebarTableSearchFilter.spec.ts | 126 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 21 ++- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.sidebarTableSearchFilter.spec.ts diff --git a/apps/desktop/src/stores/__tests__/connectionStore.sidebarTableSearchFilter.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.sidebarTableSearchFilter.spec.ts new file mode 100644 index 0000000000..94d97f14ae --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.sidebarTableSearchFilter.spec.ts @@ -0,0 +1,126 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function pgConnection(id: string): ConnectionConfig { + return { id, name: id, db_type: "postgres", host: "127.0.0.1", port: 5432, username: "postgres", password: "", database: "app", read_only: false } as ConnectionConfig; +} + +function databaseNode(connectionId: string, database: string): TreeNode { + return { + id: `${connectionId}:${database}`, + label: database, + type: "database", + connectionId, + database, + isExpanded: true, + children: [{ id: `${connectionId}:${database}:__tables`, label: "tables", type: "group-tables", connectionId, database, children: [] }], + }; +} + +async function setup() { + const checkConnectionHealth = vi.fn().mockResolvedValue(undefined); + const connectDb = vi.fn(async (config: ConnectionConfig) => config.id); + const listTables = vi.fn().mockResolvedValue([ + { name: "t_order", table_type: "BASE TABLE" }, + { name: "t_user", table_type: "BASE TABLE" }, + ]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth, + connectDb, + listTables, + disconnectDb: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + deleteTableVGroupsForConnection: vi.fn().mockResolvedValue(undefined), + 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 store = useConnectionStore(); + store.connections = [pgConnection("conn-a")]; + store.treeNodes = [ + { + id: "conn-a", + label: "conn-a", + type: "connection", + connectionId: "conn-a", + isExpanded: true, + children: [databaseNode("conn-a", "app")], + }, + ]; + return { store, connectDb, listTables }; +} + +function tablesGroupNode(store: ReturnType["store"]): TreeNode { + const database = store.treeNodes[0].children![0]; + return database.children![0]; +} + +describe("sidebar table search index respects the per-connection table filter", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("passes the per-connection table filter when building the search index", async () => { + const { store, connectDb, listTables } = await setup(); + const group = tablesGroupNode(store); + store.setSidebarTableNameFilter(store.tableNameFilterScopeKey({ connectionId: group.connectionId!, database: group.database, schema: group.schema, nodeKind: group.type, catalog: group.catalog }), { includePatterns: ["t_order%"], excludePatterns: [] }); + + const entries = await store.refreshSidebarTableSearchIndex(group.id); + + expect(connectDb).toHaveBeenCalledTimes(1); + const call = listTables.mock.calls.at(-1)!; + expect(call[8]).toEqual({ includePatterns: ["t_order%"], excludePatterns: [] }); + expect((entries ?? []).map((entry) => entry.name)).toEqual(["t_order", "t_user"]); + }, 20_000); + + it("builds the index without a filter when none is set", async () => { + const { store, listTables } = await setup(); + const group = tablesGroupNode(store); + + await store.refreshSidebarTableSearchIndex(group.id); + + const call = listTables.mock.calls.at(-1)!; + expect(call[8]).toBeUndefined(); + }, 20_000); + + it("drops the stale index when the table filter changes and rebuilds with the filter", async () => { + const { store, listTables } = await setup(); + const group = tablesGroupNode(store); + const scopeKey = store.tableNameFilterScopeKey({ connectionId: group.connectionId!, database: group.database, schema: group.schema, nodeKind: group.type, catalog: group.catalog }); + + // 无过滤时先建一次索引(未过滤的旧索引会一直留在缓存里)。 + await store.refreshSidebarTableSearchIndex(group.id); + const cached = await store.loadSidebarTableSearchIndex(group.id); + expect((cached ?? []).map((entry) => entry.name)).toEqual(["t_order", "t_user"]); + + // 用户修改过滤条件:树刷新 + 索引失效,重建时必须带上过滤器。 + store.setSidebarTableNameFilter(scopeKey, { includePatterns: ["t_user%"], excludePatterns: [] }); + await store.refreshTreeNodeForTableNameFilter(group, scopeKey, 1); + + const invalidated = await store.loadSidebarTableSearchIndex(group.id); + expect(invalidated).toBeNull(); + + const rebuilt = await store.refreshSidebarTableSearchIndex(group.id); + const call = listTables.mock.calls.at(-1)!; + expect(call[8]).toEqual({ includePatterns: ["t_user%"], excludePatterns: [] }); + expect((rebuilt ?? []).map((entry) => entry.name)).toEqual(["t_order", "t_user"]); + }, 20_000); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 7ccb4a6047..379feb91b3 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -3977,10 +3977,23 @@ export const useConnectionStore = defineStore("connection", () => { const querySchema = connectionObjectTreeQuerySchema(config, parent.database, parent.schema); const objectTypes = sidebarIndexedObjectGroupTypes.has(parent.type) ? (objectTypesForGroupNode(parent.type) ?? undefined) : undefined; const pageSize = sidebarObjectGroupPageSize(); + // 搜索索引必须与连接的表过滤器保持一致:用户右键设置的过滤条件在正常加载路径 + // 由服务端(SQL LIKE)执行,索引若不带同一过滤器,过滤掉的表就会在搜索时重新出现。 + // scope 的各字段与保存过滤器的 tableNameFilterScopeForNode 使用同一节点,键必须一致。 + const tableNameFilter = effectiveTableNameFilterForNode( + parent, + activeTableNameFilterForScope({ + connectionId, + database: parent.database, + schema: parent.schema, + nodeKind: parent.type, + catalog: parent.catalog, + }), + ); const entries: TableInfo[] = []; for (let offset = 0; ; offset += pageSize) { if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return []; - const page = await listTablesWithOptionalTableNameFilter(connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog); + const page = await listTablesWithOptionalTableNameFilter(connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog, tableNameFilter); entries.push(...page); if (page.length < pageSize) break; } @@ -8777,6 +8790,12 @@ export const useConnectionStore = defineStore("connection", () => { async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) { invalidateMetadataCachesForNode(node); + // 侧栏搜索索引的生命周期独立于元数据缓存:过滤器变更后若不失效旧索引, + // 顶部搜索会继续用未过滤的索引把过滤掉的表重新列出来。 + const searchIndexScope = sidebarTableSearchIndexScopeForNode(node); + if (searchIndexScope) { + await invalidateSidebarTableSearchIndexScopes([searchIndexScope], (scope) => scope.parentNodeId === node.id); + } // !node.tableName is generic: table-level structure groups (columns/indexes/fkeys/constraints/partitions/table triggers) // stay off the schema-level object-group loader, not just triggers. if (objectTypesForGroupNode(node.type) && !node.tableName) {