query()`. * * Design notes: * * - The implementation owns the lifecycle state on the manager * instance, NOT on the underlying `$wpdb` object. Two * {@see WpdbTransactionManager} instances pointing at the same * `$wpdb` would each track their own `$inTransaction` flag, which * in turn means callers MUST share a single manager instance per * request (the persistence service provider does this by binding * the manager into the registry once). The Phase 2 contract * explicitly forbids nested calls from the same instance, so the * "two managers, same `$wpdb`" foot-gun is a deliberate trade-off: * the alternative — probing `$wpdb` for connection-level state — * would require driver-specific introspection that is brittle * across MySQL / MariaDB versions. * * - Each transaction primitive is issued as a standalone * `$wpdb->query()` call rather than `$wpdb->startTransaction()` / * `$wpdb->commit()` / `$wpdb->rollback()` because those helper * methods are not part of WordPress's stable public surface and * are subject to change between releases. Routing through * `query()` keeps the dependency surface narrow and matches the * access pattern the rest of the persistence layer already uses. * * - Driver errors (`$wpdb->last_error`) are promoted to * {@see RuntimeException} after every transaction-control call, * so a failed `START TRANSACTION` / `COMMIT` / `ROLLBACK` surfaces * loudly at the boundary instead of producing a silently * half-applied state. When a unit of work throws and the ensuing * ROLLBACK itself fails, the rollback failure is wrapped in a * {@see RuntimeException} that chains the original exception via * `$previous` so neither error is lost. * * - The unit of work's return value is forwarded verbatim, matching * {@see TransactionManager::transactional()}'s value-transparent * contract. Exceptions thrown by the unit of work are re-thrown * verbatim after rollback — no wrapping, no swallowing — so the * caller can match on the original type. * * - `$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, matching the convention * used by every other Wpdb-backed repository in this namespace. * The manager still requires the real interface surface at call * time; the abstract type hint only keeps the PSR-4 loader and * the PHPCS / PHPStan analyzers happy in pure-unit mode. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use RuntimeException; use Throwable; /** * {@see TransactionManager} implementation backed by WordPress's * `$wpdb`. * * See the file-level docblock for the per-instance lifecycle-state * design, the `$wpdb->query()`-based driver-call strategy, and the * error-promotion policy. */ final class WpdbTransactionManager implements TransactionManager { /** * SQL statement issued to open a fresh transaction. * * Named as a class constant so tests and diagnostics can * reference the exact string without duplicating the literal, * and so a future driver-portability change has a single * location to update. */ private const SQL_BEGIN = 'START TRANSACTION'; /** * SQL statement issued to commit the active transaction. */ private const SQL_COMMIT = 'COMMIT'; /** * SQL statement issued to roll back the active transaction. */ private const SQL_ROLLBACK = 'ROLLBACK'; /** * WordPress `$wpdb`-like object used for every transaction * primitive. * * Typed as `object` rather than `\wpdb` so this file stays * loadable in unit tests that run outside a WordPress * environment; see the file-level docblock for the rationale. */ private object $wpdb; /** * Whether a transaction is currently open on this manager * instance. * * Toggled to true by {@see self::transactional()} immediately * after `START TRANSACTION` succeeds and back to false in the * finally branch after the matching `COMMIT` / `ROLLBACK` has * been issued. Used to enforce the "no nesting" rule from the * {@see TransactionManager} contract. * * NOTE: This flag tracks the lifecycle on the manager instance * only, not on the underlying `$wpdb` connection. Callers MUST * share a single manager instance per request to avoid two * managers each thinking the connection is idle while it is * actually inside a transaction owned by the other. */ private bool $inTransaction = false; /** * Construct the manager. * * @param object $wpdb WordPress `$wpdb`-like object exposing * `query()` and the `last_error` property. */ public function __construct( object $wpdb ) { $this->wpdb = $wpdb; } /** * Execute the unit of work inside a database transaction. * * See {@see TransactionManager::transactional()} for the full * contract. This implementation: * * - Refuses re-entry: if a transaction is already active on * this manager instance, raises {@see RuntimeException} * before issuing any driver call, matching the contract's * "Phase 2 does not support nesting" rule. * * - Issues `START TRANSACTION` via `$wpdb->query()` and * promotes any driver error to {@see RuntimeException} * before invoking the unit of work. * * - On normal return, issues `COMMIT` and promotes any driver * error to {@see RuntimeException}, then forwards the * callable's return value verbatim. * * - On any thrown {@see Throwable}, issues `ROLLBACK`. A * successful rollback re-throws the original exception * verbatim. A driver error during rollback surfaces as a * fresh {@see RuntimeException} that chains the original * exception via `$previous` so neither error is lost. * * @template TReturn * * @param callable():TReturn $work Unit of work to execute. * * @return TReturn The value returned by `$work`. * * @throws RuntimeException When called while a transaction is * already active on this instance, or * when the underlying driver reports * an error during * `START TRANSACTION` / `COMMIT` / * `ROLLBACK`. * @throws Throwable Any exception raised by `$work` is * re-thrown verbatim after the * transaction has been rolled back, so * long as the rollback itself succeeded. */ public function transactional( callable $work ) { if ( $this->inTransaction ) { $message = 'WpdbTransactionManager::transactional() does not support nested calls; ' . 'a transaction is already active on this instance.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } $this->runStatement( 'begin' ); $this->inTransaction = true; try { $result = $work(); } catch ( Throwable $error ) { try { $this->safeRollback( $error ); } finally { $this->inTransaction = false; } throw $error; } try { $this->runStatement( 'commit' ); } finally { $this->inTransaction = false; } return $result; } /** * Report whether a transaction is currently active on this * manager instance. * * Mirrors the {@see TransactionManager::inTransaction()} surface; * see the contract for the rule that production code SHOULD NOT * branch on this method. */ public function inTransaction(): bool { return $this->inTransaction; } /** * Issue `ROLLBACK` after a unit-of-work failure, taking care to * preserve the original exception when the rollback itself fails. * * On a successful rollback, the method returns normally and the * caller is responsible for re-throwing the original * `$workError`. When the rollback raises a driver error, this * method throws a fresh {@see RuntimeException} that chains * `$workError` via `$previous` so the test / diagnostics surface * sees both failures. * * @param Throwable $workError The exception raised by the unit of * work, preserved as the chained * cause when rollback itself fails. * * @throws RuntimeException When `$wpdb->query('ROLLBACK')` raises * a driver error. */ private function safeRollback( Throwable $workError ): void { try { $this->runStatement( 'rollback' ); } catch ( RuntimeException $rollbackError ) { $message = sprintf( 'WpdbTransactionManager: ROLLBACK failed after a unit-of-work exception: %s ' . '(original error: %s)', $rollbackError->getMessage(), $workError->getMessage() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message, 0, $workError ); } } /** * Issue a single transaction-control statement and promote any * driver error to {@see RuntimeException}. * * Centralizes the clear / dispatch / probe sequence shared by * `START TRANSACTION`, `COMMIT`, and `ROLLBACK` so the * per-method bodies stay focused on lifecycle bookkeeping. * * @param string $operation Short operation label used in error * messages (for example `begin`, * `commit`, `rollback`). * * @throws RuntimeException When `$wpdb->query()` returns false * or `$wpdb->last_error` is populated * after the call. */ private function runStatement( string $operation ): void { $this->clearLastError(); if ( $operation === 'begin' ) { $sql = self::SQL_BEGIN; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Fixed transaction-control statement has no user-supplied parameters. $result = $this->wpdb->query( self::SQL_BEGIN ); } elseif ( $operation === 'commit' ) { $sql = self::SQL_COMMIT; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Fixed transaction-control statement has no user-supplied parameters. $result = $this->wpdb->query( self::SQL_COMMIT ); } else { $sql = self::SQL_ROLLBACK; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Fixed transaction-control statement has no user-supplied parameters. $result = $this->wpdb->query( self::SQL_ROLLBACK ); } $this->assertNoDriverError( $operation ); if ( $result === false ) { $message = sprintf( 'WpdbTransactionManager: $wpdb->query(%s) returned false during %s ' . 'without raising a driver error.', $sql, $operation ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } } /** * 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 `begin`, * `commit`, `rollback`). * * @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( 'WpdbTransactionManager: $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 ); } }