MikroORM v6.5 is out. This is quite a big release, adding a new way to define entities, new relation loading strategy, improvements in filters, transaction management, and many more.
defineEntity
helper
MikroORM was always centered around the mapping to classes, allowing developers to treat them as domain objects, adding various methods to interact between them as well as validate their state. With the addition of EntitySchema
, we allowed defining your entities without classes too, and while this approach was type-safe, it required you to first write the entity interface, and only then define the EntitySchema
mapping based on it.
With the new defineEntity
helper, this process is now simpler, since you can infer the entity type based on the metadata:
import { type InferEntity, defineEntity } from '@mikro-orm/core';
// We use `p` as a shortcut for `defineEntity.properties`
const p = defineEntity.properties;
// It is recommended to use composition over inheritance when using `defineEntity`
export const baseProperties = {
id: p.integer().primary(),
createdAt: p.datetime().onCreate(() => new Date()),
updatedAt: p.datetime()
.onCreate(() => new Date())
.onUpdate(() => new Date()),
};
// Book is an instance of `EntitySchema`
export const Book = defineEntity({
name: 'Book',
properties: p => ({
...baseProperties,
title: p.string(),
author: () => p.manyToOne(Author).inversedBy('books'),
publisher: () => p.oneToOne(Publisher).inversedBy('book'),
tags: () => p.manyToMany(BookTag).inversedBy('books').fixedOrder(),
}),
});
// We can use `InferEntity` to infer the type of an entity
export interface IBook extends InferEntity<typeof Book> {}
Read more about this here or take a look at the examples in the Defining Entities section.
This feature was implemented by @xcfox, all kudos goes to him!
Balanced loading strategy
A new loading strategy was added, combining the benefits of the select-in
and joined
strategies. It will only join to-one relations, so it won't produce the cartesian product of rows in the raw results, which should make things more performant. For to-many relations, a separate query will be used as it would with the select-in
strategy.
Consider the following example:
const author = await em.findOne(Author, 1, {
populate: ['books.inspiredBy'],
strategy: 'balanced',
});
This would issue two queries, one to load the Author
entity, and another to load their books, joining the Book.inpiredBy
to-one relation. Comparing this to the other strategies, with select-in
you'd get three queries (loading the Book.inspiredBy
via the third query), while with joined
you'd get a single query for all, potentially duplicating a lot of data that would slow down the hydration.
This will become the new default in v7.
Changes in handling of filters on relations
When a to-one relation has a filter enabled on it, we used to do an INNER JOIN
for not-null columns, to discard the root entity from valid results if the relation filter discarded such a foreign key. This worked only for not-null to-one relations, and only via filters. This was a half-baked solution, which missed a lot of cases, especially with the select-in strategy. Sometimes it even resulted in joins that were not used at all.
In v6.5, both filters and explicit populateWhere
conditions will do this. If the relation is nullable, we use a LEFT JOIN
, with an additional WHERE
condition that discards the rows if the value is not null. If we consider the traditional example of Book
and Author
entities, if an Author
has a soft-delete filter on it, this query would be generated (with select-in strategy):
select `b0`.*, `a1`.`id` as `a1__id`
from `book` as `b0`
inner join `author` as `a1`
on `b0`.`author_id` = `a1`.`id`
-- filter condition
and `a1`.`deleted_at` is null
If the Book.author
property would be nullable, we'd generate this query instead:
select `b0`.*
from `book` as `b0`
left join `author` as `a1`
on `b0`.`author_id` = `a1`.`id`
-- filter condition
and `a1`.`deleted_at` is null
-- this mimics the inner join behaviour when the FK is present
where `b0`.`author_id` is null or `a1`.`id` is not null
Nested inner joins
When adding an inner join on a left joined relation, we nest them automatically, to discard the inner joined rows, but not the root entity ones. This worked before on a limited scale for filters, now it is enabled by default even for explicit QueryBuilder
usage. Consider following query:
em.createQueryBuilder(Author, 'a')
.leftJoinAndSelect('a.favouriteBook', 'fb') // nullable relation
.innerJoinAndSelect('fb.author', 'fba'); // not-null relation
This will now use a nested join, so if some Author
doesn't have a favouriteBook
, it won't be discarded from the results. Query similar to the following will be generated:
select `a`.*, `fb`.`id` as `fb__id`, `fb`.`author_id` as `fb__author_id`, `fba`.`id` as `fba__id`
from `author` as `a`
left join (`book` as `fb`
inner join `author` as `fba` on `fb`.`author_id` = `fba`.`id`
) on `a`.`favourite_book_id` = `fb`.`id`
You can opt out of this behaviour via qb.setFlag(QueryFlag.DISABLE_NESTED_INNER_JOIN)
.
Transaction propagation support
Both em.transactional
and the recently added @Transactional
decorator allow granular control over so-called transaction propagation. Propagation defines our business logic’s transaction boundary. MikroORM manages to start and pause a transaction according to our propagation setting.
There are 7 propagation settings you can now use:
REQUIRED
: Join existing transaction or create new oneREQUIRES_NEW
: Always create independent transactionNESTED
: Create savepoint if transaction exists, otherwise new transactionSUPPORTS
: Use transaction if available, otherwise execute non-transactionallyMANDATORY
: Require existing transaction, throw error if none existsNEVER
: Must execute without transaction, throw error if one existsNOT_SUPPORTED
: Suspend existing transaction and execute without transaction
The current behavior for nested em.transactional
calls matches the NESTED
setting.
The default propagation for the
@Transactional
decorator will change in v7 toREQUIRED
, while forem.transactional
, we'll keep the currentNESTED
default.
Read more about this feature here.
This feature was implemented by @junhyeongkim2, all kudos goes to him!
Allow triggering onCreate
hooks during em.create
Property onCreate
hooks are normally executed during flush operation. You can use the processOnCreateHooksEarly
ORM config option to trigger them early inside the em.create()
method. This option can also be overridden locally via em.create(Type, data, { processOnCreateHooks: true })
.
This flag affects only em.create()
, onCreate
property hooks for entities created explicitly via constructor will be processed during flush regardless of this option.
This will likely become the new default in v7.
Improvements in index expressions
Index expressions now provide table
, columns
and indexName
arguments in the callback. You can use the table
object to access current table name and schema, or simply stringify it to get a fully qualified table name with schema prefix. Similarly, the columns
object gives you a type-safe map of existing entity properties to their corresponding column names.
@Index({
name: 'country_index',
expression: (table, columns, indexName) => {
return `create index "${indexName}" on "${table.schema}"."${table.name}" ("${columns.country}")`;
},
})
country!: string;
For quoting, there is a new helper function called quote
. It's a template literal function, just like the existing raw
helper, but for quoting of identifiers (e.g. column name) as opposed to the raw
helper which quotes values.
@Index({
name: 'country_index',
expression: (table, columns, indexName) => {
return quote`create index ${indexName} on ${table} (${columns.country})`;
},
})
country!: string;
This feature was implemented by @nicomouss, all kudos goes to him!
What about v7?
The next major version has been in the works for more than 6 months now. Most of the big refactorings are now done, including:
- query building completely implemented on the ORM level to have absolute control
- knex being replaces with kysely for query execution
- native ESM rewrite
And here are a few things I want to do for v7:
- compatibility layer for the
knex
->kysely
replacement - improved support for raw queries via
kysely
(likely a codegen from the ORM metadata to kysely types) - Oracle Database driver
- native support for database views
- improved type-safety for
QueryBuilder
I'll continue the work on v7 now, the rough plan is to have something ready for the end of 2025, likely a release candidate, leaving the stable release for the beginning of 2026.
What do you think?
So those were some highlights from the new version. There are many other improvements as well as lots of bug fixes, so be sure to check the full changelog too, and let us know what you think about it in the comments!