【Laravel】Eloquentでシーク法ページネーション

# シーク法とは

`offset`を**用いずに**ページネーションをする仕組みです。
`offset`を使用する方法は、sqlがシンプルな反面、データ数が大きくなるにつれて処理が重くなるというデメリットがあります。
対してシーク法は、やや複雑なsqlになりますが、データ数が大きくなっても高速で取得できるというメリットがあります。

シーク法の概要については、以下の記事が非常にわかりやすいです。
[offsetでページネーションは遅い。これからはシーク法だ！](https://qiita.com/madilloar/items/b4e786a932ef9d4551b9)

# Laravelで実装

Laravelの`Eloquent`でシーク法を実装します。
実装内容の概要は以下になります。

**実装内容**

* `posts`：投稿記事テーブル
* `posts`を投稿日時（`created_at`）の降順で取得する

本題のシーク法へ入る前に、まずは従来のオフセット法によるページネーションを見てみましょう。

## オフセット法

従来の`offset`でのページネーションだと、以下のようになります。

```php:PostController.php
public function index(Request $request): JsonResponse
{
    $perPage = 10; // 1ページあたりの取得件数

    // 現在のページ番号
    $page = $request->get('page');

    $posts = Post::select(['*'])
        ->orderByDesc('created_at')
        ->offset(($page - 1) * $perPage)
        ->limit($perPage)
        ->get();
        
        // 以下省略
```

## シーク法

続いてが本題です。これをシーク法で実装すると、次のようになります。

```php:PostController.php
    public function index(Request $request): JsonResponse
    {
        $perPage = 10; // 1ページあたりの取得件数
        
        $postQuery = Post::select(['*'])
            ->orderByDesc('created_at')
            ->orderByDesc('id')
            ->limit($perPage);

        // lastPostIdに前回取得時の最後尾のposts.idが入る
        $lastPostId = $request->get('lastPostId');
        if (isset($lastPostId)) {
            $lastPost = Post::find($lastPostId);
            $postQuery->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($lastPost) {
                $query
                    ->where('created_at', '<', $lastPost->created_at)
                    ->orWhere(function (\Illuminate\Database\Eloquent\Builder $query) use ($lastPost) {
                        $query
                            ->where('created_at', $lastPost->created_at)
                            ->where('id', '<', $lastPost->id);
                    });
            });
        }

        $posts = $postQuery->get();

        // 以下省略
```

これでシーク法によるページネーションが実現できました。
シーク法では`offset`は使わず、`where`でページネーションを行います。同じタイムスタンプの投稿が複数ある場合を考慮し、一意である`id`でもソートする必要があります。
そのため`created_at`だけでなく`id`でも`orderBy`する必要がある点や、`where`や`orWhere`など多くてややこしいですね。
しかし一度理解してしまえば非常に有用なので、ぜひ覚えておきましょう。

### なぜシーク法が早いのか？

ざっくり言うと、オフセット法の場合は**100件目までスキップするイメージ**、シーク法は**カーソルの位置を記憶するイメージ**です。
オフセット法の場合はスキップ数が増えるほど処理も重くなりますが、シーク法はページ件数に関係なく取得ができるのです。

## havingを用いる場合

シーク法のソート対象カラムについて、`where`ではなく`having`を用なければならない場合があります。（例：リレーション先のカウント数でのソートなど）
ここでは、以下の内容で`having`によるシーク法を行います。

**実装内容**
* `posts`：投稿記事テーブル
* `post_like_users`：投稿記事に「いいね」をしたユーザーを記録するテーブル
* `posts`にはHasManyで`post_like_users`が紐づいている（リレーション名：`likes`）
* いいね数（`likes_count`）の降順で取得する

ソート対象の`likes_count`は`where`で扱うことができず、`having`を用いる必要があります。
これでページネーション実装すると、以下のようになります。

```php:PostController.php
    public function index(Request $request): JsonResponse
    {
        $perPage = 10; // 1ページあたりの取得件数
        
        $postQuery = Post::select(['*', 'posts.id AS alias_id']) // idをhavingで使用するためにalias_idとする
            ->withCount('likes')
            ->orderByDesc('likes_count') // withCount('likes')により、likes_countが使用可能
            ->orderByDesc('id')
            ->limit($perPage);

        // lastPostIdに前回取得時の最後尾のposts.idが入る
        $lastPostId = $request->get('lastPostId');
        if (isset($lastPostId)) {
            $lastPost = Post::withCount('likes')->find($lastPostId);
            $postQuery->having(function (\Illuminate\Database\Query\Builder $query) use ($lastPost) {
                $query
                    ->having('likes_count', '<', $lastPost->likes_count)
                    ->orHaving(function (\Illuminate\Database\Query\Builder $query) use ($lastPost) {
                        $query
                            ->having('likes_count', $lastPost->likes_count)
                            ->having('alias_id', '<', $lastPost->id);
                    });
            });
        }

        $posts = $postQuery->get();
        
        // 以下省略
```

基本的には`where`の場合とあまり変わらないのですが、`id`の比較部分が`where`の場合と少し異なりますね。

```php
->having('alias_id', '<', $lastPost->id);
```

`where`ではなく`having`を用いる必要があるので、`select`で定義しておいた`alias_id`で行っています。

# まとめ

シーク法は、`offset`を使用しないことで**大量データでも高速にページネーションができる手法**です。
特にデータ数が多いシステムでは、オフセット法に比べてパフォーマンス面で大きなメリットがあります。

本記事では、LaravelのEloquentでの基本的なシーク法の実装から、`having`を使った応用例まで紹介しました。

**ポイント**

* `offset`を使わず、前回取得した最後のレコード情報（カーソル）を基準に次のデータを取得する
* `created_at`だけでは重複を考慮しきれないため、`id`でもソートする必要がある
* ソート対象がリレーション先のカウントなどの場合は、`having`を使ったシーク法が必要

シーク法を使うことで、APIの応答速度やパフォーマンスが向上するため、大規模なデータを扱う場面では積極的に取り入れていきたいですね。

# 告知
最後にお知らせとなりますが、イーディーエーでは一緒に働くエンジニアを
募集しております。詳しくは採用情報ページをご確認ください。

https://eda-inc.jp/recruit/

みなさまからのご応募をお待ちしております。