fix(completion): stop aliasing DELETE target tables

This commit is contained in:
onenewcode
2026-09-16 11:28:31 +08:00
committed by GitHub
parent e916e9e927
commit 9bf52dab69
3 changed files with 156 additions and 1 deletions
@@ -510,6 +510,98 @@ FROM (
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi2");
});
it("omits generated aliases on a DELETE target table (issue #9186)", () => {
const { items } = semanticCompletion("DELETE FROM DH|_MODEL_CAP", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
expect(items.filter((item) => item.type === "table").map((item) => item.apply)).toEqual(["DH_MODEL_CAP"]);
});
it("omits generated aliases on a schema-qualified DELETE target table", () => {
const { items } = semanticCompletion("DELETE FROM DH.DH_MODEL_CAP|", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
expect(items.filter((item) => item.type === "table").map((item) => item.apply)).toEqual(["DH_MODEL_CAP"]);
});
it("omits generated aliases while the DELETE target schema is being typed", () => {
const { items } = semanticCompletion("DELETE FROM DH.DH|", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
// The qualifier resolves to the delete target itself, so no alias is offered.
expect(items.filter((item) => item.type === "table").map((item) => item.apply)).toEqual(["DH_MODEL_CAP"]);
});
it("omits generated aliases on an empty-prefix DELETE target (manual trigger)", () => {
const { items } = semanticCompletion("DELETE FROM |", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
expect(items.filter((item) => item.type === "table").map((item) => item.apply)).toEqual(["DH_MODEL_CAP"]);
});
it("omits generated aliases on an empty-prefix schema-qualified DELETE target", () => {
const { items } = semanticCompletion("DELETE FROM DH.|", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
expect(items.filter((item) => item.type === "table").map((item) => item.apply)).toEqual(["DH_MODEL_CAP"]);
});
it("omits generated aliases on empty-prefix UPDATE and INSERT targets", () => {
const updated = semanticCompletion("UPDATE |", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
const inserted = semanticCompletion("INSERT INTO |", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
expect(updated.items.filter((item) => item.type === "table").map((item) => item.apply)).toEqual(["DH_MODEL_CAP"]);
expect(inserted.items.filter((item) => item.type === "table").map((item) => item.apply)).toEqual(["DH_MODEL_CAP"]);
});
it("keeps generated aliases on JOIN sources inside a DELETE statement", () => {
const { items } = semanticCompletion("DELETE t1 FROM t1 JOIN ord|", {
tables: [{ name: "order_items", type: "table" }],
autoAliasTables: true,
});
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi");
});
it("keeps generated aliases after a multi-table DELETE target list", () => {
const { items } = semanticCompletion("DELETE t1 FROM |", {
tables: [{ name: "order_items", type: "table" }],
autoAliasTables: true,
});
expect(items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi");
});
it("keeps generated aliases on FROM and JOIN sources", () => {
const joined = semanticCompletion("SELECT * FROM DH_MODEL_CAP JOIN ord|", {
tables: [{ name: "order_items", type: "table" }],
autoAliasTables: true,
});
const queried = semanticCompletion("SELECT * FROM DH|_MODEL_CAP", {
tables: [{ name: "DH_MODEL_CAP", schema: "DH", type: "table" }],
autoAliasTables: true,
});
expect(joined.items.find((item) => item.label === "order_items")?.apply).toBe("order_items AS oi");
expect(queried.items.find((item) => item.label === "DH_MODEL_CAP")?.apply).toBe("DH_MODEL_CAP AS dmc");
});
it("preserves dialect-aware identifier quoting in apply text", () => {
const columnsByTable = new Map<string, SqlCompletionColumn[]>([["Order Details", [{ name: "User Name", table: "Order Details" }]]]);
@@ -258,6 +258,66 @@ function semanticMutationTarget(model: SqlSemanticModel): SqlSemanticRowSource |
return targetId ? model.rowSources.find((source) => source.id === targetId) : model.rowSources.find((source) => source.kind === "mutation_target");
}
/**
* A generated alias is usually a syntax error on a mutation target: an INSERT
* target carries no alias at all, and a DELETE or UPDATE target only accepts
* one in the multi-table dialects that re-list it elsewhere (issue #9186:
* SQL Server rejects `DELETE FROM t AS a`). The typed case matches the
* replacement range against the parsed mutation-target span; the empty-prefix
* case cannot produce that source yet, so the mutation slot is recognized from
* the statement verb and the introducer right before the qualified name being
* completed. JOIN/USING/APPLY sources and SELECT FROM sources stay aliasable.
*/
function tableCompletionTargetIsAliasUnsafe(model: SqlSemanticModel): boolean {
if (model.cursorIntent.kind !== "table" && model.cursorIntent.kind !== "delete_target") return false;
const replacement = model.cursorIntent.replacementRange;
if (model.rowSources.some((source) => source.kind === "mutation_target" && !source.alias && replacement.start <= source.sourceSpan.end && replacement.end >= source.sourceSpan.start)) {
return true;
}
const introducer = mutationTargetIntroducer(model);
if (model.statement.kind === "delete") {
if (introducer.normalized === "delete") return true;
if (introducer.normalized !== "from") return false;
// Only the simple `DELETE FROM t` form targets the source being completed;
// once a target list sits between the verb and FROM (`DELETE t FROM ...`),
// the FROM clause introduces aliasable row sources.
const verbSide = introducer.index > 0 ? introducer.tokens[introducer.index - 1] : undefined;
return verbSide?.kind === "word" && verbSide.normalized === "delete";
}
if (model.statement.kind === "update") return introducer.normalized === "update";
if (model.statement.kind === "insert") return introducer.normalized === "into" || introducer.normalized === "insert";
return false;
}
/**
* The last syntax word before the table name being completed, skipping that
* name's qualifier parts and typed prefix, so `DELETE FROM dh.|` still reports
* `from` instead of the qualifier itself.
*/
function mutationTargetIntroducer(model: SqlSemanticModel): { normalized: string; index: number; tokens: readonly SqlSemanticToken[] } {
const before = model.tokens.filter((token) => token.span.end <= model.cursorIntent.replacementRange.start && token.kind !== "comment");
let index = before.length - 1;
let identifiersToSkip = model.cursorIntent.qualifierParts.length + (model.cursorIntent.prefix.length > 0 ? 1 : 0);
while (index >= 0 && identifiersToSkip > 0) {
if (before[index]?.text === ".") {
index -= 1;
continue;
}
if (!isSemanticIdentifier(before[index])) break;
identifiersToSkip -= 1;
index -= 1;
}
for (; index >= 0; index -= 1) {
const token = before[index];
if (!token) continue;
if (token.kind === "word") return { normalized: token.normalized, index, tokens: before };
if (token.kind === "quoted_identifier") return { normalized: "", index, tokens: before };
if (token.text === "." || token.kind === "comment") continue;
break;
}
return { normalized: "", index: -1, tokens: before };
}
/**
* When the semantic scanner misses the trailing identifier that the legacy
* scanner still reports (semantic prefix empty with a replacement range
@@ -322,6 +382,7 @@ export function sqlCompletionContextFromSemantic(model: SqlSemanticModel, base:
insertSchema: model.cursorIntent.kind === "insert_column" ? mutationSchema : base.insertSchema,
updateTarget: model.cursorIntent.kind === "update_column" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.updateTarget,
deleteTarget: model.cursorIntent.kind === "delete_target" && mutationTarget ? { table: mutationTarget.name, schema: mutationSchema } : base.deleteTarget,
tableCompletionTargetAliasUnsafe: tableCompletionTargetIsAliasUnsafe(model),
onStar: model.cursorIntent.kind === "star" || base.onStar,
contextKind,
};
+3 -1
View File
@@ -1433,6 +1433,8 @@ export interface SqlCompletionContext {
deleteTarget?: { table: string; schema?: string };
oracleTableFunctionContext?: boolean;
autoAliasTableCompletions: boolean;
/** The table position being completed is a DML target, where a generated alias can be rejected by the server. */
tableCompletionTargetAliasUnsafe?: boolean;
tableAliasAfterCursor?: boolean;
openingParenAfterCursor: boolean;
contextKind: SqlCompletionContextKind;
@@ -1665,7 +1667,7 @@ class SqlCompletionProvider {
}
if (!context.exclusiveColumnSuggestions && context.suggestTables) {
const autoAliasTables = !!this.input.autoAliasTables && context.autoAliasTableCompletions && supportsTableAliases(this.databaseType);
const autoAliasTables = !!this.input.autoAliasTables && context.autoAliasTableCompletions && !context.tableCompletionTargetAliasUnsafe && supportsTableAliases(this.databaseType);
this.items.push(...buildForeignKeyRelatedTableItems(context, completionTables, this.input.foreignKeysByTable, this.dialect, autoAliasTables, this.databaseType, this.input.keywordCase, this.input.currentSchema));
this.items.push(...buildTableItems(context, completionTables, this.dialect, autoAliasTables, context.referencedTables, this.databaseType, this.input.currentSchema, this.input.keywordCase));
if (this.databaseType === "clickhouse") {