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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/__tests__/sqlCompletion/analyzer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @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')
})

it('(i) schema-qualified qualifier with unmatched FROM table → activeTable null (no fallback)', () => {
const text = 'SELECT u.name, public.us FROM users u'
const ctx = analyzeCompletionContext(text, text.indexOf('public.us') + 'public.us'.length)
expect(ctx.qualifier).toBe('public.')
expect(ctx.isAfterDot).toBe(true)
expect(ctx.activeTable).toBeNull()
})

it('(j) quoted qualifier matches the table name (unquoted before compare)', () => {
const text = 'SELECT "users". FROM users'
const ctx = analyzeCompletionContext(text, text.indexOf('"users".') + '"users".'.length)
expect(ctx.qualifier).toBe('"users".')
expect(ctx.isAfterDot).toBe(true)
expect(ctx.activeTable?.table).toBe('users')
})

it('(k) multi-segment qualifier never matches a bare FROM table', () => {
const text = 'SELECT * FROM app.public.'
const ctx = analyzeCompletionContext(text, text.length)
expect(ctx.qualifier).toBe('app.public.')
expect(ctx.isAfterDot).toBe(true)
expect(ctx.activeTable).toBeNull()
})
})
182 changes: 182 additions & 0 deletions src/__tests__/sqlCompletion/builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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>): 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('(d2) schema-qualified word → schema tables, never the FROM-table columns', () => {
// Regression: `public.us` must offer tables in `public`, not columns of
// the last FROM table (`users`) — activeTable is null for unmatched
// qualifiers, so the builder falls through to the schema branch.
const snap: SchemaSnapshot = {
...snapshot,
tablesByKey: { ...snapshot.tablesByKey, 'app.public': ['users', 'orders', 'user_events'] },
columnsByTable: {
...snapshot.columnsByTable,
'c1|app|public|users': [
{ name: 'id', dataType: 'int4' },
{ name: 'user_id', dataType: 'int4' },
],
},
}
const result = buildSuggestions(
ctx({
word: 'user',
isAfterDot: true,
qualifier: 'public.',
tableRefs: [{ table: 'users', alias: 'u' }],
activeTable: null,
}),
snap,
profile,
opts,
)
expect(labels(result)).toEqual(['user_events', 'users'])
expect(result.every(r => r.kind !== 'column')).toBe(true)
})

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)
})
})
85 changes: 85 additions & 0 deletions src/__tests__/sqlCompletion/dialects.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading