mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(completion): replace partially quoted identifiers
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user