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 };