*/ private array $records = array(); /** * Secondary index over the `(product_id, meta_key)` composite * unique axis, mapping each `productId|metaKey` composite to the * primary-key id currently occupying it. * * The composite key uses a `\u{0000}` (null byte) separator so it * cannot collide with any value a caller could pass as a meta key * on the application side, where keys are bounded to printable * `varchar(191)` content per §5.2. * * Maintained in sync with {@see self::$records} by every mutating * method. * * @var array */ private array $compositeIndex = 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 * `(productId, metaKey)` axis. */ public function __construct( array $seed = array() ) { foreach ( $seed as $record ) { $id = $record->id(); if ( $id === null ) { $message = 'InMemoryProductMetaRepository 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( 'InMemoryProductMetaRepository seed contains duplicate id %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $composite = self::compositeKey( $record->productId(), $record->metaKey() ); if ( array_key_exists( $composite, $this->compositeIndex ) ) { $message = sprintf( 'InMemoryProductMetaRepository seed contains duplicate (product_id=%d, meta_key="%s").', $record->productId(), $record->metaKey() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->records[ $id ] = $record; $this->compositeIndex[ $composite ] = $id; if ( $id >= $this->nextId ) { $this->nextId = $id + 1; } } } /** * Persist the record and return its post-save form. * * See {@see ProductMetaRepository::save()} for the full contract. * This implementation: * * - Treats a null id as an insert, allocates the next * auto-increment id, probes the `(product_id, meta_key)` * composite 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 `(product_id, meta_key)` tuple * already occupied by a different row, raises * {@see \RuntimeException} so the collision surfaces loudly. * * @param ProductMetaRecord $record Record to persist. * * @throws RuntimeException When an update targets a missing id or * when either branch would violate the * `(product_id, meta_key)` unique * constraint. */ public function save( ProductMetaRecord $record ): ProductMetaRecord { $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 ProductMetaRecord $record Record to insert. Must have a null id. * * @throws RuntimeException When the `(product_id, meta_key)` axis * already exists. */ private function insert( ProductMetaRecord $record ): ProductMetaRecord { $composite = self::compositeKey( $record->productId(), $record->metaKey() ); if ( array_key_exists( $composite, $this->compositeIndex ) ) { $message = sprintf( 'InMemoryProductMetaRepository: duplicate (product_id=%d, meta_key="%s") on insert.', $record->productId(), $record->metaKey() ); // 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->compositeIndex[ $composite ] = $id; return $persisted; } /** * Overwrite an existing row with the supplied record. * * @param int $id Primary key of the row being updated. * @param ProductMetaRecord $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 `(product_id, meta_key)` tuple * already occupied by a different row. */ private function update( int $id, ProductMetaRecord $record ): ProductMetaRecord { if ( ! array_key_exists( $id, $this->records ) ) { $message = sprintf( 'InMemoryProductMetaRepository: 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 ]; $newComposite = self::compositeKey( $record->productId(), $record->metaKey() ); $oldComposite = self::compositeKey( $existing->productId(), $existing->metaKey() ); if ( $newComposite !== $oldComposite && array_key_exists( $newComposite, $this->compositeIndex ) ) { $message = sprintf( 'InMemoryProductMetaRepository: update would collide with existing ' . '(product_id=%d, meta_key="%s").', $record->productId(), $record->metaKey() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } if ( $newComposite !== $oldComposite ) { unset( $this->compositeIndex[ $oldComposite ] ); $this->compositeIndex[ $newComposite ] = $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 ): ?ProductMetaRecord { if ( $id <= 0 ) { $message = sprintf( 'InMemoryProductMetaRepository::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 `(product_id, meta_key)` composite * unique key. * * Returns null when no row matches; raises * {@see InvalidArgumentException} for non-positive product ids * and empty meta keys because the only way to reach those paths * is a programming error at the call site. * * @param int $productId Owning `affilun_products.id` reference. * @param string $metaKey Meta key. * * @throws InvalidArgumentException When `$productId` is * non-positive or `$metaKey` is * the empty string. */ public function findByProductAndKey( int $productId, string $metaKey ): ?ProductMetaRecord { if ( $productId <= 0 ) { $message = sprintf( 'InMemoryProductMetaRepository::findByProductAndKey() requires a positive productId, got %d.', $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $metaKey === '' ) { $message = 'InMemoryProductMetaRepository::findByProductAndKey() requires a non-empty metaKey.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $composite = self::compositeKey( $productId, $metaKey ); if ( ! array_key_exists( $composite, $this->compositeIndex ) ) { return null; } $id = $this->compositeIndex[ $composite ]; 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( 'InMemoryProductMetaRepository::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 ]; $composite = self::compositeKey( $existing->productId(), $existing->metaKey() ); unset( $this->records[ $id ], $this->compositeIndex[ $composite ] ); 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 ProductMetaRepository} * 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; } /** * Compose a stable lookup key for the * `(product_id, meta_key)` composite unique axis. * * Uses a null-byte separator so the composite cannot collide with * any printable `varchar(191)` meta key the application layer * might supply. * * @param int $productId Owning `affilun_products.id` reference. * @param string $metaKey Meta key. */ private static function compositeKey( int $productId, string $metaKey ): string { return $productId . "\0" . $metaKey; } }