get_charset_collate()`. * * - The physical table name is resolved through the injected * {@see TableNameResolver} exactly once per {@see self::up()} call * and passed to the subclass's DDL-assembly path, so repeated * calls to {@see self::tableName()} from tests and diagnostics * stay consistent with the name used in the actually-emitted SQL. * * Idempotency and failure semantics are inherited from the * {@see Migration} contract and the {@see SchemaInstaller} it delegates * to: `dbDelta()` is already idempotent at the DDL level, and any * exception raised by the installer propagates verbatim so the * {@see \Affilun\Infrastructure\Persistence\MigrationRunner} * can treat the step as failed. * * Per the {@see Migration} contract, subclasses MUST NOT persist the * schema version themselves; that responsibility belongs to the * runner, which writes to the * {@see \Affilun\Infrastructure\Persistence\SchemaVersionRepository} * after {@see self::up()} returns without throwing. * * @package Affilun\Infrastructure\Persistence\Migrations */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence\Migrations; use Affilun\Infrastructure\Persistence\Migration; use Affilun\Infrastructure\Persistence\SchemaInstaller; use Affilun\Infrastructure\Persistence\SchemaVersion; use Affilun\Infrastructure\Persistence\TableNameResolver; /** * Shared skeleton for migrations that install a single AFFILUN table. * * See the file-level docblock for the template-method contract that * subclasses implement and the formatting guarantees this base class * provides. */ abstract class AbstractCreateTableMigration implements Migration { /** * Schema installer used to apply the `CREATE TABLE` DDL. * * Injected via the constructor so subclasses remain unit-testable * against an in-memory fake that captures the issued SQL; the * production wiring code passes * {@see \Affilun\Infrastructure\Persistence\WpdbSchemaInstaller}. */ private SchemaInstaller $installer; /** * Resolver used to map each subclass's logical table name to the * physical, prefix-applied table name that goes into the DDL. * * Injected via the constructor so tests can resolve to * deterministic names (for example `test_affilun_products`) without * standing up a `$wpdb` instance. */ private TableNameResolver $tableNames; /** * Charset and collation clause appended to the `CREATE TABLE` * statement. * * In production this is `$wpdb->get_charset_collate()`, which * returns a string such as * `DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci`. * Injected as a plain string so tests can supply a stable value * (typically `DEFAULT CHARSET=utf8mb4`) and assert on the emitted * SQL without instantiating `$wpdb`. * * An empty string is accepted and triggers the * `DEFAULT CHARSET=utf8mb4` fallback in * {@see self::buildCreateTableSql()}; the resulting DDL is still * valid, though the table will use the server's default collation * rather than WordPress's configured one. */ private string $charsetCollate; /** * Construct the migration with its explicit collaborators. * * @param SchemaInstaller $installer Installer used to apply the DDL. * @param TableNameResolver $tableNames Resolver that produces the * physical table name. * @param string $charsetCollate Charset / collation clause * appended to the * `CREATE TABLE` statement. * Production callers pass * `$wpdb->get_charset_collate()`; * an empty string triggers * the `DEFAULT CHARSET=utf8mb4` * fallback. */ public function __construct( SchemaInstaller $installer, TableNameResolver $tableNames, string $charsetCollate ) { $this->installer = $installer; $this->tableNames = $tableNames; $this->charsetCollate = $charsetCollate; } /** * Target schema version installed by this migration. * * Subclasses return the revision they install (for example * `SchemaVersion::of( 1 )` for the initial `affilun_products` table). * See `database-schema.md` §13.2 for the canonical version * numbering. */ abstract public function targetVersion(): SchemaVersion; /** * Short, human-readable description used in diagnostics and logs. * * Kept terse and operator-friendly per the * {@see Migration::description()} contract, for example * `"create affilun_products table"`. */ abstract public function description(): string; /** * Logical, unprefixed AFFILUN table name installed by this migration. * * The returned string is the bare identifier used in * `database-schema.md` (for example `affilun_products`, * `affilun_embed_items`). It is passed through the injected * {@see TableNameResolver} to produce the physical name used in * the emitted DDL. */ abstract protected function logicalTableName(): string; /** * Column definition fragments for this table, in the order they * should appear in the emitted DDL. * * Each entry is a single column definition without its trailing * comma and without the leading indent — for example * `"id bigint unsigned NOT NULL AUTO_INCREMENT"` or * `"sync_state varchar(16) NOT NULL DEFAULT 'fresh'"`. The base * class adds the two-space indent, inter-column commas, and line * breaks when assembling the final SQL. * * @return list */ abstract protected function columnDefinitions(): array; /** * Index definition fragments for this table, in the order they * should appear in the emitted DDL. * * Each entry is a single PRIMARY / UNIQUE / KEY clause without * its trailing comma and without the leading indent — for example * `"PRIMARY KEY (id)"` (note the two-space separator that * `dbDelta()` requires), `"UNIQUE KEY uq_provider_item (provider, provider_item_id)"`, * or `"KEY idx_created_at (created_at)"`. The base class adds the * two-space indent, inter-index commas, and line breaks when * assembling the final SQL. * * The list MUST be non-empty: every AFFILUN table declares at least a * primary key, and an empty index list is therefore a programming * error that the base class does not attempt to mask. * * @return list */ abstract protected function indexDefinitions(): array; /** * Apply the schema change. * * Resolves the physical table name, assembles the canonical DDL * from the subclass's column and index manifests, and hands the * resulting SQL to the injected {@see SchemaInstaller}. Per the * {@see Migration} contract, this method does NOT persist the * schema version; that is the runner's responsibility. * * Any exception raised by the installer propagates verbatim so the * {@see \Affilun\Infrastructure\Persistence\MigrationRunner} * can treat the step as failed and leave the schema version at * the previous revision. */ public function up(): void { $tableName = $this->tableNames->resolve( $this->logicalTableName() ); $sql = $this->buildCreateTableSql( $tableName ); $this->installer->install( $sql ); } /** * Physical table name this migration installs, resolved through * the injected {@see TableNameResolver}. * * Exposed so tests and diagnostics can ask for the resolved name * without replaying the full `up()` path. Not part of the * {@see Migration} contract; production call sites should prefer * {@see self::up()}. */ public function tableName(): string { return $this->tableNames->resolve( $this->logicalTableName() ); } /** * Assemble the canonical `CREATE TABLE` DDL from the subclass's * column and index manifests. * * The formatting follows the conventions that `dbDelta()` requires * (one column per line, two-space indent, explicit index names, * no trailing comma, `ENGINE=InnoDB` followed by the charset * clause). An empty injected charset clause falls back to * `DEFAULT CHARSET=utf8mb4` so the emitted DDL is still valid on * test beds that do not surface `$wpdb->get_charset_collate()`. * * Kept `final` because the exact SQL shape is part of this base * class's contract with the {@see SchemaInstaller}; subclasses * customize the payload through the column / index / name hooks * rather than by rewriting the assembled string. * * @param string $tableName Physical table name to create. */ final protected function buildCreateTableSql( string $tableName ): string { $charsetClause = $this->charsetCollate === '' ? 'DEFAULT CHARSET=utf8mb4' : $this->charsetCollate; $definitions = array(); foreach ( $this->columnDefinitions() as $column ) { $definitions[] = ' ' . $column; } foreach ( $this->indexDefinitions() as $index ) { $definitions[] = ' ' . $index; } $body = implode( ",\n", $definitions ); return sprintf( "CREATE TABLE %s (\n", $tableName ) . $body . "\n" . sprintf( ') ENGINE=InnoDB %s;', $charsetClause ); } }