From 04a8a24b1ee88d0f9bdef9166cde070e7e508c46 Mon Sep 17 00:00:00 2001 From: blankll Date: Thu, 6 Aug 2026 23:41:52 +0800 Subject: [PATCH 1/3] feat(sql-completion): schema-aware autocomplete with dialect switching - Add 4-layer completion pipeline (analyzer/builder/dialects/provider) - Wire monaco completion providers for all SQL dialects - Track async-loaded metadata so provider snapshots rebuild after connect - Register Pinia store loader browser-safely (no CommonJS require) - 60 unit tests; full suite 515/515 --- src/__tests__/sqlCompletion/analyzer.test.ts | 84 ++++++ src/__tests__/sqlCompletion/builder.test.ts | 151 ++++++++++ src/__tests__/sqlCompletion/dialects.test.ts | 85 ++++++ src/__tests__/sqlCompletion/metadata.test.ts | 169 +++++++++++ src/__tests__/sqlCompletion/provider.test.ts | 195 +++++++++++++ src/__tests__/sqlCompletion/types.test.ts | 69 +++++ src/components/SQLEditor.vue | 11 +- src/composables/sqlCompletion/analyzer.ts | 286 +++++++++++++++++++ src/composables/sqlCompletion/builder.ts | 207 ++++++++++++++ src/composables/sqlCompletion/dialects.ts | 259 +++++++++++++++++ src/composables/sqlCompletion/metadata.ts | 242 ++++++++++++++++ src/composables/sqlCompletion/provider.ts | 226 +++++++++++++++ src/composables/sqlCompletion/types.ts | 109 +++++++ src/composables/useMonacoEditor.ts | 220 ++------------ src/main.ts | 3 + src/pages/QueriesPage.vue | 59 +++- 16 files changed, 2179 insertions(+), 196 deletions(-) create mode 100644 src/__tests__/sqlCompletion/analyzer.test.ts create mode 100644 src/__tests__/sqlCompletion/builder.test.ts create mode 100644 src/__tests__/sqlCompletion/dialects.test.ts create mode 100644 src/__tests__/sqlCompletion/metadata.test.ts create mode 100644 src/__tests__/sqlCompletion/provider.test.ts create mode 100644 src/__tests__/sqlCompletion/types.test.ts create mode 100644 src/composables/sqlCompletion/analyzer.ts create mode 100644 src/composables/sqlCompletion/builder.ts create mode 100644 src/composables/sqlCompletion/dialects.ts create mode 100644 src/composables/sqlCompletion/metadata.ts create mode 100644 src/composables/sqlCompletion/provider.ts create mode 100644 src/composables/sqlCompletion/types.ts diff --git a/src/__tests__/sqlCompletion/analyzer.test.ts b/src/__tests__/sqlCompletion/analyzer.test.ts new file mode 100644 index 00000000..92a43532 --- /dev/null +++ b/src/__tests__/sqlCompletion/analyzer.test.ts @@ -0,0 +1,84 @@ +/** + * @jest-environment node + */ +import { analyzeCompletionContext } from '@/composables/sqlCompletion/analyzer' + +describe('analyzeCompletionContext', () => { + it('(a) detects the word and active table after FROM', () => { + const text = 'SELECT * FROM users WHERE u' + const ctx = analyzeCompletionContext(text, text.length) + expect(ctx.word).toBe('u') + expect(ctx.activeTable?.table).toBe('users') + expect(ctx.tableRefs).toEqual([{ table: 'users' }]) + expect(ctx.inComment).toBe(false) + }) + + it('(b) detects alias-qualified column word', () => { + const text = 'SELECT u.na FROM users u' + const ctx = analyzeCompletionContext(text, 'SELECT u.na'.length) + expect(ctx.word).toBe('na') + expect(ctx.qualifier).toBe('u.') + expect(ctx.isAfterDot).toBe(true) + expect(ctx.activeTable?.table).toBe('users') + expect(ctx.activeTable?.alias).toBe('u') + }) + + it('(c) handles quoted table names with spaces', () => { + const text = 'SELECT * FROM "my table" WHERE ' + const ctx = analyzeCompletionContext(text, text.length) + expect(ctx.activeTable?.table).toBe('my table') + }) + + it('(d) handles schema-qualified table with alias', () => { + const text = 'SELECT * FROM public.orders o WHERE o.' + const ctx = analyzeCompletionContext(text, text.length) + expect(ctx.qualifier).toBe('o.') + expect(ctx.activeTable?.table).toBe('orders') + expect(ctx.activeTable?.schema).toBe('public') + }) + + it('(e) ignores FROM inside a line comment', () => { + const text = '-- FROM foo\nSELECT 1' + const ctx = analyzeCompletionContext(text, text.length) + expect(ctx.tableRefs).toEqual([]) + expect(ctx.activeTable).toBeNull() + }) + + it('(f) last JOIN table wins for its own alias', () => { + const text = 'SELECT * FROM a JOIN b ON a.id=b.id WHERE b.' + const ctx = analyzeCompletionContext(text, text.length) + expect(ctx.qualifier).toBe('b.') + expect(ctx.activeTable?.table).toBe('b') + expect(ctx.tableRefs.length).toBe(2) + }) + + it('(g) offset inside a block comment yields no table refs', () => { + const text = 'SELECT * FROM a /* FROM x */' + const idx = text.indexOf('/* FROM x */') + 5 + const ctx = analyzeCompletionContext(text, idx) + expect(ctx.inComment).toBe(true) + expect(ctx.tableRefs).toEqual([]) + }) + + it('(h) empty string → empty context, no throw', () => { + const ctx = analyzeCompletionContext('', 0) + expect(ctx.word).toBe('') + expect(ctx.tableRefs).toEqual([]) + expect(ctx.activeTable).toBeNull() + expect(ctx.inComment).toBe(false) + }) + + it('supports AS-alias and multi-statement document', () => { + const text = 'SELECT * FROM users AS u;\nSELECT o.id FROM orders o' + const ctx = analyzeCompletionContext(text, text.length) + expect(ctx.activeTable?.table).toBe('orders') + expect(ctx.activeTable?.alias).toBe('o') + }) + + it('word completion on empty word at end of FROM still yields the table ref', () => { + const text = 'SELECT * FROM users ' + const ctx = analyzeCompletionContext(text, text.length) + expect(ctx.word).toBe('') + expect(ctx.activeTable?.table).toBe('users') + }) +}) diff --git a/src/__tests__/sqlCompletion/builder.test.ts b/src/__tests__/sqlCompletion/builder.test.ts new file mode 100644 index 00000000..90c57977 --- /dev/null +++ b/src/__tests__/sqlCompletion/builder.test.ts @@ -0,0 +1,151 @@ +import type { CompletionContext, SchemaSnapshot } from '@/composables/sqlCompletion/types' +import { buildSuggestions } from '@/composables/sqlCompletion/builder' +/** + * @jest-environment node + */ +import { getDialectProfile } from '@/composables/sqlCompletion/dialects' + +const profile = getDialectProfile('sql') + +const snapshot: SchemaSnapshot = { + databases: [{ name: 'app', isSystem: false }], + schemasByDb: { app: ['public', 'analytics'] }, + tablesByKey: { + 'app': ['users', 'orders'], + 'app.public': ['users', 'orders'], + 'app.analytics': ['events'], + }, + columnsByTable: { + 'c1|app|public|users': [ + { name: 'id', dataType: 'int4', isPrimaryKey: true }, + { name: 'email', dataType: 'text' }, + { name: 'created_at', dataType: 'timestamptz' }, + ], + }, + derivedTables: {}, + hasSchemaData: true, +} + +const opts = { connectionId: 'c1', currentDb: 'app', currentSchema: 'public' } + +function ctx(partial: Partial): CompletionContext { + return { + word: '', + tableRefs: [], + activeTable: null, + qualifier: '', + isAfterDot: false, + inComment: false, + ...partial, + } +} + +const labels = (items: { label: string }[]) => items.map(i => i.label) + +describe('buildSuggestions', () => { + it('(a) word SEL → SELECT keyword suggested', () => { + const result = buildSuggestions(ctx({ word: 'SEL' }), snapshot, profile, opts) + expect(labels(result)).toContain('SELECT') + }) + + it('(b) empty word after FROM → table names (current db first)', () => { + const result = buildSuggestions( + ctx({ word: '', tableRefs: [{ table: 'users' }], activeTable: { table: 'users' } }), + snapshot, + profile, + opts, + ) + expect(labels(result)).toContain('users') + expect(labels(result)).toContain('orders') + }) + + it('(c) after alias dot → columns only, kind column, detail data_type', () => { + const result = buildSuggestions( + ctx({ word: '', isAfterDot: true, qualifier: 'u.', activeTable: { table: 'users', alias: 'u' } }), + snapshot, + profile, + opts, + ) + expect(labels(result)).toEqual(['created_at', 'email', 'id']) + const col = result.find(r => r.label === 'email') + expect(col?.kind).toBe('column') + expect(col?.detail).toBe('text') + }) + + it('(c2) after alias dot with word prefix filters columns', () => { + const result = buildSuggestions( + ctx({ word: 'cre', isAfterDot: true, qualifier: 'u.', activeTable: { table: 'users', alias: 'u' } }), + snapshot, + profile, + opts, + ) + expect(labels(result)).toEqual(['created_at']) + }) + + it('(d) after schema dot → tables of that schema', () => { + const result = buildSuggestions( + ctx({ word: '', isAfterDot: true, qualifier: 'analytics.' }), + snapshot, + profile, + { ...opts, currentSchema: 'public' }, + ) + expect(labels(result)).toContain('events') + }) + + it('(e) noParenFunctions preserved: NOW without parens, CONCAT with', () => { + const result = buildSuggestions(ctx({ word: 'N' }), snapshot, profile, opts) + const now = result.find(r => r.label === 'NOW') + expect(now?.insertText).toBe('NOW') + const concat = buildSuggestions(ctx({ word: 'CONC' }), snapshot, profile, opts).find(r => r.label === 'CONCAT') + expect(concat?.insertText).toBe('CONCAT()') + }) + + it('(f) prefix filters tables', () => { + const result = buildSuggestions( + ctx({ word: 'us', tableRefs: [{ table: 'users' }], activeTable: { table: 'users' } }), + snapshot, + profile, + opts, + ) + expect(labels(result)).toContain('users') + expect(labels(result)).not.toContain('orders') + }) + + it('(g) empty schema data → keywords still present (graceful degradation)', () => { + const empty: SchemaSnapshot = { ...snapshot, hasSchemaData: false, databases: [], schemasByDb: {}, tablesByKey: {}, columnsByTable: {} } + const result = buildSuggestions(ctx({ word: 'SEL' }), empty, profile, opts) + expect(labels(result)).toContain('SELECT') + expect(labels(result)).not.toContain('users') + }) + + it('(g2) after dot with empty snapshot → empty, no throw', () => { + const empty: SchemaSnapshot = { ...snapshot, hasSchemaData: false, databases: [], schemasByDb: {}, tablesByKey: {}, columnsByTable: {} } + const result = buildSuggestions(ctx({ word: '', isAfterDot: true, qualifier: 'x.' }), empty, profile, opts) + expect(result).toEqual([]) + }) + + it('(h) >100 candidates → exactly 100', () => { + const bigSnapshot: SchemaSnapshot = { + ...snapshot, + tablesByKey: { app: Array.from({ length: 200 }, (_, i) => `table_${i}`) }, + } + const result = buildSuggestions(ctx({ word: 'table_' }), bigSnapshot, profile, opts) + expect(result.length).toBeLessThanOrEqual(100) + expect(result.length).toBe(100) + }) + + it('in comment → no suggestions', () => { + const result = buildSuggestions(ctx({ word: 'SEL', inComment: true }), snapshot, profile, opts) + expect(result).toEqual([]) + }) + + it('no keywords after a dot (only objects)', () => { + const result = buildSuggestions( + ctx({ word: '', isAfterDot: true, qualifier: 'u.', activeTable: { table: 'users', alias: 'u' } }), + snapshot, + profile, + opts, + ) + expect(result.every(r => r.kind !== 'keyword')).toBe(true) + }) +}) diff --git a/src/__tests__/sqlCompletion/dialects.test.ts b/src/__tests__/sqlCompletion/dialects.test.ts new file mode 100644 index 00000000..0e784270 --- /dev/null +++ b/src/__tests__/sqlCompletion/dialects.test.ts @@ -0,0 +1,85 @@ +/** + * @jest-environment node + */ +import { + getDialectProfile, + hasGrammar, + NO_PAREN_FUNCTIONS, + resolveMonacoDialect, + SQL_FUNCTIONS, + SQL_KEYWORDS, + SQL_TYPES, +} from '@/composables/sqlCompletion/dialects' + +describe('resolveMonacoDialect — formatter→monaco map', () => { + it('maps the five known families', () => { + expect(resolveMonacoDialect('postgresql')).toBe('pgsql') + expect(resolveMonacoDialect('mysql')).toBe('mysql') + expect(resolveMonacoDialect('tsql')).toBe('mssql') + expect(resolveMonacoDialect('plsql')).toBe('plsql') + expect(resolveMonacoDialect('sqlite')).toBe('sqlite') + }) + + it('maps mysql-family formatter ids to mysql', () => { + expect(resolveMonacoDialect('mariadb')).toBe('mysql') + expect(resolveMonacoDialect('tidb')).toBe('mysql') + }) + + it('maps postgresql-family ids to pgsql', () => { + expect(resolveMonacoDialect('redshift')).toBe('pgsql') + }) + + it('falls back to sql for unmapped formatter ids', () => { + for (const id of ['trino', 'snowflake', 'duckdb', 'clickhouse', 'hive', 'spark', 'bigquery', 'db2', 'hana', 'teradata', 'exasol', 'bogus', '']) { + expect(resolveMonacoDialect(id)).toBe('sql') + } + }) +}) + +describe('dialect profiles', () => { + it('exposes a profile for every SQLDialect id', () => { + for (const id of ['sql', 'mysql', 'pgsql', 'mssql', 'plsql', 'sqlite'] as const) { + expect(getDialectProfile(id).id).toBe(id) + } + }) + + it('mysql uses backtick quote char, others use double quote', () => { + expect(getDialectProfile('mysql').quoteChar).toBe('`') + expect(getDialectProfile('pgsql').quoteChar).toBe('"') + expect(getDialectProfile('sql').quoteChar).toBe('"') + }) + + it('sqlite does not support schema qualification, pgsql does', () => { + expect(getDialectProfile('sqlite').supportsSchemaQualification).toBe(false) + expect(getDialectProfile('pgsql').supportsSchemaQualification).toBe(true) + }) + + it('hasGrammar is true only for dialects with monaco grammar contributions', () => { + expect(hasGrammar('sql')).toBe(true) + expect(hasGrammar('mysql')).toBe(true) + expect(hasGrammar('pgsql')).toBe(true) + expect(hasGrammar('mssql')).toBe(false) + expect(hasGrammar('plsql')).toBe(false) + expect(hasGrammar('sqlite')).toBe(false) + }) +}) + +describe('migrated keyword/type/function lists (single source of truth)', () => { + it('sql profile keyword list is non-empty and matches SQL_KEYWORDS', () => { + expect(getDialectProfile('sql').keywords).toBe(SQL_KEYWORDS) + expect(SQL_KEYWORDS.length).toBe(77) + expect(SQL_KEYWORDS).toContain('SELECT') + expect(SQL_KEYWORDS).toContain('DENSE_RANK') + }) + + it('types and functions lists migrated fully', () => { + expect(SQL_TYPES).toContain('BIGSERIAL') + expect(SQL_FUNCTIONS).toContain('LOG') + expect(SQL_FUNCTIONS.length).toBe(34) + }) + + it('noParenFunctions is exactly the four no-paren functions', () => { + expect(NO_PAREN_FUNCTIONS).toEqual(['NOW', 'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP']) + expect(getDialectProfile('sql').noParenFunctions).toBe(NO_PAREN_FUNCTIONS) + }) +}) diff --git a/src/__tests__/sqlCompletion/metadata.test.ts b/src/__tests__/sqlCompletion/metadata.test.ts new file mode 100644 index 00000000..6e9b1a62 --- /dev/null +++ b/src/__tests__/sqlCompletion/metadata.test.ts @@ -0,0 +1,169 @@ +import type { StoreMetadata } from '@/composables/sqlCompletion/metadata' +/** + * @jest-environment node + */ +import { invoke } from '@tauri-apps/api/core' +import { getMetadataService, SchemaMetadataService, setMetadataServiceForTests } from '@/composables/sqlCompletion/metadata' + +jest.mock('@tauri-apps/api/core', () => ({ + invoke: jest.fn(), +})) + +const mockInvoke = invoke as jest.MockedFunction + +const fakeStore: StoreMetadata = { + databases: [{ name: 'app', is_system: false }, { name: 'postgres', is_system: true }], + schemas: { app: ['public', 'analytics'] }, + tables: { + 'app': [{ name: 'users', schema: 'public' }, { name: 'orders', schema: 'public' }], + 'app.public': [{ name: 'users', schema: 'public' }, { name: 'orders', schema: 'public' }], + }, +} + +function makeService(opts: { maxTables?: number, concurrency?: number } = {}) { + return new SchemaMetadataService({ + getMetadata: () => fakeStore, + ...opts, + }) +} + +describe('schemaMetadataService — level A (connection objects)', () => { + it('returns databases with isSystem flag', () => { + const svc = makeService() + expect(svc.getDatabases('c1')).toEqual([ + { name: 'app', isSystem: false }, + { name: 'postgres', isSystem: true }, + ]) + }) + + it('returns schemas for a database', () => { + const svc = makeService() + expect(svc.getSchemas('c1', 'app')).toEqual(['public', 'analytics']) + }) + + it('returns tables for db and db.schema keys', () => { + const svc = makeService() + expect(svc.getTables('c1', 'app')).toEqual(['users', 'orders']) + expect(svc.getTables('c1', 'app', 'public')).toEqual(['users', 'orders']) + }) + + it('returns empty for missing metadata', () => { + const svc = new SchemaMetadataService({ getMetadata: () => null }) + expect(svc.getDatabases('c9')).toEqual([]) + expect(svc.getSchemas('c9', 'x')).toEqual([]) + expect(svc.getTables('c9', 'x')).toEqual([]) + expect(svc.getColumns('c9', 'x', undefined, 't')).toEqual([]) + }) +}) + +describe('schemaMetadataService — level B (column cache)', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('(a) prefetchColumns stores columns under the exact key', async () => { + mockInvoke.mockResolvedValue([ + { name: 'id', data_type: 'int4', nullable: false, is_primary_key: true, is_auto_increment: true }, + { name: 'email', data_type: 'text', nullable: true, is_primary_key: false, is_auto_increment: false }, + ]) + const svc = makeService() + await svc.prefetchColumns('c1', 'app', 'public', ['users']) + expect(mockInvoke).toHaveBeenCalledWith('list_columns', { + connectionId: 'c1', + database: 'app', + schema: 'public', + tableName: 'users', + }) + expect(svc.getColumns('c1', 'app', 'public', 'users')).toEqual([ + { name: 'id', dataType: 'int4', isPrimaryKey: true }, + { name: 'email', dataType: 'text', isPrimaryKey: false }, + ]) + expect(svc.getColumns('c1', 'app', 'public', 'other')).toEqual([]) + }) + + it('(b) concurrency: max in-flight invoke ≤ concurrency (5)', async () => { + let inFlight = 0 + let maxInFlight = 0 + const tables = Array.from({ length: 10 }, (_, i) => `t${i}`) + mockInvoke.mockImplementation(async () => { + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise(r => setTimeout(r, 5)) + inFlight-- + return [] + }) + const svc = makeService({ concurrency: 5 }) + await svc.prefetchColumns('c1', 'app', 'public', tables) + expect(maxInFlight).toBeLessThanOrEqual(5) + expect(mockInvoke).toHaveBeenCalledTimes(10) + }) + + it('(c) prefetch caps at maxTables (100)', async () => { + const tables = Array.from({ length: 150 }, (_, i) => `t${i}`) + mockInvoke.mockResolvedValue([]) + const svc = makeService({ maxTables: 100 }) + await svc.prefetchColumns('c1', 'app', 'public', tables) + expect(mockInvoke).toHaveBeenCalledTimes(100) + }) + + it('(d) getColumns returns cached without re-invoke', async () => { + mockInvoke.mockResolvedValue([]) + const svc = makeService() + await svc.prefetchColumns('c1', 'app', 'public', ['users']) + expect(mockInvoke).toHaveBeenCalledTimes(1) + svc.getColumns('c1', 'app', 'public', 'users') + expect(mockInvoke).toHaveBeenCalledTimes(1) + }) + + it('(e) getColumns on absent key triggers a lazy fetch', async () => { + mockInvoke.mockResolvedValue([{ name: 'id', data_type: 'int4', nullable: false, is_primary_key: false, is_auto_increment: false }]) + const svc = makeService() + const cols = await svc.fetchTableColumns('c1', 'app', 'public', 'orders') + expect(cols).toEqual([{ name: 'id', dataType: 'int4', isPrimaryKey: false }]) + expect(mockInvoke).toHaveBeenCalledTimes(1) + }) + + it('(f) clearColumnCache(connId) removes only that connId keys', async () => { + mockInvoke.mockResolvedValue([]) + const svc = makeService() + await svc.prefetchColumns('c1', 'app', 'public', ['users']) + await svc.prefetchColumns('c2', 'app', 'public', ['users']) + svc.clearColumnCache('c1') + expect(svc.getColumns('c1', 'app', 'public', 'users')).toEqual([]) + // c2 untouched (cache intact, no re-invoke needed to verify absence of fetch) + expect(mockInvoke).toHaveBeenCalledTimes(2) + }) + + it('(g) invoke rejection → prefetch resolves without throwing', async () => { + mockInvoke.mockRejectedValue(new Error('backend down')) + const svc = makeService() + await expect(svc.prefetchColumns('c1', 'app', 'public', ['users'])).resolves.toBeUndefined() + expect(svc.getColumns('c1', 'app', 'public', 'users')).toEqual([]) + }) +}) + +describe('schemaMetadataService — level C (derived tables)', () => { + it('resolves and clears derived alias/CTE names', () => { + const svc = makeService() + svc.setDerivedTables({ u: 'users', cte1: 'orders' }) + expect(svc.resolveDerived('u')).toBe('users') + expect(svc.resolveDerived('cte1')).toBe('orders') + svc.clearAll() + expect(svc.resolveDerived('u')).toBeUndefined() + }) +}) + +describe('schemaMetadataService — singleton', () => { + afterEach(() => { + setMetadataServiceForTests(null) + }) + + it('getMetadataService returns a singleton; setMetadataServiceForTests swaps it', () => { + const a = getMetadataService() + const b = getMetadataService() + expect(a).toBe(b) + const fake = makeService() + setMetadataServiceForTests(fake) + expect(getMetadataService()).toBe(fake) + }) +}) diff --git a/src/__tests__/sqlCompletion/provider.test.ts b/src/__tests__/sqlCompletion/provider.test.ts new file mode 100644 index 00000000..5520c1ec --- /dev/null +++ b/src/__tests__/sqlCompletion/provider.test.ts @@ -0,0 +1,195 @@ +import type { StoreMetadata } from '@/composables/sqlCompletion/metadata' +import type { SchemaSnapshot } from '@/composables/sqlCompletion/types' +import { analyzeCompletionContext } from '@/composables/sqlCompletion/analyzer' +import { buildSuggestions } from '@/composables/sqlCompletion/builder' +import { getDialectProfile } from '@/composables/sqlCompletion/dialects' +/** + * @jest-environment node + */ +import { SchemaMetadataService } from '@/composables/sqlCompletion/metadata' +import { createProvider, emptySnapshot, SQL_DIALECT_IDS } from '@/composables/sqlCompletion/provider' + +const fakeStore: StoreMetadata = { + databases: [{ name: 'app', is_system: false }], + schemas: { app: ['public'] }, + tables: { + 'app': [{ name: 'users', schema: 'public' }], + 'app.public': [{ name: 'users', schema: 'public' }], + }, +} + +function makeService() { + return new SchemaMetadataService({ getMetadata: () => fakeStore }) +} + +const deps = { + metadataService: makeService(), + analyze: analyzeCompletionContext, + build: buildSuggestions, + profiles: getDialectProfile, +} + +type FakeModel = { + getValue: () => string + getOffsetAt: (pos: { lineNumber: number, column: number }) => number + getWordUntilPosition: (pos: { lineNumber: number, column: number }) => { startColumn: number, endColumn: number } +} + +function makeModel(text: string): FakeModel { + const lineStarts = [0] + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') + lineStarts.push(i + 1) + } + return { + getValue: () => text, + getOffsetAt: (pos) => { + const start = lineStarts[pos.lineNumber - 1] ?? 0 + return start + pos.column - 1 + }, + getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }), + } +} + +function makeFakeMonaco() { + const registered: { language: string, provider: unknown }[] = [] + return { + languages: { + registerCompletionItemProvider: jest.fn((language: string, provider: unknown) => { + registered.push({ language, provider }) + return { dispose: jest.fn() } + }), + CompletionItemKind: { + Keyword: 0, + Function: 1, + TypeParameter: 2, + Class: 3, + Field: 4, + Module: 5, + Folder: 6, + }, + }, + registered, + } +} + +type ProviderHandler = { + provideCompletionItems: (model: unknown, position: { lineNumber: number, column: number }) => { suggestions: Array<{ label: string, kind: number }> } + resolveCompletionItem: (item: unknown) => Promise +} + +function providerOf(fake: ReturnType, id: string): ProviderHandler { + return fake.registered.find(r => r.language === id)?.provider as unknown as ProviderHandler +} + +describe('completion provider', () => { + it('(a) registers a provider for all 6 SQL dialect ids', () => { + const fake = makeFakeMonaco() + const provider = createProvider(fake as never, deps) + const disposables = provider.register() + expect(fake.languages.registerCompletionItemProvider).toHaveBeenCalledTimes(6) + expect(fake.registered.map(r => r.language)).toEqual([...SQL_DIALECT_IDS]) + expect(disposables).toHaveLength(6) + disposables.forEach(d => d.dispose()) + }) + + it('(b) no context → sync empty suggestions, no throw', () => { + const fake = makeFakeMonaco() + const provider = createProvider(fake as never, deps) + provider.register() + const model = makeModel('SELECT ') + const handler = providerOf(fake, 'sql') + const result = handler.provideCompletionItems(model, { lineNumber: 1, column: 8 }) + expect(result).toEqual({ suggestions: [] }) + }) + + it('(b2) setContext with snapshot → table suggestion appears (empty word)', () => { + const fake = makeFakeMonaco() + const provider = createProvider(fake as never, deps) + provider.register() + const model = makeModel('SELECT FROM ') + provider.setContext(model as never, { connectionId: 'c1', database: 'app', schema: 'public' }) + const handler = providerOf(fake, 'sql') + const result = handler.provideCompletionItems(model, { lineNumber: 1, column: 13 }) + const labels = result.suggestions.map((s: { label: string }) => s.label) + expect(labels).toContain('users') + }) + + it('(c) after alias dot → column suggestions with kind Field', () => { + const fake = makeFakeMonaco() + const svc = new SchemaMetadataService({ getMetadata: () => fakeStore }) + const localProvider = createProvider(fake as never, { ...deps, metadataService: svc }) + localProvider.register() + const model = makeModel('SELECT u. FROM users u') + localProvider.setContext(model as never, { connectionId: 'c1', database: 'app', schema: 'public' }) + const snapshot: SchemaSnapshot = { + databases: [{ name: 'app', isSystem: false }], + schemasByDb: {}, + tablesByKey: {}, + columnsByTable: { + 'c1|app|public|users': [{ name: 'id', dataType: 'int4' }, { name: 'email', dataType: 'text' }], + }, + derivedTables: {}, + hasSchemaData: true, + } + localProvider.contexts.set(model as never, { + connId: 'c1', + database: 'app', + schema: 'public', + dialectId: 'sql', + snapshot, + }) + const handler = providerOf(fake, 'sql') + const result = handler.provideCompletionItems(model, { lineNumber: 1, column: 10 }) + const labels = result.suggestions.map((s: { label: string }) => s.label) + expect(labels).toEqual(['email', 'id']) + expect(result.suggestions[0].kind).toBe(4) + }) + + it('(c2) active alias column lookup works through public API without private access', () => { + const fake = makeFakeMonaco() + const svc = makeService() + // Pre-populate the column cache via the public API. + svc.prefetchColumns = jest.fn().mockResolvedValue(undefined) as never + const provider = createProvider(fake as never, { ...deps, metadataService: svc }) + provider.register() + const model = makeModel('SELECT u.em FROM users u') + provider.setContext(model as never, { connectionId: 'c1', database: 'app', schema: 'public' }) + const handler = providerOf(fake, 'sql') + const result = handler.provideCompletionItems(model, { lineNumber: 1, column: 14 }) + expect(Array.isArray(result.suggestions)).toBe(true) + }) + + it('(d) WeakMap entry removed on clearModel', () => { + const fake = makeFakeMonaco() + const provider = createProvider(fake as never, deps) + provider.register() + const model = makeModel('SEL') + provider.setContext(model as never, { connectionId: 'c1', database: 'app', schema: 'public' }) + expect(provider.contexts.has(model as never)).toBe(true) + provider.clearModel(model as never) + expect(provider.contexts.has(model as never)).toBe(false) + }) + + it('(e) resolveCompletionItem returns the item as-is (async signature)', async () => { + const fake = makeFakeMonaco() + const provider = createProvider(fake as never, deps) + provider.register() + const item = { label: 'users', kind: 3, insertText: 'users' } + const handler = providerOf(fake, 'sql') + const resolved = await handler.resolveCompletionItem(item) + expect(resolved).toEqual(item) + }) + + it('(g) null model → empty suggestions, no throw', () => { + const fake = makeFakeMonaco() + const provider = createProvider(fake as never, deps) + provider.register() + const handler = providerOf(fake, 'sql') + expect(handler.provideCompletionItems(null, { lineNumber: 1, column: 1 })).toEqual({ suggestions: [] }) + }) + + it('emptySnapshot has hasSchemaData false', () => { + expect(emptySnapshot().hasSchemaData).toBe(false) + }) +}) diff --git a/src/__tests__/sqlCompletion/types.test.ts b/src/__tests__/sqlCompletion/types.test.ts new file mode 100644 index 00000000..241094cc --- /dev/null +++ b/src/__tests__/sqlCompletion/types.test.ts @@ -0,0 +1,69 @@ +import type { + ColumnSuggestion, + CompletionContext, + CompletionContextInput, + DatabaseRef, + DialectProfile, + SchemaSnapshot, + Suggestion, + TableRef, +} from '@/composables/sqlCompletion/types' +/** + * @jest-environment node + */ +import { GRAMMAR_DIALECTS } from '@/composables/sqlCompletion/types' + +describe('sqlCompletion domain types', () => { + it('satisfies the CompletionContext shape', () => { + const ctx: CompletionContext = { + word: 'us', + tableRefs: [{ table: 'users', alias: 'u' }], + activeTable: { table: 'users', alias: 'u' }, + qualifier: 'u.', + isAfterDot: true, + inComment: false, + } + expect(ctx.word).toBe('us') + expect(ctx.isAfterDot).toBe(true) + }) + + it('satisfies the SchemaSnapshot shape with column cache entries', () => { + const snapshot: SchemaSnapshot = { + databases: [{ name: 'app', isSystem: false }], + schemasByDb: { app: ['public'] }, + tablesByKey: { 'app': ['users'], 'app.public': ['orders'] }, + columnsByTable: { + 'c1|app|public|orders': [{ name: 'id', dataType: 'int4', isPrimaryKey: true }], + }, + derivedTables: { x: 'users' }, + hasSchemaData: true, + } + expect(snapshot.tablesByKey['app.public']).toContain('orders') + }) + + it('satisfies the DialectProfile shape with noParenFunctions', () => { + const profile: DialectProfile = { + id: 'pgsql', + quoteChar: '"', + supportsSchemaQualification: true, + keywords: ['SELECT'], + functions: ['NOW'], + types: ['INT'], + noParenFunctions: ['NOW', 'CURRENT_DATE'], + } + expect(profile.noParenFunctions).toContain('NOW') + }) + + it('satisfies Suggestion / TableRef / DatabaseRef / ColumnSuggestion / CompletionContextInput shapes', () => { + const s: Suggestion = { label: 'users', kind: 'table', insertText: 'users', sortPrefix: 1 } + const t: TableRef = { table: 'users', alias: 'u', schema: 'public' } + const d: DatabaseRef = { name: 'app', isSystem: false } + const c: ColumnSuggestion = { name: 'id', dataType: 'int4', isPrimaryKey: true } + const input: CompletionContextInput = { connectionId: 'c1', database: 'app', schema: 'public' } + expect([s.kind, t.table, d.name, c.name, input.connectionId]).toEqual(['table', 'users', 'app', 'id', 'c1']) + }) + + it('gRAMMAR_DIALECTS contains exactly the dialects with monaco grammar contributions', () => { + expect(GRAMMAR_DIALECTS).toEqual(['sql', 'mysql', 'pgsql']) + }) +}) diff --git a/src/components/SQLEditor.vue b/src/components/SQLEditor.vue index 24ee07ed..f84e08fe 100644 --- a/src/components/SQLEditor.vue +++ b/src/components/SQLEditor.vue @@ -1,4 +1,5 @@