554 lines
21 KiB
JavaScript
554 lines
21 KiB
JavaScript
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 };
|