TypeORMを使用したNestJSアプリケーションにおいて、エンティティ間のリレーションシップ設計は避けて通れない重要なトピックです。本記事では、@OneToManyや@ManyToOneなどのリレーションデコレータの適切な使い方から、開発者を悩ませるN+1問題の原因と解決策まで、実践的なコード例とともに解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Node.js |
20以上 |
| npm |
10以上 |
| NestJS |
11.x |
| TypeORM |
0.3.x |
| MySQL |
8.0以上 |
| OS |
Windows / macOS / Linux |
| エディタ |
VS Code(推奨) |
事前に以下の準備を完了してください。
- NestJS CLIのインストール済み(
npm install -g @nestjs/cli)
- NestJSプロジェクトの作成済み
- TypeORMとMySQLの接続設定完了
TypeORMの導入と接続設定についてはNestJSとTypeORMによるMySQL接続を、Repositoryの基本操作についてはNestJSのRepository実装を参照してください。
TypeORMリレーションシップデコレータの種類#
TypeORMでは、エンティティ間の関連を表現するために4種類のリレーションシップデコレータが提供されています。
| デコレータ |
関連の種類 |
例 |
@OneToOne |
1対1 |
ユーザーとプロフィール |
@OneToMany |
1対多 |
著者と書籍 |
@ManyToOne |
多対1 |
書籍と著者 |
@ManyToMany |
多対多 |
記事とタグ |
本記事では、実務で最も頻繁に使用される@OneToManyと@ManyToOneの双方向関連を中心に解説します。
サンプルドメインモデルの設計#
本記事では、書籍管理システムを題材にします。著者(Author)と書籍(Book)のエンティティを定義します。
erDiagram
AUTHOR ||--o{ BOOK : "writes"
AUTHOR {
number id PK
string name
string email
}
BOOK {
number id PK
string title
string isbn
number authorId FK
}著者は複数の書籍を執筆でき、書籍は必ず1人の著者に紐づくという1対多の関係です。
@ManyToOneデコレータで多対1関連を定義する#
@ManyToOneは、多側のエンティティから1側のエンティティへの参照を定義します。データベースでは外部キーを持つ側がリレーションのオーナーとなります。
Bookエンティティの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// src/books/entities/book.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { Author } from '../../authors/entities/author.entity';
@Entity('books')
export class Book {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 200 })
title: string;
@Column({ length: 13, unique: true })
isbn: string;
@Column({ type: 'text', nullable: true })
description: string;
@ManyToOne(() => Author, (author) => author.books, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'author_id' })
author: Author;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
|
@ManyToOneの主要なオプション#
| オプション |
説明 |
デフォルト値 |
eager |
親エンティティ取得時に自動的にロード |
false |
nullable |
関連が必須かどうか |
true |
onDelete |
親削除時の動作(CASCADE、SET NULL等) |
NO ACTION |
onUpdate |
親更新時の動作 |
NO ACTION |
@JoinColumnの役割#
@JoinColumnは外部キーカラムの詳細を指定します。省略した場合、TypeORMはpropertyName + Idという命名規則で外部キーカラムを生成します。
1
2
3
4
5
6
7
8
9
10
|
// @JoinColumnを省略した場合
@ManyToOne(() => Author, (author) => author.books)
author: Author;
// → 外部キーは "authorId" になる
// @JoinColumnで明示的に指定
@ManyToOne(() => Author, (author) => author.books)
@JoinColumn({ name: 'author_id' })
author: Author;
// → 外部キーは "author_id" になる
|
データベースの命名規則(snake_case)とTypeScriptの命名規則(camelCase)を一致させるために、@JoinColumnで明示的に指定することを推奨します。
@OneToManyデコレータで1対多関連を定義する#
@OneToManyは、1側のエンティティから多側のエンティティへの参照を定義します。@OneToManyは常に@ManyToOneと対になり、@ManyToOne側がリレーションのオーナーとなります。
Authorエンティティの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// src/authors/entities/author.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { Book } from '../../books/entities/book.entity';
@Entity('authors')
export class Author {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ unique: true })
email: string;
@Column({ type: 'text', nullable: true })
bio: string;
@OneToMany(() => Book, (book) => book.author, {
cascade: ['insert', 'update'],
})
books: Book[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
|
@OneToManyの主要なオプション#
| オプション |
説明 |
デフォルト値 |
cascade |
カスケード操作の種類(insert、update、remove等) |
なし |
eager |
自動的にリレーションをロード |
false |
cascadeオプションの詳細#
cascadeオプションを使用すると、親エンティティの操作が子エンティティにも自動的に適用されます。
1
2
3
4
5
6
7
8
9
10
|
@OneToMany(() => Book, (book) => book.author, {
cascade: true, // すべてのカスケード操作を有効化
})
books: Book[];
// または特定の操作のみ指定
@OneToMany(() => Book, (book) => book.author, {
cascade: ['insert', 'update'], // insert と update のみ
})
books: Book[];
|
| カスケード種別 |
説明 |
insert |
親を保存するときに新しい子も保存 |
update |
親を更新するときに子も更新 |
remove |
親を削除するときに子も削除 |
soft-remove |
親を論理削除するときに子も論理削除 |
recover |
親を復元するときに子も復元 |
双方向リレーションの整合性を保つヘルパーメソッド#
双方向リレーションでは、両側の参照を同期させる必要があります。ヘルパーメソッドを用意することで、データの整合性を保ちながら安全に関連を操作できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
// src/authors/entities/author.entity.ts
@Entity('authors')
export class Author {
// ...他のフィールド
@OneToMany(() => Book, (book) => book.author, {
cascade: ['insert', 'update'],
})
books: Book[];
// ヘルパーメソッド:書籍を追加
addBook(book: Book): void {
if (!this.books) {
this.books = [];
}
this.books.push(book);
book.author = this;
}
// ヘルパーメソッド:書籍を削除
removeBook(book: Book): void {
const index = this.books?.indexOf(book);
if (index !== undefined && index > -1) {
this.books.splice(index, 1);
book.author = null as unknown as Author;
}
}
}
|
ヘルパーメソッドを使用することで、片側だけ設定する不整合を防げます。
1
2
3
4
5
|
// 良い例:ヘルパーメソッドを使用
author.addBook(book);
// 悪い例:片側だけ設定すると不整合が発生する可能性
author.books.push(book); // book.author は設定されない
|
Eager LoadingとLazy Loadingの違いと使い分け#
TypeORMでは、関連エンティティをいつ取得するかを2つの方法で制御できます。
フェッチ戦略の比較#
| 戦略 |
動作 |
メリット |
デメリット |
| Eager Loading |
親エンティティと同時に取得 |
常に関連データが利用可能 |
不要なデータも取得、N+1問題の原因 |
| Lazy Loading |
関連エンティティにアクセスしたときに取得 |
必要なときだけ取得 |
Promiseの扱いが必要 |
Eager Loadingの設定#
eager: trueを設定すると、親エンティティを取得する際に自動的に関連エンティティも取得されます。
1
2
3
4
|
@ManyToOne(() => Author, (author) => author.books, {
eager: true, // 自動的にAuthorを取得
})
author: Author;
|
ただし、eager: trueの使用は推奨されません。以下の理由があります。
- パフォーマンスへの影響: すべてのクエリで関連データが取得される
- 制御の難しさ: 特定のケースでのみ関連データが必要な場合に対応できない
- N+1問題の誘発: 複数のエンティティを取得する際にN+1問題が発生しやすい
Lazy Loadingの使用(TypeScript)#
TypeORMのLazy LoadingはPromiseを返します。
1
2
3
4
5
6
7
8
9
10
11
|
// エンティティでPromise型を使用
@Entity('books')
export class Book {
@ManyToOne(() => Author, (author) => author.books)
author: Promise<Author>;
}
// 使用時はawaitが必要
const book = await bookRepository.findOneBy({ id: 1 });
const author = await book.author; // Promiseを解決
console.log(author.name);
|
Lazy Loadingの注意点として、TypeORMのLazy Loadingは初回アクセス時に追加のクエリを発行するため、ループ内で使用するとN+1問題を引き起こします。
推奨されるアプローチ#
実務では、eager: false(デフォルト)のまま、必要に応じてクエリ時に明示的に関連を取得する方法が推奨されます。
1
2
3
4
5
6
7
8
9
|
// relationsオプションで明示的に指定
const books = await bookRepository.find({
relations: { author: true },
});
// または relations を配列で指定
const books = await bookRepository.find({
relations: ['author'],
});
|
N+1問題とは何か#
N+1問題は、ORMを使用したアプリケーション開発で最も頻繁に発生するパフォーマンス問題の1つです。
N+1問題の発生メカニズム#
N+1問題とは、1件の親エンティティ一覧を取得するクエリ(1)に加えて、各親エンティティに対する関連エンティティ取得クエリがN回発生する現象です。
sequenceDiagram
participant App as アプリケーション
participant DB as データベース
App->>DB: 1. SELECT * FROM books(書籍一覧取得)
DB-->>App: 書籍10件を返却
loop 各書籍に対して
App->>DB: 2. SELECT * FROM authors WHERE id = 1
App->>DB: 3. SELECT * FROM authors WHERE id = 2
App->>DB: ...
App->>DB: 11. SELECT * FROM authors WHERE id = 10
end
Note over App,DB: 合計11回のクエリ(1 + 10 = N+1)N+1問題を再現するコード#
以下のコードでN+1問題を確認できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// src/books/books.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Book } from './entities/book.entity';
@Injectable()
export class BooksService {
constructor(
@InjectRepository(Book)
private readonly booksRepository: Repository<Book>,
) {}
async demonstrateN1Problem(): Promise<void> {
// 1回目のクエリ:書籍一覧を取得
const books = await this.booksRepository.find();
// 書籍ごとにN回のクエリが発生(Lazy Loadingの場合)
for (const book of books) {
// authorにアクセスするたびにSELECTが実行される
const author = await book.author;
console.log(`${book.title} by ${author.name}`);
}
}
}
|
SQLログの確認方法#
TypeORMの設定でSQLログを有効化すると、実行されるSQLを確認できます。
1
2
3
4
5
6
7
8
9
10
11
12
|
// src/app.module.ts
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'password',
database: 'bookstore',
entities: [Author, Book],
synchronize: true,
logging: true, // SQLログを有効化
})
|
実行結果のログ例:
1
2
3
4
5
6
7
8
9
10
11
12
|
-- 1回目:書籍一覧取得
SELECT `Book`.`id`, `Book`.`title`, `Book`.`isbn`, `Book`.`author_id`
FROM `books` `Book`
-- 2回目以降:各書籍の著者を個別に取得(N+1の原因)
SELECT `Author`.`id`, `Author`.`name`, `Author`.`email`
FROM `authors` `Author` WHERE `Author`.`id` = 1
SELECT `Author`.`id`, `Author`.`name`, `Author`.`email`
FROM `authors` `Author` WHERE `Author`.`id` = 2
SELECT `Author`.`id`, `Author`.`name`, `Author`.`email`
FROM `authors` `Author` WHERE `Author`.`id` = 3
-- ...N回繰り返し
|
relationsオプションでN+1問題を解決する#
最もシンプルな解決策は、find()系メソッドのrelationsオプションを使用することです。
relationsオプションの使用#
1
2
3
4
5
6
7
|
async findAllWithAuthors(): Promise<Book[]> {
return this.booksRepository.find({
relations: {
author: true,
},
});
}
|
または配列形式でも指定できます。
1
2
3
4
5
|
async findAllWithAuthors(): Promise<Book[]> {
return this.booksRepository.find({
relations: ['author'],
});
}
|
実行されるSQL#
1
2
3
4
5
|
SELECT
`Book`.`id`, `Book`.`title`, `Book`.`isbn`, `Book`.`author_id`,
`Author`.`id` AS `Author_id`, `Author`.`name` AS `Author_name`, `Author`.`email` AS `Author_email`
FROM `books` `Book`
LEFT JOIN `authors` `Author` ON `Author`.`id` = `Book`.`author_id`
|
1回のクエリで書籍と著者をすべて取得でき、N+1問題が解決します。
ネストしたリレーションの取得#
複数階層のリレーションも取得できます。
1
2
3
4
5
6
7
8
9
10
|
// 著者とその所属出版社も取得
async findAllWithAuthorAndPublisher(): Promise<Book[]> {
return this.booksRepository.find({
relations: {
author: {
publisher: true, // ネストしたリレーション
},
},
});
}
|
QueryBuilderによるJOIN FETCHの活用#
より複雑なクエリや条件付きのリレーション取得には、QueryBuilderを使用します。
leftJoinAndSelectの基本#
1
2
3
4
5
6
|
async findAllWithAuthorsQueryBuilder(): Promise<Book[]> {
return this.booksRepository
.createQueryBuilder('book')
.leftJoinAndSelect('book.author', 'author')
.getMany();
}
|
条件付きJOIN#
特定の条件に一致する関連エンティティのみを取得できます。
1
2
3
4
5
6
7
8
9
|
// アクティブな著者の書籍のみ取得
async findBooksWithActiveAuthors(): Promise<Book[]> {
return this.booksRepository
.createQueryBuilder('book')
.innerJoinAndSelect('book.author', 'author', 'author.isActive = :isActive', {
isActive: true,
})
.getMany();
}
|
複数のリレーションを同時に取得#
1
2
3
4
5
6
7
8
|
async findBooksWithAllRelations(): Promise<Book[]> {
return this.booksRepository
.createQueryBuilder('book')
.leftJoinAndSelect('book.author', 'author')
.leftJoinAndSelect('book.categories', 'category')
.leftJoinAndSelect('book.reviews', 'review')
.getMany();
}
|
1対多リレーションの取得(著者と書籍一覧)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// src/authors/authors.service.ts
async findAuthorWithBooks(id: number): Promise<Author | null> {
return this.authorsRepository
.createQueryBuilder('author')
.leftJoinAndSelect('author.books', 'book')
.where('author.id = :id', { id })
.getOne();
}
// すべての著者とその書籍を取得
async findAllAuthorsWithBooks(): Promise<Author[]> {
return this.authorsRepository
.createQueryBuilder('author')
.leftJoinAndSelect('author.books', 'book')
.orderBy('author.name', 'ASC')
.addOrderBy('book.title', 'ASC')
.getMany();
}
|
集計と組み合わせる#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 各著者の書籍数を取得
async findAuthorsWithBookCount(): Promise<
{ authorId: number; authorName: string; bookCount: number }[]
> {
return this.authorsRepository
.createQueryBuilder('author')
.select('author.id', 'authorId')
.addSelect('author.name', 'authorName')
.addSelect('COUNT(book.id)', 'bookCount')
.leftJoin('author.books', 'book')
.groupBy('author.id')
.addGroupBy('author.name')
.getRawMany();
}
|
innerJoinAndSelectとleftJoinAndSelectの使い分け#
JOINの種類によって取得結果が異なります。
JOINの種類と違い#
| JOIN種類 |
説明 |
使用場面 |
leftJoinAndSelect |
関連が存在しなくても親を取得 |
書籍がない著者も含めて取得したい場合 |
innerJoinAndSelect |
関連が存在する親のみ取得 |
書籍がある著者のみ取得したい場合 |
使用例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// LEFT JOIN: 書籍がない著者も取得
async findAllAuthors(): Promise<Author[]> {
return this.authorsRepository
.createQueryBuilder('author')
.leftJoinAndSelect('author.books', 'book')
.getMany();
}
// INNER JOIN: 書籍がある著者のみ取得
async findAuthorsWithBooks(): Promise<Author[]> {
return this.authorsRepository
.createQueryBuilder('author')
.innerJoinAndSelect('author.books', 'book')
.getMany();
}
|
パフォーマンス計測による効果の検証#
N+1問題の解決効果を数値で確認します。
テストデータの準備#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// src/database/seeders/author-book.seeder.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Author } from '../../authors/entities/author.entity';
import { Book } from '../../books/entities/book.entity';
@Injectable()
export class AuthorBookSeeder {
constructor(
@InjectRepository(Author)
private readonly authorsRepository: Repository<Author>,
@InjectRepository(Book)
private readonly booksRepository: Repository<Book>,
) {}
async seed(): Promise<void> {
// 100人の著者、各著者に10冊の書籍を登録
for (let i = 1; i <= 100; i++) {
const author = this.authorsRepository.create({
name: `Author ${i}`,
email: `author${i}@example.com`,
});
await this.authorsRepository.save(author);
const books = [];
for (let j = 1; j <= 10; j++) {
books.push(
this.booksRepository.create({
title: `Book ${j} by Author ${i}`,
isbn: `978-${String(i).padStart(4, '0')}-${String(j).padStart(4, '0')}`,
author,
}),
);
}
await this.booksRepository.save(books);
}
}
}
|
パフォーマンス比較の実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
// src/performance/performance.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Author } from '../authors/entities/author.entity';
@Injectable()
export class PerformanceService {
private readonly logger = new Logger(PerformanceService.name);
constructor(
@InjectRepository(Author)
private readonly authorsRepository: Repository<Author>,
) {}
async comparePerformance(): Promise<void> {
// N+1問題が発生するパターン
const startN1 = Date.now();
const authorsN1 = await this.authorsRepository.find();
let totalBooksN1 = 0;
for (const author of authorsN1) {
// booksがLazy Loadingの場合、ここで追加クエリが発生
const books = await Promise.resolve(author.books);
totalBooksN1 += books?.length ?? 0;
}
const endN1 = Date.now();
this.logger.log(`N+1パターン: ${endN1 - startN1}ms, 書籍総数: ${totalBooksN1}`);
// QueryBuilderで最適化したパターン
const startOptimized = Date.now();
const authorsOptimized = await this.authorsRepository
.createQueryBuilder('author')
.leftJoinAndSelect('author.books', 'book')
.getMany();
let totalBooksOptimized = 0;
for (const author of authorsOptimized) {
totalBooksOptimized += author.books?.length ?? 0;
}
const endOptimized = Date.now();
this.logger.log(
`最適化パターン: ${endOptimized - startOptimized}ms, 書籍総数: ${totalBooksOptimized}`,
);
}
}
|
期待される結果#
| パターン |
クエリ数 |
実行時間(目安) |
| N+1問題あり |
101回 |
500-1000ms |
| JOIN FETCH使用 |
1回 |
50-100ms |
実際のパフォーマンス改善効果は、データ量やネットワーク遅延によって異なりますが、N+1問題を解決することで10倍以上の高速化が期待できます。
実践的なAPIエンドポイントの実装例#
学んだ内容を活かして、実践的なAPIエンドポイントを実装します。
DTOの定義#
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// src/authors/dto/author-response.dto.ts
export class BookSummaryDto {
id: number;
title: string;
isbn: string;
}
export class AuthorWithBooksDto {
id: number;
name: string;
email: string;
books: BookSummaryDto[];
}
|
Serviceの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
// src/authors/authors.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Author } from './entities/author.entity';
import { AuthorWithBooksDto, BookSummaryDto } from './dto/author-response.dto';
@Injectable()
export class AuthorsService {
constructor(
@InjectRepository(Author)
private readonly authorsRepository: Repository<Author>,
) {}
async findAllWithBooks(): Promise<AuthorWithBooksDto[]> {
const authors = await this.authorsRepository
.createQueryBuilder('author')
.leftJoinAndSelect('author.books', 'book')
.orderBy('author.name', 'ASC')
.addOrderBy('book.title', 'ASC')
.getMany();
return authors.map((author) => this.toAuthorWithBooksDto(author));
}
async findOneWithBooks(id: number): Promise<AuthorWithBooksDto> {
const author = await this.authorsRepository
.createQueryBuilder('author')
.leftJoinAndSelect('author.books', 'book')
.where('author.id = :id', { id })
.orderBy('book.title', 'ASC')
.getOne();
if (!author) {
throw new NotFoundException(`Author with ID ${id} not found`);
}
return this.toAuthorWithBooksDto(author);
}
private toAuthorWithBooksDto(author: Author): AuthorWithBooksDto {
return {
id: author.id,
name: author.name,
email: author.email,
books: author.books?.map((book) => this.toBookSummaryDto(book)) ?? [],
};
}
private toBookSummaryDto(book: { id: number; title: string; isbn: string }): BookSummaryDto {
return {
id: book.id,
title: book.title,
isbn: book.isbn,
};
}
}
|
Controllerの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// src/authors/authors.controller.ts
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { AuthorsService } from './authors.service';
import { AuthorWithBooksDto } from './dto/author-response.dto';
@Controller('authors')
export class AuthorsController {
constructor(private readonly authorsService: AuthorsService) {}
@Get()
async findAll(): Promise<AuthorWithBooksDto[]> {
return this.authorsService.findAllWithBooks();
}
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number): Promise<AuthorWithBooksDto> {
return this.authorsService.findOneWithBooks(id);
}
}
|
循環参照とシリアライズの問題への対処#
双方向リレーションをJSONにシリアライズする際、循環参照が発生する場合があります。
問題の発生#
1
2
3
4
5
6
|
// Authorが参照するBookがAuthorを参照する → 無限ループ
const author = await authorsRepository.findOne({
where: { id: 1 },
relations: { books: true },
});
console.log(JSON.stringify(author)); // エラーまたは無限ループ
|
解決策1: DTOへの変換#
エンティティをそのまま返さず、必要なフィールドのみを含むDTOに変換します(前述の例を参照)。
1
2
3
4
5
6
7
8
|
import { Exclude } from 'class-transformer';
@Entity('books')
export class Book {
@ManyToOne(() => Author, (author) => author.books)
@Exclude() // JSONシリアライズ時に除外
author: Author;
}
|
解決策3: toJSON()メソッドのオーバーライド#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
@Entity('books')
export class Book {
// ...フィールド
toJSON() {
return {
id: this.id,
title: this.title,
isbn: this.isbn,
authorId: this.author?.id,
// authorオブジェクト全体は含めない
};
}
}
|
まとめ#
本記事では、NestJSとTypeORMにおけるエンティティ間リレーションシップの設計と、N+1問題の解決方法を解説しました。
主なポイントを振り返ります。
- リレーションデコレータ:
@OneToManyと@ManyToOneで双方向関連を定義
- ヘルパーメソッド: 双方向リレーションの整合性を保つためのメソッドを実装
- フェッチ戦略:
eager: trueは避け、必要に応じて明示的にリレーションを取得
- N+1問題の解決:
relationsオプションまたはQueryBuilderのleftJoinAndSelectを使用
- パフォーマンス: JOINを使用することで10倍以上の高速化が期待できる
複雑なデータ構造を持つAPIを効率的に実装するために、これらのテクニックを活用してください。
参考リンク#