diff --git a/.changeset/seven-gorillas-smile.md b/.changeset/seven-gorillas-smile.md new file mode 100644 index 0000000000..b15db26109 --- /dev/null +++ b/.changeset/seven-gorillas-smile.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/cli": patch +--- + +Feat(medusa, cli): plugin db generate diff --git a/packages/cli/medusa-cli/src/create-cli.ts b/packages/cli/medusa-cli/src/create-cli.ts index 4c26cf50be..47264680ae 100644 --- a/packages/cli/medusa-cli/src/create-cli.ts +++ b/packages/cli/medusa-cli/src/create-cli.ts @@ -238,6 +238,16 @@ function buildLocalCommands(cli, isLocalProject) { }) ), }) + .command({ + command: "plugin:db:generate", + desc: "Generate migrations for a given module", + handler: handlerP( + getCommandHandler("plugin/db/generate", (args, cmd) => { + process.env.NODE_ENV = process.env.NODE_ENV || `development` + return cmd(args) + }) + ), + }) .command({ command: "db:sync-links", desc: "Sync database schema with the links defined by your application and Medusa core", diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 28fd26d61b..e0001a32d8 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -40,7 +40,8 @@ "watch": "tsc --build --watch", "build": "rimraf dist && tsc --build", "serve": "node dist/app.js", - "test": "jest --silent=false --bail --maxWorkers=50% --forceExit" + "test": "jest --runInBand --bail --forceExit --testPathIgnorePatterns='/integration-tests/' -- src/**/__tests__/**/*.ts", + "test:integration": "jest --forceExit -- src/**/integration-tests/**/__tests__/**/*.ts" }, "devDependencies": { "@medusajs/framework": "^2.2.0", diff --git a/packages/medusa/src/commands/plugin/db/generate.ts b/packages/medusa/src/commands/plugin/db/generate.ts new file mode 100644 index 0000000000..caa5e6114a --- /dev/null +++ b/packages/medusa/src/commands/plugin/db/generate.ts @@ -0,0 +1,135 @@ +import { logger } from "@medusajs/framework/logger" +import { + defineMikroOrmCliConfig, + DmlEntity, + dynamicImport, +} from "@medusajs/framework/utils" +import { dirname, join } from "path" + +import { MetadataStorage } from "@mikro-orm/core" +import { MikroORM } from "@mikro-orm/postgresql" +import { glob } from "glob" + +const TERMINAL_SIZE = process.stdout.columns + +/** + * Generate migrations for all scanned modules in a plugin + */ +const main = async function ({ directory }) { + try { + const moduleDescriptors = [] as { + serviceName: string + migrationsPath: string + entities: any[] + }[] + + const modulePaths = glob.sync( + join(directory, "src", "modules", "*", "index.ts") + ) + + for (const path of modulePaths) { + const moduleDirname = dirname(path) + const serviceName = await getModuleServiceName(path) + const entities = await getEntitiesForModule(moduleDirname) + + moduleDescriptors.push({ + serviceName, + migrationsPath: join(moduleDirname, "migrations"), + entities, + }) + } + + /** + * Generating migrations + */ + logger.info("Generating migrations...") + + await generateMigrations(moduleDescriptors) + + console.log(new Array(TERMINAL_SIZE).join("-")) + logger.info("Migrations generated") + + process.exit() + } catch (error) { + console.log(new Array(TERMINAL_SIZE).join("-")) + + logger.error(error.message, error) + process.exit(1) + } +} + +async function getEntitiesForModule(path: string) { + const entities = [] as any[] + + const entityPaths = glob.sync(join(path, "models", "*.ts"), { + ignore: ["**/index.{js,ts}"], + }) + + for (const entityPath of entityPaths) { + const entityExports = await dynamicImport(entityPath) + + const validEntities = Object.values(entityExports).filter( + (potentialEntity) => { + return ( + DmlEntity.isDmlEntity(potentialEntity) || + !!MetadataStorage.getMetadataFromDecorator(potentialEntity as any) + ) + } + ) + entities.push(...validEntities) + } + + return entities +} + +async function getModuleServiceName(path: string) { + const moduleExport = await dynamicImport(path) + if (!moduleExport.default) { + throw new Error("The module should default export the `Module()`") + } + return (moduleExport.default.service as any).prototype.__joinerConfig() + .serviceName +} + +async function generateMigrations( + moduleDescriptors: { + serviceName: string + migrationsPath: string + entities: any[] + }[] = [] +) { + const DB_HOST = process.env.DB_HOST ?? "localhost" + const DB_USERNAME = process.env.DB_USERNAME ?? "" + const DB_PASSWORD = process.env.DB_PASSWORD ?? "" + + for (const moduleDescriptor of moduleDescriptors) { + logger.info( + `Generating migrations for module ${moduleDescriptor.serviceName}...` + ) + + const mikroOrmConfig = defineMikroOrmCliConfig( + moduleDescriptor.serviceName, + { + entities: moduleDescriptor.entities, + host: DB_HOST, + user: DB_USERNAME, + password: DB_PASSWORD, + migrations: { + path: moduleDescriptor.migrationsPath, + }, + } + ) + + const orm = await MikroORM.init(mikroOrmConfig) + const migrator = orm.getMigrator() + const result = await migrator.createMigration() + + if (result.fileName) { + logger.info(`Migration created: ${result.fileName}`) + } else { + logger.info(`No migration created`) + } + } +} + +export default main diff --git a/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1-no-default/src/modules/module-1/index.ts b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1-no-default/src/modules/module-1/index.ts new file mode 100644 index 0000000000..e9b82443ac --- /dev/null +++ b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1-no-default/src/modules/module-1/index.ts @@ -0,0 +1,5 @@ +import { MedusaService, Module } from "@medusajs/framework/utils" + +export const module1 = Module("module1", { + service: class Module1Service extends MedusaService({}) {}, +}) diff --git a/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1-no-default/src/modules/module-1/models/module-model-1.ts b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1-no-default/src/modules/module-1/models/module-model-1.ts new file mode 100644 index 0000000000..c13fc1d203 --- /dev/null +++ b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1-no-default/src/modules/module-1/models/module-model-1.ts @@ -0,0 +1,8 @@ +import { model } from "@medusajs/framework/utils" + +const model1 = model.define("module_model_1", { + id: model.id().primaryKey(), + name: model.text(), +}) + +export default model1 diff --git a/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1/src/modules/module-1/index.ts b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1/src/modules/module-1/index.ts new file mode 100644 index 0000000000..ca6b5cbd40 --- /dev/null +++ b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1/src/modules/module-1/index.ts @@ -0,0 +1,5 @@ +import { MedusaService, Module } from "@medusajs/framework/utils" + +export default Module("module1", { + service: class Module1Service extends MedusaService({}) {}, +}) diff --git a/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1/src/modules/module-1/models/module-model-1.ts b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1/src/modules/module-1/models/module-model-1.ts new file mode 100644 index 0000000000..c13fc1d203 --- /dev/null +++ b/packages/medusa/src/commands/plugin/db/integration-tests/__fixtures__/plugins-1/src/modules/module-1/models/module-model-1.ts @@ -0,0 +1,8 @@ +import { model } from "@medusajs/framework/utils" + +const model1 = model.define("module_model_1", { + id: model.id().primaryKey(), + name: model.text(), +}) + +export default model1 diff --git a/packages/medusa/src/commands/plugin/db/integration-tests/__tests__/plugin-generate.spec.ts b/packages/medusa/src/commands/plugin/db/integration-tests/__tests__/plugin-generate.spec.ts new file mode 100644 index 0000000000..b4dbad7fa4 --- /dev/null +++ b/packages/medusa/src/commands/plugin/db/integration-tests/__tests__/plugin-generate.spec.ts @@ -0,0 +1,93 @@ +import { logger } from "@medusajs/framework/logger" +import { FileSystem } from "@medusajs/framework/utils" +import { join } from "path" +import main from "../../generate" + +jest.mock("@medusajs/framework/logger") + +describe("plugin-generate", () => { + beforeEach(() => { + jest.clearAllMocks() + jest + .spyOn(process, "exit") + .mockImplementation((code?: string | number | null) => { + return code as never + }) + }) + + afterEach(async () => { + const module1 = new FileSystem( + join( + __dirname, + "..", + "__fixtures__", + "plugins-1", + "src", + "modules", + "module-1" + ) + ) + await module1.remove("migrations") + }) + + describe("main function", () => { + it("should successfully generate migrations when valid modules are found", async () => { + await main({ + directory: join(__dirname, "..", "__fixtures__", "plugins-1"), + }) + + expect(logger.info).toHaveBeenNthCalledWith(1, "Generating migrations...") + expect(logger.info).toHaveBeenNthCalledWith( + 2, + "Generating migrations for module module1..." + ) + expect(logger.info).toHaveBeenNthCalledWith( + 3, + expect.stringContaining("Migration created") + ) + expect(logger.info).toHaveBeenNthCalledWith(4, "Migrations generated") + expect(process.exit).toHaveBeenCalledWith() + }) + + it("should handle case when no migrations are needed", async () => { + await main({ + directory: join(__dirname, "..", "__fixtures__", "plugins-1"), + }) + + jest.clearAllMocks() + + await main({ + directory: join(__dirname, "..", "__fixtures__", "plugins-1"), + }) + + expect(logger.info).toHaveBeenNthCalledWith(1, "Generating migrations...") + expect(logger.info).toHaveBeenNthCalledWith( + 2, + "Generating migrations for module module1..." + ) + expect(logger.info).toHaveBeenNthCalledWith( + 3, + expect.stringContaining("No migration created") + ) + expect(logger.info).toHaveBeenNthCalledWith(4, "Migrations generated") + expect(process.exit).toHaveBeenCalledWith() + }) + + it("should handle error when module has no default export", async () => { + await main({ + directory: join( + __dirname, + "..", + "__fixtures__", + "plugins-1-no-default" + ), + }) + expect(logger.error).toHaveBeenCalledWith( + "The module should default export the `Module()`", + new Error("The module should default export the `Module()`") + ) + + expect(process.exit).toHaveBeenCalledWith(1) + }) + }) +})