=1) and `perPage` (1..100). The * ceiling matches {@see LogListQuery} from slice 11a — both are * admin-only diagnostics surfaces where larger pages reduce * click-through cost on incident triage. * - Filter axis: `status` (DB vocabulary closed set) and `jobType` * (closed-set whitelist from `sync-and-cache-strategy.md` §10.1). * Both filters are optional; null means "do not constrain this * axis". * * Wire ⇔ DB status mapping: * * At the wire (REST / UI) the `status` axis uses the vocabulary * `queued|running|completed|failed|cancelled`; at the DB level * (`affilun_sync_jobs.status`, see `database-schema.md` §8.2) the * vocabulary is `pending|running|completed|failed|cancelled`. Per * `rest-api-spec.md` §6.0, wire `queued` corresponds to DB * `pending`. This value object exposes the DB-level vocabulary * directly; the REST controller in slice 11e is responsible for * translating wire ⇔ DB so that the persistence layer never has to * reason about the wire-side spelling. * * Sort axis is intentionally not part of this VO. Spec §11.3 does not * expose a sort parameter — the sync-jobs viewer is always * newest-first (ORDER BY `created_at DESC, id DESC`), and the * repository implementations interpolate that ordering directly. * * Design notes: * * - Both vocabularies are exposed as public class constants * ({@see self::VALID_STATUSES}, {@see self::VALID_JOB_TYPES}) and * as static accessors ({@see self::validStatuses()}, * {@see self::validJobTypes()}) 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 the filter values directly into * bound SQL parameters without re-validating. * * - `jobType` is trimmed at the boundary; whitespace-only inputs * are rejected loudly because the REST layer is responsible for * coercing them to "filter absent" before reaching this VO. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use InvalidArgumentException; /** * Immutable list-query inputs for {@see SyncJobRepository::findPaginated()}. * * See the file-level docblock for the spec-driven shape of every axis, * the wire ⇔ DB status mapping, and the rationale for exhaustive * constructor-time validation. */ final class SyncJobListQuery { /** * Default per-page count. */ public const DEFAULT_PER_PAGE = 20; /** * Maximum per-page count. Matches {@see LogListQuery::MAX_PER_PAGE} * because both surfaces are admin-only diagnostics views. */ public const MAX_PER_PAGE = 100; /** * Closed-set list of DB-vocabulary status values accepted by the * `status` filter. * * Mirrors `database-schema.md` §8.2 verbatim. Note that wire * `queued` maps to DB `pending` per `rest-api-spec.md` §6.0; the * REST layer is responsible for that translation before reaching * this VO. * * @var list */ public const VALID_STATUSES = array( 'pending', 'running', 'completed', 'failed', 'cancelled', ); /** * Closed-set whitelist of job-type discriminators accepted by the * `jobType` filter. * * Mirrors the catalog declared in * `specifications/sync-and-cache-strategy.md` §10.1. * * @var list */ public const VALID_JOB_TYPES = array( 'sync_product', 'sync_products_bulk', 'rebuild_render_cache', 'recalculate_usage_index', 'cleanup_logs', 'refresh_provider_health', ); /** * DB-vocabulary status exact-match filter, or null when absent. */ private ?string $status; /** * Job-type exact-match filter, or null when absent. */ private ?string $jobType; /** * 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 LogListQuery}: 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 $status DB-vocabulary status filter, * or null. One of * `pending|running|completed|failed|cancelled`. * @param string|null $jobType Job-type filter, or null. * Must be on the * {@see self::VALID_JOB_TYPES} * whitelist. * @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 `status` / * `jobType` / `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 $status = null, ?string $jobType = null, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, array $overrides = array() ) { if ( array_key_exists( 'status', $overrides ) ) { /** @var string|null $status */ $status = $overrides['status']; } if ( array_key_exists( 'jobType', $overrides ) ) { /** @var string|null $jobType */ $jobType = $overrides['jobType']; } 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 ( $status !== null && ! in_array( $status, self::VALID_STATUSES, true ) ) { $message = sprintf( 'SyncJobListQuery: status "%s" is not a DB-vocabulary value [%s]. ' . 'Wire-vocabulary "queued" must be mapped to DB "pending" by the REST layer ' . 'per rest-api-spec.md §6.0.', $status, implode( ', ', self::VALID_STATUSES ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $jobType !== null ) { $trimmed = trim( $jobType ); if ( $trimmed === '' ) { $message = 'SyncJobListQuery: jobType 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 ( ! in_array( $trimmed, self::VALID_JOB_TYPES, true ) ) { $message = sprintf( 'SyncJobListQuery: jobType "%s" is not on the whitelist [%s] ' . '(see sync-and-cache-strategy.md §10.1).', $trimmed, implode( ', ', self::VALID_JOB_TYPES ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $jobType = $trimmed; } if ( $page < 1 ) { $message = sprintf( 'SyncJobListQuery: 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( 'SyncJobListQuery: 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->status = $status; $this->jobType = $jobType; $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 DB-vocabulary status values accepted by the * `status` filter. * * @return list */ public static function validStatuses(): array { return self::VALID_STATUSES; } /** * Closed-set whitelist of job-type discriminators accepted by the * `jobType` filter. * * @return list */ public static function validJobTypes(): array { return self::VALID_JOB_TYPES; } /** * DB-vocabulary status exact-match filter, or null when absent. */ public function status(): ?string { return $this->status; } /** * Job-type exact-match filter, or null when absent. */ public function jobType(): ?string { return $this->jobType; } /** * 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->status, $this->jobType, $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->status, $this->jobType, $this->page, $perPage ); } /** * Deep equality against another query. * * @param self $other Query to compare. */ public function equals( self $other ): bool { return $this->status === $other->status && $this->jobType === $other->jobType && $this->page === $other->page && $this->perPage === $other->perPage; } }