Paginacja wyników bazy danych

Podczas tworzenia aplikacji internetowych bardzo często spotkasz się z wymogiem ograniczenia liczby wyświetlanych elementów na stronie.

Wyjdziemy ze stanu, w którym wyświetlamy wszystkie dane bez paginacji. Do wyboru danych z bazy danych mamy klasę ArticleRepository, która oprócz konstruktora zawiera metodę findPublishedArticles, zwracającą wszystkie opublikowane artykuły posortowane malejąco według daty publikacji.

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,
		);
	}
}

W prezenterze następnie wstrzykujemy klasę modelu, a w metodzie render pobieramy opublikowane artykuły, które przekazujemy do szablonu:

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();
	}
}

W szablonie default.latte zajmujemy się następnie wyświetlaniem artykułów:

{block content}
<h1>Artykuły</h1>

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

W ten sposób potrafimy wyświetlić wszystkie artykuły, co jednak zacznie sprawiać problemy w momencie, gdy liczba artykułów wzrośnie. W tym momencie przyda się implementacja mechanizmu paginacji.

Zapewni on, że wszystkie artykuły zostaną podzielone na kilka stron, a my wyświetlimy tylko artykuły z jednej bieżącej strony. Całkowitą liczbę stron i podział artykułów obliczy Paginator sam na podstawie tego, ile artykułów mamy łącznie i ile artykułów na stronę chcemy wyświetlić.

W pierwszym kroku zmodyfikujemy metodę do pobierania artykułów w klasie repozytorium tak, aby potrafiła zwracać tylko artykuły dla jednej strony. Dodamy również metodę do sprawdzania całkowitej liczby artykułów w bazie danych, której będziemy potrzebować do ustawienia Paginatora:

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,
		);
	}

	/**
	 * Zwraca całkowitą liczbę opublikowanych artykułów
	 */
	public function getPublishedArticlesCount(): int
	{
		return $this->database->fetchField('SELECT COUNT(*) FROM articles WHERE created_at < ?', new \DateTime);
	}
}

Następnie przystąpimy do modyfikacji presentera. Do metody render będziemy przekazywać numer aktualnie wyświetlanej strony. W przypadku, gdy ten numer nie będzie częścią URL, ustawimy domyślną wartość pierwszej strony.

Dalej rozszerzymy również metodę render o uzyskanie instancji Paginatora, jego ustawienie i wybór odpowiednich artykułów do wyświetlenia w szablonie. HomePresenter po modyfikacjach będzie wyglądał tak:

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
	{
		// Sprawdzamy całkowitą liczbę opublikowanych artykułów
		$articlesCount = $this->articleRepository->getPublishedArticlesCount();

		// Tworzymy instancję Paginatora i ustawiamy ją
		$paginator = new Nette\Utils\Paginator;
		$paginator->setItemCount($articlesCount); // całkowita liczba artykułów
		$paginator->setItemsPerPage(10); // liczba elementów na stronie
		$paginator->setPage($page); // numer bieżącej strony

		// Pobieramy z bazy danych ograniczony zestaw artykułów zgodnie z obliczeniami Paginatora
		$articles = $this->articleRepository->findPublishedArticles($paginator->getLength(), $paginator->getOffset());

		// który przekazujemy do szablonu
		$this->template->articles = $articles;
		// a także sam Paginator do wyświetlania opcji paginacji
		$this->template->paginator = $paginator;
	}
}

Szablon już teraz iteruje tylko po artykułach jednej strony, wystarczy nam dodać linki paginacji:

{block content}
<h1>Artykuły</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">Pierwsza</a>
		&nbsp;|&nbsp;
		<a n:href="default, $paginator->page-1">Poprzednia</a>
		&nbsp;|&nbsp;
	{/if}

	Strona {$paginator->getPage()} z {$paginator->getPageCount()}

	{if !$paginator->isLast()}
		&nbsp;|&nbsp;
		<a n:href="default, $paginator->getPage() + 1">Następna</a>
		&nbsp;|&nbsp;
		<a n:href="default, $paginator->getPageCount()">Ostatnia</a>
	{/if}
</div>

W ten sposób uzupełniliśmy stronę o możliwość paginacji za pomocą Paginatora. W przypadku, gdy zamiast Nette Database Core jako warstwę bazodanową użyjemy Nette Database Explorer, jesteśmy w stanie zaimplementować paginację również bez użycia Paginatora. Klasa Nette\Database\Table\Selection bowiem zawiera metodę page z logiką paginacji przejętą z Paginatora.

Repozytorium przy tym sposobie implementacji będzie wyglądać tak:

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');
	}
}

W prezenterze nie musimy tworzyć Paginatora, użyjemy zamiast niego metody klasy Selection, którą zwraca repozytorium:

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
	{
		// Pobieramy opublikowane artykuły
		$articles = $this->articleRepository->findPublishedArticles();

		// i do szablonu wysyłamy tylko ich część ograniczoną zgodnie z obliczeniami metody page
		$lastPage = 0;
		$this->template->articles = $articles->page($page, 10, $lastPage);

		// a także potrzebne dane do wyświetlania opcji paginacji
		$this->template->page = $page;
		$this->template->lastPage = $lastPage;
	}
}

Ponieważ do szablonu teraz nie wysyłamy Paginatora, zmodyfikujemy część wyświetlającą linki paginacji:

{block content}
<h1>Artykuły</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">Pierwsza</a>
		&nbsp;|&nbsp;
		<a n:href="default, $page - 1">Poprzednia</a>
		&nbsp;|&nbsp;
	{/if}

	Strona {$page} z {$lastPage}

	{if $page < $lastPage}
		&nbsp;|&nbsp;
		<a n:href="default, $page + 1">Następna</a>
		&nbsp;|&nbsp;
		<a n:href="default, $lastPage">Ostatnia</a>
	{/if}
</div>

W ten sposób zaimplementowaliśmy mechanizm paginacji bez użycia Paginatora.