754 lines
31 KiB
JavaScript
754 lines
31 KiB
JavaScript
import { ArrayType, BooleanType, DateTimeType, inspect, JsonType, parseJsonSafe, Utils } from '@mikro-orm/core';
|
|
/**
|
|
* Compares two Schemas and return an instance of SchemaDifference.
|
|
*/
|
|
export class SchemaComparator {
|
|
#helper;
|
|
#logger;
|
|
#platform;
|
|
constructor(platform) {
|
|
this.#platform = platform;
|
|
this.#helper = this.#platform.getSchemaHelper();
|
|
this.#logger = this.#platform.getConfig().getLogger();
|
|
}
|
|
/**
|
|
* Returns a SchemaDifference object containing the differences between the schemas fromSchema and toSchema.
|
|
*
|
|
* The returned differences are returned in such a way that they contain the
|
|
* operations to change the schema stored in fromSchema to the schema that is
|
|
* stored in toSchema.
|
|
*/
|
|
compare(fromSchema, toSchema, inverseDiff) {
|
|
const diff = {
|
|
newTables: {},
|
|
removedTables: {},
|
|
changedTables: {},
|
|
newViews: {},
|
|
changedViews: {},
|
|
removedViews: {},
|
|
orphanedForeignKeys: [],
|
|
newNativeEnums: [],
|
|
removedNativeEnums: [],
|
|
newNamespaces: new Set(),
|
|
removedNamespaces: new Set(),
|
|
fromSchema,
|
|
};
|
|
const foreignKeysToTable = {};
|
|
for (const namespace of toSchema.getNamespaces()) {
|
|
if (fromSchema.hasNamespace(namespace) || namespace === this.#platform.getDefaultSchemaName()) {
|
|
continue;
|
|
}
|
|
diff.newNamespaces.add(namespace);
|
|
}
|
|
for (const namespace of fromSchema.getNamespaces()) {
|
|
if (toSchema.hasNamespace(namespace) || namespace === this.#platform.getDefaultSchemaName()) {
|
|
continue;
|
|
}
|
|
diff.removedNamespaces.add(namespace);
|
|
}
|
|
for (const [key, nativeEnum] of Object.entries(toSchema.getNativeEnums())) {
|
|
if (fromSchema.hasNativeEnum(key)) {
|
|
continue;
|
|
}
|
|
if (nativeEnum.schema === '*' && fromSchema.hasNativeEnum(`${toSchema.name}.${key}`)) {
|
|
continue;
|
|
}
|
|
diff.newNativeEnums.push(nativeEnum);
|
|
}
|
|
for (const [key, nativeEnum] of Object.entries(fromSchema.getNativeEnums())) {
|
|
if (toSchema.hasNativeEnum(key)) {
|
|
continue;
|
|
}
|
|
if (
|
|
key.startsWith(`${fromSchema.name}.`) &&
|
|
(fromSchema.name !== toSchema.name ||
|
|
toSchema.getNativeEnum(key.substring(fromSchema.name.length + 1))?.schema === '*')
|
|
) {
|
|
continue;
|
|
}
|
|
diff.removedNativeEnums.push(nativeEnum);
|
|
}
|
|
for (const table of toSchema.getTables()) {
|
|
const tableName = table.getShortestName(false);
|
|
if (!fromSchema.hasTable(tableName)) {
|
|
diff.newTables[tableName] = toSchema.getTable(tableName);
|
|
} else {
|
|
const tableDifferences = this.diffTable(
|
|
fromSchema.getTable(tableName),
|
|
toSchema.getTable(tableName),
|
|
inverseDiff?.changedTables[tableName],
|
|
);
|
|
if (tableDifferences !== false) {
|
|
diff.changedTables[tableName] = tableDifferences;
|
|
}
|
|
}
|
|
}
|
|
// Check if there are tables removed
|
|
for (let table of fromSchema.getTables()) {
|
|
const tableName = table.getShortestName();
|
|
table = fromSchema.getTable(tableName);
|
|
if (!toSchema.hasTable(tableName)) {
|
|
diff.removedTables[tableName] = table;
|
|
}
|
|
// also remember all foreign keys that point to a specific table
|
|
for (const foreignKey of Object.values(table.getForeignKeys())) {
|
|
if (!foreignKeysToTable[foreignKey.referencedTableName]) {
|
|
foreignKeysToTable[foreignKey.referencedTableName] = [];
|
|
}
|
|
foreignKeysToTable[foreignKey.referencedTableName].push(foreignKey);
|
|
}
|
|
}
|
|
for (const table of Object.values(diff.removedTables)) {
|
|
const tableName = (table.schema ? table.schema + '.' : '') + table.name;
|
|
if (!foreignKeysToTable[tableName]) {
|
|
continue;
|
|
}
|
|
diff.orphanedForeignKeys.push(...foreignKeysToTable[tableName]);
|
|
// Deleting duplicated foreign keys present both on the orphanedForeignKey and the removedForeignKeys from changedTables.
|
|
for (const foreignKey of foreignKeysToTable[tableName]) {
|
|
const localTableName = foreignKey.localTableName;
|
|
if (!diff.changedTables[localTableName]) {
|
|
continue;
|
|
}
|
|
for (const [key, fk] of Object.entries(diff.changedTables[localTableName].removedForeignKeys)) {
|
|
// We check if the key is from the removed table, if not we skip.
|
|
if (tableName !== fk.referencedTableName) {
|
|
continue;
|
|
}
|
|
delete diff.changedTables[localTableName].removedForeignKeys[key];
|
|
}
|
|
}
|
|
}
|
|
// Compare views
|
|
for (const toView of toSchema.getViews()) {
|
|
const viewName = toView.schema ? `${toView.schema}.${toView.name}` : toView.name;
|
|
if (!fromSchema.hasView(toView.name) && !fromSchema.hasView(viewName)) {
|
|
diff.newViews[viewName] = toView;
|
|
this.log(`view ${viewName} added`);
|
|
} else {
|
|
const fromView = fromSchema.getView(toView.name) ?? fromSchema.getView(viewName);
|
|
if (fromView && this.diffExpression(fromView.definition, toView.definition)) {
|
|
diff.changedViews[viewName] = { from: fromView, to: toView };
|
|
this.log(`view ${viewName} changed`);
|
|
}
|
|
}
|
|
}
|
|
// Check for removed views
|
|
for (const fromView of fromSchema.getViews()) {
|
|
const viewName = fromView.schema ? `${fromView.schema}.${fromView.name}` : fromView.name;
|
|
if (!toSchema.hasView(fromView.name) && !toSchema.hasView(viewName)) {
|
|
diff.removedViews[viewName] = fromView;
|
|
this.log(`view ${viewName} removed`);
|
|
}
|
|
}
|
|
return diff;
|
|
}
|
|
/**
|
|
* Returns the difference between the tables fromTable and toTable.
|
|
* If there are no differences this method returns the boolean false.
|
|
*/
|
|
diffTable(fromTable, toTable, inverseTableDiff) {
|
|
let changes = 0;
|
|
const tableDifferences = {
|
|
name: fromTable.getShortestName(),
|
|
addedColumns: {},
|
|
addedForeignKeys: {},
|
|
addedIndexes: {},
|
|
addedChecks: {},
|
|
changedColumns: {},
|
|
changedForeignKeys: {},
|
|
changedIndexes: {},
|
|
changedChecks: {},
|
|
removedColumns: {},
|
|
removedForeignKeys: {},
|
|
removedIndexes: {},
|
|
removedChecks: {},
|
|
renamedColumns: {},
|
|
renamedIndexes: {},
|
|
fromTable,
|
|
toTable,
|
|
};
|
|
if (this.diffComment(fromTable.comment, toTable.comment)) {
|
|
tableDifferences.changedComment = toTable.comment;
|
|
this.log(`table comment changed for ${tableDifferences.name}`, {
|
|
fromTableComment: fromTable.comment,
|
|
toTableComment: toTable.comment,
|
|
});
|
|
changes++;
|
|
}
|
|
const fromTableColumns = fromTable.getColumns();
|
|
const toTableColumns = toTable.getColumns();
|
|
// See if all the columns in "from" table exist in "to" table
|
|
for (const column of toTableColumns) {
|
|
if (fromTable.hasColumn(column.name)) {
|
|
continue;
|
|
}
|
|
tableDifferences.addedColumns[column.name] = column;
|
|
this.log(`column ${tableDifferences.name}.${column.name} of type ${column.type} added`);
|
|
changes++;
|
|
}
|
|
/* See if there are any removed columns in "to" table */
|
|
for (const column of fromTableColumns) {
|
|
// See if column is removed in "to" table.
|
|
if (!toTable.hasColumn(column.name)) {
|
|
tableDifferences.removedColumns[column.name] = column;
|
|
this.log(`column ${tableDifferences.name}.${column.name} removed`);
|
|
changes++;
|
|
continue;
|
|
}
|
|
// See if column has changed properties in "to" table.
|
|
const changedProperties = this.diffColumn(column, toTable.getColumn(column.name), fromTable, true);
|
|
if (changedProperties.size === 0) {
|
|
continue;
|
|
}
|
|
if (changedProperties.size === 1 && changedProperties.has('generated')) {
|
|
tableDifferences.addedColumns[column.name] = toTable.getColumn(column.name);
|
|
tableDifferences.removedColumns[column.name] = column;
|
|
changes++;
|
|
continue;
|
|
}
|
|
tableDifferences.changedColumns[column.name] = {
|
|
oldColumnName: column.name,
|
|
fromColumn: column,
|
|
column: toTable.getColumn(column.name),
|
|
changedProperties,
|
|
};
|
|
this.log(`column ${tableDifferences.name}.${column.name} changed`, { changedProperties });
|
|
changes++;
|
|
}
|
|
this.detectColumnRenamings(tableDifferences, inverseTableDiff);
|
|
const fromTableIndexes = fromTable.getIndexes();
|
|
const toTableIndexes = toTable.getIndexes();
|
|
// See if all the indexes in "from" table exist in "to" table
|
|
for (const index of Object.values(toTableIndexes)) {
|
|
if ((index.primary && fromTableIndexes.find(i => i.primary)) || fromTable.hasIndex(index.keyName)) {
|
|
continue;
|
|
}
|
|
tableDifferences.addedIndexes[index.keyName] = index;
|
|
this.log(`index ${index.keyName} added to table ${tableDifferences.name}`, { index });
|
|
changes++;
|
|
}
|
|
// See if there are any removed indexes in "to" table
|
|
for (const index of fromTableIndexes) {
|
|
// See if index is removed in "to" table.
|
|
if ((index.primary && !toTable.hasPrimaryKey()) || (!index.primary && !toTable.hasIndex(index.keyName))) {
|
|
tableDifferences.removedIndexes[index.keyName] = index;
|
|
this.log(`index ${index.keyName} removed from table ${tableDifferences.name}`);
|
|
changes++;
|
|
continue;
|
|
}
|
|
// See if index has changed in "to" table.
|
|
const toTableIndex = index.primary ? toTable.getPrimaryKey() : toTable.getIndex(index.keyName);
|
|
if (!this.diffIndex(index, toTableIndex)) {
|
|
continue;
|
|
}
|
|
tableDifferences.changedIndexes[index.keyName] = toTableIndex;
|
|
this.log(`index ${index.keyName} changed in table ${tableDifferences.name}`, {
|
|
fromTableIndex: index,
|
|
toTableIndex,
|
|
});
|
|
changes++;
|
|
}
|
|
this.detectIndexRenamings(tableDifferences);
|
|
const fromTableChecks = fromTable.getChecks();
|
|
const toTableChecks = toTable.getChecks();
|
|
// See if all the checks in "from" table exist in "to" table
|
|
for (const check of toTableChecks) {
|
|
if (fromTable.hasCheck(check.name)) {
|
|
continue;
|
|
}
|
|
tableDifferences.addedChecks[check.name] = check;
|
|
this.log(`check constraint ${check.name} added to table ${tableDifferences.name}`, { check });
|
|
changes++;
|
|
}
|
|
// See if there are any removed checks in "to" table
|
|
for (const check of fromTableChecks) {
|
|
if (!toTable.hasCheck(check.name)) {
|
|
tableDifferences.removedChecks[check.name] = check;
|
|
this.log(`check constraint ${check.name} removed from table ${tableDifferences.name}`);
|
|
changes++;
|
|
continue;
|
|
}
|
|
// See if check has changed in "to" table
|
|
const toTableCheck = toTable.getCheck(check.name);
|
|
const toColumn = toTable.getColumn(check.columnName);
|
|
const fromColumn = fromTable.getColumn(check.columnName);
|
|
if (!this.diffExpression(check.expression, toTableCheck.expression)) {
|
|
continue;
|
|
}
|
|
if (
|
|
fromColumn?.enumItems &&
|
|
toColumn?.enumItems &&
|
|
!this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)
|
|
) {
|
|
continue;
|
|
}
|
|
this.log(`check constraint ${check.name} changed in table ${tableDifferences.name}`, {
|
|
fromTableCheck: check,
|
|
toTableCheck,
|
|
});
|
|
tableDifferences.changedChecks[check.name] = toTableCheck;
|
|
changes++;
|
|
}
|
|
const fromForeignKeys = { ...fromTable.getForeignKeys() };
|
|
const toForeignKeys = { ...toTable.getForeignKeys() };
|
|
for (const fromConstraint of Object.values(fromForeignKeys)) {
|
|
for (const toConstraint of Object.values(toForeignKeys)) {
|
|
if (!this.diffForeignKey(fromConstraint, toConstraint, tableDifferences)) {
|
|
delete fromForeignKeys[fromConstraint.constraintName];
|
|
delete toForeignKeys[toConstraint.constraintName];
|
|
} else if (fromConstraint.constraintName.toLowerCase() === toConstraint.constraintName.toLowerCase()) {
|
|
this.log(`FK constraint ${fromConstraint.constraintName} changed in table ${tableDifferences.name}`, {
|
|
fromConstraint,
|
|
toConstraint,
|
|
});
|
|
tableDifferences.changedForeignKeys[toConstraint.constraintName] = toConstraint;
|
|
changes++;
|
|
delete fromForeignKeys[fromConstraint.constraintName];
|
|
delete toForeignKeys[toConstraint.constraintName];
|
|
}
|
|
}
|
|
}
|
|
for (const fromConstraint of Object.values(fromForeignKeys)) {
|
|
tableDifferences.removedForeignKeys[fromConstraint.constraintName] = fromConstraint;
|
|
this.log(`FK constraint ${fromConstraint.constraintName} removed from table ${tableDifferences.name}`);
|
|
changes++;
|
|
}
|
|
for (const toConstraint of Object.values(toForeignKeys)) {
|
|
tableDifferences.addedForeignKeys[toConstraint.constraintName] = toConstraint;
|
|
this.log(`FK constraint ${toConstraint.constraintName} added to table ${tableDifferences.name}`, {
|
|
constraint: toConstraint,
|
|
});
|
|
changes++;
|
|
}
|
|
return changes ? tableDifferences : false;
|
|
}
|
|
/**
|
|
* Try to find columns that only changed their name, rename operations maybe cheaper than add/drop
|
|
* however ambiguities between different possibilities should not lead to renaming at all.
|
|
*/
|
|
detectColumnRenamings(tableDifferences, inverseTableDiff) {
|
|
const renameCandidates = {};
|
|
const oldFKs = Object.values(tableDifferences.fromTable.getForeignKeys());
|
|
const newFKs = Object.values(tableDifferences.toTable.getForeignKeys());
|
|
for (const addedColumn of Object.values(tableDifferences.addedColumns)) {
|
|
for (const removedColumn of Object.values(tableDifferences.removedColumns)) {
|
|
const diff = this.diffColumn(addedColumn, removedColumn, tableDifferences.fromTable);
|
|
if (diff.size !== 0) {
|
|
continue;
|
|
}
|
|
const wasFK = oldFKs.some(fk => fk.columnNames.includes(removedColumn.name));
|
|
const isFK = newFKs.some(fk => fk.columnNames.includes(addedColumn.name));
|
|
if (wasFK !== isFK) {
|
|
continue;
|
|
}
|
|
const renamedColumn = inverseTableDiff?.renamedColumns[addedColumn.name];
|
|
if (renamedColumn && renamedColumn?.name !== removedColumn.name) {
|
|
continue;
|
|
}
|
|
renameCandidates[addedColumn.name] = renameCandidates[addedColumn.name] ?? [];
|
|
renameCandidates[addedColumn.name].push([removedColumn, addedColumn]);
|
|
}
|
|
}
|
|
for (const candidateColumns of Object.values(renameCandidates)) {
|
|
if (candidateColumns.length !== 1) {
|
|
continue;
|
|
}
|
|
const [removedColumn, addedColumn] = candidateColumns[0];
|
|
const removedColumnName = removedColumn.name;
|
|
const addedColumnName = addedColumn.name;
|
|
/* v8 ignore next */
|
|
if (tableDifferences.renamedColumns[removedColumnName]) {
|
|
continue;
|
|
}
|
|
tableDifferences.renamedColumns[removedColumnName] = addedColumn;
|
|
delete tableDifferences.addedColumns[addedColumnName];
|
|
delete tableDifferences.removedColumns[removedColumnName];
|
|
this.log(`renamed column detected in table ${tableDifferences.name}`, {
|
|
old: removedColumnName,
|
|
new: addedColumnName,
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Try to find indexes that only changed their name, rename operations maybe cheaper than add/drop
|
|
* however ambiguities between different possibilities should not lead to renaming at all.
|
|
*/
|
|
detectIndexRenamings(tableDifferences) {
|
|
const renameCandidates = {};
|
|
// Gather possible rename candidates by comparing each added and removed index based on semantics.
|
|
for (const addedIndex of Object.values(tableDifferences.addedIndexes)) {
|
|
for (const removedIndex of Object.values(tableDifferences.removedIndexes)) {
|
|
if (this.diffIndex(addedIndex, removedIndex)) {
|
|
continue;
|
|
}
|
|
renameCandidates[addedIndex.keyName] = renameCandidates[addedIndex.keyName] ?? [];
|
|
renameCandidates[addedIndex.keyName].push([removedIndex, addedIndex]);
|
|
}
|
|
}
|
|
for (const candidateIndexes of Object.values(renameCandidates)) {
|
|
// If the current rename candidate contains exactly one semantically equal index, we can safely rename it.
|
|
// Otherwise it is unclear if a rename action is really intended, therefore we let those ambiguous indexes be added/dropped.
|
|
if (candidateIndexes.length !== 1) {
|
|
continue;
|
|
}
|
|
const [removedIndex, addedIndex] = candidateIndexes[0];
|
|
const removedIndexName = removedIndex.keyName;
|
|
const addedIndexName = addedIndex.keyName;
|
|
if (tableDifferences.renamedIndexes[removedIndexName]) {
|
|
continue;
|
|
}
|
|
tableDifferences.renamedIndexes[removedIndexName] = addedIndex;
|
|
delete tableDifferences.addedIndexes[addedIndexName];
|
|
delete tableDifferences.removedIndexes[removedIndexName];
|
|
this.log(`renamed index detected in table ${tableDifferences.name}`, {
|
|
old: removedIndexName,
|
|
new: addedIndexName,
|
|
});
|
|
}
|
|
}
|
|
diffForeignKey(key1, key2, tableDifferences) {
|
|
if (key1.columnNames.join('~').toLowerCase() !== key2.columnNames.join('~').toLowerCase()) {
|
|
return true;
|
|
}
|
|
if (key1.referencedColumnNames.join('~').toLowerCase() !== key2.referencedColumnNames.join('~').toLowerCase()) {
|
|
return true;
|
|
}
|
|
if (key1.constraintName !== key2.constraintName) {
|
|
return true;
|
|
}
|
|
if (key1.referencedTableName !== key2.referencedTableName) {
|
|
return true;
|
|
}
|
|
if (key1.deferMode !== key2.deferMode) {
|
|
return true;
|
|
}
|
|
if (key1.localTableName === key1.referencedTableName && !this.#platform.supportsMultipleCascadePaths()) {
|
|
return false;
|
|
}
|
|
if (key1.columnNames.some(col => tableDifferences.changedColumns[col]?.changedProperties.has('type'))) {
|
|
return true;
|
|
}
|
|
const defaultRule = ['restrict', 'no action'];
|
|
const rule = (key, method) => {
|
|
return (key[method] ?? defaultRule[0]).toLowerCase().replace(defaultRule[1], defaultRule[0]).replace(/"/g, '');
|
|
};
|
|
const compare = method => rule(key1, method) === rule(key2, method);
|
|
// Skip updateRule comparison for platforms that don't support ON UPDATE (e.g., Oracle)
|
|
const updateRuleDiffers = this.#platform.supportsOnUpdate() && !compare('updateRule');
|
|
return updateRuleDiffers || !compare('deleteRule');
|
|
}
|
|
/**
|
|
* Returns the difference between the columns
|
|
*/
|
|
diffColumn(fromColumn, toColumn, fromTable, logging) {
|
|
const changedProperties = new Set();
|
|
const fromProp = this.mapColumnToProperty({ ...fromColumn, autoincrement: false });
|
|
const toProp = this.mapColumnToProperty({ ...toColumn, autoincrement: false });
|
|
const fromColumnType = this.#platform.normalizeColumnType(
|
|
fromColumn.mappedType.getColumnType(fromProp, this.#platform).toLowerCase(),
|
|
fromProp,
|
|
);
|
|
const fromNativeEnum =
|
|
fromTable.nativeEnums[fromColumnType] ??
|
|
Object.values(fromTable.nativeEnums).find(e => e.name === fromColumnType && e.schema !== '*');
|
|
let toColumnType = this.#platform.normalizeColumnType(
|
|
toColumn.mappedType.getColumnType(toProp, this.#platform).toLowerCase(),
|
|
toProp,
|
|
);
|
|
const log = (msg, params) => {
|
|
if (logging) {
|
|
const copy = Utils.copy(params);
|
|
Utils.dropUndefinedProperties(copy);
|
|
this.log(msg, copy);
|
|
}
|
|
};
|
|
if (
|
|
fromColumnType !== toColumnType &&
|
|
(!fromNativeEnum || `${fromNativeEnum.schema}.${fromNativeEnum.name}` !== toColumnType) &&
|
|
!(fromColumn.ignoreSchemaChanges?.includes('type') || toColumn.ignoreSchemaChanges?.includes('type')) &&
|
|
!fromColumn.generated &&
|
|
!toColumn.generated
|
|
) {
|
|
if (
|
|
!toColumnType.includes('.') &&
|
|
fromTable.schema &&
|
|
fromTable.schema !== this.#platform.getDefaultSchemaName()
|
|
) {
|
|
toColumnType = `${fromTable.schema}.${toColumnType}`;
|
|
}
|
|
if (fromColumnType !== toColumnType) {
|
|
log(`'type' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumnType, toColumnType });
|
|
changedProperties.add('type');
|
|
}
|
|
}
|
|
if (!!fromColumn.nullable !== !!toColumn.nullable && !fromColumn.generated && !toColumn.generated) {
|
|
log(`'nullable' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('nullable');
|
|
}
|
|
if (this.diffExpression(fromColumn.generated, toColumn.generated)) {
|
|
log(`'generated' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('generated');
|
|
}
|
|
if (!!fromColumn.autoincrement !== !!toColumn.autoincrement) {
|
|
log(`'autoincrement' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('autoincrement');
|
|
}
|
|
if (!!fromColumn.unsigned !== !!toColumn.unsigned && this.#platform.supportsUnsigned()) {
|
|
log(`'unsigned' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('unsigned');
|
|
}
|
|
if (
|
|
!(fromColumn.ignoreSchemaChanges?.includes('default') || toColumn.ignoreSchemaChanges?.includes('default')) &&
|
|
!this.hasSameDefaultValue(fromColumn, toColumn)
|
|
) {
|
|
log(`'default' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('default');
|
|
}
|
|
if (this.diffComment(fromColumn.comment, toColumn.comment)) {
|
|
log(`'comment' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('comment');
|
|
}
|
|
if (
|
|
!(fromColumn.mappedType instanceof ArrayType) &&
|
|
!(toColumn.mappedType instanceof ArrayType) &&
|
|
this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)
|
|
) {
|
|
log(`'enumItems' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('enumItems');
|
|
}
|
|
if (
|
|
(fromColumn.extra || '').toLowerCase() !== (toColumn.extra || '').toLowerCase() &&
|
|
!(fromColumn.ignoreSchemaChanges?.includes('extra') || toColumn.ignoreSchemaChanges?.includes('extra'))
|
|
) {
|
|
log(`'extra' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
|
|
changedProperties.add('extra');
|
|
}
|
|
return changedProperties;
|
|
}
|
|
diffEnumItems(items1 = [], items2 = []) {
|
|
return items1.length !== items2.length || items1.some((v, i) => v !== items2[i]);
|
|
}
|
|
diffComment(comment1, comment2) {
|
|
// A null value and an empty string are actually equal for a comment so they should not trigger a change.
|
|
// eslint-disable-next-line eqeqeq
|
|
return comment1 != comment2 && !(comment1 == null && comment2 === '') && !(comment2 == null && comment1 === '');
|
|
}
|
|
/**
|
|
* Finds the difference between the indexes index1 and index2.
|
|
* Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
|
|
*/
|
|
diffIndex(index1, index2) {
|
|
// if one of them is a custom expression or full text index, compare only by name
|
|
if (index1.expression || index2.expression || index1.type === 'fulltext' || index2.type === 'fulltext') {
|
|
return index1.keyName !== index2.keyName;
|
|
}
|
|
return !this.isIndexFulfilledBy(index1, index2) || !this.isIndexFulfilledBy(index2, index1);
|
|
}
|
|
/**
|
|
* Checks if the other index already fulfills all the indexing and constraint needs of the current one.
|
|
*/
|
|
isIndexFulfilledBy(index1, index2) {
|
|
// allow the other index to be equally large only. It being larger is an option but it creates a problem with scenarios of the kind PRIMARY KEY(foo,bar) UNIQUE(foo)
|
|
if (index1.columnNames.length !== index2.columnNames.length) {
|
|
return false;
|
|
}
|
|
function spansColumns() {
|
|
for (let i = 0; i < index1.columnNames.length; i++) {
|
|
if (index1.columnNames[i] === index2.columnNames[i]) {
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
// Check if columns are the same, and even in the same order
|
|
if (!spansColumns()) {
|
|
return false;
|
|
}
|
|
// Compare advanced column options (sort order, nulls, length, collation)
|
|
if (!this.compareIndexColumns(index1, index2)) {
|
|
return false;
|
|
}
|
|
// Compare INCLUDE columns for covering indexes
|
|
if (!this.compareArrays(index1.include, index2.include)) {
|
|
return false;
|
|
}
|
|
// Compare fill factor
|
|
if (index1.fillFactor !== index2.fillFactor) {
|
|
return false;
|
|
}
|
|
// Compare invisible flag
|
|
if (!!index1.invisible !== !!index2.invisible) {
|
|
return false;
|
|
}
|
|
// Compare disabled flag
|
|
if (!!index1.disabled !== !!index2.disabled) {
|
|
return false;
|
|
}
|
|
// Compare clustered flag
|
|
if (!!index1.clustered !== !!index2.clustered) {
|
|
return false;
|
|
}
|
|
if (!index1.unique && !index1.primary) {
|
|
// this is a special case: If the current key is neither primary or unique, any unique or
|
|
// primary key will always have the same effect for the index and there cannot be any constraint
|
|
// overlaps. This means a primary or unique index can always fulfill the requirements of just an
|
|
// index that has no constraints.
|
|
return true;
|
|
}
|
|
if (this.#platform.supportsDeferredUniqueConstraints() && index1.deferMode !== index2.deferMode) {
|
|
return false;
|
|
}
|
|
return index1.primary === index2.primary && index1.unique === index2.unique;
|
|
}
|
|
/**
|
|
* Compare advanced column options between two indexes.
|
|
*/
|
|
compareIndexColumns(index1, index2) {
|
|
const cols1 = index1.columns ?? [];
|
|
const cols2 = index2.columns ?? [];
|
|
// If neither has column options, they match
|
|
if (cols1.length === 0 && cols2.length === 0) {
|
|
return true;
|
|
}
|
|
// If only one has column options, they don't match
|
|
if (cols1.length !== cols2.length) {
|
|
return false;
|
|
}
|
|
// Compare each column's options
|
|
// Note: We don't check c1.name !== c2.name because the indexes already have matching columnNames
|
|
// and the columns array is derived from those same column names
|
|
for (let i = 0; i < cols1.length; i++) {
|
|
const c1 = cols1[i];
|
|
const c2 = cols2[i];
|
|
const sort1 = c1.sort?.toUpperCase() ?? 'ASC';
|
|
const sort2 = c2.sort?.toUpperCase() ?? 'ASC';
|
|
if (sort1 !== sort2) {
|
|
return false;
|
|
}
|
|
const defaultNulls = s => (s === 'DESC' ? 'FIRST' : 'LAST');
|
|
const nulls1 = c1.nulls?.toUpperCase() ?? defaultNulls(sort1);
|
|
const nulls2 = c2.nulls?.toUpperCase() ?? defaultNulls(sort2);
|
|
if (nulls1 !== nulls2) {
|
|
return false;
|
|
}
|
|
if (c1.length !== c2.length) {
|
|
return false;
|
|
}
|
|
if (c1.collation !== c2.collation) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
/**
|
|
* Compare two arrays for equality (order matters).
|
|
*/
|
|
compareArrays(arr1, arr2) {
|
|
if (!arr1 && !arr2) {
|
|
return true;
|
|
}
|
|
if (!arr1 || !arr2 || arr1.length !== arr2.length) {
|
|
return false;
|
|
}
|
|
return arr1.every((val, i) => val === arr2[i]);
|
|
}
|
|
diffExpression(expr1, expr2) {
|
|
// expressions like check constraints might be normalized by the driver,
|
|
// e.g. quotes might be added (https://github.com/mikro-orm/mikro-orm/issues/3827)
|
|
const simplify = str => {
|
|
return (
|
|
str
|
|
?.replace(/_\w+'(.*?)'/g, '$1')
|
|
.replace(/in\s*\((.*?)\)/gi, '= any (array[$1])')
|
|
// MySQL normalizes count(*) to count(0)
|
|
.replace(/\bcount\s*\(\s*0\s*\)/gi, 'count(*)')
|
|
// Remove quotes first so we can process identifiers
|
|
.replace(/['"`]/g, '')
|
|
// MySQL adds table/alias prefixes to columns (e.g., a.name or table_name.column vs just column)
|
|
// Strip these prefixes - match word.word patterns and keep only the last part
|
|
.replace(/\b\w+\.(\w+)/g, '$1')
|
|
// Normalize JOIN syntax: inner join -> join (equivalent in SQL)
|
|
.replace(/\binner\s+join\b/gi, 'join')
|
|
// Remove redundant column aliases like `title AS title` -> `title`
|
|
.replace(/\b(\w+)\s+as\s+\1\b/gi, '$1')
|
|
// Remove AS keyword (optional in SQL, MySQL may add/remove it)
|
|
.replace(/\bas\b/gi, '')
|
|
// Remove remaining special chars, parentheses, type casts, asterisks, and normalize whitespace
|
|
.replace(/[()\n[\]*]|::\w+| +/g, '')
|
|
.replace(/anyarray\[(.*)]/gi, '$1')
|
|
.toLowerCase()
|
|
// PostgreSQL adds default aliases to aggregate functions (e.g., count(*) AS count)
|
|
// After removing AS and whitespace, this results in duplicate adjacent words
|
|
// Remove these duplicates: "countcount" -> "count", "minmin" -> "min"
|
|
// Use lookahead to match repeated patterns of 3+ chars (avoid false positives on short sequences)
|
|
.replace(/(\w{3,})\1/g, '$1')
|
|
// Remove trailing semicolon (PostgreSQL adds it to view definitions)
|
|
.replace(/;$/, '')
|
|
);
|
|
};
|
|
return simplify(expr1) !== simplify(expr2);
|
|
}
|
|
parseJsonDefault(defaultValue) {
|
|
/* v8 ignore next */
|
|
if (!defaultValue) {
|
|
return null;
|
|
}
|
|
const val = defaultValue.replace(/^(_\w+\\)?'(.*?)\\?'$/, '$2').replace(/^\(?'(.*?)'\)?$/, '$1');
|
|
return parseJsonSafe(val);
|
|
}
|
|
hasSameDefaultValue(from, to) {
|
|
if (
|
|
from.default == null ||
|
|
from.default.toString().toLowerCase() === 'null' ||
|
|
from.default.toString().startsWith('nextval(')
|
|
) {
|
|
return to.default == null || to.default.toLowerCase() === 'null';
|
|
}
|
|
if (to.mappedType instanceof BooleanType) {
|
|
const defaultValueFrom = !['0', 'false', 'f', 'n', 'no', 'off'].includes('' + from.default);
|
|
const defaultValueTo = !['0', 'false', 'f', 'n', 'no', 'off'].includes('' + to.default);
|
|
return defaultValueFrom === defaultValueTo;
|
|
}
|
|
if (to.mappedType instanceof JsonType) {
|
|
const defaultValueFrom = this.parseJsonDefault(from.default);
|
|
const defaultValueTo = this.parseJsonDefault(to.default);
|
|
return Utils.equals(defaultValueFrom, defaultValueTo);
|
|
}
|
|
if (to.mappedType instanceof DateTimeType && from.default && to.default) {
|
|
// normalize now/current_timestamp defaults, also remove `()` from the end of default expression
|
|
const defaultValueFrom = from.default.toLowerCase().replace('current_timestamp', 'now').replace(/\(\)$/, '');
|
|
const defaultValueTo = to.default.toLowerCase().replace('current_timestamp', 'now').replace(/\(\)$/, '');
|
|
return defaultValueFrom === defaultValueTo;
|
|
}
|
|
if (from.default && to.default) {
|
|
return from.default.toString().toLowerCase() === to.default.toString().toLowerCase();
|
|
}
|
|
if (['', this.#helper.getDefaultEmptyString()].includes(to.default) && from.default != null) {
|
|
return ['', this.#helper.getDefaultEmptyString()].includes(from.default.toString());
|
|
}
|
|
// eslint-disable-next-line eqeqeq
|
|
return from.default == to.default; // == intentionally
|
|
}
|
|
mapColumnToProperty(column) {
|
|
const length = /\w+\((\d+)\)/.exec(column.type);
|
|
const match = /\w+\((\d+), ?(\d+)\)/.exec(column.type);
|
|
return {
|
|
fieldNames: [column.name],
|
|
columnTypes: [column.type],
|
|
items: column.enumItems,
|
|
...column,
|
|
length: length ? +length[1] : column.length,
|
|
precision: match ? +match[1] : column.precision,
|
|
scale: match ? +match[2] : column.scale,
|
|
};
|
|
}
|
|
log(message, params) {
|
|
if (params) {
|
|
message += ' ' + inspect(params);
|
|
}
|
|
this.#logger.log('schema', message);
|
|
}
|
|
}
|