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/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/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/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/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
new file mode 100644
index 0000000..37fbe38
--- /dev/null
+++ b/frontend/src/features/editor/hooks/useSqlDiagnostics.ts
@@ -0,0 +1,50 @@
+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 unknown table names; the ref under the caret is skipped so typing doesn't flicker.
+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..0c05857 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).catch(() => []);
+ }),
+ );
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..f8b31b3
--- /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).catch(() => []);
+ 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..6901aa4 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,226 @@ function schemaTablesFor(ctx: CompletionContext, schemaName: string): TableInfo[
);
}
-function dotCompletionItems(
+// `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(
+ (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';
+}
+
+// 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()));
+ 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..8288c85
--- /dev/null
+++ b/frontend/src/features/editor/lib/sqlContext.ts
@@ -0,0 +1,465 @@
+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` = 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 }
+ | { 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..64f2abd
--- /dev/null
+++ b/frontend/src/features/editor/lib/sqlDiagnostics.ts
@@ -0,0 +1,32 @@
+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;
+}
+
+// Unresolved table refs - typos caught before the server round-trip. 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..e02ec8c
--- /dev/null
+++ b/frontend/src/features/editor/lib/sqlHover.ts
@@ -0,0 +1,105 @@
+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';
+
+// 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;
+ 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..72316e2 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,154 @@ 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]`; 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];
+ 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;
+}
+
+// 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;
+ 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;
+
+// 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);
+ 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 +244,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 +339,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 +352,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..20a98f7 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,44 @@ function firstCodeOffset(text: string, from: number, to: number): number {
return -1;
}
-export function parseSqlStatements(sql: string): SqlStatement[] {
+// 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[] {
+ 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 +89,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 +143,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..bc91d89 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,112 @@ 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: shape gate, engines, and whether it survives inside WHERE.
+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 +151,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 +223,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 +332,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 +354,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 +381,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..732b0b1
--- /dev/null
+++ b/frontend/src/features/editor/lib/sqlText.ts
@@ -0,0 +1,99 @@
+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', 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;
+}
+
+// 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 past the closing quote ('' doubling; optional \x escapes), or -1 when unterminated.
+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 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;
+ const close = text.indexOf(tag, from + tag.length);
+ return close === -1 ? end : close + tag.length;
+}
+
+// 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;
+ 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..724ecc9
--- /dev/null
+++ b/frontend/src/features/editor/lib/sqlTokens.ts
@@ -0,0 +1,171 @@
+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 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;
+ 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/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/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/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/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_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 4e387c4..e3312bf 100644
--- a/internal/app/app_query.go
+++ b/internal/app/app_query.go
@@ -175,7 +175,12 @@ 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.
+ s, err := a.sessionFor(connectionID)
+ if err != nil {
+ return err
+ }
+ a.runBatchStream(tabID, connectionID, database.SplitStatements(s.DriverType(), sql))
return nil
}
@@ -251,7 +256,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/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/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"
diff --git a/internal/database/exec.go b/internal/database/exec.go
index 1769a84..3dab596 100644
--- a/internal/database/exec.go
+++ b/internal/database/exec.go
@@ -35,12 +35,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.
+// 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))
- 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 +193,16 @@ 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)
+ // SQLite reads LIMIT -1 as "no limit" and Postgres rejects it; negative OFFSET errors everywhere.
+ 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 +275,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..aeeb66d 100644
--- a/internal/database/readonly.go
+++ b/internal/database/readonly.go
@@ -65,7 +65,12 @@ var deniedFirstKeywords = map[string]bool{
}
func AssertReadOnlySQL(sql string) error {
- if IsReadOnlySQL(sql) {
+ return AssertReadOnlySQLFor("", sql)
+}
+
+// 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
}
return ErrReadOnly
@@ -99,7 +104,13 @@ 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 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) {
if !isReadOnlyStatement(stmt) {
return false
@@ -195,15 +206,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..7a9e5b4 100644
--- a/internal/database/statements.go
+++ b/internal/database/statements.go
@@ -5,45 +5,80 @@ 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.
-//
-// 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 {
+// 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"
+ 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 `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 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
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 +89,100 @@ 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: 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]) == ""
+}
+
+// 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: index past the closing `*/` (nesting-aware), or n when unterminated.
+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: 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 {
- 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
}
+// 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
+ }
+ 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..f26e474 100644
--- a/internal/database/types.go
+++ b/internal/database/types.go
@@ -33,12 +33,14 @@ 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"`
- DefaultVal string `json:"defaultVal,omitempty"`
+ 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"`
}
type TableInfo struct {
diff --git a/internal/database/util.go b/internal/database/util.go
index 364d738..7e835f1 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,16 @@ func BuildDeleteSQL(driver DriverType, schema, table string, pkCols []string, pk
return q, args, nil
}
+// 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 {
+ 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 +357,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