Skip to content
Open
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
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();
}
}
99 changes: 99 additions & 0 deletions packages/support/src/Paginator/SimplePaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Tempest\Support\Paginator;

use Tempest\Support\Paginator\Exceptions\ArgumentWasInvalid;

final class SimplePaginator
{
public function __construct(
private(set) int $itemsPerPage = 20,
private(set) int $currentPage = 1,
) {
if ($this->itemsPerPage <= 0) {
throw new ArgumentWasInvalid('Items per page must be positive');
}

if ($this->currentPage <= 0) {
throw new ArgumentWasInvalid('Current page must be positive');
}
Comment on lines +13 to +19

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's set an upper-bound as well. Though edge-case, a big enough argument (e.g. PHP_INT_MAX) can cause an overflow, coercing the type to float and causing a TypeError.

Comment on lines +13 to +19

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also create named, past tense exceptions for these, see other exceptions in the codebase. The old paginator follows the currently implemented patterns, but I'd consider it legacy and move towards named exceptions. Only fix these exceptions for now, if you wish, you can open a followup PR for the original paginator exceptions. Though I'd wait for @brendt's approval with this.

}

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