assertNonEmpty( 'metaKey', $metaKey ); $this->id = $id; $this->productId = $productId; $this->metaKey = $metaKey; $this->metaValue = $metaValue; $this->createdAt = self::normalizeToUtc( $createdAt ) ?? $createdAt; $this->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt; } /** * Ergonomic named constructor for fresh (unpersisted) records. * * Required columns are taken as positional arguments; the single * nullable column (`metaValue`) flows through an overrides array * so callers that only want to assert the key's presence can stay * terse. * * @param int $productId Owning `affilun_products.id` reference. * @param string $metaKey Meta key. * @param DateTimeImmutable $now Current UTC timestamp. * @param array $optional Optional column overrides keyed by property name. */ public static function createNew( int $productId, string $metaKey, DateTimeImmutable $now, array $optional = array() ): self { $metaValue = null; if ( array_key_exists( 'metaValue', $optional ) && $optional['metaValue'] !== null ) { $metaValue = (string) $optional['metaValue']; } return new self( null, $productId, $metaKey, $metaValue, $now, $now ); } /** * Reconstitute a record from a row-shaped associative array. * * Intended as the single decode seam used by * {@see WpdbProductMetaRepository} 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( 'ProductMetaRecord::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 ]; }; $requireInt = static function ( string $key ) use ( $row ): int { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'ProductMetaRecord::fromArray() missing required integer "%s".', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return (int) $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( 'ProductMetaRecord::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 ] ); }; $idRaw = $row['id'] ?? null; $id = $idRaw === null ? null : (int) $idRaw; return new self( $id, $requireInt( 'product_id' ), $requireString( 'meta_key' ), $optionalString( 'meta_value' ), $requireDate( 'created_at' ), $requireDate( 'updated_at' ) ); } /** * Encode the record as a row-shaped associative array keyed by the * canonical snake_case column names from §5.2. * * Used by {@see WpdbProductMetaRepository::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, 'product_id' => $this->productId, 'meta_key' => $this->metaKey, 'meta_value' => $this->metaValue, '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( 'ProductMetaRecord::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; } /** * Produce a copy of this record with a new `meta_value`. * * Exposed because `meta_value` is the only column on this table * that callers routinely advance in place — meta entries are * keyed by `(product_id, meta_key)` and overwritten when the * upstream value changes. This helper does NOT advance * `updated_at`; callers should compose with * {@see self::withUpdatedAt()} when persisting. * * @param string|null $metaValue New value, nullable. */ public function withMetaValue( ?string $metaValue ): self { $clone = clone $this; $clone->metaValue = $metaValue; 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; } /** * Owning `affilun_products.id` reference per §5.2. */ public function productId(): int { return $this->productId; } /** * Meta key per §5.2. */ public function metaKey(): string { return $this->metaKey; } /** * Meta value per §5.2, or null. */ public function metaValue(): ?string { return $this->metaValue; } /** * 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( 'ProductMetaRecord::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' ) ); } /** * 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( 'ProductMetaRecord 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 ); } } }