Skip to main content

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​

DeclaredTypeDirection
relationship: { type: 'CREATED', direction }CREATEDas declared, OUT if omitted
a many-to-many with a declared pivot entitythe pivot entity's name, uppercasedfrom the pivot's own declaration
nothingthe property name, uppercasedOUT

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.

Auto-generated pivots are plumbing

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.

Streaming populates differently

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.