Постраничный вывод результатов из базы данных

Разрабатывая веб-приложения, вы часто сталкиваетесь с требованием ограничить количество выводимых элементов на странице – приёмом, известным как пагинация.

Начнём с состояния, когда мы выводим все данные без пагинации. Для выборки данных из базы у нас есть класс 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>

Так мы можем вывести все статьи, но это становится проблемой по мере роста их количества. Тогда и пригождается механизм пагинации.

Этот механизм делит все статьи на несколько страниц, и мы показываем только те статьи, которые относятся к выбранной странице. Общее число страниц и распределение статей вычисляет вспомогательный класс Paginator на основе общего числа статей и желаемого числа статей на странице.

На первом шаге изменим в классе репозитория метод получения статей так, чтобы он мог возвращать статьи только для одной страницы. Добавим также метод получения общего числа статей в базе, который нужен для настройки 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); // число элементов на странице
		$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 Explorer вместо Nette 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. Вместо этого мы используем метод page() объекта Selection, возвращённого из репозитория:

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.