`). Held as a plain string at * this layer; the format is validated by the application layer. */ private string $uuid; /** * Optional management-oriented display name. Nullable per §6.2. */ private ?string $name; /** * Type discriminator per §6.2. Initial enum is `single` / `grid`; * this DTO does not enforce the set (see file-level docblock). */ private string $type; /** * Serialized JSON configuration blob per §6.4. Held as a plain * string; this layer does not parse the payload. */ private string $configJson; /** * WordPress user id of the creating actor, or null when the * creator is not tracked (for example programmatically seeded * definitions). */ private ?int $createdBy; /** * WordPress user id of the most recent editor, or null. */ private ?int $updatedBy; /** * Row creation timestamp. Required per §6.2. */ private DateTimeImmutable $createdAt; /** * Row update timestamp. Required per §6.2. */ private DateTimeImmutable $updatedAt; /** * Construct a record with an explicit value for every column. * * The constructor is deliberately exhaustive so that a programming * error (missing column on a write) surfaces at the type system * boundary rather than at the database. Named-argument callers and * the `fromArray()` / `toArray()` helpers are the ergonomic entry * points for most code; construction-by-position is reserved for * the repository implementations themselves. * * @param int|null $id Primary key, null for unpersisted records. * @param string $uuid Prefixed-ULID external identifier. * @param string|null $name Optional display name. * @param string $type Type discriminator (`single` / `grid`). * @param string $configJson Serialized configuration JSON. * @param int|null $createdBy Creating WordPress user id. * @param int|null $updatedBy Last-editor WordPress user id. * @param DateTimeImmutable $createdAt Row creation timestamp. * @param DateTimeImmutable $updatedAt Row update timestamp. * * @throws InvalidArgumentException When a non-nullable string is * empty, `id` is non-positive, or * either actor id is non-positive. */ public function __construct( ?int $id, string $uuid, ?string $name, string $type, string $configJson, ?int $createdBy, ?int $updatedBy, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt ) { if ( $id !== null && $id <= 0 ) { $message = sprintf( 'EmbedRecord id must be a positive integer, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $createdBy !== null && $createdBy <= 0 ) { $message = sprintf( 'EmbedRecord createdBy must be a positive integer, got %d.', $createdBy ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $updatedBy !== null && $updatedBy <= 0 ) { $message = sprintf( 'EmbedRecord updatedBy must be a positive integer, got %d.', $updatedBy ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->assertNonEmpty( 'uuid', $uuid ); $this->assertNonEmpty( 'type', $type ); $this->assertNonEmpty( 'configJson', $configJson ); $this->id = $id; $this->uuid = $uuid; $this->name = $name; $this->type = $type; $this->configJson = $configJson; $this->createdBy = $createdBy; $this->updatedBy = $updatedBy; $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 two * nullable actor columns and the nullable display name flow * through an overrides array so callers that do not track actor * ids can omit them entirely. * * @param string $uuid Prefixed-ULID external id. * @param string $type Type discriminator. * @param string $configJson Serialized configuration JSON. * @param DateTimeImmutable $now Current UTC timestamp. * @param array $optional Optional column overrides keyed by property name. */ public static function createNew( string $uuid, string $type, string $configJson, DateTimeImmutable $now, array $optional = array() ): self { $name = null; if ( array_key_exists( 'name', $optional ) && $optional['name'] !== null ) { $name = (string) $optional['name']; } $createdBy = null; if ( array_key_exists( 'createdBy', $optional ) && $optional['createdBy'] !== null ) { $createdBy = (int) $optional['createdBy']; } $updatedBy = null; if ( array_key_exists( 'updatedBy', $optional ) && $optional['updatedBy'] !== null ) { $updatedBy = (int) $optional['updatedBy']; } return new self( null, $uuid, $name, $type, $configJson, $createdBy, $updatedBy, $now, $now ); } /** * Reconstitute a record from a row-shaped associative array. * * Intended as the single decode seam used by * {@see WpdbEmbedRepository} 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( 'EmbedRecord::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( 'EmbedRecord::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( 'uuid' ), $optionalString( 'name' ), $requireString( 'type' ), $requireString( 'config_json' ), $optionalInt( 'created_by' ), $optionalInt( 'updated_by' ), $requireDate( 'created_at' ), $requireDate( 'updated_at' ) ); } /** * Encode the record as a row-shaped associative array keyed by the * canonical snake_case column names from §6.2. * * Used by {@see WpdbEmbedRepository::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, 'uuid' => $this->uuid, 'name' => $this->name, 'type' => $this->type, 'config_json' => $this->configJson, 'created_by' => $this->createdBy, 'updated_by' => $this->updatedBy, '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( 'EmbedRecord::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; } /** * Prefixed-ULID external identifier per §6.2. */ public function uuid(): string { return $this->uuid; } /** * Optional management-oriented display name. */ public function name(): ?string { return $this->name; } /** * Type discriminator per §6.2. */ public function type(): string { return $this->type; } /** * Serialized JSON configuration blob per §6.4. */ public function configJson(): string { return $this->configJson; } /** * WordPress user id of the creating actor, or null. */ public function createdBy(): ?int { return $this->createdBy; } /** * WordPress user id of the most recent editor, or null. */ public function updatedBy(): ?int { return $this->updatedBy; } /** * 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( 'EmbedRecord::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( 'EmbedRecord 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 ); } } }