Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
use Tempest\Support\Conditions\HasConditions;
use Tempest\Support\Paginator\PaginatedData;
use Tempest\Support\Paginator\Paginator;
use Tempest\Support\Paginator\SimplePaginatedData;
use Tempest\Support\Paginator\SimplePaginator;
use Tempest\Support\Str\ImmutableString;

use function Tempest\Container\get;
Expand Down Expand Up @@ -131,6 +133,32 @@ public function paginate(int $itemsPerPage = 20, int $currentPage = 1, int $maxL
);
}

/**
* Returns offset-paginated data for the current query without executing a count query.
*
* Because the total number of items is unknown, a page beyond the available data
* is returned empty and still reports a previous page when `currentPage` is greater than one.
* For large or frequently changing datasets, cursor pagination may be more appropriate.
*
* @return SimplePaginatedData<TModel>
*/
public function simplePaginate(
int $itemsPerPage = 20,
int $currentPage = 1,
): SimplePaginatedData {
$paginator = new SimplePaginator(
itemsPerPage: $itemsPerPage,
currentPage: $currentPage,
);

return $paginator->paginateWith(
callback: fn (int $limit, int $offset) => $this
->limit($limit)
->offset($offset)
->all(),
);
}

/**
* Returns the first record matching the given primary key.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Tempest\Support\Paginator\Exceptions;

use Exception;

final class CurrentPageWasInvalid extends Exception implements PaginationException
{
public function __construct(int $currentPage, int $itemsPerPage, int $maximumCurrentPage)
{
parent::__construct("Current page should be between 1 and {$maximumCurrentPage} for {$itemsPerPage} items per page. Instead got {$currentPage}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Tempest\Support\Paginator\Exceptions;

use Exception;

final class ItemsPerPageWasInvalid extends Exception implements PaginationException
{
public function __construct(int $itemsPerPage)
{
$maximum = PHP_INT_MAX - 1;

parent::__construct("Items per page should be between 1 and {$maximum}. Instead got {$itemsPerPage}");
}
}
115 changes: 115 additions & 0 deletions packages/support/src/Paginator/SimplePaginatedData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

namespace Tempest\Support\Paginator;

use JsonSerializable;

/**
* @template T
*/
final class SimplePaginatedData implements JsonSerializable
{
/**
* @param array<T> $data
*/
public function __construct(
public array $data,
public int $currentPage,
public int $itemsPerPage,
public int $offset,
public int $limit,
public bool $hasNext,
public bool $hasPrevious,
public ?int $nextPage,
public ?int $previousPage,
) {}

public int $count {
get => count($this->data);
}

public bool $isEmpty {
get => $this->count === 0;
}

public bool $isNotEmpty {
get => ! $this->isEmpty;
}

/**
* @template U
*
* @param callable(T): U $callback
*
* @return SimplePaginatedData<U>
*/
public function map(callable $callback): self
{
return new self(
data: array_map($callback, $this->data),
currentPage: $this->currentPage,
itemsPerPage: $this->itemsPerPage,
offset: $this->offset,
limit: $this->limit,
hasNext: $this->hasNext,
hasPrevious: $this->hasPrevious,
nextPage: $this->nextPage,
previousPage: $this->previousPage,
);
}

/**
* @return array{
* data: array<T>,
* pagination: array{
* current_page: int,
* items_per_page: int,
* offset: int,
* limit: int,
* has_next: bool,
* has_previous: bool,
* next_page: ?int,
* previous_page: ?int,
* count: int
* }
* }
*/
public function toArray(): array
{
return [
'data' => $this->data,
'pagination' => [
'current_page' => $this->currentPage,
'items_per_page' => $this->itemsPerPage,
'offset' => $this->offset,
'limit' => $this->limit,
'has_next' => $this->hasNext,
'has_previous' => $this->hasPrevious,
'next_page' => $this->nextPage,
'previous_page' => $this->previousPage,
'count' => $this->count,
],
];
}

/**
* @return array{
* data: array<T>,
* pagination: array{
* current_page: int,
* items_per_page: int,
* offset: int,
* limit: int,
* has_next: bool,
* has_previous: bool,
* next_page: ?int,
* previous_page: ?int,
* count: int
* }
* }
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}
106 changes: 106 additions & 0 deletions packages/support/src/Paginator/SimplePaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

namespace Tempest\Support\Paginator;

use Tempest\Support\Paginator\Exceptions\CurrentPageWasInvalid;
use Tempest\Support\Paginator\Exceptions\ItemsPerPageWasInvalid;

final class SimplePaginator
{
public function __construct(
private(set) int $itemsPerPage = 20,
private(set) int $currentPage = 1,
) {
if ($this->itemsPerPage <= 0 || $this->itemsPerPage === PHP_INT_MAX) {
throw new ItemsPerPageWasInvalid($this->itemsPerPage);
}

$maximumCurrentPage = min(PHP_INT_MAX - 2, intdiv(PHP_INT_MAX, $this->itemsPerPage)) + 1;

if ($this->currentPage <= 0 || $this->currentPage > $maximumCurrentPage) {
throw new CurrentPageWasInvalid(
currentPage: $this->currentPage,
itemsPerPage: $this->itemsPerPage,
maximumCurrentPage: $maximumCurrentPage,
);
}
}

public int $offset {
get => ($this->currentPage - 1) * $this->itemsPerPage;
}

/**
* One additional item is requested to determine whether
* the next page exists.
*/
public int $limit {
get => $this->itemsPerPage + 1;
}

public bool $hasPrevious {
get => $this->currentPage > 1;
}

public ?int $previousPage {
get => $this->hasPrevious ? $this->currentPage - 1 : null;
}

public function withPage(int $page): self
{
return new self(
itemsPerPage: $this->itemsPerPage,
currentPage: $page,
);
}

public function withItemsPerPage(int $itemsPerPage): self
{
return new self(
itemsPerPage: $itemsPerPage,
currentPage: $this->currentPage,
);
}

/**
* Creates simple paginated data with the provided items.
*
* Any items beyond the configured page size are used to determine
* whether the next page exists and are omitted from the result.
*
* @template T
* @param array<T> $data
* @return SimplePaginatedData<T>
*/
public function paginate(array $data): SimplePaginatedData
{
$hasNext = count($data) > $this->itemsPerPage;
$data = array_slice($data, 0, $this->itemsPerPage);

return new SimplePaginatedData(
data: $data,
currentPage: $this->currentPage,
itemsPerPage: $this->itemsPerPage,
offset: $this->offset,
limit: $this->itemsPerPage,
hasNext: $hasNext,
hasPrevious: $this->hasPrevious,
nextPage: $hasNext ? $this->currentPage + 1 : null,
previousPage: $this->previousPage,
);
}

/**
* Creates simple paginated data from a callable that fetches data.
*
* @template T
* @param callable(int $limit, int $offset): array<T> $callback
* @return SimplePaginatedData<T>
*/
public function paginateWith(callable $callback): SimplePaginatedData
{
return $this->paginate(
$callback($this->limit, $this->offset),
);
}
}
Loading
Loading