PHP+Doctrineでページネーションしてみた。

ちょっと長いですが、Paginator及びILimitableQuery, PageNavigatorは別のところで解説しているのでまずそちらをみてください。

PageNavigator
Paginator

この二つを使ってPaginationRendererを作成、してましたがクラスでHTML出力させるのはちょっと汎用性ないなと。
実体はレンダリングでなく必要な情報を取得するクラスになってました、気にしない。

use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;

use App\Entities\Article;
use Doctrine\ORM\Query;

class Paginator
{
    public function __construct(
        private int $itemsCount,
        private int $pageSize
    )
    {
        if($itemsCount < 1 || $pageSize < 1)
        {
            throw new Exception();
        }
    }

    public function getItemsCount(): int
    {
        return $this->itemsCount;
    }

    public function getPageSize(): int
    {
        return $this->pageSize;
    }

    public function getLastPage(): int
    {
        return $this->getPage($this->itemsCount - 1);
    }

    public function getPage(int $position): int
    {
        $this->validPosition($position);
        return floor($position / $this->pageSize) + 1;
    }

    public function getRange(int $page): array
    {
        $this->validPage($page);

        $sp = ($page - 1) * $this->pageSize;
        $ep = $sp + $this->pageSize - 1;
        $ep = min($ep, $this->itemsCount - 1);
        return [$sp, $ep];
    }

    public function checkPosition(int $position): bool
    {
        return $position >= 0 && $position < $this->itemsCount;
    }

    public function validPosition(int $position): void
    {
        if(!$this->checkPosition($position))
        {
            throw new Exception('valid position');
        }
    }

    public function checkPage(int $page): bool
    {
        return $page <= $this->getLastPage() && $page >= 1;
    }

    public function validPage(int $page): void
    {
        if(!$this->checkPage($page))
        {
            throw new Exception('valid page');
        }
    }

}

class FillMap
{
    private array $map = [];

    public function __construct(private $validator)
    {

    }

    public function fill(int $begin, int $end): self
    {
        for($i=$begin; $i<=$end; $i++)
        {
            /** @var callable $val */
            $validate = $this->validator;

            if(!in_array($i, $this->map) && $validate($i))
            {
                $this->map[] = $i;
            }
        }

        return $this;
    }

    public function toArray(): array
    {
        return [...$this->map];
    }

}

class PageNavigator
{

    public function __construct(
        private int $page,
        private int $lastPage,
        private int $pageSideLength = 3,
        private int $sideLength = 2
    )
    {

    }

    public function rangeValidate(int $current): bool
    {
        return $current >= 1 && $current <= $this->lastPage;
    }

    public function getPageMap(): array
    {
        return (new FillMap(fn(int $current) => $this->rangeValidate($current)))
            ->fill(...$this->getFirstRange())
            ->fill(...$this->getPageRange())
            ->fill(...$this->getLastRange())
            ->toArray();
    }

    public function getFirstRange(): array
    {
        return [1, 1 + $this->sideLength];
    }

    public function getPageRange(): array
    {
        return [$this->page - $this->pageSideLength, $this->page + $this->pageSideLength];
    }

    public function getLastRange(): array
    {
        return [$this->lastPage - $this->sideLength, $this->lastPage];
    }

    public function isFirst(int $current): bool { return $current === 1; }
    public function isPage(int $current): bool { return $current === $this->page; }
    public function isLast(int $current): bool { return $current === $this->lastPage; }

    public function isSpaceBefore(array $map, int $current): bool
    {
        return $this->rangeValidate($current) && !in_array($current - 1, $map);
    }

    public function isSpaceAfter(array $map, int $current): bool
    {
        return $this->rangeValidate($current) && !in_array($current + 1, $map);
    }

}

// アイテム数の取得と、範囲を指定してのアイテムリストの取得を実装してください。
interface ILimitableQuery
{
    function countItems(): int;
    function getItems(int $top, int $take): array;
}

class PaginationRenderer
{
    private int $itemsLength;

    public function __construct(private ILimitableQuery $query, private int $pageSize)
    {

    }

    private function getItemsLength(): int
    {
        return $this->itemsLength = $this->itemsLength ?? $this->query->countItems();
    }

    public function getPageSize(): int
    {
        return $this->pageSize;
    }

    // ページが範囲外だった場合は例外を発生させます。
    public function createPaginator($page): Paginator
    {
        $itemsCount = $this->getItemsLength();
        $paginator = new Paginator($itemsCount, $this->pageSize);
        return $paginator;
    }

    /**
     * @return [array,[int,int]]
     */
    public function getRenderingItems(int $page)
    {
        $paginator = $this->createPaginator($page);
        list($begin, $end) = $paginator->getRange($page);
        $items = $this->query->getItems($begin, $this->pageSize);

        return [$items, [$begin, $end]];
    }

    public function getRenderingNavigation(int $page, int $pageSideLength = 3, int $sideLength = 2)
    {
        $paginator = $this->createPaginator($page);
        $navigator = new PageNavigator($page, $paginator->getLastPage(), $pageSideLength, $sideLength);

        $map = $navigator->getPageMap($page);
        return [$map, $navigator];
    }
}

// ----------------- ↑ここまでがライブラリ ------------------------------

// ----------------- ↓こっからがDoctrineに依存の実装、環境に合わせる ------------------------------

// Doctrine用クエリ
class DoctrineLimitableQuery implements ILimitableQuery
{
    private DoctrinePaginator $docPaginator;

    public function __construct(private Query $query)
    {
        $this->docPaginator = new DoctrinePaginator($query, $fetchJoinCollection = true);
    }

    public function countItems(): int
    {
        return $this->docPaginator->count();
    }

    public function getItems(int $top, int $take): array
    {
        return $this->docPaginator
            ->getQuery()
            ->setFirstResult($top)
            ->setMaxResults($take)
            ->getResult();
    }
}

// Doctrine用

/** @var EntityManager $manager */
$manager = require './createManager.php';
$query = $manager->createQuery('SELECT a, c FROM App\Entities\Article a JOIN a.category c');
$dlq = new DoctrineLimitableQuery($query);
$renderer = new PaginationRenderer($dlq, 10);

// ----------------- ↑ここまでDoctrineに依存の実装 ------------------------------

// ----------------- ↓こっからが実際に表示する際の例 ------------------------------

//
// パラメータから現在のページを取得
//
$page = $_GET['page'] ?? 1;

// アイテムリストの表示
try
{
    /** @var array<Article> $items */
    [$items, [$begin, $end]] = $renderer->getRenderingItems($page);

    $count = count($items);
    echo "{$page}ページの{$count}件を表示\n";

    foreach($items as $item)
    {
        $title = htmlspecialchars($item->getTitle());
        echo "<p>{$title}</p>\n";
    }

}
catch(Exception $ex)
{
    echo "<h2>このページにはアイテムがありません。</h2>\n";
}

// ナビの表示
try
{
    [$map, $nav] = $renderer->getRenderingNavigation($page);
    $links = [];

    foreach($map as $current)
    {
        if($nav->isPage($current))
        {
            $links[] = sprintf('<a href="./index.php?page=%s" class="page">(%s)</a>', $current, $current);
        }
        elseif($nav->isFirst($current) || $nav->isLast($current))
        {
            $links[] = sprintf('<a href="./index.php?page=%s" class="firstorlast">[%s]</a>', $current, $current);
        }
        else
        {
            if($nav->isSpaceBefore($map, $current))
            {
                $links[] = '...';
            }

            $links[] = sprintf('<a href="./index.php?page=%s" class="nomal">%s</a>', $current, $current);
        }
    }
    echo sprintf("<div class=\"page-nav\">\n%s\n</div>\n", implode(" \n", $links));
}
catch(Exception $ex)
{
    echo "<div class=\"page-nav\"> - </div>\n";
}

PaginationRendererの第一引数はILimitableQueryを実装したオブジェクト、二つ目は1ページに表示する件数の設定です。

今回は私の環境でのDoctrine用に実装してます。
テーブル定義やクエリ等は自身の環境に合わせて書きます。
このコードをそのまま実行しても正しく動きません。

$renderer = new PaginationRenderer($dlq, 10);

PHP側の知識ですが、クエリからpageで現在のページを取得出来るようにします。デフォルトは1ページ目です。

$page = $_GET['page'] ?? 1;

内部でPaginatorクラスのインスタンスを生成しているので例外を発生する場合があります。
現在のページの取得範囲のアイテムリストを取得します。

[$items, [$begin, $end]] = $renderer->getRenderingItems($page);

こちらも内部的にPaginatorを取得しているので例外の発生に気を付けます。
ページのナビゲーターに必要なリストを取得します。

[$map, $nav] = $renderer->getRenderingNavigation($page);

結果、

1ページの10件を表示
<p>  j = 0</p>
<p>  j = 1</p>
<p>  j = 2</p>
<p>  j = 3</p>
<p>  j = 4</p>
<p>  j = 5</p>
<p>  j = 6</p>
<p>  j = 7</p>
<p>  j = 8</p>
<p>  j = 9</p>
<div class="page-nav">
<a href="./index.php?page=1" class="page">(1)</a> 
<a href="./index.php?page=2" class="nomal">2</a> 
<a href="./index.php?page=3" class="nomal">3</a> 
<a href="./index.php?page=4" class="nomal">4</a> 
... 
<a href="./index.php?page=8" class="nomal">8</a> 
<a href="./index.php?page=9" class="nomal">9</a> 
<a href="./index.php?page=10" class="firstorlast">[10]</a>
</div>

ちなみにitemsCountが0の時は例外が発生するのでこうなります。

<h2>このページにはアイテムがありません。</h2>
<div class="page-nav"> - </div>

3ページ目をブラウザで表示した場合こうなります。

BlockEditor certificate css DataGrid Docker Gutenberg Hyper-V iframe MUI openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク