import { type AnyColumn, type AnySchema, type AnyTable, compileForeignKey, type NameVariants,} from "../schema/create";import type { RelationMode } from "../shared/config";import type { Provider } from "../shared/providers";import { deepEqual } from "../utils/deep-equal";import { type ColumnOperation, isUpdated, type MigrationOperation,} from "./shared"; type Operation = MigrationOperation & { enforce?: "pre" | "post" }; /** * Generate migration by comparing two schemas */export function generateMigrationFromSchema( old: AnySchema, schema: AnySchema, options: { provider: Provider; relationMode?: RelationMode; /** * Drop tables if no longer exist in latest schema. * * This only detects tables from schema, user tables won't be affected. */ dropUnusedTables?: boolean; dropUnusedColumns?: boolean; }): MigrationOperation[] { const { provider, relationMode = provider === "mssql" || provider === "mongodb" ? "@executor-js/fumadb" : "foreign-keys", dropUnusedTables = true, dropUnusedColumns = true, } = options; function getName(names: NameVariants) { return provider === "mongodb" ? names.mongodb : names.sql; } function columnActionToOperation( tableName: string, actions: ColumnOperation[] ): MigrationOperation[] { if (actions.length === 0) return []; if ( provider === "mysql" || provider === "postgresql" || provider === "mongodb" ) { return [ { type: "update-table", name: tableName, value: actions, }, ]; } return actions.map((action) => ({ type: "update-table", name: tableName, value: [action], })); } function onUniqueConstraintCheck(prev: AnyTable, next: AnyTable) { const operations: Operation[] = []; const newConstraints = next.getUniqueConstraints(); const oldConstraints = prev.getUniqueConstraints(); for (const con of newConstraints) { const oldCon = oldConstraints.find((item) => item.name === con.name); const columnNames = con.columns.map((col) => getName(col.names)); if (!oldCon) { operations.push({ type: "add-unique-constraint", name: con.name, table: getName(next.names), columns: columnNames, }); continue; } if ( deepEqual( columnNames, oldCon.columns.map((col) => getName(col.names)) ) ) continue; operations.push( { type: "drop-unique-constraint", table: getName(next.names), name: con.name, }, { type: "add-unique-constraint", table: getName(next.names), name: con.name, columns: columnNames, } ); } for (const con of oldConstraints) { const isUnused = newConstraints.every((item) => item.name !== con.name); if (isUnused) operations.push({ type: "drop-unique-constraint", table: getName(next.names), name: con.name, }); } return operations; } function onTableRenameCheck(oldTable: AnyTable, newTable: AnyTable) { const operations: Operation[] = []; if (getName(newTable.names) !== getName(oldTable.names)) { operations.push({ type: "rename-table", from: getName(oldTable.names), to: getName(newTable.names), enforce: "pre", }); } return operations; } function onTableColumnsCheck( oldTable: AnyTable, newTable: AnyTable ): Operation[] { const colActions: ColumnOperation[] = []; for (const column of Object.values(newTable.columns)) { const oldColumn = oldTable.columns[column.ormName]; if (!oldColumn) { colActions.push({ type: "create-column", value: column, }); continue; } if (getName(column.names) !== getName(oldColumn.names)) { colActions.push({ type: "rename-column", from: getName(oldColumn.names), to: getName(column.names), }); } /** * Generate hash to compare default values */ function hashDefaultValue(col: AnyColumn) { if (!col.default || "runtime" in col.default) return; if (col.type === "string" && provider === "mysql") return; return col.default.value; } const action: ColumnOperation = { type: "update-column", name: getName(column.names), updateDataType: column.type !== oldColumn.type, updateDefault: !deepEqual( hashDefaultValue(column), hashDefaultValue(oldColumn) ), updateNullable: column.isNullable !== oldColumn.isNullable, value: column, }; if (isUpdated(action)) colActions.push(action); } return columnActionToOperation(getName(newTable.names), colActions); } function onTableForeignKeyCheck( oldTable: AnyTable, newTable: AnyTable ): Operation[] { const tableName = getName(newTable.names); const operations: Operation[] = []; if (relationMode === "@executor-js/fumadb") return operations; for (const foreignKey of newTable.foreignKeys) { const compiled = compileForeignKey(foreignKey, "sql"); const oldKey = oldTable.foreignKeys.find( (key) => key.name === foreignKey.name ); if (!oldKey) { operations.push({ type: "add-foreign-key", table: tableName, value: compiled, enforce: "post", }); continue; } const isUpdated = !deepEqual(compiled, compileForeignKey(oldKey, "sql")); if (isUpdated) { operations.push( { type: "drop-foreign-key", name: oldKey.name, table: tableName, enforce: "post", }, { type: "add-foreign-key", table: tableName, value: compiled, enforce: "post", } ); } } return operations; } function onTableUnusedForeignKeyCheck( oldTable: AnyTable, newTable: AnyTable ) { const operations: Operation[] = []; for (const oldKey of oldTable.foreignKeys) { const isUnused = newTable.foreignKeys.every( (key) => key.name !== oldKey.name ); if (!isUnused) continue; operations.push({ type: "drop-foreign-key", name: oldKey.name, table: getName(oldTable.names), enforce: "pre", }); } return operations; } function onTableUnusedColumnsCheck( oldTable: AnyTable, newTable: AnyTable ): Operation[] { // this check happens after unique constraint check const constraints = newTable.getUniqueConstraints(); const operations: Operation[] = []; for (const oldColumn of Object.values(oldTable.columns)) { const isUnused = !newTable.columns[oldColumn.ormName]; const isRequired = !oldColumn.isNullable && !oldColumn.default; const shouldDrop = isUnused && (dropUnusedColumns || isRequired); if (!shouldDrop) continue; // mssql doesn't auto drop unique index/constraint if (provider === "mssql" && oldColumn.isUnique) { for (const con of constraints) { if (con.columns.every((col) => col.ormName !== oldColumn.ormName)) continue; operations.push({ type: "drop-unique-constraint", name: con.name, table: getName(newTable.names), }); } } operations.push({ type: "update-table", name: getName(newTable.names), value: [{ type: "drop-column", name: getName(oldColumn.names) }], enforce: "post", }); } return operations; } function reorder(operations: Operation[]) { const out: MigrationOperation[] = []; for (const item of operations) { if (item.enforce === "pre") out.push(item); } for (const item of operations) { if (!item.enforce) out.push(item); } for (const item of operations) { if (item.enforce === "post") out.push(item); } return out; } function generate() { const operations: Operation[] = []; for (const table of Object.values(schema.tables)) { const oldTable = old.tables[table.ormName]; if (!oldTable) { operations.push({ type: "create-table", value: table, }); continue; } operations.push( ...onTableUnusedForeignKeyCheck(oldTable, table), ...onTableRenameCheck(oldTable, table), ...onTableColumnsCheck(oldTable, table), ...onUniqueConstraintCheck(oldTable, table), ...onTableForeignKeyCheck(oldTable, table), ...onTableUnusedColumnsCheck(oldTable, table) ); } for (const oldTable of Object.values(old.tables)) { if (!schema.tables[oldTable.ormName] && dropUnusedTables) { operations.push({ type: "drop-table", name: getName(oldTable.names), enforce: "post", }); } } return reorder(operations); } return generate();}
import {
type AnyColumn,
type AnySchema,
type AnyTable,
compileForeignKey,
type NameVariants,
} from "../schema/create";
import type { RelationMode } from "../shared/config";
import type { Provider } from "../shared/providers";
import { deepEqual } from "../utils/deep-equal";
import {
type ColumnOperation,
isUpdated,
type MigrationOperation,
} from "./shared";
type Operation = MigrationOperation & { enforce?: "pre" | "post" };
/**
* Generate migration by comparing two schemas
*/
export function generateMigrationFromSchema(
old: AnySchema,
schema: AnySchema,
options: {
provider: Provider;
relationMode?: RelationMode;
/**
* Drop tables if no longer exist in latest schema.
*
* This only detects tables from schema, user tables won't be affected.
*/
dropUnusedTables?: boolean;
dropUnusedColumns?: boolean;
}
): MigrationOperation[] {
const {
provider,
relationMode = provider === "mssql" || provider === "mongodb"
? "@executor-js/fumadb"
: "foreign-keys",
dropUnusedTables = true,
dropUnusedColumns = true,
} = options;
function getName(names: NameVariants) {
return provider === "mongodb" ? names.mongodb : names.sql;
}
function columnActionToOperation(
tableName: string,
actions: ColumnOperation[]
): MigrationOperation[] {
if (actions.length === 0) return [];
if (
provider === "mysql" ||
provider === "postgresql" ||
provider === "mongodb"
) {
return [
{
type: "update-table",
name: tableName,
value: actions,
},
];
}
return actions.map((action) => ({
type: "update-table",
name: tableName,
value: [action],
}));
}
function onUniqueConstraintCheck(prev: AnyTable, next: AnyTable) {
const operations: Operation[] = [];
const newConstraints = next.getUniqueConstraints();
const oldConstraints = prev.getUniqueConstraints();
for (const con of newConstraints) {
const oldCon = oldConstraints.find((item) => item.name === con.name);
const columnNames = con.columns.map((col) => getName(col.names));
if (!oldCon) {
operations.push({
type: "add-unique-constraint",
name: con.name,
table: getName(next.names),
columns: columnNames,
});
continue;
}
if (
deepEqual(
columnNames,
oldCon.columns.map((col) => getName(col.names))
)
)
continue;
operations.push(
{
type: "drop-unique-constraint",
table: getName(next.names),
name: con.name,
},
{
type: "add-unique-constraint",
table: getName(next.names),
name: con.name,
columns: columnNames,
}
);
}
for (const con of oldConstraints) {
const isUnused = newConstraints.every((item) => item.name !== con.name);
if (isUnused)
operations.push({
type: "drop-unique-constraint",
table: getName(next.names),
name: con.name,
});
}
return operations;
}
function onTableRenameCheck(oldTable: AnyTable, newTable: AnyTable) {
const operations: Operation[] = [];
if (getName(newTable.names) !== getName(oldTable.names)) {
operations.push({
type: "rename-table",
from: getName(oldTable.names),
to: getName(newTable.names),
enforce: "pre",
});
}
return operations;
}
function onTableColumnsCheck(
oldTable: AnyTable,
newTable: AnyTable
): Operation[] {
const colActions: ColumnOperation[] = [];
for (const column of Object.values(newTable.columns)) {
const oldColumn = oldTable.columns[column.ormName];
if (!oldColumn) {
colActions.push({
type: "create-column",
value: column,
});
continue;
}
if (getName(column.names) !== getName(oldColumn.names)) {
colActions.push({
type: "rename-column",
from: getName(oldColumn.names),
to: getName(column.names),
});
}
/**
* Generate hash to compare default values
*/
function hashDefaultValue(col: AnyColumn) {
if (!col.default || "runtime" in col.default) return;
if (col.type === "string" && provider === "mysql") return;
return col.default.value;
}
const action: ColumnOperation = {
type: "update-column",
name: getName(column.names),
updateDataType: column.type !== oldColumn.type,
updateDefault: !deepEqual(
hashDefaultValue(column),
hashDefaultValue(oldColumn)
),
updateNullable: column.isNullable !== oldColumn.isNullable,
value: column,
};
if (isUpdated(action)) colActions.push(action);
}
return columnActionToOperation(getName(newTable.names), colActions);
}
function onTableForeignKeyCheck(
oldTable: AnyTable,
newTable: AnyTable
): Operation[] {
const tableName = getName(newTable.names);
const operations: Operation[] = [];
if (relationMode === "@executor-js/fumadb") return operations;
for (const foreignKey of newTable.foreignKeys) {
const compiled = compileForeignKey(foreignKey, "sql");
const oldKey = oldTable.foreignKeys.find(
(key) => key.name === foreignKey.name
);
if (!oldKey) {
operations.push({
type: "add-foreign-key",
table: tableName,
value: compiled,
enforce: "post",
});
continue;
}
const isUpdated = !deepEqual(compiled, compileForeignKey(oldKey, "sql"));
if (isUpdated) {
operations.push(
{
type: "drop-foreign-key",
name: oldKey.name,
table: tableName,
enforce: "post",
},
{
type: "add-foreign-key",
table: tableName,
value: compiled,
enforce: "post",
}
);
}
}
return operations;
}
function onTableUnusedForeignKeyCheck(
oldTable: AnyTable,
newTable: AnyTable
) {
const operations: Operation[] = [];
for (const oldKey of oldTable.foreignKeys) {
const isUnused = newTable.foreignKeys.every(
(key) => key.name !== oldKey.name
);
if (!isUnused) continue;
operations.push({
type: "drop-foreign-key",
name: oldKey.name,
table: getName(oldTable.names),
enforce: "pre",
});
}
return operations;
}
function onTableUnusedColumnsCheck(
oldTable: AnyTable,
newTable: AnyTable
): Operation[] {
// this check happens after unique constraint check
const constraints = newTable.getUniqueConstraints();
const operations: Operation[] = [];
for (const oldColumn of Object.values(oldTable.columns)) {
const isUnused = !newTable.columns[oldColumn.ormName];
const isRequired = !oldColumn.isNullable && !oldColumn.default;
const shouldDrop = isUnused && (dropUnusedColumns || isRequired);
if (!shouldDrop) continue;
// mssql doesn't auto drop unique index/constraint
if (provider === "mssql" && oldColumn.isUnique) {
for (const con of constraints) {
if (con.columns.every((col) => col.ormName !== oldColumn.ormName))
continue;
operations.push({
type: "drop-unique-constraint",
name: con.name,
table: getName(newTable.names),
});
}
}
operations.push({
type: "update-table",
name: getName(newTable.names),
value: [{ type: "drop-column", name: getName(oldColumn.names) }],
enforce: "post",
});
}
return operations;
}
function reorder(operations: Operation[]) {
const out: MigrationOperation[] = [];
for (const item of operations) {
if (item.enforce === "pre") out.push(item);
}
for (const item of operations) {
if (!item.enforce) out.push(item);
}
for (const item of operations) {
if (item.enforce === "post") out.push(item);
}
return out;
}
function generate() {
const operations: Operation[] = [];
for (const table of Object.values(schema.tables)) {
const oldTable = old.tables[table.ormName];
if (!oldTable) {
operations.push({
type: "create-table",
value: table,
});
continue;
}
operations.push(
...onTableUnusedForeignKeyCheck(oldTable, table),
...onTableRenameCheck(oldTable, table),
...onTableColumnsCheck(oldTable, table),
...onUniqueConstraintCheck(oldTable, table),
...onTableForeignKeyCheck(oldTable, table),
...onTableUnusedColumnsCheck(oldTable, table)
);
}
for (const oldTable of Object.values(old.tables)) {
if (!schema.tables[oldTable.ormName] && dropUnusedTables) {
operations.push({
type: "drop-table",
name: getName(oldTable.names),
enforce: "post",
});
}
}
return reorder(operations);
}
return generate();
}