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
29 changes: 27 additions & 2 deletions scripts/check-architecture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -101,15 +119,22 @@ 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);
for (const rule of RULES) {
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}`);
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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'];

Expand Down Expand Up @@ -171,7 +171,7 @@ async function main(): Promise<void> {

const out = flags.get('out') ?? './schema.ts';

const result = await runGenerate({
const result = await generateSchema({
url,
out,
dialect: parseDialect(flags.get('dialect')),
Expand Down
17 changes: 0 additions & 17 deletions src/cli/types.ts

This file was deleted.

5 changes: 5 additions & 0 deletions src/compiler/analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type {
CompletionContext,
EditorDiagnostic,
QueryAnalysis,
} from './contracts/editor.js';
23 changes: 23 additions & 0 deletions src/compiler/contracts/editor.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
int: 'number',
Expand Down Expand Up @@ -126,7 +126,7 @@ export function mssqlUrlToConfig(url: string): MssqlConnectionConfig {
return config;
}

export async function introspectMssql(connection: ConnectionInfo): Promise<TableSchema[]> {
export async function introspectMssql(connection: IntrospectionOptions): Promise<TableSchema[]> {
let connect: typeof import('mssql').connect;
try {
({ connect } = await import('mssql'));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
tinyint: 'number',
Expand Down Expand Up @@ -57,7 +57,7 @@ function readField(row: MysqlColumnRow, name: string): string {
return typeof value === 'string' ? value : '';
}

export async function introspectMysql(connection: ConnectionInfo): Promise<TableSchema[]> {
export async function introspectMysql(connection: IntrospectionOptions): Promise<TableSchema[]> {
let createConnection: typeof import('mysql2/promise').createConnection;
try {
({ createConnection } = await import('mysql2/promise'));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
int2: 'number',
Expand Down Expand Up @@ -82,7 +82,7 @@ const POSTGRES_ARRAY_TYPE_OVERRIDES: Record<string, string> = {
// 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(' | ');
Expand Down Expand Up @@ -129,7 +129,7 @@ interface PgEnumRow {
enumlabel: string;
}

export async function introspectPostgres(connection: ConnectionInfo): Promise<TableSchema[]> {
export async function introspectPostgres(connection: IntrospectionOptions): Promise<TableSchema[]> {
let PoolCtor: typeof import('pg').Pool;
try {
({ Pool: PoolCtor } = await import('pg'));
Expand Down
3 changes: 1 addition & 2 deletions src/cli/redact.ts → src/tooling/introspection/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}***@`)
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -102,7 +102,7 @@ function isMemoryDatabase(path: string): boolean {
return path === ':memory:' || path.startsWith('file::memory:');
}

export async function introspectSqlite(connection: ConnectionInfo): Promise<TableSchema[]> {
export async function introspectSqlite(connection: IntrospectionOptions): Promise<TableSchema[]> {
let DatabaseSyncCtor: typeof import('node:sqlite').DatabaseSync;
try {
({ DatabaseSync: DatabaseSyncCtor } = await import('node:sqlite'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
68 changes: 36 additions & 32 deletions src/cli/generate.ts → src/tooling/schema-generator/generate.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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(
Expand Down Expand Up @@ -81,11 +78,11 @@ export function detectDialect(url: string): Dialect {
return 'sqlite';
}

const INTROSPECTORS: Record<Dialect, (connection: ConnectionInfo) => Promise<TableSchema[]>> = {
postgres: introspectPostgres,
mysql: introspectMysql,
sqlite: introspectSqlite,
mssql: introspectMssql,
const INTROSPECTORS: Record<Dialect, Introspector> = {
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
Expand All @@ -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());

Expand Down Expand Up @@ -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<GenerateResult> {
export async function generateSchema(
options: GenerateSchemaOptions,
injectedIntrospector?: Introspector,
): Promise<GenerateSchemaResult> {
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.');
Expand Down
34 changes: 34 additions & 0 deletions src/tooling/schema-generator/types.ts
Original file line number Diff line number Diff line change
@@ -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<TableSchema[]>;
}

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[] };
38 changes: 38 additions & 0 deletions tests/architecture/dependencies.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
Loading