mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(sidebar): add regular expression search
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+88
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -337,6 +337,8 @@ export default withEnglishFallback({
|
||||
refreshLocalTableSearchIndex: "ローカルテーブルインデックスを更新",
|
||||
globalLocalSearch: "ローカル検索",
|
||||
globalLocalSearchTooltip: "リモート要求を行わず、現在読み込まれているサイドバーオブジェクトのみを検索します。",
|
||||
regexSearch: "正規表現検索",
|
||||
regexSearchTooltip: "JavaScript 正規表現でサイドバーの名前、コメント、エイリアスを照合します。",
|
||||
clearFilter: "フィルターをクリア",
|
||||
locateActiveTab: "サイドバーで表示",
|
||||
},
|
||||
|
||||
@@ -331,6 +331,8 @@ export default withEnglishFallback({
|
||||
searchScopeView: "뷰",
|
||||
searchTablesInCurrentScope: "이 데이터베이스에서 테이블 검색...",
|
||||
clearTableSearch: "테이블 검색 지우기",
|
||||
regexSearch: "정규식 검색",
|
||||
regexSearchTooltip: "JavaScript 정규식으로 사이드바 이름, 주석, 별칭을 검색합니다.",
|
||||
clearFilter: "필터 지우기",
|
||||
locateActiveTab: "사이드바에서 찾기",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -261,6 +261,8 @@ export default withEnglishFallback({
|
||||
refreshLocalTableSearchIndex: "刷新本地表索引",
|
||||
globalLocalSearch: "本地搜索",
|
||||
globalLocalSearchTooltip: "开启后仅搜索当前已加载的侧边栏对象,不发送远程请求。",
|
||||
regexSearch: "正则搜索",
|
||||
regexSearchTooltip: "使用 JavaScript 正则表达式匹配侧边栏名称、备注和别名。",
|
||||
clearFilter: "清除筛选",
|
||||
locateActiveTab: "在侧边栏中定位",
|
||||
},
|
||||
|
||||
@@ -337,6 +337,8 @@ export default withEnglishFallback({
|
||||
refreshLocalTableSearchIndex: "重新整理本機資料表索引",
|
||||
globalLocalSearch: "本機搜尋",
|
||||
globalLocalSearchTooltip: "只搜尋目前已載入的側邊欄物件,不會發出遠端請求。",
|
||||
regexSearch: "正規表示式搜尋",
|
||||
regexSearchTooltip: "使用 JavaScript 正規表示式比對側邊欄名稱、備註與別名。",
|
||||
clearFilter: "清除篩選",
|
||||
locateActiveTab: "在側邊欄中定位",
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user