assertNonEmpty( 'providerType', $providerType ); $this->assertNonEmpty( 'url', $url ); $this->assertNonEmpty( 'urlHash', $urlHash ); $this->assertNonEmpty( 'source', $source ); $this->id = $id; $this->productId = $productId; $this->providerType = $providerType; $this->providerItemId = self::normalizeOptionalString( $providerItemId ); $this->url = $url; $this->urlHash = $urlHash; $this->source = $source; $this->createdAt = self::normalizeToUtc( $createdAt ) ?? $createdAt; $this->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt; } /** * Build a new unsaved URL record. * * @param int $productId Owning product id. * @param string $providerType Provider namespace. * @param string|null $providerItemId Optional provider-side item identifier. * @param string $url Product or affiliate URL. * @param DateTimeImmutable $now Creation/update timestamp. * @param array $optional Optional overrides. */ public static function createNew( int $productId, string $providerType, ?string $providerItemId, string $url, DateTimeImmutable $now, array $optional = array() ): self { $source = isset( $optional['source'] ) && $optional['source'] !== null ? (string) $optional['source'] : 'user'; $urlHash = isset( $optional['urlHash'] ) && $optional['urlHash'] !== null ? (string) $optional['urlHash'] : self::hashUrl( $url ); return new self( null, $productId, $providerType, $providerItemId, $url, $urlHash, $source, $now, $now ); } /** * @param array $row Row data. */ public static function fromArray( array $row ): self { $requireString = static function ( string $key ) use ( $row ): string { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::fromArray() missing required key "%s".', $key ) ); } 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 ]; }; $requireInt = static function ( string $key ) use ( $row ): int { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::fromArray() missing required integer "%s".', $key ) ); } return (int) $row[ $key ]; }; $requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::fromArray() missing required timestamp "%s".', $key ) ); } return self::parseDateTime( $key, $row[ $key ] ); }; $idRaw = $row['id'] ?? null; return new self( $idRaw === null ? null : (int) $idRaw, $requireInt( 'product_id' ), $requireString( 'provider_type' ), $optionalString( 'provider_item_id' ), $requireString( 'url' ), $requireString( 'url_hash' ), $requireString( 'source' ), $requireDate( 'created_at' ), $requireDate( 'updated_at' ) ); } /** * @return array */ public function toArray(): array { return array( 'id' => $this->id, 'product_id' => $this->productId, 'provider_type' => $this->providerType, 'provider_item_id' => $this->providerItemId, 'url' => $this->url, 'url_hash' => $this->urlHash, 'source' => $this->source, 'created_at' => self::formatDateTime( $this->createdAt ), 'updated_at' => self::formatDateTime( $this->updatedAt ), ); } /** * Return a copy with the database row id set. * * @param int $id Database row id. */ public function withId( int $id ): self { if ( $id <= 0 ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::withId() requires a positive id, got %d.', $id ) ); } $clone = clone $this; $clone->id = $id; return $clone; } /** * Return a copy with the owning product id changed. * * @param int $productId Owning product id. */ public function withProductId( int $productId ): self { if ( $productId <= 0 ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::withProductId() requires a positive id, got %d.', $productId ) ); } $clone = clone $this; $clone->productId = $productId; return $clone; } /** * Return a copy with the update timestamp changed. * * @param DateTimeImmutable $updatedAt Update timestamp. */ public function withUpdatedAt( DateTimeImmutable $updatedAt ): self { $clone = clone $this; $clone->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt; return $clone; } /** * Return the database row id. */ public function id(): ?int { return $this->id; } /** * Return the owning product id. */ public function productId(): int { return $this->productId; } /** * Return the provider namespace. */ public function providerType(): string { return $this->providerType; } /** * Return the optional provider-side item identifier. */ public function providerItemId(): ?string { return $this->providerItemId; } /** * Return the product or affiliate URL. */ public function url(): string { return $this->url; } /** * Return the normalized URL hash. */ public function urlHash(): string { return $this->urlHash; } /** * Return the source that produced the URL row. */ public function source(): string { return $this->source; } /** * Return the creation timestamp. */ public function createdAt(): DateTimeImmutable { return $this->createdAt; } /** * Return the update timestamp. */ public function updatedAt(): DateTimeImmutable { return $this->updatedAt; } /** * Hash a URL for uniqueness checks. * * @param string $url URL to hash. */ public static function hashUrl( string $url ): string { return hash( 'sha256', trim( $url ) ); } /** * Assert that a string field is non-empty. * * @param string $field Field name. * @param string $value Field value. */ private function assertNonEmpty( string $field, string $value ): void { if ( trim( $value ) === '' ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord %s must be non-empty.', $field ) ); } } /** * Trim an optional string and normalize blanks to null. * * @param string|null $value Optional string. */ private static function normalizeOptionalString( ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } /** * Normalize a timestamp to UTC. * * @param DateTimeInterface|null $value Timestamp. */ private static function normalizeToUtc( ?DateTimeInterface $value ): ?DateTimeImmutable { if ( $value === null ) { return null; } $immutable = $value instanceof DateTimeImmutable ? $value : DateTimeImmutable::createFromInterface( $value ); return $immutable->setTimezone( new DateTimeZone( 'UTC' ) ); } /** * Parse a MySQL datetime value as UTC. * * @param string $key Source field name. * @param mixed $value Raw timestamp value. */ private static function parseDateTime( string $key, mixed $value ): DateTimeImmutable { if ( $value instanceof DateTimeImmutable ) { return self::normalizeToUtc( $value ) ?? $value; } if ( ! is_string( $value ) || trim( $value ) === '' ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord timestamp "%s" must be a non-empty string.', $key ) ); } $parsed = DateTimeImmutable::createFromFormat( self::MYSQL_DATETIME_FORMAT, $value, new DateTimeZone( 'UTC' ) ); if ( ! $parsed instanceof DateTimeImmutable ) { throw new InvalidArgumentException( sprintf( 'ProductUrlRecord timestamp "%s" is invalid.', $key ) ); } return $parsed; } /** * Format a timestamp for MySQL storage. * * @param DateTimeInterface $value Timestamp. */ private static function formatDateTime( DateTimeInterface $value ): string { return ( self::normalizeToUtc( $value ) ?? DateTimeImmutable::createFromInterface( $value ) ) ->format( self::MYSQL_DATETIME_FORMAT ); } }