*/ private array $records = array(); /** * Secondary index over the `uuid` unique axis, mapping each uuid * to the primary-key id currently occupying it. * * Maintained in sync with {@see self::$records} by every mutating * method. * * @var array */ private array $uuidIndex = array(); /** * Next auto-increment id to allocate on insert. * * Starts at 1 and advances monotonically with every successful * insert. Mirrors the MySQL `AUTO_INCREMENT` convention: ids are * never reused, even after a delete. */ private int $nextId = 1; /** * Construct the repository, optionally seeded with a set of * already-persisted records. * * Seeding is primarily useful for tests that need to exercise * "existing environment" code paths without driving a sequence of * {@see self::save()} calls first. Seed records MUST carry a * non-null id; their ids are honored verbatim, and the repository's * auto-increment cursor advances past the highest seeded id so * subsequent inserts cannot collide with a seeded row. * * @param list $seed Records to preload. * * @throws InvalidArgumentException When a seed record has no id, * when two seeds share an id, or * when two seeds share the * `uuid` value. */ public function __construct( array $seed = array() ) { foreach ( $seed as $record ) { $id = $record->id(); if ( $id === null ) { $message = 'InMemoryEmbedRepository seed records must carry a non-null id.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( array_key_exists( $id, $this->records ) ) { $message = sprintf( 'InMemoryEmbedRepository seed contains duplicate id %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $uuid = $record->uuid(); if ( array_key_exists( $uuid, $this->uuidIndex ) ) { $message = sprintf( 'InMemoryEmbedRepository seed contains duplicate uuid "%s".', $uuid ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->records[ $id ] = $record; $this->uuidIndex[ $uuid ] = $id; if ( $id >= $this->nextId ) { $this->nextId = $id + 1; } } } /** * Persist the record and return its post-save form. * * See {@see EmbedRepository::save()} for the full contract. This * implementation: * * - Treats a null id as an insert, allocates the next * auto-increment id, probes the `uuid` index for a collision, * and stores the record under the new id. * * - Treats a non-null id as an update. If no row currently * matches the id, raises {@see \RuntimeException} per the * "update-after-delete is a visible error" rule. If the update * would move the record onto a `uuid` already occupied by a * different row, raises {@see \RuntimeException} so the * collision surfaces loudly. * * @param EmbedRecord $record Record to persist. * * @throws RuntimeException When an update targets a missing id or * when either branch would violate the * `uuid` unique constraint. */ public function save( EmbedRecord $record ): EmbedRecord { $id = $record->id(); if ( $id === null ) { return $this->insert( $record ); } return $this->update( $id, $record ); } /** * Insert a fresh (unpersisted) record under a newly-allocated id. * * @param EmbedRecord $record Record to insert. Must have a null id. * * @throws RuntimeException When the `uuid` value already exists. */ private function insert( EmbedRecord $record ): EmbedRecord { $uuid = $record->uuid(); if ( array_key_exists( $uuid, $this->uuidIndex ) ) { $message = sprintf( 'InMemoryEmbedRepository: duplicate uuid on insert: "%s".', $uuid ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } $id = $this->nextId; ++$this->nextId; $persisted = $record->withId( $id ); $this->records[ $id ] = $persisted; $this->uuidIndex[ $uuid ] = $id; return $persisted; } /** * Overwrite an existing row with the supplied record. * * @param int $id Primary key of the row being updated. * @param EmbedRecord $record Record carrying the new column values. * * @throws RuntimeException When no row currently matches `$id` or * when the update would move the record * onto a `uuid` already occupied by a * different row. */ private function update( int $id, EmbedRecord $record ): EmbedRecord { if ( ! array_key_exists( $id, $this->records ) ) { $message = sprintf( 'InMemoryEmbedRepository: cannot update missing id %d; row may have been deleted.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } $existing = $this->records[ $id ]; $newUuid = $record->uuid(); $oldUuid = $existing->uuid(); if ( $newUuid !== $oldUuid && array_key_exists( $newUuid, $this->uuidIndex ) ) { $message = sprintf( 'InMemoryEmbedRepository: update would collide with existing uuid "%s".', $newUuid ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } if ( $newUuid !== $oldUuid ) { unset( $this->uuidIndex[ $oldUuid ] ); $this->uuidIndex[ $newUuid ] = $id; } $this->records[ $id ] = $record; return $record; } /** * Look up a record by its primary key. * * Returns null when no row matches; raises * {@see InvalidArgumentException} for non-positive ids so a * programming error at the call site surfaces loudly instead of * silently missing. * * @param int $id Primary key to look up. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function findById( int $id ): ?EmbedRecord { if ( $id <= 0 ) { $message = sprintf( 'InMemoryEmbedRepository::findById() requires a positive id, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $this->records[ $id ] ?? null; } /** * Look up a record by its `uuid` unique key. * * Returns null when no row matches; raises * {@see InvalidArgumentException} when the argument is an empty * string because the only way to reach that path is a programming * error at the call site. * * @param string $uuid Prefixed-ULID external identifier. * * @throws InvalidArgumentException When `$uuid` is empty. */ public function findByUuid( string $uuid ): ?EmbedRecord { if ( $uuid === '' ) { $message = 'InMemoryEmbedRepository::findByUuid() requires a non-empty uuid.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( $uuid, $this->uuidIndex ) ) { return null; } $id = $this->uuidIndex[ $uuid ]; return $this->records[ $id ] ?? null; } /** * Remove the row identified by the given id. * * Idempotent: returns true when a row was removed, false when no * row matched. Non-positive ids raise * {@see InvalidArgumentException} so a programming error at the * call site surfaces loudly instead of silently missing. * * @param int $id Primary key of the row to delete. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function delete( int $id ): bool { if ( $id <= 0 ) { $message = sprintf( 'InMemoryEmbedRepository::delete() requires a positive id, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( $id, $this->records ) ) { return false; } $existing = $this->records[ $id ]; unset( $this->records[ $id ], $this->uuidIndex[ $existing->uuid() ] ); return true; } /** * Total number of rows currently stored. */ public function count(): int { return count( $this->records ); } /** * Return every stored record, ordered by ascending primary key. * * Exposed purely as a convenience for test assertions that want * to enumerate the repository contents without driving a * paginated listing. Production code MUST NOT rely on this method * because it has no counterpart on the {@see EmbedRepository} * contract and the Wpdb-backed implementation does not offer it. * * @return list */ public function all(): array { $ordered = $this->records; ksort( $ordered, SORT_NUMERIC ); return array_values( $ordered ); } /** * Next auto-increment id the repository would allocate on the * following insert. * * Exposed purely as a convenience for test assertions that want to * probe the internal cursor without issuing a dummy insert. Like * {@see self::all()}, production code MUST NOT rely on this. */ public function nextId(): int { return $this->nextId; } }