Files
metapi/src/server/db/schemaArtifactGenerator.ts
T

249 lines
9.4 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;
}
type Dialect = 'mysql' | 'postgres';
function resolveDbDir(): string {
return dirname(fileURLToPath(import.meta.url));
}
export function resolveGeneratedArtifactPath(filename: string): string {
return resolve(resolveDbDir(), 'generated', filename);
}
function quoteIdentifier(dialect: Dialect, identifier: string): string {
return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`;
}
function escapeMysqlTextPrefix(columnType: LogicalColumnType): string {
return columnType === 'text' || columnType === 'datetime' || columnType === 'json' ? '(191)' : '';
}
function mapColumnType(dialect: Dialect, columnName: string, column: SchemaContractColumn): string {
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':
case 'json':
case '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':
case 'json':
case 'text':
default:
return 'TEXT';
}
}
function formatDefaultValue(defaultValue: string | null): string {
if (defaultValue == null) {
return '';
}
return ` DEFAULT ${defaultValue}`;
}
function buildColumnDefinition(
dialect: Dialect,
columnName: string,
column: SchemaContractColumn,
): string {
const sqlType = mapColumnType(dialect, columnName, column);
const notNull = column.notNull ? ' NOT NULL' : '';
const defaultValue = formatDefaultValue(column.defaultValue);
const primaryKey = column.primaryKey ? ' PRIMARY KEY' : '';
return `${quoteIdentifier(dialect, columnName)} ${sqlType}${notNull}${defaultValue}${primaryKey}`;
}
function buildForeignKeyClause(dialect: Dialect, 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 buildCreateTableStatement(
dialect: Dialect,
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: Dialect, uniqueIndex: SchemaContractUnique, contract: SchemaContract): string {
const columns = uniqueIndex.columns
.map((columnName) => {
const column = contract.tables[uniqueIndex.table]?.columns[columnName];
const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : '';
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
})
.join(', ');
return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`;
}
function buildIndexStatement(dialect: Dialect, index: SchemaContractIndex, contract: SchemaContract): string {
const columns = index.columns
.map((columnName) => {
const column = contract.tables[index.table]?.columns[columnName];
const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : '';
return `${quoteIdentifier(dialect, columnName)}${suffix}`;
})
.join(', ');
return `CREATE INDEX ${quoteIdentifier(dialect, index.name)} ON ${quoteIdentifier(dialect, index.table)} (${columns})`;
}
function buildBootstrapSql(dialect: Dialect, contract: SchemaContract): string {
const tableStatements = Object.keys(contract.tables)
.sort((left, right) => left.localeCompare(right, 'en'))
.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: Dialect,
tableName: string,
columnName: string,
column: SchemaContractColumn,
): string {
return `ALTER TABLE ${quoteIdentifier(dialect, tableName)} ADD COLUMN ${buildColumnDefinition(dialect, columnName, column)}`;
}
function buildUpgradeSql(
dialect: Dialect,
currentContract: SchemaContract,
previousContract?: SchemaContract | null,
): string {
if (!previousContract) {
return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`;
}
const previousTableNames = new Set(Object.keys(previousContract.tables));
const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en'));
const addedTableStatements = currentTableNames
.filter((tableName) => !previousTableNames.has(tableName))
.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));
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));
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: buildBootstrapSql('mysql', contract),
postgresBootstrap: buildBootstrapSql('postgres', contract),
mysqlUpgrade: buildUpgradeSql('mysql', contract, previousContract),
postgresUpgrade: buildUpgradeSql('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;
}