DEV Community

Martin Adámek
Martin Adámek

Posted on • Originally published at mikro-orm.io

MikroORM 6: Polished

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.

In case you don’t know…

If you never heard of MikroORM, it’s a TypeScript data-mapper ORM with Unit of Work and Identity Map. It supports MongoDB, MySQL, PostgreSQL, and SQLite drivers currently. Key features of the ORM are:

You can read the full introductory article here (but note that many things have changed since that was written) or browse through the docs.

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() and em.upsertMany()
  • custom pivot table entity
  • automatic relation discovery
  • fulltext search support
  • explicit serialization
  • rel() and ref() 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
  // highlight-next-line
  middleName: string & Opt = '';

  @Property()
  lastName!: string;

}
Enter fullscreen mode Exit fullscreen mode

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;

}
Enter fullscreen mode Exit fullscreen mode

Those properties won't be accessible on the DTO:

const book = await em.findOneOrFail(Book, 1);
const bookDTO = wrap(book).toObject();

bookDTO.hiddenField; // fails
Enter fullscreen mode Exit fullscreen mode

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: ['*'] });
Enter fullscreen mode Exit fullscreen mode

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'],
});
Enter fullscreen mode Exit fullscreen mode

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'];

}
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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: '...' }`
Enter fullscreen mode Exit fullscreen mode

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: '...' } }] }`
Enter fullscreen mode Exit fullscreen mode

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 {

  // highlight-next-line
  [Config]?: DefineConfig<{ forceObject: true }>;

  @PrimaryKey()
  id!: number;

}
Enter fullscreen mode Exit fullscreen mode

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' },
});
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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'
  },
});
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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' } },
});
Enter fullscreen mode Exit fullscreen mode

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: {} },
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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 })));
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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); 
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:',
  // highlight-next-line
  entityManager: MyEntityManager,
});
console.log(orm.em instanceof MyEntityManager); // true
const res = orm.em.myCustomMethod(123);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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') },
  },
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (0)