prefix`, e.g. * `wp_`), it concatenates that prefix with the supplied logical AFFILUN * table name to produce the physical name. * * The prefix is captured once at construction time and held verbatim. * Call sites that need per-request prefix lookup (for example * multisite installations that switch between sites mid-request) should * construct a fresh resolver after each `switch_to_blog()` rather than * reusing a cached instance, since `$wpdb->prefix` is mutated by the * switch and the resolver's stability contract requires that repeated * calls within a single resolver's lifetime return the same physical * name. * * Validation: * * - The constructor accepts any string (including empty). A genuinely * empty prefix is a legitimate configuration on non-WordPress test * beds and during fixture construction; treating it as an error * here would force test code to invent a fake prefix it does not * otherwise need. * * - {@see self::resolve()} rejects an empty logical name with an * {@see \InvalidArgumentException} because the only way to reach * that path is a programming error at the call site (typically a * forgotten constant). Failing loudly surfaces the bug instead of * producing SQL that targets whatever table happens to share the * site's prefix. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use InvalidArgumentException; /** * {@see TableNameResolver} that prepends a fixed WordPress table prefix * to each logical AFFILUN table name. * * See the file-level docblock for the prefix-capture semantics and the * empty-name validation rationale. */ final class PrefixedTableNameResolver implements TableNameResolver { /** * WordPress table prefix captured at construction time. * * Typically `$wpdb->prefix` (for example `wp_` on a default install * or `wp_2_` on the second site of a multisite network). Stored * verbatim; the resolver performs no additional normalization so * the physical name round-trips exactly what WordPress itself * would produce. */ private string $prefix; /** * Construct the resolver with a fixed WordPress table prefix. * * The prefix is captured verbatim and used for every subsequent * {@see self::resolve()} call on this instance. Callers that need * to follow a `switch_to_blog()` mutation of `$wpdb->prefix` must * construct a fresh resolver after the switch; see the file-level * docblock for the stability contract. * * @param string $prefix WordPress table prefix, typically * `$wpdb->prefix` (for example `wp_`). An * empty string is accepted for test-bed use. */ public function __construct( string $prefix ) { $this->prefix = $prefix; } /** * Named constructor that captures the prefix from a `$wpdb`-like * object exposing a public `prefix` property. * * Sugar over direct construction for production wiring code that * would otherwise spell out `new PrefixedTableNameResolver( $wpdb->prefix )`. * The parameter 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. * * @param object $wpdb Object exposing a public string `prefix` * property. Typically the `$wpdb` global. * * @throws InvalidArgumentException When the supplied object does * not expose a string `prefix` * property. */ public static function fromWpdb( object $wpdb ): self { if ( ! property_exists( $wpdb, 'prefix' ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( 'PrefixedTableNameResolver::fromWpdb() requires an object exposing a public "prefix" property.' ); } /** @var mixed $prefix */ $prefix = $wpdb->prefix; if ( ! is_string( $prefix ) ) { $rendered = is_scalar( $prefix ) ? sprintf( '%s(%s)', gettype( $prefix ), (string) $prefix ) : gettype( $prefix ); $message = sprintf( 'PrefixedTableNameResolver::fromWpdb() expected a string "prefix" property, got %s.', $rendered ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return new self( $prefix ); } /** * WordPress table prefix this resolver was constructed with. * * Exposed primarily for diagnostics and for tests that want to * verify the resolver captured the expected prefix without having * to resolve a sentinel name. */ public function prefix(): string { return $this->prefix; } /** * Resolve a logical AFFILUN table name into its physical, prefix-applied form. * * Concatenates the captured prefix with the supplied logical name * verbatim. Rejects an empty logical name with an * {@see InvalidArgumentException} because the only way to reach * that path is a programming error at the call site; see the * file-level docblock for rationale. * * @param string $logicalName Logical, unprefixed AFFILUN table name * (for example `affilun_products`). Must * be non-empty. * * @return string Physical table name suitable for direct * interpolation into SQL. * * @throws InvalidArgumentException When `$logicalName` is empty. */ public function resolve( string $logicalName ): string { if ( $logicalName === '' ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( 'PrefixedTableNameResolver::resolve() requires a non-empty logical table name.' ); } return $this->prefix . $logicalName; } }