🎻

シンプルでカスタマイズしやすい最強のページネーションバンドル ttskch/paginator-bundle のご紹介 2023

2023/12/24に公開

メリークリスマスイブ!🎅🎁

Symfony Advent Calendar 2023 の24日目の記事です!🎄✨

Twitter (X) でもちょいちょいSymfonyネタを呟いてます。よろしければ フォロー お願いします🤲

ttskch/paginator-bundle

ダイマです。拙作のシンプルでカスタマイズしやすい最強のページネーションバンドル ttskch/paginator-bundle を紹介させてください。

https://github.com/ttskch/TtskchPaginatorBundle

こんな感じの美しいページネーションが簡単に実装できます。

僕自身が実務でページネーションを実装するにあたって、既存のどのバンドルにも満足できなかったので自作しました。

最初のバージョンを公開してからもう3年半ぐらい経っていて未だに実務でバリバリ使ってるんですが、自分でもとても満足しているので、ぜひみなさんにも使ってみてほしい所存です!

デモ

https://ttskchpaginatorbundle.herokuapp.com

こちらで実際に動いているサンプルを触れます。

特徴

  • 超軽量
  • 型安全(PHPStan level max)
  • SymfonyとTwig以外には一切依存なし
  • にもかかわらず、ほとんどのSymfonyプロジェクトで採用されているDoctrine ORMとの連携がめっちゃ簡単
  • 他にも任意のデータ構造を(自分でコールバックを書くことで)ページネート可能
  • ページャー部分のHTMLはTwigで簡単にカスタマイズ可能
  • 見出し部分を簡単にソートのためのリンクにできる(ここのHTMLもTwigでカスタマイズ可能)
  • 検索フォームとの連動も簡単
  • Bootstrap 4/5ベースの美しいテンプレートをプリセット

という感じのナイスなバンドルです。

  • バンドル固有のコードが少なく済んで、いざとなったらいつでも捨てて別の実装に移行できる
  • だけどやりたいことは全部できる

というのを意識して作っています。

いつでも捨てられるというのが特に大事だと思っていて、実際に「バンドルを使わずに自分で実装したとしてもどうせ書かないといけないようなもの」だけしか提供していません。

もし普段からページネーションを自力で書いているという方がいたら、試しに使ってみていただけると嬉しいです!

動作要件

最新バージョンである v6 系は

  • PHP: ^8.0
  • Symfony: ^5.0|^6.0|^7.0

です。

インストール

普通にComposerでインストールして、bundles.php に追加してください。

$ composer require ttskch/paginator-bundle
// config/bundles.php

return [
    // ...
    Ttskch\PaginatorBundle\TtskchPaginatorBundle::class => ['all' => true],
];

Doctrine ORMとあわせて使う

ttskch/paginator-bundle 自体はDoctrine ORMにロックインはしていませんが、Doctrine ORMとあわせて使うためのユーティリティをプリセットとして提供しているので、それを使えばとても少ない変更で簡単にページネーションを導入できます。

例えば、以下のようなよくある一覧画面の場合なら、ttskch/paginator-bundle 導入前後のコードはこんな感じになります。

Before

// FooController.php

use Symfony\Component\HttpFoundation\Response;

public function index(FooRepository $fooRepository): Response
{
    return $this->render('index.html.twig', [
        'foos' => $fooRepository->findAll(),
    ]);
}
{# index.html.twig #}

<table>
  <thead>
  <tr>
    <th>Id</th>
    <th>Name</th>
    <th>Email</th>
  </tr>
  </thead>
  <tbody>
  {% for foo in foos %}
    <tr>
      <td>{{ foo.id }}</td>
      <td>{{ foo.name }}</td>
      <td>{{ foo.email }}</td>
    </tr>
  {% endfor %}
  </tbody>
</table>

After

// FooController.php

use Symfony\Component\HttpFoundation\Response;
use Ttskch\PaginatorBundle\Counter\Doctrine\ORM\QueryBuilderCounter;
use Ttskch\PaginatorBundle\Criteria\Criteria;
use Ttskch\PaginatorBundle\Paginator;
use Ttskch\PaginatorBundle\Slicer\Doctrine\ORM\QueryBuilderSlicer;

/**
 * @param Paginator<\Traversable<array-key, Foo>, Criteria> $paginator
 */
public function index(FooRepository $fooRepository, Paginator $paginator): Response
{
    $qb = $fooRepository->createQueryBuilder('f');
    $paginator->initialize(new QueryBuilderSlicer($qb), new QueryBuilderCounter($qb), new Criteria('id'));

    return $this->render('index.html.twig', [
        'foos' => $paginator->getSlice(),
    ]);
}
{# index.html.twig #}

<table>
  <thead>
  <tr>
    <th>{{ ttskch_paginator_sortable('id', 'Id') }}</th>
    <th>{{ ttskch_paginator_sortable('name', 'Name') }}</th>
    <th>{{ ttskch_paginator_sortable('email', 'Email') }}</th>
  </tr>
  </thead>
  <tbody>
  {% for foo in foos %}
    <tr>
      <td>{{ foo.id }}</td>
      <td>{{ foo.name }}</td>
      <td>{{ foo.email }}</td>
    </tr>
  {% endfor %}
  </tbody>
</table>

{{ ttskch_paginator_pager() }}

以下の部分

$paginator->initialize(new QueryBuilderSlicer($qb), new QueryBuilderCounter($qb), new Criteria('id'));

で、$paginator が内部的に handleRequest() を実行して、URLクエリパラメータをもとにページネーションを実行してくれます。

QueryBuilderSlicerQueryBuilderCounter はそれぞれ、

  • 現在のページの Foo エンティティのコレクションを作成するための処理
  • Foo エンティティの総数を算出するための処理

を担っています。

また、Criteria は、URLクエリパラメータとして送信されたページネーションの情報(ページ番号や1ページあたりの表示件数など)をオブジェクトの形で保持してくれるやつです。コンストラクタ引数に渡している 'id' は、デフォルトのソート項目です。

これらが ttskch/paginator-bundle が用意してくれているDoctrine ORM用のユーティリティです。

ビュー側では、

  • ttskch_paginator_sortable() 関数でソートのためのリンクを出力
  • ttskch_paginator_pager() 関数でページャーを出力

しています。

ビューのテンプレートを変更する

ビューのテンプレートは設定で自由に変更できます。

# config/packages/ttskch_paginator.yaml

ttskch_paginator:
  template:
    pager: 'your/own/pager.html.twig'
    sortable: 'your/own/sortable.html.twig'

プリセットされているBootstrap 5スタイルのページャーを使いたいだけなら、以下のようにすればOKです。

# config/packages/ttskch_paginator.yaml

ttskch_paginator:
  template:
    pager: '@TtskchPaginator/pager/bootstrap5.html.twig'

検索フォームとあわせて使う

検索フォームとあわせて使う場合は、リポジトリクラスなどに QueryBuilder の組み立てを書いて、それをコールバックとして渡します。

このとき、URLクエリパラメータとして送信された検索条件やページネーション情報をいちいちリクエストオブジェクトから取り出したりする必要はありません。先ほど登場した Criteria というオブジェクトがそれらを保持する役割を担っていて、コントローラで $paginator->initialize() したときにURLクエリパラメータから値が適切にセットされます。

デフォルトの Criteria はページネーション情報だけしか持たないため、これを継承して検索条件の情報を持つようにした独自の FooCriteria を作るところから始めます。

今回は例として、文字列で検索するための query というプロパティを追加してみます。

// FooCriteria.php

use Ttskch\PaginatorBundle\Criteria\AbstractCriteria;

class FooCriteria extends AbstractCriteria
{
    public ?string $query = null;

    public function __construct(string $sort)
    {
        parent::__construct($sort);
    }

    public function getFormTypeClass(): string
    {
        return FooSearchType::class;
    }
}

続いて、この FooCriteria に対応する FooFormType を作ります。ttskch/paginator-bundle が提供しているデフォルトの CriteriaType を継承することで、ページネーション情報に関する処理を気にせず、自分が追加した検索項目のことだけを考えればいいようになっています。

// FooSearchType.php

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Ttskch\PaginatorBundle\Form\CriteriaType;

class FooSearchType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('query', SearchType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => FooCriteria::class,
            // symfony/security-csrf がインストールされている環境では以下が必要
            // 'csrf_protection' => false,
        ]);
    }

    public function getParent(): string
    {
        return CriteriaType::class;
    }
}

次に、リポジトリクラスなどに FooCriteria をもとに QueryBuilder を組み立てるメソッドを作ります。

// FooRepository.php

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;

/**
 * @extends ServiceEntityRepository<Foo>
 */
class FooRepository extends ServiceEntityRepository
{
    // ...

    private function createQueryBuilderFromCriteria(FooCriteria $criteria): QueryBuilder
    {
        return $this->createQueryBuilder('f')
            ->orWhere('f.name like :query')
            ->orWhere('f.email like :query')
            ->setParameter('query', '%'.str_replace('%', '\%', $criteria->query).'%')
        ;
    }
}

$criteria->query をLIKE句に食わせて検索しているだけですね。

この QueryBuilder を組み立てる処理だけはアプリ固有なので自分で書く必要がどうしてもありますが、これをもとに Slicer および Counter を作る処理には ttskch/paginator-bundle が提供するユーティリティを活用できます。

// FooRepository.php

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Ttskch\PaginatorBundle\Counter\Doctrine\ORM\QueryBuilderCounter;
use Ttskch\PaginatorBundle\Slicer\Doctrine\ORM\QueryBuilderSlicer;

/**
 * @extends ServiceEntityRepository<Foo>
 */
class FooRepository extends ServiceEntityRepository
{
    // ...

    /**
     * @return \Traversable<array-key, Foo>
     */
    public function sliceByCriteria(FooCriteria $criteria): \Traversable
    {
        $qb = $this->createQueryBuilderFromCriteria($criteria);
        $slicer = new QueryBuilderSlicer($qb);
    
        return $slicer->slice($criteria);
    }
    
    public function countByCriteria(FooCriteria $criteria): int
    {
        $qb = $this->createQueryBuilderFromCriteria($criteria);
        $counter = new QueryBuilderCounter($qb);
    
        return $counter->count($criteria);
    }
    
    private function createQueryBuilderFromCriteria(FooCriteria $criteria): QueryBuilder
    {
        return $this->createQueryBuilder('f')
            ->orWhere('f.name like :query')
            ->orWhere('f.email like :query')
            ->setParameter('query', '%'.str_replace('%', '\%', $criteria->query).'%')
        ;
    }
}

こんな感じで、検索条件適用済みの QueryBuilderを 渡して Slicer および Counter を作り、それらの処理を FooCriteria を渡して実行するようにすればOKです。

最後にコントローラの実装です。

$paginator->initialize() に先ほど作ったリポジトリクラスのメソッドを渡します。

Criteria が持つ getFormTypeClass()メソッドによって対応する FormType クラスが自動で判別される(ここでは FooFormType)ので、$paginator->getForm() によって handleRequest() 済みの FooFormTypeForm オブジェクトが取得できます。

// FooController.php

use Symfony\Component\HttpFoundation\Response;
use Ttskch\PaginatorBundle\Paginator;

/**
 * @param Paginator<\Traversable<array-key, Foo>, FooCriteria> $paginator
 */
public function index(FooRepository $fooRepository, Paginator $paginator): Response
{
    $paginator->initialize(
        $fooRepository->sliceByCriteria(...),
        $fooRepository->countByCriteria(...),
        new FooCriteria('id'),
    );

    return $this->render('index.html.twig', [
        'foos' => $paginator->getSlice(),
        'form' => $paginator->getForm()->createView(),
    ]);
}

あとはビューにフォームを設置すれば完了です。

  {# index.html.twig #}

+ {{ form(form, {action: path('foo_index'), method: 'get'}) }}
+ 
  <table>
      <thead>
      <tr>
          <th>{{ ttskch_paginator_sortable('id', 'Id') }}</th>
          <th>{{ ttskch_paginator_sortable('name', 'Name') }}</th>
          <th>{{ ttskch_paginator_sortable('email', 'Email') }}</th>
      </tr>
      </thead>
      <tbody>
      {% for foo in foos %}
          <tr>
              <td>{{ foo.id }}</td>
              <td>{{ foo.name }}</td>
              <td>{{ foo.email }}</td>
          </tr>
      {% endfor %}
      </tbody>
  </table>
  
  {{ ttskch_paginator_pager() }}

おわりに

というわけで、拙作のシンプルでカスタマイズしやすい最強のページネーションバンドル ttskch/paginator-bundle を紹介させていただきました。

https://github.com/ttskch/TtskchPaginatorBundle

繰り返しになりますが、もし普段からページネーションを自力で書いているという方がいたら、試しに使ってみていただけると嬉しいです!

感想やIssue/PRなどもお待ちしてます!

Symfony Advent Calendar 2023、明日は @kuni__94 さんです!お楽しみに!

GitHubで編集を提案

Discussion