From 13878a1a306e46567d1b2c7a13ce662073767fb5 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Fri, 7 Aug 2026 13:43:42 +0800 Subject: [PATCH] fix(sidebar): match cross-word table names via prefix concatenation --- .../src/components/sidebar/ConnectionTree.vue | 11 +- apps/desktop/src/lib/sidebar/sidebarSearch.ts | 118 ++++++++++++++--- .../src/lib/sidebar/sidebarSearchTree.ts | 15 +-- packages/app-tests/sidebarSearch.test.ts | 119 ++++++++++++++++++ 4 files changed, 234 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index ba79243e98..a6db12c61a 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -428,15 +428,14 @@ function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] { if (!query || !children) return children === node.children ? node : { ...node, children }; const indexed = localTableSearchResults.value[node.id]; + // matchSidebarLabel compares case-insensitively internally and needs the + // ORIGINAL label (and entry name) so camelCase boundaries stay detectable. const matchingChildren = indexed === null - ? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase())) + ? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query)) : indexed - ? reuseLiveSidebarTreeNodes( - buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name.toLowerCase(), query.toLowerCase())) }), - children, - ) - : children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase())); + ? reuseLiveSidebarTreeNodes(buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name, query)) }), children) + : children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query)); return { ...node, children: matchingChildren }; }); } diff --git a/apps/desktop/src/lib/sidebar/sidebarSearch.ts b/apps/desktop/src/lib/sidebar/sidebarSearch.ts index 4b149cc5ad..be54695cf0 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearch.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearch.ts @@ -12,7 +12,12 @@ export type SidebarLabelMatcher = (label: string) => SidebarSearchMatch | null; function isWordBoundary(text: string, index: number): boolean { if (index === 0) return true; const prev = text[index - 1]; - return prev === "_" || prev === "-" || prev === "." || prev === " " || prev === "/" || prev === "\\"; + if (prev === "_" || prev === "-" || prev === "." || prev === " " || prev === "/" || prev === "\\") return true; + // camelCase transition: "camelCaseTable" reads as "camel" | "Case" | "Table". + // Extends word segmentation only — tier priority and labels without a + // lowercase-to-uppercase transition are unchanged. + const curr = text[index]; + return prev >= "a" && prev <= "z" && curr >= "A" && curr <= "Z"; } const SEPARATOR_RE = /[_\-. /\\]/g; @@ -27,17 +32,23 @@ function stripSeparators(text: string): string { return text.replace(SEPARATOR_RE, ""); } +// Boundary-aware helpers take the ORIGINAL label so `isWordBoundary` can see +// camelCase transitions, and a LOWER-CASED query. `toLowerCase` preserves +// string length, so label/query indices stay aligned. + function matchesWordPrefix(text: string, query: string): boolean { + const lowerText = text.toLowerCase(); for (let i = 0; i < text.length; i++) { - if (isWordBoundary(text, i) && text.startsWith(query, i)) return true; + if (isWordBoundary(text, i) && lowerText.startsWith(query, i)) return true; } return false; } function matchesAbbreviation(text: string, query: string): boolean { + const lowerText = text.toLowerCase(); let j = 0; for (let i = 0; i < text.length && j < query.length; i++) { - if (isWordBoundary(text, i) && text[i] === query[j]) j++; + if (isWordBoundary(text, i) && lowerText[i] === query[j]) j++; } return j === query.length; } @@ -45,35 +56,114 @@ function matchesAbbreviation(text: string, query: string): boolean { function matchesSubsequence(text: string, query: string): boolean { if (query.length < 2 || query.length > text.length) return false; + const lowerText = text.toLowerCase(); let j = 0; for (let i = 0; i < text.length && j < query.length; i++) { if (isWordBoundary(text, i) && i > 0) { j = 0; } - if (text[i] === query[j]) j++; + if (lowerText[i] === query[j]) j++; } return j === query.length; } +const MIN_CONCAT_PREFIX_LEN = 2; + +/** + * Split an identifier into words for word-segment prefix concatenation. + * Splits on the same separators as `isWordBoundary`/`stripSeparators` and on + * camelCase transitions ("camelCaseTable" -> "camel", "Case", "Table"). + * Original case is preserved so transitions stay detectable. + */ +function splitLabelWords(label: string): string[] { + const words: string[] = []; + for (const segment of label.split(SEPARATOR_RE)) { + if (!segment) continue; + let start = 0; + for (let index = 1; index < segment.length; index++) { + const prev = segment[index - 1]; + const curr = segment[index]; + if (prev >= "a" && prev <= "z" && curr >= "A" && curr <= "Z") { + words.push(segment.slice(start, index)); + start = index; + } + } + words.push(segment.slice(start)); + } + return words; +} + +/** + * Word-segment prefix concatenation (DataGrip-style). A query matches when it + * can be split into k >= 2 pieces, each length >= 2 and a prefix of a distinct + * label word consumed in order (words may be skipped). This is the tier that + * makes "exclog" match "system_exception_log" ("exc" from exception + "log") + * and "syslog" match it (skipping the middle word). The min-2-chars-per-piece + * rule keeps loose cross-word queries ("roles" on "sys_role_data_scope", + * "urf" on "user_profile") from matching. Matching is case-insensitive so the + * camelCase words found by `splitLabelWords` align with lowercase queries. + */ +function matchesWordPrefixConcatenation(label: string, query: string): boolean { + const words = splitLabelWords(label); + if (words.length < 2 || query.length < 2 * MIN_CONCAT_PREFIX_LEN) return false; + + const lowerQuery = query.toLowerCase(); + const queryLength = query.length; + const INF = Infinity; + // dp[j] = fewest words needed to consume lowerQuery[0..j). + let dp = Array.from({ length: queryLength + 1 }, () => INF); + dp[0] = 0; + for (const word of words) { + const next = [...dp]; + const lowerWord = word.toLowerCase(); + for (let j = 0; j < queryLength; j++) { + if (dp[j] === INF) continue; + const maxLen = Math.min(word.length, queryLength - j); + for (let len = MIN_CONCAT_PREFIX_LEN; len <= maxLen; len++) { + if (lowerWord.startsWith(lowerQuery.slice(j, j + len))) { + next[j + len] = Math.min(next[j + len], dp[j] + 1); + } + } + } + dp = next; + } + return dp[queryLength] !== INF && dp[queryLength] >= 2; +} + function matchSidebarLabelWithRegex(label: string, query: string, regex: RegExp | null): SidebarSearchMatch | null { if (!query) return null; if (regex) return regex.test(label) ? { kind: "regex", score: 95 } : null; - if (label === query) return { kind: "exact", score: 100 }; - if (label.startsWith(query)) return { kind: "prefix", score: 90 }; - if (matchesWordPrefix(label, query)) return { kind: "word-prefix", score: 80 }; - if (label.includes(query)) return { kind: "substring", score: 70 }; - if (query.length >= 2 && matchesAbbreviation(label, query)) return { kind: "abbreviation", score: 60 }; - if (matchesSubsequence(label, query)) return { kind: "fuzzy", score: 40 }; + // Case-insensitive comparison is centralized here so callers can pass the + // original label and query. The original label is what the boundary-aware + // tiers below need to detect camelCase transitions ("camelCaseTable" reads + // as "camel" | "Case" | "Table"). + const lowerLabel = label.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + if (lowerLabel === lowerQuery) return { kind: "exact", score: 100 }; + if (lowerLabel.startsWith(lowerQuery)) return { kind: "prefix", score: 90 }; + if (matchesWordPrefix(label, lowerQuery)) return { kind: "word-prefix", score: 80 }; + if (lowerLabel.includes(lowerQuery)) return { kind: "substring", score: 70 }; + if (lowerQuery.length >= 2 && matchesAbbreviation(label, lowerQuery)) return { kind: "abbreviation", score: 60 }; + if (matchesSubsequence(label, lowerQuery)) return { kind: "fuzzy", score: 40 }; // Separator-blind matching: strip underscores, hyphens, dots, spaces, // and slashes, then try again. This lets "delo" match "del_order" // without typing the underscore separator between prefix and name. - const stripped = stripSeparators(label); - if (stripped !== label && stripped.length >= query.length) { - if (stripped.startsWith(query)) return { kind: "word-prefix", score: 65 }; - if (stripped.includes(query)) return { kind: "substring", score: 55 }; + const stripped = stripSeparators(lowerLabel); + if (stripped !== lowerLabel && stripped.length >= lowerQuery.length) { + if (stripped.startsWith(lowerQuery)) return { kind: "word-prefix", score: 65 }; + if (stripped.includes(lowerQuery)) return { kind: "substring", score: 55 }; + } + + // Word-segment prefix concatenation: lets "exclog" find + // "system_exception_log". Uses the ORIGINAL label so camelCase boundaries + // survive for word segmentation, and compares case-insensitively. Flat 50 + // keeps it below every existing tier — purely additive. + if (matchesWordPrefixConcatenation(label, query)) { + return { kind: "abbreviation", score: 50 }; } return null; diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts index 4fdd825cd7..e1d3d1bd85 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts @@ -3,23 +3,20 @@ import { createSidebarLabelMatcher, type SidebarLabelMatcher } from "@/lib/sideb const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]); -const normalizedLabelCache = new WeakMap(); - function bestMatch(matchLabel: SidebarLabelMatcher, label: string, comment?: string | null) { const lm = matchLabel(label); if (!comment) return lm; - const cm = matchLabel(comment.toLowerCase()); + const cm = matchLabel(comment); if (lm && cm) return lm.score >= cm.score ? lm : cm; return lm ?? cm; } function normalizedLabel(node: TreeNode): string { - const cached = normalizedLabelCache.get(node); - if (cached?.label === node.label) return cached.normalized; - - const normalized = node.label.toLowerCase(); - normalizedLabelCache.set(node, { label: node.label, normalized }); - return normalized; + // 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 node.label; } export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet, searchableNodeTypes?: ReadonlySet): TreeNode[] { diff --git a/packages/app-tests/sidebarSearch.test.ts b/packages/app-tests/sidebarSearch.test.ts index e7aa1d7fd0..458d483176 100644 --- a/packages/app-tests/sidebarSearch.test.ts +++ b/packages/app-tests/sidebarSearch.test.ts @@ -1,6 +1,8 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { matchSidebarLabel } from "../../apps/desktop/src/lib/sidebar/sidebarSearch.ts"; +import { filterSidebarTree } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts"; +import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; test("matches exact and prefix labels first", () => { assert.equal(matchSidebarLabel("orders", "orders")?.kind, "exact"); @@ -15,6 +17,10 @@ test("matches word prefixes in underscored and dotted identifiers", () => { test("matches DataGrip-style abbreviations by identifier word boundaries", () => { assert.equal(matchSidebarLabel("additional_country", "ac")?.kind, "abbreviation"); assert.equal(matchSidebarLabel("sales.customer_profile", "scp")?.kind, "abbreviation"); + // Existing behavior: s/system + e/exception + l/log. + const sel = matchSidebarLabel("system_exception_log", "sel"); + assert.equal(sel?.kind, "abbreviation"); + assert.equal(sel?.score, 60); }); test("keeps one-character fuzzy matches disabled", () => { @@ -385,3 +391,116 @@ test("matches slash-delimited regular expression queries case-insensitively by d test("keeps invalid regular expression queries from matching every label", () => { assert.equal(matchSidebarLabel("orders", "/["), null); }); + +// Cross-word prefix concatenation (issue #5407) + +test("matches cross-word prefix concatenation (issue #5407)", () => { + const r1 = matchSidebarLabel("system_exception_log", "exclog"); + assert.equal(r1?.kind, "abbreviation"); + assert.equal(r1?.score, 50); + const r2 = matchSidebarLabel("system_exception_log", "syslog"); + assert.equal(r2?.kind, "abbreviation"); + assert.equal(r2?.score, 50); + const r3 = matchSidebarLabel("system_exception_log", "exlog"); + assert.equal(r3?.kind, "abbreviation"); + assert.equal(r3?.score, 50); + const r4 = matchSidebarLabel("customer_order_detail", "custdet"); + assert.equal(r4?.kind, "abbreviation"); + assert.equal(r4?.score, 50); + const r5 = matchSidebarLabel("order_payment_record", "payrec"); + assert.equal(r5?.kind, "abbreviation"); + assert.equal(r5?.score, 50); +}); + +test("rejects loose cross-word queries (issue #5407 negatives)", () => { + assert.equal(matchSidebarLabel("system_exception_log", "slog"), null); + assert.equal(matchSidebarLabel("system_exception_log", "selog"), null); + assert.equal(matchSidebarLabel("sys_role_data_scope", "roles"), null); + assert.equal(matchSidebarLabel("user_profile", "urf"), null); + assert.equal(matchSidebarLabel("t_json", "tson"), null); +}); + +test("tokenizes camelCase identifiers for word-boundary matching", () => { + const r1 = matchSidebarLabel("camelCaseTable", "caseTab"); + assert.equal(r1?.kind, "word-prefix"); + assert.equal(r1?.score, 80); + const r2 = matchSidebarLabel("camelCaseTable", "camTab"); + assert.equal(r2?.kind, "abbreviation"); + assert.equal(r2?.score, 50); + const r3 = matchSidebarLabel("camelCaseTable", "cct"); + assert.equal(r3?.kind, "abbreviation"); + assert.equal(r3?.score, 60); + assert.equal(matchSidebarLabel("camelCaseTable", "aseTb"), null); +}); + +test("camelCase tokenization survives the real tree-search path (issue #5407)", () => { + // filterSidebarTree must preserve the original label. Lowercasing + // camelCaseTable first still finds camTab as fuzzy/40, so score ordering + // against an existing fuzzy/40 candidate makes the boundary observable. + const tree: TreeNode[] = [ + { + id: "conn-1", + label: "My Connection", + type: "connection", + connectionId: "conn-1", + isExpanded: true, + children: [ + { + id: "db-1", + label: "app_db", + type: "database", + connectionId: "conn-1", + database: "app_db", + isExpanded: true, + children: [ + { + id: "tbl-fuzzy", + label: "camxxtab", + type: "table", + connectionId: "conn-1", + database: "app_db", + schema: "public", + }, + { + id: "tbl-camel", + label: "camelCaseTable", + type: "table", + connectionId: "conn-1", + database: "app_db", + schema: "public", + }, + ], + }, + ], + }, + ]; + + const findTable = (nodes: TreeNode[]): TreeNode | undefined => { + for (const node of nodes) { + if (node.type === "table") return node; + const found = node.children ? findTable(node.children) : undefined; + if (found) return found; + } + return undefined; + }; + + // 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.equal(findTable(filterSidebarTree(tree, "caseTab", new Set()))?.label, "camelCaseTable"); + assert.equal(findTable(filterSidebarTree(tree, "aseTb", new Set())), undefined); + + const mixedCaseTree: TreeNode[] = [ + { + id: "tbl-2", + label: "UserOrderDetail", + type: "table", + connectionId: "conn-1", + database: "app_db", + schema: "public", + }, + ]; + assert.equal(findTable(filterSidebarTree(mixedCaseTree, "userorderdetail", new Set()))?.label, "UserOrderDetail"); + assert.equal(findTable(filterSidebarTree(mixedCaseTree, "uod", new Set()))?.label, "UserOrderDetail"); +});