diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index 29978b8a15..ffa77bd2f1 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -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([["Order Details", [{ name: "User Name", table: "Order Details" }]]]); diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index ca120ce479..7c988cc51a 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -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, }; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index dfc314de14..a7482795d3 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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") {