diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 15ee57e4f..c22049658 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -71,7 +71,7 @@ import { isTableDataEditable } from "@/lib/table/tableEditing"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; -import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { codeMirrorSqlDialect, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { chartableColumnIndexes } from "@/lib/dataGrid/chartData"; import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse"; import * as api from "@/lib/backend/api"; @@ -268,11 +268,7 @@ const activeTabDimension = computed(() => { const activeSqlFormatDialect = computed(() => sqlFormatDialectForDbType(activeEffectiveDatabaseType.value)); -const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => { - if (activeEffectiveDatabaseType.value === "postgres" || activeEffectiveDatabaseType.value === "kwdb") return "postgres"; - if (activeEffectiveDatabaseType.value === "sqlserver") return "sqlserver"; - return "mysql"; -}); +const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => codeMirrorSqlDialect(activeEffectiveDatabaseType.value)); const shortcutModifier = computed(() => (navigator.platform.toLowerCase().includes("mac") ? "Cmd" : "Ctrl")); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index ad23749e9..27d18b4c6 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -2763,7 +2763,115 @@ function requiresPostgresIdentifierQuote(identifier: string): boolean { return POSTGRES_IDENTIFIER_KEYWORDS.has(identifier); } -const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()).concat(["current_user", "session_user", "user"])); +// PostgreSQL reserved_keyword + type_func_name_keyword categories (pg_get_keywords() +// catcodes R and T) — identifiers matching these cannot appear as a bare column +// reference and must be quoted. SQL_KEYWORDS alone misses most of them because it is +// a completion phrase list ("ORDER BY", not "order"). +const POSTGRES_RESERVED_KEYWORDS = [ + "all", + "analyse", + "analyze", + "and", + "any", + "array", + "as", + "asc", + "asymmetric", + "authorization", + "binary", + "both", + "case", + "cast", + "check", + "collate", + "collation", + "column", + "concurrently", + "constraint", + "create", + "cross", + "current_catalog", + "current_date", + "current_role", + "current_schema", + "current_time", + "current_timestamp", + "current_user", + "default", + "deferrable", + "desc", + "distinct", + "do", + "else", + "end", + "except", + "false", + "fetch", + "for", + "foreign", + "freeze", + "from", + "full", + "grant", + "group", + "having", + "ilike", + "in", + "initially", + "inner", + "intersect", + "into", + "is", + "isnull", + "join", + "lateral", + "leading", + "left", + "like", + "limit", + "localtime", + "localtimestamp", + "natural", + "not", + "notnull", + "null", + "offset", + "on", + "only", + "or", + "order", + "outer", + "overlaps", + "placing", + "primary", + "references", + "returning", + "right", + "select", + "session_user", + "similar", + "some", + "symmetric", + "system_user", + "table", + "tablesample", + "then", + "to", + "trailing", + "true", + "union", + "unique", + "user", + "using", + "variadic", + "verbose", + "when", + "where", + "window", + "with", +]; + +const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()).concat(POSTGRES_RESERVED_KEYWORDS)); function buildTableItems( context: Pick, diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 588c3d17b..356109b0b 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -344,6 +344,36 @@ test("leaves safe PostgreSQL column identifiers unquoted when completion inserts assert.equal(column?.apply, "article"); }); +test("quotes PostgreSQL reserved-word column identifiers when completion inserts them", () => { + // Reserved words absent from the completion keyword phrase list (which has + // "ORDER BY" but not "order") must still be quoted as column references. + const reservedColumns = ["order", "do", "returning", "ilike", "window", "true"]; + const reservedColumnsByTable = new Map([["public.bookings", reservedColumns.map((name) => ({ name, table: "bookings", schema: "public", dataType: "text" }))]]); + const sql = "select from bookings"; + const items = buildSqlCompletionItems(sql, "select ".length, { + tables: [{ name: "bookings", schema: "public", type: "table" }], + columnsByTable: reservedColumnsByTable, + dialect: "postgres", + }); + + for (const name of reservedColumns) { + const column = items.find((item) => item.type === "column" && item.label === name); + assert.equal(column?.apply, `"${name}"`, `expected reserved column "${name}" to be quoted`); + } +}); + +test("quotes PostgreSQL reserved-word table identifiers when completion inserts them", () => { + const sql = "select * from ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "order", schema: "public", type: "table" }], + columnsByTable: new Map(), + dialect: "postgres", + }); + + const table = items.find((item) => item.type === "table" && item.label === "order"); + assert.equal(table?.apply, '"order"'); +}); + test("suggests matching table names after FROM", () => { const sql = "select * from us"; const items = buildSqlCompletionItems(sql, sql.length, {