*/ private array $rows = array(); /** * Construct the repository, optionally seeded with a set of * already-persisted rows. * * Seeding is primarily useful for tests that need to exercise * "existing environment" code paths without driving a sequence * of {@see self::replaceEmbedItems()} calls first. Each seed * entry MUST be a `{embed_id, product_id, sort_order}` triple * with positive `embed_id` / `product_id` and non-negative * `sort_order`. Duplicate `(embed_id, product_id)` pairs raise * {@see InvalidArgumentException} so a malformed seed fails * loudly rather than silently violating the §7.3 unique * constraint. * * @param list $seed Rows to preload. * * @throws InvalidArgumentException When a seed entry is malformed * or when two seeds share the * same `(embed_id, product_id)` * pair. */ public function __construct( array $seed = array() ) { $seenPairs = array(); foreach ( $seed as $index => $entry ) { if ( ! is_array( $entry ) ) { $message = sprintf( 'InMemoryEmbedItemRepository seed[%d] must be an array.', (int) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'embed_id', $entry ) || ! array_key_exists( 'product_id', $entry ) ) { $message = sprintf( 'InMemoryEmbedItemRepository seed[%d] missing required keys "embed_id" / "product_id".', (int) $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $embedId = (int) $entry['embed_id']; $productId = (int) $entry['product_id']; $sortOrder = array_key_exists( 'sort_order', $entry ) ? (int) $entry['sort_order'] : 0; if ( $embedId <= 0 ) { $message = sprintf( 'InMemoryEmbedItemRepository seed[%d] requires positive embed_id; got %d.', (int) $index, $embedId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $productId <= 0 ) { $message = sprintf( 'InMemoryEmbedItemRepository seed[%d] requires positive product_id; got %d.', (int) $index, $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $sortOrder < 0 ) { $message = sprintf( 'InMemoryEmbedItemRepository seed[%d] requires non-negative sort_order; got %d.', (int) $index, $sortOrder ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $pairKey = $embedId . ':' . $productId; if ( array_key_exists( $pairKey, $seenPairs ) ) { $message = sprintf( 'InMemoryEmbedItemRepository seed contains duplicate (embed_id=%d, product_id=%d) pair.', $embedId, $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $seenPairs[ $pairKey ] = true; $this->rows[] = array( 'embed_id' => $embedId, 'product_id' => $productId, 'sort_order' => $sortOrder, ); } } /** * Find every embed id that references the supplied product. * * Returns ascending embed ids per the contract. An empty list * is the normal "no embed references this product" outcome. * * @param int $productId Local `affilun_products.id` to look up. * * @return list * * @throws InvalidArgumentException When `$productId` is non-positive. */ public function findEmbedIdsByProductId( int $productId ): array { if ( $productId <= 0 ) { $message = sprintf( 'InMemoryEmbedItemRepository::findEmbedIdsByProductId() requires a positive productId, got %d.', $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $matches = array(); foreach ( $this->rows as $row ) { if ( $row['product_id'] === $productId ) { $matches[ $row['embed_id'] ] = true; } } $ids = array_keys( $matches ); sort( $ids, SORT_NUMERIC ); /** @var list $ids */ return $ids; } /** * Replace the items associated with `$embedId` with the * supplied ordered product-id list. * * Validates `$embedId > 0` and that every entry in * `$productIds` is a positive integer with no duplicates. * Then removes every existing row for `$embedId` and inserts * the supplied rows with `sort_order` taken from their * position in the list (0-based). * * @param int $embedId Positive `affilun_embeds.id`. * @param list $productIds Ordered product ids. * * @return int Number of inserted rows. * * @throws InvalidArgumentException When validation fails. */ public function replaceEmbedItems( int $embedId, array $productIds ): int { if ( $embedId <= 0 ) { $message = sprintf( 'InMemoryEmbedItemRepository::replaceEmbedItems() requires a positive embedId, got %d.', $embedId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized = array(); $seen = array(); foreach ( $productIds as $position => $productId ) { if ( ! is_int( $productId ) || $productId <= 0 ) { $message = sprintf( 'InMemoryEmbedItemRepository::replaceEmbedItems() requires positive product ids; productIds[%d] invalid.', (int) $position ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( array_key_exists( $productId, $seen ) ) { $message = sprintf( 'InMemoryEmbedItemRepository::replaceEmbedItems() rejects duplicate productId %d.', $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $seen[ $productId ] = true; $normalized[] = $productId; } // Remove every existing row for the embed. $kept = array(); foreach ( $this->rows as $row ) { if ( $row['embed_id'] !== $embedId ) { $kept[] = $row; } } $this->rows = $kept; // Insert the supplied rows with positional sort_order. foreach ( $normalized as $sortOrder => $productId ) { $this->rows[] = array( 'embed_id' => $embedId, 'product_id' => $productId, 'sort_order' => $sortOrder, ); } return count( $normalized ); } /** * Remove every junction row for the supplied embed id. * * Idempotent: returns the count of removed rows. * * @param int $embedId Positive `affilun_embeds.id`. * * @throws InvalidArgumentException When `$embedId` is non-positive. */ public function deleteByEmbedId( int $embedId ): int { if ( $embedId <= 0 ) { $message = sprintf( 'InMemoryEmbedItemRepository::deleteByEmbedId() requires a positive embedId, got %d.', $embedId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $kept = array(); $removed = 0; foreach ( $this->rows as $row ) { if ( $row['embed_id'] === $embedId ) { ++$removed; continue; } $kept[] = $row; } $this->rows = $kept; return $removed; } /** * Total number of junction rows currently stored. */ public function count(): int { return count( $this->rows ); } /** * Return every stored row, ordered by ascending embed id and * sort_order. * * Exposed purely as a convenience for test assertions that * want to enumerate the repository contents without driving a * lookup. Production code MUST NOT rely on this method * because it has no counterpart on the * {@see EmbedItemRepository} contract and the Wpdb-backed * implementation does not offer it. * * @return list */ public function all(): array { $ordered = $this->rows; usort( $ordered, static function ( array $a, array $b ): int { $cmp = $a['embed_id'] <=> $b['embed_id']; if ( $cmp !== 0 ) { return $cmp; } return $a['sort_order'] <=> $b['sort_order']; } ); return array_values( $ordered ); } }