Initial commit - Event Planner application

This commit is contained in:
mberlin
2026-03-18 14:55:56 -03:00
commit 86d779eb4d
7548 changed files with 1006324 additions and 0 deletions

View File

@@ -0,0 +1,553 @@
import { CommitOrderCalculator, TableNotFoundException, Utils } from '@mikro-orm/core';
import { AbstractSchemaGenerator } from '@mikro-orm/core/schema';
import { DatabaseSchema } from './DatabaseSchema.js';
import { SchemaComparator } from './SchemaComparator.js';
/** Generates and manages SQL database schemas based on entity metadata. Supports create, update, and drop operations. */
export class SqlSchemaGenerator extends AbstractSchemaGenerator {
helper = this.platform.getSchemaHelper();
options = this.config.get('schemaGenerator');
lastEnsuredDatabase;
static register(orm) {
orm.config.registerExtension('@mikro-orm/schema-generator', () => new SqlSchemaGenerator(orm.em));
}
async create(options) {
await this.ensureDatabase();
const sql = await this.getCreateSchemaSQL(options);
await this.execute(sql);
}
/**
* Returns true if the database was created.
*/
async ensureDatabase(options) {
await this.connection.ensureConnection();
const dbName = this.config.get('dbName');
if (this.lastEnsuredDatabase === dbName && !options?.forceCheck) {
return true;
}
const exists = await this.helper.databaseExists(this.connection, dbName);
this.lastEnsuredDatabase = dbName;
if (!exists) {
const managementDbName = this.helper.getManagementDbName();
if (managementDbName) {
this.config.set('dbName', managementDbName);
await this.driver.reconnect({ skipOnConnect: true });
await this.createDatabase(dbName, { skipOnConnect: true });
}
if (options?.create) {
await this.create(options);
}
return true;
}
/* v8 ignore next */
if (options?.clear) {
await this.clear({ ...options, clearIdentityMap: false });
}
return false;
}
getTargetSchema(schema) {
const metadata = this.getOrderedMetadata(schema);
const schemaName = schema ?? this.config.get('schema') ?? this.platform.getDefaultSchemaName();
return DatabaseSchema.fromMetadata(metadata, this.platform, this.config, schemaName, this.em);
}
getOrderedMetadata(schema) {
const metadata = super.getOrderedMetadata(schema);
// Filter out skipped tables
return metadata.filter(meta => {
const tableName = meta.tableName;
const tableSchema = meta.schema ?? schema ?? this.config.get('schema');
return !this.isTableSkipped(tableName, tableSchema);
});
}
async getCreateSchemaSQL(options = {}) {
const toSchema = this.getTargetSchema(options.schema);
const ret = [];
for (const namespace of toSchema.getNamespaces()) {
if (namespace === this.platform.getDefaultSchemaName()) {
continue;
}
const sql = this.helper.getCreateNamespaceSQL(namespace);
this.append(ret, sql);
}
if (this.platform.supportsNativeEnums()) {
const created = [];
for (const [enumName, enumOptions] of Object.entries(toSchema.getNativeEnums())) {
/* v8 ignore next */
if (created.includes(enumName)) {
continue;
}
created.push(enumName);
const sql = this.helper.getCreateNativeEnumSQL(
enumOptions.name,
enumOptions.items,
this.getSchemaName(enumOptions, options),
);
this.append(ret, sql);
}
}
for (const table of toSchema.getTables()) {
this.append(ret, this.helper.createTable(table), true);
}
if (this.helper.supportsSchemaConstraints()) {
for (const table of toSchema.getTables()) {
const fks = Object.values(table.getForeignKeys()).map(fk => this.helper.createForeignKey(table, fk));
this.append(ret, fks, true);
}
}
// Create views after tables (views may depend on tables)
// Sort views by dependencies (views depending on other views come later)
const sortedViews = this.sortViewsByDependencies(toSchema.getViews());
for (const view of sortedViews) {
if (view.materialized) {
this.append(
ret,
this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true),
);
} else {
this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
}
}
return this.wrapSchema(ret, options);
}
async drop(options = {}) {
if (options.dropDb) {
const name = this.config.get('dbName');
return this.dropDatabase(name);
}
const sql = await this.getDropSchemaSQL(options);
await this.execute(sql);
}
async createNamespace(name) {
const sql = this.helper.getCreateNamespaceSQL(name);
await this.execute(sql);
}
async dropNamespace(name) {
const sql = this.helper.getDropNamespaceSQL(name);
await this.execute(sql);
}
async clear(options) {
// truncate by default, so no value is considered as true
/* v8 ignore next */
if (options?.truncate === false) {
return super.clear(options);
}
if (this.options.disableForeignKeysForClear) {
await this.execute(this.helper.disableForeignKeysSQL());
}
const schema = options?.schema ?? this.config.get('schema', this.platform.getDefaultSchemaName());
for (const meta of this.getOrderedMetadata(schema).reverse()) {
try {
await this.driver
.createQueryBuilder(meta.class, this.em?.getTransactionContext(), 'write', false)
.withSchema(schema)
.truncate()
.execute();
} catch (e) {
if (this.platform.getExceptionConverter().convertException(e) instanceof TableNotFoundException) {
continue;
}
throw e;
}
}
if (this.options.disableForeignKeysForClear) {
await this.execute(this.helper.enableForeignKeysSQL());
}
if (options?.clearIdentityMap ?? true) {
this.clearIdentityMap();
}
}
async getDropSchemaSQL(options = {}) {
await this.ensureDatabase();
const metadata = this.getOrderedMetadata(options.schema).reverse();
const schemas = this.getTargetSchema(options.schema).getNamespaces();
const schema = await DatabaseSchema.create(
this.connection,
this.platform,
this.config,
options.schema,
schemas,
undefined,
this.options.skipTables,
this.options.skipViews,
);
const ret = [];
// Drop views first (views may depend on tables)
// Drop in reverse dependency order (dependent views first)
const targetSchema = this.getTargetSchema(options.schema);
const sortedViews = this.sortViewsByDependencies(targetSchema.getViews()).reverse();
for (const view of sortedViews) {
if (view.materialized) {
this.append(ret, this.helper.dropMaterializedViewIfExists(view.name, view.schema));
} else {
this.append(ret, this.helper.dropViewIfExists(view.name, view.schema));
}
}
// remove FKs explicitly if we can't use a cascading statement and we don't disable FK checks (we need this for circular relations)
for (const meta of metadata) {
if (!this.platform.usesCascadeStatement() && (!this.options.disableForeignKeys || options.dropForeignKeys)) {
const table = schema.getTable(meta.tableName);
if (!table) {
continue;
}
const foreignKeys = Object.values(table.getForeignKeys());
for (const fk of foreignKeys) {
this.append(ret, this.helper.dropForeignKey(table.getShortestName(), fk.constraintName));
}
}
}
for (const meta of metadata) {
this.append(ret, this.helper.dropTableIfExists(meta.tableName, this.getSchemaName(meta, options)));
}
if (this.platform.supportsNativeEnums()) {
for (const columnName of Object.keys(schema.getNativeEnums())) {
const sql = this.helper.getDropNativeEnumSQL(columnName, options.schema ?? this.config.get('schema'));
this.append(ret, sql);
}
}
if (options.dropMigrationsTable) {
this.append(
ret,
this.helper.dropTableIfExists(this.config.get('migrations').tableName, this.config.get('schema')),
);
}
return this.wrapSchema(ret, options);
}
getSchemaName(meta, options) {
const schemaName = options.schema ?? this.config.get('schema');
/* v8 ignore next */
const resolvedName = meta.schema && meta.schema === '*' ? schemaName : (meta.schema ?? schemaName);
// skip default schema name
if (resolvedName === this.platform.getDefaultSchemaName()) {
return undefined;
}
return resolvedName;
}
async update(options = {}) {
const sql = await this.getUpdateSchemaSQL(options);
await this.execute(sql);
}
async getUpdateSchemaSQL(options = {}) {
await this.ensureDatabase();
const { fromSchema, toSchema } = await this.prepareSchemaForComparison(options);
const comparator = new SchemaComparator(this.platform);
const diffUp = comparator.compare(fromSchema, toSchema);
return this.diffToSQL(diffUp, options);
}
async getUpdateSchemaMigrationSQL(options = {}) {
if (!options.fromSchema) {
await this.ensureDatabase();
}
const { fromSchema, toSchema } = await this.prepareSchemaForComparison(options);
const comparator = new SchemaComparator(this.platform);
const diffUp = comparator.compare(fromSchema, toSchema);
const diffDown = comparator.compare(toSchema, fromSchema, diffUp);
return {
up: this.diffToSQL(diffUp, options),
down: this.platform.supportsDownMigrations() ? this.diffToSQL(diffDown, options) : '',
};
}
async prepareSchemaForComparison(options) {
options.safe ??= false;
options.dropTables ??= true;
const toSchema = this.getTargetSchema(options.schema);
const schemas = toSchema.getNamespaces();
const fromSchema =
options.fromSchema ??
(await DatabaseSchema.create(
this.connection,
this.platform,
this.config,
options.schema,
schemas,
undefined,
this.options.skipTables,
this.options.skipViews,
));
const wildcardSchemaTables = [...this.metadata.getAll().values()]
.filter(meta => meta.schema === '*')
.map(meta => meta.tableName);
fromSchema.prune(options.schema, wildcardSchemaTables);
toSchema.prune(options.schema, wildcardSchemaTables);
return { fromSchema, toSchema };
}
diffToSQL(schemaDiff, options) {
const ret = [];
globalThis.idx = 0;
if (this.platform.supportsSchemas()) {
for (const newNamespace of schemaDiff.newNamespaces) {
const sql = this.helper.getCreateNamespaceSQL(newNamespace);
this.append(ret, sql);
}
}
if (this.platform.supportsNativeEnums()) {
for (const newNativeEnum of schemaDiff.newNativeEnums) {
const sql = this.helper.getCreateNativeEnumSQL(
newNativeEnum.name,
newNativeEnum.items,
this.getSchemaName(newNativeEnum, options),
);
this.append(ret, sql);
}
}
// Drop removed and changed views first (before modifying tables they may depend on)
// Drop in reverse dependency order (dependent views first)
if (options.dropTables && !options.safe) {
const sortedRemovedViews = this.sortViewsByDependencies(Object.values(schemaDiff.removedViews)).reverse();
for (const view of sortedRemovedViews) {
if (view.materialized) {
this.append(ret, this.helper.dropMaterializedViewIfExists(view.name, view.schema));
} else {
this.append(ret, this.helper.dropViewIfExists(view.name, view.schema));
}
}
}
// Drop changed views (they will be recreated after table changes)
// Also in reverse dependency order
const changedViewsFrom = Object.values(schemaDiff.changedViews).map(v => v.from);
const sortedChangedViewsFrom = this.sortViewsByDependencies(changedViewsFrom).reverse();
for (const view of sortedChangedViewsFrom) {
if (view.materialized) {
this.append(ret, this.helper.dropMaterializedViewIfExists(view.name, view.schema));
} else {
this.append(ret, this.helper.dropViewIfExists(view.name, view.schema));
}
}
if (!options.safe && this.options.createForeignKeyConstraints) {
for (const orphanedForeignKey of schemaDiff.orphanedForeignKeys) {
const [schemaName, tableName] = this.helper.splitTableName(orphanedForeignKey.localTableName, true);
/* v8 ignore next */
const name = (schemaName ? schemaName + '.' : '') + tableName;
this.append(ret, this.helper.dropForeignKey(name, orphanedForeignKey.constraintName));
}
if (schemaDiff.orphanedForeignKeys.length > 0) {
ret.push('');
}
}
for (const newTable of Object.values(schemaDiff.newTables)) {
this.append(ret, this.helper.createTable(newTable, true), true);
}
if (this.helper.supportsSchemaConstraints()) {
for (const newTable of Object.values(schemaDiff.newTables)) {
const sql = [];
if (this.options.createForeignKeyConstraints) {
const fks = Object.values(newTable.getForeignKeys()).map(fk => this.helper.createForeignKey(newTable, fk));
this.append(sql, fks);
}
for (const check of newTable.getChecks()) {
this.append(sql, this.helper.createCheck(newTable, check));
}
this.append(ret, sql, true);
}
}
if (options.dropTables && !options.safe) {
for (const table of Object.values(schemaDiff.removedTables)) {
this.append(ret, this.helper.dropTableIfExists(table.name, table.schema));
}
if (Utils.hasObjectKeys(schemaDiff.removedTables)) {
ret.push('');
}
}
for (const changedTable of Object.values(schemaDiff.changedTables)) {
this.append(ret, this.preAlterTable(changedTable, options.safe), true);
}
for (const changedTable of Object.values(schemaDiff.changedTables)) {
this.append(ret, this.helper.alterTable(changedTable, options.safe), true);
}
for (const changedTable of Object.values(schemaDiff.changedTables)) {
this.append(ret, this.helper.getPostAlterTable(changedTable, options.safe), true);
}
if (!options.safe && this.platform.supportsNativeEnums()) {
for (const removedNativeEnum of schemaDiff.removedNativeEnums) {
this.append(ret, this.helper.getDropNativeEnumSQL(removedNativeEnum.name, removedNativeEnum.schema));
}
}
if (options.dropTables && !options.safe) {
for (const removedNamespace of schemaDiff.removedNamespaces) {
const sql = this.helper.getDropNamespaceSQL(removedNamespace);
this.append(ret, sql);
}
}
// Create new views after all table changes are done
// Sort views by dependencies (views depending on other views come later)
const sortedNewViews = this.sortViewsByDependencies(Object.values(schemaDiff.newViews));
for (const view of sortedNewViews) {
if (view.materialized) {
this.append(
ret,
this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true),
);
} else {
this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
}
}
// Recreate changed views (also sorted by dependencies)
const changedViews = Object.values(schemaDiff.changedViews).map(v => v.to);
const sortedChangedViews = this.sortViewsByDependencies(changedViews);
for (const view of sortedChangedViews) {
if (view.materialized) {
this.append(
ret,
this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true),
);
} else {
this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
}
}
return this.wrapSchema(ret, options);
}
/**
* We need to drop foreign keys first for all tables to allow dropping PK constraints.
*/
preAlterTable(diff, safe) {
const ret = [];
this.append(ret, this.helper.getPreAlterTable(diff, safe));
for (const foreignKey of Object.values(diff.removedForeignKeys)) {
ret.push(this.helper.dropForeignKey(diff.toTable.getShortestName(), foreignKey.constraintName));
}
for (const foreignKey of Object.values(diff.changedForeignKeys)) {
ret.push(this.helper.dropForeignKey(diff.toTable.getShortestName(), foreignKey.constraintName));
}
return ret;
}
/**
* creates new database and connects to it
*/
async createDatabase(name, options) {
name ??= this.config.get('dbName');
const sql = this.helper.getCreateDatabaseSQL(name);
if (sql) {
await this.execute(sql);
}
this.config.set('dbName', name);
await this.driver.reconnect(options);
}
async dropDatabase(name) {
name ??= this.config.get('dbName');
this.config.set('dbName', this.helper.getManagementDbName());
await this.driver.reconnect();
await this.execute(this.helper.getDropDatabaseSQL(name));
this.config.set('dbName', name);
}
async execute(sql, options = {}) {
options.wrap ??= false;
const lines = this.wrapSchema(sql, options).split('\n');
const groups = [];
let i = 0;
for (const line of lines) {
if (line.trim() === '') {
if (groups[i]?.length > 0) {
i++;
}
continue;
}
groups[i] ??= [];
groups[i].push(line.trim());
}
if (groups.length === 0) {
return;
}
if (this.platform.supportsMultipleStatements()) {
for (const group of groups) {
const query = group.join('\n');
await this.driver.execute(query);
}
return;
}
const statements = groups.flatMap(group => {
return group
.join('\n')
.split(';\n')
.map(s => s.trim())
.filter(s => s);
});
await Utils.runSerial(statements, stmt => this.driver.execute(stmt));
}
async dropTableIfExists(name, schema) {
const sql = this.helper.dropTableIfExists(name, schema);
return this.execute(sql);
}
wrapSchema(sql, options) {
const array = Utils.asArray(sql);
if (array.length === 0) {
return '';
}
if (array[array.length - 1] === '') {
array.pop();
}
if (options.wrap === false) {
return array.join('\n') + '\n';
}
let ret = this.helper.getSchemaBeginning(this.config.get('charset'), this.options.disableForeignKeys);
ret += array.join('\n') + '\n';
ret += this.helper.getSchemaEnd(this.options.disableForeignKeys);
return ret;
}
append(array, sql, pad) {
return this.helper.append(array, sql, pad);
}
matchName(name, nameToMatch) {
return typeof nameToMatch === 'string'
? name.toLocaleLowerCase() === nameToMatch.toLocaleLowerCase()
: nameToMatch.test(name);
}
isTableSkipped(tableName, schemaName) {
const skipTables = this.options.skipTables;
if (!skipTables || skipTables.length === 0) {
return false;
}
const fullTableName = schemaName ? `${schemaName}.${tableName}` : tableName;
return skipTables.some(pattern => this.matchName(tableName, pattern) || this.matchName(fullTableName, pattern));
}
/**
* Sorts views by their dependencies so that views depending on other views are created after their dependencies.
* Uses topological sort based on view definition string matching.
*/
sortViewsByDependencies(views) {
if (views.length <= 1) {
return views;
}
// Use CommitOrderCalculator for topological sort
const calc = new CommitOrderCalculator();
// Map views to numeric indices for the calculator
const viewToIndex = new Map();
const indexToView = new Map();
for (let i = 0; i < views.length; i++) {
viewToIndex.set(views[i], i);
indexToView.set(i, views[i]);
calc.addNode(i);
}
// Check each view's definition for references to other view names
for (const view of views) {
const definition = view.definition.toLowerCase();
const viewIndex = viewToIndex.get(view);
for (const otherView of views) {
if (otherView === view) {
continue;
}
// Check if the definition references the other view's name
// Use word boundary matching to avoid false positives
const patterns = [new RegExp(`\\b${this.escapeRegExp(otherView.name.toLowerCase())}\\b`)];
if (otherView.schema) {
patterns.push(
new RegExp(`\\b${this.escapeRegExp(`${otherView.schema}.${otherView.name}`.toLowerCase())}\\b`),
);
}
for (const pattern of patterns) {
if (pattern.test(definition)) {
// view depends on otherView, so otherView must come first
// addDependency(from, to) puts `from` before `to` in result
const otherIndex = viewToIndex.get(otherView);
calc.addDependency(otherIndex, viewIndex, 1);
break;
}
}
}
}
// Sort and map back to views
return calc.sort().map(index => indexToView.get(index));
}
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
// for back compatibility
export { SqlSchemaGenerator as SchemaGenerator };