diff --git a/lib/migrations/migrate/Migrator.js b/lib/migrations/migrate/Migrator.js index 10b27a4874..9fce1f9bf2 100644 --- a/lib/migrations/migrate/Migrator.js +++ b/lib/migrations/migrate/Migrator.js @@ -379,12 +379,18 @@ class Migrator { let batchNo = await this._latestBatchNumber(trx); if (direction === 'up') batchNo++; - const res = await this._waterfallBatch( - batchNo, - migrations, - direction, - trx - ); + + // Run any hooks before/after this batch + const beforeAll = this.config.beforeAll || (() => {}); + const afterAll = this.config.afterAll || (() => {}); + + let res = []; + if (migrations.length > 0) { + await beforeAll(trx || this.knex, migrations); + res = await this._waterfallBatch(batchNo, migrations, direction, trx); + await afterAll(trx || this.knex, migrations); + } + await this._freeLock(canGetLockInTransaction ? trx : undefined); return res; } catch (error) { @@ -496,30 +502,40 @@ class Migrator { const migrationContent = this.config.migrationSource.getMigration(migration); + const beforeEach = this.config.beforeEach || (() => {}); + const afterEach = this.config.afterEach || (() => {}); + // We're going to run each of the migrations in the current "up". current = current .then(async () => await migrationContent) //maybe promise - .then((migrationContent) => { + .then(async (migrationContent) => { this._activeMigration.fileName = name; if ( !trx && this._useTransaction(migrationContent, disableTransactions) ) { this.knex.enableProcessing(); - return this._transaction( - this.knex, - migrationContent, - direction, - name - ); + return await this.knex.transaction(async (trx) => { + await beforeEach(trx, [migration]); + const migrationResult = await checkPromise( + this.knex.client.logger, + migrationContent[direction](trx), + name + ); + await afterEach(trx, [migration]); + return migrationResult; + }); } trxOrKnex.enableProcessing(); - return checkPromise( + await beforeEach(trxOrKnex, [migration]); + const migrationResult = await checkPromise( this.knex.client.logger, migrationContent[direction](trxOrKnex), name ); + await afterEach(trxOrKnex, [migration]); + return migrationResult; }) .then(() => { trxOrKnex.disableProcessing(); @@ -584,12 +600,9 @@ function getNewMigrations(migrationSource, all, completed) { }); } -function checkPromise(logger, migrationPromise, name, commitFn) { +function checkPromise(logger, migrationPromise, name) { if (!migrationPromise || typeof migrationPromise.then !== 'function') { logger.warn(`migration ${name} did not return a promise`); - if (commitFn) { - commitFn(); - } } return migrationPromise; } diff --git a/test/integration2/migrate/migration-lifecycle-integration.spec.js b/test/integration2/migrate/migration-lifecycle-integration.spec.js new file mode 100644 index 0000000000..98b772503c --- /dev/null +++ b/test/integration2/migrate/migration-lifecycle-integration.spec.js @@ -0,0 +1,326 @@ +'use strict'; +const sinon = require('sinon'); +const chai = require('chai'); +chai.use(require('chai-as-promised')); +const { expect } = chai; + +const path = require('path'); +const rimraf = require('rimraf'); +const logger = require('../../integration/logger'); +const { getAllDbs, getKnexForDb } = require('../util/knex-instance-provider'); + +describe('Migrations Lifecycle Hooks', function () { + getAllDbs().forEach((db) => { + describe(db, () => { + let knex; + + // Force clean slate before each test + beforeEach(async () => { + rimraf.sync(path.join(__dirname, './migration')); + knex = logger(getKnexForDb(db)); + // make sure lock was not left from previous failed test run + await knex.schema.dropTableIfExists('knex_migrations'); + await knex.schema.dropTableIfExists('migration_test_1'); + await knex.schema.dropTableIfExists('migration_test_2'); + await knex.schema.dropTableIfExists('migration_test_2_1'); + await knex.migrate.forceFreeMigrationsLock({ + directory: 'test/integration2/migrate/test', + }); + }); + + describe('knex.migrate.latest', function () { + describe('beforeAll', function () { + it('runs before the migrations batch', async function () { + let count, migrationFiles; + await knex.migrate.latest({ + directory: 'test/integration2/migrate/test', + beforeAll: async (knexOrTrx, migrations) => { + const data = await knexOrTrx('knex_migrations').select('*'); + count = data.length; + migrationFiles = migrations; + }, + }); + + const data = await knex('knex_migrations').select('*'); + expect(data.length).to.equal(count + 2); + + expect(migrationFiles).to.deep.equal([ + { + directory: 'test/integration2/migrate/test', + file: '20131019235242_migration_1.js', + }, + { + directory: 'test/integration2/migrate/test', + file: '20131019235306_migration_2.js', + }, + ]); + }); + + it('does not run the migration or beforeEach/afterEach/afterAll hooks if it fails', async function () { + const beforeAll = sinon + .stub() + .throws(new Error('force beforeAll hook failure')); + const beforeEach = sinon.stub(); + const afterEach = sinon.stub(); + const afterAll = sinon.stub(); + + await knex.migrate + .latest({ + directory: 'test/integration2/migrate/test', + beforeAll, + beforeEach, + afterEach, + afterAll, + }) + .catch((error) => { + expect(error.message).to.equal('force beforeAll hook failure'); + }); + + // Should not have run the migration + const hasTableCreatedByMigration = await knex.schema.hasTable( + 'migration_test_1' + ); + expect(hasTableCreatedByMigration).to.be.false; + + // Should not have called the other hooks + expect(beforeEach.called).to.be.false; + expect(afterEach.called).to.be.false; + expect(afterAll.called).to.be.false; + }); + }); + + describe('afterAll', function () { + it('runs after the migrations batch', async function () { + let count, migrationFiles; + await knex.migrate.latest({ + directory: 'test/integration2/migrate/test', + afterAll: async (knexOrTrx, migrations) => { + const data = await knexOrTrx('knex_migrations').select('*'); + count = data.length; + migrationFiles = migrations; + }, + }); + + const data = await knex('knex_migrations').select('*'); + expect(data.length).to.equal(count); + + expect(migrationFiles).to.deep.equal([ + { + directory: 'test/integration2/migrate/test', + file: '20131019235242_migration_1.js', + }, + { + directory: 'test/integration2/migrate/test', + file: '20131019235306_migration_2.js', + }, + ]); + }); + + it('is not called if the migration fails', async function () { + const afterAll = sinon.stub(); + + await knex.migrate + .latest({ + directory: 'test/integration2/migrate/test_with_invalid', + afterAll, + }) + .catch(() => {}); + + expect(afterAll.called).to.be.false; + }); + }); + + describe('beforeEach', function () { + it('runs before each migration', async function () { + const tableExistenceChecks = []; + const beforeEach = sinon.stub().callsFake(async (trx) => { + const hasFirstTestTable = await trx.schema.hasTable( + 'migration_test_1' + ); + const hasSecondTestTable = await trx.schema.hasTable( + 'migration_test_2' + ); + tableExistenceChecks.push({ + hasFirstTestTable, + hasSecondTestTable, + }); + }); + + await knex.migrate.latest({ + directory: 'test/integration2/migrate/test', + beforeEach, + }); + + expect(beforeEach.callCount).to.equal(2); + expect(tableExistenceChecks).to.deep.equal([ + { + hasFirstTestTable: false, + hasSecondTestTable: false, + }, + { + hasFirstTestTable: true, + hasSecondTestTable: false, + }, + ]); + }); + + it('does not run the migration and the afterEach/afterAll hooks if the hook fails', async function () { + const beforeEach = sinon + .stub() + .throws(new Error('force beforeEach hook failure')); + const afterEach = sinon.stub(); + const afterAll = sinon.stub(); + + const error = await knex.migrate + .latest({ + directory: 'test/integration2/migrate/test', + beforeEach, + afterEach, + afterAll, + }) + .catch((error) => error); + + expect(error.message).to.equal('force beforeEach hook failure'); + + // Should not have run the migration + const hasTableCreatedByMigration = await knex.schema.hasTable( + 'migration_test_1' + ); + expect(hasTableCreatedByMigration).to.be.false; + + // Should not have called the after hooks + expect(afterEach.called).to.be.false; + expect(afterAll.called).to.be.false; + }); + }); + + describe('afterEach', function () { + it('runs after each migration', async function () { + const tableExistenceChecks = []; + const afterEach = sinon.stub().callsFake(async (trx) => { + const hasFirstTestTable = await trx.schema.hasTable( + 'migration_test_1' + ); + const hasSecondTestTable = await trx.schema.hasTable( + 'migration_test_2' + ); + tableExistenceChecks.push({ + hasFirstTestTable, + hasSecondTestTable, + }); + }); + + await knex.migrate.latest({ + directory: 'test/integration2/migrate/test', + afterEach, + }); + + expect(afterEach.callCount).to.equal(2); + expect(tableExistenceChecks).to.deep.equal([ + { + hasFirstTestTable: true, + hasSecondTestTable: false, + }, + { + hasFirstTestTable: true, + hasSecondTestTable: true, + }, + ]); + }); + + it('is not called after a migration fails', async function () { + const afterEach = sinon.stub(); + + await knex.migrate + .latest({ + directory: 'test/integration2/migrate/test_with_invalid', + afterEach, + }) + .catch(() => {}); + + // The afterEach hook should have run for the first two successful migrations, but + // not after failed third migration + expect(afterEach.callCount).to.equal(2); + expect( + afterEach.args.map(([_knex, [{ file }]]) => file) + ).to.deep.equal([ + '20131019235242_migration_1.js', + '20131019235306_migration_2.js', + ]); + }); + + it('does not run the afterAll hook if the hook fails', async function () { + const afterEach = sinon + .stub() + .throws(new Error('force afterEach hook failure')); + const afterAll = sinon.stub(); + + await knex.migrate + .latest({ + directory: 'test/integration2/migrate/test', + afterEach, + afterAll, + }) + .catch((error) => { + expect(error.message).to.equal('force afterEach hook failure'); + }); + + expect(afterAll.called).to.be.false; + }); + }); + + describe('execution order', function () { + it('runs in the expected order of beforeAll -> beforeEach -> afterEach -> afterAll', async function () { + const order = []; + + await knex.migrate.latest({ + directory: 'test/integration2/migrate/test', + beforeAll: () => order.push('beforeAll'), + beforeEach: (_knex, [{ file }]) => + order.push(`beforeEach-${file}`), + afterEach: (_knex, [{ file }]) => order.push(`afterEach-${file}`), + afterAll: () => order.push('afterAll'), + }); + + expect(order).to.deep.equal([ + 'beforeAll', + 'beforeEach-20131019235242_migration_1.js', + 'afterEach-20131019235242_migration_1.js', + 'beforeEach-20131019235306_migration_2.js', + 'afterEach-20131019235306_migration_2.js', + 'afterAll', + ]); + }); + }); + + describe('when there are no pending migrations', function () { + it('does not run any of the hooks', async function () { + // Fire the migrations once to get the DB up to date + await knex.migrate.latest({ + directory: 'test/integration2/migrate/test', + }); + + // Now there should not be any pending migrations + const beforeAll = sinon.stub(); + const beforeEach = sinon.stub(); + const afterEach = sinon.stub(); + const afterAll = sinon.stub(); + + await knex.migrate.latest({ + directory: 'test/integration2/migrate/test', + beforeAll, + beforeEach, + afterEach, + afterAll, + }); + + expect(beforeAll.called).to.be.false; + expect(beforeEach.called).to.be.false; + expect(afterEach.called).to.be.false; + expect(afterAll.called).to.be.false; + }); + }); + }); + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index d191bd4764..e5bf25c6fd 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3151,15 +3151,31 @@ declare namespace Knex { name?: string; } + // Note that the shape of the `migration` depends on the MigrationSource which may be custom. + type LifecycleHook = ( + knexOrTrx: Knex | Transaction, + migrations: unknown[] + ) => Promise; + + interface MigratorConfigWithLifecycleHooks extends MigratorConfig { + beforeAll?: LifecycleHook; + beforeEach?: LifecycleHook; + afterEach?: LifecycleHook; + afterAll?: LifecycleHook; + } + interface Migrator { make(name: string, config?: MigratorConfig): Promise; - latest(config?: MigratorConfig): Promise; - rollback(config?: MigratorConfig, all?: boolean): Promise; + latest(config?: MigratorConfigWithLifecycleHooks): Promise; + rollback( + config?: MigratorConfigWithLifecycleHooks, + all?: boolean + ): Promise; status(config?: MigratorConfig): Promise; currentVersion(config?: MigratorConfig): Promise; list(config?: MigratorConfig): Promise; - up(config?: MigratorConfig): Promise; - down(config?: MigratorConfig): Promise; + up(config?: MigratorConfigWithLifecycleHooks): Promise; + down(config?: MigratorConfigWithLifecycleHooks): Promise; forceFreeMigrationsLock(config?: MigratorConfig): Promise; }