docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination;
use App\Framework\Pagination\ValueObjects\PaginationRequest;
use App\Framework\Pagination\ValueObjects\PaginationResponse;
use App\Framework\Pagination\ValueObjects\Cursor;
use App\Framework\Pagination\ValueObjects\Direction;
use App\Framework\Serializer\Json\JsonSerializer;
/**
* Array-based paginator for in-memory pagination
*/
final readonly class ArrayPaginator implements Paginator
{
/**
* @param array<mixed> $data
*/
public function __construct(
private array $data
) {
}
/**
* {@inheritdoc}
*/
public function paginate(PaginationRequest $request): PaginationResponse
{
if ($request->isCursorBased()) {
return $this->paginateWithCursor($request);
}
return $this->paginateWithOffset($request);
}
/**
* {@inheritdoc}
*/
public function supports(PaginationRequest $request): bool
{
return true; // ArrayPaginator supports both offset and cursor pagination
}
/**
* Paginate with offset-based pagination
*/
private function paginateWithOffset(PaginationRequest $request): PaginationResponse
{
$sortedData = $this->sortData($this->data, $request->sortField, $request->direction);
$totalCount = count($sortedData);
$slice = array_slice($sortedData, $request->offset, $request->limit);
return PaginationResponse::offset(
data: $slice,
request: $request,
totalCount: $totalCount
);
}
/**
* Paginate with cursor-based pagination
*/
private function paginateWithCursor(PaginationRequest $request): PaginationResponse
{
$sortedData = $this->sortData($this->data, $request->sortField, $request->direction);
if ($request->cursor === null) {
// First page
$slice = array_slice($sortedData, 0, $request->limit);
$nextCursor = $this->createNextCursor($slice, $request);
return PaginationResponse::cursor(
data: $slice,
request: $request,
nextCursor: $nextCursor
);
}
// Find cursor position
$cursorPosition = $this->findCursorPosition($sortedData, $request->cursor, $request->sortField);
if ($cursorPosition === -1) {
// Cursor not found, return empty
return PaginationResponse::cursor(
data: [],
request: $request
);
}
// Get slice based on direction
if ($request->direction->isAscending()) {
$slice = array_slice($sortedData, $cursorPosition + 1, $request->limit);
} else {
$startPosition = max(0, $cursorPosition - $request->limit);
$slice = array_slice($sortedData, $startPosition, $cursorPosition - $startPosition);
}
$nextCursor = $this->createNextCursor($slice, $request);
$previousCursor = $this->createPreviousCursor($slice, $request);
return PaginationResponse::cursor(
data: $slice,
request: $request,
nextCursor: $nextCursor,
previousCursor: $previousCursor
);
}
/**
* Sort data by field and direction
*
* @param array<mixed> $data
* @return array<mixed>
*/
private function sortData(array $data, ?string $sortField, Direction $direction): array
{
if ($sortField === null) {
return $data;
}
$sorted = $data;
usort($sorted, function ($a, $b) use ($sortField, $direction) {
$valueA = $this->getFieldValue($a, $sortField);
$valueB = $this->getFieldValue($b, $sortField);
$comparison = $valueA <=> $valueB;
return $direction->isAscending() ? $comparison : -$comparison;
});
return $sorted;
}
/**
* Get field value from item
*/
private function getFieldValue(mixed $item, string $field): mixed
{
if (is_array($item)) {
return $item[$field] ?? null;
}
if (is_object($item)) {
return $item->$field ?? null;
}
return $item;
}
/**
* Find cursor position in data
*
* @param array<mixed> $data
*/
private function findCursorPosition(array $data, Cursor $cursor, ?string $sortField): int
{
if ($sortField === null) {
return -1;
}
$cursorValue = $cursor->getFieldValue($sortField);
foreach ($data as $index => $item) {
$itemValue = $this->getFieldValue($item, $sortField);
if ($itemValue === $cursorValue) {
return $index;
}
}
return -1;
}
/**
* Create next cursor from slice
*
* @param array<mixed> $slice
*/
private function createNextCursor(array $slice, PaginationRequest $request): ?Cursor
{
if (empty($slice) || $request->sortField === null) {
return null;
}
$lastItem = end($slice);
$lastValue = $this->getFieldValue($lastItem, $request->sortField);
if ($lastValue === null) {
return null;
}
return Cursor::fromField($request->sortField, $lastValue, $request->direction);
}
/**
* Create previous cursor from slice
*
* @param array<mixed> $slice
*/
private function createPreviousCursor(array $slice, PaginationRequest $request): ?Cursor
{
if (empty($slice) || $request->sortField === null) {
return null;
}
$firstItem = reset($slice);
$firstValue = $this->getFieldValue($firstItem, $request->sortField);
if ($firstValue === null) {
return null;
}
$oppositeDirection = $request->direction->opposite();
return Cursor::fromField($request->sortField, $firstValue, $oppositeDirection);
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination;
use App\Framework\Database\EntityManager;
use App\Framework\Database\QueryBuilder\SelectQueryBuilder;
use App\Framework\Pagination\ValueObjects\PaginationRequest;
use App\Framework\Pagination\ValueObjects\PaginationResponse;
use App\Framework\Pagination\ValueObjects\Cursor;
use App\Framework\Pagination\ValueObjects\Direction;
use InvalidArgumentException;
/**
* Database-based paginator using EntityManager and QueryBuilder
*/
final readonly class DatabasePaginator implements Paginator
{
public function __construct(
private EntityManager $entityManager,
private string $entityClass,
private ?SelectQueryBuilder $queryBuilder = null
) {
}
/**
* {@inheritdoc}
*/
public function paginate(PaginationRequest $request): PaginationResponse
{
if ($request->isCursorBased()) {
return $this->paginateWithCursor($request);
}
return $this->paginateWithOffset($request);
}
/**
* {@inheritdoc}
*/
public function supports(PaginationRequest $request): bool
{
return true; // DatabasePaginator supports both pagination types
}
/**
* Paginate with offset-based pagination
*/
private function paginateWithOffset(PaginationRequest $request): PaginationResponse
{
$baseQuery = $this->getBaseQuery();
// Apply sorting
if ($request->sortField !== null) {
$baseQuery->orderBy($request->sortField, $request->direction->toSql());
}
// Get total count
$countQuery = clone $baseQuery;
$totalCount = $countQuery->count();
// Apply pagination
$dataQuery = clone $baseQuery;
$dataQuery->limit($request->limit)
->offset($request->offset);
// Execute query
$data = $dataQuery->getEntities();
return PaginationResponse::offset(
data: $data,
request: $request,
totalCount: $totalCount
);
}
/**
* Paginate with cursor-based pagination
*/
private function paginateWithCursor(PaginationRequest $request): PaginationResponse
{
if ($request->sortField === null) {
throw new InvalidArgumentException('Sort field is required for cursor-based pagination');
}
$baseQuery = $this->getBaseQuery();
// Apply cursor filtering
if ($request->cursor !== null) {
$cursorValue = $request->cursor->getFieldValue($request->sortField);
$operator = $request->direction->isAscending() ? '>' : '<';
$baseQuery->where($request->sortField, $operator, $cursorValue);
}
// Apply sorting
$baseQuery->orderBy($request->sortField, $request->direction->toSql());
// Get one extra result to determine if there are more pages
$dataQuery = clone $baseQuery;
$dataQuery->limit($request->limit + 1);
$results = $dataQuery->getEntities();
// Check if there are more results
$hasMore = count($results) > $request->limit;
if ($hasMore) {
array_pop($results); // Remove the extra result
}
// Create cursors
$nextCursor = null;
$previousCursor = null;
if ($hasMore && !empty($results)) {
$lastItem = end($results);
$lastValue = $this->getFieldValue($lastItem, $request->sortField);
$nextCursor = Cursor::fromField($request->sortField, $lastValue, $request->direction);
}
if ($request->cursor !== null && !empty($results)) {
$firstItem = reset($results);
$firstValue = $this->getFieldValue($firstItem, $request->sortField);
$oppositeDirection = $request->direction->opposite();
$previousCursor = Cursor::fromField($request->sortField, $firstValue, $oppositeDirection);
}
return PaginationResponse::cursor(
data: $results,
request: $request,
nextCursor: $nextCursor,
previousCursor: $previousCursor
);
}
/**
* Get base query builder
*/
private function getBaseQuery(): SelectQueryBuilder
{
if ($this->queryBuilder !== null) {
return clone $this->queryBuilder;
}
return $this->entityManager->createQueryBuilderFor($this->entityClass);
}
/**
* Get field value from entity
*/
private function getFieldValue(object $entity, string $field): mixed
{
// Try property access first
if (property_exists($entity, $field)) {
return $entity->$field;
}
// Try getter method
$getter = 'get' . ucfirst($field);
if (method_exists($entity, $getter)) {
return $entity->$getter();
}
// Try is/has methods for boolean fields
$isMethod = 'is' . ucfirst($field);
if (method_exists($entity, $isMethod)) {
return $entity->$isMethod();
}
$hasMethod = 'has' . ucfirst($field);
if (method_exists($entity, $hasMethod)) {
return $entity->$hasMethod();
}
throw new InvalidArgumentException("Cannot access field '{$field}' on entity " . get_class($entity));
}
/**
* Create new paginator with different query builder
*/
public function withQuery(SelectQueryBuilder $queryBuilder): self
{
return new self(
entityManager: $this->entityManager,
entityClass: $this->entityClass,
queryBuilder: $queryBuilder
);
}
/**
* Create new paginator for different entity class
*/
public function forEntity(string $entityClass): self
{
return new self(
entityManager: $this->entityManager,
entityClass: $entityClass
);
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination;
use App\Framework\Database\EntityManager;
use App\Framework\Database\QueryBuilder\SelectQueryBuilder;
use App\Framework\Pagination\ValueObjects\PaginationRequest;
use App\Framework\Pagination\ValueObjects\PaginationResponse;
use InvalidArgumentException;
/**
* Main pagination service that manages different paginators
*/
final readonly class PaginationService
{
public function __construct(
private EntityManager $entityManager
) {
}
/**
* Create paginator for array data
*
* @param array<mixed> $data
*/
public function forArray(array $data): ArrayPaginator
{
return new ArrayPaginator($data);
}
/**
* Create paginator for database entity
*/
public function forEntity(string $entityClass): DatabasePaginator
{
return new DatabasePaginator(
entityManager: $this->entityManager,
entityClass: $entityClass
);
}
/**
* Create paginator for custom query
*/
public function forQuery(string $entityClass, SelectQueryBuilder $queryBuilder): DatabasePaginator
{
return new DatabasePaginator(
entityManager: $this->entityManager,
entityClass: $entityClass,
queryBuilder: $queryBuilder
);
}
/**
* Paginate data using appropriate paginator
*/
public function paginate(mixed $source, PaginationRequest $request): PaginationResponse
{
$paginator = $this->resolvePaginator($source);
if (!$paginator->supports($request)) {
throw new InvalidArgumentException(
'Paginator ' . get_class($paginator) . ' does not support the given request type'
);
}
return $paginator->paginate($request);
}
/**
* Resolve appropriate paginator for data source
*/
private function resolvePaginator(mixed $source): Paginator
{
return match (true) {
is_array($source) => new ArrayPaginator($source),
$source instanceof SelectQueryBuilder => new DatabasePaginator(
entityManager: $this->entityManager,
entityClass: 'UnknownEntity', // This should be improved
queryBuilder: $source
),
is_string($source) && class_exists($source) => new DatabasePaginator(
entityManager: $this->entityManager,
entityClass: $source
),
default => throw new InvalidArgumentException(
'Cannot resolve paginator for source type: ' . get_debug_type($source)
)
};
}
/**
* Create offset-based pagination request
*/
public function offsetRequest(
int $limit,
int $offset = 0,
?string $sortField = null,
string $direction = 'asc'
): PaginationRequest {
return PaginationRequest::offset(
limit: $limit,
offset: $offset,
sortField: $sortField,
direction: $direction === 'desc' ?
\App\Framework\Pagination\ValueObjects\Direction::DESC :
\App\Framework\Pagination\ValueObjects\Direction::ASC
);
}
/**
* Create cursor-based pagination request
*/
public function cursorRequest(
int $limit,
string $cursorValue,
?string $sortField = null,
string $direction = 'asc'
): PaginationRequest {
$directionEnum = $direction === 'desc' ?
\App\Framework\Pagination\ValueObjects\Direction::DESC :
\App\Framework\Pagination\ValueObjects\Direction::ASC;
$cursor = \App\Framework\Pagination\ValueObjects\Cursor::fromEncoded($cursorValue, $directionEnum);
return PaginationRequest::cursor(
limit: $limit,
cursor: $cursor,
sortField: $sortField
);
}
/**
* Create first page request
*/
public function firstPageRequest(
int $limit,
?string $sortField = null,
string $direction = 'asc'
): PaginationRequest {
return PaginationRequest::first(
limit: $limit,
sortField: $sortField,
direction: $direction === 'desc' ?
\App\Framework\Pagination\ValueObjects\Direction::DESC :
\App\Framework\Pagination\ValueObjects\Direction::ASC
);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Database\EntityManager;
/**
* Initializer for the Pagination service
*/
final readonly class PaginationServiceInitializer
{
public function __construct(
private Container $container
) {
}
#[Initializer]
public function __invoke(): PaginationService
{
// Create PaginationService with EntityManager dependency
$paginationService = new PaginationService(
entityManager: $this->container->get(EntityManager::class)
);
// Register the service in container for future use
$this->container->singleton(PaginationService::class, $paginationService);
return $paginationService;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination;
use App\Framework\Pagination\ValueObjects\PaginationRequest;
use App\Framework\Pagination\ValueObjects\PaginationResponse;
/**
* Interface for pagination services
*/
interface Paginator
{
/**
* Paginate data based on request
*/
public function paginate(PaginationRequest $request): PaginationResponse;
/**
* Check if paginator supports the given request type
*/
public function supports(PaginationRequest $request): bool;
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination\ValueObjects;
use App\Framework\Serializer\Json\JsonSerializer;
use App\Framework\Serializer\Exception\SerializeException;
use App\Framework\Serializer\Exception\DeserializeException;
use InvalidArgumentException;
/**
* Cursor for cursor-based pagination
*/
final readonly class Cursor
{
public function __construct(
public string $value,
public Direction $direction = Direction::ASC
) {
if (empty($this->value)) {
throw new InvalidArgumentException('Cursor value cannot be empty');
}
}
/**
* Create cursor from base64 encoded value
*/
public static function fromEncoded(string $encodedValue, Direction $direction = Direction::ASC): self
{
$decoded = base64_decode($encodedValue, true);
if ($decoded === false) {
throw new InvalidArgumentException('Invalid cursor encoding');
}
return new self($decoded, $direction);
}
/**
* Create cursor from field value
*/
public static function fromField(string $field, mixed $value, Direction $direction = Direction::ASC): self
{
$serializer = new JsonSerializer();
try {
$cursorValue = $serializer->serialize([$field => $value]);
} catch (SerializeException $e) {
throw new InvalidArgumentException('Cannot encode cursor value: ' . $e->getMessage(), 0, $e);
}
return new self($cursorValue, $direction);
}
/**
* Get base64 encoded cursor value
*/
public function toEncoded(): string
{
return base64_encode($this->value);
}
/**
* Parse cursor value as array
*/
public function parseValue(): array
{
$serializer = new JsonSerializer();
try {
$parsed = $serializer->deserialize($this->value);
} catch (DeserializeException $e) {
throw new InvalidArgumentException('Cursor value is not valid JSON: ' . $e->getMessage(), 0, $e);
}
if (!is_array($parsed)) {
throw new InvalidArgumentException('Cursor value is not an array');
}
return $parsed;
}
/**
* Get field value from cursor
*/
public function getFieldValue(string $field): mixed
{
$parsed = $this->parseValue();
return $parsed[$field] ?? null;
}
/**
* Check if cursor is for ascending direction
*/
public function isAscending(): bool
{
return $this->direction->isAscending();
}
/**
* Check if cursor is for descending direction
*/
public function isDescending(): bool
{
return $this->direction->isDescending();
}
/**
* Create new cursor with different direction
*/
public function withDirection(Direction $direction): self
{
return new self($this->value, $direction);
}
public function toString(): string
{
return $this->toEncoded();
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination\ValueObjects;
/**
* Pagination direction for cursor-based pagination
*/
enum Direction: string
{
case ASC = 'asc';
case DESC = 'desc';
/**
* Check if direction is ascending
*/
public function isAscending(): bool
{
return $this === self::ASC;
}
/**
* Check if direction is descending
*/
public function isDescending(): bool
{
return $this === self::DESC;
}
/**
* Get opposite direction
*/
public function opposite(): self
{
return match ($this) {
self::ASC => self::DESC,
self::DESC => self::ASC,
};
}
/**
* Get SQL ORDER BY clause value
*/
public function toSql(): string
{
return strtoupper($this->value);
}
}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination\ValueObjects;
/**
* Pagination metadata
*/
final readonly class PaginationMeta
{
public function __construct(
public int $resultCount,
public int $limit,
public bool $hasMore,
public bool $hasPrevious,
public PaginationType $type,
public ?int $totalCount = null,
public ?int $totalPages = null,
public ?int $currentPage = null,
public ?Cursor $nextCursor = null,
public ?Cursor $previousCursor = null,
public ?string $sortField = null,
public Direction $direction = Direction::ASC
) {
}
/**
* Create metadata for offset-based pagination
*/
public static function offset(
PaginationRequest $request,
int $totalCount,
int $resultCount
): self {
$totalPages = $request->limit > 0 ? (int) ceil($totalCount / $request->limit) : 1;
$currentPage = $request->getPage();
return new self(
resultCount: $resultCount,
limit: $request->limit,
hasMore: ($request->offset + $request->limit) < $totalCount,
hasPrevious: $request->offset > 0,
type: PaginationType::OFFSET,
totalCount: $totalCount,
totalPages: $totalPages,
currentPage: $currentPage,
nextCursor: null,
previousCursor: null,
sortField: $request->sortField,
direction: $request->direction
);
}
/**
* Create metadata for cursor-based pagination
*/
public static function cursor(
PaginationRequest $request,
int $resultCount,
?Cursor $nextCursor = null,
?Cursor $previousCursor = null
): self {
return new self(
resultCount: $resultCount,
limit: $request->limit,
hasMore: $nextCursor !== null,
hasPrevious: $previousCursor !== null,
type: PaginationType::CURSOR,
totalCount: null,
totalPages: null,
currentPage: null,
nextCursor: $nextCursor,
previousCursor: $previousCursor,
sortField: $request->sortField,
direction: $request->direction
);
}
/**
* Check if this is offset-based pagination
*/
public function isOffsetBased(): bool
{
return $this->type->isOffset();
}
/**
* Check if this is cursor-based pagination
*/
public function isCursorBased(): bool
{
return $this->type->isCursor();
}
/**
* Create new metadata with different result count
*/
public function withResultCount(int $resultCount): self
{
return new self(
resultCount: $resultCount,
limit: $this->limit,
hasMore: $this->hasMore,
hasPrevious: $this->hasPrevious,
type: $this->type,
totalCount: $this->totalCount,
totalPages: $this->totalPages,
currentPage: $this->currentPage,
nextCursor: $this->nextCursor,
previousCursor: $this->previousCursor,
sortField: $this->sortField,
direction: $this->direction
);
}
/**
* Get navigation information for offset-based pagination
*/
public function getNavigationInfo(): array
{
if (!$this->isOffsetBased()) {
return [];
}
return [
'first_page' => 1,
'last_page' => $this->totalPages,
'current_page' => $this->currentPage,
'next_page' => $this->hasMore ? $this->currentPage + 1 : null,
'previous_page' => $this->hasPrevious ? $this->currentPage - 1 : null,
];
}
/**
* Get cursor navigation information
*/
public function getCursorInfo(): array
{
if (!$this->isCursorBased()) {
return [];
}
return [
'next_cursor' => $this->nextCursor?->toEncoded(),
'previous_cursor' => $this->previousCursor?->toEncoded(),
];
}
/**
* Convert to array for JSON serialization
*/
public function toArray(): array
{
$base = [
'result_count' => $this->resultCount,
'limit' => $this->limit,
'has_more' => $this->hasMore,
'has_previous' => $this->hasPrevious,
'type' => $this->type->value,
'sort_field' => $this->sortField,
'direction' => $this->direction->value,
];
if ($this->isOffsetBased()) {
$base = array_merge($base, [
'total_count' => $this->totalCount,
'total_pages' => $this->totalPages,
'current_page' => $this->currentPage,
], $this->getNavigationInfo());
}
if ($this->isCursorBased()) {
$base = array_merge($base, $this->getCursorInfo());
}
return $base;
}
/**
* Check if results are at the maximum limit
*/
public function isAtLimit(): bool
{
return $this->resultCount === $this->limit;
}
/**
* Get pagination type
*/
public function getType(): PaginationType
{
return $this->type;
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination\ValueObjects;
use InvalidArgumentException;
/**
* Pagination request parameters
*/
final readonly class PaginationRequest
{
public function __construct(
public int $limit,
public int $offset = 0,
public ?Cursor $cursor = null,
public Direction $direction = Direction::ASC,
public ?string $sortField = null
) {
if ($this->limit <= 0) {
throw new InvalidArgumentException('Limit must be positive');
}
if ($this->limit > 1000) {
throw new InvalidArgumentException('Limit cannot exceed 1000');
}
if ($this->offset < 0) {
throw new InvalidArgumentException('Offset cannot be negative');
}
}
/**
* Create offset-based pagination request
*/
public static function offset(int $limit, int $offset = 0, ?string $sortField = null, Direction $direction = Direction::ASC): self
{
return new self(
limit: $limit,
offset: $offset,
cursor: null,
direction: $direction,
sortField: $sortField
);
}
/**
* Create cursor-based pagination request
*/
public static function cursor(int $limit, Cursor $cursor, ?string $sortField = null): self
{
return new self(
limit: $limit,
offset: 0,
cursor: $cursor,
direction: $cursor->direction,
sortField: $sortField
);
}
/**
* Create first page request
*/
public static function first(int $limit, ?string $sortField = null, Direction $direction = Direction::ASC): self
{
return new self(
limit: $limit,
offset: 0,
cursor: null,
direction: $direction,
sortField: $sortField
);
}
/**
* Check if this is cursor-based pagination
*/
public function isCursorBased(): bool
{
return $this->cursor !== null;
}
/**
* Check if this is offset-based pagination
*/
public function isOffsetBased(): bool
{
return $this->cursor === null;
}
/**
* Get page number for offset-based pagination
*/
public function getPage(): int
{
if ($this->limit === 0) {
return 1;
}
return (int) floor($this->offset / $this->limit) + 1;
}
/**
* Create next page request for offset-based pagination
*/
public function nextPage(): self
{
if ($this->isCursorBased()) {
throw new InvalidArgumentException('Cannot get next page for cursor-based pagination');
}
return new self(
limit: $this->limit,
offset: $this->offset + $this->limit,
cursor: null,
direction: $this->direction,
sortField: $this->sortField
);
}
/**
* Create previous page request for offset-based pagination
*/
public function previousPage(): self
{
if ($this->isCursorBased()) {
throw new InvalidArgumentException('Cannot get previous page for cursor-based pagination');
}
$newOffset = max(0, $this->offset - $this->limit);
return new self(
limit: $this->limit,
offset: $newOffset,
cursor: null,
direction: $this->direction,
sortField: $this->sortField
);
}
/**
* Create request with different limit
*/
public function withLimit(int $limit): self
{
return new self(
limit: $limit,
offset: $this->offset,
cursor: $this->cursor,
direction: $this->direction,
sortField: $this->sortField
);
}
/**
* Create request with different sort field
*/
public function withSort(string $sortField, Direction $direction = Direction::ASC): self
{
return new self(
limit: $this->limit,
offset: $this->offset,
cursor: $this->cursor?->withDirection($direction),
direction: $direction,
sortField: $sortField
);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination\ValueObjects;
use App\Framework\Serializer\Json\JsonSerializer;
/**
* Pagination response with data and metadata
*/
final readonly class PaginationResponse
{
/**
* @param array<mixed> $data
*/
public function __construct(
public array $data,
public PaginationMeta $meta
) {
}
/**
* Create response for offset-based pagination
*
* @param array<mixed> $data
*/
public static function offset(
array $data,
PaginationRequest $request,
int $totalCount
): self {
$meta = PaginationMeta::offset(
request: $request,
totalCount: $totalCount,
resultCount: count($data)
);
return new self($data, $meta);
}
/**
* Create response for cursor-based pagination
*
* @param array<mixed> $data
*/
public static function cursor(
array $data,
PaginationRequest $request,
?Cursor $nextCursor = null,
?Cursor $previousCursor = null
): self {
$meta = PaginationMeta::cursor(
request: $request,
resultCount: count($data),
nextCursor: $nextCursor,
previousCursor: $previousCursor
);
return new self($data, $meta);
}
/**
* Check if there are more results
*/
public function hasMore(): bool
{
return $this->meta->hasMore();
}
/**
* Check if there are previous results
*/
public function hasPrevious(): bool
{
return $this->meta->hasPrevious();
}
/**
* Get data count
*/
public function count(): int
{
return count($this->data);
}
/**
* Check if response is empty
*/
public function isEmpty(): bool
{
return empty($this->data);
}
/**
* Convert to array for JSON serialization
*/
public function toArray(): array
{
return [
'data' => $this->data,
'meta' => $this->meta->toArray(),
];
}
/**
* Convert to JSON string
*/
public function toJson(): string
{
$serializer = new JsonSerializer();
return $serializer->serialize($this->toArray());
}
/**
* Get first item from data
*/
public function first(): mixed
{
return $this->data[0] ?? null;
}
/**
* Get last item from data
*/
public function last(): mixed
{
if (empty($this->data)) {
return null;
}
return $this->data[array_key_last($this->data)];
}
/**
* Map data through a callback
*
* @param callable(mixed): mixed $callback
*/
public function map(callable $callback): self
{
$mappedData = array_map($callback, $this->data);
return new self($mappedData, $this->meta);
}
/**
* Filter data through a callback
*
* @param callable(mixed): bool $callback
*/
public function filter(callable $callback): self
{
$filteredData = array_values(array_filter($this->data, $callback));
// Update meta with new result count
$newMeta = $this->meta->withResultCount(count($filteredData));
return new self($filteredData, $newMeta);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Pagination\ValueObjects;
/**
* Pagination type enumeration
*/
enum PaginationType: string
{
case OFFSET = 'offset';
case CURSOR = 'cursor';
/**
* Check if this is offset-based pagination
*/
public function isOffset(): bool
{
return $this === self::OFFSET;
}
/**
* Check if this is cursor-based pagination
*/
public function isCursor(): bool
{
return $this === self::CURSOR;
}
/**
* Get human-readable description
*/
public function getDescription(): string
{
return match ($this) {
self::OFFSET => 'Offset-based pagination with page numbers',
self::CURSOR => 'Cursor-based pagination for large datasets',
};
}
/**
* Get recommended use case
*/
public function getUseCase(): string
{
return match ($this) {
self::OFFSET => 'Small to medium datasets where total count is needed',
self::CURSOR => 'Large datasets or real-time data where performance is critical',
};
}
}