fix(sidebar): cancel stale table search work across modes
This commit is contained in:
1 parent
e671b3da7d
commit
71136377e5
8 files changed
+763
-24
No files matched your search
@@ -40,6 +40,7 @@ import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
|||||||
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime";
|
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime";
|
||||||
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
|
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
|
||||||
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
|
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
|
||||||
|
import { createSidebarTableSearchDebouncer, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex";
|
||||||
import TreeItem from "./TreeItem.vue";
|
import TreeItem from "./TreeItem.vue";
|
||||||
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
|
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
|
||||||
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
|
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
|
||||||
@@ -149,7 +150,10 @@ const searchRefreshedNodeIds = searchExpansionState.filteredNodeIds;
|
|||||||
// touched group open and reloading it again.
|
// touched group open and reloading it again.
|
||||||
const searchAutoExpandedNodeIds = searchExpansionState.autoExpandedNodeIds;
|
const searchAutoExpandedNodeIds = searchExpansionState.autoExpandedNodeIds;
|
||||||
let searchTimer: number | undefined;
|
let searchTimer: number | undefined;
|
||||||
const tableSearchTimers = new Map<string, number>();
|
const remoteTableSearchDebouncer = createSidebarTableSearchDebouncer();
|
||||||
|
// Local-mode table searches debounce like remote ones: rapid typing coalesces
|
||||||
|
// into a single index load/build instead of one full build per keystroke.
|
||||||
|
const localTableSearchDebouncer = createSidebarTableSearchDebouncer();
|
||||||
const tableSearchFocusRestoreTokens = new Map<string, number>();
|
const tableSearchFocusRestoreTokens = new Map<string, number>();
|
||||||
let tableSearchFocusRestoreTokenSeq = 0;
|
let tableSearchFocusRestoreTokenSeq = 0;
|
||||||
let latestTableSearchInteractionParentId: string | null = null;
|
let latestTableSearchInteractionParentId: string | null = null;
|
||||||
@@ -244,8 +248,8 @@ watch(
|
|||||||
if (parentNodeIds.length === 0) return;
|
if (parentNodeIds.length === 0) return;
|
||||||
|
|
||||||
for (const parentNodeId of parentNodeIds) {
|
for (const parentNodeId of parentNodeIds) {
|
||||||
window.clearTimeout(tableSearchTimers.get(parentNodeId));
|
remoteTableSearchDebouncer.cancel(parentNodeId);
|
||||||
tableSearchTimers.delete(parentNodeId);
|
localTableSearchDebouncer.cancel(parentNodeId);
|
||||||
tableSearchFocusRestoreTokens.delete(parentNodeId);
|
tableSearchFocusRestoreTokens.delete(parentNodeId);
|
||||||
store.setSidebarTableSearchQuery(parentNodeId, "");
|
store.setSidebarTableSearchQuery(parentNodeId, "");
|
||||||
}
|
}
|
||||||
@@ -486,15 +490,17 @@ function clearSearchScopeFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) {
|
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) {
|
||||||
window.clearTimeout(tableSearchTimers.get(parentNodeId));
|
if (isTreeSearchFiltering.value) {
|
||||||
if (isTreeSearchFiltering.value) return;
|
remoteTableSearchDebouncer.cancel(parentNodeId);
|
||||||
|
localTableSearchDebouncer.cancel(parentNodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
|
const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
|
||||||
if (restoreToken) {
|
if (restoreToken) {
|
||||||
tableSearchFocusRestoreTokens.clear();
|
tableSearchFocusRestoreTokens.clear();
|
||||||
tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken);
|
tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken);
|
||||||
}
|
}
|
||||||
const timer = window.setTimeout(() => {
|
scheduleExclusiveSidebarTableSearchDebounce(parentNodeId, remoteTableSearchDebouncer, localTableSearchDebouncer, () => {
|
||||||
tableSearchTimers.delete(parentNodeId);
|
|
||||||
void store.refreshSidebarTableSearch(parentNodeId).then(() => {
|
void store.refreshSidebarTableSearch(parentNodeId).then(() => {
|
||||||
if (!restoreToken) return;
|
if (!restoreToken) return;
|
||||||
if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return;
|
if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return;
|
||||||
@@ -503,8 +509,7 @@ function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { foc
|
|||||||
if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return;
|
if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return;
|
||||||
restoreTableSearchInput(focusRestore);
|
restoreTableSearchInput(focusRestore);
|
||||||
});
|
});
|
||||||
}, 250);
|
});
|
||||||
tableSearchTimers.set(parentNodeId, timer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore {
|
function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore {
|
||||||
@@ -607,10 +612,35 @@ function filterGloballyIndexedRegexTables(nodes: TreeNode[]): TreeNode[] {
|
|||||||
return mergeSidebarRegexIndexScopes(nodes, matchingScopes);
|
return mergeSidebarRegexIndexScopes(nodes, matchingScopes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleLocalSidebarTableSearchRefresh(parentNodeId: string, focusRestore?: TableSearchFocusRestore) {
|
||||||
|
if (isTreeSearchFiltering.value) {
|
||||||
|
localTableSearchDebouncer.cancel(parentNodeId);
|
||||||
|
remoteTableSearchDebouncer.cancel(parentNodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleExclusiveSidebarTableSearchDebounce(parentNodeId, localTableSearchDebouncer, remoteTableSearchDebouncer, () => {
|
||||||
|
void loadLocalTableSearchResults(parentNodeId, false, focusRestore);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) {
|
async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) {
|
||||||
try {
|
try {
|
||||||
const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId);
|
// A missing persisted index (null) means this scope was never indexed, so
|
||||||
localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries };
|
// only the currently loaded first page of children is searchable. That
|
||||||
|
// silently misses alphabetically-late tables (e.g. "T_Erp_Nc_SuPlan_List"
|
||||||
|
// for "erpncs") until an explicit index refresh happens. Build the index
|
||||||
|
// on the first search so results always cover the complete table set,
|
||||||
|
// matching the behavior of an explicit refresh. The scope key deduplicates
|
||||||
|
// concurrent full builds, and an empty (cleared) query never builds.
|
||||||
|
const query = store.sidebarTableSearchQueries[parentNodeId]?.trim() ?? "";
|
||||||
|
const entries = await loadOrBuildSidebarTableSearchIndex(
|
||||||
|
parentNodeId,
|
||||||
|
query,
|
||||||
|
() => store.loadSidebarTableSearchIndex(parentNodeId),
|
||||||
|
() => store.refreshSidebarTableSearchIndex(parentNodeId),
|
||||||
|
refresh,
|
||||||
|
);
|
||||||
|
if (query || refresh) localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries };
|
||||||
} finally {
|
} finally {
|
||||||
if (focusRestore) restoreTableSearchInput(focusRestore);
|
if (focusRestore) restoreTableSearchInput(focusRestore);
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1129,7 @@ provide(sidebarTreeContextKey, {
|
|||||||
restoreTableSearchInput(focusRestore);
|
restoreTableSearchInput(focusRestore);
|
||||||
localTableSearchFocusPending = false;
|
localTableSearchFocusPending = false;
|
||||||
});
|
});
|
||||||
void loadLocalTableSearchResults(parentNodeId, false, focusRestore);
|
scheduleLocalSidebarTableSearchRefresh(parentNodeId, focusRestore);
|
||||||
} else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore });
|
} else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore });
|
||||||
},
|
},
|
||||||
refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true),
|
refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true),
|
||||||
@@ -1999,10 +2029,8 @@ onUnmounted(() => {
|
|||||||
resetSidebarTreeDialogState();
|
resetSidebarTreeDialogState();
|
||||||
window.removeEventListener("keydown", onWindowKeydown);
|
window.removeEventListener("keydown", onWindowKeydown);
|
||||||
cancelPendingSidebarDataOpen();
|
cancelPendingSidebarDataOpen();
|
||||||
for (const timer of tableSearchTimers.values()) {
|
remoteTableSearchDebouncer.cancelAll();
|
||||||
window.clearTimeout(timer);
|
localTableSearchDebouncer.cancelAll();
|
||||||
}
|
|
||||||
tableSearchTimers.clear();
|
|
||||||
tableSearchFocusRestoreTokens.clear();
|
tableSearchFocusRestoreTokens.clear();
|
||||||
latestTableSearchInteractionParentId = null;
|
latestTableSearchInteractionParentId = null;
|
||||||
latestTableSearchInteractionId = 0;
|
latestTableSearchInteractionId = 0;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { TableInfo } from "@/types/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-flight full-index builds, keyed by sidebar scope (parent node id).
|
||||||
|
*
|
||||||
|
* A missing persisted index (read returns null) means the scope was never
|
||||||
|
* indexed, so only the currently loaded first page of children would be
|
||||||
|
* searchable. That silently misses alphabetically-late tables — e.g.
|
||||||
|
* "T_Erp_Nc_SuPlan_List" for the fuzzy query "erpncs" — until an explicit
|
||||||
|
* index refresh happens (the refresh button). Building the index on the first
|
||||||
|
* search makes the complete table set searchable from the start, matching the
|
||||||
|
* behavior of an explicit refresh.
|
||||||
|
*
|
||||||
|
* Building pages through every table of a scope, so rapid consecutive first
|
||||||
|
* searches must never start concurrent full builds: while one build for a
|
||||||
|
* scope is in flight, further requests reuse the same promise instead of
|
||||||
|
* spawning a duplicate scan. Entries are removed once the build settles
|
||||||
|
* (success or failure) so a later search can retry.
|
||||||
|
*/
|
||||||
|
const inFlightSidebarTableSearchBuilds = new Map<string, Promise<TableInfo[]>>();
|
||||||
|
|
||||||
|
function dedupeInFlightBuild(scopeKey: string, build: () => Promise<TableInfo[]>): Promise<TableInfo[]> {
|
||||||
|
const existing = inFlightSidebarTableSearchBuilds.get(scopeKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
const pending = (async () => {
|
||||||
|
try {
|
||||||
|
return await build();
|
||||||
|
} finally {
|
||||||
|
inFlightSidebarTableSearchBuilds.delete(scopeKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
inFlightSidebarTableSearchBuilds.set(scopeKey, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the local table search index for a sidebar scope, building it on first
|
||||||
|
* use — exactly once per scope, even under rapid consecutive input.
|
||||||
|
*
|
||||||
|
* - An empty (or cleared) query filters nothing, so no read or build happens:
|
||||||
|
* clearing the input must not scan every page of the table set for nothing.
|
||||||
|
* - An explicit refresh (`refresh = true`) always rebuilds, but still through
|
||||||
|
* the in-flight lock so double-clicks cannot start duplicate full builds.
|
||||||
|
* - A persisted index is reused without rebuilding; a missing one is built
|
||||||
|
* through the lock, and concurrent callers share the same build promise.
|
||||||
|
*/
|
||||||
|
export async function loadOrBuildSidebarTableSearchIndex(scopeKey: string, query: string, read: () => Promise<TableInfo[] | null>, build: () => Promise<TableInfo[]>, refresh = false): Promise<TableInfo[] | null> {
|
||||||
|
if (!refresh && !query.trim()) return null;
|
||||||
|
if (refresh) return dedupeInFlightBuild(scopeKey, build);
|
||||||
|
const entries = await read();
|
||||||
|
if (entries) return entries;
|
||||||
|
return dedupeInFlightBuild(scopeKey, build);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarTableSearchDebouncer {
|
||||||
|
/** Debounce a per-key load; consecutive calls for the same key within the window coalesce. */
|
||||||
|
schedule(key: string, run: () => void): void;
|
||||||
|
/** Cancel a pending (scheduled, not yet run) load for a key. */
|
||||||
|
cancel(key: string): void;
|
||||||
|
/** Cancel every pending load. */
|
||||||
|
cancelAll(): void;
|
||||||
|
/** Number of pending (scheduled, not yet run) loads. */
|
||||||
|
pendingCount(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-key debouncer used to coalesce rapid table-search input into a single
|
||||||
|
* load/build. Each schedule() for the same key resets the window, so fast
|
||||||
|
* typing issues one load instead of one full index build per keystroke.
|
||||||
|
*/
|
||||||
|
export function createSidebarTableSearchDebouncer(delayMs = 250): SidebarTableSearchDebouncer {
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
return {
|
||||||
|
schedule(key, run) {
|
||||||
|
const existing = timers.get(key);
|
||||||
|
if (existing !== undefined) clearTimeout(existing);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timers.delete(key);
|
||||||
|
run();
|
||||||
|
}, delayMs);
|
||||||
|
timers.set(key, timer);
|
||||||
|
},
|
||||||
|
cancel(key) {
|
||||||
|
const existing = timers.get(key);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
timers.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelAll() {
|
||||||
|
for (const timer of timers.values()) clearTimeout(timer);
|
||||||
|
timers.clear();
|
||||||
|
},
|
||||||
|
pendingCount() {
|
||||||
|
return timers.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleExclusiveSidebarTableSearchDebounce(key: string, active: SidebarTableSearchDebouncer, inactive: SidebarTableSearchDebouncer, run: () => void): void {
|
||||||
|
inactive.cancel(key);
|
||||||
|
active.schedule(key, run);
|
||||||
|
}
|
||||||
@@ -126,4 +126,42 @@ describe("connectionStore Doris catalog tree", () => {
|
|||||||
["iceberg", "sales", "tree.queries"],
|
["iceberg", "sales", "tree.queries"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the bounded fuzzy-search budget for external catalog tables", async () => {
|
||||||
|
const listTables = vi.fn().mockResolvedValue([{ name: "late_orders", table_type: "BASE TABLE", comment: null }]);
|
||||||
|
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 = "simple";
|
||||||
|
settingsStore.desktopSettings.sidebar_table_page_size = 500;
|
||||||
|
const connection = starRocksConnection();
|
||||||
|
const database = databaseNode(connection.id, "hive", "sales");
|
||||||
|
store.connections = [connection];
|
||||||
|
store.connectedIds.add(connection.id);
|
||||||
|
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [catalogNode(connection.id, "hive", "Hive", [database])] }];
|
||||||
|
store.setSidebarTableSearchQuery(database.id, "orders");
|
||||||
|
|
||||||
|
await store.loadTreeNodeChildren(database, {
|
||||||
|
force: true,
|
||||||
|
searchFilter: "orders",
|
||||||
|
sidebarTableSearchParentId: database.id,
|
||||||
|
expectedSidebarTableSearchQuery: "orders",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listTables.mock.calls[0]?.[4]).toBe(2000);
|
||||||
|
expect(listTables.mock.calls[0]?.[5]).toBeUndefined();
|
||||||
|
expect(listTables.mock.calls[0]?.[7]).toBe("hive");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -12,6 +12,11 @@ function installLocalStorage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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_SEARCH_RESULT_BUDGET = 2000;
|
||||||
|
|
||||||
function postgresConnection(): ConnectionConfig {
|
function postgresConnection(): ConnectionConfig {
|
||||||
return {
|
return {
|
||||||
id: "pg-1",
|
id: "pg-1",
|
||||||
@@ -3165,7 +3170,7 @@ describe("connectionStore metadata loading", () => {
|
|||||||
expect(tablesGroup.objectCount).toBe(5);
|
expect(tablesGroup.objectCount).toBe(5);
|
||||||
expect(tablesGroup.isLoading).toBe(false);
|
expect(tablesGroup.isLoading).toBe(false);
|
||||||
expect(listTables.mock.calls.map((call) => [call[3], call[4], call[5]])).toEqual([
|
expect(listTables.mock.calls.map((call) => [call[3], call[4], call[5]])).toEqual([
|
||||||
["0003", 2, undefined],
|
["0003", SIDEBAR_SEARCH_RESULT_BUDGET, undefined],
|
||||||
[undefined, 3, 0],
|
[undefined, 3, 0],
|
||||||
[undefined, 3, 0],
|
[undefined, 3, 0],
|
||||||
[undefined, 3, 2],
|
[undefined, 3, 2],
|
||||||
@@ -3219,7 +3224,7 @@ describe("connectionStore metadata loading", () => {
|
|||||||
store.sidebarSearchQuery = "orders";
|
store.sidebarSearchQuery = "orders";
|
||||||
await store.loadObjectGroupChildren(tablesGroup, { force: true });
|
await store.loadObjectGroupChildren(tablesGroup, { force: true });
|
||||||
|
|
||||||
expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", 200, undefined, ["TABLE"]);
|
expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", SIDEBAR_SEARCH_RESULT_BUDGET, undefined, ["TABLE"]);
|
||||||
expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]);
|
expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]);
|
||||||
|
|
||||||
let resolveAncestorLoad!: (tables: TableInfo[]) => void;
|
let resolveAncestorLoad!: (tables: TableInfo[]) => void;
|
||||||
|
|||||||
@@ -198,6 +198,19 @@ function sidebarObjectGroupPageSize(): number {
|
|||||||
return typeof size === "number" && size > 0 ? size : 500;
|
return typeof size === "number" && size > 0 ? size : 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound for a single remote fuzzy table-search result set.
|
||||||
|
*
|
||||||
|
* Every fuzzy match travels database → IPC → store → tree rendering, so an
|
||||||
|
* unbounded result set (e.g. a single-letter query against a large schema)
|
||||||
|
* would push thousands of rows through the whole pipeline. The budget is 4×
|
||||||
|
* the default page size (500) and comfortably covers the reported #6190
|
||||||
|
* schema (801 fuzzy matches) while keeping a single IPC payload bounded.
|
||||||
|
* Queries whose fuzzy match set exceeds the budget are truncated; narrowing
|
||||||
|
* the query reaches later tables.
|
||||||
|
*/
|
||||||
|
export const SIDEBAR_TABLE_SEARCH_RESULT_BUDGET = 2000;
|
||||||
|
|
||||||
function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
|
function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
|
||||||
if (!config || config.db_type !== "mq") return false;
|
if (!config || config.db_type !== "mq") return false;
|
||||||
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true;
|
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true;
|
||||||
@@ -1855,7 +1868,14 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
catalog: options.node.catalog,
|
catalog: options.node.catalog,
|
||||||
});
|
});
|
||||||
const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter);
|
const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter);
|
||||||
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
|
// 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;
|
const fetchOffset = searchFilter ? undefined : options.offset;
|
||||||
const tables = await loadCachedMetadataListPage<TableInfo[]>(
|
const tables = await loadCachedMetadataListPage<TableInfo[]>(
|
||||||
metadataListCacheScope({
|
metadataListCacheScope({
|
||||||
@@ -1964,7 +1984,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
schema: options.effectiveSchema ?? options.querySchema,
|
schema: options.effectiveSchema ?? options.querySchema,
|
||||||
nodeKind: "simple-tables",
|
nodeKind: "simple-tables",
|
||||||
});
|
});
|
||||||
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
|
// 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 fetchOffset = searchFilter ? undefined : options.offset;
|
||||||
const tables = await loadCachedMetadataListPage<TableInfo[]>(
|
const tables = await loadCachedMetadataListPage<TableInfo[]>(
|
||||||
metadataListCacheScope({
|
metadataListCacheScope({
|
||||||
@@ -4674,6 +4698,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
const configForScope = getConfig(connectionId);
|
const configForScope = getConfig(connectionId);
|
||||||
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||||||
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
|
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
|
||||||
|
const searchFilterForScope = activeTreeLoadSearchFilter(options);
|
||||||
|
const pageSizeForScope = sidebarObjectGroupPageSize();
|
||||||
return runTreeMetadataLoad(
|
return runTreeMetadataLoad(
|
||||||
{
|
{
|
||||||
kind: "schema-tables",
|
kind: "schema-tables",
|
||||||
@@ -4682,8 +4708,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
schema: undefined,
|
schema: undefined,
|
||||||
nodeKind: "database",
|
nodeKind: "database",
|
||||||
objectTypes: objectTypesForScope,
|
objectTypes: objectTypesForScope,
|
||||||
searchFilter: activeTreeLoadSearchFilter(options),
|
searchFilter: searchFilterForScope,
|
||||||
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined,
|
limit: simpleObjectDisplayForScope ? (searchFilterForScope ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSizeForScope + 1) : undefined,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
|
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
|
||||||
driverProfile: metadataDriverProfile(configForScope),
|
driverProfile: metadataDriverProfile(configForScope),
|
||||||
@@ -4710,8 +4736,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pageSize = sidebarObjectGroupPageSize();
|
const pageSize = pageSizeForScope;
|
||||||
const fetchLimit = searchFilter ? pageSize : pageSize + 1;
|
const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSize + 1;
|
||||||
const fetchOffset = searchFilter ? undefined : 0;
|
const fetchOffset = searchFilter ? undefined : 0;
|
||||||
const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables");
|
const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables");
|
||||||
const hasMore = searchFilter ? false : tables.length > pageSize;
|
const hasMore = searchFilter ? false : tables.length > pageSize;
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// Regression test for t8y2/dbx #6190.
|
||||||
|
//
|
||||||
|
// Local-mode sidebar table search (sidebarTableSearchLocal, the default) only
|
||||||
|
// searches the persisted table search index. When that index has never been
|
||||||
|
// built, the first search falls back to the currently loaded first page of
|
||||||
|
// children, which silently misses alphabetically-late tables such as
|
||||||
|
// "T_Erp_Nc_SuPlan_List" (sorted after hundreds of "A_Erp_*"/"T_Bas_*" names)
|
||||||
|
// 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).
|
||||||
|
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<string, string>();
|
||||||
|
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 sqlServerConnection(): ConnectionConfig {
|
||||||
|
return {
|
||||||
|
id: "mssql-1",
|
||||||
|
name: "MSSQL",
|
||||||
|
db_type: "sqlserver",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 1433,
|
||||||
|
username: "sa",
|
||||||
|
password: "",
|
||||||
|
database: "erp",
|
||||||
|
} as ConnectionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the SQL Server backend: SQL-side fuzzy (contains OR subsequence),
|
||||||
|
// name-ordered, with limit applied last (SELECT TOP semantics).
|
||||||
|
function subsequence(text: string, q: string): boolean {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < lower.length && j < q.length; i++) if (lower[i] === q[j]) j++;
|
||||||
|
return j === q.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A large ERP-like schema: the target table sorts after hundreds of other
|
||||||
|
// tables, so it is absent from the first unfiltered page (page size 500).
|
||||||
|
function buildTables(): TableInfo[] {
|
||||||
|
const tables: TableInfo[] = [];
|
||||||
|
for (let i = 0; i < 700; i++) tables.push({ name: `A_Erp_Nc_Sys_${i}`, table_type: "TABLE", comment: null });
|
||||||
|
for (let i = 0; i < 100; i++) tables.push({ name: `T_Erp_Nc_Su_Table_${i}`, table_type: "TABLE", comment: null });
|
||||||
|
for (let i = 0; i < 300; i++) tables.push({ name: `T_Bas_Customer_${i}`, table_type: "TABLE", comment: null });
|
||||||
|
for (let i = 0; i < 300; i++) tables.push({ name: `T_Fin_Account_${i}`, table_type: "TABLE", comment: null });
|
||||||
|
tables.push({ name: "T_Erp_Nc_SuPlan_List", table_type: "TABLE", comment: null });
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTablesFor(allTables: TableInfo[]) {
|
||||||
|
return vi.fn(async (_conn: string, _db: string, _schema: string, filter?: string, limit?: number, offset?: number) => {
|
||||||
|
const q = filter?.trim().toLowerCase();
|
||||||
|
const sorted = [...allTables].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
let matched = sorted;
|
||||||
|
if (q) matched = sorted.filter((t) => t.name.toLowerCase().includes(q) || (q.length >= 2 && subsequence(t.name, q)));
|
||||||
|
const start = offset ?? 0;
|
||||||
|
return matched.slice(start, start + (limit ?? matched.length));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installStore(listTables: ReturnType<typeof vi.fn>) {
|
||||||
|
const cachedPayloads = new Map<string, unknown>();
|
||||||
|
const loadSchemaCache = vi.fn(async (key: string) => {
|
||||||
|
const payload = cachedPayloads.get(key) ?? null;
|
||||||
|
await Promise.resolve();
|
||||||
|
return payload == null ? null : structuredClone(payload);
|
||||||
|
});
|
||||||
|
const saveSchemaCache = vi.fn(async (key: string, payload: unknown) => {
|
||||||
|
cachedPayloads.set(key, structuredClone(payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||||
|
vi.doMock("@/lib/backend/api", () => ({
|
||||||
|
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||||
|
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||||
|
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
listTables,
|
||||||
|
loadSchemaCache,
|
||||||
|
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveSchemaCache,
|
||||||
|
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||||
|
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||||
|
const store = useConnectionStore();
|
||||||
|
useSettingsStore().desktopSettings.sidebar_table_page_size = 500;
|
||||||
|
|
||||||
|
const connection = sqlServerConnection();
|
||||||
|
const tablesGroup: TreeNode = {
|
||||||
|
id: "mssql-1:erp:dbo:__tables",
|
||||||
|
label: "tree.tables",
|
||||||
|
type: "group-tables",
|
||||||
|
connectionId: connection.id,
|
||||||
|
database: "erp",
|
||||||
|
schema: "dbo",
|
||||||
|
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: "mssql-1:erp",
|
||||||
|
label: "erp",
|
||||||
|
type: "database",
|
||||||
|
connectionId: connection.id,
|
||||||
|
database: "erp",
|
||||||
|
isExpanded: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "mssql-1:erp:dbo",
|
||||||
|
label: "dbo",
|
||||||
|
type: "schema",
|
||||||
|
connectionId: connection.id,
|
||||||
|
database: "erp",
|
||||||
|
schema: "dbo",
|
||||||
|
isExpanded: true,
|
||||||
|
children: [tablesGroup],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return { store, tablesGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar local table search first-search index build (#6190)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
installLocalStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the local search index on the first search so late-sorted tables are found", async () => {
|
||||||
|
const allTables = buildTables();
|
||||||
|
const listTables = listTablesFor(allTables);
|
||||||
|
const { store, tablesGroup } = await installStore(listTables);
|
||||||
|
|
||||||
|
// 1. Expand the tables group: the first page does not contain the target.
|
||||||
|
await store.loadObjectGroupChildren(tablesGroup, { force: true });
|
||||||
|
const firstPage = (tablesGroup.children ?? []).map((node) => node.label);
|
||||||
|
expect(firstPage).not.toContain("T_Erp_Nc_SuPlan_List");
|
||||||
|
|
||||||
|
// 2. Before the fix, the persisted index is missing, so the first search
|
||||||
|
// only sees the loaded first page and misses the target.
|
||||||
|
expect(await store.loadSidebarTableSearchIndex(tablesGroup.id)).toBeNull();
|
||||||
|
// Pre-fix UI path: filterLocallySearchedTables with indexed === null falls
|
||||||
|
// back to the loaded children, so the target is invisible on first search.
|
||||||
|
const preFixResults = firstPage.filter((name) => !!matchSidebarLabel(name, "erpncs"));
|
||||||
|
expect(preFixResults).not.toContain("T_Erp_Nc_SuPlan_List");
|
||||||
|
|
||||||
|
// 3. The fix: the first search builds the index (refreshSidebarTableSearchIndex
|
||||||
|
// pages through the complete table set), which then contains the target.
|
||||||
|
const index = await store.refreshSidebarTableSearchIndex(tablesGroup.id);
|
||||||
|
const indexNames = index.map((entry) => entry.name);
|
||||||
|
expect(indexNames).toContain("T_Erp_Nc_SuPlan_List");
|
||||||
|
|
||||||
|
// 4. The UI filter used by filterLocallySearchedTables (indexed branch)
|
||||||
|
// matches the target for the reported queries, on the first try.
|
||||||
|
const erpncs = index.filter((entry) => !!matchSidebarLabel(entry.name, "erpncs"));
|
||||||
|
expect(erpncs.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List");
|
||||||
|
|
||||||
|
const terpncs = index.filter((entry) => !!matchSidebarLabel(entry.name, "terpncs"));
|
||||||
|
expect(terpncs.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List");
|
||||||
|
|
||||||
|
// 5. Case variants behave identically.
|
||||||
|
for (const query of ["ERPnCS", "ERPNCS"]) {
|
||||||
|
const matches = index.filter((entry) => !!matchSidebarLabel(entry.name, query));
|
||||||
|
expect(matches.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Repeat searches are order-independent: the built index is served from
|
||||||
|
// the persisted cache on subsequent searches.
|
||||||
|
const cached = await store.loadSidebarTableSearchIndex(tablesGroup.id);
|
||||||
|
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 () => {
|
||||||
|
const allTables = buildTables();
|
||||||
|
const listTables = listTablesFor(allTables);
|
||||||
|
const { store, tablesGroup } = await installStore(listTables);
|
||||||
|
|
||||||
|
const searchOptions = (searchFilter: string): Parameters<typeof store.loadObjectGroupChildren>[1] => ({
|
||||||
|
force: true,
|
||||||
|
searchFilter,
|
||||||
|
sidebarTableSearchParentId: tablesGroup.id,
|
||||||
|
expectedSidebarTableSearchQuery: searchFilter,
|
||||||
|
});
|
||||||
|
// Mirrors the real input path: the query is committed before the refresh.
|
||||||
|
const search = async (searchFilter: string) => {
|
||||||
|
store.setSidebarTableSearchQuery(tablesGroup.id, searchFilter);
|
||||||
|
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.
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Repeated searches are order-independent.
|
||||||
|
await search("terpncs");
|
||||||
|
expect((tablesGroup.children ?? []).map((node) => node.label)).toContain("T_Erp_Nc_SuPlan_List");
|
||||||
|
await search("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.
|
||||||
|
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);
|
||||||
|
|
||||||
|
await store.loadObjectGroupChildren(tablesGroup, {
|
||||||
|
force: true,
|
||||||
|
searchFilter: "erpncs",
|
||||||
|
sidebarTableSearchParentId: tablesGroup.id,
|
||||||
|
expectedSidebarTableSearchQuery: "erpncs",
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("an empty (cleared) query never issues a remote fuzzy search", async () => {
|
||||||
|
const allTables = buildTables();
|
||||||
|
const listTables = listTablesFor(allTables);
|
||||||
|
const { store, tablesGroup } = await installStore(listTables);
|
||||||
|
|
||||||
|
// Expand without a query: normal paginated load (pageSize + 1 probe).
|
||||||
|
await store.loadObjectGroupChildren(tablesGroup, { force: true });
|
||||||
|
expect(listTables.mock.calls.at(-1)?.[4]).toBe(501);
|
||||||
|
|
||||||
|
// Clear the query and reload: must stay a plain paginated load, never a
|
||||||
|
// fuzzy search with the result budget.
|
||||||
|
store.setSidebarTableSearchQuery(tablesGroup.id, "");
|
||||||
|
await store.loadObjectGroupChildren(tablesGroup, { force: true });
|
||||||
|
expect(listTables.mock.calls.at(-1)?.[3]).toBeUndefined();
|
||||||
|
expect(listTables.mock.calls.at(-1)?.[4]).toBe(501);
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Regression test for t8y2/dbx #6190.
|
||||||
|
//
|
||||||
|
// The sidebar label matcher itself matches "erpncs"/"terpncs" against
|
||||||
|
// "T_Erp_Nc_SuPlan_List" (via the separator-blind tiers). The reported bug is
|
||||||
|
// not a matcher defect: it is that local-mode first searches only had the
|
||||||
|
// currently loaded first page of children available. This test locks in both
|
||||||
|
// halves of the behavior so the fix cannot regress.
|
||||||
|
import { test } from "vitest";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { matchSidebarLabel } from "../../apps/desktop/src/lib/sidebar/sidebarSearch.ts";
|
||||||
|
|
||||||
|
const TARGET = "T_Erp_Nc_SuPlan_List";
|
||||||
|
|
||||||
|
function erpTables(): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
for (let i = 0; i < 700; i++) names.push(`A_Erp_Nc_Sys_${i}`);
|
||||||
|
for (let i = 0; i < 100; i++) names.push(`T_Erp_Nc_Su_Table_${i}`);
|
||||||
|
for (let i = 0; i < 300; i++) names.push(`T_Bas_Customer_${i}`);
|
||||||
|
for (let i = 0; i < 300; i++) names.push(`T_Fin_Account_${i}`);
|
||||||
|
names.push(TARGET);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterNames(names: string[], query: string): string[] {
|
||||||
|
return names.filter((name) => !!matchSidebarLabel(name, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("matcher hits the target for erpncs / terpncs and case variants", () => {
|
||||||
|
for (const query of ["erpncs", "terpncs", "ERPnCS", "ERPNCS"]) {
|
||||||
|
assert.ok(matchSidebarLabel(TARGET, query), `expected ${query} to match ${TARGET}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("first page of a large schema does not contain the target (pre-fix fallback failed)", () => {
|
||||||
|
const all = erpTables();
|
||||||
|
const sorted = [...all].sort((a, b) => a.localeCompare(b));
|
||||||
|
const firstPage = sorted.slice(0, 501);
|
||||||
|
assert.equal(firstPage.includes(TARGET), false, "target must sort beyond the first page");
|
||||||
|
// The pre-fix first search only had these children: even a correct matcher
|
||||||
|
// cannot surface a table that is not in the candidate set.
|
||||||
|
assert.equal(filterNames(firstPage, "erpncs").includes(TARGET), false);
|
||||||
|
assert.equal(filterNames(firstPage, "terpncs").includes(TARGET), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("complete index search finds the target for erpncs / terpncs on first search", () => {
|
||||||
|
const all = erpTables();
|
||||||
|
assert.ok(filterNames(all, "erpncs").includes(TARGET));
|
||||||
|
assert.ok(filterNames(all, "terpncs").includes(TARGET));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("existing fuzzy behavior is unchanged (per-word subsequence, not cross-word)", () => {
|
||||||
|
// Per-word subsequence: "odr" inside "orders" matches, but a sequence that
|
||||||
|
// would cross a word separator does not (deliberate anti-overmatch design,
|
||||||
|
// see sidebarSearch.test.ts "resets at _"). The #6190 fix does not alter the
|
||||||
|
// matcher, so these tiers must stay untouched.
|
||||||
|
assert.equal(matchSidebarLabel("orders", "odr")?.kind, "fuzzy");
|
||||||
|
assert.equal(matchSidebarLabel("system_user", "sysu"), null);
|
||||||
|
// Separator-blind tiers still work for the target table itself.
|
||||||
|
assert.equal(matchSidebarLabel("T_Erp_Nc_SuPlan_List", "erpncs")?.kind, "substring");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single-character query does not enable loose fuzzy on the target", () => {
|
||||||
|
// "u" only matches via plain substring containment.
|
||||||
|
assert.equal(matchSidebarLabel("T_Erp_Nc_SuPlan_List", "u")?.kind, "substring");
|
||||||
|
assert.equal(matchSidebarLabel("orders", "u"), null);
|
||||||
|
});
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
// Unit tests for the local table search index loading helper introduced for
|
||||||
|
// t8y2/dbx #6190.
|
||||||
|
//
|
||||||
|
// Pre-fix behavior: local-mode first search only read the persisted index
|
||||||
|
// (loadSidebarTableSearchIndex). When it had never been built (null), the UI
|
||||||
|
// fell back to the currently loaded first page of children, silently missing
|
||||||
|
// alphabetically-late tables (T_Erp_Nc_SuPlan_List for "erpncs").
|
||||||
|
//
|
||||||
|
// Post-fix behavior: a missing index is built on first use so the complete
|
||||||
|
// table set is searchable immediately — exactly once per scope even under
|
||||||
|
// rapid consecutive input (in-flight build deduplication + input debounce),
|
||||||
|
// and never for an empty (cleared) query.
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createSidebarTableSearchDebouncer, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "../../apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts";
|
||||||
|
import type { TableInfo } from "../../apps/desktop/src/types/database.ts";
|
||||||
|
|
||||||
|
const TARGET: TableInfo = { name: "T_Erp_Nc_SuPlan_List", table_type: "TABLE", comment: null };
|
||||||
|
|
||||||
|
test("builds the index when the persisted index is missing (first search)", async () => {
|
||||||
|
const read = async () => null;
|
||||||
|
const build = vi.fn(async () => [TARGET]);
|
||||||
|
const result = await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build);
|
||||||
|
assert.deepEqual(result, [TARGET]);
|
||||||
|
expect(build).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reuses the persisted index without rebuilding", async () => {
|
||||||
|
const read = async () => [TARGET];
|
||||||
|
const build = vi.fn(async () => []);
|
||||||
|
const result = await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build);
|
||||||
|
assert.deepEqual(result, [TARGET]);
|
||||||
|
expect(build).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty persisted index (no tables) is not treated as missing", async () => {
|
||||||
|
const read = async () => [];
|
||||||
|
const build = vi.fn(async () => [TARGET]);
|
||||||
|
const result = await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build);
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
expect(build).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit refresh always rebuilds", async () => {
|
||||||
|
const read = vi.fn(async () => [TARGET]);
|
||||||
|
const build = vi.fn(async () => [{ name: "fresh", table_type: "TABLE", comment: null }]);
|
||||||
|
const result = await loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true);
|
||||||
|
assert.deepEqual(result, [{ name: "fresh", table_type: "TABLE", comment: null }]);
|
||||||
|
expect(build).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty query never reads or builds (clearing the input is a no-op)", async () => {
|
||||||
|
const read = vi.fn(async () => null);
|
||||||
|
const build = vi.fn(async () => [TARGET]);
|
||||||
|
const result = await loadOrBuildSidebarTableSearchIndex("scope", " ", read, build);
|
||||||
|
assert.equal(result, null);
|
||||||
|
expect(read).not.toHaveBeenCalled();
|
||||||
|
expect(build).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent first-use searches share a single in-flight build", async () => {
|
||||||
|
const read = async () => null;
|
||||||
|
const build = vi.fn(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
return [TARGET];
|
||||||
|
});
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build),
|
||||||
|
loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(first, [TARGET]);
|
||||||
|
assert.deepEqual(second, [TARGET]);
|
||||||
|
expect(build).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parallel builds for different scopes run independently", async () => {
|
||||||
|
const read = async () => null;
|
||||||
|
const buildA = vi.fn(async () => [TARGET]);
|
||||||
|
const buildB = vi.fn(async () => [{ name: "other", table_type: "TABLE", comment: null }]);
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
loadOrBuildSidebarTableSearchIndex("scope-a", "erpncs", read, buildA),
|
||||||
|
loadOrBuildSidebarTableSearchIndex("scope-b", "erpncs", read, buildB),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(a, [TARGET]);
|
||||||
|
assert.deepEqual(b, [{ name: "other", table_type: "TABLE", comment: null }]);
|
||||||
|
expect(buildA).toHaveBeenCalledTimes(1);
|
||||||
|
expect(buildB).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a settled build is released so a later search can rebuild", async () => {
|
||||||
|
const read = async () => null;
|
||||||
|
const build = vi.fn(async () => [TARGET]);
|
||||||
|
await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build);
|
||||||
|
await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build);
|
||||||
|
expect(build).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent refreshes for the same scope share a single in-flight build", async () => {
|
||||||
|
const read = async () => null;
|
||||||
|
const build = vi.fn(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
return [TARGET];
|
||||||
|
});
|
||||||
|
const results = await Promise.all([
|
||||||
|
loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true),
|
||||||
|
loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(results, [[TARGET], [TARGET]]);
|
||||||
|
expect(build).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rapid consecutive schedules coalesce into a single run", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const debouncer = createSidebarTableSearchDebouncer(250);
|
||||||
|
const run = vi.fn();
|
||||||
|
debouncer.schedule("parent-1", () => run("parent-1"));
|
||||||
|
debouncer.schedule("parent-1", () => run("parent-1"));
|
||||||
|
debouncer.schedule("parent-1", () => run("parent-1"));
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(249);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
expect(run).toHaveBeenCalledWith("parent-1");
|
||||||
|
expect(debouncer.pendingCount()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different keys debounce independently", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const debouncer = createSidebarTableSearchDebouncer(100);
|
||||||
|
const run = vi.fn();
|
||||||
|
debouncer.schedule("parent-1", () => run("parent-1"));
|
||||||
|
debouncer.schedule("parent-2", () => run("parent-2"));
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
expect(run).toHaveBeenNthCalledWith(1, "parent-1");
|
||||||
|
expect(run).toHaveBeenNthCalledWith(2, "parent-2");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancel drops a pending schedule", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const debouncer = createSidebarTableSearchDebouncer(250);
|
||||||
|
const run = vi.fn();
|
||||||
|
debouncer.schedule("parent-1", () => run());
|
||||||
|
expect(debouncer.pendingCount()).toBe(1);
|
||||||
|
debouncer.cancel("parent-1");
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
expect(debouncer.pendingCount()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelAll drops every pending schedule", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const debouncer = createSidebarTableSearchDebouncer(100);
|
||||||
|
const run = vi.fn();
|
||||||
|
debouncer.schedule("parent-1", () => run());
|
||||||
|
debouncer.schedule("parent-2", () => run());
|
||||||
|
expect(debouncer.pendingCount()).toBe(2);
|
||||||
|
debouncer.cancelAll();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(run).not.toHaveBeenCalled();
|
||||||
|
expect(debouncer.pendingCount()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching search modes cancels the pending debounce from the other mode", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const local = createSidebarTableSearchDebouncer(250);
|
||||||
|
const remote = createSidebarTableSearchDebouncer(250);
|
||||||
|
const localRun = vi.fn();
|
||||||
|
const remoteRun = vi.fn();
|
||||||
|
|
||||||
|
scheduleExclusiveSidebarTableSearchDebounce("parent-1", local, remote, localRun);
|
||||||
|
scheduleExclusiveSidebarTableSearchDebounce("parent-1", remote, local, remoteRun);
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
|
||||||
|
expect(localRun).not.toHaveBeenCalled();
|
||||||
|
expect(remoteRun).toHaveBeenCalledTimes(1);
|
||||||
|
expect(local.pendingCount()).toBe(0);
|
||||||
|
expect(remote.pendingCount()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user