=1) and `perPage` (1..100). The * ceiling is wider than the public products list (50 in §5.1) * because the logs viewer is an admin-only diagnostics surface * where larger pages reduce click-through cost on incident * triage. * - Filter axis: `level` (PSR-3 closed set), `channel` (exact match), * `relatedProductId` and `relatedJobId` (positive ids). All * filters are optional; null means "do not constrain this axis". * * Spec §12.2 also lists `event_type`, `request_id`, `correlation_id`, * and `job_id` query parameters. Those four filters are deliberately * NOT exposed on this VO yet — they require JSON-extraction queries * against the `context_json` column (see `database-schema.md` §9 and * the {@see LogRecord} field-level docblock for why those structured * fields live inside the JSON blob rather than as dedicated columns). * The Phase 11 MVP exposes only the column-level filters; the JSON * filters are tracked as a Phase 11 / 12 follow-up so the logs admin * page can ship with column-filter support without waiting on the * JSON-extraction query plumbing. * * Sort axis is intentionally not part of this VO. Spec §12.2 does not * expose a sort parameter — the logs viewer is always newest-first * (ORDER BY `created_at DESC, id DESC`), and the repository * implementations interpolate that ordering directly. Reintroducing a * sort axis is a Phase 11 / 12 follow-up if the admin UX needs it. * * Design notes: * * - The PSR-3 level vocabulary is exposed as both a public class * constant array ({@see self::VALID_LEVELS}) and as a static * accessor ({@see self::validLevels()}) so call sites — REST * mappers, admin UI dropdowns, persistence implementations — can * enumerate the accepted set without duplicating the literals. * * - Validation is exhaustive at construction time so persistence * implementations may interpolate `level` directly into bound * SQL parameters without re-validating: by the time a query * reaches the repository, the level (when non-null) is guaranteed * to be on the whitelist. The wither methods replay the same * validation through the constructor so an off-whitelist token * cannot sneak in via a partial mutation. * * - `channel` is a nullable string; when supplied, it MUST be * non-empty after trimming. Empty-after-trim is rejected loudly * because the REST layer is responsible for coercing whitespace- * only inputs to "filter absent" before reaching this VO — * letting them through would silently match every row. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use InvalidArgumentException; /** * Immutable list-query inputs for {@see LogRepository::findPaginated()}. * * See the file-level docblock for the spec-driven shape of every axis, * the deferred JSON filter set tracked for Phase 11 / 12, and the * rationale for exhaustive constructor-time validation. */ final class LogListQuery { /** * Default per-page count. */ public const DEFAULT_PER_PAGE = 20; /** * Maximum per-page count. Wider than the public products list * (50 in §5.1) because the logs viewer is an admin-only * diagnostics surface. */ public const MAX_PER_PAGE = 100; /** * Closed-set list of PSR-3 severity levels accepted by the * `level` filter. * * Mirrors the PSR-3 `LogLevel` constants verbatim so call sites * can validate against this list without importing the PSR-3 * package at the persistence layer. * * @var list */ public const VALID_LEVELS = array( 'emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug', ); /** * PSR-3 level exact-match filter, or null when absent. */ private ?string $level; /** * Channel exact-match filter, or null when absent. */ private ?string $channel; /** * `affilun_logs.related_product_id` filter, or null when absent. */ private ?int $relatedProductId; /** * `affilun_logs.related_job_id` filter, or null when absent. */ private ?int $relatedJobId; /** * 1-based page number. */ private int $page; /** * Number of records per page (1..MAX_PER_PAGE). */ private int $perPage; /** * Construct the query with full validation. * * Mirrors the positional-plus-overrides idiom used by * {@see ProductListQuery}: every axis has a positional parameter * with a sensible default, and the `$overrides` array lets a * named-keyword call site specify a single axis without restating * the others. * * @param string|null $level PSR-3 level filter, or null. * @param string|null $channel Channel filter, or null. * @param int|null $relatedProductId Related product id filter (>=1), or null. * @param int|null $relatedJobId Related job id filter (>=1), or null. * @param int $page 1-based page number (>=1). * @param int $perPage Records per page (1..MAX_PER_PAGE). * @param array $overrides Named-keyword overrides keyed * by `level` / `channel` / * `relatedProductId` / * `relatedJobId` / `page` / * `perPage`. Unknown keys are * ignored so callers can pass * request payloads verbatim * without filtering. * * @throws InvalidArgumentException When any axis is out of range * or off-whitelist. */ public function __construct( ?string $level = null, ?string $channel = null, ?int $relatedProductId = null, ?int $relatedJobId = null, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, array $overrides = array() ) { if ( array_key_exists( 'level', $overrides ) ) { /** @var string|null $level */ $level = $overrides['level']; } if ( array_key_exists( 'channel', $overrides ) ) { /** @var string|null $channel */ $channel = $overrides['channel']; } if ( array_key_exists( 'relatedProductId', $overrides ) ) { /** @var int|null $relatedProductId */ $relatedProductId = $overrides['relatedProductId']; } if ( array_key_exists( 'relatedJobId', $overrides ) ) { /** @var int|null $relatedJobId */ $relatedJobId = $overrides['relatedJobId']; } if ( array_key_exists( 'page', $overrides ) ) { /** @var int $page */ $page = (int) $overrides['page']; } if ( array_key_exists( 'perPage', $overrides ) ) { /** @var int $perPage */ $perPage = (int) $overrides['perPage']; } if ( $level !== null && ! in_array( $level, self::VALID_LEVELS, true ) ) { $message = sprintf( 'LogListQuery: level "%s" is not a PSR-3 level [%s].', $level, implode( ', ', self::VALID_LEVELS ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $channel !== null && trim( $channel ) === '' ) { $message = 'LogListQuery: channel must be null or a non-empty (post-trim) string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $relatedProductId !== null && $relatedProductId < 1 ) { $message = sprintf( 'LogListQuery: relatedProductId must be null or >= 1, got %d.', $relatedProductId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $relatedJobId !== null && $relatedJobId < 1 ) { $message = sprintf( 'LogListQuery: relatedJobId must be null or >= 1, got %d.', $relatedJobId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $page < 1 ) { $message = sprintf( 'LogListQuery: page must be >= 1, got %d.', $page ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $perPage < 1 || $perPage > self::MAX_PER_PAGE ) { $message = sprintf( 'LogListQuery: perPage must be between 1 and %d, got %d.', self::MAX_PER_PAGE, $perPage ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->level = $level; $this->channel = $channel; $this->relatedProductId = $relatedProductId; $this->relatedJobId = $relatedJobId; $this->page = $page; $this->perPage = $perPage; } /** * Default query: page 1, 20 per page, no filters. */ public static function defaults(): self { return new self(); } /** * Closed-set list of PSR-3 levels accepted by the `level` filter. * * Static accessor mirroring the {@see ProductListQuery::validXxx()} * idiom so call sites can enumerate the vocabulary without * referencing the constant directly. * * @return list */ public static function validLevels(): array { return self::VALID_LEVELS; } /** * PSR-3 level exact-match filter, or null when absent. */ public function level(): ?string { return $this->level; } /** * Channel exact-match filter, or null when absent. */ public function channel(): ?string { return $this->channel; } /** * `affilun_logs.related_product_id` filter, or null when absent. */ public function relatedProductId(): ?int { return $this->relatedProductId; } /** * `affilun_logs.related_job_id` filter, or null when absent. */ public function relatedJobId(): ?int { return $this->relatedJobId; } /** * 1-based page number. */ public function page(): int { return $this->page; } /** * Number of records per page. */ public function perPage(): int { return $this->perPage; } /** * Zero-based offset into the result set, derived from `page` and * `perPage`. * * Convenience accessor for repository implementations that map * the pagination axis to a SQL `LIMIT $perPage OFFSET $offset` * pair. */ public function offset(): int { return ( $this->page - 1 ) * $this->perPage; } /** * Return a new query with the given page number. * * @param int $page 1-based page number (>=1). * * @throws InvalidArgumentException When `$page < 1`. */ public function withPage( int $page ): self { return new self( $this->level, $this->channel, $this->relatedProductId, $this->relatedJobId, $page, $this->perPage ); } /** * Return a new query with the given per-page count. * * @param int $perPage Records per page (1..MAX_PER_PAGE). * * @throws InvalidArgumentException When `$perPage` is out of range. */ public function withPerPage( int $perPage ): self { return new self( $this->level, $this->channel, $this->relatedProductId, $this->relatedJobId, $this->page, $perPage ); } /** * Deep equality against another query. * * @param self $other Query to compare. */ public function equals( self $other ): bool { return $this->level === $other->level && $this->channel === $other->channel && $this->relatedProductId === $other->relatedProductId && $this->relatedJobId === $other->relatedJobId && $this->page === $other->page && $this->perPage === $other->perPage; } }