Skip to content

Commit

Permalink
feat(sql): generate down migrations automatically
Browse files Browse the repository at this point in the history
Currently not supported in sqlite driver, due to how knex does complex
schema diffing in sqlite (always based on current schema, which is not
"in sync", as we generate the down migration in the same time as up one,
which is not yet executed).
  • Loading branch information
B4nan committed Aug 23, 2021
1 parent fd43099 commit eba7bd3
Show file tree
Hide file tree
Showing 29 changed files with 1,710 additions and 676 deletions.
13 changes: 11 additions & 2 deletions packages/cli/src/commands/MigrationCommandFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,22 @@ export class MigrationCommandFactory {
private static async handleCreateCommand(migrator: Migrator, args: Arguments<Options>, config: Configuration): Promise<void> {
const ret = await migrator.createMigration(args.path, args.blank, args.initial);

if (ret.diff.length === 0) {
if (ret.diff.up.length === 0) {
return CLIHelper.dump(c.green(`No changes required, schema is up-to-date`));
}

if (args.dump) {
CLIHelper.dump(c.green('Creating migration with following queries:'));
CLIHelper.dump(ret.diff.map(sql => ' ' + sql).join('\n'), config);
CLIHelper.dump(c.green('up:'));
CLIHelper.dump(ret.diff.up.map(sql => ' ' + sql).join('\n'), config);

/* istanbul ignore else */
if (config.getDriver().getPlatform().supportsDownMigrations()) {
CLIHelper.dump(c.green('down:'));
CLIHelper.dump(ret.diff.down.map(sql => ' ' + sql).join('\n'), config);
} else {
CLIHelper.dump(c.yellow(`(${config.getDriver().constructor.name} does not support automatic down migrations)`));
}
}

CLIHelper.dump(c.green(`${ret.fileName} successfully created`));
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/platforms/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,4 +369,11 @@ export abstract class Platform {
return [ReferenceType.SCALAR, ReferenceType.MANY_TO_ONE].includes(prop.reference) || (prop.reference === ReferenceType.ONE_TO_ONE && prop.owner);
}

/**
* Currently not supported due to how knex does complex sqlite diffing (always based on current schema)
*/
supportsDownMigrations(): boolean {
return true;
}

}
1 change: 1 addition & 0 deletions packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export interface ISchemaGenerator {
getDropSchemaSQL(options?: { wrap?: boolean; dropMigrationsTable?: boolean }): Promise<string>;
updateSchema(options?: { wrap?: boolean; safe?: boolean; dropDb?: boolean; dropTables?: boolean }): Promise<void>;
getUpdateSchemaSQL(options?: { wrap?: boolean; safe?: boolean; dropDb?: boolean; dropTables?: boolean }): Promise<string>;
getUpdateSchemaMigrationSQL(options?: { wrap?: boolean; safe?: boolean; dropDb?: boolean; dropTables?: boolean }): Promise<{ up: string; down: string }>;
createDatabase(name: string): Promise<void>;
dropDatabase(name: string): Promise<void>;
execute(sql: string, options?: { wrap?: boolean }): Promise<void>;
Expand Down
6 changes: 4 additions & 2 deletions packages/knex/src/schema/SchemaComparator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,10 @@ export class SchemaComparator {
}

if (to.mappedType instanceof BooleanType) {
const defaultValue = !['0', 'false', 'f', 'n', 'no', 'off'].includes(from.default!);
return '' + defaultValue === to.default;
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 (from.default && to.default) {
Expand Down
38 changes: 30 additions & 8 deletions packages/knex/src/schema/SchemaGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Knex } from 'knex';
import { CommitOrderCalculator, Dictionary, EntityMetadata } from '@mikro-orm/core';
import { Column, ForeignKey, Index, TableDifference } from '../typings';
import { Column, ForeignKey, Index, SchemaDifference, TableDifference } from '../typings';
import { DatabaseSchema } from './DatabaseSchema';
import { DatabaseTable } from './DatabaseTable';
import { SqlEntityManager } from '../SqlEntityManager';
Expand Down Expand Up @@ -87,11 +87,11 @@ export class SchemaGenerator {
let ret = '';

for (const meta of metadata) {
ret += await this.dump(this.dropTable(meta.collection), '\n');
ret += await this.dump(this.dropTable(meta.collection, meta.schema), '\n');
}

if (options.dropMigrationsTable) {
ret += await this.dump(this.dropTable(this.config.get('migrations').tableName!), '\n');
ret += await this.dump(this.dropTable(this.config.get('migrations').tableName!, this.config.get('schema')), '\n');
}

return this.wrapSchema(ret + '\n', { wrap });
Expand All @@ -103,13 +103,35 @@ export class SchemaGenerator {
}

async getUpdateSchemaSQL(options: { wrap?: boolean; safe?: boolean; dropTables?: boolean; fromSchema?: DatabaseSchema } = {}): Promise<string> {
const wrap = options.wrap ?? true;
options.wrap = options.wrap ?? true;
options.safe = options.safe ?? false;
options.dropTables = options.dropTables ?? true;
const toSchema = this.getTargetSchema();
/* istanbul ignore next */
const fromSchema = options.fromSchema ?? await DatabaseSchema.create(this.connection, this.platform, this.config);
const comparator = new SchemaComparator(this.platform);
const schemaDiff = comparator.compare(fromSchema, toSchema);
const diffUp = comparator.compare(fromSchema, toSchema);

return this.diffToSQL(diffUp, options);
}

async getUpdateSchemaMigrationSQL(options: { wrap?: boolean; safe?: boolean; dropTables?: boolean; fromSchema?: DatabaseSchema } = {}): Promise<{ up: string; down: string }> {
options.wrap = options.wrap ?? true;
options.safe = options.safe ?? false;
options.dropTables = options.dropTables ?? true;
const toSchema = this.getTargetSchema();
const fromSchema = options.fromSchema ?? await DatabaseSchema.create(this.connection, this.platform, this.config);
const comparator = new SchemaComparator(this.platform);
const diffUp = comparator.compare(fromSchema, toSchema);
const diffDown = comparator.compare(toSchema, fromSchema);

return {
up: await this.diffToSQL(diffUp, options),
down: this.platform.supportsDownMigrations() ? await this.diffToSQL(diffDown, options) : '',
};
}

async diffToSQL(schemaDiff: SchemaDifference, options: { wrap?: boolean; safe?: boolean; dropTables?: boolean }): Promise<string> {
let ret = '';

if (this.platform.supportsSchemas()) {
Expand Down Expand Up @@ -139,13 +161,13 @@ export class SchemaGenerator {
}

for (const changedTable of Object.values(schemaDiff.changedTables)) {
for (const builder of this.preAlterTable(changedTable, options.safe)) {
for (const builder of this.preAlterTable(changedTable, options.safe!)) {
ret += await this.dump(builder);
}
}

for (const changedTable of Object.values(schemaDiff.changedTables)) {
for (const builder of this.alterTable(changedTable, options.safe)) {
for (const builder of this.alterTable(changedTable, options.safe!)) {
ret += await this.dump(builder);
}
}
Expand All @@ -156,7 +178,7 @@ export class SchemaGenerator {
}
}

return this.wrapSchema(ret, { wrap });
return this.wrapSchema(ret, options);
}

private createForeignKey(table: Knex.CreateTableBuilder, foreignKey: ForeignKey) {
Expand Down
4 changes: 2 additions & 2 deletions packages/knex/src/schema/SchemaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,9 @@ export abstract class SchemaHelper {
const guard = (key: string) => !changedProperties || changedProperties.has(key);

if (changedProperties) {
Utils.runIfNotEmpty(() => col.defaultTo(column.default === undefined ? null : knex.raw(column.default)), guard('default'));
Utils.runIfNotEmpty(() => col.defaultTo(column.default == null ? null : knex.raw(column.default)), guard('default'));
} else {
Utils.runIfNotEmpty(() => col.defaultTo(knex.raw(column.default!)), column.default !== undefined);
Utils.runIfNotEmpty(() => col.defaultTo(column.default == null ? null : knex.raw(column.default)), column.default !== undefined);
}

return col;
Expand Down
25 changes: 20 additions & 5 deletions packages/migrations/src/MigrationGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class MigrationGenerator {
protected readonly namingStrategy: NamingStrategy,
protected readonly options: MigrationsOptions) { }

async generate(diff: string[], path?: string): Promise<[string, string]> {
async generate(diff: { up: string[]; down: string[] }, path?: string): Promise<[string, string]> {
path = Utils.normalizePath(path || this.options.path!);
await ensureDir(path);
const timestamp = new Date().toISOString().replace(/[-T:]|\.\d{3}z$/ig, '');
Expand Down Expand Up @@ -36,26 +36,41 @@ export class MigrationGenerator {
return '\n';
}

generateJSMigrationFile(className: string, diff: string[]): string {
generateJSMigrationFile(className: string, diff: { up: string[]; down: string[] }): string {
let ret = `'use strict';\n`;
ret += `Object.defineProperty(exports, '__esModule', { value: true });\n`;
ret += `const Migration = require('@mikro-orm/migrations').Migration;\n\n`;
ret += `class ${className} extends Migration {\n\n`;
ret += ` async up() {\n`;
diff.forEach(sql => ret += this.createStatement(sql, 4));
diff.up.forEach(sql => ret += this.createStatement(sql, 4));
ret += ` }\n\n`;

/* istanbul ignore else */
if (diff.down.length > 0) {
ret += ` async down() {\n`;
diff.down.forEach(sql => ret += this.createStatement(sql, 4));
ret += ` }\n\n`;
}

ret += `}\n`;
ret += `exports.${className} = ${className};\n`;

return ret;
}

generateTSMigrationFile(className: string, diff: string[]): string {
generateTSMigrationFile(className: string, diff: { up: string[]; down: string[] }): string {
let ret = `import { Migration } from '@mikro-orm/migrations';\n\n`;
ret += `export class ${className} extends Migration {\n\n`;
ret += ` async up(): Promise<void> {\n`;
diff.forEach(sql => ret += this.createStatement(sql, 4));
diff.up.forEach(sql => ret += this.createStatement(sql, 4));
ret += ` }\n\n`;

if (diff.down.length > 0) {
ret += ` async down(): Promise<void> {\n`;
diff.down.forEach(sql => ret += this.createStatement(sql, 4));
ret += ` }\n\n`;
}

ret += `}\n`;

return ret;
Expand Down
34 changes: 20 additions & 14 deletions packages/migrations/src/Migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class Migrator {
await this.ensureMigrationsDirExists();
const diff = await this.getSchemaDiff(blank, initial);

if (diff.length === 0) {
if (diff.up.length === 0) {
return { fileName: '', code: '', diff };
}

Expand Down Expand Up @@ -200,33 +200,39 @@ export class Migrator {
};
}

private async getSchemaDiff(blank: boolean, initial: boolean): Promise<string[]> {
const lines: string[] = [];
private async getSchemaDiff(blank: boolean, initial: boolean): Promise<{ up: string[]; down: string[] }> {
const up: string[] = [];
const down: string[] = [];

if (blank) {
lines.push('select 1');
up.push('select 1');
} else if (initial) {
const dump = await this.schemaGenerator.getCreateSchemaSQL({ wrap: false });
lines.push(...dump.split('\n'));
up.push(...dump.split('\n'));
} else {
const dump = await this.schemaGenerator.getUpdateSchemaSQL({
const diff = await this.schemaGenerator.getUpdateSchemaMigrationSQL({
wrap: false,
safe: this.options.safe,
dropTables: this.options.dropTables,
fromSchema: await this.getCurrentSchema(),
});
lines.push(...dump.split('\n'));
up.push(...diff.up.split('\n'));
down.push(...diff.down.split('\n'));
}

for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i]) {
break;
}
const cleanUp = (diff: string[]) => {
for (let i = diff.length - 1; i >= 0; i--) {
if (diff[i]) {
break;
}

lines.splice(i, 1);
}
diff.splice(i, 1);
}
};
cleanUp(up);
cleanUp(down);

return lines;
return { up, down };
}

private prefix<T extends string | string[] | { from?: string; to?: string; migrations?: string[]; transaction?: Transaction }>(options?: T): T {
Expand Down
2 changes: 1 addition & 1 deletion packages/migrations/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { Transaction } from '@mikro-orm/core';

export type UmzugMigration = { name?: string; path?: string; file: string };
export type MigrateOptions = { from?: string | number; to?: string | number; migrations?: string[]; transaction?: Transaction };
export type MigrationResult = { fileName: string; code: string; diff: string[] };
export type MigrationResult = { fileName: string; code: string; diff: { up: string[]; down: string[] } };
export type MigrationRow = { name: string; executed_at: Date };
26 changes: 21 additions & 5 deletions packages/postgresql/src/PostgreSqlConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,20 @@ export class PostgreSqlConnection extends AbstractSqlConnection {
const colName = this.client.wrapIdentifier(col.getColumnName(), col.columnBuilder.queryContext());
const constraintName = `${this.tableNameRaw}_${col.getColumnName()}_check`;
this.pushQuery({ sql: `alter table ${quotedTableName} drop constraint if exists "${constraintName}"`, bindings: [] });
that.dropColumnDefault.call(this, col, colName);

if (col.type === 'enu') {
this.pushQuery({ sql: `alter table ${quotedTableName} alter column ${colName} type text using (${colName}::text)`, bindings: [] });
this.pushQuery({ sql: `alter table ${quotedTableName} add constraint "${constraintName}" ${type.replace(/^text /, '')}`, bindings: [] });
} else if (type === 'uuid') {
// we need to drop the default as it would be invalid
this.pushQuery({ sql: `alter table ${quotedTableName} alter column ${colName} drop default`, bindings: [] });
this.pushQuery({ sql: `alter table ${quotedTableName} alter column ${colName} type ${type} using (${colName}::text::uuid)`, bindings: [] });
} else {
this.pushQuery({ sql: `alter table ${quotedTableName} alter column ${colName} type ${type} using (${colName}::${type})`, bindings: [] });
}

that.alterColumnDefault.call(this, col, colName);
that.addColumnDefault.call(this, col, colName);
that.alterColumnNullable.call(this, col, colName);
}

Expand All @@ -95,20 +100,31 @@ export class PostgreSqlConnection extends AbstractSqlConnection {
}
}

private alterColumnDefault(this: any, col: Dictionary, colName: string): void {
private addColumnDefault(this: any, col: Dictionary, colName: string): void {
const quotedTableName = this.tableName();
const defaultTo = col.modified.defaultTo;

if (!defaultTo) {
return;
}

if (defaultTo[0] === null) {
this.pushQuery({ sql: `alter table ${quotedTableName} alter column ${colName} drop default`, bindings: [] });
} else {
if (defaultTo[0] !== null) {
const modifier = col.defaultTo(...defaultTo);
this.pushQuery({ sql: `alter table ${quotedTableName} alter column ${colName} set ${modifier}`, bindings: [] });
}
}

private dropColumnDefault(this: any, col: Dictionary, colName: string): void {
const quotedTableName = this.tableName();
const defaultTo = col.modified.defaultTo;

if (!defaultTo) {
return;
}

if (defaultTo[0] === null) {
this.pushQuery({ sql: `alter table ${quotedTableName} alter column ${colName} drop default`, bindings: [] });
}
}

}
13 changes: 12 additions & 1 deletion packages/postgresql/src/PostgreSqlSchemaHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BigIntType, Dictionary, EnumType, Utils } from '@mikro-orm/core';
import { AbstractSqlConnection, SchemaHelper, Column, Index, DatabaseTable } from '@mikro-orm/knex';
import { AbstractSqlConnection, SchemaHelper, Column, Index, DatabaseTable, TableDifference } from '@mikro-orm/knex';
import { Knex } from 'knex';

export class PostgreSqlSchemaHelper extends SchemaHelper {
Expand Down Expand Up @@ -159,6 +159,17 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
return col;
}

getPreAlterTable(tableDiff: TableDifference, safe: boolean): string {
// changing uuid column type requires to cast it to text first
const uuid = Object.values(tableDiff.changedColumns).find(col => col.changedProperties.has('type') && col.fromColumn.type === 'uuid');

if (!uuid) {
return '';
}

return `alter table "${tableDiff.name}" alter column "${uuid.column.name}" type text using ("${uuid.column.name}"::text)`;
}

getAlterColumnAutoincrement(tableName: string, column: Column): string {
const ret: string[] = [];
const quoted = (val: string) => this.platform.quoteIdentifier(val);
Expand Down
4 changes: 4 additions & 0 deletions packages/sqlite/src/SqlitePlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,8 @@ export class SqlitePlatform extends AbstractSqlPlatform {
return super.getIndexName(tableName, columns, type);
}

supportsDownMigrations(): boolean {
return false;
}

}
1 change: 1 addition & 0 deletions tests/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export async function initORMSqlite2() {
forceUndefined: true,
logger: i => i,
cache: { pretty: true },
migrations: { path: BASE_DIR + '/../temp/migrations', snapshot: false },
});
const schemaGenerator = new SchemaGenerator(orm.em);
await schemaGenerator.dropSchema();
Expand Down
4 changes: 2 additions & 2 deletions tests/features/cli/CreateMigrationCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const close = jest.fn();
jest.spyOn(MikroORM.prototype, 'close').mockImplementation(close);
jest.spyOn(require('yargs'), 'showHelp').mockReturnValue('');
const createMigrationMock = jest.spyOn(Migrator.prototype, 'createMigration');
createMigrationMock.mockResolvedValue({ fileName: '1', code: '2', diff: ['3'] });
createMigrationMock.mockResolvedValue({ fileName: '1', code: '2', diff: { up: ['3'], down: [] } });
const dumpMock = jest.spyOn(CLIHelper, 'dump');
dumpMock.mockImplementation(() => void 0);

Expand Down Expand Up @@ -45,7 +45,7 @@ describe('CreateMigrationCommand', () => {
expect(close.mock.calls.length).toBe(2);
expect(dumpMock).toHaveBeenLastCalledWith('1 successfully created');

createMigrationMock.mockImplementationOnce(async () => ({ fileName: '', code: '', diff: [] }));
createMigrationMock.mockImplementationOnce(async () => ({ fileName: '', code: '', diff: { up: [], down: [] } }));
await expect(cmd.handler({} as any)).resolves.toBeUndefined();
expect(createMigrationMock.mock.calls.length).toBe(3);
expect(close.mock.calls.length).toBe(3);
Expand Down
Loading

0 comments on commit eba7bd3

Please sign in to comment.