*/ private array $records = array(); /** * Secondary index over the `(provider, provider_item_id)` unique * axis, keyed by `"{provider}\x00{provider_item_id}"` and mapped * to the primary-key id currently occupying the pair. * * Maintained in sync with {@see self::$records} by every mutating * method. Null-byte separator keeps the composite key unambiguous * even when a provider or item id contains a literal delimiter * character. * * @var array */ private array $providerItemIndex = 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 * `(provider, provider_item_id)` * pair. */ public function __construct( array $seed = array() ) { foreach ( $seed as $record ) { $id = $record->id(); if ( $id === null ) { $message = 'InMemoryProductRepository 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( 'InMemoryProductRepository seed contains duplicate id %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $indexKey = self::providerItemKey( $record->provider(), $record->providerItemId() ); if ( array_key_exists( $indexKey, $this->providerItemIndex ) ) { $message = sprintf( 'InMemoryProductRepository seed contains duplicate (provider, provider_item_id) pair: %s / %s.', $record->provider(), $record->providerItemId() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->records[ $id ] = $record; $this->providerItemIndex[ $indexKey ] = $id; if ( $id >= $this->nextId ) { $this->nextId = $id + 1; } } } /** * Persist the record and return its post-save form. * * See {@see ProductRepository::save()} for the full contract. This * implementation: * * - Treats a null id as an insert, allocates the next * auto-increment id, probes the `(provider, provider_item_id)` * 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 `(provider, provider_item_id)` * pair already occupied by a different row, raises * {@see \RuntimeException} so the collision surfaces loudly. * * @param ProductRecord $record Record to persist. * * @throws RuntimeException When an update targets a missing id or * when either branch would violate the * `(provider, provider_item_id)` unique * constraint. */ public function save( ProductRecord $record ): ProductRecord { $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 ProductRecord $record Record to insert. Must have a null id. * * @throws RuntimeException When the `(provider, provider_item_id)` * pair already exists. */ private function insert( ProductRecord $record ): ProductRecord { $indexKey = self::providerItemKey( $record->provider(), $record->providerItemId() ); if ( array_key_exists( $indexKey, $this->providerItemIndex ) ) { $message = sprintf( 'InMemoryProductRepository: duplicate (provider, provider_item_id) pair on insert: %s / %s.', $record->provider(), $record->providerItemId() ); // 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->providerItemIndex[ $indexKey ] = $id; return $persisted; } /** * Overwrite an existing row with the supplied record. * * @param int $id Primary key of the row being updated. * @param ProductRecord $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 `(provider, provider_item_id)` * pair already occupied by a different row. */ private function update( int $id, ProductRecord $record ): ProductRecord { if ( ! array_key_exists( $id, $this->records ) ) { $message = sprintf( 'InMemoryProductRepository: 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 ]; $newIndexKey = self::providerItemKey( $record->provider(), $record->providerItemId() ); $oldIndexKey = self::providerItemKey( $existing->provider(), $existing->providerItemId() ); if ( $newIndexKey !== $oldIndexKey && array_key_exists( $newIndexKey, $this->providerItemIndex ) ) { $message = sprintf( 'InMemoryProductRepository: update would collide with existing ' . '(provider, provider_item_id) pair: %s / %s.', $record->provider(), $record->providerItemId() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } if ( $newIndexKey !== $oldIndexKey ) { unset( $this->providerItemIndex[ $oldIndexKey ] ); $this->providerItemIndex[ $newIndexKey ] = $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 ): ?ProductRecord { if ( $id <= 0 ) { $message = sprintf( 'InMemoryProductRepository::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; } /** * Bulk lookup by primary key. * * Single-pass implementation: deduplicate the input, validate * every id is positive, then walk the deduped input order picking * present entries from the in-memory store. Missing ids are * silently omitted from the returned list, matching the contract * on {@see ProductRepository::findByIds()}. * * @param list $ids Positive ids to look up. * * @return list Found records in deduplicated input * order; missing ids omitted. * * @throws InvalidArgumentException When `$ids` contains a * non-positive id. */ public function findByIds( array $ids ): array { if ( $ids === array() ) { return array(); } $seen = array(); $ordered = array(); foreach ( $ids as $id ) { if ( $id <= 0 ) { $message = sprintf( 'InMemoryProductRepository::findByIds() requires positive ids, 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, $seen ) ) { continue; } $seen[ $id ] = true; $ordered[] = $id; } $result = array(); foreach ( $ordered as $id ) { if ( array_key_exists( $id, $this->records ) ) { $result[] = $this->records[ $id ]; } } return $result; } /** * Look up a record by the `(provider, provider_item_id)` unique * constraint. * * Returns null when no row matches either component; raises * {@see InvalidArgumentException} when either argument is an empty * string because the only way to reach that path is a programming * error at the call site. * * @param string $provider Provider identifier. * @param string $providerItemId Provider-side item identifier. * * @throws InvalidArgumentException When either argument is empty. */ public function findByProviderItem( string $provider, string $providerItemId ): ?ProductRecord { if ( $provider === '' ) { $message = 'InMemoryProductRepository::findByProviderItem() requires a non-empty provider.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $providerItemId === '' ) { $message = 'InMemoryProductRepository::findByProviderItem() requires a non-empty providerItemId.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $indexKey = self::providerItemKey( $provider, $providerItemId ); if ( ! array_key_exists( $indexKey, $this->providerItemIndex ) ) { return null; } $id = $this->providerItemIndex[ $indexKey ]; 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( 'InMemoryProductRepository::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 ]; $indexKey = self::providerItemKey( $existing->provider(), $existing->providerItemId() ); unset( $this->records[ $id ], $this->providerItemIndex[ $indexKey ] ); return true; } /** * Total number of rows currently stored. */ public function count(): int { return count( $this->records ); } /** * Look up a paginated, filtered, sorted slice of stored rows. * * Implements {@see ProductRepository::findPaginated()} against the * in-memory store. Filters with non-null values on `$query` narrow * the candidate set in this order: * * - `provider`: exact-match against {@see ProductRecord::provider()}. * - `search`: case-insensitive substring match against * {@see ProductRecord::title()} via `mb_stripos()`. * - `syncState`: exact-match against {@see ProductRecord::syncState()}. * - `lifecycleState`: exact-match against * {@see ProductRecord::lifecycleState()}. * - `usedInPostId`: NOT honored. The `usedInPostId` filter is a * Phase-9 concern (sync / usage tracking) and is currently * silently ignored by both repository implementations. The * query VO carries the field through so the REST controller * does not need to pre-strip it; the wider repository * contract (default-query parity, filter composition) still * holds because the candidate set is left unfiltered for * that axis alone. * * After filtering, the candidate set is sorted by the closed-set * `sortKey` (already validated by {@see ProductListQuery}) using a * stable comparator. Records whose sort field is null sort LAST * regardless of order direction so the result envelope never * mixes "no value" rows into the middle of the ordered slice. * * The total row count is computed AFTER filtering and BEFORE * pagination so the returned {@see ProductListResult} reports the * value `total_pages` is derived from per * `specifications/rest-api-spec.md` §4.2. * * @param ProductListQuery $query Validated list-query inputs. */ public function findPaginated( ProductListQuery $query ): ProductListResult { $filtered = array_values( $this->records ); if ( $query->provider() !== null ) { $provider = $query->provider(); $filtered = array_values( array_filter( $filtered, static function ( ProductRecord $record ) use ( $provider ): bool { return $record->provider() === $provider; } ) ); } if ( $query->search() !== null ) { $needle = $query->search(); $filtered = array_values( array_filter( $filtered, static function ( ProductRecord $record ) use ( $needle ): bool { return mb_stripos( $record->title(), $needle ) !== false; } ) ); } if ( $query->syncState() !== null ) { $syncState = $query->syncState(); $filtered = array_values( array_filter( $filtered, static function ( ProductRecord $record ) use ( $syncState ): bool { return $record->syncState() === $syncState; } ) ); } if ( $query->lifecycleState() !== null ) { $lifecycleState = $query->lifecycleState(); $filtered = array_values( array_filter( $filtered, static function ( ProductRecord $record ) use ( $lifecycleState ): bool { return $record->lifecycleState() === $lifecycleState; } ) ); } // The `usedInPostId` filter is a Phase-9 concern (sync / // usage tracking) and is currently silently ignored by both // repository implementations. The candidate set is left // unfiltered for that axis alone so the rest of the contract // (filter composition, pagination math) still exercises // end-to-end. $sortKey = $query->sortKey(); $sortOrder = $query->sortOrder(); usort( $filtered, static function ( ProductRecord $a, ProductRecord $b ) use ( $sortKey, $sortOrder ): int { $valueA = self::sortValue( $a, $sortKey ); $valueB = self::sortValue( $b, $sortKey ); // Null sort fields sort LAST regardless of direction. if ( $valueA === null && $valueB === null ) { return 0; } if ( $valueA === null ) { return 1; } if ( $valueB === null ) { return -1; } if ( is_string( $valueA ) && is_string( $valueB ) ) { $cmp = strcmp( $valueA, $valueB ); } else { $cmp = $valueA <=> $valueB; } return $sortOrder === ProductListQuery::SORT_ORDER_ASC ? $cmp : -$cmp; } ); $totalItems = count( $filtered ); $page = $query->page(); $perPage = $query->perPage(); $offset = ( $page - 1 ) * $perPage; $slice = array_values( array_slice( $filtered, $offset, $perPage ) ); return new ProductListResult( $slice, $totalItems, $page, $perPage ); } /** * Extract the comparable value for a record under the given sort * key. Returns null when the record's value for that key is null * (drives the "null sorts LAST" branch in the comparator). * * The closed-set `$sortKey` is enforced by * {@see ProductListQuery}, so no defensive default branch is * needed here. * * @param ProductRecord $record Record to project. * @param string $sortKey Closed-set sort key. * * @return string|int|null */ private static function sortValue( ProductRecord $record, string $sortKey ) { switch ( $sortKey ) { case ProductListQuery::SORT_KEY_TITLE: return $record->title(); case ProductListQuery::SORT_KEY_CREATED_AT: return $record->createdAt()->getTimestamp(); case ProductListQuery::SORT_KEY_LAST_SYNCED_AT: $lastSynced = $record->lastSyncedAt(); return $lastSynced === null ? null : $lastSynced->getTimestamp(); case ProductListQuery::SORT_KEY_UPDATED_AT: default: return $record->updatedAt()->getTimestamp(); } } /** * 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 ProductRepository} * contract and the forthcoming Wpdb-backed implementation will * 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 the composite key used by the `(provider, provider_item_id)` * secondary index. * * A null-byte separator is used so the composite key is unambiguous * even when a provider or item id contains a literal delimiter * character. Exposed as a private static helper (rather than inlined) * so that every read / write site through this class agrees on the * exact encoding. * * @param string $provider Provider identifier. * @param string $providerItemId Provider-side item identifier. */ private static function providerItemKey( string $provider, string $providerItemId ): string { return $provider . "\0" . $providerItemId; } }