diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index f447f1b..930f3cf 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -3,6 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const SOURCE_EXTENSIONS = ['.ts', '.cts', '.mts']; +const SOURCE_DIRECTORIES = ['src', 'ts-plugin/src']; const RULES = [ { @@ -42,6 +43,23 @@ const RULES = [ forbidden: ['src/compiler/legacy.ts'], name: 'next compiler isolation', }, + { + from: 'src/tooling/', + forbidden: [ + 'src/compiler/next/', + 'src/compiler/legacy.ts', + 'src/language/', + 'src/runtime/', + 'src/public/', + ], + name: 'tooling isolation', + }, + { + from: 'ts-plugin/src/', + forbidden: ['src/compiler/'], + allowTargets: ['src/compiler/analysis.ts'], + name: 'editor plugin contract', + }, ]; function normalize(path) { @@ -101,7 +119,11 @@ function matchesPrefix(path, prefix) { export function checkArchitecture(root) { const violations = []; - for (const sourceFile of collectSourceFiles(root)) { + const sourceFiles = SOURCE_DIRECTORIES.flatMap((directory) => + collectSourceFiles(root, directory), + ); + + for (const sourceFile of sourceFiles) { const source = readFileSync(join(root, sourceFile), 'utf8'); for (const specifier of importSpecifiers(source)) { const target = resolveProjectImport(root, sourceFile, specifier); @@ -109,7 +131,10 @@ export function checkArchitecture(root) { if (!sourceFile.startsWith(rule.from) || rule.allow?.includes(sourceFile)) { continue; } - if (rule.forbidden.some((prefix) => matchesPrefix(target, prefix))) { + const targetAllowed = rule.allowTargets?.some((prefix) => + matchesPrefix(target, prefix), + ); + if (!targetAllowed && rule.forbidden.some((prefix) => matchesPrefix(target, prefix))) { violations.push(`${sourceFile} -> ${target} violates ${rule.name}`); } } diff --git a/src/cli/index.ts b/src/cli/index.ts index b40a95d..7384874 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; -import type { Dialect } from './types.js'; -import { runGenerate } from './generate.js'; +import type { Dialect } from '../tooling/schema-generator/types.js'; +import { generateSchema } from '../tooling/schema-generator/generate.js'; const DIALECTS: Dialect[] = ['postgres', 'mysql', 'sqlite', 'mssql']; @@ -171,7 +171,7 @@ async function main(): Promise { const out = flags.get('out') ?? './schema.ts'; - const result = await runGenerate({ + const result = await generateSchema({ url, out, dialect: parseDialect(flags.get('dialect')), diff --git a/src/cli/types.ts b/src/cli/types.ts deleted file mode 100644 index 1cfd3f3..0000000 --- a/src/cli/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface ColumnSchema { - name: string; - tsType: string; - nullable: boolean; -} - -export interface TableSchema { - name: string; - columns: ColumnSchema[]; -} - -export type Dialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql'; - -export interface ConnectionInfo { - url: string; - schema?: string | undefined; -} diff --git a/src/compiler/analysis.ts b/src/compiler/analysis.ts new file mode 100644 index 0000000..89b2435 --- /dev/null +++ b/src/compiler/analysis.ts @@ -0,0 +1,5 @@ +export type { + CompletionContext, + EditorDiagnostic, + QueryAnalysis, +} from './contracts/editor.js'; diff --git a/src/compiler/contracts/editor.ts b/src/compiler/contracts/editor.ts new file mode 100644 index 0000000..27ffd98 --- /dev/null +++ b/src/compiler/contracts/editor.ts @@ -0,0 +1,23 @@ +import type { + DiagnosticLocation, + QueryDiagnosticCode, +} from './diagnostic.js'; + +export interface EditorDiagnostic< + Code extends QueryDiagnosticCode = QueryDiagnosticCode, +> { + code: Code; + message: string; + location: DiagnosticLocation; + reference: string; +} + +export interface CompletionContext { + clause: 'select' | 'from' | 'join-on' | 'where' | 'having' | 'unknown'; + qualifier?: string | undefined; +} + +export interface QueryAnalysis { + diagnostics: readonly EditorDiagnostic[]; + context: CompletionContext; +} diff --git a/src/cli/dialects/mssql.ts b/src/tooling/introspection/mssql.ts similarity index 96% rename from src/cli/dialects/mssql.ts rename to src/tooling/introspection/mssql.ts index a3917b4..97b77a0 100644 --- a/src/cli/dialects/mssql.ts +++ b/src/tooling/introspection/mssql.ts @@ -1,4 +1,4 @@ -import type { ConnectionInfo, TableSchema } from '../types.js'; +import type { IntrospectionOptions, TableSchema } from '../schema-generator/types.js'; const MSSQL_SCALAR_TYPES: Record = { int: 'number', @@ -126,7 +126,7 @@ export function mssqlUrlToConfig(url: string): MssqlConnectionConfig { return config; } -export async function introspectMssql(connection: ConnectionInfo): Promise { +export async function introspectMssql(connection: IntrospectionOptions): Promise { let connect: typeof import('mssql').connect; try { ({ connect } = await import('mssql')); diff --git a/src/cli/dialects/mysql.ts b/src/tooling/introspection/mysql.ts similarity index 95% rename from src/cli/dialects/mysql.ts rename to src/tooling/introspection/mysql.ts index aae428e..6aaf46e 100644 --- a/src/cli/dialects/mysql.ts +++ b/src/tooling/introspection/mysql.ts @@ -1,4 +1,4 @@ -import type { ConnectionInfo, TableSchema } from '../types.js'; +import type { IntrospectionOptions, TableSchema } from '../schema-generator/types.js'; const MYSQL_SCALAR_TYPES: Record = { tinyint: 'number', @@ -57,7 +57,7 @@ function readField(row: MysqlColumnRow, name: string): string { return typeof value === 'string' ? value : ''; } -export async function introspectMysql(connection: ConnectionInfo): Promise { +export async function introspectMysql(connection: IntrospectionOptions): Promise { let createConnection: typeof import('mysql2/promise').createConnection; try { ({ createConnection } = await import('mysql2/promise')); diff --git a/src/cli/dialects/postgres.ts b/src/tooling/introspection/postgres.ts similarity index 95% rename from src/cli/dialects/postgres.ts rename to src/tooling/introspection/postgres.ts index 21e5e95..da8bb7c 100644 --- a/src/cli/dialects/postgres.ts +++ b/src/tooling/introspection/postgres.ts @@ -1,4 +1,4 @@ -import type { ConnectionInfo, TableSchema } from '../types.js'; +import type { IntrospectionOptions, TableSchema } from '../schema-generator/types.js'; const POSTGRES_SCALAR_TYPES: Record = { int2: 'number', @@ -82,7 +82,7 @@ const POSTGRES_ARRAY_TYPE_OVERRIDES: Record = { // JSON.stringify emits a TypeScript-valid double-quoted literal and escapes // backslashes, quotes and control characters - hand-rolled quoting only // escaped `'`, so a label ending in a backslash escaped the closing quote and -// the generated schema.ts stopped parsing. renderKey in src/cli/codegen.ts +// the generated schema.ts stopped parsing. renderKey in schema-generator/codegen.ts // already quotes identifiers this way. function renderEnumUnion(labels: string[]): string { return labels.map((label) => JSON.stringify(label)).join(' | '); @@ -129,7 +129,7 @@ interface PgEnumRow { enumlabel: string; } -export async function introspectPostgres(connection: ConnectionInfo): Promise { +export async function introspectPostgres(connection: IntrospectionOptions): Promise { let PoolCtor: typeof import('pg').Pool; try { ({ Pool: PoolCtor } = await import('pg')); diff --git a/src/cli/redact.ts b/src/tooling/introspection/redact.ts similarity index 90% rename from src/cli/redact.ts rename to src/tooling/introspection/redact.ts index 84e7a6b..38ec7d2 100644 --- a/src/cli/redact.ts +++ b/src/tooling/introspection/redact.ts @@ -16,8 +16,7 @@ const URL_CREDENTIALS_PATTERN = /(\/\/|(?<=[a-z][a-z0-9+.-]):\/)[^/?#]*@/i; const DSN_PASSWORD_PATTERN = /((?:^|;)\s*(?:pwd|password)\s*=)("[^"]*"|\{[^}]*\}|'[^']*'|[^;]*)/gi; -// Lives here rather than in generate.ts so the dialect modules can call it -// without importing the module that imports them (issue #278). +// Lives with introspection so dialect modules do not depend on schema generation. export function redactCredentials(url: string): string { return url .replace(URL_CREDENTIALS_PATTERN, (_match, separator: string) => `${separator}***@`) diff --git a/src/cli/dialects/sqlite.ts b/src/tooling/introspection/sqlite.ts similarity index 96% rename from src/cli/dialects/sqlite.ts rename to src/tooling/introspection/sqlite.ts index 5ceb6b7..d6cd08e 100644 --- a/src/cli/dialects/sqlite.ts +++ b/src/tooling/introspection/sqlite.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; -import type { ConnectionInfo, TableSchema } from '../types.js'; -import { redactCredentials } from '../redact.js'; +import type { IntrospectionOptions, TableSchema } from '../schema-generator/types.js'; +import { redactCredentials } from './redact.js'; export function mapSqliteType(declaredType: string): string { const type = declaredType.toUpperCase().trim(); @@ -102,7 +102,7 @@ function isMemoryDatabase(path: string): boolean { return path === ':memory:' || path.startsWith('file::memory:'); } -export async function introspectSqlite(connection: ConnectionInfo): Promise { +export async function introspectSqlite(connection: IntrospectionOptions): Promise { let DatabaseSyncCtor: typeof import('node:sqlite').DatabaseSync; try { ({ DatabaseSync: DatabaseSyncCtor } = await import('node:sqlite')); diff --git a/src/cli/codegen.ts b/src/tooling/schema-generator/codegen.ts similarity index 90% rename from src/cli/codegen.ts rename to src/tooling/schema-generator/codegen.ts index b158bab..db94c64 100644 --- a/src/cli/codegen.ts +++ b/src/tooling/schema-generator/codegen.ts @@ -16,7 +16,7 @@ function renderTable(table: TableSchema): string { return ` ${renderKey(table.name)}: {\n${columns}\n };`; } -export function renderSchema(tables: TableSchema[]): string { +export function renderSchema(tables: readonly TableSchema[]): string { const body = tables.map(renderTable).join('\n'); return `export interface DB {\n${body}\n}\n`; } diff --git a/src/cli/generate.ts b/src/tooling/schema-generator/generate.ts similarity index 81% rename from src/cli/generate.ts rename to src/tooling/schema-generator/generate.ts index e364854..c1da9b2 100644 --- a/src/cli/generate.ts +++ b/src/tooling/schema-generator/generate.ts @@ -1,26 +1,23 @@ import { readFile, writeFile } from 'node:fs/promises'; -import type { ConnectionInfo, Dialect, TableSchema } from './types.js'; +import type { + Dialect, + GenerateSchemaOptions, + GenerateSchemaResult, + Introspector, + TableSchema, +} from './types.js'; import { renderSchema } from './codegen.js'; -import { introspectPostgres } from './dialects/postgres.js'; -import { introspectMysql } from './dialects/mysql.js'; -import { introspectSqlite } from './dialects/sqlite.js'; -import { introspectMssql } from './dialects/mssql.js'; -import { redactCredentials } from './redact.js'; - -export interface GenerateOptions { - url: string; - out: string; - dialect?: Dialect | undefined; - schema?: string | undefined; - tables?: string[] | undefined; - exclude?: string[] | undefined; - check?: boolean | undefined; -} - -export type GenerateResult = - | { kind: 'written'; warnings?: string[] } - | { kind: 'upToDate'; warnings?: string[] } - | { kind: 'drift'; summary: string; warnings?: string[] }; +import { introspectPostgres } from '../introspection/postgres.js'; +import { introspectMysql } from '../introspection/mysql.js'; +import { introspectSqlite } from '../introspection/sqlite.js'; +import { introspectMssql } from '../introspection/mssql.js'; +import { redactCredentials } from '../introspection/redact.js'; + +export type { + GenerateSchemaOptions, + GenerateSchemaResult, + Introspector, +} from './types.js'; const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i; @@ -38,7 +35,7 @@ const MISTYPED_URL_CREDENTIALS_PATTERN = /^(?![a-z]:[/\\])[a-z][a-z0-9+.-]*:\/{1 const ADO_CREDENTIALS_PATTERN = /(^|;)\s*(uid|user id|pwd|password|database|initial catalog|trusted_connection|integrated security|driver|dsn)\s*=/i; -export { redactCredentials } from './redact.js'; +export { redactCredentials } from '../introspection/redact.js'; function unrecognizedUrlError(url: string): Error { return new Error( @@ -81,11 +78,11 @@ export function detectDialect(url: string): Dialect { return 'sqlite'; } -const INTROSPECTORS: Record Promise> = { - postgres: introspectPostgres, - mysql: introspectMysql, - sqlite: introspectSqlite, - mssql: introspectMssql, +const INTROSPECTORS: Record = { + postgres: { introspect: introspectPostgres }, + mysql: { introspect: introspectMysql }, + sqlite: { introspect: introspectSqlite }, + mssql: { introspect: introspectMssql }, }; // A name in --table/--exclude that matches nothing is a typo, and silently @@ -100,7 +97,7 @@ function unmatchedNames(requested: string[] | undefined, tables: TableSchema[]): return requested.filter((name) => !available.has(name.toLowerCase())); } -function filterTables(tables: TableSchema[], options: GenerateOptions): TableSchema[] { +function filterTables(tables: TableSchema[], options: GenerateSchemaOptions): TableSchema[] { const include = options.tables?.map((name) => name.toLowerCase()); const exclude = options.exclude?.map((name) => name.toLowerCase()); @@ -153,11 +150,18 @@ function summarizeDrift(existing: string | null, generated: string): string { return 'differs only in trailing whitespace or line endings.'; } -export async function runGenerate(options: GenerateOptions): Promise { +export async function generateSchema( + options: GenerateSchemaOptions, + injectedIntrospector?: Introspector, +): Promise { const dialect = options.dialect ?? detectDialect(options.url); - const connection: ConnectionInfo = { url: options.url, schema: options.schema }; - - const introspected = await INTROSPECTORS[dialect](connection); + const introspector = injectedIntrospector ?? INTROSPECTORS[dialect]; + const introspected = await introspector.introspect({ + url: options.url, + schema: options.schema, + tables: options.tables, + exclude: options.exclude, + }); if (introspected.length === 0) { throw new Error('No tables found. Check the connection URL and --schema, if provided.'); diff --git a/src/tooling/schema-generator/types.ts b/src/tooling/schema-generator/types.ts new file mode 100644 index 0000000..e0d3097 --- /dev/null +++ b/src/tooling/schema-generator/types.ts @@ -0,0 +1,34 @@ +export interface ColumnSchema { + name: string; + tsType: string; + nullable: boolean; +} + +export interface TableSchema { + name: string; + columns: ColumnSchema[]; +} + +export type Dialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql'; + +export interface IntrospectionOptions { + url: string; + schema?: string | undefined; + tables?: string[] | undefined; + exclude?: string[] | undefined; +} + +export interface Introspector { + introspect(options: IntrospectionOptions): Promise; +} + +export interface GenerateSchemaOptions extends IntrospectionOptions { + out: string; + dialect?: Dialect | undefined; + check?: boolean | undefined; +} + +export type GenerateSchemaResult = + | { kind: 'written'; warnings?: string[] } + | { kind: 'upToDate'; warnings?: string[] } + | { kind: 'drift'; summary: string; warnings?: string[] }; diff --git a/tests/architecture/dependencies.test.mjs b/tests/architecture/dependencies.test.mjs index f6bec69..69c3877 100644 --- a/tests/architecture/dependencies.test.mjs +++ b/tests/architecture/dependencies.test.mjs @@ -44,4 +44,42 @@ describe('architecture dependencies', () => { rmSync(root, { recursive: true, force: true }); } }); + + it('reports tooling imports from compiler implementation internals', () => { + const root = mkdtempSync(join(tmpdir(), 'owlsql-architecture-')); + mkdirSync(join(root, 'src', 'tooling'), { recursive: true }); + mkdirSync(join(root, 'src', 'compiler', 'next'), { recursive: true }); + writeFileSync(join(root, 'src', 'compiler', 'next', 'index.ts'), 'export type Next = string;\n'); + writeFileSync( + join(root, 'src', 'tooling', 'invalid.ts'), + "import type { Next } from '../compiler/next/index.js';\n", + ); + + try { + expect(checkArchitecture(root)).toEqual([ + 'src/tooling/invalid.ts -> src/compiler/next/index.ts violates tooling isolation', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('reports editor plugin imports outside the analysis bridge', () => { + const root = mkdtempSync(join(tmpdir(), 'owlsql-architecture-')); + mkdirSync(join(root, 'src', 'compiler', 'next'), { recursive: true }); + mkdirSync(join(root, 'ts-plugin', 'src'), { recursive: true }); + writeFileSync(join(root, 'src', 'compiler', 'next', 'index.ts'), 'export type Next = string;\n'); + writeFileSync( + join(root, 'ts-plugin', 'src', 'invalid.cts'), + "import type { Next } from '../../src/compiler/next/index.js';\n", + ); + + try { + expect(checkArchitecture(root)).toEqual([ + 'ts-plugin/src/invalid.cts -> src/compiler/next/index.ts violates editor plugin contract', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/tests/cli-codegen-edge.test.ts b/tests/cli-codegen-edge.test.ts index b911061..243dbdc 100644 --- a/tests/cli-codegen-edge.test.ts +++ b/tests/cli-codegen-edge.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import ts from 'typescript'; -import { renderSchema } from '../src/cli/codegen.js'; -import { mapPostgresType } from '../src/cli/dialects/postgres.js'; +import { renderSchema } from '../src/tooling/schema-generator/codegen.js'; +import { mapPostgresType } from '../src/tooling/introspection/postgres.js'; function parseErrorCount(source: string): number { const sourceFile = ts.createSourceFile('schema.ts', source, ts.ScriptTarget.Latest, true); diff --git a/tests/cli-codegen.test.ts b/tests/cli-codegen.test.ts index 30b5c4b..775e913 100644 --- a/tests/cli-codegen.test.ts +++ b/tests/cli-codegen.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { renderSchema } from '../src/cli/codegen'; -import type { TableSchema } from '../src/cli/types'; +import { renderSchema } from '../src/tooling/schema-generator/codegen'; +import type { TableSchema } from '../src/tooling/schema-generator/types'; describe('renderSchema', () => { it('renders multiple tables with their columns', () => { diff --git a/tests/cli-generate.test.ts b/tests/cli-generate.test.ts index 60d8faa..1049ccd 100644 --- a/tests/cli-generate.test.ts +++ b/tests/cli-generate.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { runGenerate, detectDialect } from '../src/cli/generate'; +import { generateSchema, detectDialect } from '../src/tooling/schema-generator/generate'; import { loadSqlite, sqliteAvailable } from './sqlite-availability.js'; describe('detectDialect', () => { @@ -88,7 +88,7 @@ describe('detectDialect', () => { }); }); -describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite file)', () => { +describe.skipIf(!sqliteAvailable)('generateSchema (end to end against a real sqlite file)', () => { it('introspects the database and writes the rendered schema to --out', async () => { const dir = mkdtempSync(join(tmpdir(), 'owlsql-')); const dbFile = join(dir, 'app.db'); @@ -99,7 +99,7 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite db.exec('create table users (id integer primary key, name text not null, bio text)'); db.close(); - await runGenerate({ url: dbFile, out: outFile, dialect: 'sqlite' }); + await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite' }); const written = readFileSync(outFile, 'utf8'); expect(written).toBe( @@ -125,7 +125,7 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite db.close(); await expect( - runGenerate({ url: dbFile, out: join(dir, 'schema.ts'), dialect: 'sqlite' }), + generateSchema({ url: dbFile, out: join(dir, 'schema.ts'), dialect: 'sqlite' }), ).rejects.toThrow('No tables found'); } finally { rmSync(dir, { recursive: true, force: true }); @@ -138,7 +138,7 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite try { await expect( - runGenerate({ url: missingFile, out: join(dir, 'schema.ts'), dialect: 'sqlite' }), + generateSchema({ url: missingFile, out: join(dir, 'schema.ts'), dialect: 'sqlite' }), ).rejects.toThrow('SQLite database file not found'); expect(existsSync(missingFile)).toBe(false); @@ -157,10 +157,10 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite db.exec('create table users (id integer primary key, name text not null)'); db.close(); - await runGenerate({ url: dbFile, out: outFile, dialect: 'sqlite' }); + await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite' }); const written = readFileSync(outFile, 'utf8'); - const result = await runGenerate({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); + const result = await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); expect(result).toEqual({ kind: 'upToDate' }); expect(readFileSync(outFile, 'utf8')).toBe(written); @@ -182,7 +182,7 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite writeFileSync(outFile, staleContent, 'utf8'); - const result = await runGenerate({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); + const result = await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); expect(result.kind).toBe('drift'); if (result.kind === 'drift') { @@ -204,7 +204,7 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite db.exec('create table users (id integer primary key, name text not null)'); db.close(); - const result = await runGenerate({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); + const result = await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); expect(result).toEqual({ kind: 'drift', summary: 'the file does not exist yet.' }); expect(existsSync(outFile)).toBe(false); @@ -224,9 +224,9 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite db.exec('create table posts (id integer primary key, title text not null)'); db.close(); - await runGenerate({ url: dbFile, out: outFile, dialect: 'sqlite', tables: ['users'] }); + await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite', tables: ['users'] }); - const matching = await runGenerate({ + const matching = await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite', @@ -235,7 +235,7 @@ describe.skipIf(!sqliteAvailable)('runGenerate (end to end against a real sqlite }); expect(matching).toEqual({ kind: 'upToDate' }); - const withoutFilter = await runGenerate({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); + const withoutFilter = await generateSchema({ url: dbFile, out: outFile, dialect: 'sqlite', check: true }); expect(withoutFilter.kind).toBe('drift'); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/tests/cli-mssql-introspect.test.ts b/tests/cli-mssql-introspect.test.ts index 9b49d88..edd53d1 100644 --- a/tests/cli-mssql-introspect.test.ts +++ b/tests/cli-mssql-introspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { introspectMssql } from '../src/cli/dialects/mssql.js'; +import { introspectMssql } from '../src/tooling/introspection/mssql.js'; describe('introspectMssql', () => { it('resolves alias types via system_type_id, groups columns and keeps empty tables', async () => { diff --git a/tests/cli-mssql-url.test.ts b/tests/cli-mssql-url.test.ts index 6e19d09..9c4c2f5 100644 --- a/tests/cli-mssql-url.test.ts +++ b/tests/cli-mssql-url.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mssqlUrlToConfig } from '../src/cli/dialects/mssql.js'; -import { detectDialect } from '../src/cli/generate.js'; +import { mssqlUrlToConfig } from '../src/tooling/introspection/mssql.js'; +import { detectDialect } from '../src/tooling/schema-generator/generate.js'; describe('mssqlUrlToConfig', () => { it('translates a full mssql:// URL into a driver config', () => { diff --git a/tests/cli-mysql-introspect.test.ts b/tests/cli-mysql-introspect.test.ts index 13e7945..3e4f67e 100644 --- a/tests/cli-mysql-introspect.test.ts +++ b/tests/cli-mysql-introspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { groupMysqlColumns, introspectMysql } from '../src/cli/dialects/mysql.js'; +import { groupMysqlColumns, introspectMysql } from '../src/tooling/introspection/mysql.js'; const UPPERCASE_ROWS = [ { diff --git a/tests/cli-pg-introspect.test.ts b/tests/cli-pg-introspect.test.ts index f93e1df..4262e52 100644 --- a/tests/cli-pg-introspect.test.ts +++ b/tests/cli-pg-introspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { introspectPostgres } from '../src/cli/dialects/postgres.js'; +import { introspectPostgres } from '../src/tooling/introspection/postgres.js'; const TABLE_ROWS = [{ table_name: 'users' }, { table_name: 'empty_t' }]; diff --git a/tests/cli-redact.test.ts b/tests/cli-redact.test.ts index 280e530..b2d87ab 100644 --- a/tests/cli-redact.test.ts +++ b/tests/cli-redact.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { detectDialect, redactCredentials } from '../src/cli/generate.js'; -import { introspectSqlite } from '../src/cli/dialects/sqlite.js'; +import { detectDialect, redactCredentials } from '../src/tooling/schema-generator/generate.js'; +import { introspectSqlite } from '../src/tooling/introspection/sqlite.js'; import { sqliteAvailable } from './sqlite-availability.js'; describe('redactCredentials', () => { diff --git a/tests/cli-sqlite-introspect.test.ts b/tests/cli-sqlite-introspect.test.ts index 939e861..6d0a4e1 100644 --- a/tests/cli-sqlite-introspect.test.ts +++ b/tests/cli-sqlite-introspect.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import type { DatabaseSync } from 'node:sqlite'; -import { introspectSqlite } from '../src/cli/dialects/sqlite'; +import { introspectSqlite } from '../src/tooling/introspection/sqlite'; import { loadSqlite, sqliteAvailable } from './sqlite-availability.js'; function withTempDatabase(setup: (db: DatabaseSync) => void): string { diff --git a/tests/cli-type-mapping.test.ts b/tests/cli-type-mapping.test.ts index 21f2609..2061ec9 100644 --- a/tests/cli-type-mapping.test.ts +++ b/tests/cli-type-mapping.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { mapPostgresType } from '../src/cli/dialects/postgres'; -import { mapMysqlType } from '../src/cli/dialects/mysql'; -import { mapSqliteType } from '../src/cli/dialects/sqlite'; -import { mapMssqlType } from '../src/cli/dialects/mssql'; +import { mapPostgresType } from '../src/tooling/introspection/postgres'; +import { mapMysqlType } from '../src/tooling/introspection/mysql'; +import { mapSqliteType } from '../src/tooling/introspection/sqlite'; +import { mapMssqlType } from '../src/tooling/introspection/mssql'; describe('mapPostgresType', () => { it('maps small integers to number', () => { diff --git a/tests/cli-ux.test.ts b/tests/cli-ux.test.ts index be86108..7f2d663 100644 --- a/tests/cli-ux.test.ts +++ b/tests/cli-ux.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { detectDialect, runGenerate } from '../src/cli/generate.js'; +import { detectDialect, generateSchema } from '../src/tooling/schema-generator/generate.js'; import { loadSqlite, sqliteAvailable } from './sqlite-availability.js'; -import { normalizeSqlitePath } from '../src/cli/dialects/sqlite.js'; +import { normalizeSqlitePath } from '../src/tooling/introspection/sqlite.js'; import { formatCliError } from '../src/cli/index.js'; function createDatabase(): { dir: string; file: string } { @@ -57,7 +57,7 @@ describe('sqlite URL forms', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'schema.ts'); - await runGenerate({ url: `file:${file}`, out }); + await generateSchema({ url: `file:${file}`, out }); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -67,7 +67,7 @@ describe('sqlite URL forms', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'schema.ts'); - await runGenerate({ url: `sqlite://${file}`, out }); + await generateSchema({ url: `sqlite://${file}`, out }); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -79,7 +79,7 @@ describe.skipIf(!sqliteAvailable)('table filtering', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'schema.ts'); - await runGenerate({ url: file, out, tables: ['users'] }); + await generateSchema({ url: file, out, tables: ['users'] }); const { readFileSync } = await import('node:fs'); const written = readFileSync(out, 'utf8'); expect(written).toContain('users'); @@ -93,7 +93,7 @@ describe.skipIf(!sqliteAvailable)('table filtering', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'schema.ts'); - await runGenerate({ url: file, out, exclude: ['posts'] }); + await generateSchema({ url: file, out, exclude: ['posts'] }); const { readFileSync } = await import('node:fs'); const written = readFileSync(out, 'utf8'); expect(written).toContain('users'); @@ -107,7 +107,7 @@ describe.skipIf(!sqliteAvailable)('table filtering', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'schema.ts'); - await expect(runGenerate({ url: file, out, tables: ['nope'] })).rejects.toThrow( + await expect(generateSchema({ url: file, out, tables: ['nope'] })).rejects.toThrow( 'Available tables: users, posts', ); } finally { @@ -122,7 +122,7 @@ describe.skipIf(!sqliteAvailable)('table filtering', () => { try { const out = join(dir, 'schema.ts'); await expect( - runGenerate({ url: file, out, tables: ['users', 'ordrs'] }), + generateSchema({ url: file, out, tables: ['users', 'ordrs'] }), ).rejects.toThrow('--table matched no such table: ordrs'); } finally { rmSync(dir, { recursive: true, force: true }); @@ -133,7 +133,7 @@ describe.skipIf(!sqliteAvailable)('table filtering', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'schema.ts'); - const result = await runGenerate({ url: file, out, exclude: ['posts', 'ordrs'] }); + const result = await generateSchema({ url: file, out, exclude: ['posts', 'ordrs'] }); expect(result).toEqual({ kind: 'written', warnings: ['--exclude matched no such table: ordrs'], @@ -147,7 +147,7 @@ describe.skipIf(!sqliteAvailable)('table filtering', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'schema.ts'); - expect(await runGenerate({ url: file, out, exclude: ['posts'] })).toEqual({ + expect(await generateSchema({ url: file, out, exclude: ['posts'] })).toEqual({ kind: 'written', }); } finally { @@ -161,7 +161,7 @@ describe.skipIf(!sqliteAvailable)('write errors', () => { const { dir, file } = createDatabase(); try { const out = join(dir, 'missing-dir', 'schema.ts'); - await expect(runGenerate({ url: file, out })).rejects.toThrow('directory does not exist'); + await expect(generateSchema({ url: file, out })).rejects.toThrow('directory does not exist'); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/integration/cli-generate.test.ts b/tests/integration/cli-generate.test.ts index e9d7832..acddbc8 100644 --- a/tests/integration/cli-generate.test.ts +++ b/tests/integration/cli-generate.test.ts @@ -6,8 +6,8 @@ import { join } from 'node:path'; import { Pool } from 'pg'; import { createPool, type Pool as MysqlPool } from 'mysql2/promise'; import { ConnectionPool, type config as MssqlConfig } from 'mssql'; -import { runGenerate } from '../../src/cli/generate.js'; -import { mssqlUrlToConfig } from '../../src/cli/dialects/mssql.js'; +import { generateSchema } from '../../src/tooling/schema-generator/generate.js'; +import { mssqlUrlToConfig } from '../../src/tooling/introspection/mssql.js'; import { MSSQL_URL_ENV, MYSQL_URL_ENV, @@ -54,7 +54,7 @@ describe.skipIf(pgUrl === undefined)('owlsql generate against a real PostgreSQL }); it('introspects columns, nullability, enums, and arrays into a schema file', async () => { - const result = await runGenerate({ + const result = await generateSchema({ url: requireUrl(pgUrl, PG_URL_ENV), out: output.file, tables: ['it_gen_pg'], @@ -85,10 +85,10 @@ describe.skipIf(pgUrl === undefined)('owlsql generate against a real PostgreSQL check: true, }; - expect(await runGenerate(options)).toEqual({ kind: 'upToDate' }); + expect(await generateSchema(options)).toEqual({ kind: 'upToDate' }); await pool.query('alter table it_gen_pg add column extra text'); - const drifted = await runGenerate(options); + const drifted = await generateSchema(options); expect(drifted.kind).toBe('drift'); }); @@ -122,7 +122,7 @@ describe.skipIf(mysqlUrl === undefined)('owlsql generate against a real MySQL se }); it('maps tinyint(1) and bit(1) to what mysql2 actually returns', async () => { - const result = await runGenerate({ + const result = await generateSchema({ url: requireUrl(mysqlUrl, MYSQL_URL_ENV), out: output.file, tables: ['it_gen_mysql'], @@ -179,7 +179,7 @@ describe.skipIf(mssqlUrl === undefined)('owlsql generate against a real SQL Serv }); it('introspects sys.tables into a schema file', async () => { - const result = await runGenerate({ + const result = await generateSchema({ url: requireUrl(mssqlUrl, MSSQL_URL_ENV), out: output.file, tables: ['it_gen_mssql'], diff --git a/tests/integration/mssql.test.ts b/tests/integration/mssql.test.ts index abe1424..3022507 100644 --- a/tests/integration/mssql.test.ts +++ b/tests/integration/mssql.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { ConnectionPool, type config as MssqlConfig } from 'mssql'; import { createMssqlExecutor, createMssqlTransaction } from '../../src/adapters/mssql.js'; -import { mssqlUrlToConfig } from '../../src/cli/dialects/mssql.js'; +import { mssqlUrlToConfig } from '../../src/tooling/introspection/mssql.js'; import { isOk } from '../../src/index.js'; import { MSSQL_URL_ENV, metaOf, mssqlUrl, requireUrl, rowsOf } from './databases.js'; diff --git a/tests/next/editor-contract.test-d.ts b/tests/next/editor-contract.test-d.ts new file mode 100644 index 0000000..39d9a16 --- /dev/null +++ b/tests/next/editor-contract.test-d.ts @@ -0,0 +1,64 @@ +import type { + CompletionContext, + EditorDiagnostic, + QueryAnalysis, +} from '../../src/compiler/analysis.js'; +import type { + DiagnosticLocation, + QueryDiagnosticCode, +} from '../../src/compiler/contracts/diagnostic.js'; +import type { + DiagnosticLocation as PluginDiagnosticLocation, + QueryDiagnosticCode as PluginQueryDiagnosticCode, +} from '../../ts-plugin/src/analysis-contract.cjs'; + +type Equal = + (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false; + +type Expect = T; + +type UnknownColumn = EditorDiagnostic<'UNKNOWN_COLUMN'>; + +type StableDiagnostic = Expect< + Equal< + UnknownColumn, + { + code: 'UNKNOWN_COLUMN'; + message: string; + location: import('../../src/compiler/contracts/diagnostic.js').DiagnosticLocation; + reference: string; + } + > +>; + +type StableContext = Expect< + Equal< + CompletionContext, + { + clause: 'select' | 'from' | 'join-on' | 'where' | 'having' | 'unknown'; + qualifier?: string | undefined; + } + > +>; + +type StableAnalysis = Expect< + QueryAnalysis extends { + diagnostics: readonly EditorDiagnostic[]; + context: CompletionContext; + } + ? true + : false +>; + +type PluginCodesAligned = Expect>; + +type PluginLocationsAligned = Expect>; + +export type EditorContractLock = [ + StableDiagnostic, + StableContext, + StableAnalysis, + PluginCodesAligned, + PluginLocationsAligned, +]; diff --git a/tests/tooling/schema-generator.test.ts b/tests/tooling/schema-generator.test.ts new file mode 100644 index 0000000..2e2b626 --- /dev/null +++ b/tests/tooling/schema-generator.test.ts @@ -0,0 +1,74 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + generateSchema, + type Introspector, +} from '../../src/tooling/schema-generator/generate.js'; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function outputFile(): string { + const directory = mkdtempSync(join(tmpdir(), 'owlsql-tooling-')); + directories.push(directory); + return join(directory, 'schema.ts'); +} + +const introspector: Introspector = { + async introspect() { + return [ + { name: 'users', columns: [{ name: 'id', tsType: 'number', nullable: false }] }, + { name: 'posts', columns: [{ name: 'title', tsType: 'string', nullable: true }] }, + ]; + }, +}; + +describe('generateSchema', () => { + it('writes generated TypeScript and reports check state', async () => { + const out = outputFile(); + const options = { url: 'postgres://example/db', out, dialect: 'postgres' as const }; + + await expect(generateSchema(options, introspector)).resolves.toEqual({ kind: 'written' }); + expect(readFileSync(out, 'utf8')).toBe( + 'export interface DB {\n users: {\n id: number;\n };\n posts: {\n title: string | null;\n };\n}\n', + ); + await expect(generateSchema({ ...options, check: true }, introspector)).resolves.toEqual({ + kind: 'upToDate', + }); + + writeFileSync(out, 'stale\n', 'utf8'); + await expect(generateSchema({ ...options, check: true }, introspector)).resolves.toMatchObject({ + kind: 'drift', + summary: expect.stringContaining('first differs at line 1'), + }); + }); + + it('validates include names and warns for unmatched excludes', async () => { + const out = outputFile(); + + await expect( + generateSchema( + { url: 'postgres://example/db', out, tables: ['missing'] }, + introspector, + ), + ).rejects.toThrow('--table matched no such table: missing'); + + await expect( + generateSchema( + { url: 'postgres://example/db', out, tables: ['users'], exclude: ['missing'] }, + introspector, + ), + ).resolves.toEqual({ + kind: 'written', + warnings: ['--exclude matched no such table: missing'], + }); + expect(readFileSync(out, 'utf8')).not.toContain('posts'); + }); +}); diff --git a/ts-plugin/src/analysis-contract.cts b/ts-plugin/src/analysis-contract.cts new file mode 100644 index 0000000..b25653c --- /dev/null +++ b/ts-plugin/src/analysis-contract.cts @@ -0,0 +1,37 @@ +export type QueryDiagnosticCode = + | 'UNKNOWN_TABLE' + | 'UNKNOWN_COLUMN' + | 'UNKNOWN_ALIAS' + | 'AMBIGUOUS_COLUMN' + | 'PARAM_TYPE_CONFLICT' + | 'PARAM_STYLE_MISMATCH' + | 'UNSUPPORTED_STATEMENT' + | 'MALFORMED_QUERY' + | 'MULTIPLE_STATEMENTS' + | 'INVALID_WRITE_TARGET' + | 'INVALID_SCALAR_SUBQUERY' + | 'UNSUPPORTED_DIALECT_FEATURE' + | 'UNSUPPORTED_EXPRESSION'; + +export type DiagnosticLocation = + | 'statement' + | 'select' + | 'from' + | 'join-on' + | 'where' + | 'having' + | 'returning' + | 'output' + | 'parameter' + | 'cte'; + +export interface PluginDiagnostic< + Code extends QueryDiagnosticCode = QueryDiagnosticCode, +> { + code: Code; + message: string; + location: DiagnosticLocation; + reference: string; + start: number; + length: number; +} diff --git a/ts-plugin/src/diagnostics.cts b/ts-plugin/src/diagnostics.cts index 9c7776a..7b12229 100644 --- a/ts-plugin/src/diagnostics.cts +++ b/ts-plugin/src/diagnostics.cts @@ -1,4 +1,9 @@ import type * as ts from 'typescript'; +import type { + DiagnosticLocation, + PluginDiagnostic, + QueryDiagnosticCode, +} from './analysis-contract.cjs'; import sqlContext = require('./sql-context.cjs'); import schemaModule = require('./schema.cjs'); @@ -52,10 +57,68 @@ interface ColumnEntry { start: number; } -interface DiagnosticSpan { - start: number; - length: number; - message: string; +type DiagnosticSpan = PluginDiagnostic; + +type ScannedDiagnosticCode = + | 'UNKNOWN_TABLE' + | 'UNKNOWN_COLUMN' + | 'UNKNOWN_ALIAS' + | 'AMBIGUOUS_COLUMN'; + +const MESSAGE_PREFIX: Record = { + UNKNOWN_TABLE: 'unknown table', + UNKNOWN_COLUMN: 'unknown column', + UNKNOWN_ALIAS: 'unknown alias', + AMBIGUOUS_COLUMN: 'ambiguous column', +}; + +const TYPESCRIPT_DIAGNOSTIC_CODE: Record = { + UNKNOWN_TABLE: 990001, + UNKNOWN_COLUMN: 990002, + UNKNOWN_ALIAS: 990003, + AMBIGUOUS_COLUMN: 990004, + PARAM_TYPE_CONFLICT: 990005, + PARAM_STYLE_MISMATCH: 990006, + UNSUPPORTED_STATEMENT: 990007, + MALFORMED_QUERY: 990008, + MULTIPLE_STATEMENTS: 990009, + INVALID_WRITE_TARGET: 990010, + INVALID_SCALAR_SUBQUERY: 990011, + UNSUPPORTED_DIALECT_FEATURE: 990012, + UNSUPPORTED_EXPRESSION: 990013, +}; + +function scannedDiagnostic( + code: ScannedDiagnosticCode, + location: DiagnosticLocation, + reference: string, + start: number, + length: number, +): DiagnosticSpan { + return { + code, + message: `${MESSAGE_PREFIX[code]}: ${reference}`, + location, + reference, + start, + length, + }; +} + +function toEditorDiagnostic( + typescript: typeof ts, + sourceFile: ts.SourceFile, + diagnostic: PluginDiagnostic, +): ts.Diagnostic { + return { + file: sourceFile, + start: diagnostic.start, + length: diagnostic.length, + messageText: diagnostic.message, + category: typescript.DiagnosticCategory.Warning, + code: TYPESCRIPT_DIAGNOSTIC_CODE[diagnostic.code], + source: 'owlsql', + }; } function findTopLevelFromIndex(text: string): number | null { @@ -178,14 +241,14 @@ function whereTokenDiagnostics( } const matchedSource = findSourceByAlias(sources, qualifier); if (!matchedSource) { - return [{ start: token.start, length: qualifier.length, message: `unknown alias: ${qualifier}` }]; + return [scannedDiagnostic('UNKNOWN_ALIAS', 'where', qualifier, token.start, qualifier.length)]; } if (!tableExists(typescript, checker, dbType, matchedSource.table)) { return []; } return columnExists(typescript, checker, dbType, literal, matchedSource.table, columnName) ? [] - : [{ start: columnStart, length: columnName.length, message: `unknown column: ${columnName}` }]; + : [scannedDiagnostic('UNKNOWN_COLUMN', 'where', columnName, columnStart, columnName.length)]; } const knownTables = sources.filter((source) => tableExists(typescript, checker, dbType, source.table)); @@ -198,10 +261,10 @@ function whereTokenDiagnostics( ); if (containingTables.length === 0) { - return [{ start: columnStart, length: columnName.length, message: `unknown column: ${columnName}` }]; + return [scannedDiagnostic('UNKNOWN_COLUMN', 'where', columnName, columnStart, columnName.length)]; } if (containingTables.length > 1) { - return [{ start: columnStart, length: columnName.length, message: `ambiguous column: ${columnName}` }]; + return [scannedDiagnostic('AMBIGUOUS_COLUMN', 'where', columnName, columnStart, columnName.length)]; } return []; } @@ -363,11 +426,15 @@ function getQueryDiagnostics( for (const source of sources) { if (!tableExists(typescript, checker, dbType, source.table)) { - diagnostics.push({ - start: literalStart + source.tableStart, - length: source.tableEnd - source.tableStart, - message: `unknown table: ${source.table}`, - }); + diagnostics.push( + scannedDiagnostic( + 'UNKNOWN_TABLE', + 'from', + source.table, + literalStart + source.tableStart, + source.tableEnd - source.tableStart, + ), + ); } } @@ -398,14 +465,16 @@ function getQueryDiagnostics( } const matchedSource = findSourceByAlias(sources, qualifier); if (!matchedSource) { - diagnostics.push({ start: tokenStart, length: qualifier.length, message: `unknown alias: ${qualifier}` }); + diagnostics.push(scannedDiagnostic('UNKNOWN_ALIAS', 'select', qualifier, tokenStart, qualifier.length)); continue; } if (!tableExists(typescript, checker, dbType, matchedSource.table)) { continue; } if (!columnExists(typescript, checker, dbType, literal, matchedSource.table, columnName)) { - diagnostics.push({ start: columnStart, length: columnName.length, message: `unknown column: ${columnName}` }); + diagnostics.push( + scannedDiagnostic('UNKNOWN_COLUMN', 'select', columnName, columnStart, columnName.length), + ); } continue; } @@ -420,9 +489,13 @@ function getQueryDiagnostics( ); if (containingTables.length === 0) { - diagnostics.push({ start: columnStart, length: columnName.length, message: `unknown column: ${columnName}` }); + diagnostics.push( + scannedDiagnostic('UNKNOWN_COLUMN', 'select', columnName, columnStart, columnName.length), + ); } else if (containingTables.length > 1) { - diagnostics.push({ start: columnStart, length: columnName.length, message: `ambiguous column: ${columnName}` }); + diagnostics.push( + scannedDiagnostic('AMBIGUOUS_COLUMN', 'select', columnName, columnStart, columnName.length), + ); } } @@ -442,4 +515,4 @@ function getQueryDiagnostics( return diagnostics; } -export = { getQueryDiagnostics }; +export = { getQueryDiagnostics, toEditorDiagnostic }; diff --git a/ts-plugin/src/index.cts b/ts-plugin/src/index.cts index 2ea58bd..06afbae 100644 --- a/ts-plugin/src/index.cts +++ b/ts-plugin/src/index.cts @@ -15,10 +15,7 @@ const { } = sqlContext; const { getColumnNames, getColumnType } = schemaModule; const { matchQueryLiteral, findAllQueryLiterals } = detectModule; -const { getQueryDiagnostics } = diagnosticsModule; - -const OWLSQL_DIAGNOSTIC_SOURCE = 'owlsql'; -const OWLSQL_DIAGNOSTIC_CODE = 990001; +const { getQueryDiagnostics, toEditorDiagnostic } = diagnosticsModule; function resolveTableScope( sources: ReturnType, @@ -245,15 +242,7 @@ function init(modules: { typescript: typeof ts }) { for (const match of matches) { for (const span of getQueryDiagnostics(typescript, checker, match.dbType, match.literal, sourceFile)) { - extra.push({ - file: sourceFile, - start: span.start, - length: span.length, - messageText: span.message, - category: typescript.DiagnosticCategory.Warning, - code: OWLSQL_DIAGNOSTIC_CODE, - source: OWLSQL_DIAGNOSTIC_SOURCE, - }); + extra.push(toEditorDiagnostic(typescript, sourceFile, span)); } } diff --git a/ts-plugin/tests/diagnostic-contract.test.ts b/ts-plugin/tests/diagnostic-contract.test.ts new file mode 100644 index 0000000..9412466 --- /dev/null +++ b/ts-plugin/tests/diagnostic-contract.test.ts @@ -0,0 +1,41 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { rmSync } from 'node:fs'; +import ts from 'typescript'; +import type { PluginDiagnostic } from '../src/analysis-contract.cts'; +import { loadDiagnostics } from './test-helpers.js'; + +const { diagnostics, dir } = loadDiagnostics(); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('editor diagnostic mapping', () => { + it.each([ + ['UNKNOWN_COLUMN', 990002, 'unknown column: nope', 'nope'], + ['UNKNOWN_ALIAS', 990003, 'unknown alias: z', 'z'], + ['AMBIGUOUS_COLUMN', 990004, 'ambiguous column: id', 'id'], + ['PARAM_TYPE_CONFLICT', 990005, 'parameter type conflict: $1', '$1'], + ['UNSUPPORTED_STATEMENT', 990007, 'unsupported statement: vacuum', 'vacuum'], + ] as const)('maps %s to a stable TypeScript diagnostic', (code, expectedCode, message, reference) => { + const sourceFile = ts.createSourceFile('fixture.ts', reference, ts.ScriptTarget.ES2022); + const diagnostic: PluginDiagnostic = { + code, + message, + location: code === 'PARAM_TYPE_CONFLICT' ? 'parameter' : 'statement', + reference, + start: 0, + length: reference.length, + }; + + expect(diagnostics.toEditorDiagnostic(ts, sourceFile, diagnostic)).toMatchObject({ + file: sourceFile, + start: 0, + length: reference.length, + messageText: message, + category: ts.DiagnosticCategory.Warning, + code: expectedCode, + source: 'owlsql', + }); + }); +}); diff --git a/ts-plugin/tests/diagnostics.test.ts b/ts-plugin/tests/diagnostics.test.ts index cd18e10..dfca52a 100644 --- a/ts-plugin/tests/diagnostics.test.ts +++ b/ts-plugin/tests/diagnostics.test.ts @@ -23,7 +23,7 @@ interface DB { declare const db: TypedDb; `; -function diagnosticsFor(query: string, fixture: string = FIXTURE): { message: string; text: string }[] { +function structuredDiagnosticsFor(query: string, fixture: string = FIXTURE) { const source = `${fixture}\ndb.query(\`${query}\`);\n`; const { program, sourceFile, dir } = buildProgram(source, 'owlsql-ts-plugin-diagnostics-'); try { @@ -32,15 +32,21 @@ function diagnosticsFor(query: string, fixture: string = FIXTURE): { message: st expect(matches).toHaveLength(1); const [match] = matches; if (!match) return []; - return getQueryDiagnostics(ts, checker, match.dbType, match.literal, sourceFile).map((span) => ({ - message: span.message, - text: sourceFile.text.slice(span.start, span.start + span.length), - })); + return getQueryDiagnostics(ts, checker, match.dbType, match.literal, sourceFile).map( + (span) => ({ + ...span, + text: sourceFile.text.slice(span.start, span.start + span.length), + }), + ); } finally { rmSync(dir, { recursive: true, force: true }); } } +function diagnosticsFor(query: string, fixture: string = FIXTURE): { message: string; text: string }[] { + return structuredDiagnosticsFor(query, fixture).map(({ message, text }) => ({ message, text })); +} + describe('ts-plugin diagnostics: getQueryDiagnostics', () => { it('reports no diagnostics for a valid query', () => { expect(diagnosticsFor('select id, name from users')).toEqual([]); @@ -96,6 +102,16 @@ describe('ts-plugin diagnostics: getQueryDiagnostics', () => { ]); }); + it('classifies scanner diagnostics with stable codes', () => { + expect(structuredDiagnosticsFor('select z.id, id from users u join posts p on p.id = u.id')).toMatchObject([ + { code: 'UNKNOWN_ALIAS', location: 'select', reference: 'z', text: 'z' }, + { code: 'AMBIGUOUS_COLUMN', location: 'select', reference: 'id', text: 'id' }, + ]); + expect(structuredDiagnosticsFor('select nope from users')).toMatchObject([ + { code: 'UNKNOWN_COLUMN', location: 'select', reference: 'nope', text: 'nope' }, + ]); + }); + it('skips validation for function calls, literals, and star', () => { expect( diagnosticsFor("select *, count(*), 'literal', 1, name from users"),