Produced by FOURIER

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

HirayamaHirayama calender 2024.4.10

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

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

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

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

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

構成

以下のライブラリやランタイムを使用しています。データベースは 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 {}

上記設定で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;
    }
  }
}

トークンの仕様

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

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

サーバー側の処理

基本

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

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

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

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

  1. フィルタリングとソートをする
  1. (初回)先頭から指定数分+1 取得する
  1. (2 回目以降)ページの最初のデータの ID から指定分+1 取得する
  1. データ数がリクエストの指定数+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
}
  1. 指定数分ある場合は諸条件と+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">;

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

また、併せてサービスクラスも作成します。ここではまだページネーションを実装していないので、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);
  }
}

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

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

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

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

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

データ型定義

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

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

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

パラメータ名意味
size1 ページ当たりのデータ数
sortByソートするカラムの名前
directionソート方向
昇順か降順か
cursorByカーソルで参照するカラムの名前
大抵の場合、対象のテーブルのプライマリーキーになります
(User の場合は id、Post の場合は 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;
  }
}

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

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

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

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

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

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.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 / Engineer

1997年生まれ、南伊豆出身。学生時代にC#で画像処理アプリケーションを作ったりしていました。業務では主にLaravelを使用してサーバーサイドのプログラミングをしています。趣味はドライブとシミュレーションゲーム。