forked from boos/BoosAPI
feat(db): generate canonical schema contract from sqlite migrations
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSchemaContractFromSqliteMigrations } from './schemaContract.js';
|
||||
|
||||
describe('schema contract generation', () => {
|
||||
it('captures the current schema shape from sqlite migrations', () => {
|
||||
const contract = buildSchemaContractFromSqliteMigrations();
|
||||
|
||||
expect(contract.tables.sites.columns.status).toMatchObject({
|
||||
logicalType: 'text',
|
||||
notNull: true,
|
||||
primaryKey: false,
|
||||
});
|
||||
expect(contract.tables.account_tokens.columns.token_group).toBeDefined();
|
||||
expect(contract.tables.site_disabled_models).toBeDefined();
|
||||
expect(contract.indexes).toContainEqual(
|
||||
expect.objectContaining({ name: 'sites_status_idx', table: 'sites', unique: false }),
|
||||
);
|
||||
expect(contract.uniques).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'site_disabled_models_site_model_unique',
|
||||
table: 'site_disabled_models',
|
||||
columns: ['site_id', 'model_name'],
|
||||
}),
|
||||
);
|
||||
expect(contract.foreignKeys).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: 'site_disabled_models',
|
||||
columns: ['site_id'],
|
||||
referencedTable: 'sites',
|
||||
referencedColumns: ['id'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type LogicalColumnType =
|
||||
| 'integer'
|
||||
| 'real'
|
||||
| 'text'
|
||||
| 'boolean'
|
||||
| 'datetime'
|
||||
| 'json';
|
||||
|
||||
export interface SchemaContractColumn {
|
||||
logicalType: LogicalColumnType;
|
||||
notNull: boolean;
|
||||
defaultValue: string | null;
|
||||
primaryKey: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaContractTable {
|
||||
columns: Record<string, SchemaContractColumn>;
|
||||
}
|
||||
|
||||
export interface SchemaContractIndex {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: string[];
|
||||
unique: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaContractUnique {
|
||||
name: string;
|
||||
table: string;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
export interface SchemaContractForeignKey {
|
||||
table: string;
|
||||
columns: string[];
|
||||
referencedTable: string;
|
||||
referencedColumns: string[];
|
||||
onDelete: string | null;
|
||||
}
|
||||
|
||||
export interface SchemaContract {
|
||||
tables: Record<string, SchemaContractTable>;
|
||||
indexes: SchemaContractIndex[];
|
||||
uniques: SchemaContractUnique[];
|
||||
foreignKeys: SchemaContractForeignKey[];
|
||||
}
|
||||
|
||||
type TableInfoRow = {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
type IndexListRow = {
|
||||
name: string;
|
||||
unique: number;
|
||||
origin: string;
|
||||
partial: number;
|
||||
};
|
||||
|
||||
type IndexInfoRow = {
|
||||
seqno: number;
|
||||
cid: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ForeignKeyRow = {
|
||||
id: number;
|
||||
seq: number;
|
||||
table: string;
|
||||
from: string;
|
||||
to: string;
|
||||
on_update: string;
|
||||
on_delete: string;
|
||||
match: string;
|
||||
};
|
||||
|
||||
function resolveDbDir(): string {
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
export function resolveMigrationsFolder(): string {
|
||||
return resolve(resolveDbDir(), '../../../drizzle');
|
||||
}
|
||||
|
||||
export function resolveGeneratedSchemaContractPath(): string {
|
||||
return resolve(resolveDbDir(), 'generated/schemaContract.json');
|
||||
}
|
||||
|
||||
function resolveMigrationFiles(migrationsFolder: string): string[] {
|
||||
return readdirSync(migrationsFolder)
|
||||
.filter((entry) => entry.endsWith('.sql'))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'));
|
||||
}
|
||||
|
||||
function splitMigrationStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: string): void {
|
||||
for (const migrationFile of resolveMigrationFiles(migrationsFolder)) {
|
||||
const sqlText = readFileSync(join(migrationsFolder, migrationFile), 'utf8');
|
||||
for (const statement of splitMigrationStatements(sqlText)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDefaultValue(defaultValue: string | null): string | null {
|
||||
if (defaultValue == null) return null;
|
||||
return String(defaultValue).trim() || null;
|
||||
}
|
||||
|
||||
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||
const normalizedColumn = columnName.toLowerCase();
|
||||
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
||||
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
||||
return true;
|
||||
}
|
||||
return normalizedColumn.startsWith('is_')
|
||||
|| normalizedColumn.startsWith('use_')
|
||||
|| normalizedColumn.startsWith('has_')
|
||||
|| normalizedColumn.endsWith('_enabled')
|
||||
|| normalizedColumn.endsWith('_available')
|
||||
|| normalizedColumn === 'read'
|
||||
|| normalizedColumn === 'enabled'
|
||||
|| normalizedColumn === 'available'
|
||||
|| normalizedColumn === 'manual_override';
|
||||
}
|
||||
|
||||
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||
const normalizedColumn = columnName.toLowerCase();
|
||||
const normalizedDefault = (defaultValue || '').toLowerCase();
|
||||
return normalizedColumn.endsWith('_at')
|
||||
|| normalizedColumn.endsWith('_until')
|
||||
|| normalizedColumn.endsWith('_refresh')
|
||||
|| normalizedDefault.includes('datetime(')
|
||||
|| normalizedDefault.includes('current_timestamp');
|
||||
}
|
||||
|
||||
function isJsonLikeColumn(columnName: string): boolean {
|
||||
const normalizedColumn = columnName.toLowerCase();
|
||||
return normalizedColumn.endsWith('_json')
|
||||
|| normalizedColumn.includes('snapshot')
|
||||
|| normalizedColumn.includes('mapping')
|
||||
|| normalizedColumn.includes('headers')
|
||||
|| normalizedColumn.includes('config')
|
||||
|| normalizedColumn.includes('details')
|
||||
|| normalizedColumn.includes('meta')
|
||||
|| normalizedColumn.includes('models')
|
||||
|| normalizedColumn.includes('route_ids')
|
||||
|| normalizedColumn.includes('multipliers');
|
||||
}
|
||||
|
||||
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
|
||||
const normalizedType = declaredType.trim().toLowerCase();
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(columnName, defaultValue) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float')) {
|
||||
return 'real';
|
||||
}
|
||||
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
||||
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
|
||||
if (isJsonLikeColumn(columnName)) return 'json';
|
||||
return 'text';
|
||||
}
|
||||
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
||||
const tables = sqlite.prepare(`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name ASC
|
||||
`).all() as Array<{ name: string }>;
|
||||
|
||||
const result: Record<string, SchemaContractTable> = {};
|
||||
|
||||
for (const table of tables) {
|
||||
const rows = sqlite.prepare(`PRAGMA table_info("${table.name}")`).all() as TableInfoRow[];
|
||||
const columns = Object.fromEntries(rows.map((row) => {
|
||||
const defaultValue = normalizeDefaultValue(row.dflt_value);
|
||||
return [row.name, {
|
||||
logicalType: normalizeLogicalType(row.name, row.type, defaultValue),
|
||||
notNull: row.notnull === 1,
|
||||
defaultValue,
|
||||
primaryKey: row.pk > 0,
|
||||
} satisfies SchemaContractColumn];
|
||||
}));
|
||||
|
||||
result[table.name] = { columns };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readIndexes(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): {
|
||||
indexes: SchemaContractIndex[];
|
||||
uniques: SchemaContractUnique[];
|
||||
} {
|
||||
const indexes: SchemaContractIndex[] = [];
|
||||
const uniques: SchemaContractUnique[] = [];
|
||||
|
||||
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
|
||||
const rows = sqlite.prepare(`PRAGMA index_list("${tableName}")`).all() as IndexListRow[];
|
||||
for (const row of rows) {
|
||||
if (!row.name || row.name.startsWith('sqlite_autoindex')) {
|
||||
continue;
|
||||
}
|
||||
const indexColumns = (sqlite.prepare(`PRAGMA index_info("${row.name}")`).all() as IndexInfoRow[])
|
||||
.sort((left, right) => left.seqno - right.seqno)
|
||||
.map((item) => item.name)
|
||||
.filter((name) => !!name);
|
||||
|
||||
const index: SchemaContractIndex = {
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns: indexColumns,
|
||||
unique: row.unique === 1,
|
||||
};
|
||||
indexes.push(index);
|
||||
|
||||
if (row.unique === 1) {
|
||||
uniques.push({
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns: indexColumns,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexes.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
uniques.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
|
||||
return { indexes, uniques };
|
||||
}
|
||||
|
||||
function readForeignKeys(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): SchemaContractForeignKey[] {
|
||||
const foreignKeys: SchemaContractForeignKey[] = [];
|
||||
|
||||
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
|
||||
const rows = sqlite.prepare(`PRAGMA foreign_key_list("${tableName}")`).all() as ForeignKeyRow[];
|
||||
const grouped = new Map<number, SchemaContractForeignKey>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.id);
|
||||
if (existing) {
|
||||
existing.columns.push(row.from);
|
||||
existing.referencedColumns.push(row.to);
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(row.id, {
|
||||
table: tableName,
|
||||
columns: [row.from],
|
||||
referencedTable: row.table,
|
||||
referencedColumns: [row.to],
|
||||
onDelete: row.on_delete || null,
|
||||
});
|
||||
}
|
||||
|
||||
foreignKeys.push(...grouped.values());
|
||||
}
|
||||
|
||||
foreignKeys.sort((left, right) => {
|
||||
const leftKey = `${left.table}:${left.columns.join(',')}`;
|
||||
const rightKey = `${right.table}:${right.columns.join(',')}`;
|
||||
return leftKey.localeCompare(rightKey, 'en');
|
||||
});
|
||||
|
||||
return foreignKeys;
|
||||
}
|
||||
|
||||
export function buildSchemaContractFromSqliteMigrations(migrationsFolder = resolveMigrationsFolder()): SchemaContract {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
try {
|
||||
applySqliteMigrations(sqlite, migrationsFolder);
|
||||
const tables = readTables(sqlite);
|
||||
const { indexes, uniques } = readIndexes(sqlite, tables);
|
||||
const foreignKeys = readForeignKeys(sqlite, tables);
|
||||
|
||||
return {
|
||||
tables,
|
||||
indexes,
|
||||
uniques,
|
||||
foreignKeys,
|
||||
};
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSchemaContractFile(outputPath = resolveGeneratedSchemaContractPath()): SchemaContract {
|
||||
const contract = buildSchemaContractFromSqliteMigrations();
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
|
||||
return contract;
|
||||
}
|
||||
|
||||
export const __schemaContractTestUtils = {
|
||||
splitMigrationStatements,
|
||||
normalizeDefaultValue,
|
||||
normalizeLogicalType,
|
||||
resolveMigrationsFolder,
|
||||
};
|
||||
Reference in New Issue
Block a user