feat(sidebar): add regular expression search

This commit is contained in:
Lan-zk
2026-08-14 02:25:24 +08:00
committed by GitHub
parent 5df8990802
commit 98f5c52f8e
26 changed files with 2203 additions and 54 deletions
@@ -8,8 +8,19 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { ObjectSourceKind, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
import { createSidebarSearchSubtreePreserver, filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree";
import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
import {
createSidebarSearchSubtreePreserver,
filterSidebarSearchRootsByConnectionState,
filterSidebarTree,
filterSidebarTreeToConnectedConnections,
mergeSidebarRegexIndexScopes,
resolveSidebarFilterGuards,
resolveSidebarObjectSearchFilter,
reuseLiveSidebarTreeNodes,
type SidebarRegexIndexScope,
} from "@/lib/sidebar/sidebarSearchTree";
import { createSidebarLabelMatcher, matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex";
import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
@@ -54,6 +65,7 @@ import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sideba
import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
import { compileSearchRegex } from "@/lib/common/searchPattern";
const { t } = useI18n();
const store = useConnectionStore();
@@ -63,6 +75,7 @@ const settingsStore = useSettingsStore();
const { toast } = useToast();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const regexMode = ref(false);
const showConnectedConnectionsOnly = ref(false);
const isDisconnectingAllActiveConnections = ref(false);
const searchInputRef = ref<HTMLInputElement>();
@@ -148,7 +161,7 @@ type TableSearchFocusRestore = {
watch(
searchQuery,
(value) => {
const normalized = value.trim().toLowerCase();
const normalized = regexMode.value ? value.trim() : value.trim().toLowerCase();
window.clearTimeout(searchTimer);
if (!normalized) {
@@ -163,6 +176,14 @@ watch(
{ flush: "sync" },
);
watch(regexMode, (enabled) => {
window.clearTimeout(searchTimer);
searchTimer = undefined;
// Re-evaluate immediately so a pending ordinary-search debounce cannot
// overwrite a case-sensitive regular expression after the mode changes.
deferredSearchQuery.value = enabled ? searchQuery.value.trim() : searchQuery.value.trim().toLowerCase();
});
watch(
[showConnectedConnectionsOnly, () => store.connectedIds.size],
([showConnectedOnly, activeConnectionCount]) => {
@@ -222,10 +243,20 @@ watch(
},
);
watch(deferredSearchQuery, (newQuery, oldQuery) => {
store.sidebarSearchQuery = newQuery;
if (settingsStore.editorSettings.sidebarGlobalSearchLocal) {
if (!newQuery && oldQuery) searchRefreshedNodeIds.clear();
watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, wasRegexMode]) => {
// The regex source is a client-side projection; the remote tree-loading
// search state must never carry it, or explicit node expansion would leak
// the expression as a remote searchFilter.
store.sidebarSearchQuery = resolveSidebarRemoteSearchQuery(isRegexMode, newQuery);
const dispatchMode = resolveSidebarSearchDispatchMode({ query: newQuery, regexMode: isRegexMode, wasRegexMode }, { localSearchEnabled: settingsStore.editorSettings.sidebarGlobalSearchLocal });
if (dispatchMode === "regex") {
// Regex search is a read-only projection over live nodes and the local
// table index. It must never trigger ensureConnected/listTables.
void loadRegexTableSearchIndexes();
return;
}
if (dispatchMode === "none") {
if (!wasRegexMode && !newQuery && oldQuery) searchRefreshedNodeIds.clear();
return;
}
const tasks: Promise<void>[] = [];
@@ -475,6 +506,7 @@ function restoreTableSearchInput(focusRestore: TableSearchFocusRestore) {
const displayedTreeNodes = computed(() => sortConnectionListForDisplay(store.treeNodes, settingsStore.editorSettings.sidebarConnectionSortMode));
const localTableSearchResults = ref<Record<string, TableInfo[] | null>>({});
const regexTableSearchScopes = shallowRef<SidebarRegexIndexScope[]>([]);
const localTableSearchParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
const localTableSearchChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
@@ -498,6 +530,28 @@ function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] {
});
}
async function loadRegexTableSearchIndexes() {
if (!regexMode.value || !deferredSearchQuery.value) return;
const loadedScopes = await collectSidebarRegexIndexScopes(
{
loadSidebarTableSearchIndexScopes: () => store.loadSidebarTableSearchIndexScopes(),
loadSidebarTableSearchIndex: (parent) => store.loadSidebarTableSearchIndex(parent.parentNodeId, parent),
},
store.treeNodes,
() => !regexMode.value || deferredSearchQuery.value === "",
);
if (regexMode.value) {
regexTableSearchScopes.value = loadedScopes;
}
}
function filterGloballyIndexedRegexTables(nodes: TreeNode[]): TreeNode[] {
if (!regexMode.value || !deferredSearchQuery.value) return nodes;
const matcher = createSidebarLabelMatcher(deferredSearchQuery.value, { regexMode: true });
const matchingScopes = regexTableSearchScopes.value.map((scope) => ({ ...scope, entries: scope.entries.filter((entry) => !!matcher(entry.name) || !!matcher(entry.comment || "")) })).filter((scope) => scope.entries.length > 0);
return mergeSidebarRegexIndexScopes(nodes, matchingScopes);
}
async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) {
try {
const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId);
@@ -514,10 +568,11 @@ const filteredNodes = computed(() => {
}
nodes = filterLocallySearchedTables(nodes);
nodes = filterGloballyIndexedRegexTables(nodes);
const q = deferredSearchQuery.value;
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value);
if (q) {
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value, { regexMode: regexMode.value });
if (q && !regexMode.value) {
nodes = filterSidebarSearchRootsByConnectionState(nodes, store.connectedIds);
}
@@ -952,8 +1007,17 @@ const pasteHandlerRegistry = createSidebarPasteHandlerRegistry();
provide(sidebarTreeContextKey, {
getVisibleNodes: () => selectableVisibleNodes.value,
getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1,
// Cover both sides of the input debounce: the immediate query prevents a
// collapse while a projection is about to start, and the deferred query
// keeps the currently rendered projection alive while clearing settles.
isSearchProjectionActive: () => isTreeSearchFiltering.value || !!deferredSearchQuery.value,
getTreeLoadSearchOptions: (node) => {
const query = deferredSearchQuery.value;
if (regexMode.value) {
// Explicit expansion stays allowed and may connect, but must never send
// the regex expression as a remote search filter.
return { searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "" };
}
if (!query) return undefined;
const searchFilter = resolveSidebarObjectSearchFilter(store.treeNodes, node.id, query, searchableNodeTypes.value);
return searchFilter ? undefined : { searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: query };
@@ -1910,7 +1974,9 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="w-full h-6 pl-7 pr-6 text-xs rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
class="w-full h-6 pl-7 pr-6 text-xs rounded border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
:class="regexMode && compileSearchRegex(searchQuery).invalid ? 'border-destructive focus:ring-destructive' : 'border-border'"
:aria-invalid="regexMode && compileSearchRegex(searchQuery).invalid ? 'true' : 'false'"
:placeholder="t('grid.search')"
@keydown="onSearchKeydown"
/>
@@ -1919,7 +1985,19 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
</button>
</div>
<LightTooltip :text="t('sidebar.globalLocalSearchTooltip')" side="top" :delay="300">
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarGlobalSearchLocal" :aria-label="t('sidebar.globalLocalSearch')" @update:model-value="settingsStore.updateEditorSettings({ sidebarGlobalSearchLocal: Boolean($event) })" />
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarGlobalSearchLocal" :disabled="regexMode" :aria-label="t('sidebar.globalLocalSearch')" @update:model-value="settingsStore.updateEditorSettings({ sidebarGlobalSearchLocal: Boolean($event) })" />
</LightTooltip>
<LightTooltip :text="t('sidebar.regexSearchTooltip')" side="top" :delay="300">
<button
type="button"
class="shrink-0 h-6 min-w-6 px-1 flex items-center justify-center rounded border border-border text-[10px] font-mono hover:bg-accent"
:class="{ 'text-primary bg-primary/10 border-primary/30': regexMode, 'text-destructive border-destructive/60': regexMode && compileSearchRegex(searchQuery).invalid }"
:aria-label="t('sidebar.regexSearch')"
:aria-pressed="regexMode"
@click="regexMode = !regexMode"
>
.*
</button>
</LightTooltip>
<LightTooltip :text="t('sidebar.locateActiveTab')" side="top" :delay="300" nowrap>
<button type="button" class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground" :aria-label="t('sidebar.locateActiveTab')" @click="locateActiveTabInSidebar">
@@ -503,6 +503,10 @@ function routeTreeItemDialogController() {
const sidebarTreeContext = inject(sidebarTreeContextKey, null);
function shouldReleaseCollapsedTreeNodeChildren(): boolean {
return !connectionStore.sidebarSearchQuery && !sidebarTreeContext?.isSearchProjectionActive?.();
}
function currentDatabaseType(): DatabaseType | undefined {
return activeNode.value.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(activeNode.value.connectionId)) : undefined;
}
@@ -584,7 +588,7 @@ async function toggle() {
if (node.isLoading) {
if (node.isExpanded) {
node.isExpanded = false;
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
connectionStore.cancelTreeNodeLoad(node.id);
emitNodeToggled(node, true, false);
} else {
@@ -621,7 +625,7 @@ async function toggle() {
const databaseObjectGroup = !!objectTypesForGroupNode(node.type);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
return;
}
@@ -640,20 +644,20 @@ async function toggle() {
if (node.type === "group-extensions" && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
return;
}
if (isXuguTypeMemberContainer(node, connectionStore.getConfig(node.connectionId || "")?.db_type)) {
await connectionStore.loadXuguTypeMembers(node);
await connectionStore.loadXuguTypeMembers(node, { preserveCollapsedChildren: !shouldReleaseCollapsedTreeNodeChildren() });
emitNodeToggled(node, wasExpanded);
return;
}
if (node.isExpanded) {
node.isExpanded = false;
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded, false);
return;
}
@@ -122,6 +122,94 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(toggled).toHaveBeenCalledWith(packageNode, true);
});
it("expands an unloaded object group without leaking the regex source as a search filter", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: false,
children: [],
};
// Regex mode keeps the store's remote-search state empty (ConnectionTree
// writes resolveSidebarRemoteSearchQuery(...) into sidebarSearchQuery).
connectionStore.sidebarSearchQuery = "";
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(false);
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup() {
provide(sidebarTreeContextKey, {
getVisibleNodes: () => [group],
getVisibleNodeIndex: () => 0,
// ConnectionTree returns this explicit empty filter while regex
// mode is on: expansion may connect, but never with the regex.
getTreeLoadSearchOptions: () => ({ searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "" }),
});
return () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 });
},
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(group);
await vi.waitFor(() =>
expect(connectionStore.loadObjectGroupChildren).toHaveBeenCalledWith(group, {
searchFilter: "",
allowGlobalSearchMismatch: true,
expectedSidebarSearchQuery: "",
}),
);
// The regex source never reaches the store options or the connection layer.
expect(JSON.stringify(connectionStore.loadObjectGroupChildren.mock.calls)).not.toContain("A|b");
});
it("preserves loaded group children while a local regex projection is active", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: true,
children: [{ id: "mysql:basic:__tables:orders", label: "orders", type: "table", connectionId: "mysql", database: "basic" }],
};
connectionStore.sidebarSearchQuery = "";
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true);
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup() {
provide(sidebarTreeContextKey, {
getVisibleNodes: () => [group],
getVisibleNodeIndex: () => 0,
isSearchProjectionActive: () => true,
});
return () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 });
},
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(group);
await nextTick();
expect(group.isExpanded).toBe(false);
expect(connectionStore.releaseCollapsedTreeNodeChildren).not.toHaveBeenCalled();
});
it("expands an unloaded object group without leaking a matching connection name", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
+2
View File
@@ -336,6 +336,8 @@ export default {
refreshLocalTableSearchIndex: "Refresh local table index",
globalLocalSearch: "Local search",
globalLocalSearchTooltip: "Search only currently loaded sidebar objects without remote requests.",
regexSearch: "Regex search",
regexSearchTooltip: "Match sidebar names, comments, and aliases with JavaScript regular expressions.",
clearFilter: "Clear filter",
locateActiveTab: "Locate in sidebar",
},
+2
View File
@@ -337,6 +337,8 @@ export default withEnglishFallback({
refreshLocalTableSearchIndex: "Actualizar índice local de tablas",
globalLocalSearch: "Búsqueda local",
globalLocalSearchTooltip: "Busca solo los objetos de la barra lateral cargados actualmente sin solicitudes remotas.",
regexSearch: "Búsqueda con regex",
regexSearchTooltip: "Coincide con nombres, comentarios y alias mediante expresiones regulares de JavaScript.",
clearFilter: "Limpiar filtro",
locateActiveTab: "Localizar en la barra lateral",
},
+2
View File
@@ -336,6 +336,8 @@ export default withEnglishFallback({
refreshLocalTableSearchIndex: "Aggiorna indice locale delle tabelle",
globalLocalSearch: "Ricerca locale",
globalLocalSearchTooltip: "Cerca solo gli oggetti della barra laterale attualmente caricati senza richieste remote.",
regexSearch: "Ricerca regex",
regexSearchTooltip: "Trova nomi, commenti e alias con espressioni regolari JavaScript.",
clearFilter: "Cancella filtro",
locateActiveTab: "Trova nella barra laterale",
},
+2
View File
@@ -337,6 +337,8 @@ export default withEnglishFallback({
refreshLocalTableSearchIndex: "ローカルテーブルインデックスを更新",
globalLocalSearch: "ローカル検索",
globalLocalSearchTooltip: "リモート要求を行わず、現在読み込まれているサイドバーオブジェクトのみを検索します。",
regexSearch: "正規表現検索",
regexSearchTooltip: "JavaScript 正規表現でサイドバーの名前、コメント、エイリアスを照合します。",
clearFilter: "フィルターをクリア",
locateActiveTab: "サイドバーで表示",
},
+2
View File
@@ -331,6 +331,8 @@ export default withEnglishFallback({
searchScopeView: "뷰",
searchTablesInCurrentScope: "이 데이터베이스에서 테이블 검색...",
clearTableSearch: "테이블 검색 지우기",
regexSearch: "정규식 검색",
regexSearchTooltip: "JavaScript 정규식으로 사이드바 이름, 주석, 별칭을 검색합니다.",
clearFilter: "필터 지우기",
locateActiveTab: "사이드바에서 찾기",
},
+2
View File
@@ -337,6 +337,8 @@ export default withEnglishFallback({
refreshLocalTableSearchIndex: "Atualizar índice local de tabelas",
globalLocalSearch: "Busca local",
globalLocalSearchTooltip: "Pesquisa apenas os objetos da barra lateral carregados no momento, sem solicitações remotas.",
regexSearch: "Busca por regex",
regexSearchTooltip: "Correspondência de nomes, comentários e aliases com expressões regulares JavaScript.",
clearFilter: "Limpar filtro",
locateActiveTab: "Localizar na barra lateral",
},
+2
View File
@@ -261,6 +261,8 @@ export default withEnglishFallback({
refreshLocalTableSearchIndex: "刷新本地表索引",
globalLocalSearch: "本地搜索",
globalLocalSearchTooltip: "开启后仅搜索当前已加载的侧边栏对象,不发送远程请求。",
regexSearch: "正则搜索",
regexSearchTooltip: "使用 JavaScript 正则表达式匹配侧边栏名称、备注和别名。",
clearFilter: "清除筛选",
locateActiveTab: "在侧边栏中定位",
},
+2
View File
@@ -337,6 +337,8 @@ export default withEnglishFallback({
refreshLocalTableSearchIndex: "重新整理本機資料表索引",
globalLocalSearch: "本機搜尋",
globalLocalSearchTooltip: "只搜尋目前已載入的側邊欄物件,不會發出遠端請求。",
regexSearch: "正規表示式搜尋",
regexSearchTooltip: "使用 JavaScript 正規表示式比對側邊欄名稱、備註與別名。",
clearFilter: "清除篩選",
locateActiveTab: "在側邊欄中定位",
},
+39 -1
View File
@@ -1,3 +1,11 @@
/**
* Parse the conventional `/pattern/flags` spelling used by search inputs.
*
* This legacy parser intentionally preserves ordinary-search behavior: it
* accepts the established flag set and adds `i` when the user omits it.
* Explicit Regex mode uses compileSearchRegex below for runtime-native flags
* and exact explicit-flag semantics.
*/
export function parseSlashDelimitedRegexQuery(query: string): RegExp | null {
if (!query.startsWith("/") || query.length < 2) return null;
const lastSlash = query.lastIndexOf("/");
@@ -6,10 +14,40 @@ export function parseSlashDelimitedRegexQuery(query: string): RegExp | null {
const source = query.slice(1, lastSlash);
const flags = query.slice(lastSlash + 1);
if (!/^[dgimsuvy]*$/.test(flags)) return null;
try {
return new RegExp(source, flags.includes("i") ? flags : `${flags}i`);
} catch {
return null;
}
}
export interface CompiledSearchRegex {
regex: RegExp | null;
invalid: boolean;
}
/** Compile a search value when the caller has explicitly enabled Regex mode. */
export function compileSearchRegex(query: string): CompiledSearchRegex {
const value = query.trim();
if (!value) return { regex: null, invalid: false };
// Keep `/pattern/flags` compatible with the existing search syntax. A
// slash at the beginning without a closing slash is an ordinary pattern in
// explicit Regex mode, and therefore goes through the regular constructor.
if (value.startsWith("/")) {
const lastSlash = value.lastIndexOf("/");
if (lastSlash > 0) {
try {
return { regex: new RegExp(value.slice(1, lastSlash), value.slice(lastSlash + 1) || "i"), invalid: false };
} catch {
return { regex: null, invalid: true };
}
}
}
try {
return { regex: new RegExp(value, "i"), invalid: false };
} catch {
return { regex: null, invalid: true };
}
}
@@ -3,6 +3,7 @@ export const SCHEMA_TREE_CACHE_TTL_MS = 15 * 60 * 1000;
export interface TableSearchIndexEntry {
name: string;
tableType: string;
comment?: string | null;
}
export interface TableSearchIndex {
@@ -11,6 +12,47 @@ export interface TableSearchIndex {
entries: TableSearchIndexEntry[];
}
/** Stable scope metadata used to discover complete local indexes without
* walking or expanding the live connection tree. */
export interface TableSearchIndexManifestEntry {
cacheKey: string;
parentNodeId: string;
connectionId: string;
database: string;
schema?: string;
catalog?: string;
nodeType: string;
path?: Array<Pick<import("@/types/database").TreeNode, "id" | "label" | "type" | "connectionId" | "database" | "catalog" | "schema" | "linkedServer" | "linkedCatalog" | "linkedSchema">>;
}
export interface TableSearchIndexManifest {
version: 1;
scopes: TableSearchIndexManifestEntry[];
}
export function encodeTableSearchIndexManifest(scopes: readonly TableSearchIndexManifestEntry[]): TableSearchIndexManifest {
return { version: 1, scopes: [...scopes] };
}
export function decodeTableSearchIndexManifest(payload: unknown): TableSearchIndexManifestEntry[] {
if (!payload || typeof payload !== "object") return [];
const value = payload as Partial<TableSearchIndexManifest>;
if (value.version !== 1 || !Array.isArray(value.scopes)) return [];
return value.scopes.filter(
(scope): scope is TableSearchIndexManifestEntry =>
!!scope &&
typeof scope === "object" &&
typeof scope.cacheKey === "string" &&
typeof scope.parentNodeId === "string" &&
typeof scope.connectionId === "string" &&
typeof scope.database === "string" &&
typeof scope.nodeType === "string" &&
(scope.path === undefined || (Array.isArray(scope.path) && scope.path.every((node) => !!node && typeof node.id === "string" && typeof node.label === "string" && typeof node.type === "string"))) &&
(scope.schema === undefined || typeof scope.schema === "string") &&
(scope.catalog === undefined || typeof scope.catalog === "string"),
);
}
export interface SchemaTreeCacheEnvelope<T> {
version: 3;
cachedAt: string;
@@ -37,7 +79,7 @@ function decodeTableSearchIndex(value: unknown): TableSearchIndex | undefined {
if (!value || typeof value !== "object") return undefined;
const index = value as Partial<TableSearchIndex>;
if (index.complete !== true || typeof index.indexedAt !== "string" || !Array.isArray(index.entries)) return undefined;
const entries = index.entries.filter((entry): entry is TableSearchIndexEntry => !!entry && typeof entry.name === "string" && typeof entry.tableType === "string");
const entries = index.entries.filter((entry): entry is TableSearchIndexEntry => !!entry && typeof entry.name === "string" && typeof entry.tableType === "string" && (entry.comment === undefined || entry.comment === null || typeof entry.comment === "string"));
return entries.length === index.entries.length ? { complete: true, indexedAt: index.indexedAt, entries } : undefined;
}
@@ -0,0 +1,108 @@
import type { TableInfo, TreeNode, TreeNodeType } from "@/types/database";
import type { SidebarRegexIndexScope, SidebarRegexScopeIdentity } from "@/lib/sidebar/sidebarSearchTree";
export const regexLocalTableParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
export function regexTableSearchParents(nodes: readonly TreeNode[], result: TreeNode[] = []): TreeNode[] {
for (const node of nodes) {
if (regexLocalTableParentTypes.has(node.type)) result.push(node);
if (node.children) regexTableSearchParents(node.children, result);
}
return result;
}
/** Scope metadata plus its complete index entries. */
export interface SidebarRegexIndexScopeResult {
scope: Omit<SidebarRegexIndexScope, "entries">;
entries: TableInfo[];
}
/**
* Read-only local index access used by the regex search dispatch.
*
* The parent is addressed by its composite identity, never by the bare id:
* TreeNode ids are not unique across the whole tree, and the store must find
* the right cache key even when a same-id node exists in another branch.
*/
export interface SidebarRegexIndexReader {
loadSidebarTableSearchIndexScopes(): Promise<SidebarRegexIndexScopeResult[]>;
loadSidebarTableSearchIndex(parent: SidebarRegexScopeIdentity & { parentNodeId: string }): Promise<TableInfo[] | null>;
}
function sidebarRegexIndexScopeKey(scope: SidebarRegexScopeIdentity & { parentNodeId: string }): string {
return JSON.stringify([scope.parentNodeId, scope.connectionId, scope.database, scope.schema ?? null, scope.catalog ?? null, scope.nodeType]);
}
/**
* The search state exposed to remote tree loading while regex mode is active.
*
* The regex source is a client-side projection and must never leak into the
* store's remote-search query or a remote searchFilter; explicit user
* expansion may still connect and load, but only without the regex filter.
*/
export function resolveSidebarRemoteSearchQuery(regexMode: boolean, query: string): string {
return regexMode ? "" : query;
}
/**
* Assemble the complete local table indexes a regex search can consult.
*
* Only the two read methods above are ever invoked: this path cannot connect
* data sources or refresh remote metadata. Manifest scopes keep their recorded
* ancestor path; indexes that predate the manifest are discovered through the
* live tree and returned without a path so the merge layer anchors them to the
* live parent node. Reads are sequential so a large workspace never fans out
* one storage request per database for every keypress.
*/
export async function collectSidebarRegexIndexScopes(reader: SidebarRegexIndexReader, liveNodes: readonly TreeNode[], shouldCancel: () => boolean): Promise<SidebarRegexIndexScope[]> {
const loadedScopeKeys = new Set<string>();
const manifestScopes = await reader.loadSidebarTableSearchIndexScopes();
const loadedScopes: SidebarRegexIndexScope[] = [];
for (const { scope, entries } of manifestScopes) {
if (shouldCancel()) return loadedScopes;
loadedScopeKeys.add(sidebarRegexIndexScopeKey(scope));
loadedScopes.push({ ...scope, entries });
}
for (const parent of regexTableSearchParents(liveNodes)) {
if (shouldCancel()) return loadedScopes;
const parentScope = {
parentNodeId: parent.id,
connectionId: parent.connectionId || "",
database: parent.database || "",
schema: parent.schema,
catalog: parent.catalog,
nodeType: parent.type,
};
const scopeKey = sidebarRegexIndexScopeKey(parentScope);
if (loadedScopeKeys.has(scopeKey)) continue;
const entries = await reader.loadSidebarTableSearchIndex(parentScope);
loadedScopeKeys.add(scopeKey);
if (entries) {
loadedScopes.push({ ...parentScope, path: undefined, entries });
}
}
return loadedScopes;
}
export type SidebarSearchDispatchMode = "regex" | "none" | "ordinary";
export interface SidebarSearchTransition {
query: string;
regexMode: boolean;
wasRegexMode: boolean;
}
/**
* Pick the sidebar global-search dispatch branch for a query/mode transition.
*
* Regex mode always short-circuits to the local index projection regardless of
* the local-search setting. Ordinary remote refresh runs whenever regex is off,
* a non-empty query remains, and local search is disabled, including an
* explicit transition out of regex mode.
*/
export function resolveSidebarSearchDispatchMode(transition: SidebarSearchTransition, options: { localSearchEnabled: boolean }): SidebarSearchDispatchMode {
if (transition.regexMode) return "regex";
if (options.localSearchEnabled) return "none";
if (transition.wasRegexMode && !transition.query) return "none";
return "ordinary";
}
+36 -8
View File
@@ -1,4 +1,4 @@
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
import { compileSearchRegex, parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
export type SidebarSearchMatchKind = "exact" | "prefix" | "word-prefix" | "substring" | "abbreviation" | "fuzzy" | "regex";
@@ -130,10 +130,22 @@ function matchesWordPrefixConcatenation(label: string, query: string): boolean {
return dp[queryLength] !== INF && dp[queryLength] >= 2;
}
function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp | null): SidebarSearchMatch | null {
function testRegex(regex: RegExp, value: string): boolean {
// Global and sticky expressions retain lastIndex between calls. Search
// candidates are independent values, so every candidate must start from a
// deterministic index (and leave no state for the next tree row).
regex.lastIndex = 0;
const matched = regex.test(value);
regex.lastIndex = 0;
return matched;
}
function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp | null, invalidRegex = false): SidebarSearchMatch | null {
if (!query) return null;
if (regex) return regex.test(label) ? { kind: "regex", score: 95 } : null;
if (invalidRegex) return null;
if (regex) return testRegex(regex, label) ? { kind: "regex", score: 95 } : null;
// Case-insensitive comparison is centralized here so callers can pass the
// original label and query. The original label is what the boundary-aware
@@ -169,11 +181,27 @@ function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp
return null;
}
export function createSidebarLabelMatcher(query: string): SidebarLabelMatcher {
const regex = parseSlashDelimitedRegexQuery(query);
return (label) => matchSidebarLabelWithRegex(label, query, regex);
export interface SidebarSearchMatcherOptions {
regexMode?: boolean;
}
export function matchSidebarLabel(label: string, query: string): SidebarSearchMatch | null {
return matchSidebarLabelWithRegex(label, query, parseSlashDelimitedRegexQuery(query));
function slashDelimitedCandidate(query: string): boolean {
return query.startsWith("/") && query.lastIndexOf("/") > 0;
}
export function createSidebarLabelMatcher(query: string, options: SidebarSearchMatcherOptions = {}): SidebarLabelMatcher {
if (options.regexMode) {
const compiled = compileSearchRegex(query);
return (label) => matchSidebarLabelWithRegex(label, query, compiled.regex, compiled.invalid);
}
const regex = parseSlashDelimitedRegexQuery(query);
// A slash-delimited expression is an explicit regex request even when it is
// malformed. Do not silently reinterpret an invalid pattern as fuzzy text.
const invalidRegex = slashDelimitedCandidate(query) && !regex;
return (label) => matchSidebarLabelWithRegex(label, query, regex, invalidRegex);
}
export function matchSidebarLabel(label: string, query: string, options: SidebarSearchMatcherOptions = {}): SidebarSearchMatch | null {
return createSidebarLabelMatcher(query, options)(label);
}
@@ -1,5 +1,6 @@
import type { TreeNode, TreeNodeType } from "@/types/database";
import { createSidebarLabelMatcher, type SidebarLabelMatcher } from "@/lib/sidebar/sidebarSearch";
import type { TableInfo, TreeNode, TreeNodeType } from "@/types/database";
import { createSidebarLabelMatcher, type SidebarLabelMatcher, type SidebarSearchMatcherOptions } from "@/lib/sidebar/sidebarSearch";
import { buildTableTreeNodes } from "@/lib/table/tableTree";
const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]);
const hiddenSearchNodeTypes = new Set<TreeNodeType>(["user-admin", "dameng-job-admin"]);
@@ -39,21 +40,21 @@ function nodePreservesSearchSubtree(node: TreeNode, matchLabel: SidebarLabelMatc
return preserveMatchedSubtreeTypes.has(node.type) && !!bestMatch(matchLabel, normalizedLabel(node), node.comment, node.searchAliases);
}
export function createSidebarSearchSubtreePreserver(query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>): (node: TreeNode) => boolean {
const matchLabel = query ? createSidebarLabelMatcher(query) : undefined;
export function createSidebarSearchSubtreePreserver(query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarSearchMatcherOptions): (node: TreeNode) => boolean {
const matchLabel = query ? createSidebarLabelMatcher(query, options) : undefined;
return (node) => !!matchLabel && nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes);
}
export function resolveSidebarObjectSearchFilter(nodes: readonly TreeNode[], targetNodeId: string, query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>): string {
export function resolveSidebarObjectSearchFilter(nodes: readonly TreeNode[], targetNodeId: string, query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarSearchMatcherOptions): string {
if (!query) return query;
const matchLabel = createSidebarLabelMatcher(query);
const matchLabel = createSidebarLabelMatcher(query, options);
const path = findNodePath(nodes, targetNodeId);
const preservesSubtree = path?.some((node) => nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes));
return preservesSubtree ? "" : query;
}
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>): TreeNode[] {
const matchLabel = query ? createSidebarLabelMatcher(query) : undefined;
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarSearchMatcherOptions): TreeNode[] {
const matchLabel = query ? createSidebarLabelMatcher(query, options) : undefined;
if (!matchLabel && searchableNodeTypes === undefined) return nodes;
return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes);
}
@@ -63,6 +64,236 @@ export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: r
return indexedNodes.map((node) => liveNodesById.get(node.id) ?? node);
}
export interface SidebarRegexIndexScope {
parentNodeId: string;
connectionId: string;
database: string;
schema?: string;
catalog?: string;
nodeType: string;
path?: Array<Pick<TreeNode, "id" | "label" | "type" | "connectionId" | "database" | "catalog" | "schema" | "linkedServer" | "linkedCatalog" | "linkedSchema">>;
entries: TableInfo[];
}
/**
* Composite identity of a regex index parent scope. TreeNode ids are not
* unique across the whole tree (a database named "a:b" and a schema "b" under
* a database "a" can both produce "connectionId:a:b"), so index lookups,
* manifest backfills, and result merging must match on id plus the full
* database context instead of the bare id.
*/
export interface SidebarRegexScopeIdentity {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
nodeType: string;
}
function identityFieldMatches(nodeValue: string | undefined, expected: string | undefined): boolean {
return nodeValue === expected;
}
export function nodeMatchesRegexScopeIdentity(node: TreeNode, id: string, identity: SidebarRegexScopeIdentity): boolean {
if (node.id !== id) return false;
if (node.type !== identity.nodeType) return false;
if (node.connectionId !== identity.connectionId) return false;
// Connection roots deliberately carry no database scope; all actual index
// parents below them must match every database-context field strictly.
if (node.type === "connection") return true;
if (!identityFieldMatches(node.database, identity.database)) return false;
if (!identityFieldMatches(node.schema, identity.schema)) return false;
if (!identityFieldMatches(node.catalog, identity.catalog)) return false;
return true;
}
export function findNodePathByIdentity(nodes: readonly TreeNode[], id: string, identity: SidebarRegexScopeIdentity, ancestors: readonly TreeNode[] = []): readonly TreeNode[] | undefined {
for (const node of nodes) {
const path = [...ancestors, node];
if (node.id === id) {
// Same-id nodes can exist in different branches; only the one matching
// the full database context counts as the target parent.
if (nodeMatchesRegexScopeIdentity(node, id, identity)) return path;
continue;
}
if (node.children) {
const childPath = findNodePathByIdentity(node.children, id, identity, path);
if (childPath) return childPath;
}
}
return undefined;
}
function indexedTableChildren(scope: SidebarRegexIndexScope): TreeNode[] {
// Reuse the regular table-node builder so index hits share live-node id
// rules, normalized object types, name sorting, catalog/schema fields, and
// partition parent/child nesting instead of hand-rolled node literals.
return buildTableTreeNodes({
nodeId: scope.parentNodeId,
connectionId: scope.connectionId,
database: scope.database,
schema: scope.schema,
catalog: scope.catalog,
tables: scope.entries,
});
}
function syntheticRegexParent(scope: SidebarRegexIndexScope): TreeNode {
const snapshot = scope.path?.find((node) => node.id === scope.parentNodeId);
return {
id: scope.parentNodeId,
label: snapshot?.label || (scope.nodeType === "group-tables" ? "tree.tables" : scope.schema || scope.database),
type: (snapshot?.type || scope.nodeType) as TreeNodeType,
connectionId: scope.connectionId,
database: scope.database,
schema: scope.schema,
catalog: scope.catalog,
...(snapshot?.linkedServer ? { linkedServer: snapshot.linkedServer } : {}),
...(snapshot?.linkedCatalog ? { linkedCatalog: snapshot.linkedCatalog } : {}),
...(snapshot?.linkedSchema ? { linkedSchema: snapshot.linkedSchema } : {}),
isExpanded: true,
children: indexedTableChildren(scope),
};
}
function pathNode(pathNode: NonNullable<SidebarRegexIndexScope["path"]>[number]): TreeNode {
return { ...pathNode, isExpanded: true, children: [] };
}
function mergeRegexTreeNode(live: TreeNode | undefined, indexed: TreeNode): TreeNode {
if (!live) return indexed;
const children = mergeRegexTreeNodes(live.children ?? [], indexed.children ?? []);
return children.length === (live.children ?? []).length && children.every((child, index) => child === live.children?.[index]) ? live : { ...live, children };
}
function mergeRegexTreeNodes(liveNodes: readonly TreeNode[], indexedNodes: readonly TreeNode[]): TreeNode[] {
const byId = new Map(liveNodes.map((node) => [node.id, node]));
const merged = [...liveNodes];
for (const indexed of indexedNodes) {
const existing = byId.get(indexed.id);
if (existing) {
const next = mergeRegexTreeNode(existing, indexed);
if (next !== existing) merged[merged.indexOf(existing)] = next;
} else {
merged.push(indexed);
byId.set(indexed.id, indexed);
}
}
return merged;
}
/**
* Add complete local-index results to a display-only tree. The live tree is
* never mutated; existing nodes win identity/state, while missing connection,
* database, schema, and table-group ancestors are synthesized from manifest
* scope metadata. Duplicate table identities prefer the narrowest scope.
*/
export function mergeSidebarRegexIndexScopes(liveNodes: readonly TreeNode[], scopes: readonly SidebarRegexIndexScope[]): TreeNode[] {
const selected = new Map<string, { rank: number; scope: SidebarRegexIndexScope; entry: TableInfo }>();
const rankFor = (scope: SidebarRegexIndexScope) => (scope.nodeType === "group-tables" ? 3 : scope.nodeType === "schema" ? 2 : 1);
for (const scope of scopes) {
for (const entry of scope.entries) {
const key = `${scope.connectionId}\0${scope.catalog || ""}\0${scope.database}\0${scope.schema || ""}\0${entry.table_type}\0${entry.name}`;
const rank = rankFor(scope);
if (!selected.has(key) || selected.get(key)!.rank < rank) selected.set(key, { rank, scope, entry });
}
}
const scopeIdentity = (scope: SidebarRegexIndexScope): SidebarRegexScopeIdentity => ({
connectionId: scope.connectionId,
database: scope.database,
schema: scope.schema,
catalog: scope.catalog,
nodeType: scope.nodeType,
});
const indexedRoots: TreeNode[] = [];
const indexedRootIdentities = new Map<string, SidebarRegexScopeIdentity>();
const anchoredParents: Array<{ node: TreeNode; identity: SidebarRegexScopeIdentity }> = [];
const rootsById = new Map<string, TreeNode>();
const ensureRoot = (id: string, label: string, type: TreeNodeType, scope: SidebarRegexIndexScope): TreeNode => {
const existing = rootsById.get(id);
if (existing) return existing;
const created: TreeNode = { id, label, type, connectionId: scope.connectionId, database: scope.database, catalog: scope.catalog, isExpanded: true, children: [] };
rootsById.set(id, created);
indexedRoots.push(created);
// The root is a connection: its database context does not apply to the
// live connection node itself (it has no database field), so identity
// matching compares id + type + connectionId.
indexedRootIdentities.set(id, { ...scopeIdentity(scope), nodeType: "connection" });
return created;
};
const grouped = new Map<string, SidebarRegexIndexScope>();
for (const { scope, entry } of selected.values()) {
const key = JSON.stringify([scope.parentNodeId, scope.connectionId, scope.database, scope.schema ?? null, scope.catalog ?? null, scope.nodeType]);
const previous = grouped.get(key);
if (previous) previous.entries.push(entry);
else grouped.set(key, { ...scope, entries: [entry] });
}
const liveScopeParentExists = (scope: SidebarRegexIndexScope): boolean => !!findNodePathByIdentity(liveNodes, scope.parentNodeId, scopeIdentity(scope));
for (const scope of grouped.values()) {
const path = scope.path;
if (!path) {
// First-time backfill of an index that predates the manifest: merge the
// entries directly into the live parent wherever the tree places it
// (connection groups, Oracle/Dameng connection->schema, Doris catalogs,
// SQL Server linked servers). Do not guess ancestors from the id.
if (!liveScopeParentExists(scope)) continue;
anchoredParents.push({ node: syntheticRegexParent(scope), identity: scopeIdentity(scope) });
continue;
}
const connectionPath = path.find((node) => node.type === "connection");
if (!connectionPath) continue;
// A removed connection must never come back as a ghost result: manifest
// scopes only render when their recorded connection is still in the tree.
// Same-id lookups are disambiguated by the database context.
if (!findNodePathByIdentity(liveNodes, connectionPath.id, { ...scopeIdentity(scope), nodeType: "connection" })) continue;
const connection = ensureRoot(connectionPath.id, connectionPath.label, "connection", scope);
let parent: TreeNode = connection;
const ancestors = path.slice(path.findIndex((node) => node.type === "connection") + 1);
for (const ancestor of ancestors) {
if (ancestor.id === scope.parentNodeId) break;
const next = (parent.children ?? []).find((child) => child.id === ancestor.id) ?? pathNode(ancestor);
if (!parent.children?.some((child) => child.id === next.id)) parent.children = [...(parent.children ?? []), next];
parent = next;
}
const scopeParent = syntheticRegexParent(scope);
const existing = (parent.children ?? []).find((child) => child.id === scope.parentNodeId);
parent.children = existing ? (parent.children ?? []).map((child) => (child.id === existing.id ? mergeRegexTreeNode(existing, scopeParent) : child)) : [...(parent.children ?? []), scopeParent];
}
let merged = [...liveNodes];
// Same-id nodes can live in different branches (database "a:b" vs schema
// "b" under database "a"), so every merge target is matched by id plus its
// composite database context, never by the bare id.
const mergeRoot = (nodes: readonly TreeNode[], indexed: TreeNode, identity: SidebarRegexScopeIdentity): { nodes: TreeNode[]; found: boolean } => {
const direct = nodes.findIndex((node) => nodeMatchesRegexScopeIdentity(node, indexed.id, identity));
if (direct >= 0) {
return { nodes: nodes.map((node, index) => (index === direct ? mergeRegexTreeNode(node, indexed) : node)), found: true };
}
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
if (!node.children) continue;
const result = mergeRoot(node.children, indexed, identity);
if (!result.found) continue;
return { nodes: nodes.map((candidate, candidateIndex) => (candidateIndex === index ? { ...candidate, children: result.nodes } : candidate)), found: true };
}
return { nodes: [...nodes], found: false };
};
for (const indexed of indexedRoots) {
const identity = indexedRootIdentities.get(indexed.id);
const result = identity ? mergeRoot(merged, indexed, identity) : { nodes: merged, found: false };
merged = result.found ? result.nodes : mergeRegexTreeNodes(merged, [indexed]);
}
for (const { node, identity } of anchoredParents) {
const result = mergeRoot(merged, node, identity);
if (result.found) merged = result.nodes;
}
return merged;
}
function applySearchCollapsedState(node: TreeNode, collapsedIds: ReadonlySet<string>): TreeNode {
const children = node.children?.map((child) => applySearchCollapsedState(child, collapsedIds));
const childrenChanged = children?.some((child, index) => child !== node.children?.[index]) ?? false;
@@ -4,6 +4,7 @@ import type { TreeNode } from "@/types/database";
export interface SidebarTreeContext {
getVisibleNodes: () => TreeNode[];
getVisibleNodeIndex: (id: string) => number;
isSearchProjectionActive?: () => boolean;
getTreeLoadSearchOptions?: (node: TreeNode) => { searchFilter: string; allowGlobalSearchMismatch: true; expectedSidebarSearchQuery: string } | undefined;
setTableSearchQuery?: (parentNodeId: string, query: string, local: boolean) => void;
refreshTableSearchIndex?: (parentNodeId: string) => void;
@@ -313,6 +313,42 @@ describe("connectionStore metadata loading", () => {
).toBe(false);
}, 15000);
it("preserves loaded Xugu type members when a search projection collapses the type", async () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined) }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = xuguConnection();
const typeNode: TreeNode = {
id: "xugu-1:app_db:app_schema:type:address_t",
label: "ADDRESS_T",
type: "type",
objectName: "ADDRESS_T",
connectionId: connection.id,
database: "app_db",
schema: "app_schema",
xuguTypeMembersExpandable: true,
isExpanded: true,
children: Array.from({ length: 400 }, (_, index) => ({
id: `xugu-1:app_db:app_schema:type:address_t:attribute:${index}`,
label: `attribute_${index}`,
type: "type-attribute" as const,
connectionId: connection.id,
database: "app_db",
schema: "app_schema",
})),
};
store.connections = [connection];
store.connectedIds = new Set([connection.id]);
store.treeNodes = [typeNode];
await store.loadXuguTypeMembers(typeNode, { preserveCollapsedChildren: true });
expect(typeNode.isExpanded).toBe(false);
expect(typeNode.children).toHaveLength(400);
});
it("loads Oracle package overloads through the shared package member path", async () => {
const completionAssistantSearch = vi.fn().mockResolvedValue({
candidates: [
@@ -802,9 +838,12 @@ describe("connectionStore metadata loading", () => {
const treeCache = decodeSchemaTreeCache<TreeNode[]>(cachedPayloads.get(treeCacheKey));
const indexCache = decodeSchemaTreeCache<TreeNode[]>(cachedPayloads.get(indexCacheKey));
expect(treeCache?.children.map((node) => node.label)).toEqual(["fresh_table"]);
expect(indexCache?.tableSearchIndex?.entries).toEqual([{ name: "indexed_table", tableType: "TABLE" }]);
await expect(store.loadSidebarTableSearchIndex(tablesGroup.id)).resolves.toEqual([{ name: "indexed_table", table_type: "TABLE" }]);
expect(loadSchemaCache).toHaveBeenLastCalledWith(indexCacheKey);
// The local table index preserves comments for regex search matching.
expect(indexCache?.tableSearchIndex?.entries).toEqual([{ name: "indexed_table", tableType: "TABLE", comment: null }]);
await expect(store.loadSidebarTableSearchIndex(tablesGroup.id)).resolves.toEqual([{ name: "indexed_table", table_type: "TABLE", comment: null }]);
// The refreshed index is served from the in-memory cache; refreshing
// additionally registers the scope by reading the sidebar index manifest.
expect(loadSchemaCache).toHaveBeenLastCalledWith("dbx:sidebar-table-search-index-manifest-v1");
});
it("bypasses Oracle object-group caches created before DIP visibility was fixed", async () => {
@@ -52,6 +52,7 @@ describe("connectionStore MongoDB Legacy fallback", () => {
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
@@ -93,6 +94,7 @@ describe("connectionStore MongoDB Legacy fallback", () => {
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
@@ -45,6 +45,7 @@ function installApiMocks(extra: Record<string, unknown> = {}) {
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
connectDb: vi.fn().mockResolvedValue("pg-1"),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
+191 -13
View File
@@ -68,6 +68,7 @@ import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connec
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree";
import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse";
import { findNodePathByIdentity, nodeMatchesRegexScopeIdentity, type SidebarRegexScopeIdentity } from "@/lib/sidebar/sidebarSearchTree";
import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget";
import { simpleModeEmptyShellNeedsConfirmedLoad, treeNodeLoadedChildrenContentPresent } from "@/lib/sidebar/treeLoadedChildrenMarker";
import { shouldMarkDisconnected } from "@/lib/connection/connectionHealth";
@@ -97,7 +98,7 @@ import {
type DatabaseObjectTreeKind,
} from "@/lib/table/tableTree";
import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNodeSchemaCachePrefix } from "@/lib/sidebar/treeNodeContext";
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache";
import { decodeSchemaTreeCache, decodeTableSearchIndexManifest, encodeSchemaTreeCache, encodeTableSearchIndexManifest, type TableSearchIndexManifestEntry } from "@/lib/metadata/schemaTreeCache";
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
@@ -302,6 +303,7 @@ interface LoadTreeOptions {
expectedSidebarTableSearchQuery?: string;
tableNameFilterScopeKey?: string;
expectedTableNameFilterRevision?: number;
preserveCollapsedChildren?: boolean;
}
interface PersistedTreeChildrenLoadResult {
@@ -381,6 +383,15 @@ export const useConnectionStore = defineStore("connection", () => {
const schemaListCache = ref<Record<string, string[]>>({});
const sidebarSearchQuery = ref("");
const sidebarTableSearchQueries = ref<Record<string, string>>({});
// Local table indexes are immutable snapshots until an explicit refresh.
// Keep both resolved values and in-flight reads here so regex typing does
// not reread SQLite for every keypress or issue duplicate concurrent loads.
const sidebarTableSearchIndexCache = new Map<string, TableInfo[] | null>();
const sidebarTableSearchIndexInFlight = new Map<string, Promise<TableInfo[] | null>>();
const sidebarTableSearchIndexConnectionGenerations = new Map<string, number>();
let sidebarTableSearchIndexManifest: TableSearchIndexManifestEntry[] | null = null;
let sidebarTableSearchIndexManifestInFlight: Promise<TableSearchIndexManifestEntry[]> | null = null;
let sidebarTableSearchIndexManifestWriteQueue: Promise<void> = Promise.resolve();
const sidebarTableNameFilters = ref<Record<string, TableNameFilter>>(loadSidebarTableNameFilters());
const sidebarTableNameFilterRevisions = new Map<string, number>();
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
@@ -2060,25 +2071,178 @@ export const useConnectionStore = defineStore("connection", () => {
function sidebarTableSearchIndexCacheKey(parent: TreeNode): string | null {
const treeCacheKey = sidebarTableSearchTreeCacheKey(parent);
return treeCacheKey ? `${treeCacheKey}:table-search-index-v1` : null;
if (!treeCacheKey) return null;
// Catalog-capable databases may expose the same database name in several
// catalogs. Their metadata tree cache keys predate catalog support, so use
// the complete scope identity for the table index without changing those
// shared metadata-cache formats. Catalogless scopes keep the v1 key so
// indexes created before the regex-search manifest remain discoverable.
if (parent.catalog) return schemaCacheKey(parent.connectionId || "", parent.catalog, parent.database || "", parent.schema || "", parent.type, "table-search-index-v2");
return `${treeCacheKey}:table-search-index-v1`;
}
async function loadSidebarTableSearchIndex(parentNodeId: string): Promise<TableInfo[] | null> {
const parent = findNode(treeNodes.value, parentNodeId);
const sidebarTableSearchIndexManifestCacheKey = "dbx:sidebar-table-search-index-manifest-v1";
function sidebarTableSearchIndexManifestEntry(parent: TreeNode, cacheKey: string): TableSearchIndexManifestEntry | null {
if (!parent.connectionId || !parent.database || !parent.type) return null;
const identity: SidebarRegexScopeIdentity = {
connectionId: parent.connectionId,
database: parent.database,
schema: parent.schema,
catalog: parent.catalog,
nodeType: parent.type,
};
const findPath = (nodes: TreeNode[], targetId: string, path: TreeNode[] = []): TreeNode[] | null => {
for (const node of nodes) {
const next = [...path, node];
if (node.id === targetId) {
// Same-id nodes can exist in other branches; only the node matching
// the full database context is the index parent we registered.
if (nodeMatchesRegexScopeIdentity(node, targetId, identity)) return next;
continue;
}
if (node.children) {
const found = findPath(node.children, targetId, next);
if (found) return found;
}
}
return null;
};
const path = findPath(treeNodes.value, parent.id)?.map((node) => ({
id: node.id,
label: node.label,
type: node.type,
...(node.connectionId ? { connectionId: node.connectionId } : {}),
...(node.database !== undefined ? { database: node.database } : {}),
...(node.catalog !== undefined ? { catalog: node.catalog } : {}),
...(node.schema !== undefined ? { schema: node.schema } : {}),
...(node.linkedServer !== undefined ? { linkedServer: node.linkedServer } : {}),
...(node.linkedCatalog !== undefined ? { linkedCatalog: node.linkedCatalog } : {}),
...(node.linkedSchema !== undefined ? { linkedSchema: node.linkedSchema } : {}),
}));
return {
cacheKey,
parentNodeId: parent.id,
connectionId: parent.connectionId,
database: parent.database,
...(parent.schema ? { schema: parent.schema } : {}),
...(parent.catalog ? { catalog: parent.catalog } : {}),
nodeType: parent.type,
...(path ? { path } : {}),
};
}
async function loadSidebarTableSearchIndexManifest(): Promise<TableSearchIndexManifestEntry[]> {
if (sidebarTableSearchIndexManifest) return sidebarTableSearchIndexManifest;
if (sidebarTableSearchIndexManifestInFlight) return sidebarTableSearchIndexManifestInFlight;
const read = (async () => {
const payload = await api.loadSchemaCache<unknown>(sidebarTableSearchIndexManifestCacheKey).catch(() => null);
sidebarTableSearchIndexManifest = decodeTableSearchIndexManifest(payload);
return sidebarTableSearchIndexManifest;
})();
sidebarTableSearchIndexManifestInFlight = read;
try {
return await read;
} finally {
sidebarTableSearchIndexManifestInFlight = null;
}
}
function sidebarTableSearchIndexConnectionGeneration(connectionId: string): number {
return sidebarTableSearchIndexConnectionGenerations.get(connectionId) ?? 0;
}
async function invalidateSidebarTableSearchIndexesForConnection(connectionId: string): Promise<void> {
sidebarTableSearchIndexConnectionGenerations.set(connectionId, sidebarTableSearchIndexConnectionGeneration(connectionId) + 1);
const rawPrefix = `${connectionId}:`;
const encodedPrefix = `${schemaCacheKey(connectionId)}:`;
const matchesConnectionCacheKey = (cacheKey: string) => cacheKey.startsWith(rawPrefix) || cacheKey.startsWith(encodedPrefix);
for (const cacheKey of sidebarTableSearchIndexCache.keys()) {
if (matchesConnectionCacheKey(cacheKey)) sidebarTableSearchIndexCache.delete(cacheKey);
}
for (const cacheKey of sidebarTableSearchIndexInFlight.keys()) {
if (matchesConnectionCacheKey(cacheKey)) sidebarTableSearchIndexInFlight.delete(cacheKey);
}
sidebarTableSearchIndexManifestWriteQueue = sidebarTableSearchIndexManifestWriteQueue.then(async () => {
const manifest = await loadSidebarTableSearchIndexManifest();
const nextManifest = manifest.filter((scope) => scope.connectionId !== connectionId);
if (nextManifest.length === manifest.length) return;
sidebarTableSearchIndexManifest = nextManifest;
await api.saveSchemaCache(sidebarTableSearchIndexManifestCacheKey, encodeTableSearchIndexManifest(nextManifest)).catch(() => undefined);
});
await sidebarTableSearchIndexManifestWriteQueue;
}
async function registerSidebarTableSearchIndexScope(parent: TreeNode, cacheKey: string): Promise<void> {
const entry = sidebarTableSearchIndexManifestEntry(parent, cacheKey);
if (!entry) return;
sidebarTableSearchIndexManifestWriteQueue = sidebarTableSearchIndexManifestWriteQueue.then(async () => {
const manifest = await loadSidebarTableSearchIndexManifest();
const matchesEntryScope = (item: TableSearchIndexManifestEntry) => item.parentNodeId === entry.parentNodeId && item.connectionId === entry.connectionId && item.database === entry.database && item.schema === entry.schema && item.catalog === entry.catalog && item.nodeType === entry.nodeType;
const existing = manifest.find((item) => matchesEntryScope(item));
if (existing?.cacheKey === entry.cacheKey) return;
// Re-registering a scope after its cache-key format changes replaces the
// old manifest entry, so stale and refreshed indexes are never merged.
sidebarTableSearchIndexManifest = [...manifest.filter((item) => !matchesEntryScope(item)), entry];
await api.saveSchemaCache(sidebarTableSearchIndexManifestCacheKey, encodeTableSearchIndexManifest(sidebarTableSearchIndexManifest)).catch(() => undefined);
});
await sidebarTableSearchIndexManifestWriteQueue;
}
async function readSidebarTableSearchIndexCache(cacheKey: string, connectionId: string): Promise<TableInfo[] | null> {
if (sidebarTableSearchIndexCache.has(cacheKey)) return sidebarTableSearchIndexCache.get(cacheKey) ?? null;
const pending = sidebarTableSearchIndexInFlight.get(cacheKey);
if (pending) return pending;
const generation = sidebarTableSearchIndexConnectionGeneration(connectionId);
const read = (async () => {
const decoded = decodeSchemaTreeCache<TreeNode[]>(await api.loadSchemaCache<unknown>(cacheKey).catch(() => null));
const index = decoded?.tableSearchIndex;
const entries = index ? index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })) : null;
if (generation !== sidebarTableSearchIndexConnectionGeneration(connectionId)) return null;
sidebarTableSearchIndexCache.set(cacheKey, entries);
return entries;
})();
sidebarTableSearchIndexInFlight.set(cacheKey, read);
try {
return await read;
} finally {
if (sidebarTableSearchIndexInFlight.get(cacheKey) === read) sidebarTableSearchIndexInFlight.delete(cacheKey);
}
}
function findSidebarTreeNodeByIdentity(parentNodeId: string, identity: SidebarRegexScopeIdentity): TreeNode | null {
const path = findNodePathByIdentity(treeNodes.value, parentNodeId, identity);
return path?.[path.length - 1] ?? null;
}
async function loadSidebarTableSearchIndex(parentNodeId: string, identity?: SidebarRegexScopeIdentity): Promise<TableInfo[] | null> {
// The identity disambiguates same-id nodes (e.g. database "a:b" vs schema
// "b" under database "a") so the correct cache key is read and registered.
const parent = identity ? findSidebarTreeNodeByIdentity(parentNodeId, identity) : findNode(treeNodes.value, parentNodeId);
if (!parent) return null;
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return null;
const decoded = decodeSchemaTreeCache<TreeNode[]>(await api.loadSchemaCache<unknown>(cacheKey).catch(() => null));
const index = decoded?.tableSearchIndex;
if (!index) return null;
return index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType }));
const entries = await readSidebarTableSearchIndexCache(cacheKey, parent.connectionId || "");
if (entries) await registerSidebarTableSearchIndexScope(parent, cacheKey);
return entries;
}
async function refreshSidebarTableSearchIndex(parentNodeId: string): Promise<TableInfo[]> {
const parent = findNode(treeNodes.value, parentNodeId);
async function loadSidebarTableSearchIndexScopes(): Promise<Array<{ scope: TableSearchIndexManifestEntry; entries: TableInfo[] }>> {
const manifest = await loadSidebarTableSearchIndexManifest();
const scopes: Array<{ scope: TableSearchIndexManifestEntry; entries: TableInfo[] }> = [];
for (const scope of manifest) {
const entries = await readSidebarTableSearchIndexCache(scope.cacheKey, scope.connectionId);
if (entries) scopes.push({ scope, entries });
}
return scopes;
}
async function refreshSidebarTableSearchIndex(parentNodeId: string, identity?: SidebarRegexScopeIdentity): Promise<TableInfo[]> {
const parent = identity ? findSidebarTreeNodeByIdentity(parentNodeId, identity) : findNode(treeNodes.value, parentNodeId);
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return [];
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return [];
const generation = sidebarTableSearchIndexConnectionGeneration(parent.connectionId);
await ensureConnected(parent.connectionId);
const config = getConfig(parent.connectionId);
const querySchema = connectionObjectTreeQuerySchema(config, parent.database, parent.schema);
@@ -2086,13 +2250,25 @@ export const useConnectionStore = defineStore("connection", () => {
const pageSize = sidebarObjectGroupPageSize();
const entries: TableInfo[] = [];
for (let offset = 0; ; offset += pageSize) {
if (generation !== sidebarTableSearchIndexConnectionGeneration(parent.connectionId)) return [];
const page = await listTablesWithOptionalTableNameFilter(parent.connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog);
entries.push(...page);
if (page.length < pageSize) break;
}
const deduped = [...new Map(entries.map((entry) => [`${entry.table_type}\0${entry.name}`, entry])).values()];
const tableSearchIndex = { complete: true as const, indexedAt: new Date().toISOString(), entries: deduped.map((entry) => ({ name: entry.name, tableType: entry.table_type })) };
const tableSearchIndex = {
complete: true as const,
indexedAt: new Date().toISOString(),
entries: deduped.map((entry) => ({ name: entry.name, tableType: entry.table_type, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })),
};
if (generation !== sidebarTableSearchIndexConnectionGeneration(parent.connectionId)) return [];
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache<TreeNode[]>([], Date.now(), tableSearchIndex));
if (generation !== sidebarTableSearchIndexConnectionGeneration(parent.connectionId)) {
await api.deleteSchemaCachePrefix(cacheKey).catch(() => undefined);
return [];
}
sidebarTableSearchIndexCache.set(cacheKey, deduped);
await registerSidebarTableSearchIndexScope(parent, cacheKey);
return deduped;
}
@@ -2569,6 +2745,7 @@ export const useConnectionStore = defineStore("connection", () => {
clearConnectionHealthCheck(config.id);
invalidateCompletionCache(config.id);
void invalidateObjectDdlCache({ connectionId: config.id });
await invalidateSidebarTableSearchIndexesForConnection(config.id);
clearLoadedChildrenCache(config.id);
const node = findConnectionNode(config.id);
if (node?.isExpanded) {
@@ -5924,14 +6101,14 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadXuguTypeMembers(node: TreeNode): Promise<void> {
async function loadXuguTypeMembers(node: TreeNode, options?: Pick<LoadTreeOptions, "preserveCollapsedChildren">): Promise<void> {
if (!isXuguTypeMemberContainer(node, getConfig(node.connectionId || "")?.db_type)) return;
const connectionId = node.connectionId;
const database = node.database;
if (!connectionId || !database) return;
if (node.isExpanded) {
node.isExpanded = false;
if (!sidebarSearchQuery.value) releaseCollapsedTreeNodeChildren(node.id);
if (!sidebarSearchQuery.value && !options?.preserveCollapsedChildren) releaseCollapsedTreeNodeChildren(node.id);
return;
}
if (node.children && node.children.length > 0) {
@@ -7743,6 +7920,7 @@ export const useConnectionStore = defineStore("connection", () => {
setSidebarTableSearchQuery,
refreshSidebarTableSearch,
loadSidebarTableSearchIndex,
loadSidebarTableSearchIndexScopes,
refreshSidebarTableSearchIndex,
createConnectionGroup(name: string, parentGroupId?: string | null) {
const result = createGroupOp(sidebarLayout.value, name, parentGroupId);
@@ -87,7 +87,8 @@ describe("legacy WebView CSS fallbacks", () => {
expect(globalsCss).toContain("border-color: rgba(var(--dbx-primary-rgb), 0.3) !important;");
expect(globalsCss).toContain(".hover\\:bg-primary\\/15:hover");
expect(connectionTreeSource).toContain("showActiveConnectionsOnly");
expect(connectionTreeSource.match(/bg-primary\/10 border-primary\/30/g)?.length).toBe(3);
// Three pre-existing usages plus the sidebar regex-search toggle.
expect(connectionTreeSource.match(/bg-primary\/10 border-primary\/30/g)?.length).toBe(4);
});
it("keeps legacy tab triggers connected to the configured corner style", () => {
+502
View File
@@ -0,0 +1,502 @@
import assert from "node:assert/strict";
import { beforeEach, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { encodeSchemaTreeCache, encodeTableSearchIndexManifest, type TableSearchIndexManifestEntry } from "../../apps/desktop/src/lib/metadata/schemaTreeCache.ts";
import type { ConnectionConfig, TreeNode } from "../../apps/desktop/src/types/database.ts";
const MANIFEST_CACHE_KEY = "dbx:sidebar-table-search-index-manifest-v1";
const { loadSchemaCacheMock, saveSchemaCacheMock, deleteSchemaCachePrefixMock, saveConnectionsMock, listTablesMock, checkConnectionHealthMock, persistedCache } = vi.hoisted(() => {
const persisted = new Map<string, unknown>();
return {
loadSchemaCacheMock: vi.fn(async (cacheKey: string) => persisted.get(cacheKey) ?? null),
saveSchemaCacheMock: vi.fn(async (cacheKey: string, payload: unknown) => {
persisted.set(cacheKey, payload);
}),
deleteSchemaCachePrefixMock: vi.fn(async (prefix: string) => {
for (const cacheKey of persisted.keys()) {
if (cacheKey === prefix || cacheKey.startsWith(prefix)) persisted.delete(cacheKey);
}
}),
saveConnectionsMock: vi.fn(async () => undefined),
listTablesMock: vi.fn(),
checkConnectionHealthMock: vi.fn(async () => undefined),
persistedCache: persisted,
};
});
vi.mock("../../apps/desktop/src/lib/backend/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../apps/desktop/src/lib/backend/api")>();
return {
...actual,
loadSchemaCache: loadSchemaCacheMock,
saveSchemaCache: saveSchemaCacheMock,
deleteSchemaCachePrefix: deleteSchemaCachePrefixMock,
saveConnections: saveConnectionsMock,
listTables: listTablesMock,
checkConnectionHealth: checkConnectionHealthMock,
};
});
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
beforeEach(() => {
loadSchemaCacheMock.mockReset();
saveSchemaCacheMock.mockReset();
deleteSchemaCachePrefixMock.mockReset();
saveConnectionsMock.mockReset();
listTablesMock.mockReset();
checkConnectionHealthMock.mockReset();
persistedCache.clear();
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => persistedCache.get(cacheKey) ?? null);
saveSchemaCacheMock.mockImplementation(async (cacheKey: string, payload: unknown) => {
persistedCache.set(cacheKey, payload);
});
deleteSchemaCachePrefixMock.mockImplementation(async (prefix: string) => {
for (const cacheKey of persistedCache.keys()) {
if (cacheKey === prefix || cacheKey.startsWith(prefix)) persistedCache.delete(cacheKey);
}
});
saveConnectionsMock.mockResolvedValue(undefined);
});
function installMemoryStorage() {
const values = new Map<string, string>();
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear(),
},
});
return () => {
if (original) Object.defineProperty(globalThis, "localStorage", original);
else Reflect.deleteProperty(globalThis, "localStorage");
};
}
function conn(id: string): ConnectionConfig {
return {
id,
name: id,
db_type: "postgres",
host: "localhost",
port: 5432,
username: "postgres",
password: "",
};
}
function connectionNodeWithDatabases(...databases: string[]): TreeNode {
return {
id: "conn-1",
label: "conn-1",
type: "connection",
connectionId: "conn-1",
isExpanded: true,
children: databases.map((database) => ({ id: "conn-1:" + database, label: database, type: "database", connectionId: "conn-1", database })),
};
}
function indexEnvelope(entries: Array<{ name: string; tableType: string }>, cachedAt = Date.now()) {
return encodeSchemaTreeCache<TreeNode[]>([], cachedAt, {
complete: true,
indexedAt: new Date(cachedAt).toISOString(),
entries,
});
}
function manifestScope(cacheKey: string, parentNodeId: string, overrides: Partial<TableSearchIndexManifestEntry> = {}): TableSearchIndexManifestEntry {
return {
cacheKey,
parentNodeId,
connectionId: "conn-1",
database: "app",
nodeType: "group-tables",
...overrides,
};
}
test("loadSidebarTableSearchIndexScopes reads only the local schema cache", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
const cacheKey = "conn-1:app:__tables:index";
persistedCache.set(MANIFEST_CACHE_KEY, encodeTableSearchIndexManifest([manifestScope(cacheKey, "conn-1:app:__tables")]));
persistedCache.set(cacheKey, indexEnvelope([{ name: "orders", tableType: "TABLE" }]));
const scopes = await store.loadSidebarTableSearchIndexScopes();
assert.equal(scopes.length, 1);
assert.deepEqual(
scopes[0]?.entries.map((entry) => [entry.name, entry.table_type]),
[["orders", "TABLE"]],
);
assert.equal(listTablesMock.mock.calls.length, 0);
assert.equal(checkConnectionHealthMock.mock.calls.length, 0);
assert.deepEqual([...new Set(loadSchemaCacheMock.mock.calls.map(([key]) => key))].sort(), [MANIFEST_CACHE_KEY, cacheKey].sort());
} finally {
restoreStorage();
}
});
test("a stale but complete local index is still readable", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
const cacheKey = "conn-1:app:__tables:index";
persistedCache.set(MANIFEST_CACHE_KEY, encodeTableSearchIndexManifest([manifestScope(cacheKey, "conn-1:app:__tables")]));
persistedCache.set(cacheKey, indexEnvelope([{ name: "orders", tableType: "TABLE" }], Date.now() - 60 * 60 * 1000));
const scopes = await store.loadSidebarTableSearchIndexScopes();
assert.deepEqual(
scopes[0]?.entries.map((entry) => entry.name),
["orders"],
);
assert.equal(listTablesMock.mock.calls.length, 0);
} finally {
restoreStorage();
}
});
test("concurrent reads of the same index cache key hit the storage once", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.treeNodes.push(connectionNodeWithDatabases("app"));
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => {
if (cacheKey === MANIFEST_CACHE_KEY) return null;
await gate;
return indexEnvelope([{ name: "orders", tableType: "TABLE" }]);
});
const pending = Promise.all([store.loadSidebarTableSearchIndex("conn-1:app"), store.loadSidebarTableSearchIndex("conn-1:app")]);
await vi.waitFor(() => {
assert.equal(loadSchemaCacheMock.mock.calls.filter(([key]) => key !== MANIFEST_CACHE_KEY).length, 1);
});
release();
const [first, second] = await pending;
assert.deepEqual(
first?.map((entry) => entry.name),
["orders"],
);
assert.deepEqual(
second?.map((entry) => entry.name),
["orders"],
);
assert.equal(loadSchemaCacheMock.mock.calls.filter(([key]) => key !== MANIFEST_CACHE_KEY).length, 1);
assert.equal(listTablesMock.mock.calls.length, 0);
assert.equal(checkConnectionHealthMock.mock.calls.length, 0);
} finally {
restoreStorage();
}
});
test("resolves the index parent by composite identity when ids collide", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.treeNodes.push({
id: "conn-1",
label: "conn-1",
type: "connection",
connectionId: "conn-1",
isExpanded: true,
children: [
{ id: "conn-1:a:b", label: "a:b", type: "database", connectionId: "conn-1", database: "a:b", children: [] },
{
id: "conn-1:a",
label: "a",
type: "database",
connectionId: "conn-1",
database: "a",
children: [{ id: "conn-1:a:b", label: "b", type: "schema", connectionId: "conn-1", database: "a", schema: "b", children: [] }],
},
],
});
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => {
if (cacheKey === MANIFEST_CACHE_KEY) return null;
return indexEnvelope([{ name: "only_schema_table", tableType: "TABLE" }]);
});
const entries = await store.loadSidebarTableSearchIndex("conn-1:a:b", { connectionId: "conn-1", database: "a", schema: "b", nodeType: "schema" });
assert.deepEqual(
entries?.map((entry) => entry.name),
["only_schema_table"],
);
// The registered manifest path must point into the database a -> schema b
// branch, not at the same-id database "a:b" node.
const scopes = await store.loadSidebarTableSearchIndexScopes();
assert.equal(scopes.length, 1);
assert.deepEqual(
scopes[0]?.scope.path?.map((node) => node.id),
["conn-1", "conn-1:a", "conn-1:a:b"],
);
assert.deepEqual(
scopes[0]?.scope.path?.map((node) => node.type),
["connection", "database", "schema"],
);
} finally {
restoreStorage();
}
});
test("a composite identity with no matching node reads nothing", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.treeNodes.push({
id: "conn-1",
label: "conn-1",
type: "connection",
connectionId: "conn-1",
isExpanded: true,
children: [{ id: "conn-1:a:b", label: "a:b", type: "database", connectionId: "conn-1", database: "a:b", children: [] }],
});
const entries = await store.loadSidebarTableSearchIndex("conn-1:a:b", { connectionId: "conn-1", database: "a", schema: "b", nodeType: "schema" });
assert.equal(entries, null);
assert.equal(loadSchemaCacheMock.mock.calls.length, 0);
assert.equal(listTablesMock.mock.calls.length, 0);
} finally {
restoreStorage();
}
});
test("catalog-scoped indexes remain distinct when database names match", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
const hiveDatabase: TreeNode = { id: "conn-1:doris-catalog:hive:sales", label: "sales", type: "database", connectionId: "conn-1", catalog: "hive", database: "sales", children: [] };
const icebergDatabase: TreeNode = { id: "conn-1:doris-catalog:iceberg:sales", label: "sales", type: "database", connectionId: "conn-1", catalog: "iceberg", database: "sales", children: [] };
store.treeNodes.push({
id: "conn-1",
label: "conn-1",
type: "connection",
connectionId: "conn-1",
children: [
{ id: "conn-1:doris-catalog:hive", label: "hive", type: "doris-catalog", connectionId: "conn-1", catalog: "hive", children: [hiveDatabase] },
{ id: "conn-1:doris-catalog:iceberg", label: "iceberg", type: "doris-catalog", connectionId: "conn-1", catalog: "iceberg", children: [icebergDatabase] },
],
});
let indexReadCount = 0;
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => {
if (cacheKey === MANIFEST_CACHE_KEY) return null;
indexReadCount += 1;
return indexEnvelope([{ name: indexReadCount === 1 ? "hive_orders" : "iceberg_orders", tableType: "TABLE" }]);
});
const hiveEntries = await store.loadSidebarTableSearchIndex(hiveDatabase.id, { connectionId: "conn-1", database: "sales", catalog: "hive", nodeType: "database" });
const icebergEntries = await store.loadSidebarTableSearchIndex(icebergDatabase.id, { connectionId: "conn-1", database: "sales", catalog: "iceberg", nodeType: "database" });
const scopes = await store.loadSidebarTableSearchIndexScopes();
assert.deepEqual(
hiveEntries?.map((entry) => entry.name),
["hive_orders"],
);
assert.deepEqual(
icebergEntries?.map((entry) => entry.name),
["iceberg_orders"],
);
assert.equal(indexReadCount, 2);
assert.deepEqual(scopes.map(({ scope }) => scope.catalog).sort(), ["hive", "iceberg"]);
assert.equal(new Set(scopes.map(({ scope }) => scope.cacheKey)).size, 2);
} finally {
restoreStorage();
}
});
test("registering a catalog-scoped index replaces its legacy manifest entry", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
const database: TreeNode = { id: "conn-1:doris-catalog:hive:sales", label: "sales", type: "database", connectionId: "conn-1", catalog: "hive", database: "sales", children: [] };
store.treeNodes.push({
id: "conn-1",
label: "conn-1",
type: "connection",
connectionId: "conn-1",
children: [{ id: "conn-1:doris-catalog:hive", label: "hive", type: "doris-catalog", connectionId: "conn-1", catalog: "hive", children: [database] }],
});
const legacyCacheKey = "conn-1:sales:objects-grouped-v8:table-search-index-v1";
persistedCache.set(
MANIFEST_CACHE_KEY,
encodeTableSearchIndexManifest([
manifestScope(legacyCacheKey, database.id, {
database: "sales",
catalog: "hive",
nodeType: "database",
}),
]),
);
persistedCache.set(legacyCacheKey, indexEnvelope([{ name: "stale_orders", tableType: "TABLE" }]));
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => {
if (cacheKey === MANIFEST_CACHE_KEY || cacheKey === legacyCacheKey) return persistedCache.get(cacheKey) ?? null;
return indexEnvelope([{ name: "fresh_orders", tableType: "TABLE" }]);
});
const entries = await store.loadSidebarTableSearchIndex(database.id, { connectionId: "conn-1", database: "sales", catalog: "hive", nodeType: "database" });
const scopes = await store.loadSidebarTableSearchIndexScopes();
assert.deepEqual(
entries?.map((entry) => entry.name),
["fresh_orders"],
);
assert.equal(scopes.length, 1);
assert.equal(scopes[0]?.scope.catalog, "hive");
assert.notEqual(scopes[0]?.scope.cacheKey, legacyCacheKey);
assert.deepEqual(
scopes[0]?.entries.map((entry) => entry.name),
["fresh_orders"],
);
} finally {
restoreStorage();
}
});
test("concurrent scope registrations keep every manifest entry", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.treeNodes.push(connectionNodeWithDatabases("db1", "db2"));
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => {
if (cacheKey === MANIFEST_CACHE_KEY) return null;
return indexEnvelope([{ name: "orders", tableType: "TABLE" }]);
});
await Promise.all([store.loadSidebarTableSearchIndex("conn-1:db1"), store.loadSidebarTableSearchIndex("conn-1:db2")]);
const scopes = await store.loadSidebarTableSearchIndexScopes();
assert.deepEqual(scopes.map(({ scope }) => scope.parentNodeId).sort(), ["conn-1:db1", "conn-1:db2"]);
const manifestSaves = saveSchemaCacheMock.mock.calls.filter(([key]) => key === MANIFEST_CACHE_KEY);
const lastPayload = manifestSaves.at(-1)?.[1] as { scopes: Array<{ parentNodeId: string }> };
assert.equal(lastPayload?.scopes.length, 2);
assert.equal(listTablesMock.mock.calls.length, 0);
} finally {
restoreStorage();
}
});
test("an index without a complete table index never registers a manifest scope", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.treeNodes.push(connectionNodeWithDatabases("app"));
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => {
if (cacheKey === MANIFEST_CACHE_KEY) return null;
return encodeSchemaTreeCache<TreeNode[]>([], Date.now());
});
const entries = await store.loadSidebarTableSearchIndex("conn-1:app");
assert.equal(entries, null);
assert.equal(saveSchemaCacheMock.mock.calls.filter(([key]) => key === MANIFEST_CACHE_KEY).length, 0);
assert.equal(listTablesMock.mock.calls.length, 0);
assert.equal(checkConnectionHealthMock.mock.calls.length, 0);
} finally {
restoreStorage();
}
});
test("refreshSidebarTableSearchIndex is the only path that paginates listTables", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.treeNodes.push(connectionNodeWithDatabases("app"));
const all = Array.from({ length: 501 }, (_, index) => ({ name: "t" + index, table_type: "TABLE" }));
listTablesMock.mockImplementation(async (_connectionId: string, _database: string, _schema: string, _filter: unknown, limit: number, offset: number) => all.slice(offset, offset + limit));
const entries = await store.refreshSidebarTableSearchIndex("conn-1:app");
assert.equal(entries.length, 501);
const calls = listTablesMock.mock.calls as Array<[string, string, string, unknown, number, number]>;
const pageSize = calls[0]?.[4];
assert.ok(typeof pageSize === "number" && pageSize > 0);
assert.deepEqual(
calls.map((call) => call[5]),
Array.from({ length: calls.length }, (_, index) => index * pageSize),
);
assert.equal(calls.length, Math.ceil(501 / pageSize));
assert.equal(checkConnectionHealthMock.mock.calls.length, 0);
const scopes = await store.loadSidebarTableSearchIndexScopes();
assert.equal(scopes.length, 1);
assert.equal(scopes[0]?.entries.length, 501);
} finally {
restoreStorage();
}
});
test("updating a connection invalidates its in-memory index and manifest scopes", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
const store = useConnectionStore();
store.addEphemeralConnection(conn("conn-1"));
store.treeNodes.push(connectionNodeWithDatabases("app"));
let indexName = "old_orders";
loadSchemaCacheMock.mockImplementation(async (cacheKey: string) => {
if (cacheKey === MANIFEST_CACHE_KEY) return persistedCache.get(cacheKey) ?? null;
return indexEnvelope([{ name: indexName, tableType: "TABLE" }]);
});
const initialEntries = await store.loadSidebarTableSearchIndex("conn-1:app");
assert.deepEqual(
initialEntries?.map((entry) => entry.name),
["old_orders"],
);
await store.updateConnection({ ...conn("conn-1"), host: "replacement.example.com" });
assert.deepEqual(await store.loadSidebarTableSearchIndexScopes(), []);
const manifestSavesAfterUpdate = saveSchemaCacheMock.mock.calls.filter(([key]) => key === MANIFEST_CACHE_KEY);
const clearedManifest = manifestSavesAfterUpdate.at(-1)?.[1] as { scopes: unknown[] };
assert.deepEqual(clearedManifest?.scopes, []);
store.treeNodes = [connectionNodeWithDatabases("app")];
indexName = "new_orders";
const refreshedEntries = await store.loadSidebarTableSearchIndex("conn-1:app");
assert.deepEqual(
refreshedEntries?.map((entry) => entry.name),
["new_orders"],
);
const manifestSaves = saveSchemaCacheMock.mock.calls.filter(([key]) => key === MANIFEST_CACHE_KEY);
const lastPayload = manifestSaves.at(-1)?.[1] as { scopes: unknown[] };
assert.equal(lastPayload?.scopes.length, 1);
assert.equal(listTablesMock.mock.calls.length, 0);
assert.equal(checkConnectionHealthMock.mock.calls.length, 0);
} finally {
restoreStorage();
}
});
+301
View File
@@ -0,0 +1,301 @@
import { strict as assert } from "node:assert";
import { test, vi } from "vitest";
import { collectSidebarRegexIndexScopes, regexTableSearchParents, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "../../apps/desktop/src/lib/sidebar/sidebarRegexSearchIndex.ts";
import type { SidebarRegexIndexScope } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts";
import type { TableInfo, TreeNode } from "../../apps/desktop/src/types/database.ts";
function table(name: string, table_type = "TABLE"): TableInfo {
return { name, table_type };
}
function scope(parentNodeId: string, overrides: Partial<SidebarRegexIndexScope> = {}): SidebarRegexIndexScope {
return {
parentNodeId,
connectionId: "conn-1",
database: "app",
schema: "public",
nodeType: "group-tables",
entries: [],
...overrides,
};
}
test("regex mode always dispatches to the local index read regardless of the local search setting", () => {
assert.equal(resolveSidebarSearchDispatchMode({ query: "A|b", regexMode: true, wasRegexMode: false }, { localSearchEnabled: true }), "regex");
assert.equal(resolveSidebarSearchDispatchMode({ query: "A|b", regexMode: true, wasRegexMode: false }, { localSearchEnabled: false }), "regex");
// Editing and clearing the expression while regex stays on keep the same branch.
assert.equal(resolveSidebarSearchDispatchMode({ query: "A|b|c", regexMode: true, wasRegexMode: true }, { localSearchEnabled: false }), "regex");
assert.equal(resolveSidebarSearchDispatchMode({ query: "", regexMode: true, wasRegexMode: true }, { localSearchEnabled: true }), "regex");
});
test("leaving regex mode restores the configured ordinary search behavior", () => {
assert.equal(resolveSidebarSearchDispatchMode({ query: "orders", regexMode: false, wasRegexMode: true }, { localSearchEnabled: false }), "ordinary");
assert.equal(resolveSidebarSearchDispatchMode({ query: "orders", regexMode: false, wasRegexMode: true }, { localSearchEnabled: true }), "none");
assert.equal(resolveSidebarSearchDispatchMode({ query: "", regexMode: false, wasRegexMode: true }, { localSearchEnabled: false }), "none");
});
test("local search on keeps ordinary queries local and local search off enables the remote refresh", () => {
assert.equal(resolveSidebarSearchDispatchMode({ query: "orders", regexMode: false, wasRegexMode: false }, { localSearchEnabled: true }), "none");
assert.equal(resolveSidebarSearchDispatchMode({ query: "orders", regexMode: false, wasRegexMode: false }, { localSearchEnabled: false }), "ordinary");
assert.equal(resolveSidebarSearchDispatchMode({ query: "", regexMode: false, wasRegexMode: false }, { localSearchEnabled: false }), "ordinary");
});
test("collects manifest scopes and live-tree backfill scopes through the read-only reader", async () => {
const manifestScope = scope("conn-1:app:public:__tables", { path: [{ id: "conn-1", label: "c", type: "connection", connectionId: "conn-1" }] });
const live: TreeNode[] = [
{
id: "conn-1",
label: "c",
type: "connection",
connectionId: "conn-1",
children: [
{
id: "conn-1:app",
label: "app",
type: "database",
connectionId: "conn-1",
database: "app",
children: [
{
id: "conn-1:app:legacy",
label: "legacy",
type: "schema",
connectionId: "conn-1",
database: "app",
schema: "legacy",
},
],
},
],
},
];
const loadSidebarTableSearchIndex = vi.fn(async (parent: { parentNodeId: string }) => (parent.parentNodeId === "conn-1:app:legacy" ? [table("legacy_table")] : null));
const reader = {
loadSidebarTableSearchIndexScopes: vi.fn(async () => [{ scope: manifestScope, entries: [table("manifest_table")] }]),
loadSidebarTableSearchIndex,
};
const result = await collectSidebarRegexIndexScopes(reader, live, () => false);
assert.deepEqual(
result.map(({ parentNodeId }) => parentNodeId),
["conn-1:app:public:__tables", "conn-1:app:legacy"],
);
// Manifest scopes keep their recorded path...
assert.deepEqual(result[0]?.path, manifestScope.path);
assert.deepEqual(result[0]?.entries, [table("manifest_table")]);
// ...while the legacy backfill scope deliberately has no path so the merge
// layer anchors it to the live parent instead of guessing ancestors.
assert.equal(result[1]?.path, undefined);
assert.equal(result[1]?.nodeType, "schema");
assert.deepEqual(result[1]?.entries, [table("legacy_table")]);
assert.equal(reader.loadSidebarTableSearchIndexScopes.mock.calls.length, 1);
// Every live regex parent is probed for a pre-manifest index, each by its
// composite identity (id plus database context), never by the bare id.
assert.deepEqual(
loadSidebarTableSearchIndex.mock.calls.map(([parent]) => parent.parentNodeId),
["conn-1:app", "conn-1:app:legacy"],
);
assert.deepEqual(loadSidebarTableSearchIndex.mock.calls[0]?.[0], {
parentNodeId: "conn-1:app",
connectionId: "conn-1",
database: "app",
schema: undefined,
catalog: undefined,
nodeType: "database",
});
assert.deepEqual(loadSidebarTableSearchIndex.mock.calls[1]?.[0], {
parentNodeId: "conn-1:app:legacy",
connectionId: "conn-1",
database: "app",
schema: "legacy",
catalog: undefined,
nodeType: "schema",
});
});
test("skips live-tree parents that already have a manifest scope", async () => {
const manifestScope = scope("conn-1:app:public", { nodeType: "schema" });
const live: TreeNode[] = [{ id: "conn-1:app:public", label: "public", type: "schema", connectionId: "conn-1", database: "app", schema: "public" }];
const loadSidebarTableSearchIndex = vi.fn(async () => null);
const reader = {
loadSidebarTableSearchIndexScopes: vi.fn(async () => [{ scope: manifestScope, entries: [table("manifest_table")] }]),
loadSidebarTableSearchIndex,
};
const result = await collectSidebarRegexIndexScopes(reader, live, () => false);
assert.deepEqual(
result.map(({ parentNodeId }) => parentNodeId),
["conn-1:app:public"],
);
assert.equal(loadSidebarTableSearchIndex.mock.calls.length, 0);
});
test("collects every legacy scope when legal tree nodes share an id", async () => {
const sharedId = "conn-1:a:b";
const live: TreeNode[] = [
{
id: "conn-1",
label: "c",
type: "connection",
connectionId: "conn-1",
children: [
{ id: sharedId, label: "a:b", type: "database", connectionId: "conn-1", database: "a:b" },
{
id: "conn-1:a",
label: "a",
type: "database",
connectionId: "conn-1",
database: "a",
children: [{ id: sharedId, label: "b", type: "schema", connectionId: "conn-1", database: "a", schema: "b" }],
},
],
},
];
const loadSidebarTableSearchIndex = vi.fn(async (parent: { database: string; schema?: string }) => (parent.database === "a" && parent.schema === "b" ? [table("schema_table")] : null));
const reader = {
loadSidebarTableSearchIndexScopes: vi.fn(async () => []),
loadSidebarTableSearchIndex,
};
const result = await collectSidebarRegexIndexScopes(reader, live, () => false);
assert.deepEqual(
result.map(({ database, schema }) => [database, schema]),
[["a", "b"]],
);
assert.deepEqual(
loadSidebarTableSearchIndex.mock.calls.filter(([parent]) => parent.parentNodeId === sharedId).map(([parent]) => [parent.database, parent.schema]),
[
["a:b", undefined],
["a", "b"],
],
);
});
test("a manifest scope suppresses only the matching live identity", async () => {
const sharedId = "conn-1:a:b";
const manifestScope = scope(sharedId, { database: "a:b", schema: undefined, nodeType: "database" });
const live: TreeNode[] = [
{
id: "conn-1",
label: "c",
type: "connection",
connectionId: "conn-1",
children: [
{ id: sharedId, label: "a:b", type: "database", connectionId: "conn-1", database: "a:b" },
{
id: "conn-1:a",
label: "a",
type: "database",
connectionId: "conn-1",
database: "a",
children: [{ id: sharedId, label: "b", type: "schema", connectionId: "conn-1", database: "a", schema: "b" }],
},
],
},
];
const loadSidebarTableSearchIndex = vi.fn(async (parent: { database: string; schema?: string }) => (parent.database === "a" && parent.schema === "b" ? [table("schema_table")] : null));
const reader = {
loadSidebarTableSearchIndexScopes: vi.fn(async () => [{ scope: manifestScope, entries: [table("database_table")] }]),
loadSidebarTableSearchIndex,
};
const result = await collectSidebarRegexIndexScopes(reader, live, () => false);
assert.deepEqual(
result.map(({ database, schema }) => [database, schema]),
[
["a:b", undefined],
["a", "b"],
],
);
assert.deepEqual(
loadSidebarTableSearchIndex.mock.calls.filter(([parent]) => parent.parentNodeId === sharedId).map(([parent]) => [parent.database, parent.schema]),
[["a", "b"]],
);
});
test("stops collecting when the search is cancelled mid-run", async () => {
let cancelled = false;
const live: TreeNode[] = [
{
id: "conn-1",
label: "c",
type: "connection",
connectionId: "conn-1",
children: [
{
id: "conn-1:app",
label: "app",
type: "database",
connectionId: "conn-1",
database: "app",
children: [
{ id: "conn-1:app:s1", label: "s1", type: "schema", connectionId: "conn-1", database: "app", schema: "s1" },
{ id: "conn-1:app:s2", label: "s2", type: "schema", connectionId: "conn-1", database: "app", schema: "s2" },
],
},
],
},
];
const loadSidebarTableSearchIndex = vi.fn(async () => {
cancelled = true;
return [table("s1_table")];
});
const reader = {
loadSidebarTableSearchIndexScopes: vi.fn(async () => []),
loadSidebarTableSearchIndex,
};
const result = await collectSidebarRegexIndexScopes(reader, live, () => cancelled);
// The scope already collected for the first parent is kept (the component
// discards the whole run when the mode changed), but no further parent is
// probed once the search is cancelled.
assert.deepEqual(
result.map(({ parentNodeId }) => parentNodeId),
["conn-1:app"],
);
assert.deepEqual(
loadSidebarTableSearchIndex.mock.calls.map(([parent]) => parent.parentNodeId),
["conn-1:app"],
);
});
test("regex mode keeps the remote tree-loading search query empty", () => {
assert.equal(resolveSidebarRemoteSearchQuery(true, "A|b"), "");
assert.equal(resolveSidebarRemoteSearchQuery(true, ""), "");
// Ordinary search keeps using the query for remote loading.
assert.equal(resolveSidebarRemoteSearchQuery(false, "A|b"), "A|b");
assert.equal(resolveSidebarRemoteSearchQuery(false, ""), "");
});
test("regexTableSearchParents walks grouped and nested tree shapes in order", () => {
const groupTables: TreeNode = { id: "conn-1:app:public:__tables", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "app", schema: "public" };
const live: TreeNode[] = [
{
id: "group-a",
label: "A",
type: "connection-group",
children: [
{
id: "conn-1",
label: "c",
type: "connection",
connectionId: "conn-1",
children: [
{ id: "conn-1:app", label: "app", type: "database", connectionId: "conn-1", database: "app", children: [{ id: "conn-1:app:public", label: "public", type: "schema", connectionId: "conn-1", database: "app", schema: "public", children: [groupTables] }] },
{ id: "conn-1:SYSTEM", label: "SYSTEM", type: "schema", connectionId: "conn-1", database: "", schema: "SYSTEM" },
],
},
],
},
];
assert.deepEqual(
regexTableSearchParents(live).map((node) => node.id),
["conn-1:app", "conn-1:app:public", "conn-1:app:public:__tables", "conn-1:SYSTEM"],
);
});
+36 -2
View File
@@ -1,6 +1,7 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { matchSidebarLabel } from "../../apps/desktop/src/lib/sidebar/sidebarSearch.ts";
import { createSidebarLabelMatcher, matchSidebarLabel } from "../../apps/desktop/src/lib/sidebar/sidebarSearch.ts";
import { compileSearchRegex, parseSlashDelimitedRegexQuery } from "../../apps/desktop/src/lib/common/searchPattern.ts";
import { filterSidebarTree } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts";
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
@@ -392,6 +393,36 @@ test("keeps invalid regular expression queries from matching every label", () =>
assert.equal(matchSidebarLabel("orders", "/["), null);
});
test("ordinary slash-delimited search keeps its implicit case-insensitive flags", () => {
assert.equal(matchSidebarLabel("FOO", "/foo/m")?.kind, "regex");
});
test("explicit regex mode supports JavaScript syntax and defaults to case-insensitive", () => {
const pattern = "^(foo|bar)[0-9]+\\w+$";
assert.equal(matchSidebarLabel("BAR12_name", pattern, { regexMode: true })?.kind, "regex");
assert.equal(matchSidebarLabel("baz12_name", pattern, { regexMode: true }), null);
assert.equal(matchSidebarLabel("SYS_USER_LOG", "/^sys_.*_log/", { regexMode: true })?.kind, "regex");
});
test("explicit regex flags are respected and global or sticky tests are stable", () => {
assert.equal(matchSidebarLabel("FOO", "/foo/m", { regexMode: true }), null);
assert.equal(matchSidebarLabel("FOO", "/foo/i", { regexMode: true })?.kind, "regex");
const globalMatcher = createSidebarLabelMatcher("/foo/g", { regexMode: true });
assert.equal(globalMatcher("foo")?.kind, "regex");
assert.equal(globalMatcher("foo")?.kind, "regex");
const global = compileSearchRegex("/foo/g").regex!;
assert.equal(global.lastIndex, 0);
const stickyMatcher = createSidebarLabelMatcher("/foo/y", { regexMode: true });
assert.equal(stickyMatcher("foo")?.kind, "regex");
assert.equal(stickyMatcher("foo")?.kind, "regex");
});
test("invalid explicit regexes return invalid without matching", () => {
assert.deepEqual(compileSearchRegex("/["), { regex: null, invalid: true });
assert.equal(matchSidebarLabel("orders", "[", { regexMode: true }), null);
assert.equal(parseSlashDelimitedRegexQuery("/[/"), null);
});
// Cross-word prefix concatenation (issue #5407)
test("matches cross-word prefix concatenation (issue #5407)", () => {
@@ -487,7 +518,10 @@ test("camelCase tokenization survives the real tree-search path (issue #5407)",
// camel + Table skips Case, so it scores 50 and outranks camxxtab's fuzzy/40.
// If callers lowercase first, both score 40 and the stable sort keeps camxxtab first.
const camTabResults = filterSidebarTree(tree, "camTab", new Set());
assert.deepEqual(camTabResults[0]?.children?.[0]?.children?.map((node) => node.label), ["camelCaseTable", "camxxtab"]);
assert.deepEqual(
camTabResults[0]?.children?.[0]?.children?.map((node) => node.label),
["camelCaseTable", "camxxtab"],
);
assert.equal(findTable(filterSidebarTree(tree, "caseTab", new Set()))?.label, "camelCaseTable");
assert.equal(findTable(filterSidebarTree(tree, "aseTb", new Set())), undefined);
+458 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, mergeSidebarRegexIndexScopes, nodeMatchesRegexScopeIdentity, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts";
import type { TreeNode, TreeNodeType } from "../../apps/desktop/src/types/database.ts";
test("preserves loaded table children when the table itself matches search", () => {
@@ -378,6 +378,463 @@ test("does not return synthetic connection management entries as direct text mat
assert.deepEqual(filterSidebarTree(nodes, "userAdmin", new Set()), []);
});
test("merges manifest regex index scopes into existing grouped connections without mutating live nodes", () => {
const live: TreeNode[] = [
{
id: "group-a",
label: "A",
type: "connection-group",
children: [{ id: "conn-1", label: "Production", type: "connection", connectionId: "conn-1", children: [] }],
},
];
const snapshot = JSON.parse(JSON.stringify(live)) as TreeNode[];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:app:public:__tables",
connectionId: "conn-1",
database: "app",
schema: "public",
nodeType: "group-tables",
path: [
{ id: "conn-1", label: "Production", type: "connection", connectionId: "conn-1" },
{ id: "conn-1:app", label: "app", type: "database", connectionId: "conn-1", database: "app" },
{ id: "conn-1:app:public", label: "public", type: "schema", connectionId: "conn-1", database: "app", schema: "public" },
{ id: "conn-1:app:public:__tables", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "app", schema: "public" },
],
entries: [
{ name: "Foo", table_type: "TABLE" },
{ name: "foo", table_type: "VIEW" },
],
},
]);
assert.deepEqual(live, snapshot);
assert.equal(merged[0]?.type, "connection-group");
assert.equal(merged[0]?.children?.[0]?.id, "conn-1");
// group-a -> conn-1 -> app database -> public schema -> group-tables
const group = merged[0]?.children?.[0]?.children?.[0]?.children?.[0]?.children?.[0];
assert.deepEqual(
group?.children?.map((node) => [node.label, node.type]),
[
["Foo", "table"],
["foo", "view"],
],
);
});
test("anchors a pathless legacy index scope into its live parent without synthesizing ancestors", () => {
const liveTable: TreeNode = {
id: "conn-1:app:public:__tables:existing",
label: "existing",
type: "table",
connectionId: "conn-1",
database: "app",
schema: "public",
};
const live: TreeNode[] = [
{
id: "group-a",
label: "A",
type: "connection-group",
children: [
{
id: "conn-1",
label: "Production",
type: "connection",
connectionId: "conn-1",
children: [
{
id: "conn-1:app",
label: "app",
type: "database",
connectionId: "conn-1",
database: "app",
children: [
{
id: "conn-1:app:public",
label: "public",
type: "schema",
connectionId: "conn-1",
database: "app",
schema: "public",
children: [
{
id: "conn-1:app:public:__tables",
label: "Tables",
type: "group-tables",
connectionId: "conn-1",
database: "app",
schema: "public",
isExpanded: true,
children: [liveTable],
},
],
},
],
},
],
},
],
},
];
const snapshot = JSON.parse(JSON.stringify(live)) as TreeNode[];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:app:public:__tables",
connectionId: "conn-1",
database: "app",
schema: "public",
nodeType: "group-tables",
entries: [{ name: "Foo", table_type: "TABLE" }],
},
]);
assert.deepEqual(live, snapshot);
// The group keeps its live position under connection -> database -> schema;
// nothing is inserted directly below the connection.
const connection = merged[0]?.children?.[0];
assert.deepEqual(
connection?.children?.map((node) => node.id),
["conn-1:app"],
);
const group = connection?.children?.[0]?.children?.[0]?.children?.[0];
assert.equal(group?.type, "group-tables");
assert.deepEqual(
group?.children?.map((node) => [node.label, node.type]),
[
["existing", "table"],
["Foo", "table"],
],
);
// The live table node keeps its identity/state.
assert.equal(group?.children?.[0], liveTable);
});
test("anchors same-id scopes to the node matching their database context", () => {
const live: TreeNode[] = [
{
id: "conn-1",
label: "c",
type: "connection",
connectionId: "conn-1",
isExpanded: true,
children: [
{
id: "conn-1:a:b",
label: "a:b",
type: "database",
connectionId: "conn-1",
database: "a:b",
isExpanded: true,
children: [],
},
{
id: "conn-1:a",
label: "a",
type: "database",
connectionId: "conn-1",
database: "a",
isExpanded: true,
children: [
{
id: "conn-1:a:b",
label: "b",
type: "schema",
connectionId: "conn-1",
database: "a",
schema: "b",
isExpanded: true,
children: [],
},
],
},
],
},
];
const snapshot = JSON.parse(JSON.stringify(live)) as TreeNode[];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:a:b",
connectionId: "conn-1",
database: "a",
schema: "b",
nodeType: "schema",
entries: [{ name: "schema_b_table", table_type: "TABLE" }],
},
{
parentNodeId: "conn-1:a:b",
connectionId: "conn-1",
database: "a:b",
nodeType: "database",
entries: [{ name: "db_a_b_table", table_type: "TABLE" }],
},
]);
assert.deepEqual(live, snapshot);
const children = merged[0]?.children;
// Database "a:b" node (id conn-1:a:b) gets the database-scoped entry...
assert.equal(children?.[0]?.type, "database");
assert.deepEqual(
children?.[0]?.children?.map((node) => node.label),
["db_a_b_table"],
);
// ...while the schema "b" under database "a" (same id) gets its own entry.
assert.equal(children?.[1]?.type, "database");
const schema = children?.[1]?.children?.[0];
assert.equal(schema?.type, "schema");
assert.deepEqual(
schema?.children?.map((node) => node.label),
["schema_b_table"],
);
});
test("does not anchor a pathless scope to a same-id node with a different database context", () => {
const live: TreeNode[] = [
{
id: "conn-1",
label: "c",
type: "connection",
connectionId: "conn-1",
isExpanded: true,
children: [
{
id: "conn-1:a:b",
label: "a:b",
type: "database",
connectionId: "conn-1",
database: "a:b",
isExpanded: true,
children: [],
},
],
},
];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:a:b",
connectionId: "conn-1",
database: "a",
schema: "b",
nodeType: "schema",
entries: [{ name: "must_not_land_here", table_type: "TABLE" }],
},
]);
// The schema scope does not exist in this tree; the same-id database node
// must not receive the entry.
assert.deepEqual(merged[0]?.children?.[0]?.children, []);
assert.deepEqual(merged, live);
});
test("does not match a scope when a required identity field is missing from the node", () => {
const node: TreeNode = {
id: "conn-1:hive:sales",
label: "sales",
type: "database",
connectionId: "conn-1",
database: "sales",
};
assert.equal(nodeMatchesRegexScopeIdentity(node, node.id, { connectionId: "conn-1", database: "sales", catalog: "hive", nodeType: "database" }), false);
});
test("drops a pathless index scope when only its connection remains in the tree", () => {
const live: TreeNode[] = [{ id: "conn-1", label: "Production", type: "connection", connectionId: "conn-1", children: [] }];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:app:__tables",
connectionId: "conn-1",
database: "app",
nodeType: "group-tables",
entries: [{ name: "Foo", table_type: "TABLE" }],
},
]);
assert.deepEqual(merged, live);
});
test("does not resurrect a removed connection from a manifest scope with a stale path", () => {
const merged = mergeSidebarRegexIndexScopes(
[],
[
{
parentNodeId: "gone:db:__tables",
connectionId: "gone",
database: "db",
nodeType: "group-tables",
path: [
{ id: "gone", label: "Gone", type: "connection", connectionId: "gone" },
{ id: "gone:db", label: "db", type: "database", connectionId: "gone", database: "db" },
{ id: "gone:db:__tables", label: "Tables", type: "group-tables", connectionId: "gone", database: "db" },
],
entries: [{ name: "orders", table_type: "TABLE" }],
},
],
);
assert.deepEqual(merged, []);
});
test("merges a manifest scope whose parent is a schema directly under the connection", () => {
const live: TreeNode[] = [
{
id: "conn-1",
label: "oracle",
type: "connection",
connectionId: "conn-1",
isExpanded: true,
children: [{ id: "conn-1:SYSTEM", label: "SYSTEM", type: "schema", connectionId: "conn-1", database: "", schema: "SYSTEM", children: [] }],
},
];
const snapshot = JSON.parse(JSON.stringify(live)) as TreeNode[];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:SYSTEM",
connectionId: "conn-1",
database: "",
schema: "SYSTEM",
nodeType: "schema",
path: [
{ id: "conn-1", label: "oracle", type: "connection", connectionId: "conn-1" },
{ id: "conn-1:SYSTEM", label: "SYSTEM", type: "schema", connectionId: "conn-1", database: "", schema: "SYSTEM" },
],
entries: [{ name: "Foo", table_type: "TABLE" }],
},
]);
assert.deepEqual(live, snapshot);
// No synthetic database level is guessed between connection and schema.
assert.deepEqual(
merged[0]?.children?.map((node) => node.type),
["schema"],
);
const schema = merged[0]?.children?.[0];
assert.deepEqual(
schema?.children?.map((node) => [node.label, node.type]),
[["Foo", "table"]],
);
});
test("synthesizes catalog ancestors from a Doris manifest scope", () => {
const live: TreeNode[] = [{ id: "conn-1", label: "doris", type: "connection", connectionId: "conn-1", isExpanded: true, children: [] }];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:internal:analytics:public:__tables",
connectionId: "conn-1",
database: "analytics",
schema: "public",
catalog: "internal",
nodeType: "group-tables",
path: [
{ id: "conn-1", label: "doris", type: "connection", connectionId: "conn-1" },
{ id: "conn-1:internal", label: "internal", type: "doris-catalog", connectionId: "conn-1", catalog: "internal" },
{ id: "conn-1:internal:analytics", label: "analytics", type: "database", connectionId: "conn-1", database: "analytics", catalog: "internal" },
{ id: "conn-1:internal:analytics:public", label: "public", type: "schema", connectionId: "conn-1", database: "analytics", schema: "public", catalog: "internal" },
{ id: "conn-1:internal:analytics:public:__tables", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "analytics", schema: "public", catalog: "internal" },
],
entries: [{ name: "Foo", table_type: "TABLE" }],
},
]);
const catalog = merged[0]?.children?.[0];
assert.equal(catalog?.type, "doris-catalog");
assert.equal(catalog?.catalog, "internal");
const database = catalog?.children?.[0];
assert.equal(database?.type, "database");
assert.equal(database?.catalog, "internal");
const schema = database?.children?.[0];
assert.equal(schema?.type, "schema");
const group = schema?.children?.[0];
assert.deepEqual(
group?.children?.map((node) => [node.label, node.type]),
[["Foo", "table"]],
);
assert.equal(group?.children?.[0]?.catalog, "internal");
});
test("synthesizes linked-server ancestors from a SQL Server manifest scope", () => {
const live: TreeNode[] = [{ id: "conn-1", label: "mssql", type: "connection", connectionId: "conn-1", isExpanded: true, children: [] }];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:linked:catalog:schema:__tables",
connectionId: "conn-1",
database: "",
schema: "schema",
nodeType: "group-tables",
path: [
{ id: "conn-1", label: "mssql", type: "connection", connectionId: "conn-1" },
{ id: "conn-1:linked", label: "linked", type: "linked-server", connectionId: "conn-1", linkedServer: "linked" },
{ id: "conn-1:linked:catalog", label: "catalog", type: "linked-server-catalog", connectionId: "conn-1", linkedServer: "linked", linkedCatalog: "catalog" },
{ id: "conn-1:linked:catalog:schema", label: "schema", type: "linked-server-schema", connectionId: "conn-1", database: "", schema: "schema", linkedServer: "linked", linkedCatalog: "catalog" },
{ id: "conn-1:linked:catalog:schema:__tables", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "", schema: "schema", linkedServer: "linked", linkedCatalog: "catalog" },
],
entries: [{ name: "Foo", table_type: "TABLE" }],
},
]);
const linkedServer = merged[0]?.children?.[0];
assert.equal(linkedServer?.type, "linked-server");
assert.equal(linkedServer?.linkedServer, "linked");
const linkedCatalog = linkedServer?.children?.[0];
assert.equal(linkedCatalog?.type, "linked-server-catalog");
assert.equal(linkedCatalog?.linkedCatalog, "catalog");
const linkedSchema = linkedCatalog?.children?.[0];
assert.equal(linkedSchema?.type, "linked-server-schema");
assert.equal(linkedSchema?.linkedServer, "linked");
const group = linkedSchema?.children?.[0];
assert.deepEqual(
group?.children?.map((node) => [node.label, node.type]),
[["Foo", "table"]],
);
});
test("indexed entries keep partition parents, case-distinct names, normalized types, and name order", () => {
const live: TreeNode[] = [{ id: "conn-1", label: "c", type: "connection", connectionId: "conn-1", isExpanded: true, children: [] }];
const merged = mergeSidebarRegexIndexScopes(live, [
{
parentNodeId: "conn-1:app:public:__tables",
connectionId: "conn-1",
database: "app",
schema: "public",
nodeType: "group-tables",
path: [
{ id: "conn-1", label: "c", type: "connection", connectionId: "conn-1" },
{ id: "conn-1:app", label: "app", type: "database", connectionId: "conn-1", database: "app" },
{ id: "conn-1:app:public", label: "public", type: "schema", connectionId: "conn-1", database: "app", schema: "public" },
{ id: "conn-1:app:public:__tables", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "app", schema: "public" },
],
entries: [
{ name: "orders_2024", table_type: "TABLE", parent_schema: "public", parent_name: "Orders" },
{ name: "Foo", table_type: "BASE TABLE" },
{ name: "foo", table_type: "TABLE" },
{ name: "Foo", table_type: "VIEW" },
{ name: "Orders", table_type: "TABLE" },
{ name: "t10", table_type: "TABLE" },
{ name: "t2", table_type: "TABLE" },
],
},
]);
const group = merged[0]?.children?.[0]?.children?.[0]?.children?.[0];
// Sorted by name (numeric collator, case-insensitive ties keep input order);
// same name with different case/type is not deduped.
assert.deepEqual(
group?.children?.map((node) => [node.label, node.type]),
[
["Foo", "table"],
["foo", "table"],
["Foo", "view"],
["Orders", "table"],
["t2", "table"],
["t10", "table"],
],
);
const orders = group?.children?.[3];
assert.deepEqual(
orders?.children?.map((node) => node.type),
["group-partitions"],
);
assert.deepEqual(
orders?.children?.[0]?.children?.map((node) => node.label),
["orders_2024"],
);
});
test("does not resurrect a removed connection from an old index manifest", () => {
const merged = mergeSidebarRegexIndexScopes([], [{ parentNodeId: "gone:db:__tables", connectionId: "gone", database: "db", nodeType: "group-tables", entries: [{ name: "orders", table_type: "TABLE" }] }]);
assert.deepEqual(merged, []);
});
test("temporarily collapses an empty object group within a preserved search subtree", () => {
const tablesGroup: TreeNode = {
id: "conn:1:inventory:__tables",