🎻

[Symfony] シンプルでカスタマイズしやすい最強のページネーションバンドル

2020/07/30に公開

TtskchPaginatorBundle

シンプルでカスタマイズしやすい最強のページネーションバンドルを作りました。

ttskch/TtskchPaginatorBundle: The most thin and simple paginator bundle for Symfony

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

特徴

  • 超軽量
  • Symfony以外に一切依存なし
  • ページャー部分のHTMLなどはtwigで簡単にカスタマイズ可能
  • 見出し部分を簡単にソートのためのリンクにできる
  • 検索フォームとの連動も簡単
  • ページャー部分のHTMLのBootstrap4テーマをプリセット

動作要件

  • PHP >=7.1.3
  • Symfony ^4.0|^5.0

デモ

https://ttskchpaginatorbundle.herokuapp.com/

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

使い方

インストール

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

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

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

Doctrine ORMとあわせて使う

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

Before

// FooController.php

public function index(FooRepository $fooRepository)
{
    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 Ttskch\PaginatorBundle\Context;
+ use Ttskch\PaginatorBundle\Doctrine\Counter;
+ use Ttskch\PaginatorBundle\Doctrine\Slicer;
+ 
- public function index(FooRepository $fooRepository)
+ public function index(FooRepository $fooRepository, Context $context)
  {
+     $qb = $fooRepository->createQueryBuilder('f');
+     $context->initialize('id', new Slicer($qb), new Counter($qb));
+ 
      return $this->render('index.html.twig', [
-       'foos' => $fooRepository->findAll(),
+       'foos' => $context->slice,
      ]);
  }
  {# index.html.twig #}
  
  <table>
    <thead>
    <tr>
-     <th>Id</th>
-     <th>Name</th>
-     <th>Email</th>
+     <th>{{ ttskch_paginator_sortable('id') }}</th>
+     <th>{{ ttskch_paginator_sortable('name') }}</th>
+     <th>{{ ttskch_paginator_sortable('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() }}

こんな感じです。

$context->initialize('id', new Slicer($qb), new Counter($qb));

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

new Slicer($qb)new Counter($qb) はそれぞれ、

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

です。これらがTtskchPaginatorBundleが用意してくれているユーティリティです。

ビュー側では、

  • 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'

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

# config/packages/ttskch_paginator.yaml

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

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

検索フォームとあわせて使う場合は、リポジトリクラスにQueryBuilderの組み立てるを書きます。

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

デフォルトの Criteria はページ番号や1ページあたりの表示件数など、ページネーション情報しか持たないため、これを継承して独自の FooCriteria を作るところから始めます。

とりあえず文字列で検索するための query というプロパティを追加しました。

// FooCriteria.php

use Ttskch\PaginatorBundle\Entity\Criteria;

class FooCriteria extends Criteria
{
    public $query;
}

続いて、この FooCriteria に対応するFormTypeを作ります。TtskchPaginatorBundleが提供しているデフォルトの CriteriaType を継承することで、ページネーション情報に関する処理を気にする必要がなくなります。

// FooSearchType.php

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 CriteriaType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        parent::buildForm($builder, $options); // <- 忘れずに

        $builder
            ->add('query', SearchType::class)
        ;
    }

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

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

// FooRepository.php

private function createQueryBuilderFromCriteria(FooCriteria $criteria)
{
    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 を作る処理にはTtskchPaginatorBundleが提供するユーティリティを活用できます。

// FooRepository.php

use Ttskch\PaginatorBundle\Doctrine\Counter;
use Ttskch\PaginatorBundle\Doctrine\Slicer;

// ...

public function sliceByCriteria(FooCriteria $criteria)
{
    $qb = $this->createQueryBuilderFromCriteria($criteria);
    $slicer = new Slicer($qb);

    return $slicer($criteria);
}

public function countByCriteria(FooCriteria $criteria)
{
    $qb = $this->createQueryBuilderFromCriteria($criteria);
    $counter = new Counter($qb);

    return $counter($criteria);
}

private function createQueryBuilderFromCriteria(FooCriteria $criteria)
{
    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です。( Slicer Counter はinvokableです)

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

$context->initialize() の第2、第3引数で先ほど作ったリポジトリクラスのメソッドを渡します。

また、Criteria/FormTypeを独自に拡張した場合は、第4、第5引数でそのクラス名を渡します。

そうすることで、 $context->formhandleRequest() 済みのFormオブジェクトが取得できます。

// FooController.php

public function index(FooRepository $fooRepository, Context $context)
{
    $context->initialize(
        'id',
        [$fooRepository, 'sliceByCriteria'],
        [$fooRepository, 'countByCriteria'],
        FooCriteria::class,
        FooSearchType::class
    );

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

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

  {# index.html.twig #}
  
+ {{ form(form, {action: path('foo_index'), method: 'get'}) }}
+ 
  <table>
      <thead>
      <tr>
          <th>{{ ttskch_paginator_sortable('id') }}</th>
          <th>{{ ttskch_paginator_sortable('name') }}</th>
          <th>{{ ttskch_paginator_sortable('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/TtskchPaginatorBundle: The most thin and simple paginator bundle for Symfony

GitHubで編集を提案

Discussion