From 5354d994673257f8fee020027ca272f88c453334 Mon Sep 17 00:00:00 2001 From: Muhammad Naufal Kateni Date: Fri, 31 Jul 2026 16:06:11 +0700 Subject: [PATCH] feat: combine mikroorm v6 & v7 implementation --- .github/workflows/ci.yml | 32 +++++++ docs/adapters.md | 27 ++++-- src/adapters/mikroorm/index.ts | 12 ++- src/adapters/mikroorm/types.ts | 14 ++- test/adapters/mikroorm-v7.test.ts | 91 +++++++++++++++++++ .../adapter/v7-basic/mikro-orm.config.ts | 16 ++++ .../adapter/v7-basic/src/entities/Post.ts | 27 ++++++ .../adapter/v7-basic/src/entities/Tag.ts | 28 ++++++ .../adapter/v7-basic/src/entities/User.ts | 21 +++++ .../mikroorm/adapter/v7-basic/tsconfig.json | 12 +++ .../adapter/v7-composite/mikro-orm.config.ts | 7 ++ .../v7-composite/src/entities/PostTag.ts | 20 ++++ .../adapter/v7-composite/tsconfig.json | 12 +++ 13 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 test/adapters/mikroorm-v7.test.ts create mode 100644 test/fixtures/mikroorm/adapter/v7-basic/mikro-orm.config.ts create mode 100644 test/fixtures/mikroorm/adapter/v7-basic/src/entities/Post.ts create mode 100644 test/fixtures/mikroorm/adapter/v7-basic/src/entities/Tag.ts create mode 100644 test/fixtures/mikroorm/adapter/v7-basic/src/entities/User.ts create mode 100644 test/fixtures/mikroorm/adapter/v7-basic/tsconfig.json create mode 100644 test/fixtures/mikroorm/adapter/v7-composite/mikro-orm.config.ts create mode 100644 test/fixtures/mikroorm/adapter/v7-composite/src/entities/PostTag.ts create mode 100644 test/fixtures/mikroorm/adapter/v7-composite/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a11c24..9c71fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,35 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info fail_ci_if_error: false + + # The committed @mikro-orm/core/@mikro-orm/sqlite devDependencies stay + # pinned to v6 (the main `ci` job above) since it's still what most + # production/NestJS codebases run; this job additionally verifies the + # MikroORM adapter against v7 (npm's current `latest` tag) by swapping the + # installed version on top of `npm ci` without touching package.json/ + # package-lock.json. + mikroorm-v7: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci + - name: Swap in MikroORM v7 + run: npm install @mikro-orm/core@7 @mikro-orm/sqlite@7 @mikro-orm/decorators@7 --no-save + - name: Run MikroORM adapter/detect tests with coverage + run: npx vitest run test/adapters/mikroorm-v7.test.ts test/detect/mikroorm.test.ts --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + flags: mikroorm-v7 + fail_ci_if_error: false diff --git a/docs/adapters.md b/docs/adapters.md index cd8bc1d..4a186e7 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -350,10 +350,15 @@ file(s): ## MikroORM -Targets MikroORM v6 (`@mikro-orm/core@^6`) — classic `@Entity()`/`@Property()`/... decorators, the -dominant style in existing production/NestJS codebases. (v7 moved decorators to a different import -path and promotes a newer `defineEntity()` API instead; not targeted, though the discovery -mechanism below is unchanged between the two versions.) +Supports both MikroORM v6 (`@mikro-orm/core@^6`) and v7 (`@mikro-orm/core@^7`, npm's current +`latest` tag) — classic `@Entity()`/`@Property()`/... decorators, the dominant style in existing +production/NestJS codebases. v7 extracted these decorators out of `@mikro-orm/core` into a +separate `@mikro-orm/decorators` package and promotes a newer `defineEntity()` API instead — purely +informational for this adapter, since it just executes whatever a target project's own entry point +imports; it never imports decorators itself. The one real runtime difference between the two +versions is `getMetadata().getAll()`'s return shape (see below); discovery, `connect`, +`dynamicImportProvider`, folder-based scanning, and everything else described in this section is +unchanged between v6 and v7. **Detect** — [`src/detect/mikroorm.ts`](../src/detect/mikroorm.ts) @@ -371,9 +376,11 @@ mechanism below is unchanged between the two versions.) The entry is the config file. Unlike TypeORM, this never needs TypeORM's private `ConnectionMetadataBuilder`-style reach-in — `MikroORM.init()`/`.getMetadata()` is MikroORM's own -public, documented API, and (unlike the `.d.ts` for some versions implies) `getMetadata().getAll()` -returns a plain `Dictionary` in v6, not a `Map` — confirmed by reading the compiled -`MetadataStorage.js`, not assumed from the type declarations. +public, documented API. `getMetadata().getAll()`'s return shape differs by major version though — +confirmed by reading each version's compiled `MetadataStorage.js`, not assumed from either's +`.d.ts`: a plain `Dictionary` in v6, but a real `Map` in +v7. The adapter branches on `instanceof Map` to normalize both into an array before building the +model. - The config file's default export is loaded via `tsImport()` and duck-typed as either a plain options object (the standard `defineConfig({...})` convention — has an `entities`/`entitiesTs`/ @@ -390,11 +397,13 @@ returns a plain `Dictionary` in v6, not a `Map` — confirmed by `tsImport()` programmatically, not via a loader flag) — so `preferTs: true` is always forced in the options this adapter builds, or MikroORM would default to scanning nonexistent compiled `entities` output instead. -- **`connect: false` is a hard requirement, not just hygiene**: `MikroORM.init()` defaults +- **`connect: false` is a hard requirement in v6, not just hygiene**: `MikroORM.init()` defaults `connect: true` in v6 and, *after* discovery succeeds, does `if (config.get('connect')) await orm.connect()` — if that throws (an unreachable DB, which it will be from orm2erd's process), the whole `init()` call rejects and metadata is never returned even though discovery already - completed. Always forced off for the options-object path. + completed. Always forced off for the options-object path. (v7's `init()` dropped the auto-connect + step entirely — `connect()` is always a separate, explicit call now — so the flag is an inert, + harmless no-op key there, but still passed unconditionally since it's needed for v6.) - Folder-based discovery calls `Utils.dynamicImport(path)` per matched file — a real dynamic `import()`, not `require()` — and `Utils` exposes an explicit override hook, `Utils.dynamicImportProvider` (a static property on the target's own imported `Utils` class, diff --git a/src/adapters/mikroorm/index.ts b/src/adapters/mikroorm/index.ts index a50aba7..7767f42 100644 --- a/src/adapters/mikroorm/index.ts +++ b/src/adapters/mikroorm/index.ts @@ -657,6 +657,16 @@ export const mikroormAdapter: ORMAdapter = { ); } - return buildModel(Object.values(orm.getMetadata().getAll())); + // v6's `getAll()` returns a plain `Dictionary`; v7's + // returns a real `Map` — the only runtime + // behavior difference between the two versions this adapter has to + // account for (confirmed by reading each version's compiled + // MetadataStorage.js, not assumed from either's .d.ts). + const all = orm.getMetadata().getAll(); + const allMetadata = + all instanceof Map + ? [...all.values()] + : Object.values(all as Record); + return buildModel(allMetadata); }, }; diff --git a/src/adapters/mikroorm/types.ts b/src/adapters/mikroorm/types.ts index 0f366ea..33c963e 100644 --- a/src/adapters/mikroorm/types.ts +++ b/src/adapters/mikroorm/types.ts @@ -72,12 +72,16 @@ export interface MikroOrmEntityMetadata { pivotTable?: boolean; } -// `getAll()`'s real v6 runtime return type is a plain `Dictionary` -// keyed by entity name — NOT a `Map`, despite what `.d.ts` type hints from -// other MikroORM versions might suggest. Confirmed by reading the compiled -// `MetadataStorage.js` in a real v6.6.16 install. +// `getAll()`'s real runtime return type differs by major version — a plain +// `Dictionary` keyed by entity name in v6, but a real +// `Map` in v7. Confirmed by reading the compiled +// `MetadataStorage.js` in both a real v6.6.16 and v7.1.9 install (not +// assumed from either version's `.d.ts`). The adapter branches on +// `instanceof Map` to normalize both into an array. export interface MikroOrmMetadataStorage { - getAll(): Record; + getAll(): + | Record // v6 + | Map; // v7 } export interface MikroOrmInstance { diff --git a/test/adapters/mikroorm-v7.test.ts b/test/adapters/mikroorm-v7.test.ts new file mode 100644 index 0000000..6f1b388 --- /dev/null +++ b/test/adapters/mikroorm-v7.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mikroormAdapter } from "../../src/adapters/mikroorm"; + +const fixturesDir = join( + dirname(fileURLToPath(import.meta.url)), + "../fixtures/mikroorm/adapter", +); + +async function extractFixture(dir: string, filename: string) { + const cwd = join(fixturesDir, dir); + const entry = await mikroormAdapter.resolveEntry(filename, cwd); + return mikroormAdapter.extract(entry); +} + +// These fixtures import decorators from `@mikro-orm/decorators`, which +// doesn't exist at all under a v6 install (it's a v7 package split) — kept +// in its own file, separate from mikroorm.test.ts's v6 fixtures, because +// running both a v6-broken import and a real assertion in the same worker +// has been observed to corrupt vitest's own error reporting for unrelated +// tests in that file, not just fail the one test that actually broke. +// Skips entirely under v6 (the dedicated `mikroorm-v7` CI job swaps v7 in on +// top of the v6-pinned default; see .github/workflows/ci.yml). Reading the +// installed package's own package.json (not a `require()` of the module +// itself) keeps this a cheap, side-effect-free version check. +const installedMikroOrmCoreVersion = JSON.parse( + readFileSync( + createRequire(import.meta.url).resolve("@mikro-orm/core/package.json"), + "utf-8", + ), +).version as string; +const isMikroOrmV7 = installedMikroOrmCoreVersion.startsWith("7."); + +// MikroORM v7 moved classic decorators out of `@mikro-orm/core` into +// `@mikro-orm/decorators/legacy`, and `MetadataStorage.getAll()` returns a +// real `Map` instead of v6's plain `Dictionary` object — these fixtures +// exercise both of those under a real v7 install. Only a representative +// subset of the v6 "basic"/"composite" assertions is re-verified here (not +// full parity): everything else (folder discovery, embedded fields, relation +// dedup, etc.) is confirmed unchanged between versions and already covered +// in mikroorm.test.ts. +describe.skipIf(!isMikroOrmV7)( + "mikroormAdapter.extract — MikroORM v7 (@mikro-orm/decorators)", + () => { + it("discovers every entity and normalizes getAll()'s Map return into the model", async () => { + const model = await extractFixture("v7-basic", "mikro-orm.config.ts"); + expect(model.entities.map((e) => e.name).toSorted()).toEqual([ + "Post", + "Tag", + "User", + ]); + }); + + it("synthesizes a FK field for an owning @ManyToOne and emits the 1-n relation with onDelete/onUpdate", async () => { + const model = await extractFixture("v7-basic", "mikro-orm.config.ts"); + const post = model.entities.find((e) => e.name === "Post")!; + expect(post.fields.find((f) => f.name === "author_id")).toMatchObject({ + type: "int", + isForeignKey: true, + }); + + const userToPost = model.relations.find((r) => r.type === "1-n")!; + expect(userToPost).toMatchObject({ + from: "User", + to: "Post", + onDelete: "cascade", + onUpdate: "cascade", + }); + }); + + it("collapses an owning @ManyToMany pair into a single n-n relation", async () => { + const model = await extractFixture("v7-basic", "mikro-orm.config.ts"); + const nnRelations = model.relations.filter((r) => r.type === "n-n"); + expect(nnRelations).toHaveLength(1); + expect(nnRelations[0]).toMatchObject({ from: "Post", to: "Tag" }); + }); + + it("carries a composite PK and multi-column @Unique()", async () => { + const model = await extractFixture("v7-composite", "mikro-orm.config.ts"); + const postTag = model.entities.find((e) => e.name === "PostTag")!; + expect(postTag.primaryKey).toEqual(["postId", "tagId"]); + expect(postTag.uniques).toEqual([["tagId", "addedBy"]]); + expect(postTag.fields.find((f) => f.name === "slug")?.isUnique).toBe( + true, + ); + }); + }, +); diff --git a/test/fixtures/mikroorm/adapter/v7-basic/mikro-orm.config.ts b/test/fixtures/mikroorm/adapter/v7-basic/mikro-orm.config.ts new file mode 100644 index 0000000..af43590 --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-basic/mikro-orm.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@mikro-orm/sqlite"; +import { ReflectMetadataProvider } from "@mikro-orm/decorators/legacy"; + +export default defineConfig({ + entities: ["./dist/entities"], + entitiesTs: ["./src/entities"], + dbName: ":memory:", + // v7's default metadataProvider is no longer reflection-based now that + // decorators live in a separate package — a real v7 project relying on + // implicit (emitDecoratorMetadata-inferred) property types, like this + // fixture's `@PrimaryKey()`/`@Property()` with no explicit `type`, has to + // opt back into this itself. Unlike everything else in this config, this + // isn't something orm2erd's adapter needs to inject: it's just passed + // through like any other target-supplied MikroORM option. + metadataProvider: ReflectMetadataProvider, +}); diff --git a/test/fixtures/mikroorm/adapter/v7-basic/src/entities/Post.ts b/test/fixtures/mikroorm/adapter/v7-basic/src/entities/Post.ts new file mode 100644 index 0000000..131474b --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-basic/src/entities/Post.ts @@ -0,0 +1,27 @@ +import { Collection } from "@mikro-orm/core"; +import { + Entity, + Index, + ManyToMany, + ManyToOne, + PrimaryKey, + Property, +} from "@mikro-orm/decorators/legacy"; +import { User } from "./User"; +import { Tag } from "./Tag"; + +@Entity({ tableName: "posts" }) +@Index({ properties: ["title"] }) +export class Post { + @PrimaryKey() + id!: number; + + @Property({ fieldName: "post_title" }) + title!: string; + + @ManyToOne(() => User, { deleteRule: "cascade", updateRule: "cascade" }) + author!: User; + + @ManyToMany(() => Tag, (tag) => tag.posts, { owner: true }) + tags = new Collection(this); +} diff --git a/test/fixtures/mikroorm/adapter/v7-basic/src/entities/Tag.ts b/test/fixtures/mikroorm/adapter/v7-basic/src/entities/Tag.ts new file mode 100644 index 0000000..176be1f --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-basic/src/entities/Tag.ts @@ -0,0 +1,28 @@ +import { Collection } from "@mikro-orm/core"; +import { + Entity, + ManyToMany, + ManyToOne, + PrimaryKey, + Property, +} from "@mikro-orm/decorators/legacy"; +import { Post } from "./Post"; +import { User } from "./User"; + +@Entity({ tableName: "tags" }) +export class Tag { + @PrimaryKey() + id!: number; + + @Property() + name!: string; + + // Standalone @ManyToOne with no matching @OneToMany back-reference on + // User — must still emit its own 1-n relation from buildRelation's "m:1" + // case, not just when paired from the "one" side. + @ManyToOne(() => User) + createdBy!: User; + + @ManyToMany(() => Post, (post) => post.tags) + posts = new Collection(this); +} diff --git a/test/fixtures/mikroorm/adapter/v7-basic/src/entities/User.ts b/test/fixtures/mikroorm/adapter/v7-basic/src/entities/User.ts new file mode 100644 index 0000000..d9befc0 --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-basic/src/entities/User.ts @@ -0,0 +1,21 @@ +import { Collection } from "@mikro-orm/core"; +import { Entity, OneToMany, PrimaryKey, Property } from "@mikro-orm/decorators/legacy"; +import { Post } from "./Post"; + +@Entity({ tableName: "users", comment: "App users" }) +export class User { + @PrimaryKey() + id!: number; + + @Property({ unique: true }) + email!: string; + + @Property({ type: "text", nullable: true }) + bio?: string; + + @Property({ type: "boolean", default: true }) + isActive: boolean = true; + + @OneToMany(() => Post, (post) => post.author) + posts = new Collection(this); +} diff --git a/test/fixtures/mikroorm/adapter/v7-basic/tsconfig.json b/test/fixtures/mikroorm/adapter/v7-basic/tsconfig.json new file mode 100644 index 0000000..082efe2 --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-basic/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true + } +} diff --git a/test/fixtures/mikroorm/adapter/v7-composite/mikro-orm.config.ts b/test/fixtures/mikroorm/adapter/v7-composite/mikro-orm.config.ts new file mode 100644 index 0000000..28c1be7 --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-composite/mikro-orm.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "@mikro-orm/sqlite"; + +export default defineConfig({ + entitiesTs: ["./src/entities"], + entities: ["./dist/entities"], + dbName: ":memory:", +}); diff --git a/test/fixtures/mikroorm/adapter/v7-composite/src/entities/PostTag.ts b/test/fixtures/mikroorm/adapter/v7-composite/src/entities/PostTag.ts new file mode 100644 index 0000000..147a3fb --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-composite/src/entities/PostTag.ts @@ -0,0 +1,20 @@ +import { Entity, PrimaryKey, Property, Unique } from "@mikro-orm/decorators/legacy"; + +// Composite primary key (two @PrimaryKey) + a multi-column @Unique. The +// single-column `slug` unique stays on the field, not the group — mirrors +// the v6 "composite" fixture (test/fixtures/mikroorm/adapter/composite). +@Entity() +@Unique({ properties: ["tagId", "addedBy"] }) +export class PostTag { + @PrimaryKey({ type: "integer" }) + postId!: number; + + @PrimaryKey({ type: "integer" }) + tagId!: number; + + @Property({ type: "string" }) + addedBy!: string; + + @Property({ type: "string", unique: true }) + slug!: string; +} diff --git a/test/fixtures/mikroorm/adapter/v7-composite/tsconfig.json b/test/fixtures/mikroorm/adapter/v7-composite/tsconfig.json new file mode 100644 index 0000000..082efe2 --- /dev/null +++ b/test/fixtures/mikroorm/adapter/v7-composite/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true + } +}