=1) and `perPage` (1..50, matching the * §5.1 public ceiling). * - Filter axis: `provider`, `search`, `syncState` (the four-value * freshness enum from §5.3 / §6.0), `usedInPostId`, and * `lifecycleState` (the three-value lifecycle enum from §5.3 / * §6.0). All filters are optional; null means "do not constrain * this axis". * - Sort axis: `sortKey` (one of `updated_at` / `created_at` / * `title` / `last_synced_at`, per §8.2) and `sortOrder` (`asc` / * `desc`, per §5.2). Defaults are `updated_at` / `desc`, matching * the spec's "newest activity first" admin-list convention. * * Design notes: * * - Closed-set tokens (`syncState`, `lifecycleState`, `sortKey`, * `sortOrder`) are exposed as public class constants and as * static `validXxx()` lists so call sites — REST mappers, admin * UI dropdowns, persistence implementations — can enumerate the * accepted vocabulary without duplicating the literals. * * - Validation is exhaustive at construction time so persistence * implementations may interpolate `sortKey` and `sortOrder` * directly into SQL without re-validating: by the time a query * reaches the repository, every closed-set token 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. * * - `provider` and `search` are nullable strings; when supplied, * they 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 ProductRepository::findPaginated()}. * * See the file-level docblock for the spec-driven shape of every axis * and the rationale for exhaustive constructor-time validation. */ final class ProductListQuery { /** * Sort by `updated_at` (default). */ public const SORT_KEY_UPDATED_AT = 'updated_at'; /** * Sort by `created_at`. */ public const SORT_KEY_CREATED_AT = 'created_at'; /** * Sort by `title`. */ public const SORT_KEY_TITLE = 'title'; /** * Sort by `last_synced_at`. */ public const SORT_KEY_LAST_SYNCED_AT = 'last_synced_at'; /** * Ascending sort order. */ public const SORT_ORDER_ASC = 'asc'; /** * Descending sort order (default). */ public const SORT_ORDER_DESC = 'desc'; /** * Sync-state freshness filter: `fresh`. */ public const SYNC_STATE_FRESH = 'fresh'; /** * Sync-state freshness filter: `stale`. */ public const SYNC_STATE_STALE = 'stale'; /** * Sync-state freshness filter: `expired`. */ public const SYNC_STATE_EXPIRED = 'expired'; /** * Sync-state freshness filter: `broken`. */ public const SYNC_STATE_BROKEN = 'broken'; /** * Lifecycle filter: `active`. */ public const LIFECYCLE_STATE_ACTIVE = 'active'; /** * Lifecycle filter: `archived`. */ public const LIFECYCLE_STATE_ARCHIVED = 'archived'; /** * Lifecycle filter: `deleted_pending`. */ public const LIFECYCLE_STATE_DELETED_PENDING = 'deleted_pending'; /** * Default per-page count (per §5.1). */ private const DEFAULT_PER_PAGE = 20; /** * Maximum per-page count (per §5.1). */ private const MAX_PER_PAGE = 50; /** * 1-based page number. */ private int $page; /** * Number of records per page (1..50). */ private int $perPage; /** * Provider exact-match filter, or null when absent. */ private ?string $provider; /** * Title substring filter, or null when absent. */ private ?string $search; /** * Sync-state exact-match filter, or null when absent. */ private ?string $syncState; /** * `affilun_embeds.post_id` filter, or null when absent. * * Currently accepted but not honored by either repository * implementation; honored in Phase 9 once the sync / usage * table lands. */ private ?int $usedInPostId; /** * Lifecycle-state exact-match filter, or null when absent. */ private ?string $lifecycleState; /** * Closed-set sort key. */ private string $sortKey; /** * Closed-set sort order. */ private string $sortOrder; /** * Construct the query with full validation. * * @param int $page 1-based page number (>=1). * @param int $perPage Records per page (1..50). * @param string|null $provider Provider exact-match filter, or null. * @param string|null $search Title substring filter, or null. * @param string|null $syncState Sync-state filter (closed set), or null. * @param int|null $usedInPostId `affilun_embeds.post_id` filter (>=1), or null. * @param string|null $lifecycleState Lifecycle filter (closed set), or null. * @param string $sortKey Sort key (closed set). * @param string $sortOrder Sort order (closed set). * * @throws InvalidArgumentException When any axis is out of range * or off-whitelist. */ public function __construct( int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, ?string $provider = null, ?string $search = null, ?string $syncState = null, ?int $usedInPostId = null, ?string $lifecycleState = null, string $sortKey = self::SORT_KEY_UPDATED_AT, string $sortOrder = self::SORT_ORDER_DESC ) { if ( $page < 1 ) { $message = sprintf( 'ProductListQuery: 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( 'ProductListQuery: 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 ); } if ( $provider !== null && trim( $provider ) === '' ) { $message = 'ProductListQuery: provider 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 ( $search !== null && trim( $search ) === '' ) { $message = 'ProductListQuery: search 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 ( $syncState !== null && ! in_array( $syncState, self::validSyncStates(), true ) ) { $message = sprintf( 'ProductListQuery: syncState "%s" is not in the closed set [%s].', $syncState, implode( ', ', self::validSyncStates() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $usedInPostId !== null && $usedInPostId < 1 ) { $message = sprintf( 'ProductListQuery: usedInPostId must be null or >= 1, got %d.', $usedInPostId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $lifecycleState !== null && ! in_array( $lifecycleState, self::validLifecycleStates(), true ) ) { $message = sprintf( 'ProductListQuery: lifecycleState "%s" is not in the closed set [%s].', $lifecycleState, implode( ', ', self::validLifecycleStates() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! in_array( $sortKey, self::validSortKeys(), true ) ) { $message = sprintf( 'ProductListQuery: sortKey "%s" is not in the closed set [%s].', $sortKey, implode( ', ', self::validSortKeys() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! in_array( $sortOrder, self::validSortOrders(), true ) ) { $message = sprintf( 'ProductListQuery: sortOrder "%s" is not in the closed set [%s].', $sortOrder, implode( ', ', self::validSortOrders() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->page = $page; $this->perPage = $perPage; $this->provider = $provider; $this->search = $search; $this->syncState = $syncState; $this->usedInPostId = $usedInPostId; $this->lifecycleState = $lifecycleState; $this->sortKey = $sortKey; $this->sortOrder = $sortOrder; } /** * Default query: page 1, 20 per page, no filters, sort by * `updated_at` desc — the spec's "newest activity first" default. */ public static function defaults(): self { return new self(); } /** * Closed-set list of accepted sort keys (per §8.2). * * @return list */ public static function validSortKeys(): array { return array( self::SORT_KEY_UPDATED_AT, self::SORT_KEY_CREATED_AT, self::SORT_KEY_TITLE, self::SORT_KEY_LAST_SYNCED_AT, ); } /** * Closed-set list of accepted sort orders (per §5.2). * * @return list */ public static function validSortOrders(): array { return array( self::SORT_ORDER_ASC, self::SORT_ORDER_DESC, ); } /** * Closed-set list of accepted sync-state filters (per §5.3 / §6.0). * * @return list */ public static function validSyncStates(): array { return array( self::SYNC_STATE_FRESH, self::SYNC_STATE_STALE, self::SYNC_STATE_EXPIRED, self::SYNC_STATE_BROKEN, ); } /** * Closed-set list of accepted lifecycle-state filters (per §5.3 / §6.0). * * @return list */ public static function validLifecycleStates(): array { return array( self::LIFECYCLE_STATE_ACTIVE, self::LIFECYCLE_STATE_ARCHIVED, self::LIFECYCLE_STATE_DELETED_PENDING, ); } /** * 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( $page, $this->perPage, $this->provider, $this->search, $this->syncState, $this->usedInPostId, $this->lifecycleState, $this->sortKey, $this->sortOrder ); } /** * Return a new query with the given per-page count. * * @param int $perPage Records per page (1..50). * * @throws InvalidArgumentException When `$perPage` is out of range. */ public function withPerPage( int $perPage ): self { return new self( $this->page, $perPage, $this->provider, $this->search, $this->syncState, $this->usedInPostId, $this->lifecycleState, $this->sortKey, $this->sortOrder ); } /** * Return a new query with the given provider filter (or null). * * @param string|null $provider Provider exact-match filter, or null. * * @throws InvalidArgumentException When `$provider` is empty after trim. */ public function withProvider( ?string $provider ): self { return new self( $this->page, $this->perPage, $provider, $this->search, $this->syncState, $this->usedInPostId, $this->lifecycleState, $this->sortKey, $this->sortOrder ); } /** * Return a new query with the given search filter (or null). * * @param string|null $search Title substring filter, or null. * * @throws InvalidArgumentException When `$search` is empty after trim. */ public function withSearch( ?string $search ): self { return new self( $this->page, $this->perPage, $this->provider, $search, $this->syncState, $this->usedInPostId, $this->lifecycleState, $this->sortKey, $this->sortOrder ); } /** * Return a new query with the given sync-state filter (or null). * * @param string|null $syncState Sync-state filter (closed set), or null. * * @throws InvalidArgumentException When `$syncState` is off-whitelist. */ public function withSyncState( ?string $syncState ): self { return new self( $this->page, $this->perPage, $this->provider, $this->search, $syncState, $this->usedInPostId, $this->lifecycleState, $this->sortKey, $this->sortOrder ); } /** * Return a new query with the given used-in-post-id filter (or null). * * @param int|null $usedInPostId `affilun_embeds.post_id` filter (>=1), or null. * * @throws InvalidArgumentException When `$usedInPostId < 1`. */ public function withUsedInPostId( ?int $usedInPostId ): self { return new self( $this->page, $this->perPage, $this->provider, $this->search, $this->syncState, $usedInPostId, $this->lifecycleState, $this->sortKey, $this->sortOrder ); } /** * Return a new query with the given lifecycle-state filter (or null). * * @param string|null $lifecycleState Lifecycle filter (closed set), or null. * * @throws InvalidArgumentException When `$lifecycleState` is off-whitelist. */ public function withLifecycleState( ?string $lifecycleState ): self { return new self( $this->page, $this->perPage, $this->provider, $this->search, $this->syncState, $this->usedInPostId, $lifecycleState, $this->sortKey, $this->sortOrder ); } /** * Return a new query with the given sort axis. * * @param string $key Sort key (closed set). * @param string $order Sort order (closed set). * * @throws InvalidArgumentException When either argument is off-whitelist. */ public function withSort( string $key, string $order ): self { return new self( $this->page, $this->perPage, $this->provider, $this->search, $this->syncState, $this->usedInPostId, $this->lifecycleState, $key, $order ); } /** * 1-based page number. */ public function page(): int { return $this->page; } /** * Number of records per page. */ public function perPage(): int { return $this->perPage; } /** * Provider exact-match filter, or null when absent. */ public function provider(): ?string { return $this->provider; } /** * Title substring filter, or null when absent. */ public function search(): ?string { return $this->search; } /** * Sync-state exact-match filter, or null when absent. */ public function syncState(): ?string { return $this->syncState; } /** * `affilun_embeds.post_id` filter, or null when absent. */ public function usedInPostId(): ?int { return $this->usedInPostId; } /** * Lifecycle-state exact-match filter, or null when absent. */ public function lifecycleState(): ?string { return $this->lifecycleState; } /** * Closed-set sort key. */ public function sortKey(): string { return $this->sortKey; } /** * Closed-set sort order. */ public function sortOrder(): string { return $this->sortOrder; } /** * Deep equality against another query. * * @param self $other Query to compare. */ public function equals( self $other ): bool { return $this->page === $other->page && $this->perPage === $other->perPage && $this->provider === $other->provider && $this->search === $other->search && $this->syncState === $other->syncState && $this->usedInPostId === $other->usedInPostId && $this->lifecycleState === $other->lifecycleState && $this->sortKey === $other->sortKey && $this->sortOrder === $other->sortOrder; } }