feat(shared): implement pagination value objects and tests

This commit is contained in:
Jérémy Munsch 2026-09-20 17:16:42 +02:00
parent c28ce7c488
commit 0ebeafeaf1
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
11 changed files with 608 additions and 378 deletions

View file

@ -1,122 +0,0 @@
<?php
declare(strict_types=1);
namespace Shared\Helpers;
/**
* Immutable value object representing a pagination range.
*/
final readonly class Pagination implements \Stringable
{
public const int MAX_PAGE_SIZE = 10000;
private function __construct(
private int $from,
private int $to,
) {
}
/**
* Get the string representation of the pagination range.
*/
public function __toString(): string
{
return sprintf(
'Pagination{from=%d, to=%d}',
$this->from,
$this->to,
);
}
/**
* Get the from value of the pagination.
*/
public function from(): int
{
return $this->from;
}
/**
* Get the to value of the pagination.
*/
public function to(): int
{
return $this->to;
}
/**
* Calculate and get the page size of the pagination.
*/
public function pageSize(): int
{
return $this->to - $this->from + 1;
}
/**
* Calculate and get the page number of the pagination.
*/
public function pageNumber(): int
{
return (int) ($this->from / $this->pageSize()) + 1;
}
/**
* Calculate and get the indexes of the pagination.
*/
public static function ofIndexes(int $from, int $to): self
{
$from = max(0, $from);
$to = min(
max($to, $from),
$from + self::MAX_PAGE_SIZE - 1,
);
return new self($from, $to);
}
/**
* Calculate and get the indexes of the pagination.
*/
public static function ofPage(int $pageNumber, int $pageSize): self
{
$pageNumber = max(1, $pageNumber);
$pageSize = min(
max(1, $pageSize),
self::MAX_PAGE_SIZE,
);
$fromIndex = ($pageNumber - 1) * $pageSize;
$toIndex = $fromIndex + $pageSize - 1;
return new self($fromIndex, $toIndex);
}
/**
* Calculate and get the offset/limit of the pagination.
*/
public static function ofOffsetAndLimit(int $offset, int $limit): self
{
$offset = max(0, $offset);
$limit = min(
max(1, $limit),
self::MAX_PAGE_SIZE,
);
$fromIndex = $offset;
$toIndex = $offset + $limit - 1;
return new self($fromIndex, $toIndex);
}
/**
* Get the first page of the pagination.
*/
public static function firstPage(int $pageSize = 25): self
{
return self::ofPage(1, $pageSize);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects\Pagination;
use Shared\DDD\Models\Equatable;
/**
* Value object representing a page number in pagination (1-indexed).
*/
final readonly class PageNumber implements \JsonSerializable, \Stringable, Equatable
{
public const int DEFAULT_PAGE = 1;
public function __construct(
public int $value = self::DEFAULT_PAGE,
) {
if ($this->value <= 0) {
throw new \InvalidArgumentException('Page number must be greater than 0');
}
}
public static function of(int $value): self
{
return new self($value);
}
public static function default(): self
{
return new self(self::DEFAULT_PAGE);
}
public function value(): int
{
return $this->value;
}
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return (string) $this->value;
}
public function jsonSerialize(): int
{
return $this->value;
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects\Pagination;
use Shared\DDD\Models\Equatable;
/**
* Value object representing a page size in pagination.
*/
final readonly class PageSize implements \JsonSerializable, \Stringable, Equatable
{
public const int DEFAULT_SIZE = 25;
/** ERR_TOO_MANY_RECORDS fixed to 10k */
public const int MAX_PAGE_SIZE = 10000;
public function __construct(
public int $value = self::DEFAULT_SIZE,
) {
if ($this->value <= 0) {
throw new \InvalidArgumentException('Page size must be greater than 0');
}
if ($this->value > self::MAX_PAGE_SIZE) {
throw new \InvalidArgumentException('Page size must be less than or equal to ' . self::MAX_PAGE_SIZE);
}
}
public static function of(int $value): self
{
return new self($value);
}
public static function default(): self
{
return new self(self::DEFAULT_SIZE);
}
public function value(): int
{
return $this->value;
}
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return (string) $this->value;
}
public function jsonSerialize(): int
{
return $this->value;
}
}

View file

@ -2,24 +2,20 @@
declare(strict_types=1);
namespace Shared\Helpers;
namespace Shared\ValueObjects\Pagination;
/** @template T of object */
final readonly class Paginated
{
/** @param T[] $items */
public function __construct(
private Pagination $pagination,
private readonly Pagination $pagination,
private array $items,
private int $total
) {
}
/**
* Get items of the list.
*
* @return T[]
*/
/** @return T[] */
public function getItems(): array
{
$items = [];
@ -32,17 +28,11 @@ final readonly class Paginated
return $items;
}
/**
* Get the pagination of the list.
*/
public function getPagination(): Pagination
{
return $this->pagination;
}
/**
* Get the total of the list.
*/
public function getTotal(): int
{
return $this->total;

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects\Pagination;
use Shared\DDD\Models\Equatable;
/**
* Immutable value object representing a pagination range.
*/
final readonly class Pagination implements Equatable
{
/** ERR_TOO_MANY_RECORDS fixed to 10k */
public const int MAX_PAGE_SIZE = PageSize::MAX_PAGE_SIZE;
private function __construct(
private int $from,
private int $to,
) {
}
public function from(): int
{
return $this->from;
}
public function to(): int
{
return $this->to;
}
public function pageSize(): int
{
return $this->to - $this->from + 1;
}
public function pageNumber(): int
{
return (int) ($this->from / $this->pageSize()) + 1;
}
public function pageSizeVo(): PageSize
{
return new PageSize($this->pageSize());
}
public function pageNumberVo(): PageNumber
{
return new PageNumber($this->pageNumber());
}
public function equals(mixed $other): bool
{
return $other instanceof self && $this->from === $other->from && $this->to === $other->to;
}
public static function ofIndexes(int $from, int $to): self
{
$from = max(0, $from);
$to = clamp($to, $from + 1, $from + self::MAX_PAGE_SIZE);
return new self($from, $to);
}
public static function ofPage(PageNumber $pageNumber, PageSize $pageSize = new PageSize()): self
{
$fromIndex = ($pageNumber->value - 1) * $pageSize->value;
$toIndex = $fromIndex + $pageSize->value - 1;
return new self($fromIndex, $toIndex);
}
public static function ofOffsetAndLimit(int $offset, int $limit): self
{
$offset = max(0, $offset);
$limit = clamp($limit, 1, self::MAX_PAGE_SIZE);
$fromIndex = $offset;
$toIndex = $offset + $limit - 1;
return new self($fromIndex, $toIndex);
}
public static function firstPage(PageSize $pageSize = new PageSize()): self
{
return self::ofPage(new PageNumber(1), $pageSize);
}
public function __toString(): string
{
return sprintf('Pagination{from=%d, to=%d}', $this->from, $this->to);
}
}

View file

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Helpers;
use Shared\Helpers\Paginated;
use Shared\Helpers\Pagination;
describe('paginated', function (): void {
it('returns its pagination', function (): void {
// GIVEN
$pagination = Pagination::firstPage(12);
// WHEN
$paginated = new Paginated($pagination, [], 0);
// THEN
expect($paginated->getPagination())
->toBe($pagination);
});
it('returns its total', function (): void {
// GIVEN
$pagination = Pagination::firstPage(12);
// WHEN
$paginated = new Paginated($pagination, [], 42);
// THEN
expect($paginated->getTotal())->toBe(42);
});
it('returns an empty list when there are no items', function (): void {
// GIVEN
$pagination = Pagination::firstPage(12);
// WHEN
$paginated = new Paginated($pagination, [], 0);
// THEN
expect($paginated->getItems())->toBe([])
->and($paginated->getTotal())->toBe(0);
});
});

View file

@ -1,198 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Helpers;
use Shared\Helpers\Pagination;
describe('pagination', function (): void {
it('creates a pagination from indexes', function (): void {
// GIVEN
$from = 10;
$to = 19;
// WHEN
$pagination = Pagination::ofIndexes($from, $to);
// THEN
expect($pagination->from())->toBe(10)
->and($pagination->to())->toBe(19)
->and($pagination->pageSize())->toBe(10);
});
it('normalizes a negative from index', function (): void {
// GIVEN
$from = -10;
$to = 19;
// WHEN
$pagination = Pagination::ofIndexes($from, $to);
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(19);
});
it('creates a pagination from page number and page size', function (): void {
// GIVEN
$pageNumber = 1;
$pageSize = 25;
// WHEN
$pagination = Pagination::ofPage($pageNumber, $pageSize);
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(24)
->and($pagination->pageSize())->toBe(25)
->and($pagination->pageNumber())->toBe(1);
});
it('creates the second page correctly', function (): void {
// GIVEN
$pageNumber = 2;
$pageSize = 25;
// WHEN
$pagination = Pagination::ofPage($pageNumber, $pageSize);
// THEN
expect($pagination->from())->toBe(25)
->and($pagination->to())->toBe(49)
->and($pagination->pageSize())->toBe(25)
->and($pagination->pageNumber())->toBe(2);
});
it('normalizes a page number below one', function (): void {
// GIVEN
$pageNumber = 0;
$pageSize = 25;
// WHEN
$pagination = Pagination::ofPage($pageNumber, $pageSize);
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(24)
->and($pagination->pageNumber())->toBe(1);
});
it('normalizes a negative page number', function (): void {
// GIVEN
$pageNumber = -5;
$pageSize = 25;
// WHEN
$pagination = Pagination::ofPage($pageNumber, $pageSize);
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(24)
->and($pagination->pageNumber())->toBe(1);
});
it('normalizes a page size below one', function (): void {
// GIVEN
$pageNumber = 1;
$pageSize = 0;
// WHEN
$pagination = Pagination::ofPage($pageNumber, $pageSize);
// THEN
expect($pagination->pageSize())->toBe(1)
->and($pagination->from())->toBe(0)
->and($pagination->to())->toBe(0);
});
it('limits the page size to the maximum page size', function (): void {
// GIVEN
$pageNumber = 1;
$pageSize = Pagination::MAX_PAGE_SIZE + 100;
// WHEN
$pagination = Pagination::ofPage($pageNumber, $pageSize);
// THEN
expect($pagination->pageSize())
->toBe(Pagination::MAX_PAGE_SIZE);
});
it('creates a pagination from offset and limit', function (): void {
// GIVEN
$pageNumber = 20;
$pageSize = 10;
// WHEN
$pagination = Pagination::ofOffsetAndLimit($pageNumber, $pageSize);
// THEN
expect($pagination->from())->toBe(20)
->and($pagination->to())->toBe(29)
->and($pagination->pageSize())->toBe(10);
});
it('normalizes a negative offset', function (): void {
// GIVEN
$pageNumber = -10;
$pageSize = 10;
// WHEN
$pagination = Pagination::ofOffsetAndLimit($pageNumber, $pageSize);
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(9);
});
it('normalizes a limit below one', function (): void {
// GIVEN
$pageNumber = 10;
$pageSize = 0;
// WHEN
$pagination = Pagination::ofOffsetAndLimit($pageNumber, $pageSize);
// THEN
expect($pagination->from())->toBe(10)
->and($pagination->to())->toBe(10)
->and($pagination->pageSize())->toBe(1);
});
it('creates the first page with the default page size', function (): void {
// GIVEN & WHEN
$pagination = Pagination::firstPage();
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(24)
->and($pagination->pageSize())->toBe(25)
->and($pagination->pageNumber())->toBe(1);
});
it('creates the first page with a custom page size', function (): void {
// GIVEN
$pageSize = 50;
// WHEN
$pagination = Pagination::firstPage($pageSize);
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(49)
->and($pagination->pageSize())->toBe(50);
});
it('returns its string representation', function (): void {
// GIVEN
$from = 10;
$to = 19;
// WHEN
$pagination = Pagination::ofIndexes($from, $to);
// THEN
expect((string) $pagination)
->toBe('Pagination{from=10, to=19}');
});
});

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Pagination;
use Shared\ValueObjects\Pagination\PageNumber;
covers(PageNumber::class);
describe('PageNumber Value Object Unit Tests', function (): void {
it('creates page number with default value', function (): void {
// WHEN
$page = new PageNumber();
// THEN
expect($page->value)->toBe(PageNumber::DEFAULT_PAGE)
->and($page->value())->toBe(PageNumber::DEFAULT_PAGE);
});
it('creates page number with explicit value', function (): void {
// WHEN
$page = new PageNumber(5);
// THEN
expect($page->value)->toBe(5)
->and($page->value())->toBe(5);
});
it('creates page number via of factory', function (): void {
// WHEN
$page = PageNumber::of(3);
// THEN
expect($page->value)->toBe(3)
->and($page->value())->toBe(3);
});
it('creates page number via default factory', function (): void {
// WHEN
$page = PageNumber::default();
// THEN
expect($page->value)->toBe(PageNumber::DEFAULT_PAGE);
});
it('rejects page number less than or equal to zero', function (int $invalidValue): void {
// WHEN / THEN
expect(fn () => new PageNumber($invalidValue))
->toThrow(\InvalidArgumentException::class, 'Page number must be greater than 0');
})->with([
'zero' => [0],
'negative' => [-1],
'large negative' => [-99],
]);
it('implements equals correctly', function (): void {
// GIVEN
$page1 = new PageNumber(2);
$page2 = new PageNumber(2);
$page3 = new PageNumber(3);
// THEN
expect($page1->equals($page2))->toBeTrue()
->and($page1->equals($page3))->toBeFalse()
->and($page1->equals(2))->toBeFalse()
->and($page1->equals('2'))->toBeFalse()
->and($page1->equals(null))->toBeFalse();
});
it('converts to string via __toString', function (): void {
// GIVEN
$page = new PageNumber(4);
// THEN
expect((string) $page)->toBe('4');
});
it('serializes to json as integer', function (): void {
// GIVEN
$page = new PageNumber(7);
// THEN
expect($page->jsonSerialize())->toBe(7)
->and(json_encode($page))->toBe('7');
});
});

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Pagination;
use Shared\ValueObjects\Pagination\PageSize;
covers(PageSize::class);
describe('PageSize Value Object Unit Tests', function (): void {
it('creates page size with default value', function (): void {
// WHEN
$size = new PageSize();
// THEN
expect($size->value)->toBe(PageSize::DEFAULT_SIZE)
->and($size->value())->toBe(PageSize::DEFAULT_SIZE);
});
it('creates page size with explicit value', function (): void {
// WHEN
$size = new PageSize(50);
// THEN
expect($size->value)->toBe(50)
->and($size->value())->toBe(50);
});
it('creates page size via of factory', function (): void {
// WHEN
$size = PageSize::of(10);
// THEN
expect($size->value)->toBe(10)
->and($size->value())->toBe(10);
});
it('creates page size via default factory', function (): void {
// WHEN
$size = PageSize::default();
// THEN
expect($size->value)->toBe(PageSize::DEFAULT_SIZE);
});
it('allows maximum page size', function (): void {
// WHEN
$size = new PageSize(PageSize::MAX_PAGE_SIZE);
// THEN
expect($size->value)->toBe(PageSize::MAX_PAGE_SIZE);
});
it('rejects page size less than or equal to zero', function (int $invalidValue): void {
// WHEN / THEN
expect(fn () => new PageSize($invalidValue))
->toThrow(\InvalidArgumentException::class, 'Page size must be greater than 0');
})->with([
'zero' => [0],
'negative' => [-1],
'large negative' => [-100],
]);
it('rejects page size exceeding maximum page size', function (): void {
// WHEN / THEN
expect(fn () => new PageSize(PageSize::MAX_PAGE_SIZE + 1))
->toThrow(\InvalidArgumentException::class, 'Page size must be less than or equal to 10000');
});
it('implements equals correctly', function (): void {
// GIVEN
$size1 = new PageSize(25);
$size2 = new PageSize(25);
$size3 = new PageSize(50);
// THEN
expect($size1->equals($size2))->toBeTrue()
->and($size1->equals($size3))->toBeFalse()
->and($size1->equals(25))->toBeFalse()
->and($size1->equals('25'))->toBeFalse()
->and($size1->equals(null))->toBeFalse();
});
it('converts to string via __toString', function (): void {
// GIVEN
$size = new PageSize(15);
// THEN
expect((string) $size)->toBe('15');
});
it('serializes to json as integer', function (): void {
// GIVEN
$size = new PageSize(30);
// THEN
expect($size->jsonSerialize())->toBe(30)
->and(json_encode($size))->toBe('30');
});
});

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Pagination;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
covers(Paginated::class);
describe('Paginated Unit Tests', function (): void {
it('stores and retrieves pagination, items, and total', function (): void {
// GIVEN
$pagination = Pagination::firstPage();
$item1 = (object) ['id' => 1, 'name' => 'First'];
$item2 = (object) ['id' => 2, 'name' => 'Second'];
$items = [$item1, $item2];
$total = 42;
// WHEN
$paginated = new Paginated($pagination, $items, $total);
// THEN
expect($paginated->getPagination())->toBe($pagination)
->and($paginated->getTotal())->toBe(42)
->and($paginated->getItems())->toHaveCount(2)
->and($paginated->getItems()[0]->id)->toBe(1)
->and($paginated->getItems()[1]->id)->toBe(2);
});
it('returns cloned items so original objects are not mutated externally', function (): void {
// GIVEN
$pagination = Pagination::firstPage();
$original = (object) ['name' => 'Original'];
$paginated = new Paginated($pagination, [$original], 1);
// WHEN
$retrieved = $paginated->getItems();
$retrieved[0]->name = 'Mutated';
// THEN
expect($paginated->getItems()[0]->name)->toBe('Original')
->and($retrieved[0])->not->toBe($paginated->getItems()[0]);
});
it('handles empty items array', function (): void {
// GIVEN
$pagination = Pagination::firstPage();
// WHEN
$paginated = new Paginated($pagination, [], 0);
// THEN
expect($paginated->getItems())->toBeEmpty()
->and($paginated->getTotal())->toBe(0)
->and($paginated->getPagination())->toBe($pagination);
});
});

View file

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\ValueObjects\Pagination;
use Shared\ValueObjects\Pagination\Pagination;
use Shared\ValueObjects\Pagination\PageNumber;
use Shared\ValueObjects\Pagination\PageSize;
covers(Pagination::class);
describe('Pagination Value Object Unit Tests', function (): void {
it('can be created from page and page size', function (): void {
// GIVEN
$page = new PageNumber(2);
$size = new PageSize(20);
// WHEN
$pagination = Pagination::ofPage($page, $size);
// THEN
expect($pagination->from())->toBe(20)
->and($pagination->to())->toBe(39)
->and($pagination->pageSize())->toBe(20)
->and($pagination->pageNumber())->toBe(2);
});
it('can be created from offset and limit', function (): void {
// GIVEN
$offset = 20;
$limit = 10;
// WHEN
$pagination = Pagination::ofOffsetAndLimit($offset, $limit);
// THEN
expect($pagination->from())->toBe(20)
->and($pagination->to())->toBe(29)
->and($pagination->pageSize())->toBe(10)
->and($pagination->pageNumber())->toBe(3);
});
it('can be created from explicit indexes using ofIndexes', function (): void {
// GIVEN
$from = 5;
$to = 14;
// WHEN
$pagination = Pagination::ofIndexes($from, $to);
// THEN
expect($pagination->from())->toBe(5)
->and($pagination->to())->toBe(14)
->and($pagination->pageSize())->toBe(10);
});
it('can be created using first page helper with default size', function (): void {
// WHEN
$pagination = Pagination::firstPage();
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(24)
->and($pagination->pageSize())->toBe(25)
->and($pagination->pageNumber())->toBe(1);
});
it('can be created using first page helper with explicit size', function (): void {
// GIVEN
$pageSize = new PageSize(15);
// WHEN
$pagination = Pagination::firstPage($pageSize);
// THEN
expect($pagination->from())->toBe(0)
->and($pagination->to())->toBe(14)
->and($pagination->pageSize())->toBe(15)
->and($pagination->pageNumber())->toBe(1);
});
it('formats pagination as string representation via __toString', function (): void {
// GIVEN
$pagination = Pagination::ofIndexes(0, 9);
// WHEN
$stringRepresentation = (string) $pagination;
// THEN
expect($stringRepresentation)->toBe('Pagination{from=0, to=9}');
});
it('clamps invalid constructor-level values via ofIndexes', function (int $from, int $to, int $expectedFrom, int $expectedTo): void {
// GIVEN / WHEN
$pagination = Pagination::ofIndexes($from, $to);
// THEN
expect($pagination->from())->toBe($expectedFrom)
->and($pagination->to())->toBe($expectedTo);
})->with([
'negative from is clamped to 0' => [-1, 10, 0, 10],
'to below from+1 is clamped to from+1' => [10, 5, 10, 11],
'to equal from is clamped to from+1' => [10, 10, 10, 11],
'oversized range is clamped to max' => [0, 10001, 0, 10000],
]);
it('clamps invalid ofOffsetAndLimit parameters', function (int $offset, int $limit, int $expectedFrom, int $expectedSize): void {
// GIVEN / WHEN
$pagination = Pagination::ofOffsetAndLimit($offset, $limit);
// THEN
expect($pagination->from())->toBe($expectedFrom)
->and($pagination->pageSize())->toBe($expectedSize);
})->with([
'negative offset clamped to 0' => [-1, 10, 0, 10],
'limit 0 clamped to 1' => [0, 0, 0, 1],
'negative limit clamped to 1' => [0, -5, 0, 1],
'oversized limit clamped' => [0, 20000, 0, 10000],
]);
it('creates pagination using ofPage with Value Objects', function (): void {
// GIVEN
$pageNumber = new PageNumber(3);
$pageSize = new PageSize(15);
// WHEN
$pagination = Pagination::ofPage($pageNumber, $pageSize);
// THEN
expect($pagination->from())->toBe(30)
->and($pagination->to())->toBe(44)
->and($pagination->pageSize())->toBe(15)
->and($pagination->pageNumber())->toBe(3)
->and($pagination->pageNumberVo())->toEqual($pageNumber)
->and($pagination->pageSizeVo())->toEqual($pageSize);
});
it('checks equality with another Pagination instance', function (): void {
// GIVEN
$p1 = Pagination::ofPage(new PageNumber(1), new PageSize(20));
$p2 = Pagination::ofPage(new PageNumber(1), new PageSize(20));
$p3 = Pagination::ofPage(new PageNumber(2), new PageSize(20));
// THEN
expect($p1->equals($p2))->toBeTrue()
->and($p1->equals($p3))->toBeFalse()
->and($p1->equals(null))->toBeFalse()
->and($p1->equals('pagination'))->toBeFalse();
});
});