【Drizzle】複合キーでのカーソルページネーション
こんにちは、フリーランスエンジニアの太田雅昭です。
分かれば簡単なのですが、若干理解に時間のかかるかもしれない、複合キーでのカーソルページネーションについて考えます。
ページネーション
ページネーションは、よくある下記のようなものです。
前へ 1 2 3 4 次へ
検索結果を小出しにする時などに使用します。データベースでは下記のようなパラメータを用います。
- limit: ページごとの数
- offset: どれだけ飛ばすか
これが一般的なページネーションです。
実装は比較的簡単ですが、デメリットもあります。レコード数が膨大になると、計算に時間がかかってしまいます。なぜならoffsetが常に先頭から計算されるため、後になるほど負荷が大きくなります。また通常は総数も同時に用いるため、そこの計算コストもかかってきます。
カーソルページネーション
カーソルページネーションは、上記のページネーションの問題点を解決します。一長一短ですが、レコード数が膨大になることが予想される場合は、こちらのカーソルページネーションが用いられます。
前へ 次へ
通常のページネーションと違い、ページ数がありません。これは通常、総数を用いないためです。その代わりに計算コストを抑えることができます。
単一カーソルの例
次の場合を考えます。
person table
- id: auto increment
- name: string
オートインクリメントのidと、文字列のnameをもったレコードです。これをカーソルページネーション実装するには、下記のようになります。
- idでソート
- 特定idを基準にwhereを作る
たとえば id = 3 をカーソルとすると、下記のようになります。
SELECT * from person where id > 3 ORDER BY id
これにより、idが3より大きいレコードが抽出されます。idでソートされているため、順番も確保されます。なおこれは、idがuniqueであることが前提です。一方でuniqueではないフィールドでソートするには、どうすればいいでしょう。
複合カーソルの例
以下のようなテーブルを考えます。
person table
- id: auto increment
- name: string
- age: number
ageでソートするとどうなるでしょう。試しに
SELECT * from person ORDER BY age
とします。しかしageが同じであるレコードが複数存在することが予測されます。そうすると、同じage=18のレコードの中で、順番が確保されません。このままではカーソルページネーションを行うことができません。
そのため、order部分を以下のようにします。
ORDER BY age, id
idがuniqueであるため、これで順番が保たれるようになります。
この場合、カーソルページネーションを用いるには、以下のようにします。
- age, idで複合ソート
- ageでwhereを作る
- age + idでもwhereを作る
考え方としては、
ageが18より大きければ、同じ年齢帯はヒットしないため問題ない
同じ年齢帯の場合は、idで処理する
たとえばid=3, age=18の人物の次のページネーションを行うには、whereは次のようになります。
WHERE (age > 18) OR (age = 18 AND id > 3)
最終的には下記のようになります。
SELECT * FROM person WHERE (age > 18) OR (age = 18 AND id > 3) ORDER BY age, id
Drizzleでの複合キーのカーソルページネーション
Drizzleでは以下のようになります。なおlimit+1で取得して、実際に次のページがあるかどうかの判定に用いるパターンもありますが、今回はシンプルにしました。
type Cursor = {
id: string;
age: number;
}
export async function getUsers(params: { limit: number, cursor?: Cursor }) {
const { limit, cursor } = params;
const users = await db().query.usersTable.findMany({
where: cursor
? or(
lt(usersTable.age, cursor.age),
and(
eq(usersTable.age, cursor.age),
lt(usersTable.id, cursor.id)))
: undefined,
orderBy: [desc(usersTable.age), desc(usersTable.id)],
limit,
})
// 次のページがあるかどうか
const hasMore = users.length === limit;
// 次のページのカーソルを取得
const lastUser = users[users.length - 1]
const nextCursor: Cursor | undefined = hasMore
? {
id: lastUser.id,
age: lastUser.age
}
: undefined;
return { ok: true, data: { users, nextCursor } }
}
面倒なので共通化した
毎回ここまでコードを書くとメンテナンス性が落ちるため、共通化しました。
import { and, asc, desc, eq, gt, lt, or } from "drizzle-orm";
export function createCursorPagination<T>(params: { order: 'asc' | 'desc', table: any, cursor: any | undefined, field: keyof T, uniqueField: keyof T, limit: number }) {
const { order, table, cursor, field, uniqueField, limit } = params;
const ltOrGt = { 'asc': gt, 'desc': lt }[order];
const ascOrDesc = { 'asc': asc, 'desc': desc }[order]
const orderBy = [ascOrDesc(table[field]), ascOrDesc(table[uniqueField])]
const where = cursor
? or(
ltOrGt(table[field], cursor[field]),
and(
eq(table[field], cursor[field]),
ltOrGt(table[uniqueField], cursor[uniqueField])
))
: undefined;
const getNextCursor = (items: T[]): T | undefined => {
if (items.length < limit) return;
const lastItem = items[items.length - 1]
if (!lastItem) return;
return { [field]: lastItem[field], [uniqueField]: lastItem[uniqueField] } as T
}
return { where, orderBy, limit, getNextCursor }
}
これを使えば、以下のようにスッキリ書けるようになります。
type Cursor = Pick<User, 'id'|'age'>
export async function getUsers(params: { limit: number, cursor?: Cursor }) {
const { limit, cursor } = params;
const { getNextCursor, ...rest } = createCursorPagination<Cursor>({
order: 'asc',
table: usersTable,
cursor,
field: 'age',
uniqueField: 'id',
limit
})
const users = await db().query.usersTable.findMany({
...rest
})
const nextCursor = getNextCursor(users);
return { ok: true, data: { users, nextCursor } }
}