Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 18 additions & 9 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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<EntityMetadata>` 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<EntityMetadata>` in v6, but a real `Map<string, EntityMetadata>` 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`/
Expand All @@ -390,11 +397,13 @@ returns a plain `Dictionary<EntityMetadata>` 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,
Expand Down
12 changes: 11 additions & 1 deletion src/adapters/mikroorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,16 @@ export const mikroormAdapter: ORMAdapter = {
);
}

return buildModel(Object.values(orm.getMetadata().getAll()));
// v6's `getAll()` returns a plain `Dictionary<EntityMetadata>`; v7's
// returns a real `Map<string, EntityMetadata>` — 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<string, MikroOrmEntityMetadata>);
return buildModel(allMetadata);
},
};
14 changes: 9 additions & 5 deletions src/adapters/mikroorm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,16 @@ export interface MikroOrmEntityMetadata {
pivotTable?: boolean;
}

// `getAll()`'s real v6 runtime return type is a plain `Dictionary<EntityMetadata>`
// 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<EntityMetadata>` keyed by entity name in v6, but a real
// `Map<string, EntityMetadata>` 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<string, MikroOrmEntityMetadata>;
getAll():
| Record<string, MikroOrmEntityMetadata> // v6
| Map<string, MikroOrmEntityMetadata>; // v7
}

export interface MikroOrmInstance {
Expand Down
91 changes: 91 additions & 0 deletions test/adapters/mikroorm-v7.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
},
);
16 changes: 16 additions & 0 deletions test/fixtures/mikroorm/adapter/v7-basic/mikro-orm.config.ts
Original file line number Diff line number Diff line change
@@ -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,
});
27 changes: 27 additions & 0 deletions test/fixtures/mikroorm/adapter/v7-basic/src/entities/Post.ts
Original file line number Diff line number Diff line change
@@ -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<Tag>(this);
}
28 changes: 28 additions & 0 deletions test/fixtures/mikroorm/adapter/v7-basic/src/entities/Tag.ts
Original file line number Diff line number Diff line change
@@ -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<Post>(this);
}
21 changes: 21 additions & 0 deletions test/fixtures/mikroorm/adapter/v7-basic/src/entities/User.ts
Original file line number Diff line number Diff line change
@@ -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<Post>(this);
}
12 changes: 12 additions & 0 deletions test/fixtures/mikroorm/adapter/v7-basic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": false,
"skipLibCheck": true,
"esModuleInterop": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "@mikro-orm/sqlite";

export default defineConfig({
entitiesTs: ["./src/entities"],
entities: ["./dist/entities"],
dbName: ":memory:",
});
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions test/fixtures/mikroorm/adapter/v7-composite/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": false,
"skipLibCheck": true,
"esModuleInterop": true
}
}