mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(completion): stop aliasing DELETE target tables
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user