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 63b1aa641..73715f935 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -88,4 +88,13 @@ describe("semantic SQL completion candidates", () => { expect(items.find((item) => item.label === "User Name")?.apply).toBe('"User Name"'); }); + + it("suggests all target columns for insert column lists", () => { + const columnsByTable = new Map([["users", ["id", "name", "email"].map((name) => ({ name, table: "users" }))]]); + + const { context, items } = semanticCompletion("INSERT INTO users (|", { columnsByTable }); + + expect(context.insertTable).toBe("users"); + expect(items.find((item) => item.type === "snippet" && item.label === "users.*")?.apply).toBe("id, name, email"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index 2a953a8ca..c46da28be 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -160,7 +160,7 @@ describe("sqlCompletion scoped context classification", () => { expect(context.contextKind).toBe("column"); expect(context.insertSchema).toBe("dbo"); - expect(context.insertTable).toBe("users"); + expect(context.insertTable).toBe("Users"); expect(context.exclusiveColumnSuggestions).toBe(true); }); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index fd76416b3..f0366f3c5 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1256,6 +1256,7 @@ class SqlCompletionProvider { if (!context.exclusiveTableSuggestions && context.suggestColumns) { this.items.push(...buildColumnItems(context, this.input.columnsByTable, this.dialect)); this.items.push(...buildSelectAllColumnItems(context, this.input.columnsByTable, this.t, this.dialect)); + this.items.push(...buildInsertAllColumnItems(context, this.input.columnsByTable, this.t, this.dialect)); } if (context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) { @@ -1957,11 +1958,12 @@ function detectComparisonLeftColumn(beforeCursor: string): string | undefined { } function detectInsertColumnListContext(beforeCursor: string): { table: string; schema?: string } | null { - const cleaned = beforeCursor - .replace(/'[^']*'/g, "''") - .replace(/"[^"]*"/g, '""') - .toLowerCase(); - const match = /\binsert\s+into\s+([A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)?)\s*\([^)]*$/i.exec(cleaned); + // Keep quoted identifiers intact so schema/table targets resolve to their + // real names instead of placeholder string contents. + const cleaned = beforeCursor.replace(/'[^']*'/g, "''"); + const identifier = '(?:"[^"]+"|`[^`]+`|[A-Za-z_][\\w$]*)'; + const qualifiedIdentifier = `${identifier}(?:\\.${identifier})?`; + const match = new RegExp(`\\binsert\\s+into\\s+(${qualifiedIdentifier})\\s*\\([^)]*$`, "i").exec(cleaned); if (!match) return null; const fullTable = match[1]; if (!fullTable) return null; @@ -2744,6 +2746,27 @@ function buildSelectAllColumnItems(context: SqlCompletionContext, columnsByTable return items; } +function buildInsertAllColumnItems(context: SqlCompletionContext, columnsByTable: Map, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { + if (!context.insertTable) return []; + const columns = uniqueColumnsByName(columnsForInsertTarget(context, columnsByTable)); + if (columns.length === 0) return []; + + const label = `${context.insertTable}.*`; + if (!selectAllColumnItemMatchesPrefix(label, { name: context.insertTable, schema: context.insertSchema }, columns, context.prefix)) return []; + + const expansion = columns.map((column) => quoteSqlIdentifier(column.name, dialect)).join(", "); + const countText = (t?.starExpansionColumns ?? "{count} columns").replace("{count}", String(columns.length)); + return [ + { + label, + type: "snippet" as const, + detail: `${countText}: ${expansion.length > 60 ? expansion.slice(0, 57) + "..." : expansion}`, + apply: expansion, + boost: 2450 + selectAllColumnItemPrefixBoost(label, { name: context.insertTable, schema: context.insertSchema }, columns, context.prefix), + }, + ]; +} + function referencedTablesForSelectAllColumns(context: SqlCompletionContext): SqlCompletionReferencedTable[] { if (!context.qualifier) return context.referencedTables; const qualifier = context.qualifier; @@ -2986,25 +3009,37 @@ function isInTableListContext(beforeToken: string): boolean { return /,\s*$/.test(beforeToken) && /\b(?:from|join|update|into)\b/i.test(beforeToken); } -function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { - // Collect all columns from the map (all tables have been fetched) +function collectCompletionColumns(columnsByTable: Map): Array { const allColumns: Array = []; for (const [key, cols] of columnsByTable.entries()) { for (const col of cols) { allColumns.push({ ...col, key }); } } + return allColumns; +} + +function columnsForInsertTarget(context: SqlCompletionContext, columnsByTable: Map): Array { + if (!context.insertTable) return []; + const tableKey = normalizeIdentifierPart(context.insertTable); + const schemaKey = context.insertSchema ? normalizeIdentifierPart(context.insertSchema) : undefined; + const qualifiedKey = schemaKey ? normalizeCompletionKey(`${context.insertSchema}.${context.insertTable}`) : undefined; + return collectCompletionColumns(columnsByTable).filter((column) => { + if (normalizeIdentifierPart(column.table) !== tableKey) return false; + if (!schemaKey) return true; + if (column.schema && normalizeIdentifierPart(column.schema) === schemaKey) return true; + return !!qualifiedKey && normalizeCompletionKey(column.key) === qualifiedKey; + }); +} + +function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { + // Collect all columns from the map (all tables have been fetched) + const allColumns = collectCompletionColumns(columnsByTable); // Handle INSERT column list: filter to only the target table let relevantCols = allColumns; if (context.insertTable) { - const tableLower = context.insertTable.toLowerCase(); - if (context.insertSchema) { - const schemaLower = context.insertSchema.toLowerCase(); - relevantCols = allColumns.filter((c) => c.table.toLowerCase() === tableLower && (c.schema?.toLowerCase() === schemaLower || c.key.toLowerCase() === `${schemaLower}.${tableLower}`)); - } else { - relevantCols = allColumns.filter((c) => c.table.toLowerCase() === tableLower); - } + relevantCols = columnsForInsertTarget(context, columnsByTable); } else if (context.qualifier) { const q = context.qualifier; const qLower = q.toLowerCase(); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 5a7c2d299..c28e2d4f5 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1297,6 +1297,13 @@ test("detects INSERT INTO with schema-qualified table", () => { assert.equal(context.insertSchema, "public"); }); +test("detects MySQL backtick-qualified INSERT INTO column list context", () => { + const sql = "INSERT INTO `other_db`.`orders` ("; + const context = getSqlCompletionContext(sql, sql.length); + assert.equal(context.insertTable, "orders"); + assert.equal(context.insertSchema, "other_db"); +}); + test("suggests columns for INSERT INTO target table", () => { const items = buildSqlCompletionItems("INSERT INTO users (", "INSERT INTO users (".length, { tables, @@ -1309,6 +1316,92 @@ test("suggests columns for INSERT INTO target table", () => { assert.ok(columnItems.some((item) => item.label === "email")); }); +test("suggests all target columns for INSERT INTO column list", () => { + const items = buildSqlCompletionItems("INSERT INTO users (", "INSERT INTO users (".length, { + tables, + columnsByTable, + }); + + const allColumns = items.find((item) => item.type === "snippet" && item.label === "users.*"); + assert.ok(allColumns); + assert.equal(allColumns.apply, "id, name, email"); +}); + +test("keeps INSERT INTO all-column expansion available after a column prefix", () => { + const items = buildSqlCompletionItems("INSERT INTO users (id", "INSERT INTO users (id".length, { + tables, + columnsByTable, + }); + + const allColumns = items.find((item) => item.type === "snippet" && item.label === "users.*"); + assert.ok(allColumns); + assert.equal(allColumns.apply, "id, name, email"); +}); + +test("quotes PostgreSQL identifiers in INSERT INTO all-column expansion", () => { + const sql = 'INSERT INTO public."OrderLines" ('; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: postgresQuotedTables, + columnsByTable: postgresQuotedColumnsByTable, + databaseType: "postgres", + dialect: "postgres", + }); + + const allColumns = items.find((item) => item.type === "snippet" && item.label === "OrderLines.*"); + assert.ok(allColumns); + assert.equal(allColumns.apply, 'article, "OrderId", "User", "has""quote"'); +}); + +test("suggests all target columns for schema-qualified INSERT INTO column lists", () => { + const sql = "INSERT INTO dbo.Users ("; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "Users", schema: "dbo", type: "table" }], + columnsByTable: new Map([ + [ + "dbo.Users", + [ + { name: "Id", table: "Users", schema: "dbo", dataType: "bigint" }, + { name: "DisplayName", table: "Users", schema: "dbo", dataType: "nvarchar" }, + ], + ], + ]), + databaseType: "sqlserver", + dialect: "sqlserver", + }); + + const allColumns = items.find((item) => item.type === "snippet" && item.label === "Users.*"); + assert.ok(allColumns); + assert.equal(allColumns.apply, "Id, DisplayName"); +}); + +test("scopes INSERT INTO all-column expansion to the database-qualified MySQL target", () => { + const sql = "INSERT INTO other_db.orders ("; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", schema: "other_db", type: "table" }], + columnsByTable: mysqlCrossDatabaseColumnsByTable, + databaseType: "mysql", + dialect: "mysql", + }); + + const allColumns = items.find((item) => item.type === "snippet" && item.label === "orders.*"); + assert.ok(allColumns); + assert.equal(allColumns.apply, "id, number, status"); +}); + +test("suggests all target columns for MySQL backtick-qualified INSERT INTO", () => { + const sql = "INSERT INTO `other_db`.`orders` ("; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", schema: "other_db", type: "table" }], + columnsByTable: mysqlCrossDatabaseColumnsByTable, + databaseType: "mysql", + dialect: "mysql", + }); + + const allColumns = items.find((item) => item.type === "snippet" && item.label === "orders.*"); + assert.ok(allColumns); + assert.equal(allColumns.apply, "id, number, status"); +}); + // --- Column data type in detail --- test("shows column data type in detail", () => {