diff --git a/src/components/notebook/NotebookView.tsx b/src/components/notebook/NotebookView.tsx index c95615f7..cafe8d3d 100644 --- a/src/components/notebook/NotebookView.tsx +++ b/src/components/notebook/NotebookView.tsx @@ -58,6 +58,7 @@ import { createNotebookFromState, } from "../../utils/notebookStore"; import { useDatabase } from "../../hooks/useDatabase"; +import { useSqlAutocompleteRegistration } from "../../hooks/useSqlAutocompleteRegistration"; import { isMultiDatabaseCapable } from "../../utils/database"; import { useSettings } from "../../hooks/useSettings"; import { useAlert } from "../../hooks/useAlert"; @@ -90,6 +91,10 @@ export function NotebookView({ isMultiDatabaseCapable(activeCapabilities) && selectedDatabases.length > 1; const effectiveSchema = tab.schema || activeSchema || (isMultiDb ? selectedDatabases[0] : null); + useSqlAutocompleteRegistration(connectionId, { + schema: effectiveSchema, + enabled: isActive, + }); const { settings } = useSettings(); const { showAlert } = useAlert(); const { matchesShortcut } = useKeybindings(); diff --git a/src/hooks/useSqlAutocompleteRegistration.ts b/src/hooks/useSqlAutocompleteRegistration.ts new file mode 100644 index 00000000..6456a6ad --- /dev/null +++ b/src/hooks/useSqlAutocompleteRegistration.ts @@ -0,0 +1,90 @@ +import { useEffect } from "react"; +import type { Monaco } from "@monaco-editor/react"; +import { loader } from "@monaco-editor/react"; +import { useDatabase } from "./useDatabase"; +import { isMultiDatabaseCapable } from "../utils/database"; +import { registerSqlAutocomplete, disposeSqlAutocomplete } from "../utils/autocomplete"; + +type Options = { + monaco?: Monaco | null; + schema?: string | null; + /** When false, skips registration (e.g. inactive notebook tabs). Defaults to true. */ + enabled?: boolean; +}; + +/** + * Keeps the global SQL completion provider in sync with the active connection. + * Pass `monaco` from the main editor when available; otherwise Monaco is loaded via loader.init (notebook). + */ +export function useSqlAutocompleteRegistration( + connectionId: string | null, + options?: Options, +) { + const { + tables, + activeDriver, + activeSchema, + activeCapabilities, + schemaDataMap, + databaseDataMap, + selectedDatabases, + } = useDatabase(); + + const schema = options?.schema ?? activeSchema; + const isMultiDb = + isMultiDatabaseCapable(activeCapabilities) && selectedDatabases.length > 1; + + const enabled = options?.enabled ?? true; + + useEffect(() => { + if (!connectionId || !enabled) return; + + let cancelled = false; + + const register = (monaco: Monaco) => { + if (cancelled) return; + + let effectiveTables = tables; + if (activeCapabilities?.schemas && schema) { + effectiveTables = schemaDataMap[schema]?.tables ?? tables; + } else if (isMultiDb) { + effectiveTables = selectedDatabases.flatMap( + (db) => databaseDataMap[db]?.tables ?? [], + ); + } + + registerSqlAutocomplete( + monaco, + connectionId, + effectiveTables, + schema, + activeDriver ?? null, + ); + }; + + const cleanup = () => { + cancelled = true; + disposeSqlAutocomplete(); + }; + + if (options?.monaco) { + register(options.monaco); + return cleanup; + } + + loader.init().then((monaco) => register(monaco)); + return cleanup; + }, [ + connectionId, + enabled, + options?.monaco, + schema, + tables, + activeDriver, + activeCapabilities, + schemaDataMap, + databaseDataMap, + isMultiDb, + selectedDatabases, + ]); +} diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index e910e27d..8b09a7d1 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -81,8 +81,8 @@ import { import { formatDuration } from "../utils/formatTime"; import { SqlEditorWrapper } from "../components/ui/SqlEditorWrapper"; import { NotebookView } from "../components/notebook/NotebookView"; +import { useSqlAutocompleteRegistration } from "../hooks/useSqlAutocompleteRegistration"; import { createNotebook, renameNotebook } from "../utils/notebookStore"; -import { registerSqlAutocomplete } from "../utils/autocomplete"; import { type OnMount, type Monaco } from "@monaco-editor/react"; import { save } from "@tauri-apps/plugin-dialog"; import { useAlert } from "../hooks/useAlert"; @@ -140,7 +140,6 @@ export const Editor = () => { const { activeConnectionId, connections, - tables, views, activeDriver, activeSchema, @@ -148,8 +147,6 @@ export const Editor = () => { selectedDatabases, activeConnectionName, activeDatabaseName, - schemaDataMap, - databaseDataMap, } = useDatabase(); const { allDrivers } = useDrivers(); const { explorerConnectionId } = useConnectionLayoutContext(); @@ -2213,25 +2210,11 @@ export const Editor = () => { }); }; - useEffect(() => { - if (monacoInstance && activeConnectionId) { - let effectiveTables = tables; - if (activeCapabilities?.schemas && activeSchema) { - effectiveTables = schemaDataMap[activeSchema]?.tables ?? tables; - } else if (isMultiDb) { - effectiveTables = selectedDatabases.flatMap(db => - (databaseDataMap[db]?.tables ?? []).map(t => ({ ...t, schema: db })) - ); - } - const disposable = registerSqlAutocomplete( - monacoInstance, - activeConnectionId, - effectiveTables, - activeSchema, - ); - return () => disposable.dispose(); - } - }, [monacoInstance, activeConnectionId, tables, activeSchema, activeCapabilities, schemaDataMap, databaseDataMap, isMultiDb, selectedDatabases]); + useSqlAutocompleteRegistration(activeConnectionId, { + monaco: monacoInstance, + schema: activeSchema, + enabled: !isNotebookTab, + }); useEffect(() => { const state = location.state as EditorState; diff --git a/src/utils/autocomplete.ts b/src/utils/autocomplete.ts index ef8b344e..6d0304e2 100644 --- a/src/utils/autocomplete.ts +++ b/src/utils/autocomplete.ts @@ -1,6 +1,7 @@ import type { Monaco } from "@monaco-editor/react"; import { invoke } from "@tauri-apps/api/core"; import type { TableInfo } from "../contexts/DatabaseContext"; +import { formatSqlIdentifier, getQuoteChar, quoteIdentifier } from "./identifiers"; import { getCurrentStatement, parseTablesFromQuery, type ParsedTableRef } from "./sqlAnalysis"; // Lightweight column cache with TTL and size limits @@ -103,11 +104,19 @@ export const clearAutocompleteCache = (connectionId?: string) => { } }; +let sqlCompletionProvider: { dispose: () => void } | null = null; + +export const disposeSqlAutocomplete = (): void => { + sqlCompletionProvider?.dispose(); + sqlCompletionProvider = null; +}; + export const registerSqlAutocomplete = ( monaco: Monaco, connectionId: string | null, tables: TableInfo[], schema?: string | null, + driver?: string | null, ) => { const provider = monaco.languages.registerCompletionItemProvider("sql", { triggerCharacters: [".", " "], @@ -123,6 +132,51 @@ export const registerSqlAutocomplete = ( endColumn: wordUntil.endColumn, }; + // When the user has already typed an opening quote, Monaco auto-closes it + // so the cursor sits inside a pair (`"|"`); the user may also have deleted + // the closing one (`"|`). Inserting a freshly quoted identifier into the + // word range would then double the opening quote (`""AccountEventLog"`) or + // leave it dangling. So when an opening quote precedes the replacement + // range, expand the range to swallow it (and the closing quote if present) + // and emit a fully-quoted identifier — yielding a canonical result for 0, 1 + // or 2 surrounding quotes alike. + const quoteChar = getQuoteChar(driver); + const charAt = (column: number): string => + column < 1 + ? "" + : model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: column, + endLineNumber: position.lineNumber, + endColumn: column + 1, + }); + const buildIdentifierInsert = ( + name: string, + baseRange: { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number }, + ): { + insertText: string; + range: { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number }; + filterText?: string; + } => { + if (baseRange.startColumn <= 1 || charAt(baseRange.startColumn - 1) !== quoteChar) { + return { insertText: formatSqlIdentifier(name, driver), range: baseRange }; + } + const swallowsClosing = charAt(baseRange.endColumn) === quoteChar; + const insertText = quoteIdentifier(name, driver); + return { + insertText, + // The range now starts at the opening quote, so Monaco filters items + // against the leading quote; match it by giving filterText the same + // quoted form, otherwise every suggestion gets filtered out. + filterText: insertText, + range: { + ...baseRange, + startColumn: baseRange.startColumn - 1, + endColumn: swallowsClosing ? baseRange.endColumn + 1 : baseRange.endColumn, + }, + }; + }; + // Get text until cursor position const textUntilPosition = model.getValueInRange({ startLineNumber: position.lineNumber, @@ -139,7 +193,6 @@ export const registerSqlAutocomplete = ( // ============================================ // 1. DOT TRIGGER (table.column, alias.column, or db.table.column) // ============================================ - // Try qualified (db.table.) first, then simple (table.) const qualifiedDotMatch = textUntilPosition.match(/`?([a-zA-Z0-9_]+)`?\.`?([a-zA-Z0-9_]+)`?\.([a-zA-Z0-9_]*)$/); const simpleDotMatch = qualifiedDotMatch ? null : textUntilPosition.match(/(?:["'`])?([a-zA-Z0-9_]+)(?:["'`])?\.([a-zA-Z0-9_]*)$/); @@ -190,12 +243,13 @@ export const registerSqlAutocomplete = ( label: c.label, kind: monaco.languages.CompletionItemKind.Field, detail: c.detail, - insertText: c.label, - range: columnRange, + ...buildIdentifierInsert(c.label, columnRange), sortText: `0_${c.label}`, })), }; } + + return { suggestions: [] }; } // ============================================ @@ -208,6 +262,7 @@ export const registerSqlAutocomplete = ( insertText: string; range: { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number }; sortText: string; + filterText?: string; }> = []; if (tableAliases && tableAliases.size > 0) { @@ -263,8 +318,7 @@ export const registerSqlAutocomplete = ( label: col.label, kind: monaco.languages.CompletionItemKind.Field, detail: `${col.detail} — ${table.name}${aliasHint}`, - insertText: col.label, - range, + ...buildIdentifierInsert(col.label, range), sortText: `0_${col.label}`, }); } @@ -293,8 +347,7 @@ export const registerSqlAutocomplete = ( label: t.name, kind: monaco.languages.CompletionItemKind.Class, detail: "Table", - insertText: t.name, - range, + ...buildIdentifierInsert(t.name, range), sortText: `1_${t.name}` })); @@ -308,5 +361,7 @@ export const registerSqlAutocomplete = ( }, }); + sqlCompletionProvider?.dispose(); + sqlCompletionProvider = provider; return provider; }; diff --git a/src/utils/identifiers.ts b/src/utils/identifiers.ts index 466fc062..07a4a62a 100644 --- a/src/utils/identifiers.ts +++ b/src/utils/identifiers.ts @@ -36,6 +36,14 @@ export function shouldQuoteIdentifiers( return driver === "postgres"; } +// PostgreSQL folds unquoted identifiers to lowercase and only needs quotes for +// reserved words, mixed case, or special characters — mirroring quote_ident(). +const PG_SAFE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/; +const PG_RESERVED = new Set([ + "select", "from", "where", "table", "user", "order", "group", "join", "and", "or", + "as", "in", "on", "by", "null", "true", "false", "default", "check", "column", "limit", "offset", +]); + /** * Formats a SQL identifier for WHERE / ORDER BY fragments. * Quotes only when required (PostgreSQL); otherwise returns the name unchanged. @@ -44,9 +52,11 @@ export function formatSqlIdentifier( identifier: string, driver: string | null | undefined, ): string { - return shouldQuoteIdentifiers(driver) - ? quoteIdentifier(identifier, driver) - : identifier; + if (!shouldQuoteIdentifiers(driver)) return identifier; + if (PG_SAFE_IDENTIFIER.test(identifier) && !PG_RESERVED.has(identifier)) { + return identifier; + } + return quoteIdentifier(identifier, driver); } export function quoteIdentifier( @@ -76,3 +86,4 @@ export function quoteTableRef( } return quoteIdentifier(table, driver); } + diff --git a/src/utils/sqlAnalysis.ts b/src/utils/sqlAnalysis.ts index 581691ea..2f05ddec 100644 --- a/src/utils/sqlAnalysis.ts +++ b/src/utils/sqlAnalysis.ts @@ -5,6 +5,14 @@ export interface ParsedTableRef { schema?: string; } +// Removes wrapping SQL identifier quotes/backticks. +// Unquoted identifiers are normalized to lowercase. +function stripIdentifierQuotes(token: string): string { + const q = token[0]; + if (q === '"' || q === '`') return token.slice(1, -1).replaceAll(q + q, q); + return token.toLowerCase(); +} + // Isolate the FROM/JOIN section of a SQL statement so clause keywords // (WHERE, HAVING, etc.) are never present when the alias-capture regex runs. const extractFromSection = (sql: string): string => { @@ -23,8 +31,7 @@ const extractFromSection = (sql: string): string => { .replace(/\busing\s*\([^)]*\)/gi, ' '); }; -// Optimized table parser - returns alias → ParsedTableRef. -// Handles both unqualified (table) and qualified (schema.table) references. +// Returns alias → ParsedTableRef. Handles quoted identifiers, schema.table, and comma-separated FROM. export const parseTablesFromQuery = (sql: string): Map | null => { if (!sql || sql.length === 0) return null; @@ -32,21 +39,22 @@ export const parseTablesFromQuery = (sql: string): Map | if (!fromSection) return null; const tableMap = new Map(); - // Groups: 1=first-id (schema when group 2 present, else table), 2=table (qualified), 3=alias. - // The negative-lookahead stops the optional alias group from swallowing a keyword that - // legally follows a table name (JOIN/LEFT/NATURAL/FOR/…). Without it the keyword is both - // mis-registered as an alias and consumed, dropping the table that follows it. - const fromPattern = /(?:from|join)\s+`?([a-z_][a-z0-9_]*)`?(?:\.`?([a-z_][a-z0-9_]*)`?)?(?:\s+(?:as\s+)?`?(?!(?:join|left|right|inner|outer|cross|natural|full|on|using|where|group|order|having|limit|offset|union|intersect|except|for|fetch|window|lateral|tablesample|qualify|straight_join)\b)([a-z_][a-z0-9_]*)`?)?/gi; + const fromPattern = + /(?:from|join|,)\s+("(?:[^"]|"")*"|`[^`]+`|[a-zA-Z_][a-zA-Z0-9_]*)(?:\.("(?:[^"]|"")*"|`[^`]+`|[a-zA-Z_][a-zA-Z0-9_]*))?(?:\s+(?:as\s+)?("(?:[^"]|"")*"|`[^`]+`|(?!(?:join|left|right|inner|outer|cross|natural|full|on|using|where|group|order|having|limit|offset|union|intersect|except|for|fetch|window|lateral|tablesample|qualify|straight_join)\b)[a-zA-Z_][a-zA-Z0-9_]*))?/gi; let match; let matchCount = 0; const MAX_MATCHES = 10; - while ((match = fromPattern.exec(fromSection.toLowerCase())) !== null && matchCount++ < MAX_MATCHES) { - const qualified = !!match[2]; - const tableName = qualified ? match[2] : match[1]; - const schema = qualified ? match[1] : undefined; - const alias = match[3] || tableName; + while ((match = fromPattern.exec(fromSection)) !== null && matchCount++ < MAX_MATCHES) { + const schemaToken = match[2] ? match[1] : undefined; + const tableToken = match[2] ?? match[1]; + if (!tableToken) continue; + + const tableName = stripIdentifierQuotes(tableToken); + const schema = schemaToken ? stripIdentifierQuotes(schemaToken) : undefined; + const aliasToken = match[3]; + const alias = aliasToken ? stripIdentifierQuotes(aliasToken) : tableName; tableMap.set(alias, { name: tableName, schema }); } @@ -56,20 +64,21 @@ export const parseTablesFromQuery = (sql: string): Map | // Optimized statement extractor - avoid full text scan when possible export const getCurrentStatement = (model: { getValue: () => string; getOffsetAt: (position: { lineNumber: number; column: number }) => number }, position: { lineNumber: number; column: number }): string => { const fullText = model.getValue(); - + // For small files, just return full text if (fullText.length < 500) { return fullText; } - + const offset = model.getOffsetAt(position); let start = 0; let end = fullText.length; - + + // Search within reasonable bounds (±2000 chars from cursor) const searchStart = Math.max(0, offset - 2000); const searchEnd = Math.min(fullText.length, offset + 2000); - + // Find previous semicolon for (let i = offset - 1; i >= searchStart; i--) { if (fullText[i] === ';') { @@ -77,7 +86,7 @@ export const getCurrentStatement = (model: { getValue: () => string; getOffsetAt break; } } - + // Find next semicolon for (let i = offset; i < searchEnd; i++) { if (fullText[i] === ';') { @@ -85,6 +94,6 @@ export const getCurrentStatement = (model: { getValue: () => string; getOffsetAt break; } } - + return fullText.substring(start, end).trim(); }; diff --git a/tests/utils/autocomplete.test.ts b/tests/utils/autocomplete.test.ts index 2a1f28ea..c1b32928 100644 --- a/tests/utils/autocomplete.test.ts +++ b/tests/utils/autocomplete.test.ts @@ -151,6 +151,135 @@ describe('autocomplete', () => { expect(tableSuggestions[1].label).toBe('orders'); }); + it('inserts double-quoted table names for postgres', async () => { + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'AccountEventLog' }], + null, + 'postgres', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const result = await provider.provideCompletionItems( + createMockModel('SELECT * FROM '), + { lineNumber: 1, column: 15 }, + ); + + const tableSuggestions = result.suggestions.filter((s: { sortText?: string }) => + s.sortText?.startsWith('1_'), + ); + expect(tableSuggestions[0]?.insertText).toBe('"AccountEventLog"'); + }); + + it('does not prefix schema and quotes table name only if needed for postgres', async () => { + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'AccountEventLog' }], + 'public', + 'postgres', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const result = await provider.provideCompletionItems( + createMockModel('SELECT * FROM '), + { lineNumber: 1, column: 15 }, + ); + + const tableSuggestions = result.suggestions.filter((s: { sortText?: string }) => + s.sortText?.startsWith('1_'), + ); + expect(tableSuggestions[0]?.insertText).toBe('"AccountEventLog"'); + }); + + it('swallows the auto-closed quote pair when an opening quote was typed (postgres)', async () => { + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'AccountEventLog' }], + null, + 'postgres', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + // `SELECT * FROM ""` — Monaco auto-closed the quote, cursor between the pair. + const model = createMockModel('SELECT * FROM ""'); + model.getWordUntilPosition = vi.fn(() => ({ startColumn: 16, endColumn: 16 })); + + const result = await provider.provideCompletionItems(model, { + lineNumber: 1, + column: 16, + }); + + const table = result.suggestions.find((s: { sortText?: string }) => + s.sortText?.startsWith('1_'), + ); + // Canonical quoted identifier, range swallows BOTH surrounding quotes so + // the result is exactly "AccountEventLog" (not ""AccountEventLog""). + expect(table?.insertText).toBe('"AccountEventLog"'); + expect(table?.range.startColumn).toBe(15); + expect(table?.range.endColumn).toBe(17); + // Range starts at the opening quote, so filterText must also be quoted or + // Monaco filters every suggestion out. + expect(table?.filterText).toBe('"AccountEventLog"'); + }); + + it('still closes the identifier when the auto-closed quote was deleted (postgres)', async () => { + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'AccountEventLog' }], + null, + 'postgres', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + // `SELECT * FROM "` — user deleted the auto-closed quote, only the opening one remains. + const model = createMockModel('SELECT * FROM "'); + model.getWordUntilPosition = vi.fn(() => ({ startColumn: 16, endColumn: 16 })); + + const result = await provider.provideCompletionItems(model, { + lineNumber: 1, + column: 16, + }); + + const table = result.suggestions.find((s: { sortText?: string }) => + s.sortText?.startsWith('1_'), + ); + // Full quoted identifier replaces the lone opening quote → "AccountEventLog". + expect(table?.insertText).toBe('"AccountEventLog"'); + expect(table?.range.startColumn).toBe(15); + expect(table?.range.endColumn).toBe(16); + expect(table?.filterText).toBe('"AccountEventLog"'); + }); + + it('does not quote plain lowercase table names for postgres', async () => { + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'users' }], + null, + 'postgres', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const result = await provider.provideCompletionItems( + createMockModel('SELECT * FROM '), + { lineNumber: 1, column: 15 }, + ); + + const tableSuggestions = result.suggestions.filter((s: { sortText?: string }) => + s.sortText?.startsWith('1_'), + ); + expect(tableSuggestions[0]?.insertText).toBe('users'); + }); + it('should include all table suggestions regardless of count', async () => { const monaco = createMockMonaco(); const tables: TableInfo[] = Array.from({ length: 60 }, (_, i) => ({ @@ -321,6 +450,60 @@ describe('autocomplete', () => { // Should include column suggestions expect(result.suggestions.length).toBeGreaterThan(0); }); + + it('inserts double-quoted column names for postgres', async () => { + const mockInvoke = invoke as unknown as ReturnType; + mockInvoke.mockResolvedValue([{ name: 'CreatedAt', data_type: 'timestamp' }]); + + const { parseTablesFromQuery } = await import('../../src/utils/sqlAnalysis'); + (parseTablesFromQuery as ReturnType).mockReturnValue( + new Map([['ael', { name: 'AccountEventLog' }]]), + ); + + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'AccountEventLog' }], + 'public', + 'postgres', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const model = createMockModel('SELECT ael.'); + model.getValueInRange = vi.fn(() => 'SELECT ael.'); + + const result = await provider.provideCompletionItems(model, { lineNumber: 1, column: 12 }); + + expect(result.suggestions[0]?.insertText).toBe('"CreatedAt"'); + }); + + it('does not quote plain lowercase column names for postgres', async () => { + const mockInvoke = invoke as unknown as ReturnType; + mockInvoke.mockResolvedValue([{ name: 'email', data_type: 'varchar' }]); + + const { parseTablesFromQuery } = await import('../../src/utils/sqlAnalysis'); + (parseTablesFromQuery as ReturnType).mockReturnValue( + new Map([['u', { name: 'users' }]]), + ); + + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'users' }], + 'public', + 'postgres', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const model = createMockModel('SELECT u.'); + model.getValueInRange = vi.fn(() => 'SELECT u.'); + + const result = await provider.provideCompletionItems(model, { lineNumber: 1, column: 10 }); + + expect(result.suggestions[0]?.insertText).toBe('email'); + }); }); describe('suggestion limits', () => { diff --git a/tests/utils/filterBar.test.ts b/tests/utils/filterBar.test.ts index 6bfe2f44..082c3ecb 100644 --- a/tests/utils/filterBar.test.ts +++ b/tests/utils/filterBar.test.ts @@ -297,14 +297,24 @@ describe("filterBar utils", () => { expect(buildSingleFilterClause(filter)).toBe("price >= 9.99"); }); - it("should quote the column name with double quotes for postgres driver", () => { + it("should quote mixed-case column names for postgres driver", () => { + const filter: StructuredFilter = { + id: "1", + column: "UserStatus", + operator: "=", + value: "active", + }; + expect(buildSingleFilterClause(filter, "postgres")).toBe('"UserStatus" = \'active\''); + }); + + it("should not quote plain lowercase column names for postgres driver", () => { const filter: StructuredFilter = { id: "1", column: "user_status", operator: "=", value: "active", }; - expect(buildSingleFilterClause(filter, "postgres")).toBe('"user_status" = \'active\''); + expect(buildSingleFilterClause(filter, "postgres")).toBe("user_status = 'active'"); }); it("should not quote the column name for mysql driver", () => { @@ -340,13 +350,13 @@ describe("filterBar utils", () => { ); }); - it("should quote columns for postgres in structured filters", () => { + it("should quote columns that require quoting for postgres in structured filters", () => { const filters: StructuredFilter[] = [ - { id: "1", column: "status", operator: "=", value: "active" }, - { id: "2", column: "age", operator: ">", value: "18" }, + { id: "1", column: "UserStatus", operator: "=", value: "active" }, + { id: "2", column: "order", operator: ">", value: "18" }, ]; expect(buildStructuredFilterClause(filters, "postgres")).toBe( - '"status" = \'active\' AND "age" > 18' + '"UserStatus" = \'active\' AND "order" > 18' ); }); diff --git a/tests/utils/identifiers.test.ts b/tests/utils/identifiers.test.ts index 7bc83164..3348005b 100644 --- a/tests/utils/identifiers.test.ts +++ b/tests/utils/identifiers.test.ts @@ -103,18 +103,39 @@ describe('quoteTableRef', () => { }); describe('formatSqlIdentifier', () => { - it('should quote identifiers for postgres', () => { + it('should not quote plain lowercase identifiers for postgres', () => { + expect(formatSqlIdentifier('users', 'postgres')).toBe('users'); + expect(formatSqlIdentifier('user_status', 'postgres')).toBe('user_status'); + }); + + it('should quote mixed case identifiers for postgres', () => { expect(formatSqlIdentifier('Status', 'postgres')).toBe('"Status"'); - expect(formatSqlIdentifier('user_status', 'postgres')).toBe('"user_status"'); + expect(formatSqlIdentifier('AccountEventLog', 'postgres')).toBe('"AccountEventLog"'); + expect(formatSqlIdentifier('AccountId', 'postgres')).toBe('"AccountId"'); + }); + + it('should quote reserved words for postgres', () => { + expect(formatSqlIdentifier('select', 'postgres')).toBe('"select"'); + expect(formatSqlIdentifier('user', 'postgres')).toBe('"user"'); + expect(formatSqlIdentifier('table', 'postgres')).toBe('"table"'); + }); + + it('should quote identifiers with special characters or spaces for postgres', () => { + expect(formatSqlIdentifier('my table', 'postgres')).toBe('"my table"'); + expect(formatSqlIdentifier('table-name', 'postgres')).toBe('"table-name"'); }); it('should leave identifiers unchanged for mysql', () => { expect(formatSqlIdentifier('Status', 'mysql')).toBe('Status'); expect(formatSqlIdentifier('user_status', 'mariadb')).toBe('user_status'); + expect(formatSqlIdentifier('AccountEventLog', 'mysql')).toBe('AccountEventLog'); + expect(formatSqlIdentifier('select', 'mysql')).toBe('select'); }); it('should leave identifiers unchanged for sqlite and unknown drivers', () => { expect(formatSqlIdentifier('Status', 'sqlite')).toBe('Status'); expect(formatSqlIdentifier('Status', null)).toBe('Status'); + expect(formatSqlIdentifier('users', 'sqlite')).toBe('users'); + expect(formatSqlIdentifier('AccountEventLog', 'sqlite')).toBe('AccountEventLog'); }); }); \ No newline at end of file diff --git a/tests/utils/sqlAnalysis.test.ts b/tests/utils/sqlAnalysis.test.ts index 982630a4..4d1f6ce6 100644 --- a/tests/utils/sqlAnalysis.test.ts +++ b/tests/utils/sqlAnalysis.test.ts @@ -78,6 +78,23 @@ describe('sqlAnalysis utils', () => { expect(r?.get('users')).toEqual({ name: 'users', schema: 'db1' }); expect(r?.get('orders')).toEqual({ name: 'orders', schema: 'db2' }); }); + + it('should extract PostgreSQL double-quoted table with alias', () => { + const result = parseTablesFromQuery('SELECT ael. FROM "AccountEventLog" ael'); + expect(result?.get('ael')?.name).toBe('AccountEventLog'); + }); + + it('should extract schema-qualified table with alias', () => { + const result = parseTablesFromQuery('SELECT u. FROM public.users u'); + expect(result?.get('u')?.name).toBe('users'); + expect(result?.get('u')?.schema).toBe('public'); + }); + + it('should extract comma-separated FROM tables', () => { + const result = parseTablesFromQuery('SELECT * FROM users u, orders o'); + expect(result?.get('u')?.name).toBe('users'); + expect(result?.get('o')?.name).toBe('orders'); + }); }); describe('getCurrentStatement', () => {