fix(sidebar): match cross-word table names via prefix concatenation
This commit is contained in:
1 parent
fd3f9b169c
commit
13878a1a30
4 files changed
+234
-29
No files matched your search
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TreeNode, { label: string; normalized: string }>();
|
||||
|
||||
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<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>): TreeNode[] {
|
||||
|
||||
Reference in new issue
Block a user