assertNonEmpty( 'provider', $provider ); $this->assertNonEmpty( 'providerItemId', $providerItemId ); $this->assertNonEmpty( 'canonicalHash', $canonicalHash ); $this->assertNonEmpty( 'title', $title ); $this->assertNonEmpty( 'detailUrl', $detailUrl ); $this->assertNonEmpty( 'syncState', $syncState ); $this->assertNonEmpty( 'lifecycleState', $lifecycleState ); if ( $snapshotVersion < 0 ) { $message = sprintf( 'ProductRecord snapshotVersion must be non-negative, got %d.', $snapshotVersion ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->id = $id; $this->provider = $provider; $this->providerItemId = $providerItemId; $this->canonicalHash = $canonicalHash; $this->title = $title; $this->brand = $brand; $this->description = $description; $this->imagePrimaryUrl = $imagePrimaryUrl; $this->imageGalleryJson = $imageGalleryJson; $this->priceAmount = $priceAmount; $this->priceCurrency = $priceCurrency; $this->priceLabel = $priceLabel; $this->availabilityStatus = $availabilityStatus; $this->detailUrl = $detailUrl; $this->affiliateUrl = $affiliateUrl; $this->rawPayloadJson = $rawPayloadJson; $this->searchKeywords = self::normalizeOptionalString( $searchKeywords ); $this->searchKeywordsSource = self::normalizeOptionalString( $searchKeywordsSource ); $this->snapshotVersion = $snapshotVersion; $this->syncState = $syncState; $this->lifecycleState = $lifecycleState; $this->archivedAt = self::normalizeToUtc( $archivedAt ); $this->lastSyncedAt = self::normalizeToUtc( $lastSyncedAt ); $this->lastRenderedAt = self::normalizeToUtc( $lastRenderedAt ); $this->lastSyncAttemptedAt = self::normalizeToUtc( $lastSyncAttemptedAt ); $this->lastSyncErrorCode = $lastSyncErrorCode; $this->lastSyncErrorMessage = $lastSyncErrorMessage; $this->createdAt = self::normalizeToUtc( $createdAt ) ?? $createdAt; $this->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt; } /** * Ergonomic named constructor for fresh (unpersisted) records. * * Supplies `snapshot_version = 1`, `sync_state = 'fresh'`, and * `lifecycle_state = 'active'` per §4.2 defaults. Timestamps * default to the injected "now" value so tests can supply a * deterministic clock without having to spell out `created_at` * and `updated_at` separately. * * @param string $provider Provider identifier. * @param string $providerItemId Provider-side item identifier. * @param string $canonicalHash Normalized hash per §4.4. * @param string $title Product title. * @param string $detailUrl Canonical detail URL. * @param DateTimeImmutable $now Current UTC timestamp. * @param array $optional Optional column overrides keyed by property name. */ public static function createNew( string $provider, string $providerItemId, string $canonicalHash, string $title, string $detailUrl, DateTimeImmutable $now, array $optional = array() ): self { /** * Helper: pull a nullable string out of the optional overrides. * * @param string $key Override key. */ $stringOrNull = static function ( string $key ) use ( $optional ): ?string { if ( ! array_key_exists( $key, $optional ) ) { return null; } $value = $optional[ $key ]; return $value === null ? null : (string) $value; }; /** * Helper: pull a nullable DateTimeImmutable out of the overrides. * * @param string $key Override key. */ $dateOrNull = static function ( string $key ) use ( $optional ): ?DateTimeImmutable { if ( ! array_key_exists( $key, $optional ) ) { return null; } $value = $optional[ $key ]; if ( $value === null ) { return null; } if ( ! $value instanceof DateTimeImmutable ) { $message = sprintf( 'ProductRecord override "%s" must be DateTimeImmutable or null.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $value; }; $snapshotVersion = array_key_exists( 'snapshotVersion', $optional ) ? (int) $optional['snapshotVersion'] : self::DEFAULT_SNAPSHOT_VERSION; $syncState = array_key_exists( 'syncState', $optional ) ? (string) $optional['syncState'] : self::DEFAULT_SYNC_STATE; $lifecycleState = array_key_exists( 'lifecycleState', $optional ) ? (string) $optional['lifecycleState'] : self::DEFAULT_LIFECYCLE_STATE; return new self( null, $provider, $providerItemId, $canonicalHash, $title, $stringOrNull( 'brand' ), $stringOrNull( 'description' ), $stringOrNull( 'imagePrimaryUrl' ), $stringOrNull( 'imageGalleryJson' ), $stringOrNull( 'priceAmount' ), $stringOrNull( 'priceCurrency' ), $stringOrNull( 'priceLabel' ), $stringOrNull( 'availabilityStatus' ), $detailUrl, $stringOrNull( 'affiliateUrl' ), $stringOrNull( 'rawPayloadJson' ), $snapshotVersion, $syncState, $lifecycleState, $dateOrNull( 'archivedAt' ), $dateOrNull( 'lastSyncedAt' ), $dateOrNull( 'lastRenderedAt' ), $dateOrNull( 'lastSyncAttemptedAt' ), $stringOrNull( 'lastSyncErrorCode' ), $stringOrNull( 'lastSyncErrorMessage' ), $now, $now, $stringOrNull( 'searchKeywords' ), $stringOrNull( 'searchKeywordsSource' ) ); } /** * Reconstitute a record from a row-shaped associative array. * * Intended as the single decode seam used by * `WpdbProductRepository` when mapping `$wpdb->get_row()` results * back into records. Unknown keys are ignored so callers can pass * raw result rows without having to filter them; missing non- * nullable keys raise {@see InvalidArgumentException}. * * DateTime columns accept either a `DateTimeImmutable` instance * (produced by in-memory repositories) or a `Y-m-d H:i:s` string * (produced by `$wpdb`). Invalid strings raise * {@see InvalidArgumentException}. * * @param array $row Row-shaped associative array. * * @throws InvalidArgumentException When a required key is missing * or a timestamp string cannot * be parsed. */ public static function fromArray( array $row ): self { $requireString = static function ( string $key ) use ( $row ): string { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'ProductRecord::fromArray() missing required key "%s".', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return (string) $row[ $key ]; }; $optionalString = static function ( string $key ) use ( $row ): ?string { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { return null; } return (string) $row[ $key ]; }; $requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'ProductRecord::fromArray() missing required timestamp "%s".', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return self::parseDateTime( $key, $row[ $key ] ); }; $optionalDate = static function ( string $key ) use ( $row ): ?DateTimeImmutable { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { return null; } return self::parseDateTime( $key, $row[ $key ] ); }; $idRaw = $row['id'] ?? null; $id = $idRaw === null ? null : (int) $idRaw; $snapshotVersionRaw = $row['snapshot_version'] ?? $row['snapshotVersion'] ?? self::DEFAULT_SNAPSHOT_VERSION; return new self( $id, $requireString( 'provider' ), $requireString( 'provider_item_id' ), $requireString( 'canonical_hash' ), $requireString( 'title' ), $optionalString( 'brand' ), $optionalString( 'description' ), $optionalString( 'image_primary_url' ), $optionalString( 'image_gallery_json' ), $optionalString( 'price_amount' ), $optionalString( 'price_currency' ), $optionalString( 'price_label' ), $optionalString( 'availability_status' ), $requireString( 'detail_url' ), $optionalString( 'affiliate_url' ), $optionalString( 'raw_payload_json' ), (int) $snapshotVersionRaw, $requireString( 'sync_state' ), $requireString( 'lifecycle_state' ), $optionalDate( 'archived_at' ), $optionalDate( 'last_synced_at' ), $optionalDate( 'last_rendered_at' ), $optionalDate( 'last_sync_attempted_at' ), $optionalString( 'last_sync_error_code' ), $optionalString( 'last_sync_error_message' ), $requireDate( 'created_at' ), $requireDate( 'updated_at' ), $optionalString( 'search_keywords' ), $optionalString( 'search_keywords_source' ) ); } /** * Encode the record as a row-shaped associative array keyed by the * canonical snake_case column names from §4.2. * * Used by `WpdbProductRepository::save()` to produce the payload * handed to `$wpdb->insert()` / `$wpdb->update()`. Timestamps are * formatted via {@see self::formatDateTime()} so the array is * round-trippable through {@see self::fromArray()}. * * @return array */ public function toArray(): array { return array( 'id' => $this->id, 'provider' => $this->provider, 'provider_item_id' => $this->providerItemId, 'canonical_hash' => $this->canonicalHash, 'title' => $this->title, 'brand' => $this->brand, 'description' => $this->description, 'image_primary_url' => $this->imagePrimaryUrl, 'image_gallery_json' => $this->imageGalleryJson, 'price_amount' => $this->priceAmount, 'price_currency' => $this->priceCurrency, 'price_label' => $this->priceLabel, 'availability_status' => $this->availabilityStatus, 'detail_url' => $this->detailUrl, 'affiliate_url' => $this->affiliateUrl, 'raw_payload_json' => $this->rawPayloadJson, 'search_keywords' => $this->searchKeywords, 'search_keywords_source' => $this->searchKeywordsSource, 'snapshot_version' => $this->snapshotVersion, 'sync_state' => $this->syncState, 'lifecycle_state' => $this->lifecycleState, 'archived_at' => self::formatDateTime( $this->archivedAt ), 'last_synced_at' => self::formatDateTime( $this->lastSyncedAt ), 'last_rendered_at' => self::formatDateTime( $this->lastRenderedAt ), 'last_sync_attempted_at' => self::formatDateTime( $this->lastSyncAttemptedAt ), 'last_sync_error_code' => $this->lastSyncErrorCode, 'last_sync_error_message' => $this->lastSyncErrorMessage, 'created_at' => self::formatDateTime( $this->createdAt ), 'updated_at' => self::formatDateTime( $this->updatedAt ), ); } /** * Produce a copy of this record with the given primary-key id * assigned. Used by the repository's `save()` path immediately * after an insert to surface the database-assigned id to callers. * * @param int $id Newly-assigned primary key. Must be positive. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function withId( int $id ): self { if ( $id <= 0 ) { $message = sprintf( 'ProductRecord::withId() requires a positive integer, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $clone->id = $id; return $clone; } /** * Produce a copy of this record with a new `updated_at` timestamp. * * Used by the repository's `save()` path on the update branch so * `updated_at` advances even when no other field changed. * * @param DateTimeImmutable $updatedAt New `updated_at` value; normalized to UTC. */ public function withUpdatedAt( DateTimeImmutable $updatedAt ): self { $clone = clone $this; $clone->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt; return $clone; } /** * Auto-increment primary key, or null for unpersisted records. */ public function id(): ?int { return $this->id; } /** * Report whether this record has been assigned a primary key. */ public function isPersisted(): bool { return $this->id !== null; } /** * Provider identifier (for example `"rakuten"`). */ public function provider(): string { return $this->provider; } /** * Provider-side item identifier. */ public function providerItemId(): string { return $this->providerItemId; } /** * Canonical hash of the normalized snapshot payload. */ public function canonicalHash(): string { return $this->canonicalHash; } /** * Human-readable product title. */ public function title(): string { return $this->title; } /** * Brand name, when the provider supplies one. */ public function brand(): ?string { return $this->brand; } /** * Long-form product description, when available. */ public function description(): ?string { return $this->description; } /** * URL of the primary product image, when available. */ public function imagePrimaryUrl(): ?string { return $this->imagePrimaryUrl; } /** * JSON-encoded secondary image gallery, when available. */ public function imageGalleryJson(): ?string { return $this->imageGalleryJson; } /** * Price amount as a fixed-precision decimal string, when available. */ public function priceAmount(): ?string { return $this->priceAmount; } /** * ISO currency code for {@see self::priceAmount()}, when available. */ public function priceCurrency(): ?string { return $this->priceCurrency; } /** * Display label for the price (for example `"From"`), when available. */ public function priceLabel(): ?string { return $this->priceLabel; } /** * Availability status string from the provider, when available. */ public function availabilityStatus(): ?string { return $this->availabilityStatus; } /** * Canonical detail page URL on the provider. */ public function detailUrl(): string { return $this->detailUrl; } /** * Affiliate-rewritten detail URL, when available. */ public function affiliateUrl(): ?string { return $this->affiliateUrl; } /** * JSON-encoded raw provider payload, when retained. */ public function rawPayloadJson(): ?string { return $this->rawPayloadJson; } /** * Cross-provider search keywords for secondary provider buttons. */ public function searchKeywords(): ?string { return $this->searchKeywords; } /** * Source token for {@see self::searchKeywords()}. */ public function searchKeywordsSource(): ?string { return $this->searchKeywordsSource; } /** * Snapshot schema version this record was captured under. */ public function snapshotVersion(): int { return $this->snapshotVersion; } /** * Sync state of the record (see `database-schema.md` §4.2). */ public function syncState(): string { return $this->syncState; } /** * Lifecycle state of the record (see `database-schema.md` §4.2). */ public function lifecycleState(): string { return $this->lifecycleState; } /** * UTC timestamp at which the record was archived, or null. */ public function archivedAt(): ?DateTimeImmutable { return $this->archivedAt; } /** * UTC timestamp of the last successful sync, or null. */ public function lastSyncedAt(): ?DateTimeImmutable { return $this->lastSyncedAt; } /** * UTC timestamp of the last successful render, or null. */ public function lastRenderedAt(): ?DateTimeImmutable { return $this->lastRenderedAt; } /** * UTC timestamp of the last sync attempt, or null. */ public function lastSyncAttemptedAt(): ?DateTimeImmutable { return $this->lastSyncAttemptedAt; } /** * Short error code from the last failed sync attempt, or null. */ public function lastSyncErrorCode(): ?string { return $this->lastSyncErrorCode; } /** * Error message from the last failed sync attempt, or null. */ public function lastSyncErrorMessage(): ?string { return $this->lastSyncErrorMessage; } /** * UTC `created_at` timestamp for this record. */ public function createdAt(): DateTimeImmutable { return $this->createdAt; } /** * UTC `updated_at` timestamp for this record. */ public function updatedAt(): DateTimeImmutable { return $this->updatedAt; } /** * Format a {@see DateTimeInterface} as a canonical MySQL `DATETIME` * string in UTC, or return null when the input is null. * * Exposed as a public static helper so the repository implementations * and tests can round-trip timestamps through the same path. * * @param DateTimeInterface|null $value Timestamp to format. */ public static function formatDateTime( ?DateTimeInterface $value ): ?string { if ( $value === null ) { return null; } $utc = $value instanceof DateTimeImmutable ? $value->setTimezone( new DateTimeZone( 'UTC' ) ) : DateTimeImmutable::createFromInterface( $value )->setTimezone( new DateTimeZone( 'UTC' ) ); return $utc->format( self::MYSQL_DATETIME_FORMAT ); } /** * Parse a MySQL `DATETIME` string (or an already-constructed * {@see DateTimeImmutable}) into a UTC `DateTimeImmutable`. * * @param string $key Column name, used only in the * error message to surface which * timestamp failed to parse. * @param mixed $value Value to parse. * * @throws InvalidArgumentException When `$value` is not a * {@see DateTimeImmutable} or a * parseable MySQL `DATETIME` * string. */ private static function parseDateTime( string $key, $value ): DateTimeImmutable { if ( $value instanceof DateTimeImmutable ) { return self::normalizeToUtc( $value ) ?? $value; } if ( is_string( $value ) && $value !== '' ) { $parsed = DateTimeImmutable::createFromFormat( self::MYSQL_DATETIME_FORMAT, $value, new DateTimeZone( 'UTC' ) ); if ( $parsed instanceof DateTimeImmutable ) { return $parsed; } try { return new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) ); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Fall through to the descriptive throw below. } catch ( \Exception $unused ) { // Fall through to the descriptive throw below. unset( $unused ); } } $message = sprintf( 'ProductRecord::fromArray() could not parse "%s" as a MySQL DATETIME.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } /** * Normalize a nullable timestamp to UTC so equality checks and * repository round-trips do not depend on the caller's timezone. * * @param DateTimeImmutable|null $value Value to normalize. */ private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable { if ( $value === null ) { return null; } return $value->setTimezone( new DateTimeZone( 'UTC' ) ); } /** * Normalize an optional string column. * * @param string|null $value Value to normalize. */ private static function normalizeOptionalString( ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } /** * Guard against an empty string for a column that the schema * declares as `NOT NULL`. * * @param string $field Column / property name for the error message. * @param string $value Value to validate. * * @throws InvalidArgumentException When `$value` is the empty string. */ private function assertNonEmpty( string $field, string $value ): void { if ( $value === '' ) { $message = sprintf( 'ProductRecord requires a non-empty value for "%s".', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } }