= 1`. The DTO does not enforce a per-provider upper bound * because the bound is provider-specific (PA-API caps differ * from 楽天 / Yahoo!ショッピング caps); the §7.1 * "provider 非対応の条件は validation error" rule binds the * provider implementation, not this DTO. * * - `perPage` is the page size. Required and validated as * `>= 1`. The same per-provider cap argument applies as for * `page`: providers reject out-of-range values themselves. * * - `sort` is the sort-order discriminator (`relevance`, * `price_asc`, `price_desc`, `popularity`, ...). Held as a * plain string at this layer; the closed set is enforced by * the provider implementation that knows which sort orders * it supports. Nullable because most surfaces accept "let * the provider pick the default". * * - `category` is the provider-native category identifier when * the caller wants to scope the search. Nullable. * * - `minPrice` / `maxPrice` are the price-range bounds, as the * same canonical decimal-string form * {@see \Affilun\Domain\Pricing\PriceInfo::amount} * uses. Nullable (either bound or both). When both are * supplied, `minPrice <= maxPrice` is enforced per §7.1. * * - `filters` is the structured filter map (brand, condition, * prime-only, ...). Held as `array` — * the same shape `ProductSnapshot::attributes` uses — so the * rendering / mapping layers do not have to special-case * search filters separately from product attributes. Empty * map by default. * * - `locale` is the BCP-47 language / region tag for the * search (`ja-JP`, `en-US`, ...) when the caller wants to * pin it. Nullable; providers fall back to their own default * when null. * * - `currency` is the ISO 4217 alphabetic code the caller * prefers for prices in the response (`JPY`, `USD`). Held * uppercase per the same convention {@see PriceInfo::currency} * uses. Nullable. * * - `context` is the free-form contextual tag the application * layer uses to mark the request's origin * ("admin_search", "block_picker", "cron_resync", ...). * Nullable. Held verbatim; consuming surfaces are expected * to use it as an opaque correlation hint, not as a * branching key. * * Instances are immutable. Two mutation helpers are provided * because they map to distinct, frequent application-layer flows: * * - {@see self::withPage()} produces a new query with the page * number advanced — used by paginated REST endpoints and by * the sync layer's "iterate every page" loop. * * - {@see self::withPerPage()} produces a new query with the * page size adjusted — used when a caller wants to retry a * query with a smaller batch after hitting a provider's * cap, without rewriting the rest of the parameters. * * Other fields have no in-place edit path because changing them * (different keyword, different price range) is a different * search and should be modeled as a fresh {@see SearchQuery}. * * @package Affilun\Domain\Search */ declare(strict_types=1); namespace Affilun\Domain\Search; use InvalidArgumentException; /** * Immutable normalized search-query input. * * See the file-level docblock for the mapping to * `provider-api-contracts.md` §7.1, the per-field validation * rules, and the rationale for the two `with*()` mutation * helpers exposed here. */ final class SearchQuery { /** * User-supplied search keyword per §7.1. Required. */ private string $keyword; /** * 1-based page number per §7.1. */ private int $page; /** * Page size per §7.1. */ private int $perPage; /** * Sort-order discriminator, or null when the caller wants * the provider's default ordering. */ private ?string $sort; /** * Provider-native category identifier, or null. */ private ?string $category; /** * Price-range minimum as a canonical decimal string, or null. */ private ?string $minPrice; /** * Price-range maximum as a canonical decimal string, or null. */ private ?string $maxPrice; /** * Structured filter map keyed by canonical filter name. * * @var array */ private array $filters; /** * BCP-47 locale tag, or null when the caller does not pin one. */ private ?string $locale; /** * ISO 4217 currency code in uppercase, or null when the caller * does not pin one. */ private ?string $currency; /** * Free-form contextual tag for correlation, or null. */ private ?string $context; /** * Construct a query with explicit values for every field. * * The constructor is exhaustive so a programming error * (missing required field on a write, swapped min / max * price) surfaces at the type system boundary rather than at * the provider implementation. * * Validation summary: * * - `keyword` is trimmed and rejected when empty. * - `page` and `perPage` are rejected for `< 1` values. * - `sort`, `category`, `locale`, `context`, when non-null, * are trimmed and rejected when empty after trimming. * - `minPrice`, `maxPrice`, when non-null, are validated as * non-negative decimal strings using the same grammar * {@see \Affilun\Domain\Pricing\PriceInfo} * accepts. When both are non-null, `minPrice <= * maxPrice` is enforced via numeric comparison on the * canonical strings. * - `currency`, when non-null, is trimmed and uppercased; * an empty result raises {@see InvalidArgumentException}. * - `filters` keys MUST be non-empty strings; values MUST * be scalar or null. * * @param string $keyword User-supplied keyword. * @param int $page 1-based page number. * @param int $perPage Page size. * @param string|null $sort Sort-order discriminator. * @param string|null $category Provider-native category id. * @param string|null $minPrice Price-range minimum. * @param string|null $maxPrice Price-range maximum. * @param array $filters Structured filters. * @param string|null $locale BCP-47 locale tag. * @param string|null $currency ISO 4217 currency code. * @param string|null $context Free-form correlation tag. * * @throws InvalidArgumentException When any field violates the * §7.1 contract or the * cross-field validation rules * summarized above. */ public function __construct( string $keyword, int $page, int $perPage, ?string $sort, ?string $category, ?string $minPrice, ?string $maxPrice, array $filters, ?string $locale, ?string $currency, ?string $context ) { $normalizedKeyword = trim( $keyword ); if ( $normalizedKeyword === '' ) { $message = 'SearchQuery keyword must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $page < 1 ) { $message = sprintf( 'SearchQuery 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 ) { $message = sprintf( 'SearchQuery perPage must be >= 1; got %d.', $perPage ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalizedMin = self::normalizePriceBound( 'minPrice', $minPrice ); $normalizedMax = self::normalizePriceBound( 'maxPrice', $maxPrice ); if ( $normalizedMin !== null && $normalizedMax !== null ) { if ( self::comparePriceStrings( $normalizedMin, $normalizedMax ) > 0 ) { $message = sprintf( 'SearchQuery minPrice (%s) must be <= maxPrice (%s).', $normalizedMin, $normalizedMax ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } $this->keyword = $normalizedKeyword; $this->page = $page; $this->perPage = $perPage; $this->sort = self::normalizeOptionalString( 'sort', $sort ); $this->category = self::normalizeOptionalString( 'category', $category ); $this->minPrice = $normalizedMin; $this->maxPrice = $normalizedMax; $this->filters = self::normalizeFilters( $filters ); $this->locale = self::normalizeOptionalString( 'locale', $locale ); $this->currency = self::normalizeCurrency( $currency ); $this->context = self::normalizeOptionalString( 'context', $context ); } /** * User-supplied search keyword. */ public function keyword(): string { return $this->keyword; } /** * 1-based page number. */ public function page(): int { return $this->page; } /** * Page size. */ public function perPage(): int { return $this->perPage; } /** * Sort-order discriminator, or null. */ public function sort(): ?string { return $this->sort; } /** * Provider-native category identifier, or null. */ public function category(): ?string { return $this->category; } /** * Price-range minimum as a canonical decimal string, or null. */ public function minPrice(): ?string { return $this->minPrice; } /** * Price-range maximum as a canonical decimal string, or null. */ public function maxPrice(): ?string { return $this->maxPrice; } /** * Structured filter map keyed by canonical filter name. * * @return array */ public function filters(): array { return $this->filters; } /** * BCP-47 locale tag, or null. */ public function locale(): ?string { return $this->locale; } /** * ISO 4217 currency code in uppercase, or null. */ public function currency(): ?string { return $this->currency; } /** * Free-form correlation tag, or null. */ public function context(): ?string { return $this->context; } /** * Produce a copy of this query with `page` set to the supplied * value. * * Used by paginated REST endpoints and by the sync layer's * "iterate every page" loop. Other fields are preserved * verbatim. * * @param int $page New 1-based page number. * * @throws InvalidArgumentException When `$page` is `< 1`. */ public function withPage( int $page ): self { if ( $page < 1 ) { $message = sprintf( 'SearchQuery::withPage() requires page >= 1; got %d.', $page ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $clone->page = $page; return $clone; } /** * Produce a copy of this query with `perPage` set to the * supplied value. * * Used when a caller wants to retry a query with a smaller * batch after hitting a provider's cap. Other fields are * preserved verbatim. * * @param int $perPage New page size. * * @throws InvalidArgumentException When `$perPage` is `< 1`. */ public function withPerPage( int $perPage ): self { if ( $perPage < 1 ) { $message = sprintf( 'SearchQuery::withPerPage() requires perPage >= 1; got %d.', $perPage ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $clone->perPage = $perPage; return $clone; } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * Used for `sort`, `category`, `locale`, and `context` where * "present but empty" is a programming error rather than a * meaningful state. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private static function normalizeOptionalString( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'SearchQuery %s must be null or a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize a nullable price-bound field. * * Accepts the same decimal-string grammar * {@see \Affilun\Domain\Pricing\PriceInfo::amount} * accepts: optional leading `+`, optional fractional part * separated by a literal `.`, no scientific notation, no * thousands separators, no embedded whitespace, no negative * values. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @throws InvalidArgumentException When `$value` does not * match the accepted decimal * grammar. */ private static function normalizePriceBound( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'SearchQuery %s must be null or a non-empty decimal string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $trimmed[0] === '+' ) { $trimmed = substr( $trimmed, 1 ); } if ( preg_match( '/\A(?:\d+|\d+\.\d+|\.\d+)\z/', $trimmed ) !== 1 ) { $message = sprintf( 'SearchQuery %s must be a non-negative decimal string; got "%s".', $field, $value ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `currency` field at construction. * * Null passes through unchanged. A non-null value is trimmed * and uppercased; an empty result is rejected. * * @param string|null $currency Raw currency code, or null. * * @throws InvalidArgumentException When `$currency` is the * empty string after trimming. */ private static function normalizeCurrency( ?string $currency ): ?string { if ( $currency === null ) { return null; } $trimmed = trim( $currency ); if ( $trimmed === '' ) { $message = 'SearchQuery currency must be null or a non-empty ISO 4217 code.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return strtoupper( $trimmed ); } /** * Validate and normalize the filters map. * * Keys MUST be non-empty strings (after trimming) and values * MUST be scalar or null. Nested arrays / objects are rejected * because §7.1 expects a flat filter surface that providers * can map to query parameters. * * @param array $filters Raw filter map. * * @return array * * @throws InvalidArgumentException When any key is not a * non-empty string or any * value is not scalar / null. */ private static function normalizeFilters( array $filters ): array { $normalized = array(); foreach ( $filters as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'SearchQuery filters keys must be strings; got %s.', gettype( $key ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmedKey = trim( $key ); if ( $trimmedKey === '' ) { $message = 'SearchQuery filters keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $value !== null && ! is_scalar( $value ) ) { $message = sprintf( 'SearchQuery filters["%s"] must be scalar or null; got %s.', $trimmedKey, gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $trimmedKey ] = $value; } return $normalized; } /** * Compare two canonical decimal strings as numeric values. * * Used to enforce the `minPrice <= maxPrice` invariant. PHP's * `bccomp()` is used when available so the comparison stays * exact even for high-precision decimals; otherwise a simple * float comparison falls through. In practice the bcmath * extension ships with every modern PHP build the project * targets (PHP 8.1+, see specifications/coding-standards.md * §3.1) so the fallback is purely defensive. * * @param string $left Canonical decimal-string value. * @param string $right Canonical decimal-string value. * * @return int Negative when `$left < $right`, zero when equal, * positive when `$left > $right`. */ private static function comparePriceStrings( string $left, string $right ): int { if ( function_exists( 'bccomp' ) ) { return bccomp( $left, $right, 8 ); } $leftNum = (float) $left; $rightNum = (float) $right; if ( $leftNum < $rightNum ) { return -1; } if ( $leftNum > $rightNum ) { return 1; } return 0; } }