From 9c90edabe3a4c47bcc289c115a1af39addf2da41 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 25 Aug 2026 11:57:52 +0800 Subject: [PATCH] fix(completion): replace partially quoted identifiers --- .../src/components/editor/QueryEditor.vue | 14 +++- .../src/lib/sql/semantic/completion.ts | 7 +- apps/desktop/src/lib/sql/sqlCompletion.ts | 32 +++++++- packages/app-tests/sqlCompletion.test.ts | 80 +++++++++++++++++++ 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 53e350e961..a50bd5bf29 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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, 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): boolean { diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index 6944f45310..46936bc889 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -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, diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 27b8350aae..3859cc9971 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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); 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> = { + '"': '"', + "'": "'", + "`": "`", + "[": "]", +}; + +export function prepareSqlCompletionReplacement(sql: string, cursor: number, context: Pick, 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; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 3c543841d3..552eed41cd 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -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"];