Relationships
Mirror loads relationships explicitly and efficiently. There is no lazy loading via Proxy, no queries hidden in getters. Each decorator defines the loading strategy — and you always know how many queries are being fired.
The Fundamental Rule: The Explicit FK
Section titled “The Fundamental Rule: The Explicit FK”This is the number 1 gotcha for anyone coming from TypeORM or Prisma.
In Mirror, the foreign key column must exist as a separate @Column in the entity. The relation decorator only describes how to navigate the relation — it does not create the column implicitly.
// ❌ Errado — author_id não existe como coluna, o Mirror não consegue montar o INSERT@Entity('books')class Book { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@ManyToOne(() => Author, 'author_id') author!: Author;}
// ✅ Correto — a FK é uma coluna real, o decorator de relação apenas navega por ela@Entity('books')class Book { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() authorId!: number;
@ManyToOne(() => Author, 'author_id') author!: Author | null;}The foreignKey passed to the decorator is always the column name in the database (snake_case), while the property in the class can have any name.
Relationships are one-sided
Section titled “Relationships are one-sided”In Mirror, you only need to declare the side you are going to consult. Mirror never reads the related entity’s metadata to find the opposite side — each decorator is completely self-contained.
This is in contrast to TypeORM, where @OneToMany requires @ManyToOne + mappedBy on the other side. Here:
// Você pode ter apenas o @ManyToOne no Book, sem nenhum @OneToMany no Author@Entity('books')class Book { @Column() authorId!: number;
@ManyToOne(() => Author, 'author_id') author!: Author | null;}
@Entity('authors')class Author { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() name!: string; // Sem @OneToMany — e tudo funciona}Declare both sides only when you need to navigate the relationship both ways.
@ManyToOne
Section titled “@ManyToOne”The side that owns FK. Loaded with a LEFT JOIN in the main query — no extra query.
@ManyToOne(target, foreignKey, options?)| Parameter | Type | Description |
|---|---|---|
target | () => typeof Entity | Lambda returning target entity — avoid circular references |
foreignKey | string | FK column name in the database (in this table) |
options.cascade | boolean | CascadeType[] | Cascading operations: 'insert', 'update', 'remove' |
import { Entity, PrimaryColumn, Column, ManyToOne } from 'mirror-orm';
@Entity('books')class Book { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() title!: string;
@Column() authorId!: number;
@ManyToOne(() => Author, 'author_id') author!: Author | null;}Generated SQL
Section titled “Generated SQL”Mirror adds a LEFT JOIN and selects the relationship columns with the mirror__<propriedade>__ prefix:
SELECT "books"."id", "books"."title", "books"."author_id", "authors"."id" AS "mirror__author__id", "authors"."name" AS "mirror__author__name"FROM "books"LEFT JOIN "authors" ON "books"."author_id" = "authors"."id"The mirror__author__ prefix prevents name collisions between tables and is used by JIT Hydrator to instantiate the Author object. If you see this pattern in query logs, it is a @ManyToOne relation being loaded.
Loading
Section titled “Loading”Relations are not loaded by default:
const repo = conn.getRepository(Book);
// Sem relaçãoconst book = await repo.findOne({ where: { id: 1 } });// book.author === undefined
// Com relaçãoconst book = await repo.findOne({ where: { id: 1 }, relations: ['author'],});// book.author instanceof Author (ou null se authorId for null)@OneToMany
Section titled “@OneToMany”The side that does not have FK. Loaded via separate batch query, eliminating the N+1 problem.
@OneToMany(target, foreignKey, options?)| Parameter | Type | Description |
|---|---|---|
target | () => typeof Entity | Lambda returning entity “many” |
foreignKey | string | Name of the FK column in the database, in the table on the “many” side |
options.cascade | boolean | CascadeType[] | Cascade operations |
@Entity('authors')class Author { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() name!: string;
@OneToMany(() => Book, 'author_id') books!: Book[];}Generated SQL
Section titled “Generated SQL”There are two queries. The main one does not change, and Mirror triggers a batch query with all the collected IDs:
-- Query 1: busca os authorsSELECT "authors"."id", "authors"."name" FROM "authors"
-- Query 2: busca todos os books de uma vezSELECT * FROM "books"WHERE "books"."author_id" = ANY($1)-- $1 = [1, 2, 3, ...]Mirror groups the results by author_id and distributes them to each instance of Author. Result is always T[] — [] if there are no records.
@OneToOne
Section titled “@OneToOne”Mirror automatically detects which side has the FK, without the need for mappedBy. Both sides return T | null, never an array.
@OneToOne(target, foreignKey, options?)| Parameter | Type | Description |
|---|---|---|
target | () => typeof Entity | Lambda returning target entity |
foreignKey | string | Name of the FK column in the database |
options.cascade | boolean | CascadeType[] | Cascade operations |
Automatic side detection
Section titled “Automatic side detection”Mirror checks whether the foreignKey column exists in the current entity’s columns:
- Owner side (has the FK) → loads with
LEFT JOIN, like@ManyToOne - Inverse side (does not have the FK) → loads with batch query, such as
@OneToMany
@Entity('person_profiles')class PersonProfile { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() personId!: number; // FK está aqui → lado owner
@OneToOne(() => Person, 'person_id') person!: Person | null; // LEFT JOIN}
@Entity('persons')class Person { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() name!: string;
@OneToOne(() => PersonProfile, 'person_id') profile!: PersonProfile | null; // batch query (não tem a FK)}Since relationships are one-way, you can declare just the owner side if you only need to navigate in that direction:
// Somente o lado que tem a FK — perfeitamente válido@Entity('person_profiles')class PersonProfile { @Column() personId!: number;
@OneToOne(() => Person, 'person_id') person!: Person | null;}@ManyToMany
Section titled “@ManyToMany”Uses a pivot table. The signature is positional — the order of the parameters matters.
@ManyToMany(target, joinTable, ownerFk, inverseFk)| Parameter | Type | Description |
|---|---|---|
target | () => typeof Entity | Lambda returning target entity |
joinTable | string | Name of the pivot table in the bank |
ownerFk | string | Column on pivot that points to this entity |
inverseFk | string | Column on pivot that points to the target entity |
@Entity('articles')class Article { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() title!: string;
// alvo pivô ownerFk inverseFk @ManyToMany(() => Tag, 'article_tags', 'article_id', 'tag_id') tags!: Tag[];}
@Entity('tags')class Tag { @PrimaryColumn({ strategy: 'identity' }) id!: number;
@Column() name!: string;
// alvo pivô ownerFk inverseFk @ManyToMany(() => Article, 'article_tags', 'tag_id', 'article_id') articles!: Article[];}```> On the reverse side (`Tag`), the FKs are **inverted**: `ownerFk = 'tag_id'`, `inverseFk = 'article_id'`.
Just like the other decorators, `@ManyToMany` is also one-way. Declare in `Tag` only if you are going to query articles from a tag.
### Expected pivot table
```sqlCREATE TABLE article_tags ( article_id INTEGER NOT NULL REFERENCES articles(id), tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY (article_id, tag_id));Generated SQL
Section titled “Generated SQL”There are two queries. The batch query does INNER JOIN with the pivot and uses the internal alias _mirror_mtm_fk_ to track which owner each result belongs to:
-- Query 1: busca os articlesSELECT "articles"."id", "articles"."title" FROM "articles"
-- Query 2: busca todas as tags dos articles retornadosSELECT "tags".*, "article_tags"."article_id" AS "_mirror_mtm_fk_"FROM "tags"INNER JOIN "article_tags" ON "article_tags"."tag_id" = "tags"."id"WHERE "article_tags"."article_id" = ANY($1)The _mirror_mtm_fk_ alias is removed before hydrating Tag objects. If it appears in query logs, it is the @ManyToMany grouping mechanism working correctly.
Cascades
Section titled “Cascades”All decorators accept cascade as an option:
@ManyToOne(() => Author, 'author_id', { cascade: ['insert', 'update'] })author!: Author | null;
@OneToMany(() => Book, 'author_id', { cascade: true })books!: Book[];| Value | Behavior |
|---|---|
'insert' | Saves new related entities along with the parent |
'update' | Propagates save() to existing related entities |
'remove' | Removes related entities by removing parent |
Summary of Strategies
Section titled “Summary of Strategies”| Decorator | Strategy | Queries | Result |
|---|---|---|---|
@ManyToOne | LEFT JOIN | 1 | T | null |
@OneToMany | Batch ANY($1) | 2 | T[] |
@OneToOne (owner) | LEFT JOIN | 1 | T | null |
@OneToOne (inverse) | Batch ANY($1) | 2 | T | null |
@ManyToMany | INNER JOIN + Batch | 2 | T[] |
See also: To enable relation loading in queries, see the
relationsoption in Repository API. To use cascades within transactions, see Transactions.