NestJSとTypeORMの組み合わせにおいて、Repositoryパターンはデータアクセス層を構築する標準的なアプローチです。TypeOrmModule.forFeature()で登録したエンティティのRepositoryを@InjectRepository()で注入し、型安全なCRUD操作を実現します。本記事では、Repositoryの基本操作からFindOptions、QueryBuilderまで、データアクセス層実装に必要な知識を体系的に解説します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| 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パターンの概要#
Repositoryパターンは、ドメインロジックとデータアクセスロジックを分離するための設計パターンです。TypeORMでは、各エンティティに対応するRepositoryクラスが自動的に提供されます。
Repositoryパターンの利点#
| 利点 |
説明 |
| 関心の分離 |
ビジネスロジックとデータアクセスロジックを明確に分離 |
| テスタビリティ |
Repositoryをモックすることで単体テストが容易に |
| 保守性 |
データアクセスロジックの変更がビジネスロジックに影響しない |
| 再利用性 |
同じRepositoryを複数のServiceで利用可能 |
NestJSにおけるRepositoryの位置付け#
graph LR
A[Controller] --> B[Service]
B --> C[Repository]
C --> D[(Database)]
subgraph "データアクセス層"
C
end
subgraph "ビジネスロジック層"
B
end
subgraph "プレゼンテーション層"
A
endControllerがリクエストを受け取り、Serviceがビジネスロジックを処理し、Repositoryがデータベース操作を担当するという明確な責務分担が実現されます。
TypeOrmModule.forFeature()によるRepository登録#
TypeOrmModule.forFeature()は、特定のモジュールスコープ内で使用するRepositoryを登録するためのメソッドです。
基本的な使用方法#
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// src/articles/articles.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Article } from './entities/article.entity';
import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller';
@Module({
imports: [TypeOrmModule.forFeature([Article])],
providers: [ArticlesService],
controllers: [ArticlesController],
})
export class ArticlesModule {}
|
forFeature()に渡したエンティティ(Article)に対応するRepositoryが、このモジュール内で注入可能になります。
複数エンティティの登録#
1つのモジュールで複数のエンティティを扱う場合は、配列で指定します。
1
2
3
4
5
6
7
8
9
10
11
12
|
// src/blog/blog.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Article } from './entities/article.entity';
import { Category } from './entities/category.entity';
import { Tag } from './entities/tag.entity';
@Module({
imports: [TypeOrmModule.forFeature([Article, Category, Tag])],
// ...
})
export class BlogModule {}
|
他モジュールへのRepository公開#
forFeature()で登録したRepositoryを他のモジュールでも使用したい場合は、TypeOrmModule自体をエクスポートします。
1
2
3
4
5
6
7
8
9
10
11
12
|
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
exports: [TypeOrmModule, UsersService],
})
export class UsersModule {}
|
これにより、UsersModuleをインポートした他のモジュールでもUserのRepositoryを注入できます。
1
2
3
4
5
6
7
8
9
10
|
// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { OrdersService } from './orders.service';
@Module({
imports: [UsersModule],
providers: [OrdersService],
})
export class OrdersModule {}
|
@InjectRepository()によるRepository注入#
@InjectRepository()デコレータを使用して、ServiceクラスにRepositoryを注入します。
基本的な注入方法#
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// src/articles/articles.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from './entities/article.entity';
@Injectable()
export class ArticlesService {
constructor(
@InjectRepository(Article)
private readonly articlesRepository: Repository<Article>,
) {}
}
|
Repository<Article>というジェネリック型により、エンティティの型情報が保持され、型安全な操作が可能になります。
複数Repositoryの注入#
複数のRepositoryを同時に注入することも可能です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// src/blog/blog.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from './entities/article.entity';
import { Category } from './entities/category.entity';
@Injectable()
export class BlogService {
constructor(
@InjectRepository(Article)
private readonly articlesRepository: Repository<Article>,
@InjectRepository(Category)
private readonly categoriesRepository: Repository<Category>,
) {}
}
|
基本CRUD操作の実装#
Repositoryが提供する主要なメソッドを使用して、Create(作成)、Read(読み取り)、Update(更新)、Delete(削除)の各操作を実装します。
サンプルエンティティの定義#
以下のArticleエンティティを例に解説を進めます。
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
|
// src/articles/entities/article.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum ArticleStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
@Entity('articles')
export class Article {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 200 })
title: string;
@Column('text')
content: string;
@Column({ length: 100, nullable: true })
author: string;
@Column({
type: 'enum',
enum: ArticleStatus,
default: ArticleStatus.DRAFT,
})
status: ArticleStatus;
@Column({ default: 0 })
viewCount: number;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
|
Create(作成)操作#
新しいレコードを作成するには、create()とsave()メソッドを組み合わせて使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// src/articles/articles.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from './entities/article.entity';
import { CreateArticleDto } from './dto/create-article.dto';
@Injectable()
export class ArticlesService {
constructor(
@InjectRepository(Article)
private readonly articlesRepository: Repository<Article>,
) {}
async create(createArticleDto: CreateArticleDto): Promise<Article> {
// create()でエンティティインスタンスを生成(DBには保存されない)
const article = this.articlesRepository.create(createArticleDto);
// save()でDBに保存し、保存されたエンティティを返す
return this.articlesRepository.save(article);
}
}
|
create()とsave()の役割の違いを理解することが重要です。
| メソッド |
役割 |
DB操作 |
create() |
DTOからエンティティインスタンスを生成 |
なし |
save() |
エンティティをDBに永続化 |
INSERT / UPDATE |
save()メソッドは、エンティティに主キーが設定されているかどうかで動作が変わります。
- 主キーがない → INSERT
- 主キーがある → UPDATE(存在しない場合はINSERT)
複数レコードの一括作成#
1
2
3
4
|
async createMany(createArticleDtos: CreateArticleDto[]): Promise<Article[]> {
const articles = this.articlesRepository.create(createArticleDtos);
return this.articlesRepository.save(articles);
}
|
insert()メソッドによる高速挿入#
save()と異なり、insert()はエンティティを返さず、生成されたIDのみを取得できます。大量データの挿入時に高速です。
1
2
3
4
5
|
async bulkInsert(createArticleDtos: CreateArticleDto[]): Promise<number[]> {
const result = await this.articlesRepository.insert(createArticleDtos);
// 挿入されたレコードのIDを取得
return result.identifiers.map((identifier) => identifier.id);
}
|
Read(読み取り)操作#
データの取得には、用途に応じて様々なメソッドを使い分けます。
find() - 複数レコードの取得#
条件に一致するすべてのレコードを取得します。
1
2
3
4
5
6
7
8
9
10
11
|
// すべての記事を取得
async findAll(): Promise<Article[]> {
return this.articlesRepository.find();
}
// 条件を指定して取得
async findPublished(): Promise<Article[]> {
return this.articlesRepository.find({
where: { status: ArticleStatus.PUBLISHED },
});
}
|
findOne() - 単一レコードの取得#
条件に一致する最初のレコードを取得します。見つからない場合はnullを返します。
1
2
3
4
5
|
async findOne(id: number): Promise<Article | null> {
return this.articlesRepository.findOne({
where: { id },
});
}
|
findOneBy() - シンプルな条件での取得#
findOne()のシンプル版で、where条件のみを指定する場合に便利です。
1
2
3
4
5
6
7
|
async findOneById(id: number): Promise<Article | null> {
return this.articlesRepository.findOneBy({ id });
}
async findByTitle(title: string): Promise<Article | null> {
return this.articlesRepository.findOneBy({ title });
}
|
findOneOrFail() - 存在しない場合はエラー#
レコードが見つからない場合にEntityNotFoundErrorをスローします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
import { NotFoundException } from '@nestjs/common';
import { EntityNotFoundError } from 'typeorm';
async findOneOrThrow(id: number): Promise<Article> {
try {
return await this.articlesRepository.findOneOrFail({
where: { id },
});
} catch (error) {
if (error instanceof EntityNotFoundError) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
throw error;
}
}
|
findBy() - 条件指定の複数取得#
find()のシンプル版で、where条件のみを指定する場合に使用します。
1
2
3
|
async findByAuthor(author: string): Promise<Article[]> {
return this.articlesRepository.findBy({ author });
}
|
count() - レコード数の取得#
条件に一致するレコードの数を取得します。
1
2
3
4
5
|
async countPublished(): Promise<number> {
return this.articlesRepository.count({
where: { status: ArticleStatus.PUBLISHED },
});
}
|
exists() - 存在確認#
条件に一致するレコードが存在するかどうかを確認します。
1
2
3
4
5
|
async existsByTitle(title: string): Promise<boolean> {
return this.articlesRepository.exists({
where: { title },
});
}
|
Update(更新)操作#
レコードの更新には複数のアプローチがあります。
save()による更新#
既存のエンティティを取得し、変更後にsave()で保存します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
async update(
id: number,
updateArticleDto: UpdateArticleDto,
): Promise<Article> {
// 既存のエンティティを取得
const article = await this.articlesRepository.findOneBy({ id });
if (!article) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
// プロパティを更新
Object.assign(article, updateArticleDto);
// 保存して返す
return this.articlesRepository.save(article);
}
|
update()による直接更新#
エンティティを取得せずに、直接UPDATE文を実行します。
1
2
3
4
5
6
7
8
|
import { UpdateResult } from 'typeorm';
async updateStatus(
id: number,
status: ArticleStatus,
): Promise<UpdateResult> {
return this.articlesRepository.update(id, { status });
}
|
update()はエンティティのライフサイクルフック(@BeforeUpdateなど)を実行しません。フックが必要な場合はsave()を使用してください。
UpdateResultの構造#
1
2
3
4
5
|
interface UpdateResult {
raw: any; // データベースドライバからの生のレスポンス
affected?: number; // 更新されたレコード数
generatedMaps: ObjectLiteral[]; // 生成された値のマップ
}
|
preload()による部分更新#
preload()は、主キーを含むオブジェクトから既存エンティティを取得し、渡されたプロパティでマージします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
async updateWithPreload(
id: number,
updateArticleDto: UpdateArticleDto,
): Promise<Article> {
// idで既存エンティティを取得し、DTOの値でマージ
const article = await this.articlesRepository.preload({
id,
...updateArticleDto,
});
if (!article) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
return this.articlesRepository.save(article);
}
|
Delete(削除)操作#
レコードの削除にはremove()とdelete()の2つのメソッドがあります。
remove()によるエンティティベースの削除#
エンティティインスタンスを渡して削除します。
1
2
3
4
5
6
7
|
async remove(id: number): Promise<void> {
const article = await this.articlesRepository.findOneBy({ id });
if (!article) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
await this.articlesRepository.remove(article);
}
|
delete()による直接削除#
エンティティを取得せずに、直接DELETE文を実行します。
1
2
3
4
5
6
7
8
9
|
import { DeleteResult } from 'typeorm';
async delete(id: number): Promise<DeleteResult> {
const result = await this.articlesRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
return result;
}
|
remove()とdelete()の違い#
| 特性 |
remove() |
delete() |
| 引数 |
エンティティインスタンス |
主キーまたは条件 |
| ライフサイクルフック |
実行される |
実行されない |
| 戻り値 |
削除されたエンティティ |
DeleteResult |
| パフォーマンス |
遅い(事前取得必要) |
速い(直接実行) |
softRemove()による論理削除#
@DeleteDateColumnを使用したソフトデリートをサポートします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// エンティティに@DeleteDateColumnを追加
@Entity('articles')
export class Article {
// ... 他のカラム
@DeleteDateColumn()
deletedAt: Date;
}
// Serviceでの使用
async softRemove(id: number): Promise<void> {
const article = await this.articlesRepository.findOneBy({ id });
if (!article) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
await this.articlesRepository.softRemove(article);
}
// 論理削除されたレコードを復元
async restore(id: number): Promise<void> {
await this.articlesRepository.restore(id);
}
|
FindOptionsによる高度な検索#
find()系メソッドには、FindOptionsオブジェクトで詳細な検索条件を指定できます。
FindOptionsの主要オプション#
| オプション |
説明 |
型 |
where |
検索条件 |
FindOptionsWhere<Entity> |
select |
取得するカラム |
FindOptionsSelect<Entity> |
relations |
Eager Loadするリレーション |
FindOptionsRelations<Entity> |
order |
ソート順 |
FindOptionsOrder<Entity> |
skip |
スキップするレコード数 |
number |
take |
取得するレコード数 |
number |
cache |
クエリキャッシュの有効化 |
boolean | number |
withDeleted |
論理削除されたレコードを含む |
boolean |
select - 取得カラムの指定#
1
2
3
4
5
6
7
8
9
|
async findTitlesOnly(): Promise<Article[]> {
return this.articlesRepository.find({
select: {
id: true,
title: true,
createdAt: true,
},
});
}
|
where - 検索条件の指定#
基本的な等価条件の指定方法を示します。
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
|
// 単一条件
async findByStatus(status: ArticleStatus): Promise<Article[]> {
return this.articlesRepository.find({
where: { status },
});
}
// 複数条件(AND)
async findActivePublished(): Promise<Article[]> {
return this.articlesRepository.find({
where: {
status: ArticleStatus.PUBLISHED,
isActive: true,
},
});
}
// 複数条件(OR)
async findDraftOrArchived(): Promise<Article[]> {
return this.articlesRepository.find({
where: [
{ status: ArticleStatus.DRAFT },
{ status: ArticleStatus.ARCHIVED },
],
});
}
|
FindOperatorsの活用#
TypeORMは高度な検索条件を指定するための演算子を提供しています。
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
import {
Like,
ILike,
Between,
In,
IsNull,
Not,
LessThan,
LessThanOrEqual,
MoreThan,
MoreThanOrEqual,
Raw,
} from 'typeorm';
// LIKE検索
async searchByTitle(keyword: string): Promise<Article[]> {
return this.articlesRepository.find({
where: { title: Like(`%${keyword}%`) },
});
}
// 大文字小文字を無視したLIKE検索(PostgreSQL向け)
async searchByTitleInsensitive(keyword: string): Promise<Article[]> {
return this.articlesRepository.find({
where: { title: ILike(`%${keyword}%`) },
});
}
// 範囲検索
async findByDateRange(start: Date, end: Date): Promise<Article[]> {
return this.articlesRepository.find({
where: { createdAt: Between(start, end) },
});
}
// IN句
async findByIds(ids: number[]): Promise<Article[]> {
return this.articlesRepository.find({
where: { id: In(ids) },
});
}
// NULL判定
async findWithoutAuthor(): Promise<Article[]> {
return this.articlesRepository.find({
where: { author: IsNull() },
});
}
// NOT条件
async findNotDraft(): Promise<Article[]> {
return this.articlesRepository.find({
where: { status: Not(ArticleStatus.DRAFT) },
});
}
// 比較演算子
async findPopular(minViews: number): Promise<Article[]> {
return this.articlesRepository.find({
where: { viewCount: MoreThanOrEqual(minViews) },
});
}
// Raw SQLによるカスタム条件
async findRecentlyUpdated(days: number): Promise<Article[]> {
return this.articlesRepository.find({
where: {
updatedAt: Raw(
(alias) => `${alias} > DATE_SUB(NOW(), INTERVAL ${days} DAY)`,
),
},
});
}
|
order - ソート順の指定#
1
2
3
4
5
6
7
8
|
async findAllSorted(): Promise<Article[]> {
return this.articlesRepository.find({
order: {
createdAt: 'DESC',
title: 'ASC',
},
});
}
|
ページネーションの実装#
skipとtakeを使用してページネーションを実装します。
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
|
interface PaginationOptions {
page: number;
limit: number;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
async findPaginated(
options: PaginationOptions,
): Promise<PaginatedResult<Article>> {
const { page, limit } = options;
const skip = (page - 1) * limit;
const [data, total] = await this.articlesRepository.findAndCount({
skip,
take: limit,
order: { createdAt: 'DESC' },
});
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
|
findAndCount()は、条件に一致するレコードと総数を同時に取得するため、ページネーションに最適です。
relations - リレーションの取得#
関連エンティティを同時に取得(Eager Loading)します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// 単一のリレーション
async findWithCategory(id: number): Promise<Article | null> {
return this.articlesRepository.findOne({
where: { id },
relations: { category: true },
});
}
// ネストしたリレーション
async findWithDetails(id: number): Promise<Article | null> {
return this.articlesRepository.findOne({
where: { id },
relations: {
category: true,
tags: true,
author: {
profile: true,
},
},
});
}
|
QueryBuilderによる複雑なクエリ#
FindOptionsでは表現できない複雑なクエリには、QueryBuilderを使用します。
QueryBuilderの基本#
1
2
3
4
5
6
7
|
async findWithQueryBuilder(): Promise<Article[]> {
return this.articlesRepository
.createQueryBuilder('article')
.where('article.status = :status', { status: ArticleStatus.PUBLISHED })
.orderBy('article.createdAt', 'DESC')
.getMany();
}
|
SELECT句のカスタマイズ#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// 特定のカラムのみ取得
async findTitles(): Promise<{ id: number; title: string }[]> {
return this.articlesRepository
.createQueryBuilder('article')
.select(['article.id', 'article.title'])
.getMany();
}
// 集計関数の使用
async getViewStats(): Promise<{ total: number; average: number }> {
const result = await this.articlesRepository
.createQueryBuilder('article')
.select('SUM(article.viewCount)', 'total')
.addSelect('AVG(article.viewCount)', 'average')
.where('article.status = :status', { status: ArticleStatus.PUBLISHED })
.getRawOne();
return {
total: parseInt(result.total, 10) || 0,
average: parseFloat(result.average) || 0,
};
}
|
JOINの使用#
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
|
// INNER JOIN
async findWithCategory(): Promise<Article[]> {
return this.articlesRepository
.createQueryBuilder('article')
.innerJoinAndSelect('article.category', 'category')
.getMany();
}
// LEFT JOIN
async findWithOptionalCategory(): Promise<Article[]> {
return this.articlesRepository
.createQueryBuilder('article')
.leftJoinAndSelect('article.category', 'category')
.getMany();
}
// 条件付きJOIN
async findWithActiveCategory(): Promise<Article[]> {
return this.articlesRepository
.createQueryBuilder('article')
.leftJoinAndSelect(
'article.category',
'category',
'category.isActive = :isActive',
{ isActive: true },
)
.getMany();
}
|
サブクエリの使用#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
async findPopularArticles(): Promise<Article[]> {
// 平均以上の閲覧数を持つ記事を取得
return this.articlesRepository
.createQueryBuilder('article')
.where((qb) => {
const subQuery = qb
.subQuery()
.select('AVG(a.viewCount)')
.from(Article, 'a')
.getQuery();
return `article.viewCount > ${subQuery}`;
})
.getMany();
}
|
動的クエリの構築#
条件に応じてクエリを動的に構築します。
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
|
interface SearchOptions {
keyword?: string;
status?: ArticleStatus;
author?: string;
startDate?: Date;
endDate?: Date;
}
async search(options: SearchOptions): Promise<Article[]> {
const queryBuilder = this.articlesRepository
.createQueryBuilder('article');
if (options.keyword) {
queryBuilder.andWhere(
'(article.title LIKE :keyword OR article.content LIKE :keyword)',
{ keyword: `%${options.keyword}%` },
);
}
if (options.status) {
queryBuilder.andWhere('article.status = :status', {
status: options.status,
});
}
if (options.author) {
queryBuilder.andWhere('article.author = :author', {
author: options.author,
});
}
if (options.startDate && options.endDate) {
queryBuilder.andWhere(
'article.createdAt BETWEEN :startDate AND :endDate',
{ startDate: options.startDate, endDate: options.endDate },
);
}
return queryBuilder
.orderBy('article.createdAt', 'DESC')
.getMany();
}
|
UPDATE/DELETEのQueryBuilder#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// 一括更新
async incrementViewCount(id: number): Promise<void> {
await this.articlesRepository
.createQueryBuilder()
.update(Article)
.set({ viewCount: () => 'viewCount + 1' })
.where('id = :id', { id })
.execute();
}
// 条件付き一括削除
async deleteOldDrafts(days: number): Promise<number> {
const result = await this.articlesRepository
.createQueryBuilder()
.delete()
.from(Article)
.where('status = :status', { status: ArticleStatus.DRAFT })
.andWhere('createdAt < DATE_SUB(NOW(), INTERVAL :days DAY)', { days })
.execute();
return result.affected || 0;
}
|
トランザクション処理#
複数のデータベース操作を1つのトランザクションとして実行する方法を解説します。
DataSourceを使用したトランザクション#
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
|
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Article } from './entities/article.entity';
import { ArticleLog } from './entities/article-log.entity';
@Injectable()
export class ArticlesService {
constructor(
@InjectRepository(Article)
private readonly articlesRepository: Repository<Article>,
private readonly dataSource: DataSource,
) {}
async publishWithLog(id: number): Promise<Article> {
return this.dataSource.transaction(async (manager) => {
// トランザクション内でRepositoryを取得
const articlesRepo = manager.getRepository(Article);
const logsRepo = manager.getRepository(ArticleLog);
// 記事を更新
const article = await articlesRepo.findOneBy({ id });
if (!article) {
throw new Error(`Article with ID ${id} not found`);
}
article.status = ArticleStatus.PUBLISHED;
await articlesRepo.save(article);
// ログを記録
const log = logsRepo.create({
articleId: id,
action: 'PUBLISH',
timestamp: new Date(),
});
await logsRepo.save(log);
return article;
});
}
}
|
QueryRunnerを使用した手動トランザクション#
より細かな制御が必要な場合は、QueryRunnerを使用します。
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
|
async transferArticle(
articleId: number,
fromAuthor: string,
toAuthor: string,
): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const article = await queryRunner.manager.findOne(Article, {
where: { id: articleId, author: fromAuthor },
});
if (!article) {
throw new Error('Article not found or not owned by the author');
}
article.author = toAuthor;
await queryRunner.manager.save(article);
// ログを記録
await queryRunner.manager.insert(ArticleLog, {
articleId,
action: `TRANSFER: ${fromAuthor} -> ${toAuthor}`,
timestamp: new Date(),
});
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
|
Repositoryのユニットテスト#
@InjectRepository()で注入されたRepositoryをモック化してテストする方法を解説します。
テストモジュールの設定#
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
// src/articles/articles.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ArticlesService } from './articles.service';
import { Article, ArticleStatus } from './entities/article.entity';
describe('ArticlesService', () => {
let service: ArticlesService;
let repository: Repository<Article>;
const mockRepository = {
find: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ArticlesService,
{
provide: getRepositoryToken(Article),
useValue: mockRepository,
},
],
}).compile();
service = module.get<ArticlesService>(ArticlesService);
repository = module.get<Repository<Article>>(getRepositoryToken(Article));
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return an array of articles', async () => {
const expectedArticles: Article[] = [
{
id: 1,
title: 'Test Article',
content: 'Test Content',
author: 'John',
status: ArticleStatus.PUBLISHED,
viewCount: 100,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockRepository.find.mockResolvedValue(expectedArticles);
const result = await service.findAll();
expect(result).toEqual(expectedArticles);
expect(mockRepository.find).toHaveBeenCalledTimes(1);
});
});
describe('create', () => {
it('should create and return a new article', async () => {
const createDto = {
title: 'New Article',
content: 'New Content',
author: 'Jane',
};
const createdArticle = { id: 1, ...createDto };
mockRepository.create.mockReturnValue(createdArticle);
mockRepository.save.mockResolvedValue(createdArticle);
const result = await service.create(createDto);
expect(result).toEqual(createdArticle);
expect(mockRepository.create).toHaveBeenCalledWith(createDto);
expect(mockRepository.save).toHaveBeenCalledWith(createdArticle);
});
});
});
|
まとめ#
本記事では、NestJSとTypeORMにおけるRepositoryパターンを活用したデータアクセス層の実装方法を解説しました。
主なポイントを振り返ります。
- Repository登録:
TypeOrmModule.forFeature()でモジュールスコープにRepositoryを登録
- Repository注入:
@InjectRepository()デコレータで型安全なRepositoryを取得
- 基本CRUD:
create()、save()、find()、update()、delete()による基本操作
- FindOptions:
where、select、order、relations等による柔軟な検索
- QueryBuilder: 複雑なクエリや動的条件の構築
- トランザクション:
DataSourceまたはQueryRunnerによる一貫性のある操作
Repositoryパターンを正しく活用することで、保守性が高くテストしやすいデータアクセス層を構築できます。ビジネスロジックとデータアクセスの責務を明確に分離し、堅牢なNestJSアプリケーションを開発してください。
参考リンク#