assertNonEmpty( 'channel', $channel ); $this->assertNonEmpty( 'level', $level ); $this->assertNonEmpty( 'message', $message ); $this->id = $id; $this->channel = $channel; $this->level = $level; $this->message = $message; $this->contextJson = $contextJson; $this->relatedProductId = $relatedProductId; $this->relatedJobId = $relatedJobId; $this->createdAt = self::normalizeToUtc( $createdAt ) ?? $createdAt; } /** * Ergonomic named constructor for fresh (unpersisted) records. * * Required columns are taken as positional arguments; the three * nullable columns (`contextJson`, `relatedProductId`, * `relatedJobId`) flow through an overrides array so a bare * "channel + level + message + now" call site stays terse. * * @param string $channel Log channel. * @param string $level PSR-3 severity. * @param string $message Log message body. * @param DateTimeImmutable $now Current UTC timestamp. * @param array $optional Optional column overrides * keyed by property name. */ public static function createNew( string $channel, string $level, string $message, DateTimeImmutable $now, array $optional = array() ): self { $contextJson = null; if ( array_key_exists( 'contextJson', $optional ) && $optional['contextJson'] !== null ) { $contextJson = (string) $optional['contextJson']; } $relatedProductId = null; if ( array_key_exists( 'relatedProductId', $optional ) && $optional['relatedProductId'] !== null ) { $relatedProductId = (int) $optional['relatedProductId']; } $relatedJobId = null; if ( array_key_exists( 'relatedJobId', $optional ) && $optional['relatedJobId'] !== null ) { $relatedJobId = (int) $optional['relatedJobId']; } return new self( null, $channel, $level, $message, $contextJson, $relatedProductId, $relatedJobId, $now ); } /** * Reconstitute a record from a row-shaped associative array. * * Intended as the single decode seam used by * {@see WpdbLogRepository} 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}. * * The `created_at` column accepts 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 the 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( 'LogRecord::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 ]; }; $optionalInt = static function ( string $key ) use ( $row ): ?int { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { return null; } return (int) $row[ $key ]; }; $requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'LogRecord::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, $requireString( 'channel' ), $requireString( 'level' ), $requireString( 'message' ), $optionalString( 'context_json' ), $optionalInt( 'related_product_id' ), $optionalInt( 'related_job_id' ), $requireDate( 'created_at' ) ); } /** * Encode the record as a row-shaped associative array keyed by the * canonical snake_case column names from §9.2. * * Used by {@see WpdbLogRepository::save()} to produce the payload * handed to `$wpdb->insert()`. The timestamp is 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, 'channel' => $this->channel, 'level' => $this->level, 'message' => $this->message, 'context_json' => $this->contextJson, 'related_product_id' => $this->relatedProductId, 'related_job_id' => $this->relatedJobId, 'created_at' => self::formatDateTime( $this->createdAt ), ); } /** * 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. * * No `withUpdatedAt()` counterpart exists because `affilun_logs` has * no `updated_at` column — log rows are append-only (§9.1 / §9.4). * * @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( 'LogRecord::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; } /** * 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; } /** * Log channel per §9.2. */ public function channel(): string { return $this->channel; } /** * PSR-3 severity per §9.2. */ public function level(): string { return $this->level; } /** * Log message body per §9.2. */ public function message(): string { return $this->message; } /** * Serialized JSON context per §9.2, or null. */ public function contextJson(): ?string { return $this->contextJson; } /** * Cross-reference to a `affilun_products.id` row per §9.2, or null. */ public function relatedProductId(): ?int { return $this->relatedProductId; } /** * Cross-reference to a `affilun_sync_jobs.id` row per §9.2, or null. */ public function relatedJobId(): ?int { return $this->relatedJobId; } /** * UTC `created_at` timestamp for this record. */ public function createdAt(): DateTimeImmutable { return $this->createdAt; } /** * 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( 'LogRecord::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( 'LogRecord 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 ); } } }