From 424a669b562a26442f4a26b31063e009eb3835ea Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 16 Jul 2026 19:19:48 +0300 Subject: [PATCH 1/3] perf(XS-41): Optimize SQL Suggestions --- e2e/specs/editor/autocomplete.spec.ts | 40 +- .../xensql/internal/database/models.ts | 2 + frontend/src/features/editor/SqlEditor.tsx | 20 +- .../src/features/editor/hooks/useRunGlyphs.ts | 6 +- .../editor/hooks/useSqlDiagnostics.ts | 51 ++ .../editor/lib/createSqlCompletionProvider.ts | 27 +- .../editor/lib/createSqlHoverProvider.ts | 55 ++ .../features/editor/lib/sqlCompletion.test.ts | 355 ++++++++++++- .../src/features/editor/lib/sqlCompletion.ts | 502 ++++++++---------- .../editor/lib/sqlCompletionContext.ts | 205 ------- .../src/features/editor/lib/sqlContext.ts | 466 ++++++++++++++++ .../editor/lib/sqlDiagnostics.test.ts | 60 +++ .../src/features/editor/lib/sqlDiagnostics.ts | 33 ++ .../src/features/editor/lib/sqlHover.test.ts | 73 +++ frontend/src/features/editor/lib/sqlHover.ts | 106 ++++ .../src/features/editor/lib/sqlIdentifiers.ts | 20 - .../features/editor/lib/sqlQueryParse.test.ts | 83 +++ .../src/features/editor/lib/sqlQueryParse.ts | 362 ++++++++++--- ...Identifiers.test.ts => sqlQuoting.test.ts} | 2 +- .../src/features/editor/lib/sqlQuoting.ts | 28 +- .../features/editor/lib/sqlStatements.test.ts | 78 +++ .../src/features/editor/lib/sqlStatements.ts | 151 +++--- .../src/features/editor/lib/sqlSuggestions.ts | 375 ++++++------- frontend/src/features/editor/lib/sqlText.ts | 103 ++++ .../src/features/editor/lib/sqlTokens.test.ts | 130 +++++ frontend/src/features/editor/lib/sqlTokens.ts | 172 ++++++ frontend/src/features/sidebar/SchemaPanel.tsx | 2 +- frontend/src/shared/lib/normalize.ts | 4 + frontend/src/types/index.ts | 4 + internal/app/app_query.go | 10 +- internal/database/exec.go | 21 +- internal/database/mysql/driver.go | 34 +- internal/database/postgres/driver.go | 37 +- internal/database/readonly.go | 25 +- internal/database/readonly_extra_test.go | 2 +- internal/database/readonly_test.go | 20 + internal/database/runscript_test.go | 4 +- internal/database/session_base.go | 2 +- internal/database/sqlite/driver.go | 32 +- internal/database/sqlite/driver_test.go | 3 + internal/database/statements.go | 174 ++++-- internal/database/statements_test.go | 77 ++- internal/database/types.go | 4 +- internal/database/util.go | 22 +- internal/database/util_test.go | 23 + internal/windowtheme/windowtheme_darwin.go | 6 +- 46 files changed, 3017 insertions(+), 994 deletions(-) create mode 100644 frontend/src/features/editor/hooks/useSqlDiagnostics.ts create mode 100644 frontend/src/features/editor/lib/createSqlHoverProvider.ts delete mode 100644 frontend/src/features/editor/lib/sqlCompletionContext.ts create mode 100644 frontend/src/features/editor/lib/sqlContext.ts create mode 100644 frontend/src/features/editor/lib/sqlDiagnostics.test.ts create mode 100644 frontend/src/features/editor/lib/sqlDiagnostics.ts create mode 100644 frontend/src/features/editor/lib/sqlHover.test.ts create mode 100644 frontend/src/features/editor/lib/sqlHover.ts delete mode 100644 frontend/src/features/editor/lib/sqlIdentifiers.ts create mode 100644 frontend/src/features/editor/lib/sqlQueryParse.test.ts rename frontend/src/features/editor/lib/{sqlIdentifiers.test.ts => sqlQuoting.test.ts} (98%) create mode 100644 frontend/src/features/editor/lib/sqlText.ts create mode 100644 frontend/src/features/editor/lib/sqlTokens.test.ts create mode 100644 frontend/src/features/editor/lib/sqlTokens.ts diff --git a/e2e/specs/editor/autocomplete.spec.ts b/e2e/specs/editor/autocomplete.spec.ts index a5d31b4..543c725 100644 --- a/e2e/specs/editor/autocomplete.spec.ts +++ b/e2e/specs/editor/autocomplete.spec.ts @@ -1,5 +1,6 @@ -import { POSTGRES } from '@support/databases'; +import { POSTGRES, SQLITE } from '@support/databases'; import { expect, test } from '@support/fixtures'; +import { uniqueIdent } from '@support/seed'; test.describe('Editor autocomplete', () => { test('shows autocomplete suggestions', async ({ connections, editor }) => { @@ -12,4 +13,41 @@ test.describe('Editor autocomplete', () => { await expect(editor.suggestWidget).toBeVisible(); await expect(editor.suggestWidget.locator('.monaco-list-row').first()).toBeVisible(); }); + + // SQLite so the test needs no database container. + test('suggests a seeded table, its columns, and the FK join condition', async ({ + connections, + editor, + schema, + seed, + app, + }) => { + await connections.createAndConnect(SQLITE); + + const parent = await seed.table('ac_parent'); + const child = uniqueIdent('ac_child'); + await editor.run(`CREATE TABLE ${child} (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES ${parent}(id));`); + await app.expectStatementApplied(); + await schema.refresh(); + + await editor.clear(); + await editor.type(`SELECT * FROM ${parent.slice(0, 12)}`); + await editor.triggerSuggestions(); + await expect(editor.suggestWidget.locator('.monaco-list-row', { hasText: parent })).toBeVisible(); + await editor.page.keyboard.press('Escape'); + + await editor.clear(); + await editor.type(`SELECT * FROM ${parent} WHERE na`); + await editor.triggerSuggestions(); + await expect(editor.suggestWidget.locator('.monaco-list-row', { hasText: 'name' }).first()).toBeVisible(); + await editor.page.keyboard.press('Escape'); + + await editor.clear(); + await editor.type(`SELECT * FROM ${parent} JOIN ${child} ON `); + await editor.triggerSuggestions(); + await expect( + editor.suggestWidget.locator('.monaco-list-row', { hasText: `${child}.parent_id = ${parent}.id` }).first(), + ).toBeVisible(); + await editor.page.keyboard.press('Escape'); + }); }); diff --git a/frontend/bindings/xensql/internal/database/models.ts b/frontend/bindings/xensql/internal/database/models.ts index a6dd9fe..42f98d2 100644 --- a/frontend/bindings/xensql/internal/database/models.ts +++ b/frontend/bindings/xensql/internal/database/models.ts @@ -11,6 +11,8 @@ export class ColumnInfo { "isNullable": boolean; "isPrimary": boolean; "isForeign": boolean; + "foreignTable"?: string; + "foreignColumn"?: string; "defaultVal"?: string; /** Creates a new ColumnInfo instance. */ diff --git a/frontend/src/features/editor/SqlEditor.tsx b/frontend/src/features/editor/SqlEditor.tsx index b240394..d136b5c 100644 --- a/frontend/src/features/editor/SqlEditor.tsx +++ b/frontend/src/features/editor/SqlEditor.tsx @@ -9,7 +9,9 @@ import { useEditorFontSize } from '@/features/editor/hooks/useEditorFontSize'; import { useJumpToError } from '@/features/editor/hooks/useJumpToError'; import { useRunGlyphs } from '@/features/editor/hooks/useRunGlyphs'; import { useSidebarInsert } from '@/features/editor/hooks/useSidebarInsert'; +import { useSqlDiagnostics } from '@/features/editor/hooks/useSqlDiagnostics'; import { createSqlCompletionProvider } from '@/features/editor/lib/createSqlCompletionProvider'; +import { createSqlHoverProvider } from '@/features/editor/lib/createSqlHoverProvider'; import { monacoFontOptions } from '@/features/editor/lib/editorFontSize'; import { getMonacoThemeName, setupMonacoBeforeMount } from '@/features/editor/lib/monacoTheme'; import { findStatementAtRunLine } from '@/features/editor/lib/sqlStatements'; @@ -170,7 +172,8 @@ export const SqlEditor = memo(function SqlEditor({ } }, []); - const { updateRunGlyphs, statementsRef } = useRunGlyphs(editorRef, monacoRef, sql, languageRevision); + const { updateRunGlyphs, statementsRef } = useRunGlyphs(editorRef, monacoRef, sql, languageRevision, driver); + useSqlDiagnostics(editorRef, monacoRef, sql, allTables, schemas, driver); const { bindEditorActions } = useEditorActions({ editorRef, @@ -198,7 +201,7 @@ export const SqlEditor = memo(function SqlEditor({ if (!monaco || !ed) return; completionProviderRef.current?.dispose(); - completionProviderRef.current = monaco.languages.registerCompletionItemProvider( + const completion = monaco.languages.registerCompletionItemProvider( 'sql', createSqlCompletionProvider(monaco, ed, () => { const { @@ -211,6 +214,19 @@ export const SqlEditor = memo(function SqlEditor({ return { schemas: s, allTables: at, tablesBySchema: tbs, onLoadColumns: loadCols, driver: drv }; }), ); + const hover = monaco.languages.registerHoverProvider( + 'sql', + createSqlHoverProvider(ed, () => { + const { schemas: s, allTables: at, onLoadColumns: loadCols, driver: drv } = completionCtxRef.current; + return { schemas: s, allTables: at, onLoadColumns: loadCols, driver: drv }; + }), + ); + completionProviderRef.current = { + dispose: () => { + completion.dispose(); + hover.dispose(); + }, + }; }, [isActive]); const handleEditorMount = (ed: editor.IStandaloneCodeEditor, monaco: Monaco) => { diff --git a/frontend/src/features/editor/hooks/useRunGlyphs.ts b/frontend/src/features/editor/hooks/useRunGlyphs.ts index 0dd8f77..7773fc1 100644 --- a/frontend/src/features/editor/hooks/useRunGlyphs.ts +++ b/frontend/src/features/editor/hooks/useRunGlyphs.ts @@ -3,12 +3,14 @@ import type { editor } from 'monaco-editor'; import { type RefObject, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { parseSqlStatements, type SqlStatement } from '@/features/editor/lib/sqlStatements'; +import type { DriverType } from '@/types'; export function useRunGlyphs( editorRef: RefObject, monacoRef: RefObject, sql: string, languageRevision: number, + driver: DriverType, ) { const { t } = useTranslation(); const runGlyphDecorationsRef = useRef(null); @@ -16,7 +18,7 @@ export function useRunGlyphs( const updateRunGlyphs = useCallback( (ed: editor.IStandaloneCodeEditor, monaco: Monaco, text: string) => { - const statements = parseSqlStatements(text); + const statements = parseSqlStatements(text, driver); statementsRef.current = statements; const decorations = statements.map((stmt) => ({ @@ -34,7 +36,7 @@ export function useRunGlyphs( runGlyphDecorationsRef.current.set(decorations); } }, - [t], + [t, driver], ); useEffect(() => { diff --git a/frontend/src/features/editor/hooks/useSqlDiagnostics.ts b/frontend/src/features/editor/hooks/useSqlDiagnostics.ts new file mode 100644 index 0000000..85220c1 --- /dev/null +++ b/frontend/src/features/editor/hooks/useSqlDiagnostics.ts @@ -0,0 +1,51 @@ +import type { Monaco } from '@monaco-editor/react'; +import type { editor } from 'monaco-editor'; +import { type RefObject, useEffect } from 'react'; +import { collectSchemaDiagnostics } from '@/features/editor/lib/sqlDiagnostics'; +import type { DriverType, SchemaInfo, TableInfo } from '@/types'; + +const SCHEMA_MARKER_OWNER = 'xensql-schema'; + +// Warns on table names missing from the connected schema. Debounced, and the ref under the +// caret is skipped so the half-typed `FROM use` doesn't flicker while completing to `users`. +export function useSqlDiagnostics( + editorRef: RefObject, + monacoRef: RefObject, + sql: string, + tables: TableInfo[], + schemas: SchemaInfo[], + driver: DriverType, +) { + useEffect(() => { + const ed = editorRef.current; + const monaco = monacoRef.current; + const model = ed?.getModel(); + if (!ed || !monaco || !model) return; + + const handle = setTimeout(() => { + if (model.isDisposed()) return; + const cursor = ed.getPosition(); + const cursorOffset = cursor ? model.getOffsetAt(cursor) : -1; + const markers = collectSchemaDiagnostics(sql, tables, schemas, driver) + .filter((d) => cursorOffset < d.start || cursorOffset > d.end) + .map((d) => { + const s = model.getPositionAt(d.start); + const e = model.getPositionAt(d.end); + return { + severity: monaco.MarkerSeverity.Warning, + message: d.message, + startLineNumber: s.lineNumber, + startColumn: s.column, + endLineNumber: e.lineNumber, + endColumn: e.column, + }; + }); + monaco.editor.setModelMarkers(model, SCHEMA_MARKER_OWNER, markers); + }, 300); + + return () => { + clearTimeout(handle); + if (!model.isDisposed()) monaco.editor.setModelMarkers(model, SCHEMA_MARKER_OWNER, []); + }; + }, [editorRef, monacoRef, sql, tables, schemas, driver]); +} diff --git a/frontend/src/features/editor/lib/createSqlCompletionProvider.ts b/frontend/src/features/editor/lib/createSqlCompletionProvider.ts index 54e05a9..67bf627 100644 --- a/frontend/src/features/editor/lib/createSqlCompletionProvider.ts +++ b/frontend/src/features/editor/lib/createSqlCompletionProvider.ts @@ -67,7 +67,7 @@ export function createSqlCompletionProvider( const offset = model.getOffsetAt(position); // Scope to the current statement so clause context and table bindings don't leak across `;`. const { start: statementStart, end: statementEnd } = currentStatementRange( - parseSqlStatements(text), + parseSqlStatements(text, driver), offset, text.length, ); @@ -76,16 +76,11 @@ export function createSqlCompletionProvider( // Parse only the current statement; loadCols is cached per connection. const parsed = parseQueryContext(text.slice(statementStart, statementEnd), allTables, schemas, driver); const columnsByTable: Record = {}; - for (const ref of bindingsNeedingColumns(textBefore, parsed, { - tables: allTables, - schemas, - driver, - })) { - const key = columnCacheKey(ref.schema, ref.table); - if (!columnsByTable[key]) { - columnsByTable[key] = await loadCols(ref.schema, ref.table); - } - } + await Promise.all( + bindingsNeedingColumns(textBefore, parsed, { tables: allTables, schemas, driver }).map(async (ref) => { + columnsByTable[columnCacheKey(ref.schema, ref.table)] = await loadCols(ref.schema, ref.table); + }), + ); const ctx: CompletionContext = { schemas, @@ -98,10 +93,12 @@ export function createSqlCompletionProvider( const word = model.getWordUntilPosition(position); const items = buildCompletionItems({ ctx, text, position: offset, parsed, statementStart }); - const range = completionReplaceRange(position, textBefore, { - startColumn: word.startColumn, - endColumn: word.endColumn, - }); + const range = completionReplaceRange( + position, + textBefore, + { startColumn: word.startColumn, endColumn: word.endColumn }, + driver, + ); return { suggestions: items.map((item) => toMonacoCompletion(item, monaco, range)), }; diff --git a/frontend/src/features/editor/lib/createSqlHoverProvider.ts b/frontend/src/features/editor/lib/createSqlHoverProvider.ts new file mode 100644 index 0000000..d6990fb --- /dev/null +++ b/frontend/src/features/editor/lib/createSqlHoverProvider.ts @@ -0,0 +1,55 @@ +import type { editor, languages } from 'monaco-editor'; +import { analyzeHover, columnHoverLines } from '@/features/editor/lib/sqlHover'; +import { parseQueryContext } from '@/features/editor/lib/sqlQueryParse'; +import { currentStatementRange, parseSqlStatements } from '@/features/editor/lib/sqlStatements'; +import type { ColumnInfo, DriverType, SchemaInfo, TableInfo } from '@/types'; + +export interface HoverProviderCtx { + schemas: SchemaInfo[]; + allTables: TableInfo[]; + onLoadColumns: (schema: string, table: string) => Promise; + driver: DriverType; +} + +export function createSqlHoverProvider( + ed: editor.IStandaloneCodeEditor, + getCtx: () => HoverProviderCtx, +): languages.HoverProvider { + return { + provideHover: async (model, position) => { + if (model !== ed.getModel()) return null; + const { schemas, allTables, onLoadColumns, driver } = getCtx(); + + const text = model.getValue(); + const offset = model.getOffsetAt(position); + const { start, end } = currentStatementRange(parseSqlStatements(text, driver), offset, text.length); + const stmt = text.slice(start, end); + const parsed = parseQueryContext(stmt, allTables, schemas, driver); + const query = analyzeHover(stmt, offset - start, parsed, allTables, schemas, driver); + if (!query) return null; + + const s = model.getPositionAt(start + query.start); + const e = model.getPositionAt(start + query.end); + const range = { + startLineNumber: s.lineNumber, + startColumn: s.column, + endLineNumber: e.lineNumber, + endColumn: e.column, + }; + + if (query.lines) { + return { range, contents: query.lines.map((value) => ({ value })) }; + } + if (query.columnLookup) { + for (const binding of query.columnLookup.bindings) { + const cols = await onLoadColumns(binding.schema, binding.table); + const col = cols.find((c) => c.name.toLowerCase() === query.columnLookup?.name); + if (col) { + return { range, contents: columnHoverLines(col, binding).map((value) => ({ value })) }; + } + } + } + return null; + }, + }; +} diff --git a/frontend/src/features/editor/lib/sqlCompletion.test.ts b/frontend/src/features/editor/lib/sqlCompletion.test.ts index 38afac5..1aa5e6b 100644 --- a/frontend/src/features/editor/lib/sqlCompletion.test.ts +++ b/frontend/src/features/editor/lib/sqlCompletion.test.ts @@ -4,7 +4,7 @@ import { buildCompletionItems, completionReplaceRange, } from '@/features/editor/lib/sqlCompletion'; -import { clauseBodyStart, isOrderOrGroupContext } from '@/features/editor/lib/sqlCompletionContext'; +import { analyzeSqlCursor } from '@/features/editor/lib/sqlContext'; import { parseQueryContext } from '@/features/editor/lib/sqlQueryParse'; import { columnCacheKey, identifierNeedsQuote } from '@/features/editor/lib/sqlQuoting'; import { currentStatementStart, parseSqlStatements } from '@/features/editor/lib/sqlStatements'; @@ -76,17 +76,20 @@ describe('columns appear right after a clause keyword (no trailing space)', () = } it('also covers HAVING, FROM and UPDATE … SET', () => { - expect(clauseBodyStart('SELECT * FROM users GROUP BY x HAVING')).toBe('filter'); - expect(clauseBodyStart('SELECT * FROM')).toBe('table'); - expect(clauseBodyStart('SELECT * FROM users JOIN')).toBe('table'); - expect(clauseBodyStart('UPDATE users SET')).toBe('set'); + const slot = (sql: string) => analyzeSqlCursor(sql, 'postgres').slot; + expect(slot('SELECT * FROM users GROUP BY x HAVING').kind).toBe('filter-start'); + expect(slot('SELECT * FROM')).toMatchObject({ kind: 'table', leadingSpace: true }); + expect(slot('SELECT * FROM users JOIN')).toMatchObject({ kind: 'table', leadingSpace: true }); + expect(slot('UPDATE users SET')).toMatchObject({ kind: 'set-column', leadingSpace: true }); }); it('does not mistake identifiers ending in a keyword for the keyword', () => { // `brand`/`elsewhere`/`person` end in AND/WHERE/ON but are not keywords. - expect(clauseBodyStart('SELECT brand')).toBeNull(); - expect(clauseBodyStart('SELECT elsewhere')).toBeNull(); - expect(clauseBodyStart('SELECT * FROM person')).toBeNull(); + const slot = (sql: string) => analyzeSqlCursor(sql, 'postgres').slot; + expect(slot('SELECT brand').kind).toBe('general'); + expect(slot('SELECT elsewhere').kind).toBe('general'); + // A partial table name after FROM is a prefix filter, never a butted clause keyword. + expect(slot('SELECT * FROM person')).toMatchObject({ kind: 'table', prefix: 'person', leadingSpace: false }); }); }); @@ -122,8 +125,8 @@ describe('ORDER BY / GROUP BY suggest columns', () => { }); it('stops once a terminating clause follows the BY list', () => { - expect(isOrderOrGroupContext('SELECT * FROM users GROUP BY id HAVING ')).toBe(false); - expect(isOrderOrGroupContext('SELECT * FROM users ORDER BY id LIMIT ')).toBe(false); + expect(analyzeSqlCursor('SELECT * FROM users GROUP BY id HAVING ', 'postgres').slot.kind).not.toBe('order-group'); + expect(analyzeSqlCursor('SELECT * FROM users ORDER BY id LIMIT ', 'postgres').slot.kind).toBe('limit'); }); }); @@ -151,13 +154,19 @@ describe('existing contexts are unchanged', () => { it('suggests value columns after a comparison operator', () => { expect(columnLabels('SELECT * FROM users WHERE id = ')).toEqual(expect.arrayContaining(ALL_COLS)); - expect(clauseBodyStart('SELECT * FROM users WHERE id =')).toBeNull(); + expect(analyzeSqlCursor('SELECT * FROM users WHERE id =', 'postgres').slot.kind).toBe('value'); }); it('suggests columns in UPDATE … SET', () => { expect(columnLabels('UPDATE users SET ')).toEqual(expect.arrayContaining(ALL_COLS)); }); + it('after a completed SET assignment offers WHERE, and columns again after a comma', () => { + expect(columnLabels('UPDATE users SET name = 1 ')).toEqual([]); + expect(labelsOf('UPDATE users SET name = 1 ')).toContain('WHERE'); + expect(columnLabels('UPDATE users SET name = 1, ')).toEqual(expect.arrayContaining(ALL_COLS)); + }); + it('resolves dotted alias columns in WHERE', () => { expect(columnLabels('SELECT * FROM users AS "Users" WHERE "Users".')).toEqual(expect.arrayContaining(ALL_COLS)); }); @@ -540,6 +549,330 @@ describe('CTE names from a leading WITH', () => { }); }); +describe('INSERT INTO column list', () => { + it('suggests the target table’s columns inside the paren', () => { + expect(columnLabels('INSERT INTO users (')).toEqual(expect.arrayContaining(ALL_COLS)); + expect(columnLabels('INSERT INTO public.users (')).toEqual(expect.arrayContaining(ALL_COLS)); + }); + + it('filters by the typed prefix and omits already-listed columns', () => { + expect(columnLabels('INSERT INTO users (id, em')).toEqual(['email']); + expect(columnLabels('INSERT INTO users (id, ')).toEqual(['email', 'name']); + }); + + it('does not offer columns in the VALUES paren', () => { + expect(columnLabels('INSERT INTO users (id) VALUES (')).toEqual([]); + }); + + it('loads the target table’s columns (bindingsNeedingColumns)', () => { + const text = 'INSERT INTO users ('; + const parsed = parseQueryContext(text, tables, schemas, 'postgres'); + const needed = bindingsNeedingColumns(text, parsed, { tables, schemas, driver: 'postgres' }); + expect(needed.map((b) => columnCacheKey(b.schema, b.table))).toEqual(['public.users']); + }); +}); + +describe('no completions inside comments', () => { + it('offers nothing while typing a line comment', () => { + expect(labelsOf('SELECT * FROM users -- WHERE ')).toEqual([]); + expect(labelsOf('SELECT * FROM users -- sel')).toEqual([]); + }); + + it('offers nothing inside an unterminated block comment', () => { + expect(labelsOf('SELECT * FROM users /* WHERE ')).toEqual([]); + }); + + it('recovers after the comment closes', () => { + expect(labelsOf('SELECT * FROM users /* note */ ')).toEqual(expect.arrayContaining(['WHERE', 'JOIN'])); + }); + + it('requests no column loads while in a comment', () => { + const text = 'SELECT * FROM users -- WHERE '; + const parsed = parseQueryContext(text, tables, schemas, 'postgres'); + expect(bindingsNeedingColumns(text, parsed, { tables, schemas, driver: 'postgres' })).toEqual([]); + }); +}); + +describe('clause detection ignores comment/string contents', () => { + it('a WHERE inside a comment does not create a filter context', () => { + expect(columnLabels('SELECT * FROM users /* WHERE */ ')).toEqual([]); + }); + + it('a quoted string containing = does not create a value context', () => { + expect(labelsOf("SELECT * FROM users WHERE note = 'a = b' ")).toEqual(expect.arrayContaining(['AND', 'OR'])); + }); +}); + +describe('the 100-item cap keeps the best-ranked matches', () => { + it('a prefix match listed last still survives the cap', () => { + // 150 substring matches fill the list; the lone prefix match (rank 0) must not be sliced off. + const manyTables: TableInfo[] = [ + ...Array.from({ length: 150 }, (_, i) => ({ schema: 'public', name: `also_tbl_${i}`, type: 'table' })), + { schema: 'public', name: 'tbl_exact', type: 'table' }, + ]; + const text = 'SELECT * FROM tbl'; + const ctx: CompletionContext = { + schemas, + tables: manyTables, + columns: [], + tablesBySchema: { public: manyTables }, + columnsByTable: {}, + driver: 'postgres', + }; + const items = buildCompletionItems({ + ctx, + text, + position: text.length, + parsed: parseQueryContext(text, manyTables, schemas, 'postgres'), + }); + expect(items).toHaveLength(100); + expect(items[0].label).toBe('tbl_exact'); + }); +}); + +describe('filter operators are offered per dialect', () => { + const whereCol = 'SELECT * FROM users WHERE name '; + + it('offers the negated common operators in WHERE for every driver', () => { + for (const driver of ['postgres', 'mysql', 'sqlite'] as const) { + expect(labelsOf(whereCol, driver)).toEqual(expect.arrayContaining(['LIKE', 'NOT LIKE', 'NOT IN', 'NOT BETWEEN'])); + } + }); + + it('offers ILIKE / NOT ILIKE / SIMILAR TO only on postgres', () => { + expect(labelsOf(whereCol, 'postgres')).toEqual(expect.arrayContaining(['ILIKE', 'NOT ILIKE', 'SIMILAR TO'])); + expect(labelsOf(whereCol, 'mysql')).not.toContain('ILIKE'); + expect(labelsOf(whereCol, 'sqlite')).not.toContain('ILIKE'); + }); + + it('offers REGEXP / RLIKE only on mysql and GLOB only on sqlite', () => { + expect(labelsOf(whereCol, 'mysql')).toEqual(expect.arrayContaining(['REGEXP', 'RLIKE'])); + expect(labelsOf(whereCol, 'postgres')).not.toContain('REGEXP'); + expect(labelsOf(whereCol, 'sqlite')).toEqual(expect.arrayContaining(['GLOB'])); + expect(labelsOf(whereCol, 'postgres')).not.toContain('GLOB'); + expect(labelsOf(whereCol, 'mysql')).not.toContain('GLOB'); + }); + + it('matches ILIKE while typing it', () => { + expect(labelsOf('SELECT * FROM users WHERE name ILI', 'postgres')).toContain('ILIKE'); + expect(labelsOf('SELECT * FROM users WHERE name NOT L', 'postgres')).toContain('NOT LIKE'); + }); + + it('offers them in ON and HAVING clauses too', () => { + expect(labelsOf('SELECT * FROM users u JOIN orders o ON u.id ', 'postgres')).toContain('ILIKE'); + expect(labelsOf('SELECT * FROM users GROUP BY name HAVING name ', 'postgres')).toContain('ILIKE'); + }); + + it('does not offer them outside a filter clause', () => { + expect(labelsOf('SELECT * FROM users ', 'postgres')).not.toContain('ILIKE'); + expect(labelsOf('SELECT * FROM users ', 'postgres')).not.toContain('NOT LIKE'); + }); + + it('suggests columns after ILIKE, like after LIKE', () => { + expect(columnLabels('SELECT * FROM users WHERE name ILIKE ')).toEqual(expect.arrayContaining(ALL_COLS)); + expect(columnLabels('SELECT * FROM users WHERE name SIMILAR TO ')).toEqual(expect.arrayContaining(ALL_COLS)); + }); + + it('offers IS [NOT] DISTINCT FROM only on postgres and MATCH only on sqlite', () => { + expect(labelsOf(whereCol, 'postgres')).toEqual( + expect.arrayContaining(['IS DISTINCT FROM', 'IS NOT DISTINCT FROM']), + ); + expect(labelsOf(whereCol, 'mysql')).not.toContain('IS DISTINCT FROM'); + expect(labelsOf(whereCol, 'sqlite')).not.toContain('IS DISTINCT FROM'); + expect(labelsOf(whereCol, 'sqlite')).toEqual(expect.arrayContaining(['MATCH'])); + expect(labelsOf(whereCol, 'postgres')).not.toContain('MATCH'); + expect(labelsOf(whereCol, 'mysql')).not.toContain('MATCH'); + }); +}); + +describe('driver-specific statement keywords', () => { + it('gates statement starters per driver', () => { + const pg = labelsOf('', 'postgres'); + expect(pg).toEqual(expect.arrayContaining(['TRUNCATE TABLE', 'VACUUM', 'EXPLAIN'])); + expect(pg).not.toEqual(expect.arrayContaining(['REPLACE INTO', 'PRAGMA', 'SHOW TABLES'])); + + const my = labelsOf('', 'mysql'); + expect(my).toEqual(expect.arrayContaining(['TRUNCATE TABLE', 'REPLACE INTO', 'SHOW TABLES', 'SHOW DATABASES'])); + expect(my).not.toEqual(expect.arrayContaining(['PRAGMA', 'VACUUM'])); + + const lite = labelsOf('', 'sqlite'); + expect(lite).toEqual(expect.arrayContaining(['PRAGMA', 'VACUUM', 'REPLACE INTO', 'EXPLAIN'])); + expect(lite).not.toEqual(expect.arrayContaining(['TRUNCATE TABLE', 'SHOW TABLES'])); + }); + + it('keeps dialect starters out of the middle of a statement', () => { + expect(labelsOf('SELECT * FROM users ', 'sqlite')).not.toEqual(expect.arrayContaining(['PRAGMA', 'VACUUM'])); + expect(labelsOf('SELECT * FROM users ', 'mysql')).not.toContain('SHOW TABLES'); + expect(labelsOf('SELECT * FROM users ', 'postgres')).not.toContain('EXPLAIN'); + }); + + it('offers FULL JOIN everywhere but mysql, CROSS JOIN everywhere', () => { + expect(labelsOf('SELECT * FROM users ', 'postgres')).toEqual(expect.arrayContaining(['FULL JOIN', 'CROSS JOIN'])); + expect(labelsOf('SELECT * FROM users ', 'sqlite')).toContain('FULL JOIN'); + expect(labelsOf('SELECT * FROM users ', 'mysql')).not.toContain('FULL JOIN'); + expect(labelsOf('SELECT * FROM users ', 'mysql')).toContain('CROSS JOIN'); + }); + + it('offers RETURNING for postgres/sqlite writes, never for mysql', () => { + for (const sql of ['DELETE FROM users ', 'UPDATE users SET name = 1 ', 'INSERT INTO users (id) VALUES (1) ']) { + expect(labelsOf(sql, 'postgres')).toContain('RETURNING'); + expect(labelsOf(sql, 'sqlite')).toContain('RETURNING'); + expect(labelsOf(sql, 'mysql')).not.toContain('RETURNING'); + } + expect(labelsOf('SELECT * FROM users ', 'postgres')).not.toContain('RETURNING'); + }); + + it('offers the right upsert clause after INSERT … VALUES', () => { + const sql = 'INSERT INTO users (id) VALUES (1) '; + expect(labelsOf(sql, 'postgres')).toContain('ON CONFLICT'); + expect(labelsOf(sql, 'sqlite')).toContain('ON CONFLICT'); + expect(labelsOf(sql, 'mysql')).toContain('ON DUPLICATE KEY UPDATE'); + expect(labelsOf(sql, 'mysql')).not.toContain('ON CONFLICT'); + expect(labelsOf(sql, 'postgres')).not.toContain('ON DUPLICATE KEY UPDATE'); + // Not before the VALUES/SELECT body exists. + expect(labelsOf('INSERT INTO users ', 'postgres')).not.toContain('ON CONFLICT'); + }); + + it('offers DEFAULT as a value except on sqlite', () => { + expect(labelsOf('UPDATE users SET name = ', 'postgres')).toContain('DEFAULT'); + expect(labelsOf('UPDATE users SET name = ', 'mysql')).toContain('DEFAULT'); + expect(labelsOf('UPDATE users SET name = ', 'sqlite')).not.toContain('DEFAULT'); + }); +}); + +describe('IS DISTINCT FROM does not act as a table source', () => { + it('suggests columns, not the table list, after the operator', () => { + const sql = 'SELECT * FROM users WHERE id IS DISTINCT FROM '; + expect(columnLabels(sql)).toEqual(expect.arrayContaining(ALL_COLS)); + expect(labelsOf(sql)).not.toContain('orders'); + }); + + it('suggests columns right after typing the keyword (no trailing space)', () => { + const sql = 'SELECT * FROM users WHERE id IS DISTINCT FROM'; + expect(columnLabels(sql)).toEqual(expect.arrayContaining(ALL_COLS)); + expect(insertFor(sql, 'email')).toBe(' email'); + expect(labelsOf(sql)).not.toContain('orders'); + }); + + it('leaves a real FROM/JOIN table slot untouched', () => { + expect(labelsOf('SELECT DISTINCT id FROM ')).toEqual(expect.arrayContaining(['users', 'orders'])); + }); +}); + +describe('FK-aware JOIN ON suggestions', () => { + const fkTables: TableInfo[] = [ + { schema: 'public', name: 'users', type: 'table' }, + { schema: 'public', name: 'orders', type: 'table' }, + ]; + const fkCols: Record = { + 'public.users': [{ name: 'id', dataType: 'int', isNullable: false, isPrimary: true, isForeign: false }], + 'public.orders': [ + { name: 'id', dataType: 'int', isNullable: false, isPrimary: true, isForeign: false }, + { + name: 'user_id', + dataType: 'int', + isNullable: false, + isPrimary: false, + isForeign: true, + foreignTable: 'users', + foreignColumn: 'id', + }, + ], + }; + const fkComplete = (text: string) => { + const parsed = parseQueryContext(text, fkTables, schemas, 'postgres'); + const columnsByTable: Record = {}; + for (const ref of bindingsNeedingColumns(text, parsed, { tables: fkTables, schemas, driver: 'postgres' })) { + const key = columnCacheKey(ref.schema, ref.table); + columnsByTable[key] = fkCols[key] ?? []; + } + const ctx: CompletionContext = { + schemas, + tables: fkTables, + columns: [], + tablesBySchema: { public: fkTables }, + columnsByTable, + driver: 'postgres', + }; + return buildCompletionItems({ ctx, text, position: text.length, parsed }); + }; + + it('offers the FK join condition first, right after ON', () => { + const items = fkComplete('SELECT * FROM users JOIN orders ON '); + expect(items[0].label).toBe('orders.user_id = users.id'); + expect(items[0].detail).toBe('foreign key'); + }); + + it('uses aliases in the condition when present', () => { + const items = fkComplete('SELECT * FROM users u JOIN orders o ON '); + expect(items[0].label).toBe('o.user_id = u.id'); + }); + + it('space-prefixes the condition when the caret butts against ON', () => { + const items = fkComplete('SELECT * FROM users JOIN orders ON'); + expect(items[0].insertText).toBe(' orders.user_id = users.id'); + }); + + it('does not offer join conditions away from ON', () => { + const labels = fkComplete('SELECT * FROM users JOIN orders ON orders.user_id = users.id WHERE ').map( + (i) => i.label, + ); + expect(labels.some((l) => l.includes('='))).toBe(false); + }); +}); + +describe('CTE and derived-table columns', () => { + it('parses CTE projections (explicit list, AS aliases, qualified names)', () => { + const parsed = parseQueryContext( + 'WITH recent AS (SELECT u.id, email AS mail, count(*) AS n FROM users u) SELECT * FROM recent', + tables, + schemas, + 'postgres', + ); + expect(parsed.virtualColumns.get('recent')).toEqual(['id', 'mail', 'n']); + + const explicit = parseQueryContext('WITH r (a, b) AS (SELECT 1, 2) SELECT * FROM r', tables, schemas, 'postgres'); + expect(explicit.virtualColumns.get('r')).toEqual(['a', 'b']); + }); + + it('completes cte. with the projected columns', () => { + const labels = labelsOf('WITH recent AS (SELECT id, email AS mail FROM users) SELECT * FROM recent WHERE recent.'); + expect(labels).toEqual(['id', 'mail']); + }); + + it('offers CTE columns unqualified in WHERE once the CTE is referenced', () => { + const sql = 'WITH recent AS (SELECT id, email AS mail FROM users) SELECT * FROM recent WHERE '; + expect(labelsOf(sql)).toEqual(expect.arrayContaining(['mail'])); + // Declared but unreferenced CTEs stay out of scope. + const unref = 'WITH recent AS (SELECT email AS mail FROM users) SELECT * FROM orders WHERE '; + expect(labelsOf(unref)).not.toContain('mail'); + }); + + it('binds a derived-table alias and its projection', () => { + const sql = 'SELECT * FROM (SELECT id, name AS label FROM users) sub WHERE sub.'; + expect(labelsOf(sql)).toEqual(['id', 'label']); + const parsed = parseQueryContext(sql, tables, schemas, 'postgres'); + expect(parsed.virtualColumns.get('sub')).toEqual(['id', 'label']); + }); + + it('continues the FROM comma list after a derived table', () => { + const parsed = parseQueryContext('SELECT * FROM (SELECT 1 AS x) sub, orders WHERE ', tables, schemas, 'postgres'); + expect(parsed.queryTables.map((t) => t.table)).toContain('orders'); + expect(parsed.virtualColumns.get('sub')).toEqual(['x']); + }); + + it('yields no columns for an opaque projection (SELECT *) without crashing', () => { + expect(labelsOf('WITH r AS (SELECT * FROM users) SELECT * FROM r WHERE r.')).toEqual([]); + }); + + it('never asks the backend for a virtual relation’s columns', () => { + const sql = 'WITH recent AS (SELECT id FROM users) SELECT * FROM recent WHERE '; + const parsed = parseQueryContext(sql, tables, schemas, 'postgres'); + const needed = bindingsNeedingColumns(sql, parsed, { tables, schemas, driver: 'postgres' }); + expect(needed.map((b) => b.table)).not.toContain('recent'); + }); +}); + describe('column suggestions carry PK / NOT NULL hints', () => { it('annotates the detail line', () => { const items = complete('SELECT * FROM users WHERE '); diff --git a/frontend/src/features/editor/lib/sqlCompletion.ts b/frontend/src/features/editor/lib/sqlCompletion.ts index aa5b85d..d9b5db6 100644 --- a/frontend/src/features/editor/lib/sqlCompletion.ts +++ b/frontend/src/features/editor/lib/sqlCompletion.ts @@ -1,42 +1,31 @@ import { - clauseBodyStart, - type DotCompletion, - expectsExpression, - isColumnFilterContext, - isLimitOffsetContext, - isOrderOrGroupContext, - isUpdateSetColumnContext, - isValueContext, - matchTableContext, - parseDotCompletion, - sortDirectionAllowed, - updateSetColumnPrefix, - valueContextPrefix, -} from '@/features/editor/lib/sqlCompletionContext'; + analyzeSqlCursor, + type CursorSlot, + type SqlCursor, + type StatementShape, +} from '@/features/editor/lib/sqlContext'; import { type ParsedQuery, type QueryTableRef, resolveDotCompletion, - resolveQualifierToTable, type TableBinding, } from '@/features/editor/lib/sqlQueryParse'; import { columnCacheKey, formatSqlIdentifier, unquoteIdent } from '@/features/editor/lib/sqlQuoting'; import { type CompletionContext, type CompletionItem, - columnDetail, - keywordsForContext, - matchScore, + keywordItem, + keywordsForShape, rank, - suggestClauseBody, suggestColumnsForTable, suggestColumnsFromBindings, suggestCteItems, - suggestLimitOffset, suggestQueryTableRefs, suggestSchemas, suggestTables, suggestValueItems, + suggestVirtualColumns, + withLeadingSpace, } from '@/features/editor/lib/sqlSuggestions'; import type { DriverType, SchemaInfo, TableInfo } from '@/types'; @@ -46,14 +35,15 @@ export interface BindingsNeedingColumnsCtx { driver: DriverType; } +// Which tables' columns the completion at this position can show, so the provider prefetches exactly those. export function bindingsNeedingColumns( before: string, parsed: ParsedQuery, ctx?: BindingsNeedingColumnsCtx, ): TableBinding[] { + const { slot } = analyzeSqlCursor(before, ctx?.driver); const needed: TableBinding[] = []; const seen = new Set(); - const add = (b: TableBinding | null) => { if (!b) return; const k = columnCacheKey(b.schema, b.table); @@ -61,44 +51,50 @@ export function bindingsNeedingColumns( seen.add(k); needed.push(b); }; - - // clauseBodyStart position needs all in-scope columns; table position needs none (table list is known). - const body = clauseBodyStart(before); - if (body) { - if (body !== 'table') { - for (const ref of parsed.queryTables) add({ schema: ref.schema, table: ref.table }); + // CTEs / derived-table aliases have no schema entry to load columns from. + const isVirtual = (name: string) => parsed.virtualColumns.has(name.toLowerCase()); + const addQueryTables = () => { + for (const ref of parsed.queryTables) { + if (!isVirtual(ref.table)) add({ schema: ref.schema, table: ref.table }); } - return needed; - } - - if (isValueContext(before)) { - for (const b of parsed.bindings.values()) add(b); - if (seen.size === 0) { - for (const ref of parsed.queryTables) add({ schema: ref.schema, table: ref.table }); + }; + const addBindings = () => { + for (const b of parsed.bindings.values()) { + if (!isVirtual(b.table)) add(b); } - return needed; - } - - if (isUpdateSetColumnContext(before) || isColumnFilterContext(before) || isOrderOrGroupContext(before)) { - for (const ref of parsed.queryTables) add({ schema: ref.schema, table: ref.table }); - return needed; - } + }; - const dot = parseDotCompletion(before); - if (dot) { - if (ctx) { - // Full resolution handles schema.table.column and tables outside the current FROM clause. - add(resolveDotCompletion(dot, parsed.bindings, ctx.tables, ctx.schemas, ctx.driver)); - } else { - const qual = unquoteIdent(dot.segments[0]).toLowerCase(); - const fromAlias = parsed.bindings.get(qual); - if (fromAlias) add(fromAlias); + switch (slot.kind) { + case 'none': + case 'table': + case 'limit': + return needed; + case 'dot': { + if (isVirtual(unquoteIdent(slot.segments[0]))) return needed; + if (ctx) { + // Full resolution handles schema.table.column and tables outside the current FROM clause. + add(resolveDotCompletion(slot, parsed.bindings, ctx.tables, ctx.schemas, ctx.driver)); + } else { + const fromAlias = parsed.bindings.get(unquoteIdent(slot.segments[0]).toLowerCase()); + if (fromAlias) add(fromAlias); + } + return needed; } - return needed; + case 'value': + addBindings(); + if (seen.size === 0) addQueryTables(); + return needed; + case 'filter-start': + case 'set-column': + case 'insert-columns': + case 'order-group': + addQueryTables(); + return needed; + case 'general': + if (slot.inFilter) addQueryTables(); + else addBindings(); + return needed; } - - for (const b of parsed.bindings.values()) add(b); - return needed; } export interface BuildCompletionInput { @@ -116,270 +112,228 @@ function schemaTablesFor(ctx: CompletionContext, schemaName: string): TableInfo[ ); } -function dotCompletionItems( +// Ready-made `a.fk = b.pk` conditions right after ON, built from the FK metadata of the joined +// tables (both directions). Ranked first - it's almost always the condition being typed. +function fkJoinItems(ctx: CompletionContext, queryTables: QueryTableRef[]): CompletionItem[] { + const items: CompletionItem[] = []; + const refs = queryTables.filter( + (r, i, arr) => arr.findIndex((x) => x.schema === r.schema && x.table === r.table) === i, + ); + const qualify = (ref: QueryTableRef, column: string) => + `${formatSqlIdentifier(ref.alias ?? ref.table, ctx.driver)}.${formatSqlIdentifier(column, ctx.driver)}`; + + for (const from of refs) { + const cols = ctx.columnsByTable[columnCacheKey(from.schema, from.table)] || []; + for (const col of cols) { + if (!col.foreignTable || !col.foreignColumn) continue; + const target = refs.find((r) => r !== from && r.table.toLowerCase() === col.foreignTable?.toLowerCase()); + if (!target) continue; + const expr = `${qualify(from, col.name)} = ${qualify(target, col.foreignColumn)}`; + items.push({ + label: expr, + kind: 'field', + detail: 'foreign key', + insertText: expr, + sortText: rank(0, 0, expr), + }); + } + } + return items; +} + +function virtualDetail(parsed: ParsedQuery, nameLc: string): string { + return parsed.ctes.some((c) => c.toLowerCase() === nameLc) ? 'CTE column' : 'subquery column'; +} + +// Virtual relations visible to unqualified column completion: derived-table aliases always +// (they exist only in this FROM clause), CTEs only once actually referenced in FROM/JOIN. +function inScopeVirtualColumnItems(ctx: CompletionContext, parsed: ParsedQuery, lcPrefix: string): CompletionItem[] { + const cteNames = new Set(parsed.ctes.map((c) => c.toLowerCase())); + const referenced = new Set(parsed.queryTables.map((t) => t.table.toLowerCase())); + const items: CompletionItem[] = []; + for (const [name, cols] of parsed.virtualColumns) { + if (cteNames.has(name) && !referenced.has(name)) continue; + items.push(...suggestVirtualColumns(cols, lcPrefix, ctx.driver, virtualDetail(parsed, name))); + } + return items; +} + +function dotItems( ctx: CompletionContext, - dot: DotCompletion, - bindings: Map, -): CompletionItem[] | null { - const tableRef = resolveDotCompletion(dot, bindings, ctx.tables, ctx.schemas, ctx.driver); - if (tableRef) return suggestColumnsForTable(ctx, tableRef, dot.prefix.toLowerCase()); + slot: Extract, + parsed: ParsedQuery, +): CompletionItem[] { + if (slot.segments.length === 1) { + const nameLc = unquoteIdent(slot.segments[0]).toLowerCase(); + const virtual = parsed.virtualColumns.get(nameLc); + if (virtual) { + return suggestVirtualColumns(virtual, slot.prefix.toLowerCase(), ctx.driver, virtualDetail(parsed, nameLc)); + } + } - if (dot.segments.length === 1) { - const schemaName = unquoteIdent(dot.segments[0]); + const tableRef = resolveDotCompletion(slot, parsed.bindings, ctx.tables, ctx.schemas, ctx.driver); + if (tableRef) return suggestColumnsForTable(ctx, tableRef, slot.prefix.toLowerCase()); + + if (slot.segments.length === 1) { + const schemaName = unquoteIdent(slot.segments[0]); if (schemaTablesFor(ctx, schemaName).length > 0) { - return suggestTables(ctx, dot.prefix.toLowerCase(), schemaName); + return suggestTables(ctx, slot.prefix.toLowerCase(), schemaName); } } - return null; + return []; +} + +function tableSlotItems(ctx: CompletionContext, prefix: string, ctes: string[]): CompletionItem[] { + // CTEs lead (tier 0) so they survive the item cap on large schemas. + return [...suggestCteItems(ctes, prefix, ctx.driver), ...suggestTables(ctx, prefix), ...suggestSchemas(ctx, prefix)]; } function orderGroupItems( ctx: CompletionContext, - before: string, + slot: Extract, queryTables: QueryTableRef[], bindings: Map, ): CompletionItem[] { - const frag = before.match(/[\w"`]*$/)?.[0] ?? ''; - const lcPrefix = unquoteIdent(frag).toLowerCase(); // Columns/tables only at the start of a sort term (after BY/comma), not after a finished one. - const items: CompletionItem[] = expectsExpression(before) - ? [...suggestQueryTableRefs(ctx, queryTables, lcPrefix), ...suggestColumnsFromBindings(ctx, bindings, lcPrefix)] + const items: CompletionItem[] = slot.expectsExpr + ? [ + ...suggestQueryTableRefs(ctx, queryTables, slot.prefix), + ...suggestColumnsFromBindings(ctx, bindings, slot.prefix), + ] : []; - if (sortDirectionAllowed(before)) { - for (const kw of ['ASC', 'DESC']) { - const score = matchScore(kw, lcPrefix); - if (score >= 0) { - items.push({ label: kw, kind: 'keyword', insertText: kw, sortText: rank(4, score, kw) }); - } - } - } - // After a finished term, continue to LIMIT/OFFSET (plus HAVING/ORDER BY when grouping). - if (!expectsExpression(before)) { - const re = /\b(ORDER|GROUP)\s+BY\b/gi; - let kind = ''; - for (let mm = re.exec(before); mm !== null; mm = re.exec(before)) kind = mm[1].toUpperCase(); - const trailing = kind === 'GROUP' ? ['HAVING', 'ORDER BY', 'LIMIT', 'OFFSET'] : ['LIMIT', 'OFFSET']; - for (const kw of trailing) { - const score = matchScore(kw, lcPrefix); - if (score >= 0) { - items.push({ label: kw, kind: 'keyword', insertText: kw, sortText: rank(4, score, kw) }); - } - } + const keywords = slot.directionAllowed ? ['ASC', 'DESC', ...slot.trailingKeywords] : slot.trailingKeywords; + for (const kw of keywords) { + const item = keywordItem(kw, slot.prefix); + if (item) items.push(item); } return items; } -function bareWordItems( +function generalItems( ctx: CompletionContext, - before: string, - lcPrefix: string, - queryTables: QueryTableRef[], - bindings: Map, + slot: Extract, + shape: StatementShape, + parsed: ParsedQuery, ): CompletionItem[] { + const { queryTables, bindings } = parsed; const items: CompletionItem[] = []; - const inFilter = isColumnFilterContext(before); // Identifiers only when the preceding token expects one; keywords always flow through. - const wantExpr = expectsExpression(before); - if (inFilter && wantExpr) { - items.push(...suggestQueryTableRefs(ctx, queryTables, lcPrefix)); - items.push(...suggestColumnsFromBindings(ctx, bindings, lcPrefix)); + if (slot.inFilter && slot.expectsExpr) { + if (shape.afterOnKeyword && slot.prefix === '') items.push(...fkJoinItems(ctx, queryTables)); + items.push(...suggestQueryTableRefs(ctx, queryTables, slot.prefix)); + items.push(...suggestColumnsFromBindings(ctx, bindings, slot.prefix)); + items.push(...inScopeVirtualColumnItems(ctx, parsed, slot.prefix)); } - for (const kw of keywordsForContext(before)) { - const score = matchScore(kw, lcPrefix); - if (score < 0) continue; - items.push({ - label: kw, - kind: 'keyword', - insertText: kw, - sortText: rank(4, score, kw), - }); - } - - if (wantExpr && /\bSELECT\s+[^;]*$/i.test(before) && !/\bFROM\b/i.test(before)) { - items.push(...suggestColumnsFromBindings(ctx, bindings, lcPrefix)); - } - if (wantExpr && !/\bFROM\b/i.test(before) && !inFilter) { - items.push(...suggestSchemas(ctx, lcPrefix)); + for (const kw of keywordsForShape(shape, ctx.driver)) { + const item = keywordItem(kw, slot.prefix); + if (item) items.push(item); } - return items; -} - -function qualifiedItems( - ctx: CompletionContext, - parts: string[], - lcPrefix: string, - bindings: Map, -): CompletionItem[] { - if (parts.length === 2) { - const qualifier = unquoteIdent(parts[0]); - const tableRef = resolveQualifierToTable(qualifier, bindings, ctx.tables, ctx.schemas, ctx.driver); - if (tableRef) return suggestColumnsForTable(ctx, tableRef, lcPrefix); - if (schemaTablesFor(ctx, qualifier).length > 0) { - return suggestTables(ctx, lcPrefix, qualifier); - } - return suggestSchemas(ctx, lcPrefix); + if (slot.expectsExpr && shape.inSelectList) { + items.push(...suggestColumnsFromBindings(ctx, bindings, slot.prefix)); } - - const schemaName = unquoteIdent(parts[0]); - const tableName = unquoteIdent(parts[1]); - const key = columnCacheKey(schemaName, tableName); - const cols = ctx.columnsByTable[key] || ctx.columns; - const items: CompletionItem[] = []; - for (const c of cols) { - const score = matchScore(c.name, lcPrefix); - if (score < 0) continue; - items.push({ - label: c.name, - kind: 'field', - detail: columnDetail(c), - insertText: formatSqlIdentifier(c.name, ctx.driver), - sortText: rank(0, score, c.name), - }); + if (slot.expectsExpr && !shape.hasFrom && !slot.inFilter) { + items.push(...suggestSchemas(ctx, slot.prefix)); } return items; } -function completionItems(input: BuildCompletionInput): CompletionItem[] { - const { ctx, text: textVal, position: posVal, parsed } = input; - const before = textVal.slice(input.statementStart ?? 0, posVal); +function completionItems(input: BuildCompletionInput, cursor: SqlCursor): CompletionItem[] { + const { ctx, parsed } = input; + const { slot, shape } = cursor; const { queryTables, bindings } = parsed; - const dot = parseDotCompletion(before); - if (dot) { - const fromDot = dotCompletionItems(ctx, dot, bindings); - if (fromDot) return fromDot; - } - - const bodyKind = clauseBodyStart(before); - if (bodyKind) return suggestClauseBody(ctx, bodyKind, queryTables, bindings, parsed.ctes); - - if (isUpdateSetColumnContext(before)) { - return suggestColumnsFromBindings(ctx, bindings, updateSetColumnPrefix(before)); - } - - if (isValueContext(before)) { - return suggestValueItems(ctx, queryTables, bindings, valueContextPrefix(before)); - } - - const tableCtx = matchTableContext(before); - if (tableCtx) { - // CTEs join the table list (not under a schema qualifier - CTEs aren't schemas), first so they - // survive the 100-item slice on a large schema. - return tableCtx.schemaPrefix - ? suggestTables(ctx, tableCtx.prefix, tableCtx.schemaPrefix) - : [...suggestCteItems(parsed.ctes, tableCtx.prefix, ctx.driver), ...suggestTables(ctx, tableCtx.prefix)]; - } - - if (isOrderOrGroupContext(before)) { - return orderGroupItems(ctx, before, queryTables, bindings); - } - - // LIMIT/OFFSET take a number (+ optional OFFSET), not columns/tables/keywords. - if (isLimitOffsetContext(before)) { - return suggestLimitOffset(before, before.match(/[\w]+$/)?.[0].toLowerCase() ?? ''); - } - - const wordMatch = before.match(/[\w."`]*$/); - const word = wordMatch ? wordMatch[0] : ''; - const parts = word.split('.'); - const lcPrefix = parts[parts.length - 1].toLowerCase(); - - if (parts.length === 1) return bareWordItems(ctx, before, lcPrefix, queryTables, bindings); - if (parts.length === 2 || parts.length === 3) { - return qualifiedItems(ctx, parts, lcPrefix, bindings); + switch (slot.kind) { + case 'none': + return []; + case 'dot': + return dotItems(ctx, slot, parsed); + case 'table': { + const items = tableSlotItems(ctx, slot.prefix, parsed.ctes); + return slot.leadingSpace ? withLeadingSpace(items) : items; + } + case 'insert-columns': { + const used = new Set(slot.used); + return suggestColumnsFromBindings(ctx, bindings, slot.prefix).filter( + (item) => !used.has(item.label.toLowerCase()), + ); + } + case 'set-column': { + const items = suggestColumnsFromBindings(ctx, bindings, slot.prefix); + return slot.leadingSpace ? withLeadingSpace(items) : items; + } + case 'filter-start': + return withLeadingSpace([ + ...(shape.afterOnKeyword ? fkJoinItems(ctx, queryTables) : []), + ...suggestQueryTableRefs(ctx, queryTables, ''), + ...suggestColumnsFromBindings(ctx, bindings, ''), + ...inScopeVirtualColumnItems(ctx, parsed, ''), + ]); + case 'value': + return [ + ...suggestValueItems(ctx, queryTables, bindings, slot.prefix), + ...inScopeVirtualColumnItems(ctx, parsed, slot.prefix), + ]; + case 'order-group': { + const items = orderGroupItems(ctx, slot, queryTables, bindings); + if (slot.expectsExpr) items.push(...inScopeVirtualColumnItems(ctx, parsed, slot.prefix)); + return slot.leadingSpace ? withLeadingSpace(items) : items; + } + case 'limit': { + const item = slot.offerOffset ? keywordItem('OFFSET', slot.prefix) : null; + return item ? [item] : []; + } + case 'general': + return generalItems(ctx, slot, shape, parsed); } - return []; } export function buildCompletionItems(input: BuildCompletionInput): CompletionItem[] { - return completionItems(input).slice(0, 100); -} - -// Length of the partial identifier under the caret (what the completion replaces). A leading quote -// counts only when unclosed; matching `"[^"]*$` naively instead grabs from an earlier closing quote -// (`"Users" WHERE ` → `" WHERE `), a bogus range that hid WHERE suggestions after a quoted table. -function identifierFragmentLength(textBefore: string): number { - const line = textBefore.slice(textBefore.lastIndexOf('\n') + 1); - for (const q of ['"', '`']) { - if ((line.split(q).length - 1) % 2 === 1) return line.length - line.lastIndexOf(q); + const before = input.text.slice(input.statementStart ?? 0, input.position); + const items = completionItems(input, analyzeSqlCursor(before, input.ctx.driver)); + if (items.length <= 100) return items; + + // Keep the best-ranked 100: sortText leads with `${tier}${score}`, so bucketing on those two + // characters selects the top ranks without a full sort; Monaco re-sorts survivors by sortText. + const buckets = new Map(); + for (const item of items) { + const key = (item.sortText ?? '99').slice(0, 2); + const bucket = buckets.get(key); + if (bucket) bucket.push(item); + else buckets.set(key, [item]); } - return line.match(/[\w]+$/)?.[0].length ?? 0; -} - -function identifierFragmentRange( - position: { lineNumber: number; column: number }, - textBefore: string, - fallback: { startColumn: number; endColumn: number }, -): { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number } { - const fragLen = identifierFragmentLength(textBefore); - if (fragLen > 0) { - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: Math.max(1, position.column - fragLen), - endColumn: position.column, - }; + const out: CompletionItem[] = []; + for (const key of [...buckets.keys()].sort()) { + for (const item of buckets.get(key) ?? []) { + out.push(item); + if (out.length === 100) return out; + } } - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: fallback.startColumn, - endColumn: fallback.endColumn, - }; + return out; } export function completionReplaceRange( position: { lineNumber: number; column: number }, textBefore: string, fallback: { startColumn: number; endColumn: number }, + driver?: DriverType, ): { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number } { - // clauseBodyStart: zero-width insert at caret; suggestClauseBody space-prefixes so it reads cleanly. - if (clauseBodyStart(textBefore)) { - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: position.column, - endColumn: position.column, - }; - } - const dot = parseDotCompletion(textBefore); - if (dot) { - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: Math.max(1, position.column - dot.prefix.length), - endColumn: position.column, - }; - } - if (isValueContext(textBefore)) { - // Cover any started quote + partial so accepting replaces `'ab` rather than appending to it. - const consumed = textBefore.match(/['"`]?[\w."`]*$/)?.[0].length ?? 0; - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: Math.max(1, position.column - consumed), - endColumn: position.column, - }; - } - if (isUpdateSetColumnContext(textBefore)) { - const prefix = updateSetColumnPrefix(textBefore); - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: Math.max(1, position.column - prefix.length), - endColumn: position.column, - }; - } - const tableCtx = matchTableContext(textBefore); - if (tableCtx) { - return { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: Math.max(1, position.column - tableCtx.prefix.length), - endColumn: position.column, - }; - } - return identifierFragmentRange(position, textBefore, fallback); + const { slot } = analyzeSqlCursor(textBefore, driver); + const at = (startColumn: number, endColumn: number) => ({ + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn, + endColumn, + }); + + // Clause-start slots insert space-prefixed at the caret; nothing gets replaced. + if ('leadingSpace' in slot && slot.leadingSpace) return at(position.column, position.column); + if (slot.kind === 'none') return at(fallback.startColumn, fallback.endColumn); + + const replaceLen = 'replaceLen' in slot ? slot.replaceLen : 0; + if (replaceLen > 0) return at(Math.max(1, position.column - replaceLen), position.column); + // No partial word: general falls back to Monaco's word range, structured slots insert at the caret. + if (slot.kind === 'general') return at(fallback.startColumn, fallback.endColumn); + return at(position.column, position.column); } diff --git a/frontend/src/features/editor/lib/sqlCompletionContext.ts b/frontend/src/features/editor/lib/sqlCompletionContext.ts deleted file mode 100644 index 83d5fd7..0000000 --- a/frontend/src/features/editor/lib/sqlCompletionContext.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { unquoteIdent } from '@/features/editor/lib/sqlQuoting'; - -const TABLE_SOURCE_RE = - /\b(?:FROM|(?:(?:LEFT|RIGHT|FULL|INNER|CROSS|NATURAL)\s+)*JOIN|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+/gi; - -const STOPS_TABLE_CONTEXT = /\b(WHERE|ON|SET|GROUP|ORDER|HAVING|LIMIT|OFFSET|UNION|RETURNING|VALUES|SELECT)\b/i; - -export interface DotCompletion { - segments: string[]; - prefix: string; -} - -const IDENT_OR_QUOTED = `(?:"[^"]+")|(?:'[^']+')|(?:\`[^\`]+\`)|[a-zA-Z_][\\w]*`; -const TRIPLE_DOT_RE = new RegExp(`(${IDENT_OR_QUOTED})\\s*\\.\\s*(${IDENT_OR_QUOTED})\\s*\\.\\s*([\\w"\`]*)$`); -const SINGLE_DOT_RE = new RegExp(`(${IDENT_OR_QUOTED})\\s*\\.\\s*([\\w"\`]*)$`); - -export function parseDotCompletion(before: string): DotCompletion | null { - const triple = before.match(TRIPLE_DOT_RE); - if (triple) return { segments: [triple[1], triple[2]], prefix: triple[3] }; - - const single = before.match(SINGLE_DOT_RE); - if (single) return { segments: [single[1]], prefix: single[2] }; - - return null; -} - -export interface TableContextMatch { - schemaPrefix?: string; - prefix: string; -} - -export function matchTableContext(before: string): TableContextMatch | null { - let lastEnd = -1; - TABLE_SOURCE_RE.lastIndex = 0; - for (let m = TABLE_SOURCE_RE.exec(before); m !== null; m = TABLE_SOURCE_RE.exec(before)) { - lastEnd = m.index + m[0].length; - } - if (lastEnd < 0) return null; - - const segment = before.slice(lastEnd); - if (STOPS_TABLE_CONTEXT.test(segment)) return null; - - const schemaDot = segment.match(/^\s*([a-zA-Z_][\w]*)\s*\.\s*([\w."`]*)$/); - if (schemaDot) { - return { - schemaPrefix: schemaDot[1], - prefix: unquoteIdent(schemaDot[2]).toLowerCase(), - }; - } - - if (/^\s*[a-zA-Z_][\w]*\s*\.\s*$/.test(segment)) { - const schemaOnly = segment.match(/^\s*([a-zA-Z_][\w]*)\s*\.\s*$/); - if (schemaOnly) return { schemaPrefix: schemaOnly[1], prefix: '' }; - } - - const ident = segment.match(/^\s*((?:"[^"]*)?[\w."`]*)$/); - if (!ident) return null; - - const raw = (ident[1] || '').trim(); - if (!raw) return { prefix: '' }; - - return { prefix: unquoteIdent(raw).toLowerCase() }; -} - -export function isValueContext(before: string): boolean { - if (/[<>:!]=\s*[\w."'`]*$/.test(before)) return false; - return /(?:^|[^\w])=\s*[\w."'`]*$/.test(before); -} - -export function isUpdateSetColumnContext(before: string): boolean { - const match = before.match(/\bUPDATE\b[\s\S]*\bSET\s+([\s\S]*)$/i); - if (!match) return false; - if (isValueContext(before)) return false; - return !/\bWHERE\b/i.test(match[1]); -} - -export function isColumnFilterContext(before: string): boolean { - if (isValueContext(before) || matchTableContext(before)) return false; - if (!/\b(WHERE|ON|AND|OR|HAVING)\b/i.test(before)) return false; - - if (/\bFROM\b/i.test(before) && /\b(SELECT|DELETE)\b/i.test(before)) return true; - if (/\bUPDATE\b/i.test(before) && /\bSET\b/i.test(before)) return true; - return false; -} - -// Generic keyword path showed no identifiers in ORDER BY / GROUP BY; this predicate unlocks column suggestions there. -export function isOrderOrGroupContext(before: string): boolean { - if (isValueContext(before) || matchTableContext(before)) return false; - if (parseDotCompletion(before)) return false; - - const re = /\b(?:ORDER|GROUP)\s+BY\b/gi; - let bodyStart = -1; - for (let m = re.exec(before); m !== null; m = re.exec(before)) bodyStart = m.index + m[0].length; - if (bodyStart < 0) return false; - - const tail = before.slice(bodyStart); - return !/\b(WHERE|FROM|HAVING|LIMIT|OFFSET|UNION|SELECT)\b/i.test(tail); -} - -// Caret is in the trailing LIMIT/OFFSET clause (after the keyword + a space), which takes only a -// number and an optional OFFSET - so columns/tables/keywords don't belong. False while still -// typing the keyword itself (`LIMI`/`LIMIT`). -export function isLimitOffsetContext(before: string): boolean { - const re = /\b(?:LIMIT|OFFSET)\b/gi; - let lastEnd = -1; - for (let m = re.exec(before); m !== null; m = re.exec(before)) lastEnd = m.index + m[0].length; - if (lastEnd < 0) return false; - const tail = before.slice(lastEnd); - if (!/^\s/.test(tail)) return false; // caret still on the keyword, not in its argument - // A subquery's LIMIT followed by an outer clause is no longer the numeric tail. - return !/[)]|\b(SELECT|FROM|WHERE|JOIN|GROUP|ORDER|HAVING|UNION|VALUES|SET|INTO)\b/i.test(tail); -} - -// Words after which a column/table/expression is the natural next token. -const EXPRESSION_LEAD_WORDS = new Set([ - 'from', - 'join', - 'on', - 'where', - 'and', - 'or', - 'not', - 'having', - 'by', - 'set', - 'select', - 'in', - 'like', - 'between', - 'distinct', - 'when', - 'then', - 'else', -]); - -// Whether the token before the caret expects an identifier/expression next - a clause keyword -// (FROM/WHERE/ORDER BY/ON/…), a comparison/logical operator, a comma, or `(`. False after a -// completed identifier/literal/ASC/DESC/`)`. The in-progress word is stripped first, so `WHERE ema|` -// resolves to the keyword before it. -export function expectsExpression(before: string): boolean { - const partial = before.match(/[\w."`]*$/)?.[0] ?? ''; - const head = before.slice(0, before.length - partial.length).replace(/\s+$/, ''); - if (!head) return true; - const last = head[head.length - 1]; - if (last === ',' || last === '(' || last === '=' || last === '<' || last === '>') return true; - const word = head.match(/[A-Za-z_][\w]*$/)?.[0]; - return word ? EXPRESSION_LEAD_WORDS.has(word.toLowerCase()) : false; -} - -// Caret immediately after a clause keyword with no trailing space (e.g. Ctrl+Space after WHERE) - without this the keyword is matched as the word being typed, producing an empty list. -export type ClauseBodyKind = 'filter' | 'order-group' | 'set' | 'table'; - -const TRAILING_ORDER_GROUP_RE = /(?:^|[^\w])(?:ORDER|GROUP)\s+BY$/i; -const TRAILING_FILTER_RE = /(?:^|[^\w])(?:WHERE|HAVING|ON|AND|OR)$/i; -const TRAILING_SET_RE = /(?:^|[^\w])SET$/i; -const TRAILING_TABLE_RE = /(?:^|[^\w])(?:FROM|JOIN)$/i; - -export function clauseBodyStart(before: string): ClauseBodyKind | null { - if (!before || /\s$/.test(before)) return null; - if (parseDotCompletion(before) || isValueContext(before)) return null; - - if (TRAILING_ORDER_GROUP_RE.test(before)) return 'order-group'; - if (TRAILING_SET_RE.test(before) && /\bUPDATE\b/i.test(before)) return 'set'; - if (TRAILING_FILTER_RE.test(before)) return 'filter'; - if (TRAILING_TABLE_RE.test(before)) return 'table'; - return null; -} - -export function valueContextPrefix(before: string): string { - // Allow (and skip past) an opening quote so a started literal like `= 'ab` filters by `ab`. - const m = before.match(/=\s*['"`]?([\w."`]*)$/); - return m ? unquoteIdent(m[1]).toLowerCase() : ''; -} - -export function updateSetColumnPrefix(before: string): string { - const setMatch = before.match(/\bSET\s+([\s\S]*)$/i); - if (!setMatch) return ''; - let tail = setMatch[1]; - const whereIdx = tail.search(/\bWHERE\b/i); - if (whereIdx >= 0) tail = tail.slice(0, whereIdx); - const lastComma = tail.lastIndexOf(','); - const fragment = (lastComma >= 0 ? tail.slice(lastComma + 1) : tail).trim(); - const beforeEq = fragment.split('=')[0] ?? ''; - const ident = beforeEq.match(/((?:"[^"]+")|(?:'[^']+')|[\w.]+)$/); - return ident ? unquoteIdent(ident[1]).toLowerCase() : ''; -} - -// ASC/DESC valid only in ORDER BY (not GROUP BY) and only after a column is already in the current sort term. -export function sortDirectionAllowed(before: string): boolean { - const re = /\b(ORDER|GROUP)\s+BY\b/gi; - let kind = ''; - let bodyStart = -1; - for (let m = re.exec(before); m !== null; m = re.exec(before)) { - kind = m[1].toUpperCase(); - bodyStart = m.index + m[0].length; - } - if (kind !== 'ORDER') return false; - - let term = before.slice(bodyStart); - const comma = term.lastIndexOf(','); - if (comma >= 0) term = term.slice(comma + 1); - // A direction is already present for this sort key - don't offer a second one. - if (/\b(?:ASC|DESC)\s*$/i.test(term)) return false; - return /[\w"`)]\s*$/.test(term.replace(/[\w"`]*$/, '')); -} diff --git a/frontend/src/features/editor/lib/sqlContext.ts b/frontend/src/features/editor/lib/sqlContext.ts new file mode 100644 index 0000000..632a66f --- /dev/null +++ b/frontend/src/features/editor/lib/sqlContext.ts @@ -0,0 +1,466 @@ +import { isIdentLike, isKeyword, type SqlToken, tokenIdentText, tokenizeSql } from '@/features/editor/lib/sqlTokens'; +import type { DriverType } from '@/types'; + +// Statement-level facts about the text before the cursor, computed once per analysis. +export interface StatementShape { + atStatementStart: boolean; + hasFrom: boolean; + hasJoin: boolean; + inWhere: boolean; + inFilterClause: boolean; // WHERE / HAVING / ON seen + inSelectList: boolean; // SELECT present, FROM not yet + hasInsert: boolean; // INSERT or REPLACE + hasUpdate: boolean; + hasCase: boolean; + orderBySeen: boolean; + groupBySeen: boolean; + insertBody: boolean; // INSERT followed by its VALUES / SELECT body + returningSlot: boolean; // a write statement far enough along for RETURNING + joinable: boolean; // a JOIN keyword parses here (FROM present, not inside WHERE) + afterOnKeyword: boolean; // the caret sits right after a JOIN's ON - a join condition starts here +} + +// What the caret sits in. `prefix` is the partial word being typed (unquoted, lowercased unless +// noted) and `replaceLen` the raw character count the accepted suggestion should replace. +export type CursorSlot = + | { kind: 'none' } // inside a comment or a plain string literal + | { kind: 'dot'; segments: string[]; prefix: string; replaceLen: number } + | { kind: 'table'; prefix: string; replaceLen: number; leadingSpace: boolean } + | { kind: 'insert-columns'; used: string[]; prefix: string; replaceLen: number } + | { kind: 'set-column'; prefix: string; replaceLen: number; leadingSpace: boolean } + | { kind: 'filter-start'; leadingSpace: true } // caret butted against WHERE/AND/… - columns follow + | { kind: 'value'; prefix: string; replaceLen: number } + | { + kind: 'order-group'; + group: boolean; + expectsExpr: boolean; + directionAllowed: boolean; + trailingKeywords: string[]; + prefix: string; + replaceLen: number; + leadingSpace: boolean; + } + | { kind: 'limit'; offerOffset: boolean; prefix: string; replaceLen: number } + | { kind: 'general'; prefix: string; replaceLen: number; inFilter: boolean; expectsExpr: boolean }; + +export interface SqlCursor { + slot: CursorSlot; + shape: StatementShape; +} + +const COMPARISON_OPS = new Set(['=', '<', '>', '<=', '>=', '<>', '!=']); +const STOPS_TABLE = new Set([ + 'where', + 'on', + 'set', + 'group', + 'order', + 'having', + 'limit', + 'offset', + 'union', + 'returning', + 'values', + 'select', +]); +const BY_TERMINATORS = new Set(['where', 'from', 'having', 'limit', 'offset', 'union', 'select']); +const LIMIT_TERMINATORS = new Set([ + 'select', + 'from', + 'where', + 'join', + 'group', + 'order', + 'having', + 'union', + 'values', + 'set', + 'into', +]); +const FILTER_START_KEYWORDS = new Set(['where', 'having', 'on', 'and', 'or']); + +// Tokens after which a column/table/expression is the natural next thing to type. +const EXPRESSION_LEAD_WORDS = new Set([ + 'from', + 'join', + 'on', + 'where', + 'and', + 'or', + 'not', + 'having', + 'by', + 'set', + 'select', + 'in', + 'like', + 'ilike', + 'regexp', + 'rlike', + 'glob', + 'match', + 'to', // SIMILAR TO + 'between', + 'distinct', + 'when', + 'then', + 'else', +]); + +const isPunct = (t: SqlToken | undefined, ch: string) => t !== undefined && t.kind === 'punct' && t.text === ch; +const isComparison = (t: SqlToken | undefined) => t !== undefined && t.kind === 'op' && COMPARISON_OPS.has(t.text); + +interface Markers { + srcIdx: number; // last table-source keyword (FROM/JOIN/INTO/UPDATE) + byIdx: number; // last ORDER|GROUP BY's `by` + byGroup: boolean; + limitIdx: number; // last LIMIT/OFFSET keyword + offsetSeen: boolean; + setIdx: number; // last UPDATE's SET + intoIdx: number; // last INSERT/REPLACE INTO's `into` + selectSeen: boolean; + deleteSeen: boolean; +} + +function computeShape(code: SqlToken[], partial: SqlToken | null): { shape: StatementShape; m: Markers } { + const m: Markers = { + srcIdx: -1, + byIdx: -1, + byGroup: false, + limitIdx: -1, + offsetSeen: false, + setIdx: -1, + intoIdx: -1, + selectSeen: false, + deleteSeen: false, + }; + let hasFrom = false; + let hasJoin = false; + let inWhere = false; + let inFilterClause = false; + let hasInsert = false; + let hasUpdate = false; + let hasCase = false; + let orderBySeen = false; + let groupBySeen = false; + let insertBody = false; + + for (let i = 0; i < code.length; i++) { + const t = code[i]; + if (t.kind !== 'ident' || t === partial) continue; + switch (t.lower) { + case 'from': + if (!isKeyword(code[i - 1], 'distinct')) { + hasFrom = true; + m.srcIdx = i; + } + break; + case 'join': + hasJoin = true; + m.srcIdx = i; + break; + case 'into': + if (isKeyword(code[i - 1], 'insert') || isKeyword(code[i - 1], 'replace')) { + m.srcIdx = i; + m.intoIdx = i; + } + break; + case 'update': + hasUpdate = true; + m.srcIdx = i; + break; + case 'where': + inWhere = true; + inFilterClause = true; + break; + case 'having': + case 'on': + inFilterClause = true; + break; + case 'select': + m.selectSeen = true; + if (hasInsert) insertBody = true; + break; + case 'values': + if (hasInsert) insertBody = true; + break; + case 'insert': + case 'replace': + hasInsert = true; + break; + case 'delete': + m.deleteSeen = true; + break; + case 'case': + hasCase = true; + break; + case 'set': + if (hasUpdate) m.setIdx = i; + break; + case 'by': + if (isKeyword(code[i - 1], 'order') || isKeyword(code[i - 1], 'group')) { + m.byIdx = i; + m.byGroup = isKeyword(code[i - 1], 'group'); + if (m.byGroup) groupBySeen = true; + else orderBySeen = true; + } + break; + case 'limit': + case 'offset': + m.limitIdx = i; + if (t.lower === 'offset') m.offsetSeen = true; + break; + } + } + + const shape: StatementShape = { + atStatementStart: code.length === 0 || (code.length === 1 && code[0] === partial), + hasFrom, + hasJoin, + inWhere, + inFilterClause, + inSelectList: m.selectSeen && !hasFrom, + hasInsert, + hasUpdate, + hasCase, + orderBySeen, + groupBySeen, + insertBody, + returningSlot: insertBody || m.deleteSeen || (hasUpdate && m.setIdx !== -1), + joinable: hasFrom && !inWhere, + afterOnKeyword: false, // filled in by analyzeSqlCursor (cursor-local, not statement-global) + }; + return { shape, m }; +} + +// Whether the last complete token leaves an expression slot open. +function expectsExpression(prev: SqlToken | undefined): boolean { + if (!prev) return true; + if (prev.kind === 'punct') return prev.text === ',' || prev.text === '('; + if (prev.kind === 'op') return COMPARISON_OPS.has(prev.text); + return prev.kind === 'ident' && EXPRESSION_LEAD_WORDS.has(prev.lower); +} + +function lowerIdent(t: SqlToken): string { + return tokenIdentText(t).toLowerCase(); +} + +export function analyzeSqlCursor(before: string, driver?: DriverType): SqlCursor { + const all = tokenizeSql(before, driver); + const len = before.length; + + // In-comment iff the cursor sits in a comment that hasn't closed yet (line comment with no + // newline, or an open block comment). A closed comment ending at the cursor is code position. + const lastAny = all[all.length - 1]; + if (lastAny?.kind === 'comment' && lastAny.end >= len && lastAny.unterminated) { + return { slot: { kind: 'none' }, shape: computeShape([], null).shape }; + } + + const code: SqlToken[] = []; + for (const t of all) if (t.kind !== 'comment') code.push(t); + + // The token being typed (touching the cursor), if any. + const last = code[code.length - 1]; + let partial: SqlToken | null = null; + if (last && last.end >= len) { + if (last.kind === 'ident' || last.kind === 'number' || (last.kind === 'quoted' && last.unterminated)) { + partial = last; + } else if (last.kind === 'string' && last.unterminated) { + // Typing inside a literal: a value when it follows a comparison, otherwise no completions. + const prev = code[code.length - 2]; + const { shape } = computeShape(code, null); + if (isComparison(prev)) { + const prefix = before.slice(last.start + 1).toLowerCase(); + return { slot: { kind: 'value', prefix, replaceLen: len - last.start }, shape }; + } + return { slot: { kind: 'none' }, shape }; + } + } + + const { shape, m } = computeShape(code, partial); + const completeEnd = partial ? code.length - 1 : code.length; // code[0..completeEnd) are complete tokens + const prev = code[completeEnd - 1]; + const prefix = partial ? lowerIdent(partial) : ''; + const replaceLen = partial ? len - partial.start : 0; + shape.afterOnKeyword = shape.hasJoin && (isKeyword(prev, 'on') || (partial !== null && partial.lower === 'on')); + + // Qualified access: `alias.` / `schema.table.` (+ partial). Raw segments; resolvers unquote. + if (isPunct(prev, '.') && isIdentLike(code[completeEnd - 2])) { + const seg1 = code[completeEnd - 2]; + const segments: string[] = [seg1.text]; + if (isPunct(code[completeEnd - 3], '.') && isIdentLike(code[completeEnd - 4])) { + segments.unshift(code[completeEnd - 4].text); + } + return { slot: { kind: 'dot', segments, prefix: partial?.text ?? '', replaceLen }, shape }; + } + + // Value slot: right of a comparison operator. + if (isComparison(prev)) { + return { slot: { kind: 'value', prefix, replaceLen }, shape }; + } + + // Caret butted against a just-typed clause keyword (`WHERE|`, `ORDER BY|`, `FROM|`): the word is + // complete, so offer the clause body space-prefixed rather than treating it as a prefix filter. + if (partial && partial.kind === 'ident') { + const kw = partial.lower; + const prevKw = code[code.length - 2]; + if (kw === 'by' && (isKeyword(prevKw, 'order') || isKeyword(prevKw, 'group'))) { + return { + slot: { + kind: 'order-group', + group: isKeyword(prevKw, 'group'), + expectsExpr: true, + directionAllowed: false, + trailingKeywords: [], + prefix: '', + replaceLen: 0, + leadingSpace: true, + }, + shape, + }; + } + if (kw === 'set' && shape.hasUpdate) { + return { slot: { kind: 'set-column', prefix: '', replaceLen: 0, leadingSpace: true }, shape }; + } + if (FILTER_START_KEYWORDS.has(kw) || (kw === 'from' && isKeyword(prevKw, 'distinct'))) { + return { slot: { kind: 'filter-start', leadingSpace: true }, shape }; + } + if (kw === 'from' || kw === 'join') { + return { slot: { kind: 'table', prefix: '', replaceLen: 0, leadingSpace: true }, shape }; + } + } + + // UPDATE … SET column list: a fresh column position right after SET or after a comma. + if (m.setIdx !== -1 && !shape.inWhere) { + let fresh = true; + for (let i = m.setIdx + 1; i < completeEnd; i++) { + const t = code[i]; + if (isPunct(t, ',')) fresh = true; + else if (t.kind === 'op' && t.text === '=') fresh = false; + } + if (fresh) { + return { slot: { kind: 'set-column', prefix, replaceLen, leadingSpace: false }, shape }; + } + } + + // INSERT INTO tbl (…: inside the column-list paren (opened right after the table name). + if (m.intoIdx !== -1) { + let i = m.intoIdx + 1; + if (isIdentLike(code[i])) { + i++; + if (isPunct(code[i], '.') && isIdentLike(code[i + 1])) i += 2; + if (isPunct(code[i], '(')) { + let open = true; + const used: string[] = []; + for (let j = i + 1; j < completeEnd; j++) { + const t = code[j]; + if (isPunct(t, ')')) { + open = false; + break; + } + if (isIdentLike(t) && !isPunct(code[j + 1], '.') && !isPunct(code[j - 1], '.')) used.push(lowerIdent(t)); + } + if (open && i < completeEnd) { + return { slot: { kind: 'insert-columns', used, prefix, replaceLen }, shape }; + } + } + } + } + + // Table slot: after the last FROM/JOIN/INTO/UPDATE with nothing but refs/commas since. + if (m.srcIdx !== -1) { + let stopped = false; + for (let i = m.srcIdx + 1; i < completeEnd; i++) { + const t = code[i]; + if (t.kind === 'ident' && STOPS_TABLE.has(t.lower)) { + stopped = true; + break; + } + } + if (!stopped) { + const tailLen = completeEnd - (m.srcIdx + 1); + const lastTail = code[completeEnd - 1]; + if (tailLen === 0 || isPunct(lastTail, ',')) { + return { slot: { kind: 'table', prefix, replaceLen, leadingSpace: false }, shape }; + } + } + } + + // ORDER BY / GROUP BY body, until a terminating clause follows the list. + if (m.byIdx !== -1) { + let terminated = false; + let termStart = m.byIdx + 1; // first token of the current sort term + for (let i = m.byIdx + 1; i < completeEnd; i++) { + const t = code[i]; + if (t.kind === 'ident' && BY_TERMINATORS.has(t.lower)) { + terminated = true; + break; + } + if (isPunct(t, ',')) termStart = i + 1; + } + if (!terminated) { + const expectsExpr = expectsExpression(prev); + const term = code.slice(termStart, completeEnd); + const termLast = term[term.length - 1]; + const directionAllowed = + !m.byGroup && + termLast !== undefined && + !(termLast.kind === 'ident' && (termLast.lower === 'asc' || termLast.lower === 'desc')) && + (termLast.kind === 'ident' || + termLast.kind === 'quoted' || + termLast.kind === 'number' || + isPunct(termLast, ')')); + const trailingKeywords = expectsExpr + ? [] + : m.byGroup + ? ['HAVING', 'ORDER BY', 'LIMIT', 'OFFSET'] + : ['LIMIT', 'OFFSET']; + return { + slot: { + kind: 'order-group', + group: m.byGroup, + expectsExpr, + directionAllowed, + trailingKeywords, + prefix, + replaceLen, + leadingSpace: false, + }, + shape, + }; + } + } + + // LIMIT / OFFSET tail: takes a number (and at most one OFFSET), not columns/keywords. + if (m.limitIdx !== -1 && code[m.limitIdx] !== partial) { + let active = true; + let limitHasValue = false; + for (let i = m.limitIdx + 1; i < code.length; i++) { + const t = code[i]; + if (t === partial) continue; + if (isPunct(t, ')') || (t.kind === 'ident' && LIMIT_TERMINATORS.has(t.lower))) { + active = false; + break; + } + if (t.kind === 'number' || (t.kind === 'ident' && t.lower === 'all')) limitHasValue = true; + } + if (active) { + const offerOffset = code[m.limitIdx].lower === 'limit' && limitHasValue && !m.offsetSeen; + return { slot: { kind: 'limit', offerOffset, prefix, replaceLen }, shape }; + } + } + + // Column-filter position: a filter keyword has appeared and the statement is one that filters. + let filterKeywordSeen = shape.inFilterClause; + for (let i = 0; !filterKeywordSeen && i < completeEnd; i++) { + const t = code[i]; + if (t.kind === 'ident' && (t.lower === 'and' || t.lower === 'or')) filterKeywordSeen = true; + } + const inFilter = + filterKeywordSeen && ((shape.hasFrom && (m.selectSeen || m.deleteSeen)) || (shape.hasUpdate && m.setIdx !== -1)); + + return { + slot: { kind: 'general', prefix, replaceLen, inFilter, expectsExpr: expectsExpression(prev) }, + shape, + }; +} diff --git a/frontend/src/features/editor/lib/sqlDiagnostics.test.ts b/frontend/src/features/editor/lib/sqlDiagnostics.test.ts new file mode 100644 index 0000000..34d12b3 --- /dev/null +++ b/frontend/src/features/editor/lib/sqlDiagnostics.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { collectSchemaDiagnostics } from '@/features/editor/lib/sqlDiagnostics'; +import type { SchemaInfo, TableInfo } from '@/types'; + +const schemas: SchemaInfo[] = [{ name: 'public' }]; +const tables: TableInfo[] = [ + { schema: 'public', name: 'users', type: 'table' }, + { schema: 'public', name: 'orders', type: 'table' }, +]; + +const diags = (sql: string, driver: 'postgres' | 'mysql' | 'sqlite' = 'postgres') => + collectSchemaDiagnostics(sql, tables, schemas, driver); + +describe('collectSchemaDiagnostics', () => { + it('flags an unknown table with its exact source span', () => { + const sql = 'SELECT * FROM userz WHERE id = 1'; + const out = diags(sql); + expect(out).toHaveLength(1); + expect(out[0].message).toBe('Unknown table "userz"'); + expect(sql.slice(out[0].start, out[0].end)).toBe('userz'); + }); + + it('does not flag known tables, case-insensitively', () => { + expect(diags('SELECT * FROM users JOIN orders o ON o.id = users.id')).toEqual([]); + expect(diags('SELECT * FROM USERS')).toEqual([]); + }); + + it('does not flag CTE names or a schema qualifier being typed', () => { + expect(diags('WITH recent AS (SELECT 1) SELECT * FROM recent')).toEqual([]); + expect(diags('SELECT * FROM public.')).toEqual([]); + }); + + it('flags unknown write targets and schema-qualified misses', () => { + expect(diags('INSERT INTO userz (id) VALUES (1)')[0]?.message).toBe('Unknown table "userz"'); + expect(diags('UPDATE userz SET x = 1')[0]?.message).toBe('Unknown table "userz"'); + const out = diags('SELECT * FROM public.userz'); + expect(out).toHaveLength(1); + expect(out[0].message).toBe('Unknown table "userz"'); + }); + + it('keeps offsets absolute across multiple statements', () => { + const sql = 'SELECT * FROM users;\nSELECT * FROM ghost;'; + const out = diags(sql); + expect(out).toHaveLength(1); + expect(sql.slice(out[0].start, out[0].end)).toBe('ghost'); + }); + + it('ignores tables mentioned in comments and strings', () => { + expect(diags("SELECT * FROM users -- FROM phantom\nWHERE note = 'from ghost'")).toEqual([]); + }); + + it('handles quoted identifiers', () => { + const capTables: TableInfo[] = [{ schema: 'public', name: 'Users', type: 'table' }]; + const sql = 'SELECT * FROM "Userz"'; + const out = collectSchemaDiagnostics(sql, capTables, schemas, 'postgres'); + expect(out).toHaveLength(1); + expect(sql.slice(out[0].start, out[0].end)).toBe('"Userz"'); + expect(collectSchemaDiagnostics('SELECT * FROM "Users"', capTables, schemas, 'postgres')).toEqual([]); + }); +}); diff --git a/frontend/src/features/editor/lib/sqlDiagnostics.ts b/frontend/src/features/editor/lib/sqlDiagnostics.ts new file mode 100644 index 0000000..8f01bea --- /dev/null +++ b/frontend/src/features/editor/lib/sqlDiagnostics.ts @@ -0,0 +1,33 @@ +import { parseQueryContext } from '@/features/editor/lib/sqlQueryParse'; +import { parseSqlStatements } from '@/features/editor/lib/sqlStatements'; +import type { DriverType, SchemaInfo, TableInfo } from '@/types'; + +export interface SqlDiagnostic { + start: number; // absolute offsets into the full buffer + end: number; + message: string; +} + +// Table references that don't resolve against the live schema - typos caught before the +// round-trip to the server. CTE names are in scope by definition. +export function collectSchemaDiagnostics( + sql: string, + tables: TableInfo[], + schemas: SchemaInfo[], + driver: DriverType, +): SqlDiagnostic[] { + const out: SqlDiagnostic[] = []; + for (const stmt of parseSqlStatements(sql, driver)) { + const parsed = parseQueryContext(sql.slice(stmt.start, stmt.end), tables, schemas, driver); + const local = new Set(parsed.ctes.map((c) => c.toLowerCase())); + for (const ref of parsed.queryTables) { + if (ref.known || local.has(ref.table.toLowerCase()) || !ref.table.trim()) continue; + out.push({ + start: stmt.start + ref.nameStart, + end: stmt.start + ref.nameEnd, + message: `Unknown table "${ref.table}"`, + }); + } + } + return out; +} diff --git a/frontend/src/features/editor/lib/sqlHover.test.ts b/frontend/src/features/editor/lib/sqlHover.test.ts new file mode 100644 index 0000000..030f9e0 --- /dev/null +++ b/frontend/src/features/editor/lib/sqlHover.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { analyzeHover, columnHoverLines } from '@/features/editor/lib/sqlHover'; +import { parseQueryContext } from '@/features/editor/lib/sqlQueryParse'; +import type { SchemaInfo, TableInfo } from '@/types'; + +const schemas: SchemaInfo[] = [{ name: 'public' }]; +const tables: TableInfo[] = [ + { schema: 'public', name: 'users', type: 'table' }, + { schema: 'public', name: 'orders', type: 'view' }, +]; + +function hoverAt(sql: string, needle: string, occurrence = 1) { + let offset = -1; + for (let i = 0; i < occurrence; i++) offset = sql.indexOf(needle, offset + 1); + const parsed = parseQueryContext(sql, tables, schemas, 'postgres'); + return analyzeHover(sql, offset, parsed, tables, schemas, 'postgres'); +} + +describe('analyzeHover', () => { + it('describes a table with its type and schema', () => { + const q = hoverAt('SELECT * FROM users WHERE id = 1', 'users'); + expect(q?.lines).toEqual(['**users** · table', 'schema public']); + const v = hoverAt('SELECT * FROM orders', 'orders'); + expect(v?.lines).toEqual(['**orders** · view', 'schema public']); + }); + + it('spans exactly the hovered token', () => { + const sql = 'SELECT * FROM users'; + const q = hoverAt(sql, 'users'); + expect(sql.slice(q?.start, q?.end)).toBe('users'); + }); + + it('describes an alias as pointing at its table', () => { + const q = hoverAt('SELECT * FROM users u WHERE u.id = 1', 'u ', 1); + expect(q?.lines?.[0]).toBe('**u** · alias for users'); + }); + + it('requests a column lookup for a qualified column', () => { + const q = hoverAt('SELECT * FROM users u WHERE u.email = 1', 'email'); + expect(q?.columnLookup).toEqual({ bindings: [{ schema: 'public', table: 'users' }], name: 'email' }); + }); + + it('requests a lookup across in-scope tables for a bare column', () => { + const q = hoverAt('SELECT email FROM users JOIN orders o ON 1=1', 'email'); + expect(q?.columnLookup?.name).toBe('email'); + expect(q?.columnLookup?.bindings.map((b) => b.table).sort()).toEqual(['orders', 'users']); + }); + + it('answers CTE names and their columns without a lookup', () => { + const sql = 'WITH recent AS (SELECT id, email AS mail FROM users) SELECT * FROM recent WHERE recent.mail = 1'; + expect(hoverAt(sql, 'recent', 2)?.lines).toEqual(['**recent** · CTE', 'columns: id, mail']); + // 'email' contains 'mail', so the qualified recent.mail is the 3rd occurrence. + expect(hoverAt(sql, 'mail', 3)?.lines).toEqual(['**mail**', 'column of CTE recent']); + }); + + it('ignores keywords, strings and comments', () => { + expect(hoverAt('SELECT * FROM users', 'SELECT')).toBeNull(); + expect(hoverAt("SELECT * FROM users WHERE a = 'users'", 'users', 2)).toBeNull(); + expect(hoverAt('SELECT * FROM users -- users note', 'users', 2)).toBeNull(); + }); + + it('describes a schema name', () => { + expect(hoverAt('SELECT * FROM public.users', 'public')?.lines).toEqual(['**public** · schema']); + }); + + it('formats column hover lines from schema metadata', () => { + const lines = columnHoverLines( + { name: 'email', dataType: 'text', isNullable: false, isPrimary: false, isForeign: false }, + { schema: 'public', table: 'users' }, + ); + expect(lines).toEqual(['**email** · text · not null', 'column of public.users']); + }); +}); diff --git a/frontend/src/features/editor/lib/sqlHover.ts b/frontend/src/features/editor/lib/sqlHover.ts new file mode 100644 index 0000000..1213ea0 --- /dev/null +++ b/frontend/src/features/editor/lib/sqlHover.ts @@ -0,0 +1,106 @@ +import { type ParsedQuery, resolveDotCompletion, type TableBinding } from '@/features/editor/lib/sqlQueryParse'; +import { QUOTE_FORCING_KEYWORDS, unquoteIdent } from '@/features/editor/lib/sqlQuoting'; +import { columnDetail } from '@/features/editor/lib/sqlSuggestions'; +import { isIdentLike, type SqlToken, tokenIdentText, tokenizeSql } from '@/features/editor/lib/sqlTokens'; +import type { ColumnInfo, DriverType, SchemaInfo, TableInfo } from '@/types'; + +// What to show for the identifier under the pointer. Either an immediate answer (`lines`) or a +// column lookup the caller resolves by loading the candidate tables' columns (cached). +export interface HoverQuery { + start: number; // statement-relative span of the hovered token + end: number; + lines?: string[]; + columnLookup?: { bindings: TableBinding[]; name: string }; +} + +export function columnHoverLines(col: ColumnInfo, table: TableBinding): string[] { + const where = table.schema ? `${table.schema}.${table.table}` : table.table; + return [`**${col.name}** · ${columnDetail(col)}`, `column of ${where}`]; +} + +function tableLines(t: TableInfo): string[] { + return [`**${t.name}** · ${t.type || 'table'}`, t.schema ? `schema ${t.schema}` : ''].filter(Boolean); +} + +function tokenAt(tokens: SqlToken[], offset: number): SqlToken | undefined { + return tokens.find((t) => offset >= t.start && offset < t.end && isIdentLike(t)); +} + +export function analyzeHover( + stmtText: string, + offset: number, + parsed: ParsedQuery, + tables: TableInfo[], + schemas: SchemaInfo[], + driver: DriverType, +): HoverQuery | null { + const tokens = tokenizeSql(stmtText, driver).filter((t) => t.kind !== 'comment'); + const tok = tokenAt(tokens, offset); + if (!tok) return null; + // Bare keywords aren't identifiers; quoted tokens always are. + if (tok.kind === 'ident' && QUOTE_FORCING_KEYWORDS.has(tok.lower)) return null; + + const name = tokenIdentText(tok); + const nameLc = name.toLowerCase(); + const span = { start: tok.start, end: tok.end }; + const idx = tokens.indexOf(tok); + + // Leading `qual.` / `schema.qual.` segments before the hovered token. + const segments: string[] = []; + let k = idx; + while ( + segments.length < 2 && + tokens[k - 1]?.kind === 'punct' && + tokens[k - 1]?.text === '.' && + isIdentLike(tokens[k - 2]) + ) { + segments.unshift(tokens[k - 2].text); + k -= 2; + } + + if (segments.length > 0) { + const qualLc = unquoteIdent(segments[segments.length - 1]).toLowerCase(); + const virtual = parsed.virtualColumns.get(qualLc); + if (virtual) { + if (!virtual.some((c) => c.toLowerCase() === nameLc)) return null; + const kind = parsed.ctes.some((c) => c.toLowerCase() === qualLc) ? 'CTE' : 'subquery'; + return { ...span, lines: [`**${name}**`, `column of ${kind} ${qualLc}`] }; + } + const binding = resolveDotCompletion({ segments }, parsed.bindings, tables, schemas, driver); + if (binding) return { ...span, columnLookup: { bindings: [binding], name: nameLc } }; + return null; + } + + // Alias or table referenced by this query. + const bound = parsed.bindings.get(nameLc); + if (bound) { + const info = tables.find((t) => t.name === bound.table && t.schema === bound.schema); + if (bound.table.toLowerCase() !== nameLc) { + return { ...span, lines: [`**${name}** · alias for ${bound.table}`, ...(info ? tableLines(info).slice(1) : [])] }; + } + if (info) return { ...span, lines: tableLines(info) }; + } + + // CTE / derived-table alias. + const virtual = parsed.virtualColumns.get(nameLc); + if (virtual) { + const kind = parsed.ctes.some((c) => c.toLowerCase() === nameLc) ? 'CTE' : 'subquery'; + const cols = virtual.length > 0 ? virtual.slice(0, 8).join(', ') + (virtual.length > 8 ? ', …' : '') : ''; + return { ...span, lines: [`**${name}** · ${kind}`, cols && `columns: ${cols}`].filter(Boolean) as string[] }; + } + + // Any table in the connected schema. + const table = tables.find((t) => t.name.toLowerCase() === nameLc); + if (table) return { ...span, lines: tableLines(table) }; + + // A schema name. + const schema = schemas.find((s) => s.name.toLowerCase() === nameLc); + if (schema) return { ...span, lines: [`**${schema.name}** · schema`] }; + + // Bare column: search the query's in-scope tables (loaded lazily by the caller). + const candidates = [...parsed.bindings.values()].filter( + (b, i, arr) => arr.findIndex((x) => x.schema === b.schema && x.table === b.table) === i, + ); + if (candidates.length > 0) return { ...span, columnLookup: { bindings: candidates, name: nameLc } }; + return null; +} diff --git a/frontend/src/features/editor/lib/sqlIdentifiers.ts b/frontend/src/features/editor/lib/sqlIdentifiers.ts deleted file mode 100644 index ddf553b..0000000 --- a/frontend/src/features/editor/lib/sqlIdentifiers.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DriverType } from '@/types'; - -// Matches Go database.QuoteIdent. -export function quoteIdent(driver: DriverType, ident: string): string { - switch (driver) { - case 'postgres': - return `"${ident.replace(/"/g, '""')}"`; - case 'mysql': - return `\`${ident.replace(/`/g, '``')}\``; - default: - return `"${ident.replace(/"/g, '""')}"`; - } -} - -export function buildQualifiedTable(driver: DriverType, schema: string, table: string): string { - if (!schema || schema === 'main') { - return quoteIdent(driver, table); - } - return `${quoteIdent(driver, schema)}.${quoteIdent(driver, table)}`; -} diff --git a/frontend/src/features/editor/lib/sqlQueryParse.test.ts b/frontend/src/features/editor/lib/sqlQueryParse.test.ts new file mode 100644 index 0000000..427cf00 --- /dev/null +++ b/frontend/src/features/editor/lib/sqlQueryParse.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { parseQueryContext } from '@/features/editor/lib/sqlQueryParse'; +import type { SchemaInfo, TableInfo } from '@/types'; + +const schemas: SchemaInfo[] = [{ name: 'public' }]; +const tables: TableInfo[] = [ + { schema: 'public', name: 'users', type: 'table' }, + { schema: 'public', name: 'orders', type: 'table' }, +]; + +const tableNames = (sql: string, driver: 'postgres' | 'mysql' | 'sqlite' = 'postgres') => + parseQueryContext(sql, tables, schemas, driver).queryTables.map((t) => t.table); + +describe('comments and strings do not bind phantom tables', () => { + it('ignores FROM/JOIN inside line comments', () => { + expect(tableNames('SELECT * FROM users -- JOIN phantom p\nWHERE ')).toEqual(['users']); + expect(tableNames('-- SELECT * FROM old_table\nSELECT * FROM users WHERE ')).toEqual(['users']); + }); + + it('ignores FROM/JOIN inside block comments', () => { + expect(tableNames('SELECT * FROM users /* JOIN phantom p */ WHERE ')).toEqual(['users']); + }); + + it('ignores FROM inside string literals', () => { + expect(tableNames("SELECT * FROM users WHERE note = 'copied from backups' AND ")).toEqual(['users']); + }); + + it('mysql: ignores FROM inside double-quoted strings and # comments', () => { + expect(tableNames('SELECT * FROM users WHERE name = "John from accounting"', 'mysql')).toEqual(['users']); + expect(tableNames('SELECT * FROM users # JOIN phantom\nWHERE ', 'mysql')).toEqual(['users']); + }); + + it('still parses quoted-identifier tables', () => { + const parsed = parseQueryContext('SELECT * FROM "users" u JOIN `orders` o ON ', tables, schemas, 'postgres'); + expect(parsed.queryTables.map((t) => t.table)).toEqual(['users', 'orders']); + expect(parsed.bindings.get('u')?.table).toBe('users'); + }); +}); + +describe('CTE names vs comments', () => { + it('does not invent CTEs from a commented-out WITH', () => { + const parsed = parseQueryContext('-- WITH x AS (SELECT 1)\nSELECT * FROM users', tables, schemas, 'postgres'); + expect(parsed.ctes).toEqual([]); + }); + + it('finds the CTE when a leading comment precedes WITH', () => { + const parsed = parseQueryContext( + '-- recent signups\nWITH recent AS (SELECT 1) SELECT * FROM ', + tables, + schemas, + 'postgres', + ); + expect(parsed.ctes).toEqual(['recent']); + }); +}); + +describe('IS DISTINCT FROM is not a table source', () => { + it('does not bind the right-hand expression as a table', () => { + expect(tableNames('SELECT * FROM users WHERE name IS DISTINCT FROM email')).toEqual(['users']); + expect(tableNames('SELECT * FROM users WHERE name IS NOT DISTINCT FROM email AND ')).toEqual(['users']); + }); + + it('still binds tables after SELECT DISTINCT', () => { + expect(tableNames('SELECT DISTINCT name FROM users JOIN orders o ON ')).toEqual(['users', 'orders']); + }); +}); + +describe('write-target bindings', () => { + it('binds the INSERT INTO target table', () => { + const parsed = parseQueryContext('INSERT INTO users (id) VALUES (1)', tables, schemas, 'postgres'); + expect(parsed.queryTables.map((t) => t.table)).toEqual(['users']); + expect(parsed.bindings.get('users')?.table).toBe('users'); + }); + + it('binds a schema-qualified INSERT target and REPLACE INTO (mysql)', () => { + expect(tableNames('INSERT INTO public.orders (id) VALUES (1)')).toEqual(['orders']); + expect(tableNames('REPLACE INTO orders SET id = 1', 'mysql')).toEqual(['orders']); + }); + + it('keeps binding the UPDATE target', () => { + expect(tableNames('UPDATE users SET name = 1 WHERE ')).toEqual(['users']); + }); +}); diff --git a/frontend/src/features/editor/lib/sqlQueryParse.ts b/frontend/src/features/editor/lib/sqlQueryParse.ts index 06c9848..14b50e1 100644 --- a/frontend/src/features/editor/lib/sqlQueryParse.ts +++ b/frontend/src/features/editor/lib/sqlQueryParse.ts @@ -1,34 +1,15 @@ import { ALIAS_STOP_WORDS, unquoteIdent } from '@/features/editor/lib/sqlQuoting'; +import { + isIdentLike, + isKeyword, + nextCodeToken, + prevCodeToken, + type SqlToken, + tokenIdentText, + tokenizeSql, +} from '@/features/editor/lib/sqlTokens'; import type { DriverType, SchemaInfo, TableInfo } from '@/types'; -// IDENT_SRC: quoted (double/single/backtick) or bare identifier. -const IDENT_SRC = '(?:"[^"]+")|(?:\'[^\']+\')|(?:`[^`]+`)|(?:[a-zA-Z_][\\w]*)'; - -// Bare alias must not be a reserved keyword - otherwise `FROM accounts JOIN contracts` swallows JOIN as accounts' alias. Quoted aliases are always allowed. -const ALIAS_SRC = `(?:"[^"]+")|(?:'[^']+')|(?:\`[^\`]+\`)|(?:(?!(?:${[...ALIAS_STOP_WORDS].join('|')})\\b)[a-zA-Z_][\\w]*)`; - -const TABLE_BINDING_RE = new RegExp( - `\\b(?:FROM|JOIN)\\s+(${IDENT_SRC})(?:\\s*\\.\\s*(${IDENT_SRC}))?(?:\\s+(?:AS\\s+)?(${ALIAS_SRC}))?`, - 'gi', -); - -const UPDATE_BINDING_RE = - /\bUPDATE\s+((?:"[^"]+")|(?:'[^']+')|(?:`[^`]+`)|(?:[a-zA-Z_][\w]*))(?:\s*\.\s*((?:"[^"]+")|(?:'[^']+')|(?:`[^`]+`)|(?:[a-zA-Z_][\w]*)))?/gi; - -// Comma-separated FROM tables (`FROM a, b c, schema.d`): FROM_TABLE_LIST_RE captures the whole -// list, FROM_ITEM_RE walks each `table[.table] [alias]`. The ALIAS stop-word guard stops -// `FROM a JOIN b` reading JOIN as a's alias. -const FROM_ITEM_SRC = `(?:${IDENT_SRC})(?:\\s*\\.\\s*(?:${IDENT_SRC}))?(?:\\s+(?:AS\\s+)?(?:${ALIAS_SRC}))?`; -const FROM_TABLE_LIST_RE = new RegExp(`\\bFROM\\s+(${FROM_ITEM_SRC}(?:\\s*,\\s*${FROM_ITEM_SRC})*)`, 'gi'); -const FROM_ITEM_RE = new RegExp(`(${IDENT_SRC})(?:\\s*\\.\\s*(${IDENT_SRC}))?(?:\\s+(?:AS\\s+)?(${ALIAS_SRC}))?`, 'gi'); - -// CTE names declared in a leading WITH clause: `WITH a AS (…), b (c1,c2) AS (…)`. The `AS (` -// shape is CTE-specific (column aliases use `AS name` with no paren), so false positives are rare. -const CTE_NAME_RE = new RegExp( - `(?:\\bWITH\\b|,)\\s*(?:RECURSIVE\\s+)?(${IDENT_SRC})\\s*(?:\\([^)]*\\))?\\s+AS\\s+(?:NOT\\s+MATERIALIZED\\s+|MATERIALIZED\\s+)?\\(`, - 'gi', -); - export interface TableBinding { schema: string; table: string; @@ -38,13 +19,54 @@ export interface QueryTableRef { schema: string; table: string; alias?: string; - index: number; // source offset of the FROM/JOIN keyword + index: number; // source offset of the FROM/JOIN/UPDATE/INSERT keyword + nameStart: number; // source span of the table-name token (for diagnostics/hover) + nameEnd: number; + known: boolean; // resolved against the live schema (false = best-effort guess, maybe a typo) } export interface ParsedQuery { queryTables: QueryTableRef[]; bindings: Map; ctes: string[]; // CTE names from a leading WITH clause (offered like tables in FROM/JOIN) + // Projected column names of query-local relations that have no schema entry (CTEs and + // derived-table aliases). Lowercased keys; empty when the projection is opaque (SELECT *). + virtualColumns: Map; +} + +// Lowercase name/schema lookups, cached per tables-array identity (hit several times per keystroke). +interface TableIndex { + byName: Map; + schemaNames: Set; +} + +const tableIndexCache = new WeakMap(); + +function tableIndexFor(tables: TableInfo[]): TableIndex { + let index = tableIndexCache.get(tables); + if (!index) { + const byName = new Map(); + const schemaNames = new Set(); + for (const t of tables) { + const key = t.name.toLowerCase(); + const list = byName.get(key); + if (list) { + list.push(t); + } else { + byName.set(key, [t]); + } + schemaNames.add(t.schema.toLowerCase()); + } + index = { byName, schemaNames }; + tableIndexCache.set(tables, index); + } + return index; +} + +function lookupTable(tableName: string, schemaHint: string | undefined, tables: TableInfo[]): TableInfo | undefined { + const lcHint = schemaHint?.toLowerCase(); + const candidates = tableIndexFor(tables).byName.get(tableName.toLowerCase()); + return (lcHint && candidates?.find((t) => t.schema.toLowerCase() === lcHint)) || candidates?.[0]; } function resolveTableName( @@ -54,11 +76,7 @@ function resolveTableName( schemas: SchemaInfo[], driver: DriverType, ): TableBinding { - const lcName = tableName.toLowerCase(); - const lcHint = schemaHint?.toLowerCase(); - const match = - tables.find((t) => t.name.toLowerCase() === lcName && (!lcHint || t.schema.toLowerCase() === lcHint)) || - tables.find((t) => t.name.toLowerCase() === lcName); + const match = lookupTable(tableName, schemaHint, tables); let schema = schemaHint || match?.schema || ''; if (!schema) { if (driver === 'postgres') { @@ -70,17 +88,157 @@ function resolveTableName( return { schema, table: match?.name ?? tableName }; } -function parseTableRef( - part1: string, - part2: string | undefined, - tables: TableInfo[], - schemas: SchemaInfo[], - driver: DriverType, -): TableBinding { - if (part2) { - return resolveTableName(unquoteIdent(part2), unquoteIdent(part1), tables, schemas, driver); +const isDot = (t: SqlToken | undefined) => t !== undefined && t.kind === 'punct' && t.text === '.'; +const isComma = (t: SqlToken | undefined) => t !== undefined && t.kind === 'punct' && t.text === ','; + +interface RawRef { + name1: SqlToken; + name2?: SqlToken; + alias?: SqlToken; + last: number; // index of the ref's final consumed token +} + +// `name[.name] [AS] [alias]` starting at token `at`. A bare alias must not be a reserved keyword +// (else `FROM accounts JOIN …` would swallow JOIN as accounts' alias); quoted aliases always bind. +function readTableRef(tokens: SqlToken[], at: number, allowAlias: boolean): RawRef | null { + if (!isIdentLike(tokens[at])) return null; + const name1 = tokens[at]; + let last = at; + let name2: SqlToken | undefined; + let j = nextCodeToken(tokens, last); + if (j !== -1 && isDot(tokens[j])) { + const k = nextCodeToken(tokens, j); + if (k === -1 || !isIdentLike(tokens[k])) { + return { name1, last }; // dangling `schema.` while typing - bind the first part only + } + name2 = tokens[k]; + last = k; + j = nextCodeToken(tokens, last); + } + let alias: SqlToken | undefined; + if (allowAlias && j !== -1) { + const t = tokens[j]; + if (isKeyword(t, 'as')) { + const k = nextCodeToken(tokens, j); + if (k !== -1 && isIdentLike(tokens[k])) { + alias = tokens[k]; + last = k; + } + } else if (isIdentLike(t) && (t.kind === 'quoted' || !ALIAS_STOP_WORDS.has(t.lower))) { + alias = t; + last = j; + } + } + return { name1, name2, alias, last }; +} + +// Index of the `)` matching the `(` at openIdx, or -1 when unbalanced. +function matchParen(tokens: SqlToken[], openIdx: number): number { + let depth = 0; + for (let i = openIdx; i < tokens.length; i++) { + const t = tokens[i]; + if (t.kind !== 'punct') continue; + if (t.text === '(') depth++; + else if (t.text === ')' && --depth === 0) return i; + } + return -1; +} + +// Output column names of the SELECT inside tokens[start, end): explicit or trailing alias, +// else the bare/qualified column name. Unaliased expressions and `*` contribute nothing. +function extractProjection(tokens: SqlToken[], start: number, end: number): string[] { + const cols: string[] = []; + let i = start; + let depth = 0; + for (; i < end; i++) { + const t = tokens[i]; + if (t.kind === 'punct') { + if (t.text === '(') depth++; + else if (t.text === ')') depth--; + } else if (depth === 0 && isKeyword(t, 'select')) { + break; + } + } + if (i >= end) return cols; + i = nextCodeToken(tokens, i); + if (i !== -1 && (isKeyword(tokens[i], 'distinct') || isKeyword(tokens[i], 'all'))) i = nextCodeToken(tokens, i); + if (i === -1) return cols; + + let item: SqlToken[] = []; + const flush = () => { + const last = item[item.length - 1]; + if (last && (last.kind === 'ident' || last.kind === 'quoted')) { + const prev = item[item.length - 2]; + const aliased = + prev !== undefined && + (prev.kind === 'ident' || prev.kind === 'quoted' || prev.kind === 'number' || isPunctToken(prev, ')')); + if (item.length === 1 || isPunctToken(prev, '.') || aliased) { + if (!isKeyword(last, 'as')) cols.push(tokenIdentText(last)); + } + } + item = []; + }; + + let inner = 0; + for (; i < end; i++) { + const t = tokens[i]; + if (t.kind === 'comment') continue; + if (t.kind === 'punct') { + if (t.text === '(') inner++; + else if (t.text === ')') { + if (inner === 0) break; + inner--; + } else if (t.text === ',' && inner === 0) { + flush(); + continue; + } + } + if (inner === 0 && t.kind === 'ident' && t.lower === 'from') break; + item.push(t); } - return resolveTableName(unquoteIdent(part1), undefined, tables, schemas, driver); + flush(); + return cols; +} + +const isPunctToken = (t: SqlToken | undefined, ch: string) => t !== undefined && t.kind === 'punct' && t.text === ch; + +// Names (and projected columns) from a leading WITH clause. Bodies are skipped balanced for the +// name walk; the main pass still collects their inner FROM/JOIN refs. +function collectCtes(tokens: SqlToken[], virtualColumns: Map): string[] { + const ctes: string[] = []; + let i = nextCodeToken(tokens, -1); + if (i === -1 || !isKeyword(tokens[i], 'with')) return ctes; + i = nextCodeToken(tokens, i); + if (i !== -1 && isKeyword(tokens[i], 'recursive')) i = nextCodeToken(tokens, i); + + while (i !== -1 && isIdentLike(tokens[i])) { + const name = tokens[i]; + let explicitCols: string[] | null = null; + let j = nextCodeToken(tokens, i); + if (j !== -1 && isPunctToken(tokens[j], '(')) { + const close = matchParen(tokens, j); + if (close === -1) break; + explicitCols = []; + for (let k = j + 1; k < close; k++) { + if (isIdentLike(tokens[k])) explicitCols.push(tokenIdentText(tokens[k])); + } + j = nextCodeToken(tokens, close); + } + if (j === -1 || !isKeyword(tokens[j], 'as')) break; + j = nextCodeToken(tokens, j); + if (j !== -1 && isKeyword(tokens[j], 'not')) j = nextCodeToken(tokens, j); + if (j !== -1 && isKeyword(tokens[j], 'materialized')) j = nextCodeToken(tokens, j); + if (j === -1 || !isPunctToken(tokens[j], '(')) break; + ctes.push(tokenIdentText(name)); // `AS (` seen - the body may still be unterminated while typing + const close = matchParen(tokens, j); + const bodyEnd = close === -1 ? tokens.length : close; + virtualColumns.set(tokenIdentText(name).toLowerCase(), explicitCols ?? extractProjection(tokens, j + 1, bodyEnd)); + if (close === -1) break; + const comma = nextCodeToken(tokens, close); + if (comma === -1 || !isComma(tokens[comma])) break; + i = nextCodeToken(tokens, comma); + } + return ctes; } export function parseQueryContext( @@ -89,40 +247,81 @@ export function parseQueryContext( schemas: SchemaInfo[], driver: DriverType, ): ParsedQuery { + const tokens = tokenizeSql(sql, driver); const queryTables: QueryTableRef[] = []; + const virtualColumns = new Map(); - TABLE_BINDING_RE.lastIndex = 0; - for (let m = TABLE_BINDING_RE.exec(sql); m !== null; m = TABLE_BINDING_RE.exec(sql)) { - if (!m[1]) continue; - const binding = parseTableRef(m[1], m[2], tables, schemas, driver); - const aliasRaw = m[3]; - // Strip quotes before ALIAS_STOP_WORDS check so quoted keywords like "WHERE" are still filtered. - const aliasUnquoted = aliasRaw ? unquoteIdent(aliasRaw) : undefined; - const alias = aliasUnquoted && !ALIAS_STOP_WORDS.has(aliasUnquoted.toLowerCase()) ? aliasUnquoted : undefined; - queryTables.push({ schema: binding.schema, table: binding.table, alias, index: m.index }); - } + const pushRef = (kwOffset: number, raw: RawRef) => { + const nameTok = raw.name2 ?? raw.name1; + const name = tokenIdentText(nameTok); + const schemaHint = raw.name2 ? tokenIdentText(raw.name1) : undefined; + const binding = resolveTableName(name, schemaHint, tables, schemas, driver); + const aliasText = raw.alias ? tokenIdentText(raw.alias) : undefined; + const alias = aliasText && !ALIAS_STOP_WORDS.has(aliasText.toLowerCase()) ? aliasText : undefined; + // A lone name that matches a schema is a qualifier still being typed (`FROM public.`), not a typo. + const known = + lookupTable(name, schemaHint, tables) !== undefined || + (!raw.name2 && tableIndexFor(tables).schemaNames.has(name.toLowerCase())); + queryTables.push({ + schema: binding.schema, + table: binding.table, + alias, + index: kwOffset, + nameStart: nameTok.start, + nameEnd: nameTok.end, + known, + }); + }; - UPDATE_BINDING_RE.lastIndex = 0; - for (let m = UPDATE_BINDING_RE.exec(sql); m !== null; m = UPDATE_BINDING_RE.exec(sql)) { - if (!m[1]) continue; - const binding = parseTableRef(m[1], m[2], tables, schemas, driver); - queryTables.push({ schema: binding.schema, table: binding.table, index: m.index }); - } + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + if (t.kind !== 'ident') continue; - // Comma-joined FROM tables (`FROM a, b, c`) - TABLE_BINDING_RE only caught the first; dedup below - // drops the resulting overlap on that first table. - FROM_TABLE_LIST_RE.lastIndex = 0; - for (let m = FROM_TABLE_LIST_RE.exec(sql); m !== null; m = FROM_TABLE_LIST_RE.exec(sql)) { - const list = m[1]; - const listIndex = m.index; - FROM_ITEM_RE.lastIndex = 0; - for (let im = FROM_ITEM_RE.exec(list); im !== null; im = FROM_ITEM_RE.exec(list)) { - if (!im[1]) continue; - const binding = parseTableRef(im[1], im[2], tables, schemas, driver); - const aliasRaw = im[3]; - const aliasUnquoted = aliasRaw ? unquoteIdent(aliasRaw) : undefined; - const alias = aliasUnquoted && !ALIAS_STOP_WORDS.has(aliasUnquoted.toLowerCase()) ? aliasUnquoted : undefined; - queryTables.push({ schema: binding.schema, table: binding.table, alias, index: listIndex }); + if (t.lower === 'from' || t.lower === 'join') { + // The FROM of `IS [NOT] DISTINCT FROM` is a comparison operator, not a table source. + const p = prevCodeToken(tokens, i); + if (t.lower === 'from' && p !== -1 && isKeyword(tokens[p], 'distinct')) continue; + // FROM takes a comma list; JOIN a single ref. + let at = nextCodeToken(tokens, i); + for (;;) { + if (at !== -1 && isPunctToken(tokens[at], '(')) { + // Derived table `(SELECT …) alias`: record its alias + projection. The main loop still + // descends into the body and collects its inner FROM/JOIN refs. + const close = matchParen(tokens, at); + if (close === -1) break; + let aliasIdx = nextCodeToken(tokens, close); + if (aliasIdx !== -1 && isKeyword(tokens[aliasIdx], 'as')) aliasIdx = nextCodeToken(tokens, aliasIdx); + const aliasTok = aliasIdx !== -1 ? tokens[aliasIdx] : undefined; + const aliasOk = + aliasTok !== undefined && + (aliasTok.kind === 'quoted' || (aliasTok.kind === 'ident' && !ALIAS_STOP_WORDS.has(aliasTok.lower))); + if (!aliasOk) break; + virtualColumns.set(tokenIdentText(aliasTok).toLowerCase(), extractProjection(tokens, at + 1, close)); + if (t.lower === 'join') break; + const comma = nextCodeToken(tokens, aliasIdx); + if (comma === -1 || !isComma(tokens[comma])) break; + at = nextCodeToken(tokens, comma); + continue; + } + const raw = at !== -1 ? readTableRef(tokens, at, true) : null; + if (!raw) break; + pushRef(t.start, raw); + if (t.lower === 'join') break; + const comma = nextCodeToken(tokens, raw.last); + if (comma === -1 || !isComma(tokens[comma])) break; + at = nextCodeToken(tokens, comma); + } + } else if (t.lower === 'update') { + const at = nextCodeToken(tokens, i); + const raw = at !== -1 ? readTableRef(tokens, at, true) : null; + if (raw) pushRef(t.start, raw); + } else if (t.lower === 'into') { + const p = prevCodeToken(tokens, i); + if (p !== -1 && (isKeyword(tokens[p], 'insert') || isKeyword(tokens[p], 'replace'))) { + const at = nextCodeToken(tokens, i); + const raw = at !== -1 ? readTableRef(tokens, at, false) : null; + if (raw) pushRef(tokens[p].start, raw); + } } } @@ -143,15 +342,7 @@ export function parseQueryContext( } } - const ctes: string[] = []; - if (/^\s*WITH\b/i.test(sql)) { - CTE_NAME_RE.lastIndex = 0; - for (let m = CTE_NAME_RE.exec(sql); m !== null; m = CTE_NAME_RE.exec(sql)) { - if (m[1]) ctes.push(unquoteIdent(m[1])); - } - } - - return { queryTables: deduped, bindings, ctes }; + return { queryTables: deduped, bindings, ctes: collectCtes(tokens, virtualColumns), virtualColumns }; } export function resolveQualifierToTable( @@ -164,13 +355,12 @@ export function resolveQualifierToTable( const key = qualifier.toLowerCase(); // A schema qualifier (e.g. `analytics.`) must fall through to "list that schema's tables" - not // resolve to a same-named alias/table binding - so check schema membership first. - if (tables.some((t) => t.schema.toLowerCase() === key)) return null; + if (tableIndexFor(tables).schemaNames.has(key)) return null; const fromAlias = bindings.get(key); if (fromAlias) return fromAlias; - const asTable = resolveTableName(qualifier, undefined, tables, schemas, driver); - return asTable; + return resolveTableName(qualifier, undefined, tables, schemas, driver); } export function resolveDotCompletion( diff --git a/frontend/src/features/editor/lib/sqlIdentifiers.test.ts b/frontend/src/features/editor/lib/sqlQuoting.test.ts similarity index 98% rename from frontend/src/features/editor/lib/sqlIdentifiers.test.ts rename to frontend/src/features/editor/lib/sqlQuoting.test.ts index 81fc622..086320f 100644 --- a/frontend/src/features/editor/lib/sqlIdentifiers.test.ts +++ b/frontend/src/features/editor/lib/sqlQuoting.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildQualifiedTable, quoteIdent } from '@/features/editor/lib/sqlIdentifiers'; +import { buildQualifiedTable, quoteIdent } from '@/features/editor/lib/sqlQuoting'; describe('quoteIdent', () => { it('uses double quotes for postgres and sqlite', () => { diff --git a/frontend/src/features/editor/lib/sqlQuoting.ts b/frontend/src/features/editor/lib/sqlQuoting.ts index 70757bb..cca83b9 100644 --- a/frontend/src/features/editor/lib/sqlQuoting.ts +++ b/frontend/src/features/editor/lib/sqlQuoting.ts @@ -1,6 +1,24 @@ -import { quoteIdent } from '@/features/editor/lib/sqlIdentifiers'; import type { DriverType } from '@/types'; +// Matches Go database.QuoteIdent. +export function quoteIdent(driver: DriverType, ident: string): string { + switch (driver) { + case 'postgres': + return `"${ident.replace(/"/g, '""')}"`; + case 'mysql': + return `\`${ident.replace(/`/g, '``')}\``; + default: + return `"${ident.replace(/"/g, '""')}"`; + } +} + +export function buildQualifiedTable(driver: DriverType, schema: string, table: string): string { + if (!schema || schema === 'main') { + return quoteIdent(driver, table); + } + return `${quoteIdent(driver, schema)}.${quoteIdent(driver, table)}`; +} + export const ALIAS_STOP_WORDS = new Set([ 'on', 'and', @@ -50,7 +68,7 @@ export function unquoteIdent(raw: string): string { // Common SQL keywords that are also plausible identifier names; inserting them unquoted from // autocomplete (e.g. a column literally named `order`) would be a syntax error. -const QUOTE_FORCING_KEYWORDS = new Set([ +export const QUOTE_FORCING_KEYWORDS = new Set([ ...ALIAS_STOP_WORDS, 'table', 'column', @@ -86,6 +104,12 @@ const QUOTE_FORCING_KEYWORDS = new Set([ 'is', 'in', 'like', + 'ilike', + 'similar', + 'regexp', + 'rlike', + 'glob', + 'match', 'between', 'exists', 'distinct', diff --git a/frontend/src/features/editor/lib/sqlStatements.test.ts b/frontend/src/features/editor/lib/sqlStatements.test.ts index af00bbf..9483e1b 100644 --- a/frontend/src/features/editor/lib/sqlStatements.test.ts +++ b/frontend/src/features/editor/lib/sqlStatements.test.ts @@ -70,6 +70,84 @@ describe('parseSqlStatements', () => { const out = parseSqlStatements('SELECT 1'); expect(out).toEqual([{ text: 'SELECT 1', runLine: 1, start: 0, end: 8 }]); }); + + it('tracks run lines across many statements (incremental line counting)', () => { + const sql = Array.from({ length: 50 }, (_, i) => `SELECT ${i};`).join('\n'); + const out = parseSqlStatements(sql); + expect(out.map((s) => s.runLine)).toEqual(Array.from({ length: 50 }, (_, i) => i + 1)); + }); +}); + +describe('parseSqlStatements dialects', () => { + it("mysql: backslash-escaped quote ('it\\'s') does not end the string", () => { + const sql = String.raw`SELECT 'it\'s a; test'; SELECT 2`; + expect(parseSqlStatements(sql, 'mysql')).toHaveLength(2); + }); + + it('mysql: # starts a line comment', () => { + expect(parseSqlStatements('SELECT 1 # ; not a split\n; SELECT 2', 'mysql')).toHaveLength(2); + // ...but not for postgres, where # is an operator. + expect(parseSqlStatements("SELECT x # y FROM t; SELECT ';'", 'postgres')).toHaveLength(2); + }); + + it('mysql: $ is an identifier character, not a dollar quote', () => { + expect(parseSqlStatements('SELECT a$tag$ FROM t; SELECT b$tag$ FROM u', 'mysql')).toHaveLength(2); + }); + + it('postgres: block comments nest', () => { + const sql = '/* outer /* inner */ ; still comment */ SELECT 1'; + expect(parseSqlStatements(sql, 'postgres')).toHaveLength(1); + expect(parseSqlStatements(sql, 'postgres')[0].text).toContain('SELECT 1'); + }); + + it("postgres: E'...' honors backslash escapes", () => { + const sql = String.raw`SELECT E'a\'b; not a split'; SELECT 2`; + expect(parseSqlStatements(sql, 'postgres')).toHaveLength(2); + expect(parseSqlStatements(sql)).toHaveLength(2); // E-prefix is unambiguous in every dialect + }); + + it("sqlite/default: backslash is a plain character ('a\\' closes the string)", () => { + const sql = String.raw`SELECT 'path\'; SELECT 2`; + expect(parseSqlStatements(sql, 'sqlite')).toHaveLength(2); + }); +}); + +describe('mysql DELIMITER support', () => { + const proc = [ + 'DELIMITER //', + 'CREATE PROCEDURE p()', + 'BEGIN', + ' SELECT 1;', + ' SELECT 2;', + 'END//', + 'DELIMITER ;', + 'SELECT 3;', + ].join('\n'); + + it('does not split on ; inside the procedure body and strips the custom terminator', () => { + const out = parseSqlStatements(proc, 'mysql'); + expect(out.map((s) => s.text)).toEqual(['CREATE PROCEDURE p()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND', 'SELECT 3;']); + }); + + it('places run glyphs on the real statements, not the DELIMITER lines', () => { + expect(parseSqlStatements(proc, 'mysql').map((s) => s.runLine)).toEqual([2, 8]); + }); + + it('supports $$ as a custom delimiter', () => { + const sql = 'DELIMITER $$\nCREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW BEGIN SET @a = 1; END$$\nDELIMITER ;'; + const out = parseSqlStatements(sql, 'mysql'); + expect(out).toHaveLength(1); + expect(out[0].text.endsWith('END')).toBe(true); + }); + + it('requires DELIMITER at the start of a line', () => { + expect(parseSqlStatements('SELECT delimiter FROM t; SELECT 2', 'mysql')).toHaveLength(2); + }); + + it('does not treat DELIMITER as a client command on other drivers', () => { + // Without client delimiters the body semicolons split as usual. + expect(parseSqlStatements(proc, 'postgres').length).toBeGreaterThan(2); + }); }); describe('findStatementAtRunLine', () => { diff --git a/frontend/src/features/editor/lib/sqlStatements.ts b/frontend/src/features/editor/lib/sqlStatements.ts index 4575820..0ef6b06 100644 --- a/frontend/src/features/editor/lib/sqlStatements.ts +++ b/frontend/src/features/editor/lib/sqlStatements.ts @@ -1,3 +1,14 @@ +import { + findBlockCommentEnd, + findLineCommentEnd, + findQuoteEnd, + isEscapeStringPrefix, + lexOptionsFor, + type SqlLexOptions, + skipDollarQuoted, +} from '@/features/editor/lib/sqlText'; +import type { DriverType } from '@/types'; + export interface SqlStatement { text: string; // includes trailing semicolon when present runLine: number; // 1-based line of the first non-whitespace character (used for the run glyph) @@ -5,62 +16,9 @@ export interface SqlStatement { end: number; // offset just past the statement (exclusive) } -function lineColumnAt(text: string, offset: number): { line: number; column: number } { - let line = 1; - let column = 1; - for (let i = 0; i < offset && i < text.length; i++) { - if (text[i] === '\n') { - line++; - column = 1; - } else { - column++; - } - } - return { line, column }; -} - const WHITESPACE_RE = /\s/; -function skipLineComment(text: string, i: number, end: number): number { - i += 2; - while (i < end && text[i] !== '\n') i++; - return i; -} - -function skipBlockComment(text: string, i: number, end: number): number { - i += 2; - while (i < end - 1 && !(text[i] === '*' && text[i + 1] === '/')) i++; - return Math.min(i + 2, end); -} - -// doubleEscape: '' / "" are embedded quotes (SQL convention); backticks don't double-escape. -function skipQuoted(text: string, i: number, quote: string, doubleEscape: boolean, end: number): number { - i++; - while (i < end) { - if (text[i] === quote) { - if (doubleEscape && text[i + 1] === quote) { - i += 2; - continue; - } - i++; - return i; - } - i++; - } - return i; -} - -function skipDollarQuoted(text: string, i: number, end: number): number { - // Tags never start with a digit, so `$1$` is a placeholder between two `$`, not a quote delimiter. - const tagMatch = text.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/); - if (!tagMatch) return i + 1; - const tag = tagMatch[0]; - i += tag.length; - const close = text.indexOf(tag, i); - return close === -1 ? end : close + tag.length; -} - -function firstCodeOffset(text: string, from: number, to: number): number { +function firstCodeOffset(text: string, from: number, to: number, opts: SqlLexOptions): number { let i = from; while (i < to) { const ch = text[i]; @@ -69,12 +27,16 @@ function firstCodeOffset(text: string, from: number, to: number): number { i++; continue; } - if (ch === '-' && text[i + 1] === '-') { - i = skipLineComment(text, i, to); + if ((ch === '-' && text[i + 1] === '-') || (ch === '#' && opts.hashLineComments)) { + const nl = findLineCommentEnd(text, i + (ch === '#' ? 1 : 2)); + if (nl === -1 || nl >= to) return -1; + i = nl; continue; } if (ch === '/' && text[i + 1] === '*') { - i = skipBlockComment(text, i, to); + const close = findBlockCommentEnd(text, i, opts.nestedBlockComments); + if (close === -1) return -1; + i = close; continue; } return i; @@ -82,27 +44,45 @@ function firstCodeOffset(text: string, from: number, to: number): number { return -1; } -export function parseSqlStatements(sql: string): SqlStatement[] { +// mysql-client convention: a line reading `DELIMITER xx` switches the statement terminator so +// procedure/trigger bodies can contain `;`. Consumes through the end of the line. +const DELIMITER_LINE_RE = /DELIMITER[ \t]+(\S+)[ \t]*(?:\r?\n|$)/iy; + +export function parseSqlStatements(sql: string, driver?: DriverType): SqlStatement[] { + const opts = lexOptionsFor(driver); + const clientDelimiters = driver === 'mysql'; const statements: SqlStatement[] = []; const len = sql.length; let stmtStart = 0; + let delimiter = ';'; let i = 0; - const pushStatement = (endExclusive: number, includeSemicolon: boolean) => { - const sliceEnd = includeSemicolon ? endExclusive + 1 : endExclusive; + // Statements are pushed in source order, so lineAt scans incrementally instead of from offset 0. + let lineScanPos = 0; + let lineScanLine = 1; + const lineAt = (offset: number): number => { + for (let p = lineScanPos; p < offset; p++) { + if (sql.charCodeAt(p) === 10) lineScanLine++; + } + lineScanPos = offset; + return lineScanLine; + }; + + // text covers [stmtStart, contentEnd); the statement's region extends to sliceEnd. For `;` both + // include the terminator; a custom delimiter is excluded from text (the server never sees it). + const pushStatement = (contentEnd: number, sliceEnd: number) => { const start = stmtStart; - const raw = sql.slice(start, sliceEnd); - const text = raw.trim(); + const text = sql.slice(start, contentEnd).trim(); if (!text) { stmtStart = sliceEnd; return; } - const codeAt = firstCodeOffset(sql, start, sliceEnd); + const codeAt = firstCodeOffset(sql, start, contentEnd, opts); if (codeAt < 0) { stmtStart = sliceEnd; return; } - statements.push({ text, runLine: lineColumnAt(sql, codeAt).line, start, end: sliceEnd }); + statements.push({ text, runLine: lineAt(codeAt), start, end: sliceEnd }); stmtStart = sliceEnd; }; @@ -110,33 +90,52 @@ export function parseSqlStatements(sql: string): SqlStatement[] { const ch = sql[i]; const next = sql[i + 1]; - if (ch === '-' && next === '-') { - i = skipLineComment(sql, i, len); + if (clientDelimiters && (ch === 'd' || ch === 'D')) { + DELIMITER_LINE_RE.lastIndex = i; + const m = DELIMITER_LINE_RE.exec(sql); + // Only when DELIMITER is the first thing on its line (matching the mysql client). + if (m && sql.slice(sql.lastIndexOf('\n', i - 1) + 1, i).trim() === '') { + pushStatement(i, i); // anything pending stays its own (unterminated) statement + delimiter = m[1]; + i += m[0].length; + stmtStart = i; + continue; + } + } + + if ((ch === '-' && next === '-') || (ch === '#' && opts.hashLineComments)) { + const nl = findLineCommentEnd(sql, i + (ch === '#' ? 1 : 2)); + i = nl === -1 ? len : nl; continue; } if (ch === '/' && next === '*') { - i = skipBlockComment(sql, i, len); + const close = findBlockCommentEnd(sql, i, opts.nestedBlockComments); + i = close === -1 ? len : close; continue; } if (ch === "'") { - i = skipQuoted(sql, i, "'", true, len); + const close = findQuoteEnd(sql, i, ch, true, opts.backslashEscapes || isEscapeStringPrefix(sql, i)); + i = close === -1 ? len : close; continue; } if (ch === '"') { - i = skipQuoted(sql, i, '"', true, len); + const close = findQuoteEnd(sql, i, ch, true, opts.backslashEscapes && opts.doubleQuoteStrings); + i = close === -1 ? len : close; continue; } if (ch === '`') { - i = skipQuoted(sql, i, '`', false, len); + const close = findQuoteEnd(sql, i, ch, true, false); + i = close === -1 ? len : close; continue; } - if (ch === '$') { + if (ch === '$' && opts.dollarQuotes) { i = skipDollarQuoted(sql, i, len); continue; } - if (ch === ';') { - pushStatement(i, true); - i++; + if (delimiter === ';' ? ch === ';' : sql.startsWith(delimiter, i)) { + if (delimiter === ';') pushStatement(i + 1, i + 1); + else pushStatement(i, i + delimiter.length); + i += delimiter.length; continue; } @@ -145,9 +144,9 @@ export function parseSqlStatements(sql: string): SqlStatement[] { const trailing = sql.slice(stmtStart).trim(); if (trailing) { - const codeAt = firstCodeOffset(sql, stmtStart, len); + const codeAt = firstCodeOffset(sql, stmtStart, len, opts); if (codeAt >= 0) { - statements.push({ text: trailing, runLine: lineColumnAt(sql, codeAt).line, start: stmtStart, end: len }); + statements.push({ text: trailing, runLine: lineAt(codeAt), start: stmtStart, end: len }); } } diff --git a/frontend/src/features/editor/lib/sqlSuggestions.ts b/frontend/src/features/editor/lib/sqlSuggestions.ts index 02dc966..2ebfc18 100644 --- a/frontend/src/features/editor/lib/sqlSuggestions.ts +++ b/frontend/src/features/editor/lib/sqlSuggestions.ts @@ -1,4 +1,4 @@ -import { type ClauseBodyKind, isValueContext } from '@/features/editor/lib/sqlCompletionContext'; +import type { StatementShape } from '@/features/editor/lib/sqlContext'; import type { QueryTableRef, TableBinding } from '@/features/editor/lib/sqlQueryParse'; import { columnCacheKey, formatSqlIdentifier } from '@/features/editor/lib/sqlQuoting'; import type { ColumnInfo, DriverType, SchemaInfo, TableInfo } from '@/types'; @@ -23,123 +23,114 @@ export interface CompletionItem { sortText?: string; // lower lex = higher in list } -const SQL_KEYWORDS = [ - 'SELECT', - 'FROM', - 'WHERE', - 'JOIN', - 'LEFT JOIN', - 'RIGHT JOIN', - 'INNER JOIN', - 'ON', - 'GROUP BY', - 'ORDER BY', - 'HAVING', - 'LIMIT', - 'OFFSET', - 'INSERT INTO', - 'VALUES', - 'UPDATE', - 'SET', - 'DELETE', - 'CREATE TABLE', - 'ALTER TABLE', - 'DROP TABLE', - 'AND', - 'OR', - 'NOT', - 'IN', - 'LIKE', - 'BETWEEN', - 'IS NULL', - 'IS NOT NULL', - 'AS', - 'DISTINCT', - 'CASE', - 'WHEN', - 'THEN', - 'ELSE', - 'END', - 'UNION', - 'UNION ALL', - 'EXISTS', - 'CAST', - 'ASC', - 'DESC', - 'NULL', -]; - -const JOIN_KEYWORDS = new Set([ - 'JOIN', - 'LEFT JOIN', - 'RIGHT JOIN', - 'INNER JOIN', - 'FULL JOIN', - 'CROSS JOIN', - 'OUTER JOIN', -]); - -const ORDER_KEYWORDS = new Set(['ASC', 'DESC', 'ORDER BY']); - -const VALUE_LITERALS = ['NULL', 'TRUE', 'FALSE', 'DEFAULT']; +// One rule per keyword: where it parses (gate over the statement shape), which engines have it, +// and whether it survives inside a WHERE clause (operators and clause tails do; SELECT-list +// keywords don't). +interface KeywordRule { + kw: string; + drivers?: readonly DriverType[]; + whereOk?: boolean; + gate?: (s: StatementShape) => boolean; +} -// Keywords that can only begin a statement - offered just at the statement start, never mid-query. -const STATEMENT_STARTERS = new Set([ - 'SELECT', - 'INSERT INTO', - 'UPDATE', - 'DELETE', - 'CREATE TABLE', - 'ALTER TABLE', - 'DROP TABLE', -]); - -export function keywordsForContext(before: string): string[] { - const afterOrderBy = /\bORDER\s+BY\s+[^;]*$/i.test(before); - const afterGroupBy = /\bGROUP\s+BY\s+[^;]*$/i.test(before); - const inWhere = /\bWHERE\b/i.test(before); - const hasFrom = /\bFROM\b/i.test(before); - const inSelectList = /\bSELECT\s+[^;]*$/i.test(before) && !hasFrom; - // Only an optional leading word remains → we're at the very start of the statement. - const atStatementStart = /^\s*[\w]*$/.test(before); - - let allowed = SQL_KEYWORDS.filter((kw) => { - if (ORDER_KEYWORDS.has(kw) && !afterOrderBy) return false; - if (kw === 'GROUP BY' || kw === 'HAVING') { - if (!afterGroupBy && !inSelectList) return false; - } - if (JOIN_KEYWORDS.has(kw)) { - if (!hasFrom || inWhere || isValueContext(before)) return false; - } - if (kw === 'FROM' && hasFrom) return false; - if (kw === 'WHERE' && inWhere) return false; - // Clause keywords that only belong with their statement/construct. - if (STATEMENT_STARTERS.has(kw) && !atStatementStart) return false; - if (kw === 'ON' && !/\bJOIN\b/i.test(before)) return false; - if (kw === 'VALUES' && !/\bINSERT\b/i.test(before)) return false; - if (kw === 'SET' && !/\bUPDATE\b/i.test(before)) return false; - if ((kw === 'WHEN' || kw === 'THEN' || kw === 'ELSE' || kw === 'END') && !/\bCASE\b/i.test(before)) { - return false; - } - return true; - }); +const atStart = (s: StatementShape) => s.atStatementStart; +const joinable = (s: StatementShape) => s.joinable; +const inFilterClause = (s: StatementShape) => s.inFilterClause; + +const KEYWORD_RULES: readonly KeywordRule[] = [ + // Statement starters. + { kw: 'SELECT', gate: atStart }, + { kw: 'INSERT INTO', gate: atStart }, + { kw: 'UPDATE', gate: atStart }, + { kw: 'DELETE', gate: atStart }, + { kw: 'CREATE TABLE', gate: atStart }, + { kw: 'ALTER TABLE', gate: atStart }, + { kw: 'DROP TABLE', gate: atStart }, + { kw: 'EXPLAIN', gate: atStart }, + { kw: 'TRUNCATE TABLE', drivers: ['postgres', 'mysql'], gate: atStart }, + { kw: 'REPLACE INTO', drivers: ['mysql', 'sqlite'], gate: atStart }, + { kw: 'VACUUM', drivers: ['postgres', 'sqlite'], gate: atStart }, + { kw: 'PRAGMA', drivers: ['sqlite'], gate: atStart }, + { kw: 'SHOW TABLES', drivers: ['mysql'], gate: atStart }, + { kw: 'SHOW DATABASES', drivers: ['mysql'], gate: atStart }, + + // Clause structure. + { kw: 'FROM', gate: (s) => !s.hasFrom }, + { kw: 'WHERE', gate: (s) => !s.inWhere }, + { kw: 'JOIN', gate: joinable }, + { kw: 'LEFT JOIN', gate: joinable }, + { kw: 'RIGHT JOIN', gate: joinable }, + { kw: 'INNER JOIN', gate: joinable }, + { kw: 'CROSS JOIN', gate: joinable }, + { kw: 'FULL JOIN', drivers: ['postgres', 'sqlite'], gate: joinable }, + { kw: 'ON', gate: (s) => s.hasJoin }, + { kw: 'GROUP BY', whereOk: true, gate: (s) => s.hasFrom && !s.groupBySeen }, + { kw: 'ORDER BY', whereOk: true, gate: (s) => s.hasFrom && !s.orderBySeen }, + { kw: 'HAVING', gate: (s) => s.groupBySeen }, + { kw: 'LIMIT', whereOk: true }, + { kw: 'OFFSET', whereOk: true }, + { kw: 'VALUES', gate: (s) => s.hasInsert }, + { kw: 'SET', gate: (s) => s.hasUpdate }, + { kw: 'UNION' }, + { kw: 'UNION ALL' }, + + // Expression keywords. + { kw: 'AND', whereOk: true }, + { kw: 'OR', whereOk: true }, + { kw: 'NOT', whereOk: true }, + { kw: 'IN', whereOk: true }, + { kw: 'LIKE', whereOk: true }, + { kw: 'BETWEEN', whereOk: true }, + { kw: 'IS NULL', whereOk: true }, + { kw: 'IS NOT NULL', whereOk: true }, + { kw: 'EXISTS', whereOk: true }, + { kw: 'NULL', whereOk: true }, + { kw: 'AS' }, + { kw: 'DISTINCT' }, + { kw: 'CAST' }, + { kw: 'CASE' }, + { kw: 'WHEN', gate: (s) => s.hasCase }, + { kw: 'THEN', gate: (s) => s.hasCase }, + { kw: 'ELSE', gate: (s) => s.hasCase }, + { kw: 'END', gate: (s) => s.hasCase }, + + // Filter/pattern operators; the dialect ones only exist (or only behave sanely) on their engine. + { kw: 'NOT LIKE', whereOk: true, gate: inFilterClause }, + { kw: 'NOT IN', whereOk: true, gate: inFilterClause }, + { kw: 'NOT BETWEEN', whereOk: true, gate: inFilterClause }, + { kw: 'ILIKE', drivers: ['postgres'], whereOk: true, gate: inFilterClause }, + { kw: 'NOT ILIKE', drivers: ['postgres'], whereOk: true, gate: inFilterClause }, + { kw: 'SIMILAR TO', drivers: ['postgres'], whereOk: true, gate: inFilterClause }, + { kw: 'IS DISTINCT FROM', drivers: ['postgres'], whereOk: true, gate: inFilterClause }, + { kw: 'IS NOT DISTINCT FROM', drivers: ['postgres'], whereOk: true, gate: inFilterClause }, + { kw: 'REGEXP', drivers: ['mysql'], whereOk: true, gate: inFilterClause }, + { kw: 'RLIKE', drivers: ['mysql'], whereOk: true, gate: inFilterClause }, + { kw: 'GLOB', drivers: ['sqlite'], whereOk: true, gate: inFilterClause }, + { kw: 'MATCH', drivers: ['sqlite'], whereOk: true, gate: inFilterClause }, + + // Write-statement tails. + { kw: 'RETURNING', drivers: ['postgres', 'sqlite'], whereOk: true, gate: (s) => s.returningSlot }, + { kw: 'ON CONFLICT', drivers: ['postgres', 'sqlite'], whereOk: true, gate: (s) => s.insertBody }, + { kw: 'ON DUPLICATE KEY UPDATE', drivers: ['mysql'], whereOk: true, gate: (s) => s.insertBody }, +]; - if (inWhere && !isValueContext(before)) { - const whereOps = new Set(['AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN', 'IS NULL', 'IS NOT NULL', 'EXISTS', 'NULL']); - allowed = allowed.filter((kw) => whereOps.has(kw) || kw.startsWith('IS ')); +export function keywordsForShape(shape: StatementShape, driver?: DriverType): string[] { + const out: string[] = []; + for (const r of KEYWORD_RULES) { + if (r.drivers && (!driver || !r.drivers.includes(driver))) continue; + if (shape.inWhere && !r.whereOk) continue; + if (r.gate && !r.gate(shape)) continue; + out.push(r.kw); } - - return allowed; + return out; } // -1 = no match, 0 = starts-with, 1 = substring. Strips leading quote from partial quoted identifier before matching. export function matchScore(label: string, lcPrefix: string): number { if (!lcPrefix) return 0; let needle = lcPrefix; - if (needle.length > 0) { - const first = needle[0]; - if (first === '"' || first === "'" || first === '`') needle = needle.slice(1); - } + const first = needle[0]; + if (first === '"' || first === "'" || first === '`') needle = needle.slice(1); if (!needle) return 0; const lc = label.toLowerCase(); if (lc.startsWith(needle)) return 0; @@ -162,8 +153,63 @@ export function columnDetail(c: ColumnInfo): string { return c.dataType; } -// CTE names declared in a leading WITH clause, offered like tables in the FROM/JOIN slot. (We -// don't know a CTE's columns without parsing its body, so only the name is suggested.) +export function keywordItem(kw: string, lcPrefix: string): CompletionItem | null { + const score = matchScore(kw, lcPrefix); + if (score < 0) return null; + return { label: kw, kind: 'keyword', insertText: kw, sortText: rank(4, score, kw) }; +} + +function pushColumnItems( + items: CompletionItem[], + cols: ColumnInfo[], + tier: 0 | 1, + lcPrefix: string, + driver: DriverType, + seen?: Set, +): void { + for (const c of cols) { + const key = c.name.toLowerCase(); + if (seen?.has(key)) continue; + const score = matchScore(c.name, lcPrefix); + if (score < 0) continue; + seen?.add(key); + items.push({ + label: c.name, + kind: 'field', + detail: columnDetail(c), + insertText: formatSqlIdentifier(c.name, driver), + sortText: rank(tier, score, c.name), + }); + } +} + +// Projected columns of a CTE or derived-table alias - names only, no schema types to show. +export function suggestVirtualColumns( + names: string[], + lcPrefix: string, + driver: DriverType, + detail: string, +): CompletionItem[] { + const items: CompletionItem[] = []; + const seen = new Set(); + for (const name of names) { + const key = name.toLowerCase(); + if (seen.has(key)) continue; + const score = matchScore(name, lcPrefix); + if (score < 0) continue; + seen.add(key); + items.push({ + label: name, + kind: 'field', + detail, + insertText: formatSqlIdentifier(name, driver), + sortText: rank(0, score, name), + }); + } + return items; +} + +// CTE names declared in a leading WITH clause, offered like tables in the FROM/JOIN slot. export function suggestCteItems(ctes: string[], lcPrefix: string, driver: DriverType): CompletionItem[] { const items: CompletionItem[] = []; const seen = new Set(); @@ -179,8 +225,7 @@ export function suggestCteItems(ctes: string[], lcPrefix: string, driver: Driver detail: 'CTE', insertText: formatSqlIdentifier(name, driver), filterText: formatSqlIdentifier(name, driver), - // Tier 0 (query-local): ranks above the table list; callers also list CTEs first so they - // survive buildCompletionItems' 100-item slice. + // Tier 0 (query-local): ranks above the table list so it survives the 100-item cap. sortText: rank(0, score, name), }); } @@ -289,25 +334,14 @@ export function suggestColumnsFromBindings( const tk = columnCacheKey(binding.schema, binding.table); if (seenTables.has(tk)) continue; seenTables.add(tk); - for (const c of ctx.columnsByTable[tk] || []) { - const key = c.name.toLowerCase(); - if (seen.has(key)) continue; - const score = matchScore(c.name, lcPrefix); - if (score < 0) continue; - seen.add(key); - items.push({ - label: c.name, - kind: 'field', - detail: columnDetail(c), - insertText: formatSqlIdentifier(c.name, ctx.driver), - sortText: rank(0, score, c.name), - }); - } + pushColumnItems(items, ctx.columnsByTable[tk] || [], 0, lcPrefix, ctx.driver, seen); } return items; } +const VALUE_LITERALS = ['NULL', 'TRUE', 'FALSE', 'DEFAULT']; + export function suggestValueItems( ctx: CompletionContext, queryTables: QueryTableRef[], @@ -322,32 +356,23 @@ export function suggestValueItems( items.push(...suggestQueryTableRefs(ctx, queryTables, lcPrefix)); for (const lit of VALUE_LITERALS) { - const score = matchScore(lit, lcPrefix); - if (score < 0) continue; - items.push({ label: lit, kind: 'keyword', insertText: lit, sortText: rank(4, score, lit) }); + // SQLite has no DEFAULT expression (`SET x = DEFAULT` doesn't parse there). + if (lit === 'DEFAULT' && ctx.driver === 'sqlite') continue; + const item = keywordItem(lit, lcPrefix); + if (item) items.push(item); } - const addColumns = (cols: ColumnInfo[], tier: 0 | 1) => { - for (const c of cols) { - const key = c.name.toLowerCase(); - if (seenCols.has(key)) continue; - const score = matchScore(c.name, lcPrefix); - if (score < 0) continue; - seenCols.add(key); - items.push({ - label: c.name, - kind: 'field', - detail: columnDetail(c), - insertText: formatSqlIdentifier(c.name, ctx.driver), - sortText: rank(tier, score, c.name), - }); - } - }; - for (const binding of bindings.values()) { - addColumns(ctx.columnsByTable[columnCacheKey(binding.schema, binding.table)] || [], 0); + pushColumnItems( + items, + ctx.columnsByTable[columnCacheKey(binding.schema, binding.table)] || [], + 0, + lcPrefix, + ctx.driver, + seenCols, + ); } - addColumns(ctx.columns, 1); + pushColumnItems(items, ctx.columns, 1, lcPrefix, ctx.driver, seenCols); return items; } @@ -358,58 +383,12 @@ export function suggestColumnsForTable( lcPrefix: string, ): CompletionItem[] { const key = columnCacheKey(binding.schema, binding.table); - const cols = ctx.columnsByTable[key] || ctx.columns; const items: CompletionItem[] = []; - for (const c of cols) { - const score = matchScore(c.name, lcPrefix); - if (score < 0) continue; - items.push({ - label: c.name, - kind: 'field', - detail: columnDetail(c), - insertText: formatSqlIdentifier(c.name, ctx.driver), - sortText: rank(0, score, c.name), - }); - } + pushColumnItems(items, ctx.columnsByTable[key] || ctx.columns, 0, lcPrefix, ctx.driver); return items; } -// In the LIMIT/OFFSET tail the only sensible completion is OFFSET - and only once LIMIT already -// has a value and OFFSET isn't present yet. Otherwise (typing the number, or after OFFSET) nothing. -export function suggestLimitOffset(before: string, lcPrefix: string): CompletionItem[] { - if (!/\bLIMIT\b\s+(?:\d+|ALL\b)/i.test(before)) return []; - if (/\bOFFSET\b/i.test(before)) return []; - const score = matchScore('OFFSET', lcPrefix); - if (score < 0) return []; - return [{ label: 'OFFSET', kind: 'keyword', insertText: 'OFFSET', sortText: rank(4, score, 'OFFSET') }]; -} - -// clauseBodyStart caret butts against the keyword; without a space `email` inserts as `WHEREemail`. -function withLeadingSpace(items: CompletionItem[]): CompletionItem[] { +// Clause-start caret butts against the keyword; without a space `email` inserts as `WHEREemail`. +export function withLeadingSpace(items: CompletionItem[]): CompletionItem[] { return items.map((item) => ({ ...item, insertText: ` ${item.insertText}` })); } - -export function suggestClauseBody( - ctx: CompletionContext, - kind: ClauseBodyKind, - queryTables: QueryTableRef[], - bindings: Map, - ctes: string[] = [], -): CompletionItem[] { - if (kind === 'table') { - // CTEs first so they aren't dropped by the 100-item slice when the schema has many tables. - return withLeadingSpace([ - ...suggestCteItems(ctes, '', ctx.driver), - ...suggestTables(ctx, ''), - ...suggestSchemas(ctx, ''), - ]); - } - if (kind === 'set') { - return withLeadingSpace(suggestColumnsFromBindings(ctx, bindings, '')); - } - - return withLeadingSpace([ - ...suggestQueryTableRefs(ctx, queryTables, ''), - ...suggestColumnsFromBindings(ctx, bindings, ''), - ]); -} diff --git a/frontend/src/features/editor/lib/sqlText.ts b/frontend/src/features/editor/lib/sqlText.ts new file mode 100644 index 0000000..69c6398 --- /dev/null +++ b/frontend/src/features/editor/lib/sqlText.ts @@ -0,0 +1,103 @@ +import type { DriverType } from '@/types'; + +// Dialect knobs for lexical scanning. No driver = the conservative common subset. +export interface SqlLexOptions { + hashLineComments: boolean; // MySQL `# ...` + backslashEscapes: boolean; // MySQL strings: 'it\'s', "a\"b" + doubleQuoteStrings: boolean; // MySQL default mode: "..." is a string literal, not an identifier + nestedBlockComments: boolean; // Postgres: /* outer /* inner */ still outer */ + dollarQuotes: boolean; // Postgres $tag$...$tag$; in MySQL `$` is just an identifier char +} + +export function lexOptionsFor(driver?: DriverType): SqlLexOptions { + return { + hashLineComments: driver === 'mysql', + backslashEscapes: driver === 'mysql', + doubleQuoteStrings: driver === 'mysql', + nestedBlockComments: driver === 'postgres', + dollarQuotes: driver !== 'mysql', + }; +} + +// Index of the terminating '\n' (not part of the comment), or -1 when the comment runs to EOF. +// `from` is the first character after the `--` / `#` marker. +export function findLineCommentEnd(text: string, from: number): number { + const nl = text.indexOf('\n', from); + return nl; +} + +// Index just past the closing `*/`, or -1 when unterminated. `from` points at the opening `/*`. +export function findBlockCommentEnd(text: string, from: number, nested: boolean): number { + let i = from + 2; + let depth = 1; + const end = text.length; + while (i < end) { + const ch = text[i]; + if (ch === '*' && text[i + 1] === '/') { + i += 2; + if (--depth === 0) return i; + } else if (nested && ch === '/' && text[i + 1] === '*') { + depth++; + i += 2; + } else { + i++; + } + } + return -1; +} + +// Index just past the closing quote, or -1 when unterminated. `from` points at the opening quote. +// doubleEscape: '' (and "" / ``) is an embedded quote; backslashEscapes: \x is consumed as an escape. +export function findQuoteEnd( + text: string, + from: number, + quote: string, + doubleEscape: boolean, + backslashEscapes: boolean, +): number { + let i = from + 1; + const end = text.length; + while (i < end) { + const ch = text[i]; + if (backslashEscapes && ch === '\\') { + i += 2; + continue; + } + if (ch === quote) { + if (doubleEscape && text[i + 1] === quote) { + i += 2; + continue; + } + return i + 1; + } + i++; + } + return -1; +} + +// Tags never start with a digit, so `$1$` is a placeholder between two `$`, not a quote delimiter. +const DOLLAR_TAG_RE = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y; + +// The `$tag$` / `$$` delimiter starting at `from`, or null when the `$` opens no dollar quote. +export function matchDollarTag(text: string, from: number): string | null { + DOLLAR_TAG_RE.lastIndex = from; + return DOLLAR_TAG_RE.exec(text)?.[0] ?? null; +} + +// Next scan position: past the closed $tag$...$tag$, `end` when unterminated, or from+1 when the +// `$` doesn't open a dollar quote at all. +export function skipDollarQuoted(text: string, from: number, end: number): number { + const tag = matchDollarTag(text, from); + if (!tag) return from + 1; + const close = text.indexOf(tag, from + tag.length); + return close === -1 ? end : close + tag.length; +} + +// A standalone E/e right before the quote marks a Postgres escape string (E'a\'b'); backslash +// escapes then apply regardless of dialect. `1e'...'` / `TABLE'...'` don't qualify. +export function isEscapeStringPrefix(text: string, quoteIdx: number): boolean { + const prev = text[quoteIdx - 1]; + if (prev !== 'e' && prev !== 'E') return false; + const before = quoteIdx >= 2 ? text[quoteIdx - 2] : ''; + return !/[\w$'"`]/.test(before); +} diff --git a/frontend/src/features/editor/lib/sqlTokens.test.ts b/frontend/src/features/editor/lib/sqlTokens.test.ts new file mode 100644 index 0000000..b9c5d3f --- /dev/null +++ b/frontend/src/features/editor/lib/sqlTokens.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { tokenizeSql } from '@/features/editor/lib/sqlTokens'; +import type { DriverType } from '@/types'; + +describe('tokenizeSql basics', () => { + it('tokenizes a representative statement', () => { + const tokens = tokenizeSql("SELECT u.id, 'x' FROM users u -- t\nWHERE a >= 1", 'postgres'); + expect(tokens.map((t) => t.kind)).toEqual([ + 'ident', // SELECT + 'ident', // u + 'punct', // . + 'ident', // id + 'punct', // , + 'string', // 'x' + 'ident', // FROM + 'ident', // users + 'ident', // u + 'comment', // -- t + 'ident', // WHERE + 'ident', // a + 'op', // >= + 'number', // 1 + ]); + expect(tokens[0].lower).toBe('select'); + }); + + const lastToken = (sql: string) => { + const tokens = tokenizeSql(sql); + return tokens[tokens.length - 1]; + }; + + it('marks unterminated strings, quoted idents and comments', () => { + expect(lastToken("SELECT 'ab")).toMatchObject({ kind: 'string', unterminated: true }); + expect(lastToken('SELECT "ab')).toMatchObject({ kind: 'quoted', unterminated: true }); + expect(lastToken('SELECT 1 /* x')).toMatchObject({ kind: 'comment', unterminated: true }); + expect(lastToken('SELECT 1 -- x')).toMatchObject({ kind: 'comment', unterminated: true }); + expect(lastToken('SELECT 1 -- x\n').unterminated).toBeUndefined(); + }); + + it('keeps dollar-quoted bodies tokenized (delimiters are ops)', () => { + const tokens = tokenizeSql('DO $$ SELECT 1 $$', 'postgres'); + expect(tokens.map((t) => t.text)).toEqual(['DO', '$$', 'SELECT', '1', '$$']); + }); +}); + +describe('tokenizeSql fuzz invariants', () => { + // Deterministic LCG so failures reproduce; the seed is printed via the assertion message. + const lcg = (seed: number) => () => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed / 0x100000000; + }; + + const PIECES = [ + "'", + '"', + '`', + '$', + '$$', + '$tag$', + '--', + '#', + '/*', + '*/', + '\\', + ';', + '\n', + ' ', + '.', + ',', + '(', + ')', + '=', + '<=', + '<>', + '::', + 'SELECT', + 'from', + 'users', + '"Us"', + "'it''s'", + 'E', + '1.5e3', + '$1', + 'абв', // non-ASCII passes through as single-char ops without crashing + '🙂', + ]; + + const drivers: (DriverType | undefined)[] = ['postgres', 'mysql', 'sqlite', undefined]; + + it('never crashes and always produces well-formed spans', () => { + for (let seed = 1; seed <= 200; seed++) { + const rnd = lcg(seed); + const parts: string[] = []; + const n = 1 + Math.floor(rnd() * 40); + for (let i = 0; i < n; i++) parts.push(PIECES[Math.floor(rnd() * PIECES.length)]); + const text = parts.join(''); + const driver = drivers[Math.floor(rnd() * drivers.length)]; + + const tokens = tokenizeSql(text, driver); + let prevEnd = 0; + for (const t of tokens) { + const ctx = `seed=${seed} driver=${driver} text=${JSON.stringify(text)}`; + expect(t.start, ctx).toBeGreaterThanOrEqual(prevEnd); + expect(t.end, ctx).toBeGreaterThan(t.start); + expect(t.end, ctx).toBeLessThanOrEqual(text.length); + expect(t.text, ctx).toBe(text.slice(t.start, t.end)); + prevEnd = t.end; + } + } + }); + + it('skipped gaps are only whitespace', () => { + for (let seed = 200; seed <= 300; seed++) { + const rnd = lcg(seed); + const parts: string[] = []; + const n = 1 + Math.floor(rnd() * 30); + for (let i = 0; i < n; i++) parts.push(PIECES[Math.floor(rnd() * PIECES.length)]); + const text = parts.join(' '); + + const tokens = tokenizeSql(text, 'postgres'); + let pos = 0; + for (const t of tokens) { + const gap = text.slice(pos, t.start); + expect(gap.trim(), `seed=${seed} text=${JSON.stringify(text)}`).toBe(''); + pos = t.end; + } + expect(text.slice(pos).trim(), `seed=${seed}`).toBe(''); + } + }); +}); diff --git a/frontend/src/features/editor/lib/sqlTokens.ts b/frontend/src/features/editor/lib/sqlTokens.ts new file mode 100644 index 0000000..92eb373 --- /dev/null +++ b/frontend/src/features/editor/lib/sqlTokens.ts @@ -0,0 +1,172 @@ +import { + findBlockCommentEnd, + findLineCommentEnd, + findQuoteEnd, + isEscapeStringPrefix, + lexOptionsFor, + matchDollarTag, +} from '@/features/editor/lib/sqlText'; +import type { DriverType } from '@/types'; + +export type SqlTokenKind = + | 'ident' // bare identifier or keyword (`lower` carries the lowercased text) + | 'quoted' // "ident" / `ident` + | 'string' // '...' (and "..." on MySQL, where it's a literal) + | 'number' // numeric literal or $n placeholder + | 'op' // = <> <= >= != :: || + - * / and other symbols + | 'punct' // . , ( ) ; + | 'comment'; + +export interface SqlToken { + kind: SqlTokenKind; + text: string; + lower: string; // lowercased text for ident tokens, '' otherwise + start: number; + end: number; // exclusive + unterminated?: boolean; // string/quoted/comment cut off by the end of input +} + +const IDENT_START_RE = /[A-Za-z_]/; +const IDENT_CHAR_RE = /[A-Za-z0-9_$]/; +const DIGIT_RE = /[0-9]/; +const NUMBER_CHAR_RE = /[0-9A-Za-z._]/; +const WS_RE = /\s/; +const TWO_CHAR_OPS = new Set(['<=', '>=', '<>', '!=', '::', '||', ':=']); +const PUNCT = new Set(['.', ',', '(', ')', ';']); + +// One linear pass. Dollar-quote delimiters ($tag$) are emitted as ops and their bodies tokenized +// as ordinary SQL, so completion keeps working inside Postgres function bodies. +export function tokenizeSql(text: string, driver?: DriverType): SqlToken[] { + const opts = lexOptionsFor(driver); + const len = text.length; + const tokens: SqlToken[] = []; + const push = (kind: SqlTokenKind, start: number, end: number, unterminated?: boolean) => { + const raw = text.slice(start, end); + tokens.push({ + kind, + text: raw, + lower: kind === 'ident' ? raw.toLowerCase() : '', + start, + end, + ...(unterminated ? { unterminated: true } : null), + }); + }; + + let i = 0; + while (i < len) { + const ch = text[i]; + if (WS_RE.test(ch)) { + i++; + continue; + } + const next = text[i + 1]; + + if ((ch === '-' && next === '-') || (ch === '#' && opts.hashLineComments)) { + const nl = findLineCommentEnd(text, ch === '#' ? i + 1 : i + 2); + const end = nl === -1 ? len : nl; + push('comment', i, end, nl === -1); + i = end; + continue; + } + if (ch === '/' && next === '*') { + const close = findBlockCommentEnd(text, i, opts.nestedBlockComments); + push('comment', i, close === -1 ? len : close, close === -1); + i = close === -1 ? len : close; + continue; + } + if (ch === "'" || (ch === '"' && opts.doubleQuoteStrings)) { + const escapes = opts.backslashEscapes || isEscapeStringPrefix(text, i); + const close = findQuoteEnd(text, i, ch, true, escapes); + push('string', i, close === -1 ? len : close, close === -1); + i = close === -1 ? len : close; + continue; + } + if (ch === '"' || ch === '`') { + const close = findQuoteEnd(text, i, ch, true, false); + push('quoted', i, close === -1 ? len : close, close === -1); + i = close === -1 ? len : close; + continue; + } + if (ch === '$') { + if (opts.dollarQuotes) { + const tag = matchDollarTag(text, i); + if (tag) { + push('op', i, i + tag.length); + i += tag.length; + continue; + } + } + if (next !== undefined && DIGIT_RE.test(next)) { + let j = i + 1; + while (j < len && DIGIT_RE.test(text[j])) j++; + push('number', i, j); // $n placeholder: expression-valued, like a literal + i = j; + continue; + } + push('op', i, i + 1); + i++; + continue; + } + if (IDENT_START_RE.test(ch)) { + let j = i + 1; + while (j < len && IDENT_CHAR_RE.test(text[j])) j++; + push('ident', i, j); + i = j; + continue; + } + if (DIGIT_RE.test(ch)) { + let j = i + 1; + while (j < len && NUMBER_CHAR_RE.test(text[j])) j++; + push('number', i, j); + i = j; + continue; + } + if (PUNCT.has(ch)) { + push('punct', i, i + 1); + i++; + continue; + } + if (next !== undefined && TWO_CHAR_OPS.has(ch + next)) { + push('op', i, i + 2); + i += 2; + continue; + } + push('op', i, i + 1); + i++; + } + + return tokens; +} + +// Unquotes a quoted token's text ("a""b" → a"b, `x` → x); idents pass through. +export function tokenIdentText(t: SqlToken): string { + if (t.kind !== 'quoted') return t.text; + const quote = t.text[0]; + let inner = t.text.slice(1, t.unterminated ? undefined : -1); + if (quote === '"') inner = inner.replace(/""/g, '"'); + if (quote === '`') inner = inner.replace(/``/g, '`'); + return inner; +} + +export function isIdentLike(t: SqlToken | undefined): t is SqlToken { + return t !== undefined && (t.kind === 'ident' || t.kind === 'quoted'); +} + +export function isKeyword(t: SqlToken | undefined, word: string): boolean { + return t !== undefined && t.kind === 'ident' && t.lower === word; +} + +// Index of the previous / next non-comment token, or -1. +export function prevCodeToken(tokens: SqlToken[], from: number): number { + for (let i = from - 1; i >= 0; i--) { + if (tokens[i].kind !== 'comment') return i; + } + return -1; +} + +export function nextCodeToken(tokens: SqlToken[], from: number): number { + for (let i = from + 1; i < tokens.length; i++) { + if (tokens[i].kind !== 'comment') return i; + } + return -1; +} diff --git a/frontend/src/features/sidebar/SchemaPanel.tsx b/frontend/src/features/sidebar/SchemaPanel.tsx index 5477a34..b8780e3 100644 --- a/frontend/src/features/sidebar/SchemaPanel.tsx +++ b/frontend/src/features/sidebar/SchemaPanel.tsx @@ -1,7 +1,7 @@ import { CircleAlert, Copy, Loader2, Plug, RefreshCw } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { buildQualifiedTable } from '@/features/editor/lib/sqlIdentifiers'; +import { buildQualifiedTable } from '@/features/editor/lib/sqlQuoting'; import { tableKey, tableMatchesSearch, useSchemaTree } from '@/features/sidebar/hooks/useSchemaTree'; import { SchemaTreeNode } from '@/features/sidebar/SchemaTreeNode'; import { SidebarFilterBar } from '@/features/sidebar/SidebarFilterBar'; diff --git a/frontend/src/shared/lib/normalize.ts b/frontend/src/shared/lib/normalize.ts index 33835ec..187135e 100644 --- a/frontend/src/shared/lib/normalize.ts +++ b/frontend/src/shared/lib/normalize.ts @@ -36,6 +36,8 @@ export function normalizeColumns(data: unknown): ColumnInfo[] { isNullable?: boolean; isPrimary?: boolean; isForeign?: boolean; + foreignTable?: string; + foreignColumn?: string; defaultVal?: string; }>(data).map((c) => ({ name: String(c?.name ?? ''), @@ -43,6 +45,8 @@ export function normalizeColumns(data: unknown): ColumnInfo[] { isNullable: Boolean(c?.isNullable), isPrimary: Boolean(c?.isPrimary), isForeign: Boolean(c?.isForeign), + foreignTable: c?.foreignTable, + foreignColumn: c?.foreignColumn, defaultVal: c?.defaultVal, })); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8e30052..7a40df8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -28,6 +28,10 @@ export interface ColumnInfo { isNullable: boolean; isPrimary: boolean; isForeign: boolean; + // FK target when isForeign; foreignColumn may be empty when the FK implicitly references the + // target's primary key (SQLite). + foreignTable?: string; + foreignColumn?: string; defaultVal?: string; } diff --git a/internal/app/app_query.go b/internal/app/app_query.go index 4e387c4..f7cb951 100644 --- a/internal/app/app_query.go +++ b/internal/app/app_query.go @@ -175,7 +175,13 @@ func (a *App) ExecuteQueryStream(connectionID, tabID, sql string) error { if err := a.guardExecute(connectionID, sql); err != nil { return err } - a.runBatchStream(tabID, connectionID, database.SplitStatements(sql)) + // Split with the connection's dialect so boundaries match the editor's run-glyphs + // (MySQL # comments, \' escapes and DELIMITER; Postgres nested comments and E'…'). + s, err := a.sessionFor(connectionID) + if err != nil { + return err + } + a.runBatchStream(tabID, connectionID, database.SplitStatements(s.DriverType(), sql)) return nil } @@ -251,7 +257,7 @@ func (a *App) guardExecute(connectionID, sql string) error { return err } if cfg.ReadOnly { - return database.AssertReadOnlySQL(sql) + return database.AssertReadOnlySQLFor(cfg.Driver, sql) } return nil } diff --git a/internal/database/exec.go b/internal/database/exec.go index 1769a84..ab02df2 100644 --- a/internal/database/exec.go +++ b/internal/database/exec.go @@ -36,11 +36,12 @@ func execSummary(res sql.Result, startMs int64) *QueryResult { } // runStatement executes one statement, routing row-returning SQL through the streaming scan and -// everything else through Exec. +// everything else through Exec. Uses the same predicate as the script path so TABLE/VALUES/CALL +// statements surface their rows here too. func runStatement(ctx context.Context, conn *sql.Conn, driver DriverType, sqlText string, opts StreamOpts) (*QueryResult, error) { start := NowMs() upper := strings.ToUpper(StripLeadingComments(sqlText)) - if IsSelectLike(driver, upper) || hasReturningClause(upper) { + if statementReturnsRows(driver, upper) { return streamQueryRows(ctx, conn, sqlText, start, opts, &QueryResult{}) } res, err := conn.ExecContext(ctx, sqlText) @@ -193,7 +194,17 @@ func buildTableSelectSQL(driver DriverType, schema string, req TableDataRequest, } q += fmt.Sprintf(" ORDER BY %s %s", QuoteIdent(driver, orderBy), dir) } - q += fmt.Sprintf(" LIMIT %d OFFSET %d", req.Limit, req.Offset) + // Clamp: SQLite treats LIMIT -1 as "no limit" (an accidental full-table page) and Postgres + // errors on it; a negative OFFSET is an error on every driver. + limit := req.Limit + if limit <= 0 { + limit = 100 + } + offset := req.Offset + if offset < 0 { + offset = 0 + } + q += fmt.Sprintf(" LIMIT %d OFFSET %d", limit, offset) return q } @@ -266,8 +277,8 @@ func SelectRowByIntegerPK(ctx context.Context, db *sql.DB, driver DriverType, sc if pk == "" { return nil, false } - query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ? LIMIT 1", - tableRef(driver, schema, table), QuoteIdent(driver, pk)) + query := fmt.Sprintf("SELECT * FROM %s WHERE %s = %s LIMIT 1", + tableRef(driver, schema, table), QuoteIdent(driver, pk), Placeholder(driver, 1)) return SelectSingleRow(ctx, db, query, id) } diff --git a/internal/database/mysql/driver.go b/internal/database/mysql/driver.go index bebec7c..5048c50 100644 --- a/internal/database/mysql/driver.go +++ b/internal/database/mysql/driver.go @@ -150,13 +150,22 @@ func (s *Session) ListTables(ctx context.Context, schema string) ([]database.Tab func (s *Session) ListColumns(ctx context.Context, schema, table string) ([]database.ColumnInfo, error) { rows, err := s.DB.QueryContext(ctx, ` SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, COALESCE(c.COLUMN_DEFAULT, ''), c.COLUMN_KEY, - EXISTS ( - SELECT 1 FROM information_schema.KEY_COLUMN_USAGE k + COALESCE(( + SELECT k.REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE k WHERE k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME AND k.REFERENCED_TABLE_NAME IS NOT NULL - ) + LIMIT 1 + ), ''), + COALESCE(( + SELECT k.REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE k + WHERE k.TABLE_SCHEMA = c.TABLE_SCHEMA + AND k.TABLE_NAME = c.TABLE_NAME + AND k.COLUMN_NAME = c.COLUMN_NAME + AND k.REFERENCED_TABLE_NAME IS NOT NULL + LIMIT 1 + ), '') FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`, s.SchemaOr(schema), table) @@ -166,18 +175,19 @@ func (s *Session) ListColumns(ctx context.Context, schema, table string) ([]data defer rows.Close() var cols []database.ColumnInfo for rows.Next() { - var name, dtype, nullable, def, colKey string - var fk int - if err := rows.Scan(&name, &dtype, &nullable, &def, &colKey, &fk); err != nil { + var name, dtype, nullable, def, colKey, fkTable, fkColumn string + if err := rows.Scan(&name, &dtype, &nullable, &def, &colKey, &fkTable, &fkColumn); err != nil { return nil, err } cols = append(cols, database.ColumnInfo{ - Name: name, - DataType: dtype, - IsNullable: strings.EqualFold(nullable, "YES"), - IsPrimary: colKey == "PRI", - IsForeign: fk != 0, - DefaultVal: def, + Name: name, + DataType: dtype, + IsNullable: strings.EqualFold(nullable, "YES"), + IsPrimary: colKey == "PRI", + IsForeign: fkTable != "", + ForeignTable: fkTable, + ForeignColumn: fkColumn, + DefaultVal: def, }) } return cols, rows.Err() diff --git a/internal/database/postgres/driver.go b/internal/database/postgres/driver.go index 4e7a220..530c80d 100644 --- a/internal/database/postgres/driver.go +++ b/internal/database/postgres/driver.go @@ -159,15 +159,22 @@ func (s *Session) ListColumns(ctx context.Context, schema, table string) ([]data WHERE con.conrelid = c.oid AND con.contype = 'p' AND a.attnum = ANY (con.conkey) ), - EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint con - WHERE con.conrelid = c.oid AND con.contype = 'f' - AND a.attnum = ANY (con.conkey) - ) + COALESCE(fk.reftable, ''), + COALESCE(fk.refcolumn, '') FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON a.attrelid = c.oid JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid LEFT JOIN pg_catalog.pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum + LEFT JOIN LATERAL ( + SELECT ref.relname AS reftable, refatt.attname AS refcolumn + FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class ref ON ref.oid = con.confrelid + JOIN pg_catalog.pg_attribute refatt ON refatt.attrelid = con.confrelid + AND refatt.attnum = con.confkey[array_position(con.conkey, a.attnum)] + WHERE con.conrelid = c.oid AND con.contype = 'f' + AND a.attnum = ANY (con.conkey) + LIMIT 1 + ) fk ON true WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, s.SchemaOr(schema), table) @@ -178,18 +185,20 @@ func (s *Session) ListColumns(ctx context.Context, schema, table string) ([]data var cols []database.ColumnInfo for rows.Next() { var name, dtype string - var nullable, isPK, isFK bool - var def string - if err := rows.Scan(&name, &dtype, &nullable, &def, &isPK, &isFK); err != nil { + var nullable, isPK bool + var def, fkTable, fkColumn string + if err := rows.Scan(&name, &dtype, &nullable, &def, &isPK, &fkTable, &fkColumn); err != nil { return nil, err } cols = append(cols, database.ColumnInfo{ - Name: name, - DataType: dtype, - IsNullable: nullable, - IsPrimary: isPK, - IsForeign: isFK, - DefaultVal: def, + Name: name, + DataType: dtype, + IsNullable: nullable, + IsPrimary: isPK, + IsForeign: fkTable != "", + ForeignTable: fkTable, + ForeignColumn: fkColumn, + DefaultVal: def, }) } return cols, rows.Err() diff --git a/internal/database/readonly.go b/internal/database/readonly.go index 79c6b6e..93d7eab 100644 --- a/internal/database/readonly.go +++ b/internal/database/readonly.go @@ -65,7 +65,13 @@ var deniedFirstKeywords = map[string]bool{ } func AssertReadOnlySQL(sql string) error { - if IsReadOnlySQL(sql) { + return AssertReadOnlySQLFor("", sql) +} + +// AssertReadOnlySQLFor applies the driver's comment syntax before classifying - on MySQL a `#` +// comment mentioning a write keyword must not block a legitimate read. +func AssertReadOnlySQLFor(driver DriverType, sql string) error { + if IsReadOnlySQLFor(driver, sql) { return nil } return ErrReadOnly @@ -99,7 +105,14 @@ func ValidateTableFilter(filter string) error { // Backslash treated as literal - safe for Postgres (standard_conforming_strings) and SQLite; // MySQL `\'` only ever exposes more content to the scanner, never less. func IsReadOnlySQL(sql string) bool { - cleaned := maskStringLiterals(stripSQLComments(sql)) + return IsReadOnlySQLFor("", sql) +} + +// IsReadOnlySQLFor is IsReadOnlySQL with the driver's comment syntax. `#` comments are stripped +// only for MySQL: on Postgres `#` is an operator, and stripping to end-of-line there could hide +// a following write statement from the classifier. +func IsReadOnlySQLFor(driver DriverType, sql string) bool { + cleaned := maskStringLiterals(stripSQLComments(sql, driver == DriverMySQL)) for _, stmt := range splitSQLStatements(cleaned) { if !isReadOnlyStatement(stmt) { return false @@ -195,15 +208,15 @@ func maskStringLiterals(sql string) string { return b.String() } -// Removes -- and /* */ comments, preserving string/dollar-quoted spans so a marker inside one isn't stripped. -func stripSQLComments(sql string) string { +// Removes -- and /* */ comments (and, when hashLineComments, MySQL `#` comments), preserving +// string/dollar-quoted spans so a marker inside one isn't stripped. +func stripSQLComments(sql string, hashLineComments bool) string { var b strings.Builder b.Grow(len(sql)) for i := 0; i < len(sql); { ch := sql[i] switch { - case ch == '-' && i+1 < len(sql) && sql[i+1] == '-': - i += 2 + case (ch == '-' && i+1 < len(sql) && sql[i+1] == '-') || (ch == '#' && hashLineComments): for i < len(sql) && sql[i] != '\n' { i++ } diff --git a/internal/database/readonly_extra_test.go b/internal/database/readonly_extra_test.go index 1a73d2c..fa593a9 100644 --- a/internal/database/readonly_extra_test.go +++ b/internal/database/readonly_extra_test.go @@ -124,7 +124,7 @@ func TestSplitSQLStatementsHandlesQuotes(t *testing.T) { func TestStripSQLCommentsKeepsStrings(t *testing.T) { in := `SELECT '-- not a comment', 1 -- real comment` + "\nFROM t" - out := stripSQLComments(in) + out := stripSQLComments(in, false) if !contains(out, `'-- not a comment'`) { t.Fatalf("string contents should survive, got %q", out) } diff --git a/internal/database/readonly_test.go b/internal/database/readonly_test.go index a044ac8..3d4ecfe 100644 --- a/internal/database/readonly_test.go +++ b/internal/database/readonly_test.go @@ -100,3 +100,23 @@ func TestIsReadOnlySQLPragma(t *testing.T) { } } } + +func TestIsReadOnlySQLForDriverComments(t *testing.T) { + // A MySQL # comment mentioning a write keyword must not block the read. + mysqlRead := "SELECT 1 # insert note to self\nFROM t" + if !IsReadOnlySQLFor(DriverMySQL, mysqlRead) { + t.Errorf("mysql # comment should not block a read: %q", mysqlRead) + } + // The driver-less classifier stays conservative (blocks it). + if IsReadOnlySQL(mysqlRead) { + t.Errorf("driver-less classifier should stay conservative for %q", mysqlRead) + } + // On Postgres # is an operator; stripping to end-of-line would hide the DELETE. + pgWrite := "SELECT a # b; DELETE FROM t" + if IsReadOnlySQLFor(DriverPostgres, pgWrite) { + t.Errorf("postgres # operator must not hide a write: %q", pgWrite) + } + if IsReadOnlySQLFor(DriverMySQL, "SELECT 1; # note\nDROP TABLE t") { + t.Error("a write after a mysql # comment must still be caught") + } +} diff --git a/internal/database/runscript_test.go b/internal/database/runscript_test.go index 491ae70..f288049 100644 --- a/internal/database/runscript_test.go +++ b/internal/database/runscript_test.go @@ -72,7 +72,7 @@ func TestRunScriptMultipleStatements(t *testing.T) { SELECT id, name FROM t ORDER BY id; SELECT count(*) AS n FROM t; ` - stmts := SplitStatements(script) + stmts := SplitStatements(DriverSQLite, script) if len(stmts) != 4 { t.Fatalf("SplitStatements: want 4, got %d (%#v)", len(stmts), stmts) } @@ -106,7 +106,7 @@ func TestRunScriptStopsAtFirstError(t *testing.T) { defer cleanup() var sets []*capturedSet - stmts := SplitStatements("SELECT 1; SELECT bad syntax here; SELECT 2;") + stmts := SplitStatements(DriverSQLite, "SELECT 1; SELECT bad syntax here; SELECT 2;") err := RunScript(context.Background(), conn, DriverSQLite, stmts, captureSink(&sets)) if err == nil { t.Fatal("want RunScript to return the failing statement's error") diff --git a/internal/database/session_base.go b/internal/database/session_base.go index 3429a54..efb335a 100644 --- a/internal/database/session_base.go +++ b/internal/database/session_base.go @@ -59,7 +59,7 @@ func (b *SessionBase) Execute(ctx context.Context, sqlText string) (*QueryResult func (b *SessionBase) ExecuteStream(ctx context.Context, sqlText string, opts StreamOpts) (*QueryResult, error) { if b.ReadOnly { - if err := AssertReadOnlySQL(sqlText); err != nil { + if err := AssertReadOnlySQLFor(b.Driver, sqlText); err != nil { return nil, err } } diff --git a/internal/database/sqlite/driver.go b/internal/database/sqlite/driver.go index b8f42b5..cfb923d 100644 --- a/internal/database/sqlite/driver.go +++ b/internal/database/sqlite/driver.go @@ -119,27 +119,35 @@ func (s *Session) ListColumns(ctx context.Context, schema, table string) ([]data if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { return nil, err } + fk := fkCols[name] cols = append(cols, database.ColumnInfo{ - Name: name, - DataType: ctype, - IsNullable: notnull == 0, - IsPrimary: pk > 0, - IsForeign: fkCols[name], - DefaultVal: dflt.String, + Name: name, + DataType: ctype, + IsNullable: notnull == 0, + IsPrimary: pk > 0, + IsForeign: fk.table != "", + ForeignTable: fk.table, + ForeignColumn: fk.column, + DefaultVal: dflt.String, }) } return cols, rows.Err() } -// foreignKeyColumns returns the set of local column names that participate in a foreign key. -// PRAGMA table_info doesn't expose foreign keys, so they come from PRAGMA foreign_key_list. -func (s *Session) foreignKeyColumns(ctx context.Context, table string) (map[string]bool, error) { +type fkTarget struct { + table string + column string +} + +// foreignKeyColumns maps local column names to their FK target. PRAGMA table_info doesn't expose +// foreign keys, so they come from PRAGMA foreign_key_list. +func (s *Session) foreignKeyColumns(ctx context.Context, table string) (map[string]fkTarget, error) { rows, err := s.DB.QueryContext(ctx, fmt.Sprintf("PRAGMA foreign_key_list(%s)", database.QuoteIdent(database.DriverSQLite, table))) if err != nil { return nil, err } defer rows.Close() - fks := make(map[string]bool) + fks := make(map[string]fkTarget) for rows.Next() { var id, seq int var refTable, from string @@ -148,7 +156,9 @@ func (s *Session) foreignKeyColumns(ctx context.Context, table string) (map[stri if err := rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &matchType); err != nil { return nil, err } - fks[from] = true + if _, seen := fks[from]; !seen { + fks[from] = fkTarget{table: refTable, column: to.String} + } } return fks, rows.Err() } diff --git a/internal/database/sqlite/driver_test.go b/internal/database/sqlite/driver_test.go index a66de21..4e6f05d 100644 --- a/internal/database/sqlite/driver_test.go +++ b/internal/database/sqlite/driver_test.go @@ -89,6 +89,9 @@ func TestListColumnsMarksForeignKeys(t *testing.T) { if fk, ok := byName["author_id"]; !ok || !fk.IsForeign { t.Fatalf("author_id should be marked foreign, got %+v", byName["author_id"]) } + if fk := byName["author_id"]; fk.ForeignTable != "authors" || fk.ForeignColumn != "id" { + t.Errorf("author_id should reference authors(id), got %+v", fk) + } if byName["author_id"].IsPrimary { t.Errorf("author_id should not be marked primary, got %+v", byName["author_id"]) } diff --git a/internal/database/statements.go b/internal/database/statements.go index 871d88b..6ef9ef5 100644 --- a/internal/database/statements.go +++ b/internal/database/statements.go @@ -5,45 +5,88 @@ import ( "strings" ) -// SplitStatements splits a SQL script into individual executable statements, ignoring semicolons -// inside line/block comments, single/double/backtick-quoted text, and dollar-quoted bodies (so a -// PL/pgSQL function body or a string literal containing ';' stays in one piece). Comment-only and -// empty chunks are dropped, and the separating semicolon is excluded, so each returned string is -// ready to run on its own. +// lexOptions are the dialect knobs for lexical scanning. The zero value is the conservative +// common subset: no backslash escapes, no # comments, no nested block comments. +type lexOptions struct { + hashLineComments bool // MySQL `# ...` + backslashEscapes bool // MySQL strings: 'it\'s', "a\"b" + doubleQuoteStrings bool // MySQL default mode: "..." is a string literal, not an identifier + nestedBlockComments bool // Postgres: /* outer /* inner */ still outer */ + dollarQuotes bool // Postgres $tag$...$tag$; in MySQL `$` is just an identifier char + clientDelimiters bool // mysql-client `DELIMITER xx` lines switch the statement terminator +} + +func lexOptionsFor(driver DriverType) lexOptions { + return lexOptions{ + hashLineComments: driver == DriverMySQL, + backslashEscapes: driver == DriverMySQL, + doubleQuoteStrings: driver == DriverMySQL, + nestedBlockComments: driver == DriverPostgres, + dollarQuotes: driver != DriverMySQL, + clientDelimiters: driver == DriverMySQL, + } +} + +// mysql-client convention: a line reading `DELIMITER xx` switches the statement terminator so +// procedure/trigger bodies can contain `;`. Consumes through the end of the line. +var delimiterLineRe = regexp.MustCompile(`(?i)^DELIMITER[ \t]+(\S+)[ \t]*(?:\r?\n|$)`) + +// SplitStatements splits a SQL script into individual executable statements with the driver's +// lexical rules, ignoring semicolons inside comments, quoted text, and dollar-quoted bodies. +// MySQL additionally honours `#` comments, backslash string escapes, and client `DELIMITER` +// lines (the custom terminator and the DELIMITER lines themselves are excluded from the output); +// Postgres honours nested block comments and E'…' escape strings. Comment-only and empty chunks +// are dropped and terminators are excluded, so each returned string is ready to run on its own. // -// This mirrors the frontend parseSqlStatements scanner (features/editor/lib/sqlStatements.ts); keep -// the two in sync so the gutter run-glyphs and backend batch execution agree on statement boundaries. -func SplitStatements(sql string) []string { +// This mirrors the frontend parseSqlStatements scanner (features/editor/lib/sqlStatements.ts + +// sqlText.ts); keep the two in sync so the gutter run-glyphs and backend batch execution agree +// on statement boundaries. +func SplitStatements(driver DriverType, sql string) []string { + opts := lexOptionsFor(driver) var out []string n := len(sql) stmtStart := 0 + delimiter := ";" i := 0 push := func(endExclusive int) { chunk := sql[stmtStart:endExclusive] - if hasCode(chunk) { + if hasCode(chunk, opts) { out = append(out, strings.TrimSpace(chunk)) } } for i < n { c := sql[i] + + if opts.clientDelimiters && (c == 'd' || c == 'D') && atLineStart(sql, i) { + if m := delimiterLineRe.FindStringSubmatch(sql[i:]); m != nil { + push(i) // anything pending stays its own (unterminated) statement + delimiter = m[1] + i += len(m[0]) + stmtStart = i + continue + } + } + switch { case c == '-' && i+1 < n && sql[i+1] == '-': - i = splitLineComment(sql, i, n) + i = lineCommentEnd(sql, i+2, n) + case c == '#' && opts.hashLineComments: + i = lineCommentEnd(sql, i+1, n) case c == '/' && i+1 < n && sql[i+1] == '*': - i = splitBlockComment(sql, i, n) + i = blockCommentEnd(sql, i, n, opts.nestedBlockComments) case c == '\'': - i = splitQuoted(sql, i, '\'', true, n) + i = quoteEnd(sql, i, '\'', opts.backslashEscapes || isEscapeStringPrefix(sql, i), n) case c == '"': - i = splitQuoted(sql, i, '"', true, n) + i = quoteEnd(sql, i, '"', opts.backslashEscapes && opts.doubleQuoteStrings, n) case c == '`': - i = splitQuoted(sql, i, '`', false, n) - case c == '$': + i = quoteEnd(sql, i, '`', false, n) + case c == '$' && opts.dollarQuotes: i = splitDollarQuoted(sql, i, n) - case c == ';': - push(i) // [stmtStart, i) excludes the ';' - i++ + case c == delimiter[0] && strings.HasPrefix(sql[i:], delimiter): + push(i) // [stmtStart, i) excludes the terminator + i += len(delimiter) stmtStart = i default: i++ @@ -54,45 +97,104 @@ func SplitStatements(sql string) []string { } // hasCode reports whether s holds anything other than whitespace and comments. -func hasCode(s string) bool { - return strings.TrimSpace(StripLeadingComments(s)) != "" +func hasCode(s string, opts lexOptions) bool { + i, n := 0, len(s) + for i < n { + switch c := s[i]; { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + i++ + case c == '-' && i+1 < n && s[i+1] == '-': + i = lineCommentEnd(s, i+2, n) + case c == '#' && opts.hashLineComments: + i = lineCommentEnd(s, i+1, n) + case c == '/' && i+1 < n && s[i+1] == '*': + i = blockCommentEnd(s, i, n, opts.nestedBlockComments) + default: + return true + } + } + return false } -func splitLineComment(s string, i, n int) int { - i += 2 - for i < n && s[i] != '\n' { - i++ +// atLineStart reports whether only whitespace precedes s[i] on its line (matching the mysql +// client, which only honours DELIMITER at the start of a line). +func atLineStart(s string, i int) bool { + j := strings.LastIndexByte(s[:i], '\n') + 1 + return strings.TrimSpace(s[j:i]) == "" +} + +// lineCommentEnd returns the index of the terminating '\n' (left unconsumed), or n at EOF. +func lineCommentEnd(s string, from, n int) int { + if idx := strings.IndexByte(s[from:], '\n'); idx >= 0 { + return from + idx } - return i + return n } -func splitBlockComment(s string, i, n int) int { +// blockCommentEnd returns the index just past the closing `*/` (nesting-aware for Postgres), +// or n when unterminated. i points at the opening `/*`. +func blockCommentEnd(s string, i, n int, nested bool) int { i += 2 - for i < n-1 && !(s[i] == '*' && s[i+1] == '/') { - i++ - } - if i+2 < n { - return i + 2 + depth := 1 + for i < n { + if s[i] == '*' && i+1 < n && s[i+1] == '/' { + i += 2 + if depth--; depth == 0 { + return i + } + } else if nested && s[i] == '/' && i+1 < n && s[i+1] == '*' { + depth++ + i += 2 + } else { + i++ + } } return n } -// doubleEscape: ” / "" are embedded quotes (SQL convention); backticks don't double-escape. -func splitQuoted(s string, i int, quote byte, doubleEscape bool, n int) int { +// quoteEnd returns the index just past the closing quote, or n when unterminated. Doubled quotes +// (” / "") are embedded quotes; backslashEscapes additionally consumes \x pairs (MySQL, E'…'). +func quoteEnd(s string, i int, quote byte, backslashEscapes bool, n int) int { i++ for i < n { - if s[i] == quote { - if doubleEscape && i+1 < n && s[i+1] == quote { + switch { + case backslashEscapes && s[i] == '\\': + i += 2 + case s[i] == quote: + if i+1 < n && s[i+1] == quote { i += 2 continue } return i + 1 + default: + i++ } - i++ } return i } +// A standalone E/e right before the quote marks a Postgres escape string (E'a\'b'); backslash +// escapes then apply regardless of dialect. `1e'…'` / `TABLE'…'` don't qualify. +func isEscapeStringPrefix(s string, quoteIdx int) bool { + if quoteIdx < 1 { + return false + } + prev := s[quoteIdx-1] + if prev != 'e' && prev != 'E' { + return false + } + if quoteIdx < 2 { + return true + } + switch b := s[quoteIdx-2]; { + case b == '_' || b == '$' || b == '\'' || b == '"' || b == '`': + return false + case b >= '0' && b <= '9', b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z': + return false + } + return true +} + // Tags never start with a digit, so `$1$` is a placeholder between two `$`, not a quote delimiter. var splitDollarTagRe = regexp.MustCompile(`^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$`) diff --git a/internal/database/statements_test.go b/internal/database/statements_test.go index 2089e6c..a60361c 100644 --- a/internal/database/statements_test.go +++ b/internal/database/statements_test.go @@ -44,10 +44,85 @@ func TestSplitStatements(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := SplitStatements(tt.in) + got := SplitStatements("", tt.in) if !reflect.DeepEqual(got, tt.want) { t.Errorf("SplitStatements(%q)\n got %#v\n want %#v", tt.in, got, tt.want) } }) } } + +// Mirrors the frontend dialect tests (sqlStatements.test.ts); the two scanners must agree on +// statement boundaries. +func TestSplitStatementsDialects(t *testing.T) { + tests := []struct { + name string + driver DriverType + in string + want []string + }{ + { + "mysql: backslash-escaped quote does not end the string", + DriverMySQL, + `SELECT 'it\'s a; test'; SELECT 2`, + []string{`SELECT 'it\'s a; test'`, "SELECT 2"}, + }, + { + "mysql: # starts a line comment", + DriverMySQL, + "SELECT 1 # ; not a split\n; SELECT 2", + []string{"SELECT 1 # ; not a split", "SELECT 2"}, + }, + { + "mysql: $ is an identifier character, not a dollar quote", + DriverMySQL, + "SELECT a$tag$ FROM t; SELECT b$tag$ FROM u", + []string{"SELECT a$tag$ FROM t", "SELECT b$tag$ FROM u"}, + }, + { + "mysql: comment-only # chunk is dropped", + DriverMySQL, + "# just a note\nSELECT 1;", + []string{"# just a note\nSELECT 1"}, + }, + { + "postgres: block comments nest", + DriverPostgres, + "/* outer /* inner */ ; still comment */ SELECT 1", + []string{"/* outer /* inner */ ; still comment */ SELECT 1"}, + }, + { + "postgres: E'…' honours backslash escapes", + DriverPostgres, + `SELECT E'a\'b; not a split'; SELECT 2`, + []string{`SELECT E'a\'b; not a split'`, "SELECT 2"}, + }, + { + "sqlite: backslash is a plain character", + DriverSQLite, + `SELECT 'path\'; SELECT 2`, + []string{`SELECT 'path\'`, "SELECT 2"}, + }, + { + "mysql: DELIMITER keeps procedure bodies whole and strips the terminator", + DriverMySQL, + "DELIMITER //\nCREATE PROCEDURE p()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND//\nDELIMITER ;\nSELECT 3;", + []string{"CREATE PROCEDURE p()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND", "SELECT 3"}, + }, + { + "mysql: DELIMITER only counts at the start of a line", + DriverMySQL, + "SELECT delimiter FROM t; SELECT 2", + []string{"SELECT delimiter FROM t", "SELECT 2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SplitStatements(tt.driver, tt.in) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("SplitStatements(%s, %q)\n got %#v\n want %#v", tt.driver, tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/database/types.go b/internal/database/types.go index 8558345..303655e 100644 --- a/internal/database/types.go +++ b/internal/database/types.go @@ -38,7 +38,9 @@ type ColumnInfo struct { IsNullable bool `json:"isNullable"` IsPrimary bool `json:"isPrimary"` IsForeign bool `json:"isForeign"` - DefaultVal string `json:"defaultVal,omitempty"` + ForeignTable string `json:"foreignTable,omitempty"` + ForeignColumn string `json:"foreignColumn,omitempty"` + DefaultVal string `json:"defaultVal,omitempty"` } type TableInfo struct { diff --git a/internal/database/util.go b/internal/database/util.go index 364d738..112d5b5 100644 --- a/internal/database/util.go +++ b/internal/database/util.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "regexp" + "sort" "strconv" "strings" "time" @@ -20,7 +21,7 @@ var returningRe = regexp.MustCompile(`(?i)\bRETURNING\b`) // Mask comments/strings first so a literal like VALUES('RETURNING') doesn't misroute a write. func hasReturningClause(upper string) bool { - return returningRe.MatchString(maskStringLiterals(stripSQLComments(upper))) + return returningRe.MatchString(maskStringLiterals(stripSQLComments(upper, false))) } // ScanRows collects every row into a single QueryResult. Honours ctx cancellation so a UI Cancel @@ -279,9 +280,9 @@ func BuildUpdateSQL(driver DriverType, schema, table string, changes, pkValues m sets := make([]string, 0, len(changes)) args := make([]any, 0, len(changes)+len(pkCols)) idx := 1 - for col, val := range changes { + for _, col := range sortedKeys(changes) { sets = append(sets, fmt.Sprintf("%s = %s", QuoteIdent(driver, col), Placeholder(driver, idx))) - args = append(args, val) + args = append(args, changes[col]) idx++ } where := make([]string, 0, len(pkCols)) @@ -313,6 +314,17 @@ func BuildDeleteSQL(driver DriverType, schema, table string, pkCols []string, pk return q, args, nil } +// sortedKeys makes generated SQL deterministic: map iteration order is random per run, which +// defeats server-side prepared-statement caches and makes logs/tests unstable. +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // requirePKValues ensures every primary-key column has a value, so a WHERE clause can never // silently degrade to `pk = NULL` (which matches zero rows and reports a no-op as success). func requirePKValues(pkCols []string, values map[string]any) error { @@ -346,10 +358,10 @@ func BuildInsertSQL(driver DriverType, schema, table string, values map[string]a placeholders := make([]string, 0, len(values)) args := make([]any, 0, len(values)) idx := 1 - for col, val := range values { + for _, col := range sortedKeys(values) { cols = append(cols, QuoteIdent(driver, col)) placeholders = append(placeholders, Placeholder(driver, idx)) - args = append(args, val) + args = append(args, values[col]) idx++ } q := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", diff --git a/internal/database/util_test.go b/internal/database/util_test.go index cda7e44..0c76b68 100644 --- a/internal/database/util_test.go +++ b/internal/database/util_test.go @@ -186,3 +186,26 @@ func TestNowMsApproxNow(t *testing.T) { t.Errorf("NowMs() = %d not in [%d, %d]", got, before, after) } } + +func TestBuildSQLIsDeterministic(t *testing.T) { + values := map[string]any{"b": 2, "a": 1, "c": 3} + q, _, err := BuildInsertSQL(DriverPostgres, "public", "t", values) + if err != nil { + t.Fatal(err) + } + want := `INSERT INTO "public"."t" ("a", "b", "c") VALUES ($1, $2, $3)` + if q != want { + t.Errorf("BuildInsertSQL not deterministic:\n got %s\n want %s", q, want) + } + uq, args, err := BuildUpdateSQL(DriverPostgres, "public", "t", values, map[string]any{"id": 9}, []string{"id"}) + if err != nil { + t.Fatal(err) + } + uwant := `UPDATE "public"."t" SET "a" = $1, "b" = $2, "c" = $3 WHERE "id" = $4` + if uq != uwant { + t.Errorf("BuildUpdateSQL not deterministic:\n got %s\n want %s", uq, uwant) + } + if len(args) != 4 || args[0] != 1 || args[1] != 2 || args[2] != 3 || args[3] != 9 { + t.Errorf("args misordered: %#v", args) + } +} diff --git a/internal/windowtheme/windowtheme_darwin.go b/internal/windowtheme/windowtheme_darwin.go index a3f41ec..48acd6b 100644 --- a/internal/windowtheme/windowtheme_darwin.go +++ b/internal/windowtheme/windowtheme_darwin.go @@ -16,10 +16,10 @@ import "C" import "github.com/wailsapp/wails/v3/pkg/application" -// The transparent title bar shows the NSWindow background colour; the window -// appearance keeps the native title text legible on it. +// The transparent title bar shows the NSWindow background colour; the title +// text is hidden so only the traffic lights remain. func configureOS(opts *application.WebviewWindowOptions, p colours, dark bool) { - opts.Mac.TitleBar = application.MacTitleBar{AppearsTransparent: true} + opts.Mac.TitleBar = application.MacTitleBar{AppearsTransparent: true, HideTitle: true} opts.Mac.Appearance = application.NSAppearanceNameAqua if dark { opts.Mac.Appearance = application.NSAppearanceNameDarkAqua From 651f6d598a36db0603483395abc6ed1d2506d346 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 16 Jul 2026 20:04:04 +0300 Subject: [PATCH 2/3] Updated tests --- .../editor/hooks/useSchemaPreloader.ts | 17 ++--- .../editor/hooks/useSqlDiagnostics.ts | 3 +- .../editor/lib/createSqlCompletionProvider.ts | 2 +- .../editor/lib/createSqlHoverProvider.ts | 2 +- .../src/features/editor/lib/sqlCompletion.ts | 6 +- .../src/features/editor/lib/sqlContext.ts | 3 +- .../src/features/editor/lib/sqlDiagnostics.ts | 3 +- frontend/src/features/editor/lib/sqlHover.ts | 3 +- .../src/features/editor/lib/sqlQueryParse.ts | 9 +-- .../src/features/editor/lib/sqlStatements.ts | 3 +- .../src/features/editor/lib/sqlSuggestions.ts | 4 +- frontend/src/features/editor/lib/sqlText.ts | 12 ++-- frontend/src/features/editor/lib/sqlTokens.ts | 3 +- .../features/sidebar/hooks/useSchemaTree.ts | 4 +- frontend/src/shared/lib/columnCache.test.ts | 64 +++++++++++++++++++ frontend/src/shared/lib/columnCache.ts | 33 ++++++++++ internal/app/app_connections.go | 8 ++- internal/app/app_query.go | 3 +- internal/app/e2e_stream_test.go | 2 +- internal/database/exec.go | 8 +-- internal/database/readonly.go | 8 +-- internal/database/statements.go | 32 +++------- internal/database/types.go | 10 +-- internal/database/util.go | 3 +- 24 files changed, 153 insertions(+), 92 deletions(-) create mode 100644 frontend/src/shared/lib/columnCache.test.ts create mode 100644 frontend/src/shared/lib/columnCache.ts diff --git a/frontend/src/features/editor/hooks/useSchemaPreloader.ts b/frontend/src/features/editor/hooks/useSchemaPreloader.ts index b09cf78..758dbac 100644 --- a/frontend/src/features/editor/hooks/useSchemaPreloader.ts +++ b/frontend/src/features/editor/hooks/useSchemaPreloader.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect } from 'react'; import { api } from '@/shared/lib/api'; +import { cachedColumns } from '@/shared/lib/columnCache'; import { useAppStore } from '@/store/appStore'; import type { ColumnInfo, EditorTab } from '@/types'; @@ -10,9 +11,6 @@ export function useSchemaPreloader( ): { loadColumnsForConnection: (connectionId: string) => (schema: string, table: string) => Promise; } { - // Session-lifetime column cache keyed by `${connId}:${schema}.${table}`. - const columnsCacheRef = useRef>({}); - const loadSchemaForConnection = useCallback(async (connId: string) => { if (!connId) return; const { tables, setConnected, setSchemas, setTables } = useAppStore.getState(); @@ -42,15 +40,8 @@ export function useSchemaPreloader( const loadColumnsForConnection = useCallback( (connectionId: string) => - async (schema: string, table: string): Promise => { - const key = `${connectionId}:${schema}.${table}`; - const cached = columnsCacheRef.current[key]; - if (cached?.length) return cached; - const cols = await api.listColumns(connectionId, schema, table); - // Don't cache []: a transient empty miss would hide completions until restart. - if (cols.length) columnsCacheRef.current[key] = cols; - return cols; - }, + (schema: string, table: string): Promise => + cachedColumns(connectionId, schema, table, () => api.listColumns(connectionId, schema, table)), [], ); diff --git a/frontend/src/features/editor/hooks/useSqlDiagnostics.ts b/frontend/src/features/editor/hooks/useSqlDiagnostics.ts index 85220c1..37fbe38 100644 --- a/frontend/src/features/editor/hooks/useSqlDiagnostics.ts +++ b/frontend/src/features/editor/hooks/useSqlDiagnostics.ts @@ -6,8 +6,7 @@ import type { DriverType, SchemaInfo, TableInfo } from '@/types'; const SCHEMA_MARKER_OWNER = 'xensql-schema'; -// Warns on table names missing from the connected schema. Debounced, and the ref under the -// caret is skipped so the half-typed `FROM use` doesn't flicker while completing to `users`. +// Warns on unknown table names; the ref under the caret is skipped so typing doesn't flicker. export function useSqlDiagnostics( editorRef: RefObject, monacoRef: RefObject, diff --git a/frontend/src/features/editor/lib/createSqlCompletionProvider.ts b/frontend/src/features/editor/lib/createSqlCompletionProvider.ts index 67bf627..0c05857 100644 --- a/frontend/src/features/editor/lib/createSqlCompletionProvider.ts +++ b/frontend/src/features/editor/lib/createSqlCompletionProvider.ts @@ -78,7 +78,7 @@ export function createSqlCompletionProvider( const columnsByTable: Record = {}; await Promise.all( bindingsNeedingColumns(textBefore, parsed, { tables: allTables, schemas, driver }).map(async (ref) => { - columnsByTable[columnCacheKey(ref.schema, ref.table)] = await loadCols(ref.schema, ref.table); + columnsByTable[columnCacheKey(ref.schema, ref.table)] = await loadCols(ref.schema, ref.table).catch(() => []); }), ); diff --git a/frontend/src/features/editor/lib/createSqlHoverProvider.ts b/frontend/src/features/editor/lib/createSqlHoverProvider.ts index d6990fb..f8b31b3 100644 --- a/frontend/src/features/editor/lib/createSqlHoverProvider.ts +++ b/frontend/src/features/editor/lib/createSqlHoverProvider.ts @@ -42,7 +42,7 @@ export function createSqlHoverProvider( } if (query.columnLookup) { for (const binding of query.columnLookup.bindings) { - const cols = await onLoadColumns(binding.schema, binding.table); + const cols = await onLoadColumns(binding.schema, binding.table).catch(() => []); const col = cols.find((c) => c.name.toLowerCase() === query.columnLookup?.name); if (col) { return { range, contents: columnHoverLines(col, binding).map((value) => ({ value })) }; diff --git a/frontend/src/features/editor/lib/sqlCompletion.ts b/frontend/src/features/editor/lib/sqlCompletion.ts index d9b5db6..6901aa4 100644 --- a/frontend/src/features/editor/lib/sqlCompletion.ts +++ b/frontend/src/features/editor/lib/sqlCompletion.ts @@ -112,8 +112,7 @@ function schemaTablesFor(ctx: CompletionContext, schemaName: string): TableInfo[ ); } -// Ready-made `a.fk = b.pk` conditions right after ON, built from the FK metadata of the joined -// tables (both directions). Ranked first - it's almost always the condition being typed. +// `a.fk = b.pk` conditions after ON, from the joined tables' FK metadata (both directions). function fkJoinItems(ctx: CompletionContext, queryTables: QueryTableRef[]): CompletionItem[] { const items: CompletionItem[] = []; const refs = queryTables.filter( @@ -145,8 +144,7 @@ function virtualDetail(parsed: ParsedQuery, nameLc: string): string { return parsed.ctes.some((c) => c.toLowerCase() === nameLc) ? 'CTE column' : 'subquery column'; } -// Virtual relations visible to unqualified column completion: derived-table aliases always -// (they exist only in this FROM clause), CTEs only once actually referenced in FROM/JOIN. +// Derived-table aliases are always in scope; CTEs only once referenced in FROM/JOIN. function inScopeVirtualColumnItems(ctx: CompletionContext, parsed: ParsedQuery, lcPrefix: string): CompletionItem[] { const cteNames = new Set(parsed.ctes.map((c) => c.toLowerCase())); const referenced = new Set(parsed.queryTables.map((t) => t.table.toLowerCase())); diff --git a/frontend/src/features/editor/lib/sqlContext.ts b/frontend/src/features/editor/lib/sqlContext.ts index 632a66f..8288c85 100644 --- a/frontend/src/features/editor/lib/sqlContext.ts +++ b/frontend/src/features/editor/lib/sqlContext.ts @@ -20,8 +20,7 @@ export interface StatementShape { afterOnKeyword: boolean; // the caret sits right after a JOIN's ON - a join condition starts here } -// What the caret sits in. `prefix` is the partial word being typed (unquoted, lowercased unless -// noted) and `replaceLen` the raw character count the accepted suggestion should replace. +// What the caret sits in: `prefix` = partial word (lowercased), `replaceLen` = raw chars to replace. export type CursorSlot = | { kind: 'none' } // inside a comment or a plain string literal | { kind: 'dot'; segments: string[]; prefix: string; replaceLen: number } diff --git a/frontend/src/features/editor/lib/sqlDiagnostics.ts b/frontend/src/features/editor/lib/sqlDiagnostics.ts index 8f01bea..64f2abd 100644 --- a/frontend/src/features/editor/lib/sqlDiagnostics.ts +++ b/frontend/src/features/editor/lib/sqlDiagnostics.ts @@ -8,8 +8,7 @@ export interface SqlDiagnostic { message: string; } -// Table references that don't resolve against the live schema - typos caught before the -// round-trip to the server. CTE names are in scope by definition. +// Unresolved table refs - typos caught before the server round-trip. CTE names are in scope by definition. export function collectSchemaDiagnostics( sql: string, tables: TableInfo[], diff --git a/frontend/src/features/editor/lib/sqlHover.ts b/frontend/src/features/editor/lib/sqlHover.ts index 1213ea0..e02ec8c 100644 --- a/frontend/src/features/editor/lib/sqlHover.ts +++ b/frontend/src/features/editor/lib/sqlHover.ts @@ -4,8 +4,7 @@ import { columnDetail } from '@/features/editor/lib/sqlSuggestions'; import { isIdentLike, type SqlToken, tokenIdentText, tokenizeSql } from '@/features/editor/lib/sqlTokens'; import type { ColumnInfo, DriverType, SchemaInfo, TableInfo } from '@/types'; -// What to show for the identifier under the pointer. Either an immediate answer (`lines`) or a -// column lookup the caller resolves by loading the candidate tables' columns (cached). +// Immediate answer (`lines`) or a column lookup the caller resolves via cached loads. export interface HoverQuery { start: number; // statement-relative span of the hovered token end: number; diff --git a/frontend/src/features/editor/lib/sqlQueryParse.ts b/frontend/src/features/editor/lib/sqlQueryParse.ts index 14b50e1..72316e2 100644 --- a/frontend/src/features/editor/lib/sqlQueryParse.ts +++ b/frontend/src/features/editor/lib/sqlQueryParse.ts @@ -98,8 +98,7 @@ interface RawRef { last: number; // index of the ref's final consumed token } -// `name[.name] [AS] [alias]` starting at token `at`. A bare alias must not be a reserved keyword -// (else `FROM accounts JOIN …` would swallow JOIN as accounts' alias); quoted aliases always bind. +// `name[.name] [AS] [alias]`; bare aliases must not be reserved words (JOIN after FROM is no alias). function readTableRef(tokens: SqlToken[], at: number, allowAlias: boolean): RawRef | null { if (!isIdentLike(tokens[at])) return null; const name1 = tokens[at]; @@ -144,8 +143,7 @@ function matchParen(tokens: SqlToken[], openIdx: number): number { return -1; } -// Output column names of the SELECT inside tokens[start, end): explicit or trailing alias, -// else the bare/qualified column name. Unaliased expressions and `*` contribute nothing. +// Projected column names of the SELECT in tokens[start, end); unaliased expressions and `*` contribute nothing. function extractProjection(tokens: SqlToken[], start: number, end: number): string[] { const cols: string[] = []; let i = start; @@ -202,8 +200,7 @@ function extractProjection(tokens: SqlToken[], start: number, end: number): stri const isPunctToken = (t: SqlToken | undefined, ch: string) => t !== undefined && t.kind === 'punct' && t.text === ch; -// Names (and projected columns) from a leading WITH clause. Bodies are skipped balanced for the -// name walk; the main pass still collects their inner FROM/JOIN refs. +// CTE names + projections from a leading WITH; bodies skip balanced (inner refs come from the main pass). function collectCtes(tokens: SqlToken[], virtualColumns: Map): string[] { const ctes: string[] = []; let i = nextCodeToken(tokens, -1); diff --git a/frontend/src/features/editor/lib/sqlStatements.ts b/frontend/src/features/editor/lib/sqlStatements.ts index 0ef6b06..20a98f7 100644 --- a/frontend/src/features/editor/lib/sqlStatements.ts +++ b/frontend/src/features/editor/lib/sqlStatements.ts @@ -44,8 +44,7 @@ function firstCodeOffset(text: string, from: number, to: number, opts: SqlLexOpt return -1; } -// mysql-client convention: a line reading `DELIMITER xx` switches the statement terminator so -// procedure/trigger bodies can contain `;`. Consumes through the end of the line. +// mysql-client `DELIMITER xx` line: switches the terminator so procedure bodies can contain `;`. const DELIMITER_LINE_RE = /DELIMITER[ \t]+(\S+)[ \t]*(?:\r?\n|$)/iy; export function parseSqlStatements(sql: string, driver?: DriverType): SqlStatement[] { diff --git a/frontend/src/features/editor/lib/sqlSuggestions.ts b/frontend/src/features/editor/lib/sqlSuggestions.ts index 2ebfc18..bc91d89 100644 --- a/frontend/src/features/editor/lib/sqlSuggestions.ts +++ b/frontend/src/features/editor/lib/sqlSuggestions.ts @@ -23,9 +23,7 @@ export interface CompletionItem { sortText?: string; // lower lex = higher in list } -// One rule per keyword: where it parses (gate over the statement shape), which engines have it, -// and whether it survives inside a WHERE clause (operators and clause tails do; SELECT-list -// keywords don't). +// One rule per keyword: shape gate, engines, and whether it survives inside WHERE. interface KeywordRule { kw: string; drivers?: readonly DriverType[]; diff --git a/frontend/src/features/editor/lib/sqlText.ts b/frontend/src/features/editor/lib/sqlText.ts index 69c6398..732b0b1 100644 --- a/frontend/src/features/editor/lib/sqlText.ts +++ b/frontend/src/features/editor/lib/sqlText.ts @@ -19,8 +19,7 @@ export function lexOptionsFor(driver?: DriverType): SqlLexOptions { }; } -// Index of the terminating '\n' (not part of the comment), or -1 when the comment runs to EOF. -// `from` is the first character after the `--` / `#` marker. +// Index of the terminating '\n', or -1 at EOF; `from` is the first char after the marker. export function findLineCommentEnd(text: string, from: number): number { const nl = text.indexOf('\n', from); return nl; @@ -46,8 +45,7 @@ export function findBlockCommentEnd(text: string, from: number, nested: boolean) return -1; } -// Index just past the closing quote, or -1 when unterminated. `from` points at the opening quote. -// doubleEscape: '' (and "" / ``) is an embedded quote; backslashEscapes: \x is consumed as an escape. +// Index past the closing quote ('' doubling; optional \x escapes), or -1 when unterminated. export function findQuoteEnd( text: string, from: number, @@ -84,8 +82,7 @@ export function matchDollarTag(text: string, from: number): string | null { return DOLLAR_TAG_RE.exec(text)?.[0] ?? null; } -// Next scan position: past the closed $tag$...$tag$, `end` when unterminated, or from+1 when the -// `$` doesn't open a dollar quote at all. +// Next scan position past the dollar quote; `end` when unterminated, from+1 when not a dollar quote. export function skipDollarQuoted(text: string, from: number, end: number): number { const tag = matchDollarTag(text, from); if (!tag) return from + 1; @@ -93,8 +90,7 @@ export function skipDollarQuoted(text: string, from: number, end: number): numbe return close === -1 ? end : close + tag.length; } -// A standalone E/e right before the quote marks a Postgres escape string (E'a\'b'); backslash -// escapes then apply regardless of dialect. `1e'...'` / `TABLE'...'` don't qualify. +// Standalone E before the quote = Postgres escape string; `1e'…'` / `TABLE'…'` don't qualify. export function isEscapeStringPrefix(text: string, quoteIdx: number): boolean { const prev = text[quoteIdx - 1]; if (prev !== 'e' && prev !== 'E') return false; diff --git a/frontend/src/features/editor/lib/sqlTokens.ts b/frontend/src/features/editor/lib/sqlTokens.ts index 92eb373..724ecc9 100644 --- a/frontend/src/features/editor/lib/sqlTokens.ts +++ b/frontend/src/features/editor/lib/sqlTokens.ts @@ -34,8 +34,7 @@ const WS_RE = /\s/; const TWO_CHAR_OPS = new Set(['<=', '>=', '<>', '!=', '::', '||', ':=']); const PUNCT = new Set(['.', ',', '(', ')', ';']); -// One linear pass. Dollar-quote delimiters ($tag$) are emitted as ops and their bodies tokenized -// as ordinary SQL, so completion keeps working inside Postgres function bodies. +// One pass; $tag$ delimiters are ops so dollar-quoted bodies stay tokenized (completion works inside them). export function tokenizeSql(text: string, driver?: DriverType): SqlToken[] { const opts = lexOptionsFor(driver); const len = text.length; diff --git a/frontend/src/features/sidebar/hooks/useSchemaTree.ts b/frontend/src/features/sidebar/hooks/useSchemaTree.ts index f27e949..74dc36d 100644 --- a/frontend/src/features/sidebar/hooks/useSchemaTree.ts +++ b/frontend/src/features/sidebar/hooks/useSchemaTree.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { api } from '@/shared/lib/api'; +import { invalidateColumnCache } from '@/shared/lib/columnCache'; import { formatError } from '@/shared/lib/normalize'; import { readStoredJson, STORAGE_KEYS, writeStoredJson } from '@/shared/lib/storageKeys'; import { useSchemas, useStoreActions, useTablesMap } from '@/store/selectors'; @@ -61,10 +62,11 @@ export function useSchemaTree({ connId, connConnected, schemaList, schemaSearch } setLoadingSchema(true); setSchemaError(''); - // Full (re)load: forget prior fetch markers so expanded nodes re-hydrate fresh. + // Full (re)load: forget fetch markers and the editor's column cache. loadedTablesRef.current.clear(); loadedColumnsRef.current.clear(); setTableColumns({}); + invalidateColumnCache(connectionId); try { const bundle = await api.loadSchemaData(connectionId); diff --git a/frontend/src/shared/lib/columnCache.test.ts b/frontend/src/shared/lib/columnCache.test.ts new file mode 100644 index 0000000..d861c55 --- /dev/null +++ b/frontend/src/shared/lib/columnCache.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import { cachedColumns, invalidateColumnCache } from '@/shared/lib/columnCache'; +import type { ColumnInfo } from '@/types'; + +const col = (name: string): ColumnInfo => ({ + name, + dataType: 'text', + isNullable: true, + isPrimary: false, + isForeign: false, +}); + +describe('columnCache', () => { + it('fetches once and serves later requests from the cache', async () => { + const fetch = vi.fn().mockResolvedValue([col('a')]); + await cachedColumns('c1', 'public', 'users', fetch); + await cachedColumns('c1', 'public', 'users', fetch); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('shares one in-flight request between concurrent callers', async () => { + let resolve: (v: ColumnInfo[]) => void = () => {}; + const fetch = vi.fn().mockImplementation(() => new Promise((r) => (resolve = r))); + const p1 = cachedColumns('c1', 'public', 'orders', fetch); + const p2 = cachedColumns('c1', 'public', 'orders', fetch); + resolve([col('id')]); + expect(await p1).toEqual(await p2); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('caches empty results until invalidated', async () => { + const fetch = vi.fn().mockResolvedValue([]); + await cachedColumns('c1', 'main', 'ghost', fetch); + await cachedColumns('c1', 'main', 'ghost', fetch); + expect(fetch).toHaveBeenCalledTimes(1); + + invalidateColumnCache('c1'); + await cachedColumns('c1', 'main', 'ghost', fetch); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('evicts failed loads so the next request retries', async () => { + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error('busy')) + .mockResolvedValue([col('id')]); + await expect(cachedColumns('c1', 'public', 'flaky', fetch)).rejects.toThrow('busy'); + await expect(cachedColumns('c1', 'public', 'flaky', fetch)).resolves.toEqual([col('id')]); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('invalidates per connection, not globally', async () => { + const f1 = vi.fn().mockResolvedValue([col('a')]); + const f2 = vi.fn().mockResolvedValue([col('b')]); + await cachedColumns('conn-a', 'public', 't', f1); + await cachedColumns('conn-b', 'public', 't', f2); + + invalidateColumnCache('conn-a'); + await cachedColumns('conn-a', 'public', 't', f1); + await cachedColumns('conn-b', 'public', 't', f2); + expect(f1).toHaveBeenCalledTimes(2); + expect(f2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/shared/lib/columnCache.ts b/frontend/src/shared/lib/columnCache.ts new file mode 100644 index 0000000..e02f48c --- /dev/null +++ b/frontend/src/shared/lib/columnCache.ts @@ -0,0 +1,33 @@ +import type { ColumnInfo } from '@/types'; + +// One fetch per connection+table, shared in-flight promise. Failed loads are evicted so transient +// errors retry; successes (empty included) stick until a schema refresh invalidates them. +const cache = new Map>(); + +const keyOf = (connectionId: string, schema: string, table: string) => `${connectionId}:${schema}.${table}`; + +export function cachedColumns( + connectionId: string, + schema: string, + table: string, + fetch: () => Promise, +): Promise { + const key = keyOf(connectionId, schema, table); + let entry = cache.get(key); + if (!entry) { + entry = fetch().catch((err) => { + cache.delete(key); + throw err; + }); + cache.set(key, entry); + } + return entry; +} + +/** Drop a connection's cached tables (schema refresh). */ +export function invalidateColumnCache(connectionId: string): void { + const prefix = `${connectionId}:`; + for (const key of cache.keys()) { + if (key.startsWith(prefix)) cache.delete(key); + } +} diff --git a/internal/app/app_connections.go b/internal/app/app_connections.go index 3de88d7..7555d05 100644 --- a/internal/app/app_connections.go +++ b/internal/app/app_connections.go @@ -1,6 +1,9 @@ package app import ( + "context" + "time" + "xensql/internal/database" "xensql/internal/storage" ) @@ -176,7 +179,10 @@ func (a *App) ListColumns(connectionID, schema, table string) ([]database.Column if err != nil { return nil, err } - return s.ListColumns(a.ctx, schema, table) + // Completion/hover call this on cache misses; fail fast instead of wedging when the pool is saturated. + ctx, cancel := context.WithTimeout(a.ctx, 5*time.Second) + defer cancel() + return s.ListColumns(ctx, schema, table) } func (a *App) ListFolders() []storage.ConnectionFolder { diff --git a/internal/app/app_query.go b/internal/app/app_query.go index f7cb951..e3312bf 100644 --- a/internal/app/app_query.go +++ b/internal/app/app_query.go @@ -175,8 +175,7 @@ func (a *App) ExecuteQueryStream(connectionID, tabID, sql string) error { if err := a.guardExecute(connectionID, sql); err != nil { return err } - // Split with the connection's dialect so boundaries match the editor's run-glyphs - // (MySQL # comments, \' escapes and DELIMITER; Postgres nested comments and E'…'). + // Split with the connection's dialect so boundaries match the editor's run-glyphs. s, err := a.sessionFor(connectionID) if err != nil { return err diff --git a/internal/app/e2e_stream_test.go b/internal/app/e2e_stream_test.go index b134593..c6e00df 100644 --- a/internal/app/e2e_stream_test.go +++ b/internal/app/e2e_stream_test.go @@ -168,7 +168,7 @@ func TestE2EMultiStatementScript(t *testing.T) { defer pc.Close() script := "SELECT 1 AS a; SELECT 'x' AS b, 'y' AS c" - stmts := database.SplitStatements(script) + stmts := database.SplitStatements(s.DriverType(), script) if len(stmts) != 2 { t.Fatalf("SplitStatements gave %d statements, want 2: %+v", len(stmts), stmts) } diff --git a/internal/database/exec.go b/internal/database/exec.go index ab02df2..3dab596 100644 --- a/internal/database/exec.go +++ b/internal/database/exec.go @@ -35,9 +35,8 @@ func execSummary(res sql.Result, startMs int64) *QueryResult { } } -// runStatement executes one statement, routing row-returning SQL through the streaming scan and -// everything else through Exec. Uses the same predicate as the script path so TABLE/VALUES/CALL -// statements surface their rows here too. +// runStatement routes row-returning SQL (same predicate as the script path, so TABLE/VALUES/CALL +// surface rows) through the streaming scan, everything else through Exec. func runStatement(ctx context.Context, conn *sql.Conn, driver DriverType, sqlText string, opts StreamOpts) (*QueryResult, error) { start := NowMs() upper := strings.ToUpper(StripLeadingComments(sqlText)) @@ -194,8 +193,7 @@ func buildTableSelectSQL(driver DriverType, schema string, req TableDataRequest, } q += fmt.Sprintf(" ORDER BY %s %s", QuoteIdent(driver, orderBy), dir) } - // Clamp: SQLite treats LIMIT -1 as "no limit" (an accidental full-table page) and Postgres - // errors on it; a negative OFFSET is an error on every driver. + // SQLite reads LIMIT -1 as "no limit" and Postgres rejects it; negative OFFSET errors everywhere. limit := req.Limit if limit <= 0 { limit = 100 diff --git a/internal/database/readonly.go b/internal/database/readonly.go index 93d7eab..aeeb66d 100644 --- a/internal/database/readonly.go +++ b/internal/database/readonly.go @@ -68,8 +68,7 @@ func AssertReadOnlySQL(sql string) error { return AssertReadOnlySQLFor("", sql) } -// AssertReadOnlySQLFor applies the driver's comment syntax before classifying - on MySQL a `#` -// comment mentioning a write keyword must not block a legitimate read. +// AssertReadOnlySQLFor classifies with the driver's comment syntax (a MySQL # comment must not block a read). func AssertReadOnlySQLFor(driver DriverType, sql string) error { if IsReadOnlySQLFor(driver, sql) { return nil @@ -108,9 +107,8 @@ func IsReadOnlySQL(sql string) bool { return IsReadOnlySQLFor("", sql) } -// IsReadOnlySQLFor is IsReadOnlySQL with the driver's comment syntax. `#` comments are stripped -// only for MySQL: on Postgres `#` is an operator, and stripping to end-of-line there could hide -// a following write statement from the classifier. +// IsReadOnlySQLFor strips # comments only for MySQL - on Postgres # is an operator, and stripping +// to end-of-line could hide a following write from the classifier. func IsReadOnlySQLFor(driver DriverType, sql string) bool { cleaned := maskStringLiterals(stripSQLComments(sql, driver == DriverMySQL)) for _, stmt := range splitSQLStatements(cleaned) { diff --git a/internal/database/statements.go b/internal/database/statements.go index 6ef9ef5..7a9e5b4 100644 --- a/internal/database/statements.go +++ b/internal/database/statements.go @@ -5,8 +5,7 @@ import ( "strings" ) -// lexOptions are the dialect knobs for lexical scanning. The zero value is the conservative -// common subset: no backslash escapes, no # comments, no nested block comments. +// lexOptions are the dialect knobs; the zero value is the conservative common subset. type lexOptions struct { hashLineComments bool // MySQL `# ...` backslashEscapes bool // MySQL strings: 'it\'s', "a\"b" @@ -27,20 +26,13 @@ func lexOptionsFor(driver DriverType) lexOptions { } } -// mysql-client convention: a line reading `DELIMITER xx` switches the statement terminator so -// procedure/trigger bodies can contain `;`. Consumes through the end of the line. +// mysql-client `DELIMITER xx` line: switches the terminator so procedure bodies can contain `;`. var delimiterLineRe = regexp.MustCompile(`(?i)^DELIMITER[ \t]+(\S+)[ \t]*(?:\r?\n|$)`) -// SplitStatements splits a SQL script into individual executable statements with the driver's -// lexical rules, ignoring semicolons inside comments, quoted text, and dollar-quoted bodies. -// MySQL additionally honours `#` comments, backslash string escapes, and client `DELIMITER` -// lines (the custom terminator and the DELIMITER lines themselves are excluded from the output); -// Postgres honours nested block comments and E'…' escape strings. Comment-only and empty chunks -// are dropped and terminators are excluded, so each returned string is ready to run on its own. -// -// This mirrors the frontend parseSqlStatements scanner (features/editor/lib/sqlStatements.ts + -// sqlText.ts); keep the two in sync so the gutter run-glyphs and backend batch execution agree -// on statement boundaries. +// SplitStatements splits a script into runnable statements using the driver's lexical rules +// (MySQL: # comments, backslash escapes, DELIMITER lines; Postgres: nested comments, E'…'). +// Terminators and comment-only chunks are dropped. Mirrors the frontend parseSqlStatements +// scanner (features/editor/lib/sqlStatements.ts); keep the two in sync. func SplitStatements(driver DriverType, sql string) []string { opts := lexOptionsFor(driver) var out []string @@ -116,8 +108,7 @@ func hasCode(s string, opts lexOptions) bool { return false } -// atLineStart reports whether only whitespace precedes s[i] on its line (matching the mysql -// client, which only honours DELIMITER at the start of a line). +// atLineStart: only whitespace precedes s[i] on its line (mysql honours DELIMITER at line start only). func atLineStart(s string, i int) bool { j := strings.LastIndexByte(s[:i], '\n') + 1 return strings.TrimSpace(s[j:i]) == "" @@ -131,8 +122,7 @@ func lineCommentEnd(s string, from, n int) int { return n } -// blockCommentEnd returns the index just past the closing `*/` (nesting-aware for Postgres), -// or n when unterminated. i points at the opening `/*`. +// blockCommentEnd: index past the closing `*/` (nesting-aware), or n when unterminated. func blockCommentEnd(s string, i, n int, nested bool) int { i += 2 depth := 1 @@ -152,8 +142,7 @@ func blockCommentEnd(s string, i, n int, nested bool) int { return n } -// quoteEnd returns the index just past the closing quote, or n when unterminated. Doubled quotes -// (” / "") are embedded quotes; backslashEscapes additionally consumes \x pairs (MySQL, E'…'). +// quoteEnd: index past the closing quote ('' doubling; optional \x escapes), or n when unterminated. func quoteEnd(s string, i int, quote byte, backslashEscapes bool, n int) int { i++ for i < n { @@ -173,8 +162,7 @@ func quoteEnd(s string, i int, quote byte, backslashEscapes bool, n int) int { return i } -// A standalone E/e right before the quote marks a Postgres escape string (E'a\'b'); backslash -// escapes then apply regardless of dialect. `1e'…'` / `TABLE'…'` don't qualify. +// Standalone E before the quote = Postgres escape string; `1e'…'` / `TABLE'…'` don't qualify. func isEscapeStringPrefix(s string, quoteIdx int) bool { if quoteIdx < 1 { return false diff --git a/internal/database/types.go b/internal/database/types.go index 303655e..f26e474 100644 --- a/internal/database/types.go +++ b/internal/database/types.go @@ -33,11 +33,11 @@ type ConnectionConfig struct { } type ColumnInfo struct { - Name string `json:"name"` - DataType string `json:"dataType"` - IsNullable bool `json:"isNullable"` - IsPrimary bool `json:"isPrimary"` - IsForeign bool `json:"isForeign"` + Name string `json:"name"` + DataType string `json:"dataType"` + IsNullable bool `json:"isNullable"` + IsPrimary bool `json:"isPrimary"` + IsForeign bool `json:"isForeign"` ForeignTable string `json:"foreignTable,omitempty"` ForeignColumn string `json:"foreignColumn,omitempty"` DefaultVal string `json:"defaultVal,omitempty"` diff --git a/internal/database/util.go b/internal/database/util.go index 112d5b5..7e835f1 100644 --- a/internal/database/util.go +++ b/internal/database/util.go @@ -314,8 +314,7 @@ func BuildDeleteSQL(driver DriverType, schema, table string, pkCols []string, pk return q, args, nil } -// sortedKeys makes generated SQL deterministic: map iteration order is random per run, which -// defeats server-side prepared-statement caches and makes logs/tests unstable. +// sortedKeys keeps generated SQL deterministic - map iteration order is random per run. func sortedKeys(m map[string]any) []string { keys := make([]string, 0, len(m)) for k := range m { From d3bd384ba7c894d196a7a165e5ec4d9dc30f2100 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Thu, 16 Jul 2026 20:24:24 +0300 Subject: [PATCH 3/3] Bumped version --- build/config.yml | 2 +- build/darwin/Info.dev.plist | 4 ++-- build/darwin/Info.plist | 4 ++-- build/ios/Info.dev.plist | 4 ++-- build/ios/Info.plist | 4 ++-- build/linux/nfpm/nfpm.yaml | 2 +- build/windows/info.json | 4 ++-- build/windows/nsis/wails_tools.nsh | 2 +- build/windows/wails.exe.manifest | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- frontend/src/shared/lib/appInfo.ts | 2 +- internal/app/version.go | 2 +- 13 files changed, 19 insertions(+), 19 deletions(-) diff --git a/build/config.yml b/build/config.yml index 90ea003..799d3e1 100644 --- a/build/config.yml +++ b/build/config.yml @@ -11,7 +11,7 @@ info: description: "A fast, native SQL client built with Go, Wails and React." # The application description copyright: "(c) 2026, Bare7a" # Copyright text comments: "A fast, native SQL client built with Go, Wails and React." # Comments - version: "1.4.0" # The application version + version: "1.4.1" # The application version # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional) # # Should match the name of your .icon file without the extension # # If not set and Assets.car exists, defaults to "appicon" diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 31b44c9..905ac51 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -78,9 +78,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.0 + 1.4.1 CFBundleVersion - 1.4.0 + 1.4.1 LSMinimumSystemVersion 12.0.0 NSAppTransportSecurity diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index b029a62..1b40743 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -78,9 +78,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.0 + 1.4.1 CFBundleVersion - 1.4.0 + 1.4.1 LSMinimumSystemVersion 12.0.0 NSHighResolutionCapable diff --git a/build/ios/Info.dev.plist b/build/ios/Info.dev.plist index b6ec7f9..beacbef 100644 --- a/build/ios/Info.dev.plist +++ b/build/ios/Info.dev.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.0-dev + 1.4.1-dev CFBundleVersion - 1.4.0 + 1.4.1 LSRequiresIPhoneOS MinimumOSVersion diff --git a/build/ios/Info.plist b/build/ios/Info.plist index 71bf5ca..0c2fe22 100644 --- a/build/ios/Info.plist +++ b/build/ios/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.0 + 1.4.1 CFBundleVersion - 1.4.0 + 1.4.1 LSRequiresIPhoneOS MinimumOSVersion diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml index 92a7ba3..ffb534d 100644 --- a/build/linux/nfpm/nfpm.yaml +++ b/build/linux/nfpm/nfpm.yaml @@ -6,7 +6,7 @@ name: "XenSQL" arch: ${GOARCH} platform: "linux" -version: "1.4.0" +version: "1.4.1" section: "default" priority: "extra" maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> diff --git a/build/windows/info.json b/build/windows/info.json index a696f5b..cba5755 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,10 +1,10 @@ { "fixed": { - "file_version": "1.4.0" + "file_version": "1.4.1" }, "info": { "0000": { - "ProductVersion": "1.4.0", + "ProductVersion": "1.4.1", "CompanyName": "Bare7a", "FileDescription": "A fast, native SQL client built with Go, Wails and React.", "LegalCopyright": "(c) 2026, Bare7a", diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh index daaa764..062f99b 100644 --- a/build/windows/nsis/wails_tools.nsh +++ b/build/windows/nsis/wails_tools.nsh @@ -14,7 +14,7 @@ !define INFO_PRODUCTNAME "XenSQL" !endif !ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "1.4.0" + !define INFO_PRODUCTVERSION "1.4.1" !endif !ifndef INFO_COPYRIGHT !define INFO_COPYRIGHT "(c) 2026, Bare7a" diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest index 67d7ecf..5a548f1 100644 --- a/build/windows/wails.exe.manifest +++ b/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b972d6f..fc114ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "xensql-frontend", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xensql-frontend", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "@fontsource/fira-code": "^5.2.7", "@fontsource/inter": "^5.2.8", diff --git a/frontend/package.json b/frontend/package.json index cc9b005..2497d2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "xensql-frontend", "private": true, - "version": "1.4.0", + "version": "1.4.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/shared/lib/appInfo.ts b/frontend/src/shared/lib/appInfo.ts index 0ae942f..859e135 100644 --- a/frontend/src/shared/lib/appInfo.ts +++ b/frontend/src/shared/lib/appInfo.ts @@ -10,7 +10,7 @@ export interface AppInfo { /** Fallback when Go binding is unavailable (dev in browser). */ export const DEFAULT_APP_INFO: AppInfo = { name: 'XenSQL', - version: '1.4.0', + version: '1.4.1', author: 'Bare7a', email: 'bare7a@gmail.com', repository: 'https://github.com/Bare7a/XenSQL', diff --git a/internal/app/version.go b/internal/app/version.go index a55e281..eb1ffa7 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -3,4 +3,4 @@ package app // Version is the source of truth for the application version. // In order to update it use the following command: // go run ./cmd/bump-version [-major, -minor, -patch, 1.2.0] -const Version = "1.4.0" +const Version = "1.4.1"