Skip to content

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.


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.


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.


The side that owns FK. Loaded with a LEFT JOIN in the main query — no extra query.

@ManyToOne(target, foreignKey, options?)
ParameterTypeDescription
target() => typeof EntityLambda returning target entity — avoid circular references
foreignKeystringFK column name in the database (in this table)
options.cascadeboolean | 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;
}

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.

Relations are not loaded by default:

const repo = conn.getRepository(Book);
// Sem relação
const book = await repo.findOne({ where: { id: 1 } });
// book.author === undefined
// Com relação
const book = await repo.findOne({
where: { id: 1 },
relations: ['author'],
});
// book.author instanceof Author (ou null se authorId for null)

The side that does not have FK. Loaded via separate batch query, eliminating the N+1 problem.

@OneToMany(target, foreignKey, options?)
ParameterTypeDescription
target() => typeof EntityLambda returning entity “many”
foreignKeystringName of the FK column in the database, in the table on the “many” side
options.cascadeboolean | CascadeType[]Cascade operations
@Entity('authors')
class Author {
@PrimaryColumn({ strategy: 'identity' })
id!: number;
@Column()
name!: string;
@OneToMany(() => Book, 'author_id')
books!: Book[];
}

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 authors
SELECT "authors"."id", "authors"."name" FROM "authors"
-- Query 2: busca todos os books de uma vez
SELECT * 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.


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?)
ParameterTypeDescription
target() => typeof EntityLambda returning target entity
foreignKeystringName of the FK column in the database
options.cascadeboolean | CascadeType[]Cascade operations

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;
}

Uses a pivot table. The signature is positional — the order of the parameters matters.

@ManyToMany(target, joinTable, ownerFk, inverseFk)
ParameterTypeDescription
target() => typeof EntityLambda returning target entity
joinTablestringName of the pivot table in the bank
ownerFkstringColumn on pivot that points to this entity
inverseFkstringColumn 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
```sql
CREATE 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)
);

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 articles
SELECT "articles"."id", "articles"."title" FROM "articles"
-- Query 2: busca todas as tags dos articles retornados
SELECT
"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.


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[];
ValueBehavior
'insert'Saves new related entities along with the parent
'update'Propagates save() to existing related entities
'remove'Removes related entities by removing parent

DecoratorStrategyQueriesResult
@ManyToOneLEFT JOIN1T | null
@OneToManyBatch ANY($1)2T[]
@OneToOne (owner)LEFT JOIN1T | null
@OneToOne (inverse)Batch ANY($1)2T | null
@ManyToManyINNER JOIN + Batch2T[]

See also: To enable relation loading in queries, see the relations option in Repository API. To use cascades within transactions, see Transactions.