Пагінація результатів бази даних

При створенні веб-додатків дуже часто виникає вимога обмежити кількість виведених елементів на сторінці.

Почнемо зі стану, коли ми виводимо всі дані без пагінації. Для вибору даних з бази даних у нас є клас 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);
	}
}

Потім перейдемо до змін у презентері. У метод render ми будемо передавати номер поточної відображуваної сторінки. У випадку, якщо цей номер не буде частиною URL, встановимо значення за замовчуванням першої сторінки.

Далі також розширимо метод 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->page-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 Core як шар бази даних використовується Nette Database Explorer, ми можемо реалізувати пагінацію і без використання Paginator. Клас Nette\Database\Table\Selection містить метод page з логікою пагінації, взятою з Paginator.

Репозиторій при такому способі реалізації виглядатиме так:

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, який повертає репозиторій:

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.