fix(completion): replace partially quoted identifiers

This commit is contained in:
t8y2
2026-08-25 11:57:52 +08:00
parent e7ef9df85c
commit 9c90edabe3
4 changed files with 126 additions and 7 deletions
@@ -47,6 +47,7 @@ import {
getSqlCompletionResultValidFor,
isSqlCompletionSuppressedContext,
isSqlLikeCompletionStatement,
prepareSqlCompletionReplacement,
recordCompletionSelection,
selectStarResultColumnsMatch,
shouldAutoOpenSqlCompletion,
@@ -3327,6 +3328,11 @@ function buildCompletionResult(items: QueryCompletionItem[], from: number, valid
};
}
function buildSqlCompletionResult(items: SqlCompletionItem[], completionContext: SqlCompletionContext, fullDoc: string, position: number) {
const replacement = prepareSqlCompletionReplacement(fullDoc, position, completionContext, items);
return buildCompletionResult(replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.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
@@ -3679,7 +3685,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), completionContext.prefix);
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
const useDatabase = props.databaseType === "sqlserver" ? sqlServerUseDatabaseBeforeCursor(fullDoc, position) : undefined;
@@ -3740,7 +3746,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), completionContext.prefix);
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
const tableNameCompletion = isTableNameCompletionContext(completionContext);
@@ -4047,7 +4053,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType<typeof getS
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
});
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof getSqlCompletionContext>, fullDoc: string, position: number, scope: CompletionMetadataScope) {
@@ -4528,7 +4534,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext
autoAliasTables: settingsStore.editorSettings.autoAliasTables,
});
return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
return buildSqlCompletionResult(items, completionContext, fullDoc, position);
}
function isReferencedTableQualifier(completionContext: ReturnType<typeof getSqlCompletionContext>): boolean {
@@ -259,11 +259,13 @@ function semanticMutationTarget(model: SqlSemanticModel): SqlSemanticRowSource |
}
export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base: SqlCompletionContext): SqlCompletionContext {
if (model.cursorIntent.confidence === "low" || model.cursorIntent.kind === "suppressed") {
if (model.cursorIntent.kind === "suppressed") {
return base;
}
const replacementRange = model.cursorIntent.replacementRange;
if (model.cursorIntent.confidence === "low") return { ...base, replacementRange };
if ((base.suggestTables || base.exclusiveTableSuggestions) && model.cursorIntent.kind !== "table" && model.cursorIntent.kind !== "schema" && model.cursorIntent.kind !== "catalog" && model.cursorIntent.kind !== "delete_target") {
return base;
return { ...base, replacementRange };
}
const scope = sqlSemanticCompletionScope(model);
@@ -284,6 +286,7 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base:
return {
...base,
replacementRange,
prefix,
qualifier,
qualifierParts,
+31 -1
View File
@@ -1275,6 +1275,8 @@ export interface SqlCompletionForeignKey {
ref_column: string;
}
export type SqlCompletionClosingQuote = '"' | "'" | "`" | "]";
export interface SqlCompletionItem {
label: string;
filterText?: string;
@@ -1282,7 +1284,7 @@ export interface SqlCompletionItem {
detail?: string;
info?: string | ((completion: Completion) => CompletionInfo | Promise<CompletionInfo>);
apply?: string;
replaceClosingQuote?: '"' | "'";
replaceClosingQuote?: SqlCompletionClosingQuote;
boost: number;
exactMatch?: boolean;
dedupeKey?: string;
@@ -1332,6 +1334,7 @@ export type SqlCompletionContextKind = "table" | "schema" | "catalog" | "routine
export interface SqlCompletionContext {
prefix: string;
replacementRange?: { start: number; end: number };
qualifier?: string;
qualifierParts?: string[];
suggestTables: boolean;
@@ -1367,6 +1370,33 @@ export interface SqlCompletionContext {
dataTypeContext: boolean;
}
const SQL_COMPLETION_CLOSING_QUOTES: Readonly<Record<string, SqlCompletionClosingQuote>> = {
'"': '"',
"'": "'",
"`": "`",
"[": "]",
};
export function prepareSqlCompletionReplacement(sql: string, cursor: number, context: Pick<SqlCompletionContext, "prefix" | "qualifier" | "replacementRange">, items: SqlCompletionItem[]): { from: number; items: SqlCompletionItem[] } {
const range = context.replacementRange;
const from = range && range.start >= 0 && range.start <= cursor && range.end === cursor ? range.start : cursor - context.prefix.length;
const closingQuote = from < cursor ? SQL_COMPLETION_CLOSING_QUOTES[sql[from] ?? ""] : undefined;
if (!closingQuote) return { from, items };
const replaceClosingQuote = sql[cursor] === closingQuote ? closingQuote : undefined;
return {
from,
items: items.map((item) => {
let prepared = item;
const apply = item.apply ?? item.label;
if (item.type === "column" && !(apply.startsWith(sql[from] ?? "") && apply.endsWith(closingQuote)) && (context.qualifier || !apply.includes("."))) {
const escaped = apply.replaceAll(closingQuote, closingQuote + closingQuote);
prepared = { ...prepared, apply: `${sql[from]}${escaped}${closingQuote}` };
}
return replaceClosingQuote && !prepared.replaceClosingQuote ? { ...prepared, replaceClosingQuote } : prepared;
}),
};
}
export interface PostgresSequenceLiteralCompletionContext {
from: number;
prefix: string;
+80
View File
@@ -10,6 +10,7 @@ import {
shouldAutoOpenSqlCompletion,
extractCteDefinitions,
getSqlCompletionContext,
prepareSqlCompletionReplacement,
recordCompletionSelection,
shouldChainSqlCompletionAfterAccept,
type SqlCompletionColumn,
@@ -868,6 +869,85 @@ test("quotes mixed-case Dameng schema, table, and column identifiers", () => {
assert.equal(columnItems.find((item) => item.type === "column" && item.label === "MixedColumn")?.apply, '"MixedColumn"');
});
test("replaces partially typed quoted identifiers without duplicating quotes", () => {
const cases = [
{
label: "Dameng paired double quotes",
markedSql: 'SELECT h."Mi|" FROM SHA."MixedTable" h',
databaseType: "dameng",
dialect: "mysql",
table: "MixedTable",
schema: "SHA",
column: "MixedColumn",
expectedSql: 'SELECT h."MixedColumn" FROM SHA."MixedTable" h',
expectedClosingQuote: '"',
},
{
label: "Dameng unclosed double quote in a later statement",
markedSql: 'SELECT 1;\nSELECT h."Mi| FROM SHA."MixedTable" h',
databaseType: "dameng",
dialect: "mysql",
table: "MixedTable",
schema: "SHA",
column: "MixedColumn",
expectedSql: 'SELECT 1;\nSELECT h."MixedColumn" FROM SHA."MixedTable" h',
expectedClosingQuote: undefined,
},
{
label: "PostgreSQL paired double quotes",
markedSql: 'SELECT o."Us|" FROM public."Order Details" o',
databaseType: "postgres",
dialect: "postgres",
table: "Order Details",
schema: "public",
column: "User Name",
expectedSql: 'SELECT o."User Name" FROM public."Order Details" o',
expectedClosingQuote: '"',
},
{
label: "MySQL paired backticks",
markedSql: "SELECT o.`SE|` FROM app.orders o",
databaseType: "mysql",
dialect: "mysql",
table: "orders",
schema: "app",
column: "SELECT",
expectedSql: "SELECT o.`SELECT` FROM app.orders o",
expectedClosingQuote: "`",
},
{
label: "SQL Server paired brackets",
markedSql: "SELECT o.[se|] FROM dbo.orders o",
databaseType: "sqlserver",
dialect: "sqlserver",
table: "orders",
schema: "dbo",
column: "select",
expectedSql: "SELECT o.[select] FROM dbo.orders o",
expectedClosingQuote: "]",
},
] as const;
for (const fixture of cases) {
const cursor = fixture.markedSql.indexOf("|");
const sql = fixture.markedSql.replace("|", "");
const options = { databaseType: fixture.databaseType, dialect: fixture.dialect };
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor, options), getSqlCompletionContext(sql, cursor, options));
const items = buildSqlCompletionItemsFromContext(context, {
tables: [{ name: fixture.table, schema: fixture.schema, type: "table" }],
columnsByTable: new Map([[`${fixture.schema}.${fixture.table}`, [{ name: fixture.column, table: fixture.table, schema: fixture.schema }]]]),
...options,
});
const replacement = prepareSqlCompletionReplacement(sql, cursor, context, items);
const column = replacement.items.find((item) => item.type === "column" && item.label === fixture.column);
assert.ok(column, fixture.label);
assert.equal(column.replaceClosingQuote, fixture.expectedClosingQuote, fixture.label);
const replaceTo = column.replaceClosingQuote === sql[cursor] ? cursor + 1 : cursor;
assert.equal(`${sql.slice(0, replacement.from)}${column.apply ?? column.label}${sql.slice(replaceTo)}`, fixture.expectedSql, fixture.label);
}
});
test("uses PostgreSQL-style double quotes for PostgreSQL-family completion identifiers", () => {
const databaseTypes: DatabaseType[] = ["kingbase", "vastbase", "highgo", "uxdb"];