import { getColumnType, schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql'; import { DatabaseCheckConstraint, DatabaseColumn, DatabaseConstraint, DatabaseConstraintType, DatabaseForeignKeyConstraint, DatabaseIndex, DatabasePrimaryKeyConstraint, DatabaseSchema, DatabaseTable, DatabaseUniqueConstraint, SchemaDiff, SchemaDiffToSqlOptions, } from 'src/sql-tools/types'; enum Reason { MissingInSource = 'missing in source', MissingInTarget = 'missing in target', } const setIsEqual = (source: Set, target: Set) => source.size === target.size && [...source].every((x) => target.has(x)); const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? [])); }; const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { return source?.synchronize === false || target?.synchronize === false; }; const withTypeCast = (value: string, type: string) => { if (!value.startsWith(`'`)) { value = `'${value}'`; } return `${value}::${type}`; }; const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { if (source.default === target.default) { return true; } if (source.default === undefined || target.default === undefined) { return false; } if ( withTypeCast(source.default, getColumnType(source)) === target.default || source.default === withTypeCast(target.default, getColumnType(target)) ) { return true; } return false; }; /** * Compute the difference between two database schemas */ export const schemaDiff = ( source: DatabaseSchema, target: DatabaseSchema, options: { ignoreExtraTables?: boolean } = {}, ) => { const items = diffTables(source.tables, target.tables, { ignoreExtraTables: options.ignoreExtraTables ?? true, }); return { items, asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(items, options), }; }; export const diffTables = ( sources: DatabaseTable[], targets: DatabaseTable[], options: { ignoreExtraTables: boolean }, ) => { const items: SchemaDiff[] = []; const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); for (const key of keys) { if (options.ignoreExtraTables && !sourceMap[key]) { continue; } items.push(...diffTable(sourceMap[key], targetMap[key])); } return items; }; const diffTable = (source?: DatabaseTable, target?: DatabaseTable): SchemaDiff[] => { if (isSynchronizeDisabled(source, target)) { return []; } if (source && !target) { return [ { type: 'table.create', tableName: source.name, columns: Object.values(source.columns), reason: Reason.MissingInTarget, }, ...diffIndexes(source.indexes, []), // TODO merge constraints into table create record when possible ...diffConstraints(source.constraints, []), ]; } if (!source && target) { return [ { type: 'table.drop', tableName: target.name, reason: Reason.MissingInSource, }, ]; } if (!source || !target) { return []; } return [ ...diffColumns(source.columns, target.columns), ...diffConstraints(source.constraints, target.constraints), ...diffIndexes(source.indexes, target.indexes), ]; }; const diffColumns = (sources: DatabaseColumn[], targets: DatabaseColumn[]): SchemaDiff[] => { const items: SchemaDiff[] = []; const sourceMap = Object.fromEntries(sources.map((column) => [column.name, column])); const targetMap = Object.fromEntries(targets.map((column) => [column.name, column])); const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); for (const key of keys) { items.push(...diffColumn(sourceMap[key], targetMap[key])); } return items; }; const diffColumn = (source?: DatabaseColumn, target?: DatabaseColumn): SchemaDiff[] => { if (isSynchronizeDisabled(source, target)) { return []; } if (source && !target) { return [ { type: 'column.add', column: source, reason: Reason.MissingInTarget, }, ]; } if (!source && target) { return [ { type: 'column.drop', tableName: target.tableName, columnName: target.name, reason: Reason.MissingInSource, }, ]; } if (!source || !target) { return []; } const sourceType = getColumnType(source); const targetType = getColumnType(target); const isTypeChanged = sourceType !== targetType; if (isTypeChanged) { // TODO: convert between types via UPDATE when possible return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); } const items: SchemaDiff[] = []; if (source.nullable !== target.nullable) { items.push({ type: 'column.alter', tableName: source.tableName, columnName: source.name, changes: { nullable: source.nullable, }, reason: `nullable is different (${source.nullable} vs ${target.nullable})`, }); } if (!isDefaultEqual(source, target)) { items.push({ type: 'column.alter', tableName: source.tableName, columnName: source.name, changes: { default: String(source.default), }, reason: `default is different (${source.default} vs ${target.default})`, }); } return items; }; const diffConstraints = (sources: DatabaseConstraint[], targets: DatabaseConstraint[]): SchemaDiff[] => { const items: SchemaDiff[] = []; for (const type of Object.values(DatabaseConstraintType)) { const sourceMap = Object.fromEntries(sources.filter((item) => item.type === type).map((item) => [item.name, item])); const targetMap = Object.fromEntries(targets.filter((item) => item.type === type).map((item) => [item.name, item])); const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); for (const key of keys) { items.push(...diffConstraint(sourceMap[key], targetMap[key])); } } return items; }; const diffConstraint = (source?: T, target?: T): SchemaDiff[] => { if (isSynchronizeDisabled(source, target)) { return []; } if (source && !target) { return [ { type: 'constraint.add', constraint: source, reason: Reason.MissingInTarget, }, ]; } if (!source && target) { return [ { type: 'constraint.drop', tableName: target.tableName, constraintName: target.name, reason: Reason.MissingInSource, }, ]; } if (!source || !target) { return []; } switch (source.type) { case DatabaseConstraintType.PRIMARY_KEY: { return diffPrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); } case DatabaseConstraintType.FOREIGN_KEY: { return diffForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); } case DatabaseConstraintType.UNIQUE: { return diffUniqueConstraint(source, target as DatabaseUniqueConstraint); } case DatabaseConstraintType.CHECK: { return diffCheckConstraint(source, target as DatabaseCheckConstraint); } default: { return dropAndRecreateConstraint(source, target, `Unknown constraint type: ${(source as any).type}`); } } }; const diffPrimaryKeyConstraint = ( source: DatabasePrimaryKeyConstraint, target: DatabasePrimaryKeyConstraint, ): SchemaDiff[] => { if (!haveEqualColumns(source.columnNames, target.columnNames)) { return dropAndRecreateConstraint( source, target, `Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`, ); } return []; }; const diffForeignKeyConstraint = ( source: DatabaseForeignKeyConstraint, target: DatabaseForeignKeyConstraint, ): SchemaDiff[] => { let reason = ''; const sourceDeleteAction = source.onDelete ?? 'NO ACTION'; const targetDeleteAction = target.onDelete ?? 'NO ACTION'; const sourceUpdateAction = source.onUpdate ?? 'NO ACTION'; const targetUpdateAction = target.onUpdate ?? 'NO ACTION'; if (!haveEqualColumns(source.columnNames, target.columnNames)) { reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; } else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) { reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`; } else if (source.referenceTableName !== target.referenceTableName) { reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`; } else if (sourceDeleteAction !== targetDeleteAction) { reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`; } else if (sourceUpdateAction !== targetUpdateAction) { reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`; } if (reason) { return dropAndRecreateConstraint(source, target, reason); } return []; }; const diffUniqueConstraint = (source: DatabaseUniqueConstraint, target: DatabaseUniqueConstraint): SchemaDiff[] => { let reason = ''; if (!haveEqualColumns(source.columnNames, target.columnNames)) { reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; } if (reason) { return dropAndRecreateConstraint(source, target, reason); } return []; }; const diffCheckConstraint = (source: DatabaseCheckConstraint, target: DatabaseCheckConstraint): SchemaDiff[] => { if (source.expression !== target.expression) { // comparing expressions is hard because postgres reconstructs it with different formatting // for now if the constraint exists with the same name, we will just skip it } return []; }; const diffIndexes = (sources: DatabaseIndex[], targets: DatabaseIndex[]) => { const items: SchemaDiff[] = []; const sourceMap = Object.fromEntries(sources.map((index) => [index.name, index])); const targetMap = Object.fromEntries(targets.map((index) => [index.name, index])); const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); for (const key of keys) { items.push(...diffIndex(sourceMap[key], targetMap[key])); } return items; }; const diffIndex = (source?: DatabaseIndex, target?: DatabaseIndex): SchemaDiff[] => { if (isSynchronizeDisabled(source, target)) { return []; } if (source && !target) { return [{ type: 'index.create', index: source, reason: Reason.MissingInTarget }]; } if (!source && target) { return [ { type: 'index.drop', indexName: target.name, reason: Reason.MissingInSource, }, ]; } if (!target || !source) { return []; } const sourceUsing = source.using ?? 'btree'; const targetUsing = target.using ?? 'btree'; let reason = ''; if (!haveEqualColumns(source.columnNames, target.columnNames)) { reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; } else if (source.unique !== target.unique) { reason = `uniqueness is different (${source.unique} vs ${target.unique})`; } else if (sourceUsing !== targetUsing) { reason = `using method is different (${source.using} vs ${target.using})`; } else if (source.where !== target.where) { reason = `where clause is different (${source.where} vs ${target.where})`; } else if (source.expression !== target.expression) { reason = `expression is different (${source.expression} vs ${target.expression})`; } if (reason) { return dropAndRecreateIndex(source, target, reason); } return []; }; const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { return [ { type: 'column.drop', tableName: target.tableName, columnName: target.name, reason, }, { type: 'column.add', column: source, reason }, ]; }; const dropAndRecreateConstraint = ( source: DatabaseConstraint, target: DatabaseConstraint, reason: string, ): SchemaDiff[] => { return [ { type: 'constraint.drop', tableName: target.tableName, constraintName: target.name, reason, }, { type: 'constraint.add', constraint: source, reason }, ]; }; const dropAndRecreateIndex = (source: DatabaseIndex, target: DatabaseIndex, reason: string): SchemaDiff[] => { return [ { type: 'index.drop', indexName: target.name, reason }, { type: 'index.create', index: source, reason }, ]; };