After more than a year in the development, I am thrilled to announce the next major version of MikroORM has just become stable. It brings many improvements throughout the whole system, and doubles down on type-safety and strictness.
Quick summary of 5.x releases
Before we dive into all the things v6, let’s mention some of the important additions from 5.x feature releases:
em.upsert()
andem.upsertMany()
- custom pivot table entity
- automatic relation discovery
- fulltext search support
- explicit serialization
rel()
andref()
helpers- new
Collection
helpers (map/filter/reduce/exists/find/indexBy/...
)
But enough of the history lesson, let’s talk about the future!
Type safety
One of the biggest improvements in v6 is by far the overhauled typing. While v5 brought a base for this with strict populate
hint and the Loaded
type, v6 doubles down on it. Many of the internal types have been refactored to improve both strictness and autocomplete capabilities. So what actually changed?
Strict partial loading
The most visible part is the partial loading, also known as the fields
option. Let's take a look at this example:
// article is typed to `Loaded<Article, never, 'title' | 'author.email'>`
const article = await em.findOneOrFail(Article, 1, {
fields: ['title', 'author.email'],
});
const id = article.id; // ok, PK is selected automatically
const title = article.title; // ok, title is selected
const publisher = article.publisher; // fail, not selected
const author = article.author.id; // ok, PK is selected automatically
const email = article.author.email; // ok, selected
const name = article.author.name; // fail, not selected
The Loaded
type now understands partial loading too, and this example will fail to compile because of accessing the author's name which is not loaded. Note that we also skipped the populate
hint from this example, as it is inferred from our partial loading hint.
What if you wanted to exclude just a few columns instead of white-listing what you want to load? We got you covered, v6 adds a new exclude
option which does exactly that - and it is strictly typed as well!
// article is typed to `Loaded<User, never, never, 'email'>`
const user = await em.findOneOrFail(User, 1, {
exclude: ['email'],
});
const id = user.id; // ok, PK is selected automatically
const name = user.name; // ok, selected
const email = user.email; // fail, excluded
Check out the live demo on StackBlitz.
Opt
type
While v5 introduced the strict typing for em.create()
, it was a bit cumbersome, as we now have to distinguish properties with a runtime default (so technically optional properties, but on type level they are seen as required). A new symbol called OptionalProps
was introduced to mark such defaults, so they are not required in the em.create
type. The symbol approach was mainly problematic when you wanted to define some properties like this in a custom base entity.
In v6, you can leverage the new Opt
type, which is used on property level (as opposed to the entity level OptionalProps
symbol). This effectively removes the problems with extensions and added generics. You can use the type in two ways:
- with generics:
middleName: Opt<string> = '';
- with intersections:
middleName: string & Opt = '';
Both will work the same, and can be combined with the OptionalProps
symbol approach.
import { Opt, Entity, PrimaryKey, Property } from '@mikro-orm/core';
@Entity()
class User {
@PrimaryKey()
id!: number;
@Property()
firstName!: string;
@Property()
middleName: string & Opt = '';
@Property()
lastName!: string;
}
Hidden
type
Similarly to the Opt
type used for marking optional properties, we have the Hidden
type (and HiddenProps
symbol) for marking properties that should be hidden when serializing.
@Entity()
class Book {
@Property({ hidden: true })
hiddenField: Hidden<Date> = Date.now();
@Property({ hidden: true, nullable: true })
otherHiddenField?: string & Hidden;
}
Those properties won't be accessible on the DTO:
const book = await em.findOneOrFail(Book, 1);
const bookDTO = wrap(book).toObject();
bookDTO.hiddenField; // fails
Populating all relations
Previously, you were allowed to populate all relations via populate: true
, but it wasn't type-safe - the resulting Loaded
type was not respecting this option. In v6, you can use populate: ['*']
which will work with the Loaded
type correctly.
const user = await em.findOneOrFail(User, 1, { populate: ['*'] });
The populate
hint now also accepts false
as a way to disable eager loading of relations (those marked with eager: true
).
Populate based on filter
When you filter by a nested relation value, the target table is automatically joined, but nothing is selected, the join is only used for the where condition. In v6, you can use populate: ['$infer']
to automatically populate such relations:
// this will populate all the books and their authors, all via a single query
const tags = await em.find(BookTag, {
books: { author: { name: '...' } },
}, {
populate: ['$infer'],
});
Primary key type inference
If you use composite keys or non-standard primary key names, you probably know about PrimaryKeyType
and PrimaryKeyProp
symbols. While they worked fine, there was no need to have two of them—and people were often confused how they work, as one required a union type of primary property names, while the other was a tuple type. This is now consolidated into a single PrimaryKeyProp
symbol, which accepts a tuple with property names
@Entity()
class Foo {
@ManyToOne(() => Bar, { primary: true })
bar!: Bar;
@ManyToOne(() => Baz, { primary: true })
baz!: Baz;
- [PrimaryKeyType]?: [number, number];
- [PrimaryKeyProp]?: 'bar' | 'baz';
+ [PrimaryKeyProp]?: ['bar', 'baz'];
}
Some methods and interfaces like Ref
allowed you to pass in the primary key property via second generic type argument, this is now also removed in favor of the automatic inference.
Simplified BaseEntity
The optional ORM BaseEntity
used to have two generic parameters, one for the entity type and the other for the primary key type. They are both removed in v6. The former has been replaced with this
type, the latter with the PrimaryKeyProp
symbol.
-class User extends BaseEntity<User> { ... }
+class User extends BaseEntity { ... }
Implicit serialization
Next, let's talk about the changes in serialization. There are two ways to serialize your entities—implicit via wrap(entity).toObject()
, which is called automatically when you do JSON.stringify(entity)
, and explicit via serialize()
helper.
Implicit serialization now works entirely based on populate
and fields
hints. This means that, unless you explicitly marked some entity as populated via wrap(entity).populated()
, it will be part of the serialized form only if it was part of the populate
hint:
// let's say both Author and Book entity has a M:1 relation to Publisher entity
// we only populate the publisher relation of the Book entity
const user = await em.findOneOrFail(Author, 1, {
populate: ['books.publisher'],
});
const dto = wrap(user).toObject();
console.log(dto.publisher); // only the FK, e.g. `123`
console.log(dto.books[0].publisher); // populated, e.g. `{ id: 123, name: '...' }`
Moreover, the implicit serialization now respects the partial loading hints too. Previously, all loaded properties were serialized, and partial loading worked only on the database query level. Since v6, we also prune the data on runtime. This means that unless the property is part of the partial loading hint (fields
option), it won't be part of the DTO - only exception is the primary key, you can optionally hide it via hidden: true
in the property options. The main difference here will be the foreign keys, those are often automatically selected as they are needed to build the entity graph, but will no longer be part of the DTO.
const user = await em.findOneOrFail(Author, 1, {
fields: ['books.publisher.name'],
});
const dto = wrap(user).toObject();
// only the publisher's name will be available, previously there would be also `book.author`
// `{ id: 1, books: [{ id: 2, publisher: { id: 3, name: '...' } }] }`
This also works for embeddables, including nesting and object mode. And speaking of embeddables—they now also support the fieldName
option, again, including the nesting and object mode, effectively allowing partial loading on the object embeddables (so JSON properties) too.
forceObject
When you serialize an entity with unpopulated relation, it will result in a foreign key value, e.g. book.author
will be number if you don't populate the author
relation. In v6, you can use the forceObject
serialization option to get an object there instead, e.g. book.author
will be { id: 1 }
instead of just 1
.
To have the DTO properly typed, you can use the Config
symbol, preferably in your own base entity, as this flag will affect all your entities globally:
import { Config, DefineConfig, PrimaryKey } from '@mikro-orm/core';
class BaseEntity {
[Config]?: DefineConfig<{ forceObject: true }>;
@PrimaryKey()
id!: number;
}
The DefineConfig
type will offer intellisense to the type config options. Right now, it only accepts a single property, but there might be more options like this going forward.
Joined strategy
The joined loading strategy was around for a while, but it had several implementation problems resulting in different behavior when compared to the default select-in strategy. But that actually changes now, the joined strategy is back on track and should be completely aligned with the select-in behavior.
So what actually changed? The most important part is the support for populateWhere: 'all'
, which is the default behavior, and means "populate the full relations regardless of the where condition". This was previously not working with the joined strategy, as it was reusing the same join clauses as the where clause. In v6, the joined strategy will use a separate join branch for the populated relations.
Since the strategies now behave the same, this finally unlocked the switch of the defaults for all the SQL drivers—the joined strategy is the new default. The joined strategy should usually be faster unless you join a lot of to-many relations (which would result in huge cartesian products).
Filters on relations
Filters are now also applied to the relations, as part of JOIN ON
condition. If a filter exists on a M:1 or 1:1 relation target, such an entity will be automatically joined, and when the foreign key is defined as NOT NULL
, it will result in an INNER JOIN
rather than LEFT JOIN
. This is especially important for implementing soft deletes via filters, as the foreign key might point to a soft-deleted entity. When this happens, the automatic INNER JOIN
will result in such a record not being returned at all.
Cursor-based pagination
As an alternative to the offset-based pagination with limit
and offset
, you can now paginate based on a cursor. A cursor is an opaque string that defines a specific place in ordered entity graph. You can use em.findByCursor()
to access those options. Under the hood, it will call em.find()
and em.count()
just like the em.findAndCount()
method, but will use the cursor options instead.
const currentCursor = await em.findByCursor(User, {}, {
first: 10,
after: previousCursor, // cursor instance
orderBy: { id: 'desc' },
});
// to fetch next page
const nextCursor = await em.findByCursor(User, {}, {
first: 10,
after: currentCursor.endCursor, // opaque string
orderBy: { id: 'desc' },
});
// to fetch next page
const nextCursor2 = await em.findByCursor(User, {}, {
first: 10,
after: { id: lastSeenId }, // entity-like POJO
orderBy: { id: 'desc' },
});
The Cursor
object provides the following interface:
Cursor<User> {
items: [
User { ... },
User { ... },
User { ... },
...
],
totalCount: 50,
length: 10,
startCursor: 'WzRd',
endCursor: 'WzZd',
hasPrevPage: true,
hasNextPage: true,
}
Raw SQL fragments
The raw SQL fragments used to be detected automatically, which wasn't very precise. In v6, a new raw
static helper is introduced to deal with this:
const users = await em.find(User, {
[raw('lower(email)')]: 'foo@bar.baz',
});
This helper now replaces the removed expr()
function, which was only an escape hatch for strictly typed FilterQuery
, but wasn't required on runtime. It offers similar API, e.g. you can pass in a callback and get the current alias (based on the scope of execution) for given column:
const users = await em.find(User, {
books: {
[raw(alias => `lower(${alias}.title)`)]: 'some title'
},
});
Unlike in v5, this is now required way to mark your raw SQL fragments. Without it, you'd end up with the fragment being quoted as a regular string value.
The raw query can be also parametric, you can use ?
for values and ??
for keys:
const users = await em.find(User, {
// this will result in properly quoted sql, e.g. `lower("email")`
[raw('lower(??)', ['email'])]: 'foo@bar.baz',
});
And while the raw
helper is the most universal one you can use, there is also a new sql
tagged template function, which resolves to it too, if you prefer that kind of interface:
const users = await em.find(User, { [sql`lower(email)`]: 'foo@bar.baz' });
The fragments can be also used in your entity definition, to set raw database defaults. This is basically a shortcut for prop.defaultRaw
option:
@Property({ default: sql`now()` })
createdAt = new Date();
And there is more to this, the sql
function also offers several helper functions you can use, namely:
sql.ref()
sql.now()
sql.lower()
sql.upper()
Read more about this in Using raw SQL query fragments section.
Subquery operators $some
, $none
and $every
In addition to the regular operators that translate to a real SQL operator expression (e.g. >=
), you can also use the following collection operators:
operator | description |
---|---|
$some | Finds collections that have some record matching the condition. |
$none | Finds collections that have no records matching the condition. |
$every | Finds collections where every record is matching the condition. |
This will be resolved as a subquery condition:
// finds all authors that have some book called `Foo`
const res1 = await em.find(Author, {
books: { $some: { title: 'Foo' } },
});
// finds all authors that have no books called `Foo`
const res2 = await em.find(Author, {
books: { $none: { title: 'Foo' } },
});
// finds all authors that have every book called `Foo`
const res3 = await em.find(Author, {
books: { $every: { title: 'Foo' } },
});
The condition object can be also empty:
// finds all authors that have at least one book
const res1 = await em.find(Author, {
books: { $some: {} },
});
// finds all authors that have no books
const res2 = await em.find(Author, {
books: { $none: {} },
});
Subquery joining
Subqueries are now better supported all over the place. Namely, you can join a subquery, as well as use a subquery in qb.from()
method. One use case where this is handy is when you want to limit a joined relation, e.g. you have a 1:M collection, and you are interested only in the first item. In the following example, we join on the Author.books
collection, overriding the implicit join branch with a custom subquery that has a limit 1
on it.
// subquery can be a knex query builder as well
const subquery = await em.createQueryBuilder(Book, 'b')
.where({ ... })
.orderBy({ title: 'asc' }).limit(1);
const authors = await em.createQueryBuilder(Author, 'a')
.select('*')
// pass in both the property path and the subquery into the first argument as a tuple
.leftJoinAndSelect(['a.books', subquery], 'b')
// you can join more relations on top of the subquery join
.leftJoinAndSelect('b.tags', 't')
.getResultList();
Dataloader support for references and collections
MikroORM now provide out-of-box support for loading Reference
and Collection
properties via dataloader. This feature needs to be enabled either globally (via ORM config) or locally (via FindOptions
):
await MikroORM.init({
dataloader: true,
});
Then you can use Promise.all
on such objects, and it will automatically resolve to a batched select query:
const authors = await orm.em.find(Author, [1, 2, 3]);
await Promise.all(authors.map(author => author.books.load()));
// or when the dataloader support is not enabled globally:
await Promise.all(authors.map(author => author.books.load({ dataloader: true })));
This is especially useful with GraphQL since it automatically solves its notorious N+1 problem, without you even noticing it: you won't even need Promise.all
since all the requests will occur within a single tick of the event loop and will be coalesced by the dataloader library.
More about this in the new dataloader section. You can also check out this example repository which leverages the dataloader (as well as Accounts.js library).
Shout out to Niccolò Belli, who contributed this feature and is working on a more advanced version which supports dataloader also for em.find()
.
Logging improvements
Logging support has been greatly improved. You can now set up a custom logger context:
const res = await em.findAll(Author, { loggerContext: { meaningOfLife: 42 } });
// ...
class CustomLogger extends DefaultLogger {
log(namespace: LoggerNamespace, message: string, context?: LogContext) {
console.log(context?.meaningOfLife);
// 42
}
}
This context can be specific to the EntityManager
fork, and will get the EntityManager
ID automatically, so you can now track which request context/fork fired what queries.
const fork = em.fork({ loggerContext: { meaningOfLife: 42 } });
console.log(fork.id); // 3
// the logger context here will be { id: 3, meaningOfLife: 42 }
const res = await fork.findAll(Author);
The logger also supports query labels (simple way to alter what gets printed), index hints and query comments, and more.
const author = await em.findOne(Author, { id: 1 }, { logging: { label: 'Author Retrieval - /authors/me' } });
// [query] (Author Retrieval - /authors/me) select "a0".* from "author" as "a0" where "a0"."id" = 1 limit 1 [took 2 ms]
The label can be also set via
loggerContext
.
Logging can be now selectively enabled/disabled via FindOptions
. this works in both ways, if you globally disable logging, you can selectively enable it via FindOptions
, as well as the other way around.
// MikroORM.init({ debug: true });
const author = await em.findOne(Author, { id: 1 }, { logging: { enabled: false } });
// Overrides config and displays no logger output
// ...
// MikroORM.init({ debug: false });
const author = await em.findOne(Author, { id: 1 }, { logging: { enabled: true } });
// Overrides config and displays logger output
// ...
// MikroORM.init({ debug: ['query-labels'] });
const author = await em.findOne(Author, { id: 1 }, { logging: { debugMode: ['query'] } });
// Overrides config and displays logger output for query
Read more about the logger improvements in the logging section.
Improved change-tracking of M:N relations
M:N relations were always a bit problematic, the way they were implemented was only checking the owning side for changes. Thanks to the propagation of changes, it allowed working with the inverse side too, as long as the items you added/removed from the collection were loaded.
const tag = await em.findOne(BookTag, 1);
// tag.books in an inverse side, this used to fail, but now it works!
tag.books.add(em.getReference(Book, 123));
await em.flush();
This restriction is no longer valid, and changes made to inverse sides of M:N collections are also tracked. Moreover, all queries that are altering pivot tables are now properly batched.
Extending EntityManager
It is now possible to extend the EntityManager
with your own custom methods. The type is inferred automatically from the config if possible.
import { MikroORM, EntityManager } from '@mikro-orm/sqlite';
class MyEntityManager extends EntityManager {
myCustomMethod(base: number): number {
return base * Math.random();
}
}
const orm = await MikroORM.init({
entities: [...],
dbName: ':memory:',
entityManager: MyEntityManager,
});
console.log(orm.em instanceof MyEntityManager); // true
const res = orm.em.myCustomMethod(123);
GeneratedCacheAdapter
for production usage
One of the ways you can define your entity metadata is leveraging the TypeScript compiler API via ts-morph
, which allows extracting the type information that would be otherwise lost on compilation (and is not available via reflect-metadata
). While this approach works nice locally, it had several hard problems around it, the most obvious one is the dependency on TypeScript, which you don't want to have in your production builds.
In v6, MikroORM lets you generate a production cache bundle into a single JSON file via CLI:
npx mikro-orm cache:generate --combined
This will create ./temp/metadata.json
file which can be used together with GeneratedCacheAdapter
in your production configuration:
import { GeneratedCacheAdapter, MikroORM } from '@mikro-orm/core';
await MikroORM.init({
metadataCache: {
enabled: true,
adapter: GeneratedCacheAdapter,
options: { data: require('./temp/metadata.json') },
},
// ...
});
This way you can keep the @mikro-orm/reflection
package as a development dependency only, use the CLI to create the cache bundle, and depend only on that in your production build.
The cache bundle can be statically imported, which is handy in case you are using some bundler.
Entity Generator improvements
EntityGenerator now automatically detects more M:N relations—including those with an autoincrement primary key (so fixed order), or even unrelated additional columns. Over time, we might get closer to a proper schema-first approach.
Shout out to Vasil Rangelov, who contributed this feature and is working on more improvements in the EntityGenerator, e.g. ability to override the generated entities' metadata.
Inference of default values
When defining properties with a runtime default value, the reflect-metadata
provider fails to infer the type property. This is no longer a problem in v6, as the discovery mechanism now automatically tries to infer the type from the runtime defaults.
@Property()
-created: Data = new Date();
+created = new Date();
Note that this works only if your entity can be constructed without any constructor parameters. It is fine to have them, but the constructor cannot fail if they are not provided for this auto-detection to work.
Other notable changes
- Virtual entities now allow M:1 and 1:1 relations.
- New
MikroORM.initSync()
method allows initializing the ORM synchronously. - Propagation and change-tracking works with
useDefineForClassFields
enabled. - Removed static
require
calls that were problematic when bundling. - Removed
persist/remove/flush
from repository interface. - The
type
option is removed in favor of driver exports anddefineConfig
. - ORM extensions—a way to tell the ORM about optional dependencies like Seeder or Migrator without the need to declare peer dependencies.
- All drivers now re-export the
@mikro-orm/core
package, so you no longer have to think about which package to import from - just use the driver package. - Native BigInt support, allowing to set the mapping to either
bigint
,number
orstring
. - Embedded properties respect
NamingStrategy
, including object embeddables. - Entities are added to the identity map on
em.persist()
if they have a primary key. - Native support for generated columns.
- Support for lateral sub-query joins.
- Support for native enums in postgres.
- Support
Ref
wrapper on scalar properties. - Discovery hooks
onMetadata
andafterDiscovered
allowing to modify the metadata in any way you want.
And many many more, see the full changelog here. Also be sure to check the upgrading guide.
One more thing…
Over time, while some people liked the current documentation, there were also people disliking it. It wasn't really beginner-friendly, as it only described the distinct features, but was lacking some tutorials describing how to set things up as a whole.
A lot of the documentation for v6 has been updated and polished, and a completely new Getting Started Guide was added, accompanied by an example repository. It describes how to build and test an API from scratch with MikroORM, Fastify, ESM, Vitest, JWT, and some other tools. Unlike the rest of the docs, you can read it from top to bottom as a tutorial. I will continue extending the guide over time, especially the final section about type safety.
Like MikroORM? ⭐️ Star it on GitHub and share this article with your friends. If you want to support the project financially, you can do so via GitHub Sponsors.