diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 893086c67..fe956e7ae 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1,3 +1,4 @@ +import { Cassandra, MariaSQL, MSSQL, MySQL, PLSQL, PostgreSQL, SQLite, StandardSQL } from "@codemirror/lang-sql"; import type { DatabaseType, SqlSnippet } from "@/types/database"; import { buildMongoCompletionItemsFromContext, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; @@ -1064,6 +1065,15 @@ const SQL_ALIAS_RESERVED_WORDS = new Set([ "with", ]); +const SQL_ALIAS_KEYWORD_WORDS = new Set(sqlAliasKeywordWords(SQL_KEYWORDS.join(" "), StandardSQL.spec.keywords, MySQL.spec.keywords, MariaSQL.spec.keywords, PostgreSQL.spec.keywords, MSSQL.spec.keywords, SQLite.spec.keywords, PLSQL.spec.keywords, Cassandra.spec.keywords)); + +function sqlAliasKeywordWords(...sources: Array): string[] { + return sources + .flatMap((source) => (source ?? "").split(/\s+/)) + .filter((keyword) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(keyword)) + .map((keyword) => keyword.toLowerCase()); +} + export interface SqlCompletionTable { name: string; schema?: string; @@ -3045,7 +3055,7 @@ function generateTableCompletionAlias(tableName: string, existing = new Set): boolean { const lower = candidate.toLowerCase(); - return existing.has(lower) || SQL_ALIAS_RESERVED_WORDS.has(lower); + return existing.has(lower) || isUnsafeSqlAlias(lower); +} + +function isUnsafeSqlAlias(candidate: string): boolean { + return SQL_ALIAS_RESERVED_WORDS.has(candidate) || SQL_ALIAS_KEYWORD_WORDS.has(candidate); } function isFollowedByJoin(beforeToken: string): boolean { diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index d49293c01..8ad8a0c9f 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -1832,6 +1832,50 @@ test("automatic table aliases avoid reserved words", () => { assert.equal(tableItem!.apply, "orders AS ord"); }); +test("table alias suggestions avoid SQL keywords", () => { + const items = buildSqlCompletionItems("select * from item_file ", "select * from item_file ".length, { + tables: [{ name: "item_file", schema: "public", type: "table" }], + columnsByTable, + }); + + const aliasItem = items.find((item) => item.type === "snippet" && item.detail === "alias for item_file"); + assert.ok(aliasItem); + assert.notEqual(aliasItem!.apply, "AS if "); + assert.equal(aliasItem!.apply, "AS it "); +}); + +test("automatic table aliases avoid SQL keywords", () => { + const cases: Array<[string, string]> = [ + ["account_store", "account_store AS ac"], + ["account_type", "account_type AS ac"], + ["data_order", "data_order AS da"], + ["invoice_note", "invoice_note AS inv"], + ["item_file", "item_file AS it"], + ["item_status", "item_status AS it"], + ["new_order", "new_order AS ne"], + ["no_config", "no_config AS nc"], + ["order_node", "order_node AS ord"], + ["order_flow", "order_flow AS ord"], + ["order_region", "order_region AS ord"], + ["row_value", "row_value AS rv"], + ["use_case", "use_case AS uc"], + ["user_role", "user_role AS ur"], + ]; + + for (const [tableName, expectedApply] of cases) { + const sql = `select * from ${tableName.slice(0, 3)}`; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: tableName, schema: "public", type: "table" }], + columnsByTable, + autoAliasTables: true, + }); + + const tableItem = items.find((item) => item.type === "table" && item.label === tableName); + assert.ok(tableItem, `should suggest ${tableName}`); + assert.equal(tableItem!.apply, expectedApply); + } +}); + test("table alias suggestions avoid existing aliases", () => { const items = buildSqlCompletionItems("select * from customer_orders co join customer_orders ", "select * from customer_orders co join customer_orders ".length, { tables: [...tables, { name: "customer_orders", schema: "public", type: "table" }],