From 4b391fc3cf30d3e717d887482aa837f42a5391c6 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 9 Jul 2024 12:32:02 +0530 Subject: [PATCH] Initial implementation with just the generate method (#7973) --- packages/core/utils/src/migrations/index.ts | 106 +++++++++++ .../__tests__/migrations-generate.spec.ts | 139 ++++++++++++++ .../__tests__/migrations-revert.spec.ts | 173 ++++++++++++++++++ .../__tests__/migrations-run.spec.ts | 171 +++++++++++++++++ .../mikro-orm-cli-config-builder.spec.ts | 6 +- .../mikro-orm-cli-config-builder.ts | 12 +- 6 files changed, 594 insertions(+), 13 deletions(-) create mode 100644 packages/core/utils/src/migrations/index.ts create mode 100644 packages/core/utils/src/migrations/integration-tests/__tests__/migrations-generate.spec.ts create mode 100644 packages/core/utils/src/migrations/integration-tests/__tests__/migrations-revert.spec.ts create mode 100644 packages/core/utils/src/migrations/integration-tests/__tests__/migrations-run.spec.ts diff --git a/packages/core/utils/src/migrations/index.ts b/packages/core/utils/src/migrations/index.ts new file mode 100644 index 0000000000..d8112ab404 --- /dev/null +++ b/packages/core/utils/src/migrations/index.ts @@ -0,0 +1,106 @@ +import { EventEmitter } from "events" +import { + MigrateOptions, + MigrationResult, + UmzugMigration, +} from "@mikro-orm/migrations" +import { MikroORM, MikroORMOptions } from "@mikro-orm/core" + +/** + * Events emitted by the migrations class + */ +export type MigrationsEvents = { + migrating: [UmzugMigration] + migrated: [UmzugMigration] + reverting: [UmzugMigration] + reverted: [UmzugMigration] +} + +/** + * Exposes the API to programmatically manage Mikro ORM migrations + */ +export class Migrations extends EventEmitter { + #config: Partial + + constructor(config: Partial) { + super() + this.#config = config + } + + /** + * Returns an existing connection or instantiates a new + * one + */ + async #getConnection() { + return await MikroORM.init({ + ...this.#config, + migrations: { + ...this.#config.migrations, + silent: true, + }, + }) + } + + /** + * Generates migrations for a collection of entities defined + * in the config + */ + async generate(): Promise { + const connection = await this.#getConnection() + const migrator = connection.getMigrator() + try { + return await migrator.createMigration() + } finally { + await connection.close(true) + } + } + + /** + * Run migrations for the provided entities + */ + async run( + options?: string | string[] | MigrateOptions + ): Promise { + const connection = await this.#getConnection() + const migrator = connection.getMigrator() + + migrator["umzug"].on("migrating", (event: UmzugMigration) => + this.emit("migrating", event) + ) + migrator["umzug"].on("migrated", (event: UmzugMigration) => { + this.emit("migrated", event) + }) + + try { + const res = await migrator.up(options) + return res + } finally { + migrator["umzug"].clearListeners() + await connection.close(true) + } + } + + /** + * Run migrations for the provided entities + */ + async revert( + options?: string | string[] | MigrateOptions + ): Promise { + const connection = await this.#getConnection() + const migrator = connection.getMigrator() + + migrator["umzug"].on("reverting", (event: UmzugMigration) => + this.emit("reverting", event) + ) + migrator["umzug"].on("reverted", (event: UmzugMigration) => { + this.emit("reverted", event) + }) + + try { + return await migrator.down(options) + } finally { + migrator["umzug"].clearListeners() + await connection.close(true) + } + } +} diff --git a/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-generate.spec.ts b/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-generate.spec.ts new file mode 100644 index 0000000000..750cb240ae --- /dev/null +++ b/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-generate.spec.ts @@ -0,0 +1,139 @@ +import { join } from "path" +import { setTimeout } from "timers/promises" +import { MetadataStorage } from "@mikro-orm/core" +import { createDatabase, dropDatabase } from "pg-god" + +import { Migrations } from "../../index" +import { FileSystem } from "../../../common" +import { DmlEntity, model } from "../../../dml" +import { defineMikroOrmCliConfig } from "../../../modules-sdk" + +const DB_HOST = process.env.DB_HOST ?? "localhost" +const DB_USERNAME = process.env.DB_USERNAME ?? "" +const DB_PASSWORD = process.env.DB_PASSWORD ?? " " + +const dbName = "my-test-service-generate" +const moduleName = "myTestServiceGenerate" +const fs = new FileSystem(join(__dirname, "./migrations/generate")) + +const pgGodCredentials = { + user: DB_USERNAME, + password: DB_PASSWORD, + host: DB_HOST, +} + +describe("Generate migrations", () => { + beforeEach(async () => { + await createDatabase({ databaseName: dbName }, pgGodCredentials) + }) + + afterEach(async () => { + await dropDatabase( + { databaseName: dbName, errorIfNonExist: false }, + pgGodCredentials + ) + await fs.cleanup() + MetadataStorage.clear() + }, 300 * 1000) + + test("generate migrations for a single entity", async () => { + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User], + dbName: dbName, + migrations: { + path: fs.basePath, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + const results = await migrations.generate() + + expect(await fs.exists(results.fileName)) + expect(await fs.contents(results.fileName)).toMatch( + /create table if not exists "user"/ + ) + }) + + test("generate migrations for multiple entities", async () => { + const User = model + .define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + cars: model.hasMany(() => Car), + }) + .cascades({ + delete: ["cars"], + }) + + const Car = model.define("Car", { + id: model.id().primaryKey(), + name: model.text(), + user: model.belongsTo(() => User, { mappedBy: "cars" }), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User, Car], + dbName: dbName, + migrations: { + path: fs.basePath, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + const results = await migrations.generate() + + expect(await fs.exists(results.fileName)) + expect(await fs.contents(results.fileName)).toMatch( + /create table if not exists "user"/ + ) + expect(await fs.contents(results.fileName)).toMatch( + /create table if not exists "car"/ + ) + }) + + test("generate new file when entities are added", async () => { + function run(entities: DmlEntity[]) { + const config = defineMikroOrmCliConfig(moduleName, { + entities, + dbName: dbName, + migrations: { + path: fs.basePath, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + return migrations.generate() + } + + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const run1 = await run([User]) + expect(await fs.exists(run1.fileName)) + + const Car = model.define("Car", { + id: model.id().primaryKey(), + name: model.text(), + }) + + await setTimeout(1000) + + const run2 = await run([User, Car]) + expect(await fs.exists(run2.fileName)) + + expect(run1.fileName).not.toEqual(run2.fileName) + }) +}) diff --git a/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-revert.spec.ts b/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-revert.spec.ts new file mode 100644 index 0000000000..6e000d61b5 --- /dev/null +++ b/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-revert.spec.ts @@ -0,0 +1,173 @@ +import { join } from "path" +import { MikroORM } from "@mikro-orm/postgresql" +import { MetadataStorage } from "@mikro-orm/core" +import { createDatabase, dropDatabase } from "pg-god" +import { TSMigrationGenerator } from "@mikro-orm/migrations" + +import { model } from "../../../dml" +import { FileSystem } from "../../../common" +import { Migrations, MigrationsEvents } from "../../index" +import { defineMikroOrmCliConfig } from "../../../modules-sdk" + +const DB_HOST = process.env.DB_HOST ?? "localhost" +const DB_USERNAME = process.env.DB_USERNAME ?? "" +const DB_PASSWORD = process.env.DB_PASSWORD ?? " " + +const dbName = "my-test-service-revert" +const moduleName = "myTestServiceRevert" +const fs = new FileSystem(join(__dirname, "./migrations/revert")) + +const migrationFileNameGenerator = (_: string, name?: string) => { + return `Migration${new Date().getTime()}${name ? `_${name}` : ""}` +} + +const pgGodCredentials = { + user: DB_USERNAME, + password: DB_PASSWORD, + host: DB_HOST, +} + +describe("Revert migrations", () => { + beforeEach(async () => { + await createDatabase({ databaseName: dbName }, pgGodCredentials) + }) + + afterEach(async () => { + await fs.cleanup() + await dropDatabase( + { databaseName: dbName, errorIfNonExist: false }, + pgGodCredentials + ) + MetadataStorage.clear() + }, 300 * 1000) + + test("revert migrations", async () => { + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User], + dbName: dbName, + migrations: { + path: fs.basePath, + fileName: migrationFileNameGenerator, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + await migrations.generate() + await migrations.run() + const results = await migrations.revert() + + const orm = await MikroORM.init(config) + const usersTableExists = await orm.em.getKnex().schema.hasTable("user") + await orm.close() + + expect(results).toHaveLength(1) + expect(results).toEqual([ + { + name: expect.stringMatching(/Migration\d+/), + path: expect.stringContaining(__dirname), + }, + ]) + expect(usersTableExists).toEqual(false) + }) + + test("emit events during revert", async () => { + let events: { + event: keyof MigrationsEvents + payload: MigrationsEvents[keyof MigrationsEvents][number] + }[] = [] + + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User], + dbName: dbName, + migrations: { + path: fs.basePath, + fileName: migrationFileNameGenerator, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + + migrations.on("reverting", (event) => { + events.push({ event: "reverting", payload: event }) + }) + migrations.on("reverted", (event) => { + events.push({ event: "reverted", payload: event }) + }) + + await migrations.generate() + await migrations.run() + await migrations.revert() + + expect(events).toHaveLength(2) + + expect(events[0].event).toEqual("reverting") + expect(events[0].payload).toEqual({ + name: expect.stringMatching(/Migration\d+/), + path: expect.stringContaining(__dirname), + context: {}, + }) + + expect(events[1].event).toEqual("reverted") + expect(events[1].payload).toEqual({ + name: expect.stringMatching(/Migration\d+/), + path: expect.stringContaining(__dirname), + context: {}, + }) + }) + + test("throw error when migration fails during revert", async () => { + /** + * Custom strategy to output invalid SQL statement inside the + * migration file + */ + class CustomTSMigrationGenerator extends TSMigrationGenerator { + createStatement(sql: string, padLeft: number): string { + let output = super.createStatement(sql, padLeft) + return output.replace("drop table", "drop foo") + } + } + + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User], + dbName: dbName, + migrations: { + path: fs.basePath, + generator: CustomTSMigrationGenerator, + fileName: migrationFileNameGenerator, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + await migrations.generate() + + await migrations.run() + expect(migrations.revert()).rejects.toThrow(/.*Migration.*/) + + const orm = await MikroORM.init(config) + const usersTableExists = await orm.em.getKnex().schema.hasTable("user") + await orm.close() + + expect(usersTableExists).toEqual(true) + }) +}) diff --git a/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-run.spec.ts b/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-run.spec.ts new file mode 100644 index 0000000000..725fda42bd --- /dev/null +++ b/packages/core/utils/src/migrations/integration-tests/__tests__/migrations-run.spec.ts @@ -0,0 +1,171 @@ +import { join } from "path" +import { MikroORM } from "@mikro-orm/postgresql" +import { MetadataStorage } from "@mikro-orm/core" +import { createDatabase, dropDatabase } from "pg-god" +import { TSMigrationGenerator } from "@mikro-orm/migrations" + +import { model } from "../../../dml" +import { FileSystem } from "../../../common" +import { Migrations, MigrationsEvents } from "../../index" +import { defineMikroOrmCliConfig } from "../../../modules-sdk" + +const migrationFileNameGenerator = (_: string, name?: string) => { + return `Migration${new Date().getTime()}${name ? `_${name}` : ""}` +} + +const DB_HOST = process.env.DB_HOST ?? "localhost" +const DB_USERNAME = process.env.DB_USERNAME ?? "" +const DB_PASSWORD = process.env.DB_PASSWORD ?? " " +process.env.DB_PASSWORD = DB_PASSWORD + +const dbName = "my-test-service-run" +const moduleName = "myTestServiceRun" +const fs = new FileSystem(join(__dirname, "./migrations/run")) + +const pgGodCredentials = { + user: DB_USERNAME, + password: DB_PASSWORD, + host: DB_HOST, +} + +describe("Run migrations", () => { + beforeEach(async () => { + await createDatabase({ databaseName: dbName }, pgGodCredentials) + }) + + afterEach(async () => { + await dropDatabase( + { databaseName: dbName, errorIfNonExist: false }, + pgGodCredentials + ) + await fs.cleanup() + MetadataStorage.clear() + }, 300 * 1000) + + test("run migrations after generating them", async () => { + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User], + dbName: dbName, + migrations: { + path: fs.basePath, + fileName: migrationFileNameGenerator, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + await migrations.generate() + const results = await migrations.run() + + const orm = await MikroORM.init(config) + const usersTableExists = await orm.em.getKnex().schema.hasTable("user") + await orm.close() + + expect(results).toHaveLength(1) + expect(results).toEqual([ + { + name: expect.stringMatching(/Migration\d+/), + path: expect.stringContaining(__dirname), + }, + ]) + expect(usersTableExists).toEqual(true) + }) + + test("emit events when running migrations", async () => { + let events: { + event: keyof MigrationsEvents + payload: MigrationsEvents[keyof MigrationsEvents][number] + }[] = [] + + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User], + dbName: dbName, + migrations: { + path: fs.basePath, + fileName: migrationFileNameGenerator, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + await migrations.generate() + + migrations.on("migrating", (event) => { + events.push({ event: "migrating", payload: event }) + }) + migrations.on("migrated", (event) => { + events.push({ event: "migrated", payload: event }) + }) + + await migrations.run() + expect(events).toHaveLength(2) + + expect(events[0].event).toEqual("migrating") + expect(events[0].payload).toEqual({ + name: expect.stringMatching(/Migration\d+/), + path: expect.stringContaining(__dirname), + context: {}, + }) + + expect(events[1].event).toEqual("migrated") + expect(events[1].payload).toEqual({ + name: expect.stringMatching(/Migration\d+/), + path: expect.stringContaining(__dirname), + context: {}, + }) + }) + + test("throw error when migration fails during run", async () => { + /** + * Custom strategy to output invalid SQL statement inside the + * migration file + */ + class CustomTSMigrationGenerator extends TSMigrationGenerator { + createStatement(sql: string, padLeft: number): string { + let output = super.createStatement(sql, padLeft) + return output.replace('"user"', '"foo";') + } + } + + const User = model.define("User", { + id: model.id().primaryKey(), + email: model.text().unique(), + fullName: model.text().nullable(), + }) + + const config = defineMikroOrmCliConfig(moduleName, { + entities: [User], + dbName: dbName, + migrations: { + path: fs.basePath, + generator: CustomTSMigrationGenerator, + fileName: migrationFileNameGenerator, + }, + ...pgGodCredentials, + }) + + const migrations = new Migrations(config) + await migrations.generate() + + expect(migrations.run()).rejects.toThrow(/.*Migration.*/) + + const orm = await MikroORM.init(config) + const usersTableExists = await orm.em.getKnex().schema.hasTable("user") + + await orm.close() + + expect(usersTableExists).toEqual(false) + }) +}) diff --git a/packages/core/utils/src/modules-sdk/__tests__/mikro-orm-cli-config-builder.spec.ts b/packages/core/utils/src/modules-sdk/__tests__/mikro-orm-cli-config-builder.spec.ts index 62aefdf374..ea525b414e 100644 --- a/packages/core/utils/src/modules-sdk/__tests__/mikro-orm-cli-config-builder.spec.ts +++ b/packages/core/utils/src/modules-sdk/__tests__/mikro-orm-cli-config-builder.spec.ts @@ -14,13 +14,13 @@ describe("defineMikroOrmCliConfig", () => { test("should return the correct config", () => { const config = defineMikroOrmCliConfig(moduleName, { entities: [{} as any], - databaseName: "medusa-fulfillment", + dbName: "medusa-fulfillment", }) expect(config).toEqual({ entities: [{}], - clientUrl: "postgres://postgres@localhost/medusa-fulfillment", type: "postgresql", + dbName: "medusa-fulfillment", migrations: { generator: expect.any(Function), }, @@ -34,8 +34,8 @@ describe("defineMikroOrmCliConfig", () => { expect(config).toEqual({ entities: [{}], - clientUrl: "postgres://postgres@localhost/medusa-my-test", type: "postgresql", + dbName: "medusa-my-test", migrations: { generator: expect.any(Function), }, diff --git a/packages/core/utils/src/modules-sdk/mikro-orm-cli-config-builder.ts b/packages/core/utils/src/modules-sdk/mikro-orm-cli-config-builder.ts index 3572b1b700..6670acde82 100644 --- a/packages/core/utils/src/modules-sdk/mikro-orm-cli-config-builder.ts +++ b/packages/core/utils/src/modules-sdk/mikro-orm-cli-config-builder.ts @@ -17,12 +17,10 @@ type Options = Partial> & { | EntitySchema | DmlEntity )[] - databaseName?: string } type ReturnedOptions = Partial & { entities: MikroORMOptions["entities"] - clientUrl: string type: MikroORMOptions["type"] migrations: MikroORMOptions["migrations"] } @@ -53,17 +51,11 @@ export function defineMikroOrmCliConfig( ) as MikroORMOptions["entities"] const normalizedModuleName = kebabCase(moduleName.replace("Service", "")) - let databaseName = `medusa-${normalizedModuleName}` - - if (options.databaseName) { - databaseName = options.databaseName - // @ts-ignore - delete options.databaseName - } + const databaseName = `medusa-${normalizedModuleName}` return { - clientUrl: `postgres://postgres@localhost/${databaseName}`, type: "postgresql", + dbName: databaseName, ...options, entities, migrations: {