Relationships
A relation property is an edge. It has a type (ACTED_IN, WROTE) and a direction, both read
from the entity that declares the property.
@Entity()
export class Post {
@ManyToOne(() => User, { relationship: { type: 'CREATED', direction: 'IN' } })
author!: User; // (user)-[:CREATED]->(post)
}
How the edge is resolved
| Declared | Type | Direction |
|---|---|---|
relationship: { type: 'CREATED', direction } | CREATED | as declared, OUT if omitted |
| a many-to-many with a declared pivot entity | the pivot entity's name, uppercased | from the pivot's own declaration |
| nothing | the property name, uppercased | OUT |
The inverse side of a relation carries no declaration of its own: it takes the owning side's type and walks it the other way. So one edge is declared once, and both ends agree on it by construction.
When MikroORM synthesizes a pivot for a many-to-many you only declared one end of, its name is a
join-table name (tag_products) and says nothing about the edge — so it is not used as the
relationship type, and it never appears in the generated SDL.
Filtering through a relation
A filter may traverse relations at any depth. Each hop becomes a pattern subquery rather than a property comparison:
await em.find(Book, { tags: { name: 'Fiction' } });
await em.find(Tag, { books: { author: { name: 'Ann' } } }); // two hops
await em.find(Book, { $or: [{ tags: { name: 'Fiction' } }, { author: { name: 'Bob' } }] });
MATCH (this0:Book)
WHERE EXISTS { MATCH (this0)-[:TAGGED]->(t:Tag) WHERE t.name = $param0 }
RETURN this0 AS node
Passing a key instead of a condition ({ author: 'A1' }, or an operator such as
{ author: { $in: [...] } }) keeps comparing the denormalized foreign key stored on the node — no
traversal, because the answer is already there.
The collection operators ($some, $none, $every, $size) apply to relations too; see
query conditions.
populate: ['$infer']
Populating whatever the filter reached works here, and unlike the SQL drivers it is type-safe:
const tags = await em.find(Tag, { books: { author: { name: 'Ann' } } }, { populate: ['$infer'] });
tags[0].books[0].author.$.name; // typed as Loaded<Tag, 'books' | 'books.author'>
MikroORM implements $infer inside the SQL query builder, reading the hint off the joins the filter
produced. A graph read has no joins, so the driver walks the filter itself and populates select-in.
On the SQL drivers the hint reaches Loaded<> as the opaque literal '$infer', which matches no
key — IsPrefixed special-cases '*' but never '$infer' — so Loaded<Book, '$infer'> is
structurally Loaded<Book, never>, and the contravariant load-hint marker makes it unassignable even
to Loaded<Book, 'author'>. Here the hint resolves to the traversed paths, so the relations come
back typed as loaded.
How many-to-many is loaded
The driver stays on MikroORM's standard pivot path (usesPivotTable() is true), so pivotEntity,
relation properties, per-relation populate limits and reference-only loading behave exactly as they
do on the SQL drivers. What differs is what a "pivot table" is here: the edge itself, so the
join is a pattern match.
Populating a collection costs two queries — one that walks the edges, one that loads the targets
through the normal read path, which is what keeps filters, field selection, nested populate,
ordering and schema scoping identical to a plain find:
-- 1. walk the edges
MATCH (owner:Actor)-[:ACTED_IN]->(target:Movie) WHERE owner.id IN $ids
RETURN owner, target
-- 2. load the targets (regular read path)
MATCH (this0:Movie) WHERE this0.id IN $ids RETURN this0 AS node
A to-one relation needs no second query: it is an OPTIONAL MATCH in the same statement.
em.stream() cannot afford a second query, so it expands relations in the one statement and merges
the rows back into entities as they arrive. See streaming.
Edges with properties
When the relationship itself carries data — a role, a weight, a timestamp — model it as a relationship entity.