Files
dbx/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts
T

465 lines
23 KiB
TypeScript

import type { TableInfo, TreeNode, TreeNodeType } from "@/types/database";
import { createSidebarLabelMatcher, matchSidebarLabel, type SidebarLabelMatcher, type SidebarSearchMatcherOptions } from "@/lib/sidebar/sidebarSearch";
import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { stripTableVGroupsFromChildren } from "@/lib/table/tableVGroup";
const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]);
const hiddenSearchNodeTypes = new Set<TreeNodeType>(["user-admin", "dameng-job-admin"]);
function bestMatch(matchLabel: SidebarLabelMatcher, label: string, comment?: string | null, aliases?: readonly string[]) {
let best = matchLabel(label);
for (const candidate of [comment, ...(aliases ?? [])]) {
if (!candidate) continue;
const match = matchLabel(candidate);
if (match && (!best || match.score > best.score)) best = match;
}
return best;
}
function normalizedLabel(node: TreeNode, resolveLabel?: (node: TreeNode) => string): string {
// Keep the original case. The matcher lowercases internally for comparison;
// preserving case here lets it tokenize camelCase labels ("camelCaseTable"
// -> "camel" | "Case" | "Table") instead of treating them as one lowercase
// blob.
return resolveLabel?.(node) ?? node.label;
}
function findNodePath(nodes: readonly TreeNode[], targetNodeId: string, ancestors: readonly TreeNode[] = []): readonly TreeNode[] | undefined {
for (const node of nodes) {
const path = [...ancestors, node];
if (node.id === targetNodeId) return path;
if (node.children) {
const childPath = findNodePath(node.children, targetNodeId, path);
if (childPath) return childPath;
}
}
return undefined;
}
function nodePreservesSearchSubtree(node: TreeNode, matchLabel: SidebarLabelMatcher, searchableNodeTypes?: ReadonlySet<TreeNodeType>): boolean {
if (searchableNodeTypes && !searchableNodeTypes.has(node.type)) return false;
return preserveMatchedSubtreeTypes.has(node.type) && !!bestMatch(matchLabel, normalizedLabel(node), node.comment, node.searchAliases);
}
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>, options?: SidebarSearchMatcherOptions): string {
if (!query) return query;
const matchLabel = createSidebarLabelMatcher(query, options);
const path = findNodePath(nodes, targetNodeId);
const preservesSubtree = path?.some((node) => nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes));
return preservesSubtree ? "" : query;
}
export interface SidebarTreeSearchMatcherOptions extends SidebarSearchMatcherOptions {
resolveLabel?: (node: TreeNode) => string;
}
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, options?: SidebarTreeSearchMatcherOptions | ((node: TreeNode) => string)): TreeNode[] {
// Accept the pre-regex label resolver form as well as the options object so
// callers can use localized synthetic labels without losing regex matching.
const resolveLabel = typeof options === "function" ? options : options?.resolveLabel;
const matcherOptions = typeof options === "function" ? undefined : options;
const matchLabel = query ? createSidebarLabelMatcher(query, matcherOptions) : undefined;
if (!matchLabel && searchableNodeTypes === undefined) return nodes;
return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel);
}
export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: readonly TreeNode[]): TreeNode[] {
const liveNodesById = new Map(liveNodes.map((node) => [node.id, node]));
return indexedNodes.map((node) => liveNodesById.get(node.id) ?? node);
}
export const localTableSearchParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
const localTableSearchChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
export function filterLocallySearchedTables(nodes: TreeNode[], options: { enabled: boolean; queries: Readonly<Record<string, string>>; indexedResults: Readonly<Record<string, TableInfo[] | null>> }): TreeNode[] {
return nodes.map((node) => {
const children = node.children ? filterLocallySearchedTables(node.children, options) : undefined;
const query = options.enabled && localTableSearchParentTypes.has(node.type) ? options.queries[node.id]?.trim() : "";
if (!query || !children) return children === node.children ? node : { ...node, children };
const indexed = options.indexedResults[node.id];
// matchSidebarLabel compares case-insensitively internally and needs the
// ORIGINAL label (and entry name) so camelCase boundaries stay detectable.
// Table comments participate in matching, mirroring the global sidebar
// search and the backend's name-or-comment metadata filter.
const entryMatches = (name: string, comment?: string | null) => !!matchSidebarLabel(name, query) || (!!comment && !!matchSidebarLabel(comment, query));
// Grouped tables are flattened before matching so virtual groups stay
// transparent during local table search.
const flatChildren = stripTableVGroupsFromChildren(children);
const matchingChildren =
indexed === null
? flatChildren.filter((child) => localTableSearchChildTypes.has(child.type) && entryMatches(child.label, child.comment))
: indexed
? reuseLiveSidebarTreeNodes(
buildSidebarIndexedTableNodes({ parentNodeId: node.id, nodeType: node.type, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, entries: indexed.filter((entry) => entryMatches(entry.name, entry.comment)) }),
flatChildren,
)
: flatChildren.filter((child) => localTableSearchChildTypes.has(child.type) && entryMatches(child.label, child.comment));
return { ...node, children: matchingChildren };
});
}
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;
}
export function buildSidebarIndexedTableNodes(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,
// Grouped object lists include the schema in each child id; simple table
// lists do not. Search must use the same id because persisted pin keys
// include it, even when an index hit has not been loaded into the live tree.
includeSchemaInId: scope.nodeType === "group-tables",
});
}
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: buildSidebarIndexedTableNodes(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;
const collapsed = collapsedIds.has(node.id);
if (!collapsed && !childrenChanged) return node;
return {
...node,
children: childrenChanged ? children : node.children,
isExpanded: collapsed ? false : node.isExpanded,
};
}
function preservedSearchChildren(node: TreeNode, collapsedIds: ReadonlySet<string>): TreeNode[] | undefined {
if (!node.children) return undefined;
return node.children.filter((child) => !hiddenSearchNodeTypes.has(child.type)).map((child) => applySearchCollapsedState(child, collapsedIds));
}
function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>, resolveLabel?: (node: TreeNode) => string): TreeNode[] {
const filteredNodes: { node: TreeNode; score: number }[] = [];
for (const node of nodes) {
if (matchLabel && hiddenSearchNodeTypes.has(node.type)) continue;
if (node.type === "object-browser" && node.hiddenChildren) {
const matches = node.hiddenChildren.flatMap((child) => {
if (searchableNodeTypes && !searchableNodeTypes.has(child.type)) return [];
const match = matchLabel?.(normalizedLabel(child, resolveLabel));
if (matchLabel && !match) return [];
return [{ node: child, score: match?.score ?? 0 }];
});
filteredNodes.push(...matches);
continue;
}
const label = normalizedLabel(node, resolveLabel);
const canSelfMatch = !searchableNodeTypes || searchableNodeTypes.has(node.type);
const selfMatch = canSelfMatch ? (matchLabel ? bestMatch(matchLabel, label, node.comment, node.searchAliases) : { score: 0 }) : null;
// Type-only filtering keeps matching rows and their ancestor path, but not
// unrelated descendants that would make the selected type appear ignored.
const preservesSubtree = !!matchLabel && !!selfMatch && preserveMatchedSubtreeTypes.has(node.type);
// A type-matched table keeps its loaded detail groups after the text query
// is cleared instead of being rebuilt with an empty filtered child list.
const preservesTypeMatchedTable = !matchLabel && !!selfMatch && node.type === "table";
// Connection utility entries are synthetic navigation actions, not schema
// search results. Keep real loaded descendants for connection-name matches,
// but do not let those actions make a disconnected result look expanded.
const filteredChildren = preservesSubtree ? preservedSearchChildren(node, collapsedIds) : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes, resolveLabel) : undefined;
if (selfMatch || (filteredChildren && filteredChildren.length > 0)) {
if (!node.children || preservesTypeMatchedTable) {
filteredNodes.push({ node, score: selfMatch?.score ?? 0 });
} else {
const children = filteredChildren ?? [];
filteredNodes.push({
node: {
...node,
children,
isLoading: node.isLoading,
isExpanded: children.length > 0 && !collapsedIds.has(node.id),
},
score: selfMatch?.score ?? 0,
});
}
}
}
filteredNodes.sort((a, b) => b.score - a.score);
return filteredNodes.map((match) => match.node);
}
export function filterSidebarSearchRootsByConnectionState(nodes: TreeNode[], connectedIds: ReadonlySet<string>): TreeNode[] {
return nodes.filter((node) => {
if (node.type === "connection-group" || node.type === "connection") return true;
return node.connectionId ? connectedIds.has(node.connectionId) : true;
});
}
export function resolveSidebarFilterGuards(showConnectedConnectionsOnly: boolean, searchQuery: string, hasSearchScopeFilter: boolean) {
const isTreeSearchFiltering = !!searchQuery.trim() || hasSearchScopeFilter;
return {
isTreeSearchFiltering,
isRootListPartial: showConnectedConnectionsOnly || isTreeSearchFiltering,
};
}
/**
* Produces a display-only connection tree containing connected connections and
* the groups that contain them. Connection descendants stay intact because
* this filter controls the connection list, not database-object visibility.
*/
export function filterSidebarTreeToConnectedConnections(nodes: readonly TreeNode[], connectedIds: ReadonlySet<string>): TreeNode[] {
let changed = false;
const filtered: TreeNode[] = [];
for (const node of nodes) {
if (node.type === "connection") {
if (node.connectionId && connectedIds.has(node.connectionId)) {
filtered.push(node);
} else {
changed = true;
}
continue;
}
if (node.type !== "connection-group") {
filtered.push(node);
continue;
}
const children = filterSidebarTreeToConnectedConnections(node.children ?? [], connectedIds);
if (children.length === 0) {
changed = true;
continue;
}
if (children !== node.children) {
changed = true;
filtered.push({ ...node, children });
} else {
filtered.push(node);
}
}
return changed ? filtered : (nodes as TreeNode[]);
}