feat(completion): support pinyin matching for Chinese identifiers
This commit is contained in:
1 parent
eafa98b771
commit
f700627675
12 files changed
+438
-10
No files matched your search
@@ -11,6 +11,7 @@ import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
|
||||
import DelimitedListDialog from "./DelimitedListDialog.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { completionMatchRanges } from "@/lib/common/completionMatch";
|
||||
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
@@ -2506,16 +2507,41 @@ function consumeSqlCompletionAutoStartSuppression() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) {
|
||||
function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp, prefix?: string) {
|
||||
if (items.length === 0) return null;
|
||||
const bypassFilter = !!prefix && shouldBypassCompletionFilter(prefix, items);
|
||||
const resultItems = bypassFilter && prefix ? completionItemsForBypassedFilter(prefix, items) : items;
|
||||
return {
|
||||
from,
|
||||
// Keep CodeMirror's live filtering enabled so an already-open menu follows the typed prefix.
|
||||
options: items.map((item) => completionOptionForItem(item)),
|
||||
options: resultItems.map((item) => completionOptionForItem(item)),
|
||||
validFor,
|
||||
// When bypassing CodeMirror's matcher, supply our own highlight ranges so
|
||||
// the matched characters (Han substring or pinyin initials) stay marked.
|
||||
// Ranges must target the rendered text, which is displayLabel when set.
|
||||
...(bypassFilter && prefix ? { filter: false as const, getMatch: (option: { label: string; displayLabel?: string }) => completionMatchRanges(option.displayLabel ?? option.label, prefix) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// CodeMirror's built-in matcher only matches single-character queries against
|
||||
// the label start, and cannot match pinyin initials against Han labels. Our
|
||||
// provider already filters and ranks items itself (substring + pinyin), so
|
||||
// skip the second-stage filter exactly in the cases it would break.
|
||||
function shouldBypassCompletionFilter(prefix: string, items: QueryCompletionItem[]): boolean {
|
||||
if (!prefix) return false;
|
||||
if (/\p{Script=Han}/u.test(prefix)) return true;
|
||||
return /^[a-z0-9]+$/i.test(prefix) && items.some((item) => /\p{Script=Han}/u.test(item.label));
|
||||
}
|
||||
|
||||
function completionItemsForBypassedFilter(prefix: string, items: QueryCompletionItem[]): QueryCompletionItem[] {
|
||||
if ([...prefix].length !== 1 || !/^[a-z0-9]$/i.test(prefix)) return items;
|
||||
const normalized = prefix.toLowerCase();
|
||||
return items.filter((item) => {
|
||||
const renderedLabel = "displayLabel" in item && typeof item.displayLabel === "string" ? item.displayLabel : item.label;
|
||||
return /\p{Script=Han}/u.test(item.label) || renderedLabel.toLowerCase().startsWith(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
function localCompletionDatabaseNames(completionContext: ReturnType<typeof getSqlCompletionContext>): string[] {
|
||||
if (!supportsDatabaseNameCompletion(props.databaseType) || !completionContext.suggestTables || completionContext.insertTable || !props.connectionId) return [];
|
||||
return connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES);
|
||||
@@ -2761,7 +2787,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
supportsSessionDatabaseSwitch: sqlServerContext.supports_session_database_switch,
|
||||
});
|
||||
const items = buildSqlServerUseDatabaseCompletionItems(databaseNames, useDatabaseCompletion);
|
||||
return buildCompletionResult(items, useDatabaseCompletion.from);
|
||||
return buildCompletionResult(items, useDatabaseCompletion.from, undefined, useDatabaseCompletion.prefix);
|
||||
}
|
||||
|
||||
const legacyCompletionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
|
||||
@@ -2783,7 +2809,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
functionCase: settingsStore.editorSettings.sqlFormatter.functionCase,
|
||||
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
|
||||
});
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
|
||||
}
|
||||
|
||||
const useDatabase = props.databaseType === "sqlserver" ? sqlServerUseDatabaseBeforeCursor(fullDoc, position) : undefined;
|
||||
@@ -2843,7 +2869,7 @@ async function provideSqlCompletions(context: CompletionContext) {
|
||||
functionCase: settingsStore.editorSettings.sqlFormatter.functionCase,
|
||||
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
|
||||
});
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
|
||||
}
|
||||
|
||||
const tableNameCompletion = isTableNameCompletionContext(completionContext);
|
||||
@@ -3060,7 +3086,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
|
||||
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
|
||||
});
|
||||
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
|
||||
}
|
||||
|
||||
function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number, scope: CompletionMetadataScope) {
|
||||
@@ -3520,7 +3546,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
|
||||
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
|
||||
});
|
||||
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position));
|
||||
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
|
||||
}
|
||||
|
||||
function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
|
||||
|
||||
@@ -338,3 +338,44 @@ describe("DataGridConditionEditor quote completion", () => {
|
||||
expect(expandedPaneCss).not.toContain("height 150ms");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridConditionEditor Chinese column matching", () => {
|
||||
function mountChineseColumns() {
|
||||
return mountEditor("where", "", { columns: ["总租金", "租赁日期", "amount"] });
|
||||
}
|
||||
|
||||
async function typeToken(input: HTMLTextAreaElement, token: string) {
|
||||
input.focus();
|
||||
input.value = token;
|
||||
input.setSelectionRange(token.length, token.length);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
it("matches a single Han character anywhere in the column name", async () => {
|
||||
const { input } = mountChineseColumns();
|
||||
await typeToken(input, "金");
|
||||
|
||||
await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1));
|
||||
expect(document.querySelector('[role="option"]')?.textContent).toContain("总租金");
|
||||
});
|
||||
|
||||
it("matches pinyin initials and initials subsequences", async () => {
|
||||
const { input } = mountChineseColumns();
|
||||
await typeToken(input, "zzj");
|
||||
|
||||
await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1));
|
||||
expect(document.querySelector('[role="option"]')?.textContent).toContain("总租金");
|
||||
|
||||
await typeToken(input, "zj");
|
||||
await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1));
|
||||
expect(document.querySelector('[role="option"]')?.textContent).toContain("总租金");
|
||||
});
|
||||
|
||||
it("still matches plain English columns", async () => {
|
||||
const { input } = mountChineseColumns();
|
||||
await typeToken(input, "am");
|
||||
|
||||
await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1));
|
||||
expect(document.querySelector('[role="option"]')?.textContent).toContain("amount");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
|
||||
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
import { pinyinAwareMatchScore } from "@/lib/common/pinyin";
|
||||
|
||||
export type DataGridConditionSuggestionKind = "column" | "keyword" | "history";
|
||||
|
||||
@@ -232,15 +233,23 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
|
||||
const seen = new Set<string>();
|
||||
const suggestions: DataGridConditionSuggestion[] = [];
|
||||
if (role === "field") {
|
||||
const scored: Array<{ suggestion: DataGridConditionSuggestion; score: number; index: number }> = [];
|
||||
let index = 0;
|
||||
for (const column of toValue(options.columns) ?? []) {
|
||||
const columnValue = typeof column === "string" ? column : column.name;
|
||||
const normalizedValue = columnValue.toLowerCase();
|
||||
if ((normalizedToken && (!normalizedValue.startsWith(normalizedToken) || normalizedValue === normalizedToken)) || seen.has(columnValue)) continue;
|
||||
if ((normalizedToken && normalizedValue === normalizedToken) || seen.has(columnValue)) continue;
|
||||
seen.add(columnValue);
|
||||
// Substring + pinyin-initials matching (金 / zzj / zj → 总租金), scored so
|
||||
// prefix matches rank above looser ones and ties keep the column order.
|
||||
const score = normalizedToken ? pinyinAwareMatchScore(columnValue, normalizedToken) : 0;
|
||||
if (score < 0) continue;
|
||||
const comment = normalizedColumnComment(column);
|
||||
const insertText = target.quotedIdentifier ? columnValue : typeof column === "string" ? columnValue : column.insertText;
|
||||
suggestions.push({ value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) });
|
||||
scored.push({ suggestion: { value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) }, score, index: index++ });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
return scored.map((entry) => entry.suggestion);
|
||||
} else {
|
||||
for (const keyword of WHERE_CONNECTOR_KEYWORDS) {
|
||||
if (!keyword.toLowerCase().startsWith(normalizedToken) || keyword.toLowerCase() === normalizedToken) continue;
|
||||
|
||||
@@ -101,6 +101,32 @@ describe("QueryEditor completion Tab keymap", () => {
|
||||
expect(result).not.toHaveProperty("filter");
|
||||
});
|
||||
|
||||
it("keeps ASCII single-character filtering while allowing Han pinyin matches", () => {
|
||||
const source = [extractFunction("completionItemsForBypassedFilter"), extractFunction("shouldBypassCompletionFilter"), extractFunction("buildCompletionResult")].join("\n");
|
||||
const javascript = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const buildCompletionResult = new Function("completionOptionForItem", "completionMatchRanges", `${javascript}\nreturn buildCompletionResult;`)(
|
||||
(item: { label: string }) => item,
|
||||
() => [],
|
||||
) as (items: Array<{ label: string }>, from: number, validFor?: RegExp, prefix?: string) => { filter?: boolean; options: Array<{ label: string }> } | null;
|
||||
|
||||
const result = buildCompletionResult([{ label: "amount" }, { label: "memo" }, { label: "明细" }], 0, undefined, "m");
|
||||
|
||||
expect(result?.filter).toBe(false);
|
||||
expect(result?.options.map((item) => item.label)).toEqual(["memo", "明细"]);
|
||||
});
|
||||
|
||||
it("bypasses CodeMirror filtering for Han substring matches", () => {
|
||||
const javascript = ts.transpileModule(extractFunction("shouldBypassCompletionFilter"), {
|
||||
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const shouldBypassCompletionFilter = new Function(`${javascript}\nreturn shouldBypassCompletionFilter;`)() as (prefix: string, items: Array<{ label: string }>) => boolean;
|
||||
|
||||
expect(shouldBypassCompletionFilter("金", [{ label: "总租金" }])).toBe(true);
|
||||
expect(shouldBypassCompletionFilter("m", [{ label: "amount" }, { label: "memo" }])).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps normal Tab indentation when completion is inactive", () => {
|
||||
const harness = createHarness({ completionStatus: () => null });
|
||||
const view = createView();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { containsHan, matchesPinyinInitials, orderedSubsequenceSpan, pinyinAwareMatchScore, pinyinFirstLetters } from "@/lib/common/pinyin";
|
||||
import { completionMatchRanges } from "@/lib/common/completionMatch";
|
||||
import { buildSqlCompletionItems } from "@/lib/sql/sqlCompletion";
|
||||
|
||||
describe("pinyinAwareMatchScore", () => {
|
||||
it("ranks prefix above pinyin prefix above substring above pinyin subsequence", () => {
|
||||
const prefix = pinyinAwareMatchScore("总租金", "总");
|
||||
const pinyinPrefix = pinyinAwareMatchScore("总租金", "zz");
|
||||
const substring = pinyinAwareMatchScore("总租金", "金");
|
||||
const subsequence = pinyinAwareMatchScore("总租金", "zj");
|
||||
expect(prefix).toBeGreaterThan(pinyinPrefix);
|
||||
expect(pinyinPrefix).toBeGreaterThan(substring);
|
||||
expect(substring).toBeGreaterThan(subsequence);
|
||||
expect(subsequence).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns -1 when nothing matches", () => {
|
||||
expect(pinyinAwareMatchScore("总租金", "jz")).toBe(-1);
|
||||
expect(pinyinAwareMatchScore("amount", "zz")).toBe(-1);
|
||||
expect(pinyinAwareMatchScore("amount", "")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordered subsequence span", () => {
|
||||
it("matches initials subsequences", () => {
|
||||
expect(orderedSubsequenceSpan("zzj", "zj")).toEqual({ first: 0, span: 3 });
|
||||
expect(orderedSubsequenceSpan("zzj", "j")).toEqual({ first: 2, span: 1 });
|
||||
expect(orderedSubsequenceSpan("zzj", "jz")).toBeNull();
|
||||
expect(orderedSubsequenceSpan("zzj", "")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completion match highlight ranges", () => {
|
||||
it("highlights a Han substring match", () => {
|
||||
expect(completionMatchRanges("总租金", "金")).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("highlights every Han character matched by pinyin initials", () => {
|
||||
expect(completionMatchRanges("总租金", "zzj")).toEqual([0, 1, 1, 2, 2, 3]);
|
||||
expect(completionMatchRanges("总租金", "zz")).toEqual([0, 1, 1, 2]);
|
||||
});
|
||||
|
||||
it("highlights only the Han characters hit by an initials subsequence", () => {
|
||||
expect(completionMatchRanges("总租金", "zj")).toEqual([0, 1, 2, 3]);
|
||||
expect(completionMatchRanges("总租金", "j")).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("highlights ASCII substrings and falls back to in-order fuzzy", () => {
|
||||
expect(completionMatchRanges("amount", "am")).toEqual([0, 2]);
|
||||
expect(completionMatchRanges("order_detail", "od")).toEqual([0, 1, 2, 3]);
|
||||
expect(completionMatchRanges("amount", "zz")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinyin first letters", () => {
|
||||
it("maps Han characters to their pinyin initials", () => {
|
||||
expect(pinyinFirstLetters("总租金")).toBe("zzj");
|
||||
expect(pinyinFirstLetters("租赁日期")).toBe("zlrq");
|
||||
});
|
||||
|
||||
it("keeps ASCII alphanumerics and drops other characters", () => {
|
||||
expect(pinyinFirstLetters("order明细")).toBe("ordermx");
|
||||
expect(pinyinFirstLetters("Amount")).toBe("amount");
|
||||
});
|
||||
|
||||
it("matches only ASCII queries against Han candidates", () => {
|
||||
expect(matchesPinyinInitials("总租金", "zzj")).toBe(true);
|
||||
expect(matchesPinyinInitials("总租金", "zz")).toBe(true);
|
||||
expect(matchesPinyinInitials("总租金", "zj")).toBe(false);
|
||||
expect(matchesPinyinInitials("总租金", "租金")).toBe(false);
|
||||
expect(matchesPinyinInitials("amount", "am")).toBe(false);
|
||||
expect(containsHan("amount")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sqlCompletion Chinese column matching", () => {
|
||||
function completionItems(typedPrefix: string) {
|
||||
const sql = `SELECT * FROM orders WHERE ${typedPrefix}`;
|
||||
return buildSqlCompletionItems(sql, sql.length, {
|
||||
databaseType: "mysql",
|
||||
tables: [{ name: "orders", type: "table" }],
|
||||
columnsByTable: new Map([
|
||||
[
|
||||
"orders",
|
||||
[
|
||||
{ name: "总租金", table: "orders" },
|
||||
{ name: "租赁日期", table: "orders" },
|
||||
{ name: "amount", table: "orders" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
it("matches a single Han character anywhere in the column name", () => {
|
||||
const items = completionItems("金");
|
||||
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })]));
|
||||
});
|
||||
|
||||
it("matches pinyin initials like DataGrip", () => {
|
||||
const items = completionItems("zzj");
|
||||
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })]));
|
||||
expect(items.some((item) => item.label === "租赁日期")).toBe(false);
|
||||
expect(items.some((item) => item.label === "amount")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches a single ASCII letter via pinyin initials", () => {
|
||||
const items = completionItems("z");
|
||||
const labels = items.filter((item) => item.type === "column").map((item) => item.label);
|
||||
expect(labels).toEqual(expect.arrayContaining(["总租金", "租赁日期"]));
|
||||
});
|
||||
|
||||
it("matches initials subsequences like DataGrip", () => {
|
||||
const subsequenceItems = completionItems("zj");
|
||||
expect(subsequenceItems).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })]));
|
||||
expect(subsequenceItems.some((item) => item.label === "租赁日期")).toBe(false);
|
||||
|
||||
const lastInitialItems = completionItems("j");
|
||||
expect(lastInitialItems).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })]));
|
||||
expect(lastInitialItems.some((item) => item.label === "租赁日期")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps plain English matching unchanged", () => {
|
||||
const items = completionItems("am");
|
||||
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "amount", type: "column" })]));
|
||||
expect(items.some((item) => item.label === "总租金")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { pinyin } from "pinyin-pro";
|
||||
|
||||
const HAN_CHAR = /\p{Script=Han}/u;
|
||||
const ASCII_ALNUM = /[a-z0-9]/i;
|
||||
const ASCII_QUERY = /^[a-z0-9]+$/;
|
||||
|
||||
/**
|
||||
* Highlight ranges (flat [from, to, ...] UTF-16 offsets into `label`) for a
|
||||
* completion query, used when CodeMirror's built-in filter is bypassed.
|
||||
*
|
||||
* Tiers: case-insensitive substring → pinyin initials ("zzj" highlights each
|
||||
* Han character of 总租金) → in-order fuzzy. Returns [] when nothing matches.
|
||||
*/
|
||||
export function completionMatchRanges(label: string, query: string): number[] {
|
||||
if (!query) return [];
|
||||
const lowerLabel = label.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const direct = lowerLabel.indexOf(lowerQuery);
|
||||
if (direct >= 0) return [direct, direct + lowerQuery.length];
|
||||
|
||||
if (ASCII_QUERY.test(lowerQuery) && HAN_CHAR.test(label)) {
|
||||
const pinyinRanges = pinyinInitialMatchRanges(label, lowerQuery);
|
||||
if (pinyinRanges) return pinyinRanges;
|
||||
}
|
||||
|
||||
return fuzzyMatchRanges(lowerLabel, lowerQuery);
|
||||
}
|
||||
|
||||
function pinyinInitialMatchRanges(label: string, query: string): number[] | null {
|
||||
// Greedy earliest subsequence over the pinyin-initial positions, so both
|
||||
// prefix queries ("zz") and skipping queries ("zj") highlight the matched
|
||||
// Han characters.
|
||||
const ranges: number[] = [];
|
||||
let queryIndex = 0;
|
||||
let index = 0;
|
||||
for (const char of label) {
|
||||
if (queryIndex >= query.length) break;
|
||||
const isHan = HAN_CHAR.test(char);
|
||||
if (isHan || ASCII_ALNUM.test(char)) {
|
||||
const initial = isHan ? pinyin(char, { pattern: "first", toneType: "none" }) : char.toLowerCase();
|
||||
if (initial === query[queryIndex]) {
|
||||
ranges.push(index, index + char.length);
|
||||
queryIndex += 1;
|
||||
}
|
||||
}
|
||||
index += char.length;
|
||||
}
|
||||
return queryIndex === query.length ? ranges : null;
|
||||
}
|
||||
|
||||
function fuzzyMatchRanges(lowerLabel: string, lowerQuery: string): number[] {
|
||||
const ranges: number[] = [];
|
||||
let from = 0;
|
||||
for (const char of lowerQuery) {
|
||||
const position = lowerLabel.indexOf(char, from);
|
||||
if (position < 0) return [];
|
||||
ranges.push(position, position + char.length);
|
||||
from = position + char.length;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { pinyin } from "pinyin-pro";
|
||||
|
||||
const HAN_CHAR = /\p{Script=Han}/u;
|
||||
const ASCII_ALNUM = /[a-z0-9]/i;
|
||||
|
||||
const firstLetterCache = new Map<string, string>();
|
||||
const MAX_CACHE_ENTRIES = 100_000;
|
||||
|
||||
function cacheFirstLetters(text: string, result: string): void {
|
||||
if (firstLetterCache.size >= MAX_CACHE_ENTRIES) {
|
||||
const oldest = firstLetterCache.keys().next().value;
|
||||
if (oldest !== undefined) firstLetterCache.delete(oldest);
|
||||
}
|
||||
firstLetterCache.set(text, result);
|
||||
}
|
||||
|
||||
export function containsHan(text: string): boolean {
|
||||
return HAN_CHAR.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* First pinyin letter of every Han character, with ASCII letters/digits kept
|
||||
* as-is (lowercased) and every other character dropped. Used for DataGrip-style
|
||||
* initials matching, e.g. pinyinFirstLetters("总租金") === "zzj".
|
||||
*
|
||||
* Only the default reading is used for polyphonic characters.
|
||||
*/
|
||||
export function pinyinFirstLetters(text: string): string {
|
||||
const cached = firstLetterCache.get(text);
|
||||
if (cached !== undefined) {
|
||||
firstLetterCache.delete(text);
|
||||
firstLetterCache.set(text, cached);
|
||||
return cached;
|
||||
}
|
||||
let result = "";
|
||||
for (const char of text) {
|
||||
if (HAN_CHAR.test(char)) {
|
||||
result += pinyin(char, { pattern: "first", toneType: "none" });
|
||||
} else if (ASCII_ALNUM.test(char)) {
|
||||
result += char.toLowerCase();
|
||||
}
|
||||
}
|
||||
cacheFirstLetters(text, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** True when an ASCII-letters/digits query can match `candidate` via pinyin initials. */
|
||||
export function matchesPinyinInitials(candidate: string, query: string): boolean {
|
||||
if (!/^[a-z0-9]+$/.test(query) || !containsHan(candidate)) return false;
|
||||
return pinyinFirstLetters(candidate).startsWith(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered-subsequence match of `query` against `text` (e.g. "zj" against the
|
||||
* initials "zzj"). Returns the first matched index and the span covering all
|
||||
* matched characters, or null when the query is not a subsequence.
|
||||
*/
|
||||
export function orderedSubsequenceSpan(text: string, query: string): { first: number; span: number } | null {
|
||||
if (!query) return null;
|
||||
let from = 0;
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
for (const char of query) {
|
||||
const position = text.indexOf(char, from);
|
||||
if (position < 0) return null;
|
||||
if (first < 0) first = position;
|
||||
last = position;
|
||||
from = position + 1;
|
||||
}
|
||||
return { first, span: last - first + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic matcher for small pick-lists (grid condition editor, ...):
|
||||
* prefix > pinyin-initials prefix > substring > pinyin-initials subsequence.
|
||||
* Returns -1 for no match; scores are per-tier constants so same-tier
|
||||
* candidates keep their original order. An empty query matches everything.
|
||||
*/
|
||||
export function pinyinAwareMatchScore(candidate: string, query: string): number {
|
||||
const text = candidate.toLowerCase();
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return 0;
|
||||
if (text.startsWith(normalized)) return 400;
|
||||
const asciiQuery = /^[a-z0-9]+$/.test(normalized);
|
||||
const han = containsHan(text);
|
||||
if (asciiQuery && han && pinyinFirstLetters(text).startsWith(normalized)) return 300;
|
||||
if (text.includes(normalized)) return 200;
|
||||
if (asciiQuery && han) {
|
||||
const subsequence = orderedSubsequenceSpan(pinyinFirstLetters(text), normalized);
|
||||
if (subsequence) return 100;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/seman
|
||||
import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types";
|
||||
import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
|
||||
import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier";
|
||||
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
|
||||
|
||||
export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
|
||||
|
||||
@@ -4289,6 +4290,8 @@ function matchesPrefix(candidate: string, prefix: string): boolean {
|
||||
* Scoring tiers:
|
||||
* Exact match: 3000 - len
|
||||
* Initials match: 2400 + exactInitialsBonus - len
|
||||
* Pinyin initials: 2300 + exactInitialsBonus - len (ASCII query vs Han candidate, e.g. "zzj" → 总租金)
|
||||
* Pinyin subsequence: 1600 - penalties - len (ordered initials, e.g. "zj" → 总租金)
|
||||
* Prefix match: 2000 - len
|
||||
* Substring: 900 + boundaryBonus - len
|
||||
* Tight fuzzy: 1500 - gapPenalty + earlyMatchBonus - len (gaps < prefix length)
|
||||
@@ -4311,6 +4314,21 @@ function computeMatchScore(candidate: string, prefix: string): number {
|
||||
return 2400 + exactInitialsBonus - c.length;
|
||||
}
|
||||
|
||||
// DataGrip-style pinyin initials: an ASCII query matches the first pinyin
|
||||
// letters of Han characters — as a prefix ("zz" → 总租金) or as an ordered
|
||||
// subsequence ("zj" → 总租金, "j" → 总租金).
|
||||
if (/^[a-z0-9]+$/.test(p) && containsHan(c)) {
|
||||
const pinyinInitials = pinyinFirstLetters(c);
|
||||
if (pinyinInitials.startsWith(p)) {
|
||||
const exactInitialsBonus = pinyinInitials === p ? 300 : 0;
|
||||
return 2300 + exactInitialsBonus - c.length;
|
||||
}
|
||||
const subsequence = orderedSubsequenceSpan(pinyinInitials, p);
|
||||
if (subsequence) {
|
||||
return 1600 - subsequence.first * 30 - (subsequence.span - p.length) * 10 - c.length;
|
||||
}
|
||||
}
|
||||
|
||||
const substringIndex = c.indexOf(p);
|
||||
if (substringIndex >= 0) {
|
||||
const boundaryBonus = isIdentifierBoundary(candidate, substringIndex) ? 400 : Math.max(0, 180 - substringIndex * 12);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
|
||||
import { ref, computed, watch, markRaw } from "vue";
|
||||
import type {
|
||||
ColumnInfo,
|
||||
@@ -5649,6 +5650,15 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const acronym = completionNameAcronym(table.name);
|
||||
if (acronym === normalized) return score + 7_100 - text.length;
|
||||
if (acronym.startsWith(normalized)) return score + 6_900 - text.length;
|
||||
// DataGrip-style pinyin initials for Han names, e.g. "zzj" → 总租金,
|
||||
// including ordered subsequences like "zj" → 总租金.
|
||||
if (/^[a-z0-9]+$/.test(normalized) && containsHan(text)) {
|
||||
const pinyinInitials = pinyinFirstLetters(text);
|
||||
if (pinyinInitials === normalized) return score + 7_050 - text.length;
|
||||
if (pinyinInitials.startsWith(normalized)) return score + 6_850 - text.length;
|
||||
const subsequence = orderedSubsequenceSpan(pinyinInitials, normalized);
|
||||
if (subsequence) return score + 5_000 - subsequence.first * 30 - subsequence.span * 10 - text.length;
|
||||
}
|
||||
if (normalized.length <= segments.length && segments.every((segment, index) => segment.startsWith(normalized[index] ?? ""))) return score + 6_700 - text.length;
|
||||
if (text.includes(normalized)) return score + 4_000 - text.length;
|
||||
const subsequenceScore = orderedSubsequenceScore(text, normalized);
|
||||
@@ -6119,6 +6129,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (!filter) return true;
|
||||
const text = value.toLowerCase();
|
||||
if (text.includes(filter)) return true;
|
||||
// Pinyin initials, e.g. "zzj" or "zj" matches 总租金.
|
||||
if (/^[a-z0-9]+$/.test(filter) && containsHan(text)) {
|
||||
const pinyinInitials = pinyinFirstLetters(text);
|
||||
if (pinyinInitials.startsWith(filter) || orderedSubsequenceSpan(pinyinInitials, filter)) return true;
|
||||
}
|
||||
let index = 0;
|
||||
for (const ch of filter) {
|
||||
index = text.indexOf(ch, index);
|
||||
|
||||
Reference in new issue
Block a user