Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,10 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
// 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(() => {
Expand Down
28 changes: 28 additions & 0 deletions src/lib/editor/postgres-identifiers.ts
Original file line number Diff line number Diff line change
@@ -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");
}
19 changes: 14 additions & 5 deletions src/lib/editor/sql-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import type * as Monaco from "monaco-editor";
import { extractAliases, resolveAlias } from "@/lib/sql";
import { formatPostgresIdentifier } from "./postgres-identifiers";
import type { DatabaseType } from "@/lib/types";

// ---------------------------------------------------------------------------
// Static constants
Expand Down Expand Up @@ -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: [".", " "],
Expand Down Expand Up @@ -239,7 +242,10 @@ export function registerSQLCompletionProvider(
.map((table) => ({
label: table.label,
kind: monaco.languages.CompletionItemKind.Class,
insertText: table.label,
// 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(formatPostgresIdentifier).join(".") : table.label,
range: tableRange,
detail: `Table (${table.rowCount} rows)`,
documentation: table.columnNames,
Expand All @@ -248,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].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();
Expand Down
43 changes: 42 additions & 1 deletion tests/components/QueryEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────
Expand Down Expand Up @@ -1895,3 +1896,43 @@ describe("QueryEditor", () => {
expect(queryByTestId("mock-monaco-editor")).not.toBeNull();
});
});

describe("QueryEditor completion dialect", () => {
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);
},
);
});
191 changes: 191 additions & 0 deletions tests/unit/sql-completions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -535,3 +536,193 @@ 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("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();
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"]);
});
});

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"]);
});
});
Loading