feat(sidebar): page full table search results under database

This commit is contained in:
zipg authored and GitHub committed 2026-09-04 10:25:57 +08:00
1 parent 6d39eccdbf
commit 28930136d7
5 files changed
+146 -43

No files matched your search

@@ -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);
}
@@ -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 }] : []));
+30 -22
View File
@@ -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<TableInfo[]>(
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<TableInfo[]>(
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) {