From 0a53b7e25c2dfc3d4018043dcf01fd6088a1f02f Mon Sep 17 00:00:00 2001 From: Michael Villari Date: Thu, 10 Sep 2026 22:42:11 -0400 Subject: [PATCH 1/3] fix(editor): quote PostgreSQL table completion identifiers --- src/components/QueryEditor.tsx | 4 +- src/lib/editor/sql-completions.ts | 17 ++++++-- tests/components/QueryEditor.test.tsx | 21 ++++++++- tests/unit/sql-completions.test.ts | 63 +++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 4cf260933..e54611c84 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -493,10 +493,10 @@ export const QueryEditor = forwardRef( // SQL completion provider useEffect(() => { if (monaco && language === "sql") { - const disposable = registerSQLCompletionProvider(monaco, schemaCompletionCache); + const disposable = registerSQLCompletionProvider(monaco, schemaCompletionCache, databaseType); return () => disposable.dispose(); } - }, [monaco, language, schemaCompletionCache]); + }, [monaco, language, schemaCompletionCache, databaseType]); // MongoDB JSON completion provider useEffect(() => { diff --git a/src/lib/editor/sql-completions.ts b/src/lib/editor/sql-completions.ts index 72825fff5..8ad7bcd10 100644 --- a/src/lib/editor/sql-completions.ts +++ b/src/lib/editor/sql-completions.ts @@ -7,6 +7,8 @@ import type * as Monaco from "monaco-editor"; import { extractAliases, resolveAlias } from "@/lib/sql"; +import { quoteIdentifier } from "@/lib/sql/identifier"; +import type { DatabaseType } from "@/lib/types"; // --------------------------------------------------------------------------- // Static constants @@ -207,6 +209,7 @@ export interface SchemaCompletionCache { export function registerSQLCompletionProvider( monaco: typeof Monaco, schemaCompletionCache: SchemaCompletionCache, + databaseType?: DatabaseType, ): Monaco.IDisposable { return monaco.languages.registerCompletionItemProvider("sql", { triggerCharacters: [".", " "], @@ -239,7 +242,15 @@ export function registerSQLCompletionProvider( .map((table) => ({ label: table.label, kind: monaco.languages.CompletionItemKind.Class, - insertText: table.label, + // PostgreSQL folds unquoted identifiers to lowercase. Schema labels + // preserve catalog spelling, so quote each component before insertion. + insertText: + databaseType === "postgres" + ? table.label + .split(".") + .map((part) => quoteIdentifier(part, databaseType)) + .join(".") + : table.label, range: tableRange, detail: `Table (${table.rowCount} rows)`, documentation: table.columnNames, @@ -248,9 +259,9 @@ export function registerSQLCompletionProvider( // Dot-triggered: Show columns for specific table or alias if (lastChar === ".") { - const matches = line.substring(0, position.column - 1).match(/(\w+)\.$/); + const matches = line.substring(0, position.column - 1).match(/(?:"((?:[^"]|"")+)"|(\w+))\.$/); if (matches) { - const identifier = matches[1].toLowerCase(); + const identifier = (matches[1]?.replace(/""/g, '"') ?? matches[2]).toLowerCase(); // Helper to find columns by table name (handles schema.table format) const findColumns = (tableName: string) => { diff --git a/tests/components/QueryEditor.test.tsx b/tests/components/QueryEditor.test.tsx index abb4353cf..84597fa60 100644 --- a/tests/components/QueryEditor.test.tsx +++ b/tests/components/QueryEditor.test.tsx @@ -173,8 +173,9 @@ mock.module("sql-formatter", () => ({ })); // ── Mock editor/sql-completions ───────────────────────────────────────────── +const mockRegisterSQLCompletionProvider = mock((..._args: unknown[]) => ({ dispose: mock(() => {}) })); mock.module("@/lib/editor/sql-completions", () => ({ - registerSQLCompletionProvider: mock(() => ({ dispose: mock(() => {}) })), + registerSQLCompletionProvider: mockRegisterSQLCompletionProvider, })); // ── Mock editor/mongodb-completions ───────────────────────────────────────── @@ -1895,3 +1896,21 @@ describe("QueryEditor", () => { expect(queryByTestId("mock-monaco-editor")).not.toBeNull(); }); }); + +describe("QueryEditor completion dialect", () => { + test("registers completions again when the connection dialect changes", () => { + mockUseMonacoReturn = { Range: class {} }; + mockRegisterSQLCompletionProvider.mockClear(); + const { rerender, unmount } = render( + React.createElement(QueryEditor, createDefaultProps({ databaseType: "postgres" })), + ); + expect(mockRegisterSQLCompletionProvider).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + "postgres", + ); + rerender(React.createElement(QueryEditor, createDefaultProps({ databaseType: "mysql" }))); + expect(mockRegisterSQLCompletionProvider).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "mysql"); + unmount(); + }); +}); diff --git a/tests/unit/sql-completions.test.ts b/tests/unit/sql-completions.test.ts index 0f0cf7b49..4ccddf815 100644 --- a/tests/unit/sql-completions.test.ts +++ b/tests/unit/sql-completions.test.ts @@ -535,3 +535,66 @@ describe("Empty schema cache", () => { expect(labels).toContain("SELECT"); }); }); + +describe("PostgreSQL table completion quoting", () => { + test.each([ + [ + "SELECT * FROM ", + "My_Schema_With_Caps.My_Table_With_Caps", + 'SELECT * FROM "My_Schema_With_Caps"."My_Table_With_Caps"', + ], + [ + "SELECT * FROM My_Schema_With_Caps.My_T", + "My_Schema_With_Caps.My_Table_With_Caps", + 'SELECT * FROM "My_Schema_With_Caps"."My_Table_With_Caps"', + ], + [ + "SELECT * FROM My_Schema_With_Caps.", + "My_Schema_With_Caps.My_Table_With_Caps", + 'SELECT * FROM "My_Schema_With_Caps"."My_Table_With_Caps"', + ], + ["SELECT * FROM public.My_T", "My_Table_With_Caps", 'SELECT * FROM public."My_Table_With_Caps"'], + ["SELECT * FROM ", 'Odd"Schema.Order Details', 'SELECT * FROM "Odd""Schema"."Order Details"'], + ["SELECT * FROM ", "select", 'SELECT * FROM "select"'], + ["SELECT * FROM sample.dem", "sample.demo", 'SELECT * FROM "sample"."demo"'], + ])("quotes the applied PostgreSQL edit for %s / %s", (line, label, expected) => { + const monaco = createMockMonaco(); + registerSQLCompletionProvider( + monaco, + createSchemaCache({ + tableItems: [{ label, labelLower: label.toLowerCase(), rowCount: 1, columnNames: "id" }], + }), + "postgres", + ); + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line), createPosition(1, line.length + 1)); + const suggestion = result.suggestions.find((item) => item.label === label)!; + expect(suggestion).toBeDefined(); + const range = suggestion.range as Monaco.IRange; + expect(line.slice(0, range.startColumn - 1) + suggestion.insertText + line.slice(range.endColumn - 1)).toBe( + expected, + ); + }); + + test("leaves other dialects unchanged", () => { + const monaco = createMockMonaco(); + registerSQLCompletionProvider(monaco, createSchemaCache(), "mysql"); + const line = "SELECT * FROM us"; + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line), createPosition(1, line.length + 1)); + expect(result.suggestions.find((item) => item.label === "users")!.insertText).toBe("users"); + }); +}); + +describe("Quoted PostgreSQL table column lookup", () => { + test.each(['SELECT "users".', 'SELECT "public"."users".'])("retains column suggestions after %s", (line) => { + const monaco = createMockMonaco(); + registerSQLCompletionProvider(monaco, createSchemaCache(), "postgres"); + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line), createPosition(1, line.length + 1)); + expect(result.suggestions.map((item) => item.label)).toEqual(["id", "name", "email"]); + }); +}); From 1a63657a81180376a087abdc3261aa19e4b905a9 Mon Sep 17 00:00:00 2001 From: Michael Villari Date: Fri, 11 Sep 2026 08:36:01 -0400 Subject: [PATCH 2/3] fix(editor): preserve ordinary PostgreSQL completion names --- src/lib/editor/postgres-identifiers.ts | 28 +++++++++++ src/lib/editor/sql-completions.ts | 13 ++--- tests/unit/sql-completions.test.ts | 70 +++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 src/lib/editor/postgres-identifiers.ts diff --git a/src/lib/editor/postgres-identifiers.ts b/src/lib/editor/postgres-identifiers.ts new file mode 100644 index 000000000..6bc0d67a6 --- /dev/null +++ b/src/lib/editor/postgres-identifiers.ts @@ -0,0 +1,28 @@ +import { quoteIdentifier } from "@/lib/sql/identifier"; + +// PostgreSQL quote_ident quotes every keyword except UNRESERVED_KEYWORD. +// Source: PostgreSQL 18, src/include/parser/kwlist.h and +// src/backend/utils/adt/ruleutils.c (quote_identifier). +// https://github.com/postgres/postgres/blob/REL_18_STABLE/src/include/parser/kwlist.h +// Keep this separate from SQL_KEYWORDS: that list is only editor suggestions. +const QUOTED_KEYWORDS = new Set( + `all analyse analyze and any array as asc asymmetric authorization between bigint binary bit boolean +both case cast char character check coalesce collate collation column concurrently constraint create +cross current_catalog current_date current_role current_schema current_time current_timestamp +current_user dec decimal default deferrable desc distinct do else end except exists extract false +fetch float for foreign freeze from full grant greatest group grouping having ilike in initially +inner inout int integer intersect interval into is isnull join json json_array json_arrayagg +json_exists json_object json_objectagg json_query json_scalar json_serialize json_table json_value +lateral leading least left like limit localtime localtimestamp merge_action national natural nchar +none normalize not notnull null nullif numeric offset on only or order out outer overlaps overlay +placing position precision primary real references returning right row select session_user setof +similar smallint some substring symmetric system_user table tablesample then time timestamp to +trailing treat trim true union unique user using values varchar variadic verbose when where window +with xmlattributes xmlconcat xmlelement xmlexists xmlforest xmlnamespaces xmlparse xmlpi xmlroot +xmlserialize xmltable`.split(/\s+/), +); + +/** Format one catalog identifier using PostgreSQL's conservative quote_ident rule. */ +export function formatPostgresIdentifier(name: string): string { + return /^[a-z_][a-z0-9_]*$/.test(name) && !QUOTED_KEYWORDS.has(name) ? name : quoteIdentifier(name, "postgres"); +} diff --git a/src/lib/editor/sql-completions.ts b/src/lib/editor/sql-completions.ts index 8ad7bcd10..e39be748e 100644 --- a/src/lib/editor/sql-completions.ts +++ b/src/lib/editor/sql-completions.ts @@ -7,7 +7,7 @@ import type * as Monaco from "monaco-editor"; import { extractAliases, resolveAlias } from "@/lib/sql"; -import { quoteIdentifier } from "@/lib/sql/identifier"; +import { formatPostgresIdentifier } from "./postgres-identifiers"; import type { DatabaseType } from "@/lib/types"; // --------------------------------------------------------------------------- @@ -242,15 +242,10 @@ export function registerSQLCompletionProvider( .map((table) => ({ label: table.label, kind: monaco.languages.CompletionItemKind.Class, - // PostgreSQL folds unquoted identifiers to lowercase. Schema labels - // preserve catalog spelling, so quote each component before insertion. + // Preserve ordinary identifiers; quote catalog names only when needed + // to preserve case, escape special characters, or avoid keywords. insertText: - databaseType === "postgres" - ? table.label - .split(".") - .map((part) => quoteIdentifier(part, databaseType)) - .join(".") - : table.label, + databaseType === "postgres" ? table.label.split(".").map(formatPostgresIdentifier).join(".") : table.label, range: tableRange, detail: `Table (${table.rowCount} rows)`, documentation: table.columnNames, diff --git a/tests/unit/sql-completions.test.ts b/tests/unit/sql-completions.test.ts index 4ccddf815..cd9baccc5 100644 --- a/tests/unit/sql-completions.test.ts +++ b/tests/unit/sql-completions.test.ts @@ -556,7 +556,7 @@ describe("PostgreSQL table completion quoting", () => { ["SELECT * FROM public.My_T", "My_Table_With_Caps", 'SELECT * FROM public."My_Table_With_Caps"'], ["SELECT * FROM ", 'Odd"Schema.Order Details', 'SELECT * FROM "Odd""Schema"."Order Details"'], ["SELECT * FROM ", "select", 'SELECT * FROM "select"'], - ["SELECT * FROM sample.dem", "sample.demo", 'SELECT * FROM "sample"."demo"'], + ["SELECT * FROM sample.dem", "sample.demo", "SELECT * FROM sample.demo"], ])("quotes the applied PostgreSQL edit for %s / %s", (line, label, expected) => { const monaco = createMockMonaco(); registerSQLCompletionProvider( @@ -588,6 +588,74 @@ describe("PostgreSQL table completion quoting", () => { }); }); +describe("PostgreSQL selective identifier quoting", () => { + test.each([ + ["user_authority", "user_authority"], + ["authschema.users", "authschema.users"], + ["public.orders", "public.orders"], + ["authschema.UserAuthority", 'authschema."UserAuthority"'], + ["AuthSchema.users", '"AuthSchema".users'], + ["USER_AUTHORITY", '"USER_AUTHORITY"'], + ["UserAuthority", '"UserAuthority"'], + ["abort", "abort"], + ["name$1", '"name$1"'], + ["_items_2", "_items_2"], + ["user", '"user"'], + ["authorization", '"authorization"'], + ["between", '"between"'], + ["select", '"select"'], + ["current_user", '"current_user"'], + ["1st_table", '"1st_table"'], + ["order details", '"order details"'], + ['odd"name', '"odd""name"'], + ["café", '"café"'], + ])("formats %s following PostgreSQL quote_ident", (label, expected) => { + const monaco = createMockMonaco(); + registerSQLCompletionProvider( + monaco, + createSchemaCache({ + tableItems: [{ label, labelLower: label.toLowerCase(), rowCount: 1, columnNames: "id" }], + }), + "postgres", + ); + const line = "SELECT * FROM "; + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line), createPosition(1, line.length + 1)); + const suggestion = result.suggestions.find((item) => item.label === label)!; + expect(suggestion.label).toBe(label); + const range = suggestion.range as Monaco.IRange; + expect(line.slice(0, range.startColumn - 1) + suggestion.insertText).toBe("SELECT * FROM " + expected); + }); + + test("an uppercase typed prefix still matches an ordinary lowercase catalog name", () => { + const monaco = createMockMonaco(); + registerSQLCompletionProvider( + monaco, + createSchemaCache({ + tableItems: [ + { + label: "authschema.user_authority", + labelLower: "authschema.user_authority", + rowCount: 1, + columnNames: "id", + }, + ], + }), + "postgres", + ); + const line = "SELECT * FROM AUTHSCHEMA.USER_A"; + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line), createPosition(1, line.length + 1)); + const suggestion = result.suggestions.find((item) => item.label === "authschema.user_authority")!; + const range = suggestion.range as Monaco.IRange; + expect(line.slice(0, range.startColumn - 1) + suggestion.insertText).toBe( + "SELECT * FROM authschema.user_authority", + ); + }); +}); + describe("Quoted PostgreSQL table column lookup", () => { test.each(['SELECT "users".', 'SELECT "public"."users".'])("retains column suggestions after %s", (line) => { const monaco = createMockMonaco(); From 3a0d64b1127f7aa6767e9ed3d8b926f0c594d72e Mon Sep 17 00:00:00 2001 From: Michael Villari Date: Fri, 11 Sep 2026 22:50:55 -0400 Subject: [PATCH 3/3] fix(editor): isolate quoted completion lookup to PostgreSQL --- src/lib/editor/sql-completions.ts | 11 +++-- tests/components/QueryEditor.test.tsx | 52 ++++++++++++++++------- tests/unit/sql-completions.test.ts | 60 +++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/src/lib/editor/sql-completions.ts b/src/lib/editor/sql-completions.ts index e39be748e..fae672392 100644 --- a/src/lib/editor/sql-completions.ts +++ b/src/lib/editor/sql-completions.ts @@ -254,10 +254,13 @@ export function registerSQLCompletionProvider( // Dot-triggered: Show columns for specific table or alias if (lastChar === ".") { - const matches = line.substring(0, position.column - 1).match(/(?:"((?:[^"]|"")+)"|(\w+))\.$/); - if (matches) { - const identifier = (matches[1]?.replace(/""/g, '"') ?? matches[2]).toLowerCase(); - + const textToDot = line.substring(0, position.column - 1); + // Only PostgreSQL completions introduce quoted table names in this PR. + // Other dialects retain their existing bare-identifier lookup. + const quotedIdentifier = + databaseType === "postgres" ? textToDot.match(/"((?:[^"]|"")+)"\.$/)?.[1].replace(/""/g, '"') : undefined; + const identifier = (quotedIdentifier ?? textToDot.match(/(\w+)\.$/)?.[1])?.toLowerCase(); + if (identifier) { // Helper to find columns by table name (handles schema.table format) const findColumns = (tableName: string) => { const tableNameLower = tableName.toLowerCase(); diff --git a/tests/components/QueryEditor.test.tsx b/tests/components/QueryEditor.test.tsx index 84597fa60..a24d06486 100644 --- a/tests/components/QueryEditor.test.tsx +++ b/tests/components/QueryEditor.test.tsx @@ -1898,19 +1898,41 @@ describe("QueryEditor", () => { }); describe("QueryEditor completion dialect", () => { - test("registers completions again when the connection dialect changes", () => { - mockUseMonacoReturn = { Range: class {} }; - mockRegisterSQLCompletionProvider.mockClear(); - const { rerender, unmount } = render( - React.createElement(QueryEditor, createDefaultProps({ databaseType: "postgres" })), - ); - expect(mockRegisterSQLCompletionProvider).toHaveBeenLastCalledWith( - expect.anything(), - expect.anything(), - "postgres", - ); - rerender(React.createElement(QueryEditor, createDefaultProps({ databaseType: "mysql" }))); - expect(mockRegisterSQLCompletionProvider).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "mysql"); - unmount(); - }); + test.each(["mysql", "sqlite", "duckdb", "mssql", "oracle"] as const)( + "disposes and replaces completions when switching PostgreSQL to %s and back", + (dialect) => { + mockUseMonacoReturn = { Range: class {} }; + mockRegisterSQLCompletionProvider.mockClear(); + const { rerender, unmount } = render( + React.createElement(QueryEditor, createDefaultProps({ databaseType: "postgres" })), + ); + expect(mockRegisterSQLCompletionProvider).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + "postgres", + ); + const postgresRegistration = mockRegisterSQLCompletionProvider.mock.results[0].value as ReturnType< + typeof mockRegisterSQLCompletionProvider + >; + rerender(React.createElement(QueryEditor, createDefaultProps({ databaseType: dialect }))); + expect(postgresRegistration.dispose).toHaveBeenCalledTimes(1); + expect(mockRegisterSQLCompletionProvider).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), dialect); + const otherRegistration = mockRegisterSQLCompletionProvider.mock.results[1].value as ReturnType< + typeof mockRegisterSQLCompletionProvider + >; + rerender(React.createElement(QueryEditor, createDefaultProps({ databaseType: "postgres" }))); + expect(otherRegistration.dispose).toHaveBeenCalledTimes(1); + expect(mockRegisterSQLCompletionProvider).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + "postgres", + ); + expect(mockRegisterSQLCompletionProvider).toHaveBeenCalledTimes(3); + const finalRegistration = mockRegisterSQLCompletionProvider.mock.results[2].value as ReturnType< + typeof mockRegisterSQLCompletionProvider + >; + unmount(); + expect(finalRegistration.dispose).toHaveBeenCalledTimes(1); + }, + ); }); diff --git a/tests/unit/sql-completions.test.ts b/tests/unit/sql-completions.test.ts index cd9baccc5..ea0f7ab81 100644 --- a/tests/unit/sql-completions.test.ts +++ b/tests/unit/sql-completions.test.ts @@ -1,6 +1,7 @@ import "../setup"; import { describe, test, expect } from "bun:test"; import type * as Monaco from "monaco-editor"; +import { SHIPPED_DATABASE_TYPES } from "@/lib/db/compatibility"; import { SQL_KEYWORDS, SQL_FUNCTIONS, @@ -666,3 +667,62 @@ describe("Quoted PostgreSQL table column lookup", () => { expect(result.suggestions.map((item) => item.label)).toEqual(["id", "name", "email"]); }); }); + +describe("Completion dialect compatibility", () => { + // Include the legacy caller without a dialect as well as every non-Postgres + // registration. This is provider behavior coverage, not live-engine coverage. + const otherDialects = [...SHIPPED_DATABASE_TYPES.filter((type) => type !== "postgres"), undefined]; + + test.each(otherDialects)("%s preserves table insertion and qualified replacement", (dialect) => { + const monaco = createMockMonaco(); + const label = "Sales.OrderDetails"; + registerSQLCompletionProvider( + monaco, + createSchemaCache({ tableItems: [{ label, labelLower: label.toLowerCase(), rowCount: 1, columnNames: "id" }] }), + dialect, + ); + for (const line of ["SELECT * FROM ", "SELECT * FROM Sal", "SELECT * FROM Sales.", "SELECT * FROM Sales.Ord"]) { + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line), createPosition(1, line.length + 1)); + const suggestion = result.suggestions.find((item) => item.label === label)!; + expect(suggestion.insertText).toBe(label); + const range = suggestion.range as Monaco.IRange; + expect(line.slice(0, range.startColumn - 1) + suggestion.insertText).toBe("SELECT * FROM Sales.OrderDetails"); + } + }); + + test.each(otherDialects)("%s retains bare column lookup and existing delimiter behavior", (dialect) => { + const monaco = createMockMonaco(); + registerSQLCompletionProvider(monaco, createSchemaCache(), dialect); + for (const [line, fullText, expected] of [ + ["SELECT users.", "SELECT users.", ["id", "name", "email"]], + ["SELECT u.", "SELECT u. FROM users u", ["id", "name", "email"]], + ["SELECT public.products.", "SELECT public.products.", ["id", "price"]], + ['SELECT "users".', 'SELECT "users".', []], + ['SELECT "public"."users".', 'SELECT "public"."users".', []], + ["SELECT `users`.", "SELECT `users`.", []], + ["SELECT [users].", "SELECT [users].", []], + ["SELECT missing.", "SELECT missing.", []], + ] as const) { + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line, fullText), createPosition(1, line.length + 1)); + expect(result.suggestions.map((item) => item.label)).toEqual([...expected]); + } + }); + + test("PostgreSQL keeps escaped quoted-name lookup", () => { + const monaco = createMockMonaco(); + const cache = createSchemaCache(); + cache.columnMap.set('odd"name', [ + { label: "marker", labelLower: "marker", type: "text", isPrimary: false, tableName: 'Odd"Name' }, + ]); + registerSQLCompletionProvider(monaco, cache, "postgres"); + const line = 'SELECT "Odd""Name".'; + const result = monaco + ._getProvider()! + .provideCompletionItems(createMockModel(line), createPosition(1, line.length + 1)); + expect(result.suggestions.map((item) => item.label)).toEqual(["marker"]); + }); +});