flush()`). * * Design notes: * * - The physical table name is resolved through the injected * {@see TableNameResolver} (see {@see PrefixedTableNameResolver} * for the production default) and cached on the instance so * every method emits the same identifier even when the host * site mutates `$wpdb->prefix` mid-request (for example via * `switch_to_blog()`). Call sites that need per-request prefix * lookup should construct a fresh repository after each switch, * matching the stability contract on * {@see PrefixedTableNameResolver}. * * - `$wpdb` is typed as `object` rather than the concrete `\wpdb` * class so this file stays trivially loadable in unit tests * that run outside a WordPress environment. The repository * still requires the real interface surface at call time; the * abstract type just keeps PHPStan and the autoloader from * pulling in `\wpdb` for pure-unit runs. * * - {@see self::findEmbedIdsByProductId()} issues a single * `SELECT DISTINCT embed_id ... ORDER BY embed_id ASC` query so * the §7.3 `idx_product_id` index does the heavy lifting and * the per-product invalidation path stays O(matches) rather * than O(rows). * * - {@see self::replaceEmbedItems()} owns the §7.1 "delete-then- * insert" semantics. It validates the input list (positive ids, * no duplicates) BEFORE touching the database so a malformed * call cannot leave the embed half-updated, then issues the * delete + insert pair inside a `START TRANSACTION` / * `COMMIT` block when the driver supports it. A failure during * the insert phase rolls back so the embed retains its prior * row set. * * - {@see self::deleteByEmbedId()} is idempotent: it returns the * count of removed rows, with zero indicating "nothing * matched". Neither outcome is an error. * * Failure semantics: * * - Driver errors (connection loss, constraint violations, * malformed rows) are promoted to {@see RuntimeException} via * {@see self::assertNoDriverError()} so call sites can catch at * a single boundary without importing driver classes. * * - Validation failures on * {@see self::findEmbedIdsByProductId()}, * {@see self::replaceEmbedItems()}, and * {@see self::deleteByEmbedId()} raise * {@see InvalidArgumentException} BEFORE any DB write so a * programming error at the call site surfaces loudly. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); // phpcs:disable Generic.Files.LineLength.TooLong -- Developer-facing exception strings include full class/method context. // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- Repository SQL uses table-name resolver output plus prepared values. namespace Affilun\Infrastructure\Persistence; use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; use RuntimeException; /** * {@see EmbedItemRepository} implementation backed by WordPress's * `$wpdb`. * * See the file-level docblock for the table-name resolution * strategy, the §7.3 indexed reverse-lookup query, and the * transactional delete-then-insert semantics of * {@see self::replaceEmbedItems()}. */ final class WpdbEmbedItemRepository implements EmbedItemRepository { /** * Logical, unprefixed AFFILUN table name this repository targets. * * Named as a class-level constant so tests and diagnostics can * reference the exact name without duplicating the string * literal, and so a future rename has a single location to * update. */ public const TABLE_LOGICAL_NAME = 'affilun_embed_items'; /** * MySQL `DATETIME` format used for the `created_at` column. */ private const MYSQL_DATETIME_FORMAT = 'Y-m-d H:i:s'; /** * `$wpdb`-shaped collaborator. Typed as `object` so the file * stays trivially loadable in unit tests; see file-level * docblock for the rationale. */ private object $wpdb; /** * Cached physical table name. Resolved once at construction * time so repeated calls emit the same identifier even when * the host site mutates `$wpdb->prefix` mid-request. */ private string $tableName; /** * Construct the repository. * * @param object $wpdb WordPress `$wpdb`-like * object exposing `insert()`, * `delete()`, `prepare()`, * `get_col()` / * `get_results()`, * `get_var()`, `query()`, * and the `last_error` * property. * @param TableNameResolver $tableNames Resolver used to derive * the physical * `affilun_embed_items` name. */ public function __construct( object $wpdb, TableNameResolver $tableNames ) { $this->wpdb = $wpdb; $this->tableName = $tableNames->resolve( self::TABLE_LOGICAL_NAME ); } /** * Physical table name this repository targets, resolved once * at construction time through the injected * {@see TableNameResolver}. * * Exposed primarily for tests and diagnostics; production * code should not need to interpolate the name by hand. */ public function tableName(): string { return $this->tableName; } /** * Find every embed id that references the supplied product. * * Issues a single `SELECT DISTINCT embed_id` query under the * §7.3 `idx_product_id` index, ordered ascending so the * cache-invalidator loop and its tests are deterministic. * * @param int $productId Local `affilun_products.id` to look up. * * @return list * * Errors: raises {@see RuntimeException} (via * {@see self::assertNoDriverError()}) when `$wpdb` reports an * error during the SELECT. * * @throws InvalidArgumentException When `$productId` is non-positive. */ public function findEmbedIdsByProductId( int $productId ): array { if ( $productId <= 0 ) { $message = sprintf( 'WpdbEmbedItemRepository::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 ); } $selectSql = 'SELECT DISTINCT embed_id FROM ' . $this->tableName . ' WHERE product_id = %d ORDER BY embed_id ASC'; $this->clearLastError(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- KI-002 reverse-index lookup. $rows = $this->wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name resolved from a class-level constant; values bound immediately here. $this->wpdb->prepare( $selectSql, $productId ) ); $this->assertNoDriverError( 'select' ); if ( ! is_array( $rows ) ) { return array(); } $ids = array(); foreach ( $rows as $value ) { $id = (int) $value; if ( $id > 0 ) { $ids[] = $id; } } return $ids; } /** * Replace the items associated with `$embedId` with the * supplied ordered product-id list. * * Validates the input list BEFORE touching the database, then * issues the delete + insert pair inside a `START TRANSACTION` * / `COMMIT` block when the driver supports it. A failure * during the insert phase rolls back so the embed retains its * prior row set. * * @param int $embedId Positive `affilun_embeds.id`. * @param list $productIds Ordered product ids; positions * become `sort_order` values. * * @return int Number of inserted rows. * * @throws InvalidArgumentException When validation fails. * @throws RuntimeException When `$wpdb->insert()` returns false * without raising a driver error. * @throws \Throwable Re-raised verbatim when the rollback * branch fires after a failed insert * inside the transaction. */ public function replaceEmbedItems( int $embedId, array $productIds ): int { if ( $embedId <= 0 ) { $message = sprintf( 'WpdbEmbedItemRepository::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( 'WpdbEmbedItemRepository::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( 'WpdbEmbedItemRepository::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; } $useTransaction = $this->beginTransactionIfSupported(); try { $this->deleteByEmbedIdInternal( $embedId ); $now = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) ) ->format( self::MYSQL_DATETIME_FORMAT ); foreach ( $normalized as $sortOrder => $productId ) { $this->clearLastError(); $row = array( 'embed_id' => $embedId, 'product_id' => $productId, 'sort_order' => $sortOrder, 'created_at' => $now, ); $formats = array( '%d', '%d', '%d', '%s' ); /** @var false|int $result */ $result = $this->wpdb->insert( $this->tableName, $row, $formats ); $this->assertNoDriverError( 'insert' ); if ( $result === false ) { $message = sprintf( 'WpdbEmbedItemRepository::replaceEmbedItems(): $wpdb->insert() returned false for embed_id=%d product_id=%d.', $embedId, $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } } } catch ( \Throwable $e ) { if ( $useTransaction ) { $this->rollbackQuietly(); } throw $e; } if ( $useTransaction ) { $this->commitTransaction(); } 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`. * * Errors: raises {@see RuntimeException} (via * {@see self::deleteByEmbedIdInternal()}) when `$wpdb` reports * an error during the DELETE. * * @throws InvalidArgumentException When `$embedId` is non-positive. */ public function deleteByEmbedId( int $embedId ): int { if ( $embedId <= 0 ) { $message = sprintf( 'WpdbEmbedItemRepository::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 ); } return $this->deleteByEmbedIdInternal( $embedId ); } /** * Total number of rows currently stored in `affilun_embed_items`. * * Backed by a `SELECT COUNT(*)` query rather than * materializing every row. * * @throws RuntimeException When `$wpdb` reports an error. */ public function count(): int { $this->clearLastError(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counting AFFILUN-owned rows; caching handled by caller where relevant. $value = $this->wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name resolved from a class-level constant, not user input. 'SELECT COUNT(*) FROM ' . $this->tableName ); $this->assertNoDriverError( 'count' ); return (int) $value; } /** * Internal `deleteByEmbedId()` body shared by the public * surface and the {@see self::replaceEmbedItems()} flow so the * delete branch does not duplicate the driver-error promotion * boilerplate. * * @param int $embedId Positive `affilun_embeds.id`. Caller has * already validated. * * @throws RuntimeException When `$wpdb->delete()` returns false * without raising a driver error. */ private function deleteByEmbedIdInternal( int $embedId ): int { $this->clearLastError(); /** @var false|int $result */ $result = $this->wpdb->delete( $this->tableName, array( 'embed_id' => $embedId ), array( '%d' ) ); $this->assertNoDriverError( 'delete' ); if ( $result === false ) { $message = sprintf( 'WpdbEmbedItemRepository::deleteByEmbedId(): $wpdb->delete() returned false for embedId %d without driver error.', $embedId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } return (int) $result; } /** * Issue `START TRANSACTION` when the driver supports it. * * Returns true when the transaction was started (caller MUST * subsequently call {@see self::commitTransaction()} or * {@see self::rollbackQuietly()}), false when the driver does * not expose `query()` so the caller falls back on * non-transactional writes. */ private function beginTransactionIfSupported(): bool { if ( ! method_exists( $this->wpdb, 'query' ) ) { return false; } $this->clearLastError(); $this->wpdb->query( 'START TRANSACTION' ); $this->assertNoDriverError( 'begin' ); return true; } /** * Issue `COMMIT` and promote any driver error. */ private function commitTransaction(): void { $this->clearLastError(); $this->wpdb->query( 'COMMIT' ); $this->assertNoDriverError( 'commit' ); } /** * Issue `ROLLBACK` swallowing any driver error. * * Used on the failure path of * {@see self::replaceEmbedItems()} so the surrounding * exception (the original cause) reaches the caller verbatim * rather than being shadowed by a follow-up rollback failure. */ private function rollbackQuietly(): void { try { $this->clearLastError(); $this->wpdb->query( 'ROLLBACK' ); } catch ( \Throwable $e ) { // Intentionally swallowed: see method docblock. unset( $e ); } } /** * Clear `$wpdb->last_error` before a call so the post-call * probe only sees errors produced by the current statement. */ private function clearLastError(): void { if ( property_exists( $this->wpdb, 'last_error' ) ) { $this->wpdb->last_error = ''; } } /** * Raise a {@see RuntimeException} when `$wpdb->last_error` is * populated, carrying the driver message verbatim for * diagnosis. * * @param string $operation Short operation label used in the * error message (for example * `insert`, `delete`, `select`). * * @throws RuntimeException When a driver error is detected. */ private function assertNoDriverError( string $operation ): void { if ( ! property_exists( $this->wpdb, 'last_error' ) ) { return; } $lastError = (string) $this->wpdb->last_error; if ( $lastError === '' ) { return; } $message = sprintf( 'WpdbEmbedItemRepository: $wpdb reported an error during %s: %s', $operation, $lastError ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } }