` shape * (no gaps, no string keys) that JSON-encodes as a JSON array * rather than as an object. May be empty when the search * matched nothing. * * - `page` is the 1-based page number of the page this result * represents — typically the `page` value from the * {@see SearchQuery} that produced it. Required and validated * as `>= 1`. * * - `perPage` is the page size requested when this page was * fetched. Required and validated as `>= 1`. Carried alongside * `total` / `hasMore` so consuming surfaces can answer "how * many more pages are there?" without having to remember the * original query. * * - `total` is the total number of items across every page when * the provider exposes that count, or null per §7.2 * ("`total` が不明な provider では `null` 可"). Some providers * (notably PA-API on certain query shapes) deliberately do not * return a total, and the rendering layer MUST tolerate the * null case rather than asserting on the count. * * - `hasMore` is the explicit "is there another page after this * one?" flag per §7.2 ("`hasMore` は UI のページング判断に使 * えること"). It is the canonical answer to that question * because: * * * `total` may be null, so subtract-and-compare arithmetic * from `(total, page, perPage)` is not always available. * * Some providers return a smaller-than-`perPage` page * even when more pages exist (cursor-style pagination), * so `count($items) < $perPage` is not a reliable signal * either. * * Provider implementations are responsible for synthesizing * this flag from whatever signal their underlying API exposes. * * - `rawMeta` is the provider-native metadata blob preserved for * debugging — request id, rate-limit headers, total-pages * value, etc. Held as an associative array; per §7.2 * ("`rawMeta` は内部診断用の補助情報であり UI 直結ではない") * consuming surfaces MUST NOT branch on its contents. * Validated only to reject non-string keys, matching the * {@see ProductSnapshot::rawMeta()} convention. * * Instances are immutable. There are no `with*()` mutation helpers * because a search result represents a single response from a * single page fetch: any change in the underlying data should be * modeled as a fresh provider call producing a fresh * {@see SearchResult}, not as an in-place edit of an existing one. * * @package Affilun\Domain\Search */ declare(strict_types=1); namespace Affilun\Domain\Search; use Affilun\Domain\Product\ProductSnapshot; use InvalidArgumentException; /** * Immutable normalized search-response payload. * * See the file-level docblock for the mapping to * `provider-api-contracts.md` §7.2, the cross-provider field * conventions (notably the `(provider, items[].provider)` * cross-check and the `total` / `hasMore` independence), and the * rationale for keeping the type free of mutation helpers. */ final class SearchResult { /** * Stable provider key per §4 that produced this result. * * Required to match every contained snapshot's * {@see ProductSnapshot::provider()} so a programming error * that mixes results across providers surfaces at construction * time rather than at the rendering layer. */ private string $provider; /** * Matched product snapshots, re-keyed to a positional list. * * @var list */ private array $items; /** * 1-based page number this result represents. */ private int $page; /** * Page size requested when this page was fetched. */ private int $perPage; /** * Total number of items across every page, or null when the * provider does not expose a count per §7.2. */ private ?int $total; /** * Explicit "is there another page after this one?" flag per * §7.2. */ private bool $hasMore; /** * Provider-native metadata blob preserved for diagnostics. * * @var array */ private array $rawMeta; /** * Construct a result with explicit values for every field. * * The constructor is exhaustive so a programming error * (missing required field on a write, swapped page / perPage) * surfaces at the type system boundary rather than at the * rendering layer. Named-argument construction is the * ergonomic entry point for most call sites. * * Validation summary: * * - `provider` is trimmed and rejected when empty. * - Every `items` entry MUST be a {@see ProductSnapshot} * whose `provider()` equals the supplied `$provider` key; * mismatched entries raise * {@see InvalidArgumentException} carrying the offending * entry's index and provider key for diagnosis. * - `items` is re-keyed to a positional list so callers * always see a `list<>` shape regardless of what the * caller passed in. * - `page` and `perPage` are rejected for `< 1` values. * - `total`, when non-null, is rejected for `< 0` values. * - `rawMeta` rejects non-string keys. * * @param string $provider Stable provider key. * @param list $items Matched snapshots. * @param int $page 1-based page number. * @param int $perPage Page size. * @param int|null $total Cross-page total, or null. * @param bool $hasMore Whether another page exists. * @param array $rawMeta Raw provider metadata. * * @throws InvalidArgumentException When any field violates the * §7.2 contract or the * cross-field validation rules * summarized above. */ public function __construct( string $provider, array $items, int $page, int $perPage, ?int $total, bool $hasMore, array $rawMeta ) { $normalizedProvider = trim( $provider ); if ( $normalizedProvider === '' ) { $message = 'SearchResult requires a non-empty provider key.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $page < 1 ) { $message = sprintf( 'SearchResult 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( 'SearchResult perPage must be >= 1; got %d.', $perPage ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $total !== null && $total < 0 ) { $message = sprintf( 'SearchResult total must be null or >= 0; got %d.', $total ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->provider = $normalizedProvider; $this->items = self::normalizeItems( $normalizedProvider, $items ); $this->page = $page; $this->perPage = $perPage; $this->total = $total; $this->hasMore = $hasMore; $this->rawMeta = self::normalizeRawMeta( $rawMeta ); } /** * Construct an empty result for a query that matched nothing. * * Convenience factory for the common "the provider returned * zero items" case so call sites do not have to spell out the * empty list, the implicit `hasMore = false`, and the empty * raw-metadata map every time. * * @param string $provider Stable provider key. * @param int $page 1-based page number the empty result * represents. * @param int $perPage Page size that was requested. * * @throws InvalidArgumentException When any of the validation * rules on the primary * constructor would also fail * here. */ public static function empty( string $provider, int $page, int $perPage ): self { return new self( $provider, array(), $page, $perPage, 0, false, array() ); } /** * Stable provider key per §4. */ public function provider(): string { return $this->provider; } /** * Matched product snapshots in positional order. * * @return list */ public function items(): array { return $this->items; } /** * Number of items contained in this page of results. * * Convenience accessor so consuming surfaces do not have to * pull {@see self::items()} just to count it. */ public function count(): int { return count( $this->items ); } /** * Report whether this result page contains zero items. * * Exposed because the empty case is a frequent branching point * for rendering surfaces ("show the no-results message") and a * dedicated helper keeps those branches readable. */ public function isEmpty(): bool { return $this->items === array(); } /** * 1-based page number this result represents. */ public function page(): int { return $this->page; } /** * Page size requested when this page was fetched. */ public function perPage(): int { return $this->perPage; } /** * Total number of items across every page, or null. */ public function total(): ?int { return $this->total; } /** * Whether another page exists after this one per §7.2. */ public function hasMore(): bool { return $this->hasMore; } /** * Provider-native metadata blob. * * Per §7.2 the rendering layer MUST NOT branch on this map's * contents; it is preserved purely for diagnostics and for * provider-specific extensions outside the cross-provider * contract. * * @return array */ public function rawMeta(): array { return $this->rawMeta; } /** * Validate and normalize the items list. * * Every entry MUST be a {@see ProductSnapshot} whose * {@see ProductSnapshot::provider()} equals the supplied * `$provider` key. The list is re-keyed to a positional * array so callers always see a `list<>` shape regardless of * what the caller passed in (PHP arrays preserve insertion * order even when keys are mixed, and `array_values()` keeps * that order while normalizing the keys). * * @param string $provider Provider key the * result is bound to. * @param array $items Raw items list. * * @return list * * @throws InvalidArgumentException When any entry is not a * {@see ProductSnapshot} or * carries a different * provider key. */ private static function normalizeItems( string $provider, array $items ): array { $normalized = array(); $index = 0; foreach ( $items as $key => $entry ) { if ( ! $entry instanceof ProductSnapshot ) { $message = sprintf( 'SearchResult items[%s] must be a ProductSnapshot; got %s.', (string) $key, is_object( $entry ) ? get_class( $entry ) : gettype( $entry ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $entry->provider() !== $provider ) { $message = sprintf( 'SearchResult items[%d] provider "%s" does not match result provider "%s"; ' . 'see provider-api-contracts.md §7.3.', $index, $entry->provider(), $provider ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = $entry; ++$index; } return $normalized; } /** * Validate the rawMeta map's keys. * * Values are NOT validated because §7.2 expressly preserves * provider-native data here for downstream debugging; the * rendering layer is forbidden from branching on contents, so * shape constraints would only obstruct legitimate use. * * @param array $rawMeta Raw metadata map. * * @return array * * @throws InvalidArgumentException When any key is not a string. */ private static function normalizeRawMeta( array $rawMeta ): array { $normalized = array(); foreach ( $rawMeta as $key => $value ) { if ( ! is_string( $key ) ) { $message = sprintf( 'SearchResult rawMeta 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 ); } $normalized[ $key ] = $value; } return $normalized; } }