`; the constructor * enforces sequential 0-based integer keys so callers can * iterate without re-indexing. * * - `total` — total row count across every page after the * filter axes have been applied but BEFORE pagination, so * REST consumers can compute the page count without * re-issuing the query. * * - `page` / `perPage` — echoed back from the originating * {@see AuditEventListQuery} so consumers do not have to * hold the request VO alongside the response envelope to * interpret the slice. * * Convenience derivations: * * - {@see self::pageCount()} returns `(int) ceil($total / $perPage)` * for non-empty results and 0 when `$total === 0`. This is * the same shape Phase 11 admin pagination renderers consume * from {@see LogListResult::pageCount()} so the audit page * can be wired through the same UI helper without a custom * adapter. * * - {@see self::isEmpty()} reports whether the envelope * carries zero rows across every page (not just the current * slice), so an empty-state renderer can branch on the * filter outcome without inspecting `count(items())`. * * Failure semantics: * * - Validation failures raise {@see InvalidArgumentException} * at construction time so a programming error in the * repository (non-list `items`, mis-typed entry, * out-of-range pagination axis) surfaces loudly rather than * reaching the REST envelope serializer. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use InvalidArgumentException; /** * Immutable result envelope for the §12.4 audit-events listing. * * See the file-level docblock for the field semantics, the * convenience derivations ({@see self::pageCount()}, * {@see self::isEmpty()}), and the validation invariants the * constructor enforces. */ final class AuditEventListResult { /** * Page slice, in newest-first order produced by the repository. * * @var list */ private array $items; /** * Total row count across every page (after filtering, before * pagination). */ private int $total; /** * 1-based page number echoed back from the originating query. */ private int $page; /** * Records per page echoed back from the originating query. */ private int $perPage; /** * Construct a result envelope from the page slice and the * pagination axes echoed back from the originating * {@see AuditEventListQuery}. * * Validation: * * - `$items` MUST be a `list`: sequential * 0-based integer keys, every entry an * {@see AuditEventRecord} instance. * * - `$total` MUST be `>= 0`. A non-zero `$total` with an * empty `$items` slice is permitted because the caller * may have requested a page beyond the last populated * slice. * * - `$page` MUST be `>= 1`. `$perPage` MUST be `>= 1`. * * @param list $items Page slice. * @param int $total Total row count. * @param int $page 1-based page number. * @param int $perPage Records per page. * * @throws InvalidArgumentException When `$items` is not a * `list`, * when `$total < 0`, or when * either `$page` or * `$perPage` are below 1. */ public function __construct( array $items, int $total, int $page, int $perPage ) { $expectedIndex = 0; foreach ( $items as $key => $item ) { if ( $key !== $expectedIndex ) { $message = sprintf( 'AuditEventListResult: items must be a sequential list (0-based integer keys); ' . 'got key %s at position %d.', // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- Developer-facing exception needs exact invalid array key representation. var_export( $key, true ), $expectedIndex ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( ! $item instanceof AuditEventRecord ) { $message = sprintf( 'AuditEventListResult: items[%d] must be an AuditEventRecord; got %s.', $expectedIndex, is_object( $item ) ? get_class( $item ) : gettype( $item ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } ++$expectedIndex; } if ( $total < 0 ) { $message = sprintf( 'AuditEventListResult: total must be >= 0; got %d.', $total ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( $page < 1 ) { $message = sprintf( 'AuditEventListResult: page must be >= 1; got %d.', $page ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( $perPage < 1 ) { $message = sprintf( 'AuditEventListResult: perPage must be >= 1; got %d.', $perPage ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } /** @var list $items */ $this->items = $items; $this->total = $total; $this->page = $page; $this->perPage = $perPage; } /** * Page slice, in newest-first order produced by the repository. * * @return list */ public function items(): array { return $this->items; } /** * Total row count across every page (after filtering, before * pagination). */ public function total(): int { return $this->total; } /** * 1-based page number echoed back from the originating query. */ public function page(): int { return $this->page; } /** * Records per page echoed back from the originating query. */ public function perPage(): int { return $this->perPage; } /** * Total number of pages the result envelope spans. * * Returns 0 when `$total === 0` (the documented empty-state * shape) and `(int) ceil($total / $perPage)` otherwise. */ public function pageCount(): int { if ( $this->total === 0 ) { return 0; } return (int) ceil( $this->total / $this->perPage ); } /** * Whether the result envelope carries no rows across every * page (not just the current slice). */ public function isEmpty(): bool { return $this->total === 0; } }