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
    end

Controllerがリクエストを受け取り、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',
    },
  });
}

ページネーションの実装

skiptakeを使用してページネーションを実装します。

 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パターンを活用したデータアクセス層の実装方法を解説しました。

主なポイントを振り返ります。

  1. Repository登録: TypeOrmModule.forFeature()でモジュールスコープにRepositoryを登録
  2. Repository注入: @InjectRepository()デコレータで型安全なRepositoryを取得
  3. 基本CRUD: create()save()find()update()delete()による基本操作
  4. FindOptions: whereselectorderrelations等による柔軟な検索
  5. QueryBuilder: 複雑なクエリや動的条件の構築
  6. トランザクション: DataSourceまたはQueryRunnerによる一貫性のある操作

Repositoryパターンを正しく活用することで、保守性が高くテストしやすいデータアクセス層を構築できます。ビジネスロジックとデータアクセスの責務を明確に分離し、堅牢なNestJSアプリケーションを開発してください。

参考リンク