447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import type {
|
|
LogicalColumnType,
|
|
SchemaContract,
|
|
SchemaContractColumn,
|
|
SchemaContractForeignKey,
|
|
SchemaContractIndex,
|
|
SchemaContractUnique,
|
|
} from './schemaContract.js';
|
|
|
|
export interface GeneratedDialectArtifacts {
|
|
mysqlBootstrap: string;
|
|
postgresBootstrap: string;
|
|
mysqlUpgrade: string;
|
|
postgresUpgrade: string;
|
|
}
|
|
|
|
export type MysqlIndexPrefixRequirementMap = Record<string, Record<string, boolean>>;
|
|
|
|
type Dialect = 'mysql' | 'postgres';
|
|
type SqlDialect = 'sqlite' | Dialect;
|
|
type SqlGenerationOptions = {
|
|
mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap;
|
|
};
|
|
|
|
function resolveDbDir(): string {
|
|
return dirname(fileURLToPath(import.meta.url));
|
|
}
|
|
|
|
export function resolveGeneratedArtifactPath(filename: string): string {
|
|
return resolve(resolveDbDir(), 'generated', filename);
|
|
}
|
|
|
|
function quoteIdentifier(dialect: SqlDialect, identifier: string): string {
|
|
return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`;
|
|
}
|
|
|
|
function escapeMysqlTextPrefix(columnType: LogicalColumnType): string {
|
|
return columnType === 'text' ? '(191)' : '';
|
|
}
|
|
|
|
function resolveMysqlIndexPrefix(
|
|
tableName: string,
|
|
columnName: string,
|
|
contract: SchemaContract,
|
|
options?: SqlGenerationOptions,
|
|
): string {
|
|
const override = options?.mysqlIndexPrefixRequirements?.[tableName]?.[columnName];
|
|
if (override !== undefined) {
|
|
return override ? '(191)' : '';
|
|
}
|
|
|
|
const column = contract.tables[tableName]?.columns[columnName];
|
|
return column ? escapeMysqlTextPrefix(column.logicalType) : '';
|
|
}
|
|
|
|
function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string {
|
|
if (dialect === 'sqlite') {
|
|
switch (column.logicalType) {
|
|
case 'boolean':
|
|
case 'integer':
|
|
return 'INTEGER';
|
|
case 'real':
|
|
return 'REAL';
|
|
case 'datetime':
|
|
case 'json':
|
|
case 'text':
|
|
default:
|
|
return 'TEXT';
|
|
}
|
|
}
|
|
|
|
if (dialect === 'mysql') {
|
|
switch (column.logicalType) {
|
|
case 'boolean':
|
|
return 'BOOLEAN';
|
|
case 'integer':
|
|
return column.primaryKey && columnName === 'id' ? 'INT AUTO_INCREMENT' : 'INT';
|
|
case 'real':
|
|
return 'DOUBLE';
|
|
case 'datetime':
|
|
return 'VARCHAR(191)';
|
|
case 'json':
|
|
return 'JSON';
|
|
case 'text':
|
|
return column.primaryKey || column.defaultValue != null ? 'VARCHAR(191)' : 'TEXT';
|
|
default:
|
|
return 'TEXT';
|
|
}
|
|
}
|
|
|
|
switch (column.logicalType) {
|
|
case 'boolean':
|
|
return 'BOOLEAN';
|
|
case 'integer':
|
|
return column.primaryKey && columnName === 'id' ? 'INTEGER GENERATED BY DEFAULT AS IDENTITY' : 'INTEGER';
|
|
case 'real':
|
|
return 'DOUBLE PRECISION';
|
|
case 'datetime':
|
|
return 'TEXT';
|
|
case 'json':
|
|
return 'JSONB';
|
|
case 'text':
|
|
default:
|
|
return 'TEXT';
|
|
}
|
|
}
|
|
|
|
function formatDefaultValue(dialect: SqlDialect, column: SchemaContractColumn): string {
|
|
if (column.defaultValue == null || column.primaryKey) {
|
|
return '';
|
|
}
|
|
|
|
if (column.logicalType === 'datetime') {
|
|
if (dialect === 'sqlite') {
|
|
return " DEFAULT (datetime('now'))";
|
|
}
|
|
if (dialect === 'mysql') {
|
|
return " DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))";
|
|
}
|
|
return " DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')";
|
|
}
|
|
|
|
if (column.logicalType === 'boolean') {
|
|
return ` DEFAULT ${column.defaultValue.toLowerCase()}`;
|
|
}
|
|
|
|
return ` DEFAULT ${column.defaultValue}`;
|
|
}
|
|
|
|
function buildColumnDefinition(
|
|
dialect: SqlDialect,
|
|
columnName: string,
|
|
column: SchemaContractColumn,
|
|
): string {
|
|
const sqlType = mapColumnType(dialect, columnName, column);
|
|
const notNull = column.notNull ? ' NOT NULL' : '';
|
|
const defaultValue = formatDefaultValue(dialect, column);
|
|
const primaryKey = column.primaryKey ? ' PRIMARY KEY' : '';
|
|
return `${quoteIdentifier(dialect, columnName)} ${sqlType}${notNull}${defaultValue}${primaryKey}`;
|
|
}
|
|
|
|
function buildForeignKeyClause(dialect: SqlDialect, foreignKey: SchemaContractForeignKey): string {
|
|
const sourceColumns = foreignKey.columns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
|
const targetColumns = foreignKey.referencedColumns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
|
const onDelete = foreignKey.onDelete ? ` ON DELETE ${foreignKey.onDelete.toUpperCase()}` : '';
|
|
return `FOREIGN KEY (${sourceColumns}) REFERENCES ${quoteIdentifier(dialect, foreignKey.referencedTable)}(${targetColumns})${onDelete}`;
|
|
}
|
|
|
|
function sortTableNamesForCreation(contract: SchemaContract): string[] {
|
|
const ordered: string[] = [];
|
|
const visited = new Set<string>();
|
|
const visiting = new Set<string>();
|
|
const tableNames = Object.keys(contract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
|
|
|
const dependencies = new Map<string, string[]>();
|
|
for (const tableName of tableNames) {
|
|
const referencedTables = contract.foreignKeys
|
|
.filter((foreignKey) => foreignKey.table === tableName)
|
|
.map((foreignKey) => foreignKey.referencedTable)
|
|
.filter((referencedTable) => referencedTable !== tableName && tableNames.includes(referencedTable))
|
|
.sort((left, right) => left.localeCompare(right, 'en'));
|
|
dependencies.set(tableName, [...new Set(referencedTables)]);
|
|
}
|
|
|
|
function visit(tableName: string): void {
|
|
if (visited.has(tableName) || visiting.has(tableName)) {
|
|
return;
|
|
}
|
|
visiting.add(tableName);
|
|
for (const dependency of dependencies.get(tableName) ?? []) {
|
|
visit(dependency);
|
|
}
|
|
visiting.delete(tableName);
|
|
visited.add(tableName);
|
|
ordered.push(tableName);
|
|
}
|
|
|
|
for (const tableName of tableNames) {
|
|
visit(tableName);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
function buildCreateTableStatement(
|
|
dialect: SqlDialect,
|
|
tableName: string,
|
|
contract: SchemaContract,
|
|
): string {
|
|
const table = contract.tables[tableName];
|
|
const columnEntries = Object.entries(table.columns);
|
|
const foreignKeys = contract.foreignKeys.filter((foreignKey) => foreignKey.table === tableName);
|
|
const parts = [
|
|
...columnEntries.map(([columnName, column]) => buildColumnDefinition(dialect, columnName, column)),
|
|
...foreignKeys.map((foreignKey) => buildForeignKeyClause(dialect, foreignKey)),
|
|
];
|
|
return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`;
|
|
}
|
|
|
|
function buildUniqueIndexStatement(
|
|
dialect: SqlDialect,
|
|
uniqueIndex: SchemaContractUnique,
|
|
contract: SchemaContract,
|
|
options?: SqlGenerationOptions,
|
|
): string {
|
|
const columns = uniqueIndex.columns
|
|
.map((columnName) => {
|
|
const suffix = dialect === 'mysql'
|
|
? resolveMysqlIndexPrefix(uniqueIndex.table, columnName, contract, options)
|
|
: '';
|
|
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
|
|
})
|
|
.join(', ');
|
|
return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`;
|
|
}
|
|
|
|
function buildIndexStatement(
|
|
dialect: SqlDialect,
|
|
index: SchemaContractIndex,
|
|
contract: SchemaContract,
|
|
options?: SqlGenerationOptions,
|
|
): string {
|
|
const columns = index.columns
|
|
.map((columnName) => {
|
|
const suffix = dialect === 'mysql'
|
|
? resolveMysqlIndexPrefix(index.table, columnName, contract, options)
|
|
: '';
|
|
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
|
|
})
|
|
.join(', ');
|
|
return `CREATE INDEX ${quoteIdentifier(dialect, index.name)} ON ${quoteIdentifier(dialect, index.table)} (${columns})`;
|
|
}
|
|
|
|
function serializeColumn(column: SchemaContractColumn): string {
|
|
return [
|
|
column.logicalType,
|
|
column.notNull ? 'not-null' : 'nullable',
|
|
column.defaultValue ?? 'default:null',
|
|
column.primaryKey ? 'pk' : 'non-pk',
|
|
].join('|');
|
|
}
|
|
|
|
function serializeIndex(index: SchemaContractIndex): string {
|
|
return [index.table, index.columns.join(','), index.unique ? 'unique' : 'non-unique'].join('|');
|
|
}
|
|
|
|
function serializeUnique(unique: SchemaContractUnique): string {
|
|
return [unique.table, unique.columns.join(',')].join('|');
|
|
}
|
|
|
|
function serializeForeignKey(foreignKey: SchemaContractForeignKey): string {
|
|
return [
|
|
foreignKey.table,
|
|
foreignKey.columns.join(','),
|
|
foreignKey.referencedTable,
|
|
foreignKey.referencedColumns.join(','),
|
|
foreignKey.onDelete ?? 'null',
|
|
].join('|');
|
|
}
|
|
|
|
function assertAdditiveSchemaDiff(currentContract: SchemaContract, previousContract: SchemaContract): void {
|
|
const violations: string[] = [];
|
|
|
|
for (const [tableName, previousTable] of Object.entries(previousContract.tables)) {
|
|
const currentTable = currentContract.tables[tableName];
|
|
if (!currentTable) {
|
|
violations.push(`removed table ${tableName}`);
|
|
continue;
|
|
}
|
|
|
|
for (const [columnName, previousColumn] of Object.entries(previousTable.columns)) {
|
|
const currentColumn = currentTable.columns[columnName];
|
|
if (!currentColumn) {
|
|
violations.push(`removed column ${tableName}.${columnName}`);
|
|
continue;
|
|
}
|
|
|
|
if (serializeColumn(currentColumn) !== serializeColumn(previousColumn)) {
|
|
violations.push(`changed column ${tableName}.${columnName}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const currentIndexes = new Map(currentContract.indexes.map((index) => [index.name, index]));
|
|
for (const previousIndex of previousContract.indexes) {
|
|
const currentIndex = currentIndexes.get(previousIndex.name);
|
|
if (!currentIndex) {
|
|
violations.push(`removed index ${previousIndex.name}`);
|
|
continue;
|
|
}
|
|
if (serializeIndex(currentIndex) !== serializeIndex(previousIndex)) {
|
|
violations.push(`changed index ${previousIndex.name}`);
|
|
}
|
|
}
|
|
|
|
const currentUniques = new Map(currentContract.uniques.map((unique) => [unique.name, unique]));
|
|
for (const previousUnique of previousContract.uniques) {
|
|
const currentUnique = currentUniques.get(previousUnique.name);
|
|
if (!currentUnique) {
|
|
violations.push(`removed unique ${previousUnique.name}`);
|
|
continue;
|
|
}
|
|
if (serializeUnique(currentUnique) !== serializeUnique(previousUnique)) {
|
|
violations.push(`changed unique ${previousUnique.name}`);
|
|
}
|
|
}
|
|
|
|
const currentForeignKeys = new Set(currentContract.foreignKeys.map(serializeForeignKey));
|
|
for (const previousForeignKey of previousContract.foreignKeys) {
|
|
if (!currentForeignKeys.has(serializeForeignKey(previousForeignKey))) {
|
|
violations.push(`removed foreign key ${previousForeignKey.table}(${previousForeignKey.columns.join(',')})`);
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
throw new Error(`Non-additive schema diff detected:\n- ${violations.join('\n- ')}`);
|
|
}
|
|
}
|
|
|
|
export function generateBootstrapSql(dialect: SqlDialect, contract: SchemaContract): string {
|
|
const tableStatements = sortTableNamesForCreation(contract)
|
|
.map((tableName) => buildCreateTableStatement(dialect, tableName, contract));
|
|
|
|
const uniqueStatements = contract.uniques
|
|
.slice()
|
|
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
|
.map((uniqueIndex) => buildUniqueIndexStatement(dialect, uniqueIndex, contract));
|
|
|
|
const knownUniqueNames = new Set(contract.uniques.map((uniqueIndex) => uniqueIndex.name));
|
|
const indexStatements = contract.indexes
|
|
.filter((index) => !knownUniqueNames.has(index.name))
|
|
.slice()
|
|
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
|
.map((index) => buildIndexStatement(dialect, index, contract));
|
|
|
|
return `${[...tableStatements, ...uniqueStatements, ...indexStatements].join(';\n')};\n`;
|
|
}
|
|
|
|
function buildAddColumnStatement(
|
|
dialect: SqlDialect,
|
|
tableName: string,
|
|
columnName: string,
|
|
column: SchemaContractColumn,
|
|
): string {
|
|
return `ALTER TABLE ${quoteIdentifier(dialect, tableName)} ADD COLUMN ${buildColumnDefinition(dialect, columnName, column)}`;
|
|
}
|
|
|
|
export function generateUpgradeSql(
|
|
dialect: SqlDialect,
|
|
currentContract: SchemaContract,
|
|
previousContract?: SchemaContract | null,
|
|
options?: SqlGenerationOptions,
|
|
): string {
|
|
if (!previousContract) {
|
|
return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`;
|
|
}
|
|
|
|
assertAdditiveSchemaDiff(currentContract, previousContract);
|
|
|
|
const previousTableNames = new Set(Object.keys(previousContract.tables));
|
|
const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
|
const addedTableNames = currentTableNames.filter((tableName) => !previousTableNames.has(tableName));
|
|
const addedTablesContract: SchemaContract = {
|
|
tables: Object.fromEntries(addedTableNames.map((tableName) => [tableName, currentContract.tables[tableName]])),
|
|
indexes: currentContract.indexes.filter((index) => addedTableNames.includes(index.table)),
|
|
uniques: currentContract.uniques.filter((unique) => addedTableNames.includes(unique.table)),
|
|
foreignKeys: currentContract.foreignKeys.filter((foreignKey) => addedTableNames.includes(foreignKey.table)),
|
|
};
|
|
const addedTableStatements = sortTableNamesForCreation(addedTablesContract)
|
|
.map((tableName) => buildCreateTableStatement(dialect, tableName, currentContract));
|
|
|
|
const addColumnStatements: string[] = [];
|
|
for (const tableName of currentTableNames) {
|
|
if (!previousTableNames.has(tableName)) {
|
|
continue;
|
|
}
|
|
|
|
const previousColumns = previousContract.tables[tableName]?.columns ?? {};
|
|
const currentColumns = currentContract.tables[tableName]?.columns ?? {};
|
|
for (const [columnName, column] of Object.entries(currentColumns)) {
|
|
if (previousColumns[columnName]) {
|
|
continue;
|
|
}
|
|
addColumnStatements.push(buildAddColumnStatement(dialect, tableName, columnName, column));
|
|
}
|
|
}
|
|
|
|
const previousUniqueNames = new Set(previousContract.uniques.map((unique) => unique.name));
|
|
const uniqueStatements = currentContract.uniques
|
|
.filter((unique) => !previousUniqueNames.has(unique.name))
|
|
.slice()
|
|
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
|
.map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract, options));
|
|
|
|
const currentUniqueNames = new Set(currentContract.uniques.map((unique) => unique.name));
|
|
const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name));
|
|
const indexStatements = currentContract.indexes
|
|
.filter((index) => !currentUniqueNames.has(index.name))
|
|
.filter((index) => !previousIndexNames.has(index.name))
|
|
.slice()
|
|
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
|
.map((index) => buildIndexStatement(dialect, index, currentContract, options));
|
|
|
|
const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements];
|
|
if (statements.length === 0) {
|
|
return `-- no schema changes detected for ${dialect}\n`;
|
|
}
|
|
|
|
return `${statements.join(';\n')};\n`;
|
|
}
|
|
|
|
export function generateDialectArtifacts(
|
|
contract: SchemaContract,
|
|
previousContract?: SchemaContract | null,
|
|
): GeneratedDialectArtifacts {
|
|
return {
|
|
mysqlBootstrap: generateBootstrapSql('mysql', contract),
|
|
postgresBootstrap: generateBootstrapSql('postgres', contract),
|
|
mysqlUpgrade: generateUpgradeSql('mysql', contract, previousContract),
|
|
postgresUpgrade: generateUpgradeSql('postgres', contract, previousContract),
|
|
};
|
|
}
|
|
|
|
export function writeDialectArtifactFiles(
|
|
contract: SchemaContract,
|
|
previousContract?: SchemaContract | null,
|
|
): GeneratedDialectArtifacts {
|
|
const artifacts = generateDialectArtifacts(contract, previousContract);
|
|
const artifactEntries = [
|
|
['mysql.bootstrap.sql', artifacts.mysqlBootstrap],
|
|
['postgres.bootstrap.sql', artifacts.postgresBootstrap],
|
|
['mysql.upgrade.sql', artifacts.mysqlUpgrade],
|
|
['postgres.upgrade.sql', artifacts.postgresUpgrade],
|
|
] as const;
|
|
|
|
for (const [filename, content] of artifactEntries) {
|
|
const outputPath = resolveGeneratedArtifactPath(filename);
|
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
writeFileSync(outputPath, content, 'utf8');
|
|
}
|
|
|
|
return artifacts;
|
|
}
|