データベース結果のページネーション

ウェブアプリケーションを開発していると、1 ページに並べる項目の数を制限したいという要求によく出会います。これはページネーションとして知られる手法です。

まずはページネーションなしですべてのデータを並べる状態から始めましょう。データベースからデータを取り出すために、ArticleRepository クラスがあります。コンストラクタのほかに、公開済みの記事を公開日の降順で返す findPublishedArticles メソッドを持っています。

namespace App\Model;

use Nette;

class ArticleRepository
{
	public function __construct(
		private Nette\Database\Connection $database,
	) {
	}

	public function findPublishedArticles(): Nette\Database\ResultSet
	{
		return $this->database->query('
			SELECT * FROM articles
			WHERE created_at < ?
			ORDER BY created_at DESC',
			new \DateTime,
		);
	}
}

プレゼンターでは、このモデルのクラスを注入します。render メソッドで公開済みの記事を取得し、テンプレートに渡します。

namespace App\Presentation\Home;

use Nette;
use App\Model\ArticleRepository;

class HomePresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private ArticleRepository $articleRepository,
	) {
	}

	public function renderDefault(): void
	{
		$this->template->articles = $this->articleRepository->findPublishedArticles();
	}
}

default.latte テンプレートが記事の一覧を受け持ちます。

{block content}
<h1>記事</h1>

<div class="articles">
	{foreach $articles as $article}
		<h2>{$article->title}</h2>
		<p>{$article->content}</p>
	{/foreach}
</div>

これですべての記事を並べられますが、記事の数が増えると問題になります。そこでページネーションのしくみを実装すると役に立ちます。

このしくみは、すべての記事をいくつかのページに分け、今選ばれているページに属する記事だけを表示します。ページの総数と記事の分け方は、記事の総数と 1 ページあたりの記事数をもとに Paginatorユーティリティが計算します。

まず、リポジトリのクラスの記事取得メソッドを、1 ページ分の記事だけを返せるように書き換えます。あわせて、Paginator の設定に必要な、データベース内の記事の総数を得るメソッドも足します。

namespace App\Model;

use Nette;


class ArticleRepository
{
	public function __construct(
		private Nette\Database\Connection $database,
	) {
	}

	public function findPublishedArticles(int $limit, int $offset): Nette\Database\ResultSet
	{
		return $this->database->query('
			SELECT * FROM articles
			WHERE created_at < ?
			ORDER BY created_at DESC
			LIMIT ?
			OFFSET ?',
			new \DateTime, $limit, $offset,
		);
	}

	/**
	 * 公開済みの記事の総数を返します
	 */
	public function getPublishedArticlesCount(): int
	{
		return $this->database->fetchField('SELECT COUNT(*) FROM articles WHERE created_at < ?', new \DateTime);
	}
}

次にプレゼンターを書き換えます。現在のページ番号を renderDefault メソッドに渡します。この数が URL の一部でなければ、既定値の 1(最初のページ)を設定します。

さらに render メソッドを拡張して、Paginator のインスタンスを作って設定し、テンプレートに表示する記事を適切に選びます。書き換えた HomePresenter は次のようになります。

namespace App\Presentation\Home;

use Nette;
use App\Model\ArticleRepository;

class HomePresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private ArticleRepository $articleRepository,
	) {
	}

	public function renderDefault(int $page = 1): void
	{
		// 公開済みの記事の総数を取得します
		$articlesCount = $this->articleRepository->getPublishedArticlesCount();

		// Paginator のインスタンスを作って設定します
		$paginator = new Nette\Utils\Paginator;
		$paginator->setItemCount($articlesCount); // 項目の総数
		$paginator->setItemsPerPage(10); // 1 ページあたりの項目数
		$paginator->setPage($page); // 現在のページ番号

		// Paginator の計算にもとづいて、限られた数の記事をデータベースから取得します
		$articles = $this->articleRepository->findPublishedArticles($paginator->getLength(), $paginator->getOffset());

		// テンプレートに渡します
		$this->template->articles = $articles;
		// ページネーションの操作部品を表示するために Paginator 自体も渡します
		$this->template->paginator = $paginator;
	}
}

テンプレートは現在のページの記事だけを反復するようになりました。あとはページネーションのリンクを足すだけです。

{block content}
<h1>記事</h1>

<div class="articles">
	{foreach $articles as $article}
		<h2>{$article->title}</h2>
		<p>{$article->content}</p>
	{/foreach}
</div>

<div class="pagination">
	{if !$paginator->isFirst()}
		<a n:href="default, 1">最初</a>
		&nbsp;|&nbsp;
		<a n:href="default, $paginator->getPage() - 1">前へ</a>
		&nbsp;|&nbsp;
	{/if}

	ページ {$paginator->getPage()} / {$paginator->getPageCount()}

	{if !$paginator->isLast()}
		&nbsp;|&nbsp;
		<a n:href="default, $paginator->getPage() + 1">次へ</a>
		&nbsp;|&nbsp;
		<a n:href="default, $paginator->getPageCount()">最後</a>
	{/if}
</div>

これで Paginator を使ったページネーションの実装は完了です。データベース層として Nette Database ExplorerNette Database Coreの代わりに使っているなら、Paginator ユーティリティを直接使わずにページネーションを実装できます。Nette\Database\Table\Selection クラスには、ページネーションのロジックを包んだ page() メソッドがあります。

この方法なら、リポジトリは次のようになります。

namespace App\Model;

use Nette;

class ArticleRepository
{
	public function __construct(
		private Nette\Database\Explorer $database,
	) {
	}

	public function findPublishedArticles(): Nette\Database\Table\Selection
	{
		return $this->database->table('articles')
			->where('created_at < ', new \DateTime)
			->order('created_at DESC');
	}
}

プレゼンターでは Paginator のインスタンスを作る必要がありません。代わりに、リポジトリが返す Selection オブジェクトの page() メソッドを使います。

namespace App\Presentation\Home;

use Nette;
use App\Model\ArticleRepository;

class HomePresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private ArticleRepository $articleRepository,
	) {
	}

	public function renderDefault(int $page = 1): void
	{
		// 公開済みの記事を取得します
		$articles = $this->articleRepository->findPublishedArticles();

		// page メソッドの計算で限られた分だけをテンプレートに渡します
		$lastPage = 0;
		$this->template->articles = $articles->page($page, 10, $lastPage);

		// ページネーションの選択肢を表示するのに必要なデータも渡します
		$this->template->page = $page;
		$this->template->lastPage = $lastPage;
	}
}

Paginator オブジェクトをテンプレートに渡さなくなったので、ページネーションのリンクを表示する部分を調整する必要があります。

{block content}
<h1>記事</h1>

<div class="articles">
	{foreach $articles as $article}
		<h2>{$article->title}</h2>
		<p>{$article->content}</p>
	{/foreach}
</div>

<div class="pagination">
	{if $page > 1}
		<a n:href="default, 1">最初</a>
		&nbsp;|&nbsp;
		<a n:href="default, $page - 1">前へ</a>
		&nbsp;|&nbsp;
	{/if}

	ページ {$page} / {$lastPage}

	{if $page < $lastPage}
		&nbsp;|&nbsp;
		<a n:href="default, $page + 1">次へ</a>
		&nbsp;|&nbsp;
		<a n:href="default, $lastPage">最後</a>
	{/if}
</div>

こうして、Paginator ユーティリティを明示的に使わずにページネーションのしくみを実装できました。