Produced by Fourier

TypeORMとNestJSでカーソル型ページネーションを実装する

Hirayama Hirayama カレンダーアイコン 2024.04.10

最近業務上で NestJS を使用する機会があり、ORMとして TypeORM を選びました。

ただ、実装していくにあたり、TypeORMにはカーソル型ページネーションをサポートしていないことが分かり、TypeORM関連のライブラリにもカーソル型ページネーションをサポートしているライブラリは見つけられなかったため、自分で実装することにしました。

本記事では、その時作成したカーソル型ページネーションの概要と作成方法について解説します。

なお、本記事で実装した内容は、以下のリポジトリにて公開しているので、参考にしてください。

GitHub - FOURIER-Inc/typeorm-pagination: The sample code for pagination with TypeORM and NestJS thumbnail

GitHub - FOURIER-Inc/typeorm-pagination: The sample code for pagination with TypeORM and NestJS

The sample code for pagination with TypeORM and NestJS - FOURIER-Inc/typeorm-pagination

https://github.com/FOURIER-Inc/typeorm-pagination

前提

読者想定

既にNestJSやTypeORMに触れたことがある人を対象に書いています。そのため、ページネーションの実装に関係のない箇所は解説をしていません。

カーソル型ページネーションについては解説をしているので知らなくても大丈夫ですが、ページネーションの概要や、LIMITとOFFSETを指定する方法のページネーションは知っているとわかりやすいと思います。

SELECT * FROM users
LIMIT 100
OFFSET 300
LIMITとOFFSETを指定する方法で1ページを取得するSQL

構成

以下のライブラリやランタイムを使用しています。データベースはSQLite3を使用しています。

本記事ではクライアント側は実装しないので、PostmanなどのAPIクライアントソフトで動作検証します。

  • NestJS ^10.3.5
  • TypeORM ^0.3.20
  • TypeScript ^5.4.3
  • Node.js v20.10.0
  • SQLite3

セットアップ

NestJSの初期セットアップ完了後、以下のコマンドでTypeORMをインストールします。

npm install --save @nestjs/typeorm typeorm sqlite3

ShellでSQLite3のデータベースを作成し、TypeORMの設定をします。

sqlite3 db.sqlite3
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'db.sqlite3',
      synchronize: true,
      logging: true,
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
app.module.ts

上記設定で npm run start:dev を実行し、正常に立ち上がればセットアップは完了です。検索対象のモデルは後程作成していきます。

カーソル型ページネーションについて

まず、本セクションではカーソル型ページネーションを知らない人のために、カーソル型ページネーションがどういったものなのかを説明します。既にご存じの場合は本セクションは読み飛ばしてもらって大丈夫です。


カーソル型ページネーションを理解するために、他のページネーションとの違いに着目して説明します。

ページネーションの実装にはざっくり以下の2種類の方法がよく使われています。

  1. LIMITとOFFSETを指定する方法
  2. カーソルを指定する方法

この2種類のページネーションは ページの区切りのつけ方 に違いがあり、LIMITとOFFSETを指定する方法では、OFFSETで先頭行を飛ばし、LIMITに指定した数(1ページ分)のデータを取得するのに対し、カーソルを指定する方法は、 次のページの最初のデータのIDを記憶する ことで、ページを区切ります。

以下の図は、1ページ当たり最大3件でID昇順にソートした場合、2ページ目を取得するページネーションのやり方の違いです。

OFFSETとLIMITを指定する場合

先頭から3件(1ページ文)を飛ばし、最大3件を取得する。

カーソル型の場合

ページの最初のデータのIDから、それに続くデータを最大3件取得する。

カーソル型は、OFFSETとLIMITを指定する場合と比べ、以下のような利点があります。

  1. OFFSET分のレコードをスキップする処理が入らないので、高速に取得できる
  2. データが頻繁に追加、更新、削除されても重複や欠落を避けることができる

一方で、

  1. 直接ページ番号を指定して取得することができない
  2. 実装が複雑

といったデメリットが存在します。

大まかな仕様決め

カーソル型ページネーションについて概要を理解したところで、実装する前に大まかな仕様を決めておきます。

エンドポイントの仕様

カーソル型ページネーションを実装したリスト取得エンドポイントでは、最初のリクエストでフィルタリングや1ページ当たりの件数、ソート順などを指定して送信すると、データリストともに、次のページを取得する際に使用するトークンが返ってきます。

例として、 GET /users?size=1&direction=desc&sortBy=createdAt といったように、条件をクエリ文字列に含めて /users エンドポイントに送信すると、レスポンスボディの result にデータ配列、 nextPageToken に次のページを取得するためのトークンが含められる形で返ります。

{
  "result": [
        {
            "id": 1,
            "name": "hoge",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T05:33:15.000Z",
            "updatedAt": "2024-03-27T05:33:41.000Z"
        }
  ],
  "nextPageToken": "ABCD"
}

もし次のページが存在しない場合は、 nextPageTokennull になります。

2ページ目を取得する場合は、トークンをクエリ文字列に含めてリクエストすることで取得できます。(例: GET /users?token=ABCD

そのため、全ページを取得したい場合は、 nextPageTokennull になるまで取得を繰り返すことになります。

export class UserClient {
  async findAll(
    params?: PaginateParameter,
  ): Promise<IndexResult<User>> {
    return await axios.request(User, {
      url: '/users',
      method: 'GET',
      params,
    });
  }

  async *findAllGenerator(
    params?: Omit<PaginateParameter, 'token'>,
  ): AsyncGenerator<Awaited<ReturnType<typeof this.findAll>>> {
    let result = await this.findAll(params);

    yield result;

    while (result.nextPageToken) {
      result = await this.findAll({ token: result.nextPageToken });
      yield result;
    }
  }
}
findAllGenerator で一度に全ページを取得する例

トークンの仕様

「カーソル型ページネーションについて」でも説明しましたが、このページネーション方法では、次のページの最初のデータのIDを保存する必要があり、それをどこに保存するかを決めなければなりません。 また、データのID以外にもフィルタリングの条件や並び順、1ページ当たりのデータ数も保存しておかないと、1ページ目とは違うリストを取得してしまいます。

本記事の実装では、これらの 諸条件をJSON文字列にし、暗号化してbase64にエンコードした文字列 をトークンとします。 暗号化することでクライアント側でトークンの内容が改変されることを防ぎ、不整合が生じないようにしています。

サーバー側の処理

基本

クライアント、トークンの仕様で察しがついた方もいるかもしれませんが、サーバー側では初回リクエスト(トークンが無いリクエスト)と2回目以降のリクエストでは、条件の取得元と取れる条件が異なります。

初回リクエスト …リクエストのクエリ文字列からフィルタリング条件、ソート順、1ページ当たりのデータ数を取得する

2回目以降のリクエスト …リクエストのトークンから、フィルタリング条件、ソート順、1ページ当たりのデータ数、データのIDを取得する

諸条件を取得出来たら、以下の処理を実行してデータの取得とトークンの作成を行います。

  1. フィルタリングとソートをする
  2. (初回)先頭から指定数分+1取得する
  3. (2回目以降)ページの最初のデータのIDから指定分+1取得する
  4. データ数がリクエストの指定数+1に満たない場合は、トークンを発行せずにレスポンスを返す
    {
      "result": [
            {
                "id": 1,
                "name": "hoge",
                "bio": "hello, world!",
                "createdAt": "2024-03-27T05:33:15.000Z",
                "updatedAt": "2024-03-27T05:33:41.000Z"
            }
      ],
      "nextPageToken": null
    }
  5. 指定数分ある場合は諸条件と+1したデータのIDをトークン化する
    {
      "result": [
            {
                "id": 1,
                "name": "hoge",
                "bio": "hello, world!",
                "createdAt": "2024-03-27T05:33:15.000Z",
                "updatedAt": "2024-03-27T05:33:41.000Z"
            },
            {
                "id": 2,
                "name": "hoge",
                "bio": "hello, world!",
                "createdAt": "2024-03-27T05:33:54.000Z",
                "updatedAt": "2024-03-27T05:33:54.000Z"
            },
            {
                "id": 3,
                "name": "hoge",
                "bio": "hello, world!",
                "createdAt": "2024-03-27T06:02:55.000Z",
                "updatedAt": "2024-03-27T06:02:55.000Z"
            }
      ],
      "nextPageToken": "ABCD"
    }

ソートカラムとカーソルカラムについて

ここまでは主キーでソートすることを前提に説明していましたが、通常ソートカラムはクライアント側から自由に指定することができます。

ソートカラムを自由に指定できる場合、ソートカラムはnullableだったり、重複する値があるかもしれません。そうなるとソートカラムだけでは一意性を保証できないため、別途カーソル用カラムとその値を使ってクエリを実行する必要があります。

実装

大まかに仕様も決まったので、ここから実装について解説します。

目標として、主キーが intstring のモデルに適用できる汎用的なページネーションを作ります。

検索対象のモデルを用意する

ページネーションをする前に、実際に検索するデータを用意しないと意味がないため、 UserPost の2つのモデルを作成します。

user.entity.ts
import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn({
    type: 'integer',
  })
  id!: number;

  @Column({
    type: 'text',
  })
  name!: string;

  @Column({
    type: 'text',
  })
  bio!: string;

  @CreateDateColumn({
    type: 'datetime',
    name: 'created_at',
  })
  createdAt!: Date;

  @UpdateDateColumn({
    type: 'datetime',
    name: 'updated_at',
  })
  updatedAt!: Date;
}

export type CreateUser = Pick<User, 'name' | 'bio'>;

export type UpdateUser = Pick<User, 'name' | 'bio'>;
user.entity.ts
post.entity.ts
import {
  Column,
  CreateDateColumn,
  PrimaryColumn,
  UpdateDateColumn,
} from 'typeorm';

export class Post {
  @PrimaryColumn({
    type: 'text',
  })
  slug!: string;

  @Column({
    type: 'text',
  })
  title!: string;

  @Column({
    type: 'text',
  })
  content!: string;

  @CreateDateColumn({
    type: 'datetime',
    name: 'created_at',
  })
  createdAt!: Date;

  @UpdateDateColumn({
    type: 'datetime',
    name: 'updated_at',
  })
  updatedAt!: Date;
}

export type CreatePost = Pick<Post, 'slug' | 'title' | 'content'>;

export type UpdatePost = Pick<Post, 'title' | 'content'>;
post.entity.ts

また、併せてサービスクラスも作成します。ここではまだページネーションを実装していないので、 findAll は全件取得するようにします。

user.service.ts
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { CreateUser, UpdateUser, User } from './user.entity';
import { InjectRepository } from '@nestjs/typeorm';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    protected readonly repo: Repository<User>,
  ) {}

  async findAll(): Promise<User[]> {
    return this.repo.find();
  }

  async findOne(id: number): Promise<User> {
    return this.repo.findOne({ where: { id } });
  }

  async create(user: CreateUser): Promise<User> {
    return this.repo.save(user);
  }

  async update(id: number, user: UpdateUser): Promise<void> {
    await this.repo.update(id, user);
  }

  async remove(id: number): Promise<void> {
    await this.repo.delete(id);
  }
}
user.service.ts
post.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { CreatePost, Post, UpdatePost } from './post.entity';
import { Repository } from 'typeorm';

@Injectable()
export class PostService {
  constructor(
    @InjectRepository(Post)
    protected readonly repo: Repository<Post>,
  ) {}

  async findAll(): Promise<Post[]> {
    return this.repo.find();
  }

  async findOne(slug: string): Promise<Post> {
    return this.repo.findOne({ where: { slug } });
  }

  async create(post: CreatePost): Promise<Post> {
    return this.repo.save(post);
  }

  async update(slug: string, post: UpdatePost): Promise<void> {
    await this.repo.update(slug, post);
  }

  async remove(slug: string): Promise<void> {
    await this.repo.delete(slug);
  }
}
post.service.ts

そして、これらのクラスを登録します。

app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UserService } from './user.service';
import { PostService } from './post.service';
import { Post } from './post.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'db.sqlite3',
      synchronize: true,
      logging: true,
      entities: [User],
    }),
    TypeOrmModule.forFeature([User, Post]),
  ],
  controllers: [AppController],
  providers: [AppService, UserService, PostService],
})
export class AppModule {}
app.module.ts

トークン発行サービスクラスの作成

いきなりページネーションを作る前に、まずはトークンに保存するデータ型の定義と、トークンをエンコード・デコードするサービスクラスを作ります。

データ型定義

トークンに保存する型は以下のように定義します。

import { ObjectLiteral } from 'typeorm';

export type Entity<T extends ObjectLiteral = ObjectLiteral> = EntityTarget<T> &
  T;

export type EntityKey<T extends ObjectLiteral> = Extract<keyof T, string>

export type InitialTokenData<T extends ObjectLiteral> = {
  size: number;
  sortBy: EntityKey<T>;
  direction: 'asc' | 'desc';
  cursorBy: EntityKey<T>;
};

export type TokenData<T extends ObjectLiteral, U> = InitialTokenData<T> & {
  sortValue: any;
  cursorValue: any;
  filterParams: U;
};
type.ts

この TokenData の各パラメータは以下のような意味を持ちます。

パラメータ名 意味
size 1ページ当たりのデータ数
sortBy ソートするカラムの名前
direction ソート方向 昇順か降順か
cursorBy カーソルで参照するカラムの名前 大抵の場合、対象のテーブルのプライマリーキーになります ( User の場合は idPost の場合は slug
sortValue 次のページの最初のデータのソートカラムの値
cursorValue 次のページの最初のデータのID
filterParams フィルタリングするパラメータ

サービスクラス

サービスクラスでは、トークンに保存するデータのエンコード、デコードを行えるようにします。

メンバー変数の keyiv はこのまま使用せず、書き換えてください。

token.service.ts
import { Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
import { Entity, TokenData } from './token-data';

@Injectable()
export class TokenService {
  // crypto.randomBytes(32).toString('base64')
  private readonly key = Buffer.from(
    'MjJLJ+R1uAie4PVY4Y4DbWb2X/FNSBSyrdDQIsymtT8=',
    'base64',
  );
  // crypto.randomBytes(16).toString('base64')
  private readonly iv = Buffer.from('JgFk9UdYVmz9oCFdAe1CmQ==', 'base64');
  private readonly method = 'aes-256-cbc' as const;
  private readonly encoding: crypto.Encoding = 'base64' as const;

  encode(param: TokenData<Entity, any>): string {
    const cipher = crypto.createCipheriv(this.method, this.key, this.iv);

    const encrypted = cipher.update(JSON.stringify(param));
    const concat = Buffer.concat([encrypted, cipher.final()]);

    const token = concat.toString(this.encoding);

    console.log('encoded token', { param, token });

    return token;
  }

  decode<T extends Entity, U>(token: string): TokenData<T, U> {
    const decipher = crypto.createDecipheriv(this.method, this.key, this.iv);

    const decrypted = decipher.update(token, this.encoding);
    const concat = Buffer.concat([decrypted, decipher.final()]);

    const data = JSON.parse(concat.toString()) as TokenData<T, U>;

    console.log('decoded token', { token, data });

    return data;
  }
}
token.service.ts

ページネーションサービスクラスの作成

ここでようやく、本題のページネーションサービスクラスを作っていきます。

ページネーションサービスのコードは以下の通りです。

paginate.service.ts
import { Injectable } from '@nestjs/common';
import {
  DataSource,
  EntityTarget,
  FindManyOptions,
  FindOperator,
  FindOptionsOrder,
  FindOptionsWhere,
  LessThanOrEqual,
  MoreThanOrEqual,
  ObjectLiteral,
  Repository,
} from 'typeorm';

import { TokenService } from './token.service.js';
import {
  Entity,
  EntityKey,
  PaginatedEntityList,
  InitialTokenData,
  PaginateQuery,
  TokenData,
  isTokenData,
} from './token-data';
import { removeEmpty } from './remove-empty';

@Injectable()
export class PaginateService {
  constructor(
    protected readonly dataSource: DataSource,
    protected readonly tokenService: TokenService,
  ) {}

  async paginate<T extends ObjectLiteral, P extends PaginateQuery<T>>(
    repository: Repository<T>,
    entity: EntityTarget<T>,
    params: P,
    token: string | null,
    filterMaker: (params: P) => FindManyOptions<T>,
  ): Promise<PaginatedEntityList<T>>;
  async paginate<T extends ObjectLiteral>(
    repository: Repository<T>,
    entity: EntityTarget<T>,
    params: PaginateQuery<Entity<T>>,
    token: string | null,
  ): Promise<PaginatedEntityList<T>>;
  async paginate<T extends ObjectLiteral, P extends PaginateQuery<Entity<T>>>(
    repository: Repository<T>,
    entity: EntityTarget<T>,
    params: P,
    token: string | null,
    filterMaker?: (params: P) => FindManyOptions,
  ): Promise<PaginatedEntityList<T>> {
    const tokenData = this.getTokenData<P>(entity, params, token);
    const baseOp = this.completeBaseOption<T, P>(filterMaker, params, token);

    const options = this.makeFindManyOptions(entity, baseOp, {
      ...tokenData,
      size: tokenData.size + 1,
    } as InitialTokenData<T> | TokenData<T, P>);

    const entities = await repository.find(options);

    let next: T | undefined = undefined;
    if (entities.length > tokenData.size) {
      next = entities.pop();
    }

    const nextPageToken = next
      ? this.makeToken<T, P>(entity, params, tokenData, next)
      : null;

    return {
      entities,
      nextPageToken,
    };
  }

  protected getTokenData<P>(
    entity: EntityTarget<ObjectLiteral>,
    paginate: PaginateQuery<Entity>,
    token: string | null,
  ): InitialTokenData<ObjectLiteral> | TokenData<ObjectLiteral, P> {
    const defaultParam: InitialTokenData<ObjectLiteral> = {
      size: 100,
      direction: 'asc',
      sortBy: this.guessPrimaryColumn(entity),
      cursorBy: this.guessPrimaryColumn(entity),
    };

    if (token) {
      try {
        return this.tokenService.decode(token);
      } catch (e) {
        return defaultParam;
      }
    }

    return {
      size: paginate.size ?? defaultParam.size,
      direction: paginate.direction ?? defaultParam.direction,
      sortBy: paginate.sortBy ?? defaultParam.sortBy,
      cursorBy: this.getUniqueSortColumn(
        entity,
        paginate.sortBy ?? defaultParam.sortBy,
      ),
    } as InitialTokenData<ObjectLiteral>;
  }

  protected makeFindManyOptions<T extends ObjectLiteral, P>(
    entity: EntityTarget<T>,
    baseOption: FindManyOptions<T>,
    tokenData: InitialTokenData<T> | TokenData<T, P>,
  ): FindManyOptions<T> {
    const where = isTokenData(tokenData)
      ? this.makeFindWhereOption<T>(
          tokenData.cursorBy,
          tokenData.direction ?? 'asc',
          tokenData.nextIdentifier,
        )
      : {};

    let mergedWhere: FindOptionsWhere<T>[] | FindOptionsWhere<T> | undefined =
      this.filterWhere(
        this.mergeFilterAndPaginateWhere(baseOption.where ?? {}, where),
      );
    if (Array.isArray(mergedWhere) && mergedWhere.length === 0) {
      mergedWhere = undefined;
    }

    return {
      ...baseOption,
      take: tokenData.size ?? undefined,
      where: mergedWhere,
      order: {
        ...baseOption.order,
        ...this.makeFindOrderOption(
          entity,
          tokenData.sortBy,
          tokenData.cursorBy,
          tokenData.direction ?? 'asc',
        ),
      },
    };
  }

  protected makeFindWhereOption<T extends ObjectLiteral>(
    cursorBy: EntityKey<T>,
    dir: 'asc' | 'desc',
    nextIdentifier: string | number,
  ): FindOptionsWhere<T> {
    const operator: FindOperator<string | number> =
      dir === 'asc'
        ? (MoreThanOrEqual(nextIdentifier) as FindOperator<string | number>)
        : (LessThanOrEqual(nextIdentifier) as FindOperator<string | number>);

    return {
      [cursorBy]: operator,
    } as FindOptionsWhere<T>;
  }

  protected mergeFilterAndPaginateWhere<T>(
    filter: FindOptionsWhere<T>[] | FindOptionsWhere<T>,
    paginate: FindOptionsWhere<T>,
  ): FindOptionsWhere<T>[] | FindOptionsWhere<T> {
    if (Array.isArray(filter)) {
      return filter.map((w) => ({ ...w, ...paginate }));
    }

    return { ...filter, ...paginate };
  }

  protected filterWhere<T extends Entity>(
    where: FindOptionsWhere<T>[] | FindOptionsWhere<T>,
  ): FindOptionsWhere<T>[] | FindOptionsWhere<T> {
    if (!Array.isArray(where)) return where;

    return where
      .map(removeEmpty)
      .filter(
        (w): w is FindOptionsWhere<T> =>
          w !== undefined && Object.keys(w).length > 0,
      );
  }

  protected makeFindOrderOption(
    entity: EntityTarget<ObjectLiteral>,
    sortBy: EntityKey<ObjectLiteral>,
    cursorBy: EntityKey<ObjectLiteral>,
    direction: 'asc' | 'desc',
  ): FindOptionsOrder<ObjectLiteral> {
    if (!this.checkColumnExists(entity, sortBy)) {
      return {};
    }

    return {
      [sortBy]: direction,
      [cursorBy]: direction,
    } as FindOptionsOrder<Entity>;
  }

  protected makeToken<T extends ObjectLiteral, P>(
    entity: EntityTarget<T>,
    filterParams: P | undefined,
    paginate: InitialTokenData<T>,
    nextEntity: T,
  ): string {
    const column = this.getUniqueSortColumn(entity, paginate.sortBy);
    const nextIdentifier = nextEntity[column as keyof T] as string | number;

    return this.tokenService.encode({
      size: paginate.size,
      direction: paginate.direction,
      sortBy: paginate.sortBy,
      cursorBy: column,
      nextIdentifier,
      filterParams: Object.keys(filterParams ?? {})
        .filter(
          (key) =>
            ![
              'nextPageToken',
              'size',
              'direction',
              'sortBy',
              'cursorBy',
            ].includes(key),
        )
        .reduce((obj, key) => {
          obj[key] = (filterParams ?? {})[key];
          return obj;
        }, {} as any),
    });
  }

  protected completeBaseOption<T extends ObjectLiteral, P>(
    filterMaker: ((params: P) => FindManyOptions<T>) | undefined,
    filterParams: P | undefined,
    pageToken: string | undefined,
  ): FindManyOptions<T> {
    const makeOptions = (params?: P): FindManyOptions<T> =>
      filterMaker && filterParams ? filterMaker(params ?? filterParams) : {};

    if (!pageToken) return makeOptions();

    try {
      const decoded = this.tokenService.decode<Entity, any>(pageToken);
      return makeOptions(decoded.filterParams);
    } catch (e) {
      return makeOptions();
    }
  }

  protected getUniqueSortColumn(
    entity: EntityTarget<ObjectLiteral>,
    sortBy: EntityKey<Entity>,
  ): EntityKey<Entity> {
    const isUniqueSortableColumn =
      this.isUniqueColumn(entity, sortBy) &&
      this.isNotNullColumn(entity, sortBy);

    return isUniqueSortableColumn ? sortBy : this.guessPrimaryColumn(entity);
  }

  protected isUniqueColumn(
    entity: EntityTarget<ObjectLiteral>,
    column: EntityKey<ObjectLiteral>,
  ): boolean {
    const meta = this.dataSource.getMetadata(entity);

    if (this.guessPrimaryColumn(entity) === column) return true;

    return meta.indices.some((i) => {
      return i.columns.map((c) => c.propertyName).includes(column);
    });
  }

  protected isNotNullColumn(
    entity: EntityTarget<ObjectLiteral>,
    column: EntityKey<Entity>,
  ): boolean {
    const meta = this.dataSource.getMetadata(entity);

    return meta.columns.some((c) => c.propertyName === column && !c.isNullable);
  }

  protected guessPrimaryColumn(
    entity: EntityTarget<ObjectLiteral>,
  ): EntityKey<Entity> {
    const meta = this.dataSource.getMetadata(entity);

    const columns = meta.primaryColumns;
    if (columns.length > 1) {
      throw new Error(
        `${this.constructor.name} supports only one primary column. but ${meta.targetName} has multiple primary columns.`,
      );
    }

    return columns[0].propertyName as EntityKey<Entity>;
  }

  protected checkColumnExists(
    entity: EntityTarget<ObjectLiteral>,
    column: EntityKey<Entity>,
  ): boolean {
    const meta = this.dataSource.getMetadata(entity);
    return meta.columns.some((c) => c.propertyName === column);
  }
}
paginate.service.ts

このサービスでは、以下の順番で処理を実行します。

1. 検索対象のモデルのリポジトリとフィルター生成関数、パラメータ、トークンを受ける

処理を汎用的にするため、ページネーションのパラメータの他、リポジトリやフィルター生成関数( filterMaker )も受けます。

フィルタリングなどの条件を入れない場合は、フィルター生成関数の指定を省略できるようにしています。

async paginate<T extends ObjectLiteral, P extends PaginateParam<Entity<T>>>(
  repository: Repository<T>,
  entity: EntityTarget<T>,
  params: P,
  token: string | null,
  filterMaker?: (params: P) => FilterType<T>,
): Promise<PaginatedEntityList<T>> {
   // ...
}

2. パラメータからTypeORMの FilterType を作成する

1で引き受けたフィルター関数にパラメータを入れて FilterType を作成します。

protected makeFilterFindOptions<T extends ObjectLiteral, P>(
  filterMaker: ((params: P) => FilterType<T>) | undefined,
  filterParams: P,
  tokenData: InitialTokenData<T> | TokenData<T, P>,
): FilterType<T> {
  if (isTokenData(tokenData)) {
    if (filterMaker) {
      return filterMaker(tokenData.filterParams);
    }
  } else {
    if (filterMaker) {
      return filterMaker(filterParams);
    }
  }

  return {};
}

FilterType は新しく定義した型で、 FindManyOptions からいくつかのパラメータをオミットしたパラメータになります。

export type FilterType<T extends ObjectLiteral> = Omit<
  FindManyOptions<T>,
  'skip' | 'take' | 'order'
>;

フィルター生成関数ではなく、 FilterType をそのまま引数に受けてもよさそうですが、 where パラメータに RawLIKE のような関数が入っていると、トークン化する際にシリアライズできないため、このような形で実装します。

import { Raw } from "typeorm"

const loadedPosts = await dataSource.getRepository(Post).findBy({
    likes: Raw("dislikes - 4"),
});
シリアライズできない例

パラメータとトークンを両方受けた場合、トークンの値を優先するようにします。

3. カーソル型ページネーション用の条件を盛り込んだ FindOptionsWhere を作成する

カーソル型ページネーションには、カーソルの値以上のレコードを取得する条件を必ず挿入する必要があるため、そのための FindOptionsWhere を作成します。

protected makePaginationFindOptions<T extends ObjectLiteral>(
  dir: 'asc' | 'desc',
  cursorBy: EntityKey<T>,
  cursorValue: any,
  sortBy: EntityKey<T>,
  sortValue: any,
): FindOptionsWhere<T>[] {
  const op = (value: any) =>
    dir === 'asc' ? MoreThanOrEqual(value) : LessThanOrEqual(value);

  return [
    {
      [sortBy]: op(sortValue),
    },
    {
      [sortBy]: Equal(sortValue),
      [cursorBy]: op(cursorValue),
    },
  ] as FindOptionsWhere<T>[];
}

また、ソートカラムが指定されていない場合は主キー昇順ソートし、ページ当たりのデータ数が指定されていない場合は100件に制限するようにします。

4. 2と3をマージする

2と3で作成した FindOptionsWhere をマージします。

protected mergeFilterAndPaginateWhere<T>(
  filter: FindOptionsWhere<T>[] | FindOptionsWhere<T>,
  paginate: FindOptionsWhere<T>[],
): FindOptionsWhere<T>[] | FindOptionsWhere<T> {
  if (!Array.isArray(filter)) {
    return paginate.map((p) => ({ ...filter, ...p }));
  }

  const removedEmpty = filter
    .map(removeEmpty)
    .filter(
      (w): w is FindOptionsWhere<T> =>
        w !== undefined && Object.keys(w).length > 0,
    );

  if (paginate.length === 0 && removedEmpty.length === 0) {
    return [];
  }

  if (paginate.length === 0) {
    return removedEmpty;
  }

  if (removedEmpty.length === 0) {
    return paginate;
  }

  if (removedEmpty.length === 1) {
    return paginate.map((p) => ({ ...removedEmpty[0], ...p }));
  }

  return removedEmpty
    .map((w) => paginate.map((p) => ({ ...w, ...p })))
    .flat();
}

3で作成した条件の方が優先度が高いため、2と3で同じようなクエリがある場合は3の内容で上書きされます。

5. クエリを実行する

4で作成した FindWhereOptions でクエリを実行します。

const entities = await repository.find(options);

6. トークンを生成する

実行結果より、取得できたデータが指定数に満たしていた場合はトークンを生成します。

let next: T | undefined = undefined;
if (entities.length > tokenData.size) {
  next = entities.pop();
}

const nextPageToken = next
  ? this.makeToken<T, P>(entity, params, tokenData, next)
  : null;

トークンを生成する際、カーソルカラムを推測してトークンに含める必要があります。

カーソルカラムはまずソートカラムがカーソルカラムとして使用できるかをチェックします。使用できる条件は、

  1. ユニーク属性が付いている
  2. nullableでない

の2つです。

もしソートカラムがこの条件を満たしていない場合は、エンティティから主キーのカラム名を取得して、カーソルカラムとします。

ページネーション組み込み

作成したページネーションサービスを早速 UserPost のサービスに組み込みます。

あまり特筆して書くべきところでもないので、必要そうなところをかいつまんで載せます。

全文を見たい方は リポジトリ にあるので、そちらをご確認ください。

user.service.ts
export type UserFilterParam = Partial<Pick<User, 'username' | 'name' | 'bio'>>;

export type UserPaginateParam = PaginateQuery<User> & UserFilterParam;

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    protected readonly repo: Repository<User>,
    protected readonly paginate: PaginateService,
  ) {}

  async findAll(
    token: string | null,
    params?: UserPaginateParam,
  ): Promise<PaginatedEntityList<User>> {
    return this.paginate.paginate<User, UserPaginateParam>(
      this.repo,
      User,
      params,
      token,
      (p) => ({
        where: [
          { username: p.username ? Like(`%${p.username}%`) : undefined },
          { name: p.name ? Like(`%${p.name}%`) : undefined },
          { bio: p.bio ? Like(`%${p.bio}%`) : undefined },
        ],
      }),
    );
  }
}
user.service.ts
user.controller.ts
@Controller('users')
export class UserController {
  constructor(protected readonly userService: UserService) {}

  @Get('/')
  async index(
    @Query('size', new ParseIntPipe({ optional: true }))
    size: number | undefined,
    @Query('sortBy') sortBy: EntityKey<User> | undefined,
    @Query('direction') direction: 'asc' | 'desc' | undefined,
    @Query('token') token: string | undefined,
    @Query('username') username: string | undefined,
    @Query('name') name: string | undefined,
    @Query('bio') bio: string | undefined,
  ): Promise<PaginatedEntityList<User>> {
    return await this.userService.findAll(token ?? null, {
      size,
      sortBy,
      direction,
      username,
      name,
      bio,
    });
  }
}

実行

全て実装出来たら、さっそく実行して動作確認します。

まずは、以下の User データ4件を作成します。

[
  {
      "id": 1,
      "username": "hoge",
      "name": "hoge",
      "bio": "hello, world!",
      "createdAt": "2024-03-27T11:47:57.000Z",
      "updatedAt": "2024-03-27T11:47:57.000Z"
  },
  {
      "id": 2,
      "username": "fuga",
      "name": "fuga",
      "bio": "hello, world!",
      "createdAt": "2024-03-27T11:48:06.000Z",
      "updatedAt": "2024-03-27T11:48:06.000Z"
  },
  {
      "id": 3,
      "username": "piyo",
      "name": "piyo",
      "bio": "hello, world!",
      "createdAt": "2024-03-27T11:48:14.000Z",
      "updatedAt": "2024-03-27T11:48:14.000Z"
  },
  {
      "id": 4,
      "username": "piyopiyo",
      "name": "piyopiyo",
      "bio": "hello, world!",
      "createdAt": "2024-03-27T11:53:13.000Z",
      "updatedAt": "2024-03-27T11:53:13.000Z"
  }
]

この状態で GET /users エンドポイントにクエリパラメータの無いリクエストを送信します。

送信すると、以下のようなレスポンスが返ります。

{
    "entities": [
        {
            "id": 1,
            "username": "hoge",
            "name": "hoge",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:47:57.000Z",
            "updatedAt": "2024-03-27T11:47:57.000Z"
        },
        {
            "id": 2,
            "username": "fuga",
            "name": "fuga",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:48:06.000Z",
            "updatedAt": "2024-03-27T11:48:06.000Z"
        },
        {
            "id": 3,
            "username": "piyo",
            "name": "piyo",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:48:14.000Z",
            "updatedAt": "2024-03-27T11:48:14.000Z"
        },
        {
            "id": 4,
            "username": "piyopiyo",
            "name": "piyopiyo",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:53:13.000Z",
            "updatedAt": "2024-03-27T11:53:13.000Z"
        }
    ],
    "nextPageToken": null
}

ページ当たりのデータ数を指定しない場合は、データを100件取得しようとしますが、今回は4件しかなかったため、 nextPageTokennull になります。

次に、 size=3 をクエリパラメータに入れてリクエストを送しっかりと3件だけ取得し、 nextPageToken にトークンがあります。

{
    "entities": [
        {
            "id": 1,
            "username": "hoge",
            "name": "hoge",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:47:57.000Z",
            "updatedAt": "2024-03-27T11:47:57.000Z"
        },
        {
            "id": 2,
            "username": "fuga",
            "name": "fuga",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:48:06.000Z",
            "updatedAt": "2024-03-27T11:48:06.000Z"
        },
        {
            "id": 3,
            "username": "piyo",
            "name": "piyo",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:48:14.000Z",
            "updatedAt": "2024-03-27T11:48:14.000Z"
        }
    ],
    "nextPageToken": "6/pSU2Lge9Xmn83NHVWmpZWG6Ll5HD+BElvwuC1FQjybDvu8t1QFxubBXmSIJrNmm2DwMc/RGQiWj1wUJ3k7Z0EwTppps9uzwkA08j5cbpV2f8dgc1pVqfFscktS07Yh"
}

この nextPageTokentoken クエリパラメータとして送信します。Postmanを使っている場合は + が空白に置き換えられることに注意してください。もし置き換えてしまう場合は、 +%2B に置き換えてから送信します。

tokenを送信すると、以下のレスポンスが返るはずです。

{
    "entities": [
        {
            "id": 4,
            "username": "piyopiyo",
            "name": "piyopiyo",
            "bio": "hello, world!",
            "createdAt": "2024-03-27T11:53:13.000Z",
            "updatedAt": "2024-03-27T11:53:13.000Z"
        }
    ],
    "nextPageToken": null
}

他にも、フィルター条件やソート条件などを変えて実行すると、その条件に沿った実行結果が得られるはずです。

まとめ

うまく説明できたか分かりませんが、本記事では、TypeORMとNestJSをベースにカーソル型ページネーションを作成しました。最後に実装ポイントとステップアップのための改善ポイントをまとめたので、ご興味があればご覧ください。

実装ポイント

  1. 次のページの最初のレコードを特定する一意な値とそのレコードのソート値を保存する
  2. トークンに検索条件などを保存する
  3. フィルター条件とページネーション条件を合わせたSQLクエリを作り実行する
  4. 次のページが存在するか確認し、トークンを生成して返す

ステップアップ

本記事の内容をベースにより高度なページネーションを構築するとした場合、以下の改善案が考えられます。

  • 複合主キーへの対応
  • より柔軟で高度なフィルター機能の実装

Hirayama

Hirayama slash forward icon Engineer

業務では主にPHPやTypeScriptを使用したバックエンドアプリケーションやデスクトップアプリケーションの開発をしています。趣味は登山。

関連記事