` so that {@see SchemaInstaller} * implementations share a single return shape regardless of whether * the underlying installer returned an associative array (dbDelta's * historical behavior) or something already index-sequential. * * Autoload / require behavior: * * - `dbDelta()` lives in `ABSPATH . 'wp-admin/includes/upgrade.php'`. * Production call sites (plugin activation, cron, CLI) may be * invoked before the admin bootstrap has required that file, so * the adapter requires it on first use via `require_once`. The * guard is `function_exists( 'dbDelta' )` rather than a state flag * so a host that has already pulled the file in (for example via * `wp core update`) pays no extra include cost. * * - `ABSPATH` is a WordPress runtime constant. When it is not * defined (unit tests running outside WordPress), the adapter * cannot do its job; callers in that environment are expected to * inject an in-memory fake implementing {@see SchemaInstaller} * instead of instantiating this adapter. * * Error signaling: * * - `dbDelta()` itself does not throw on ordinary "table already * exists" outcomes; it simply returns an empty or short list of * diagnostic messages. That matches the {@see SchemaInstaller} * contract, so the adapter forwards the return value without * interpreting it. * * - If the DDL is malformed and `$wpdb->query()` reports an error, * `dbDelta()` surfaces the failure through `$wpdb->last_error` * rather than throwing. The adapter inspects that field after the * call and raises a {@see \RuntimeException} so the migration * runner can treat the step as failed (per its re-throw contract). * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use RuntimeException; /** * {@see SchemaInstaller} implementation backed by WordPress's `dbDelta()`. * * See the file-level docblock for lazy-require behavior and error * signaling. This adapter is deliberately allocation-light: it forwards * the caller's SQL verbatim and normalizes only the return shape. */ final class WpdbSchemaInstaller implements SchemaInstaller { /** * Apply a single `CREATE TABLE` DDL statement via `dbDelta()`. * * Lazily requires `wp-admin/includes/upgrade.php` on first use so * the adapter is safe to call from activation hooks, cron * callbacks, and CLI commands — contexts where WordPress does not * autoload the admin upgrade helpers. The require is guarded by * `function_exists( 'dbDelta' )` so repeat calls and hosts that * have already pulled the file in pay no extra cost. * * `dbDelta()` historically returns an associative array keyed by * table/column name; the adapter reindexes it to `list` so * consumers of {@see SchemaInstaller} see a uniform shape. * * @param string $createTableSql Fully-formed `CREATE TABLE` DDL. * * @return list Diagnostic messages from `dbDelta()`; empty * when no change was required. * * @throws RuntimeException When `ABSPATH` is not defined (adapter * instantiated outside WordPress) or when * `$wpdb->last_error` is populated after * the `dbDelta()` call. */ public function install( string $createTableSql ): array { if ( ! defined( 'ABSPATH' ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( 'WpdbSchemaInstaller requires a loaded WordPress environment (ABSPATH is not defined). ' . 'Unit tests should inject an in-memory SchemaInstaller fake instead.' ); } if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } global $wpdb; // Clear any prior error string so the post-call probe only sees // errors produced by the current dbDelta() invocation. if ( isset( $wpdb ) && property_exists( $wpdb, 'last_error' ) ) { $wpdb->last_error = ''; } /** @var array $messages */ $messages = dbDelta( $createTableSql ); if ( isset( $wpdb ) && property_exists( $wpdb, 'last_error' ) ) { $lastError = (string) $wpdb->last_error; if ( $lastError !== '' ) { $message = sprintf( 'dbDelta() reported a database error while applying schema: %s', $lastError ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } } $normalized = array(); foreach ( $messages as $message ) { if ( is_string( $message ) ) { $normalized[] = $message; } elseif ( is_scalar( $message ) ) { $normalized[] = (string) $message; } } return $normalized; } }