*/ private array $migrations = array(); /** * Whether {@see self::run()} has already been invoked on this * instance. * * Tracked purely so repeat calls to `run()` from the same activation * path can be treated as no-ops; registering additional migrations * after a run is still allowed, and a subsequent `run()` will pick * them up. */ private bool $hasRun = false; /** * Construct the runner. * * @param SchemaVersionRepository $versions Repository used to read * and persist the schema * version across calls to * {@see self::run()}. */ public function __construct( SchemaVersionRepository $versions ) { $this->versions = $versions; } /** * Register a migration with the runner. * * Migrations may be added in any order; {@see self::run()} sorts * them by target version before dispatching. Duplicate target * versions are rejected with a {@see LogicException} so ordering * ambiguity surfaces loudly rather than silently. * * Target versions MUST be strictly positive. Version 0 is the * reserved "no schema installed yet" sentinel on * {@see SchemaVersion} and cannot be the target of a migration. * * @param Migration $migration Migration to register. * * @throws InvalidArgumentException When `$migration` targets version 0. * @throws LogicException When another migration already * targets the same version. */ public function add( Migration $migration ): void { $version = $migration->targetVersion(); if ( $version->isZero() ) { $message = sprintf( 'Migration "%s" targets reserved schema version 0; migrations must target a strictly positive version.', $migration->description() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $key = $version->toInt(); if ( array_key_exists( $key, $this->migrations ) ) { $existing = $this->migrations[ $key ]; $message = sprintf( 'Duplicate migration target version %d: "%s" conflicts with previously-registered "%s".', $key, $migration->description(), $existing->description() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $this->migrations[ $key ] = $migration; } /** * Register multiple migrations at once. * * Sugar over repeated {@see self::add()} calls that returns `$this` * so wiring code can chain the call: * * `( new MigrationRunner( $repo ) )->withMigrations( [ ... ] )->run();` * * Each element is added in iteration order; duplicate target * versions raise {@see LogicException} exactly as in * {@see self::add()}. * * @param list $migrations Migrations to register, in any order. * * @return static */ public function withMigrations( array $migrations ): self { foreach ( $migrations as $migration ) { $this->add( $migration ); } return $this; } /** * Return every migration registered with the runner, sorted by * target version ascending. * * Exposed primarily for tests and for diagnostics surfaces that * want to enumerate the known migration pipeline without triggering * it. Production call sites should invoke {@see self::run()} rather * than iterate this list directly. * * @return list */ public function migrations(): array { $ordered = $this->migrations; ksort( $ordered, SORT_NUMERIC ); return array_values( $ordered ); } /** * Highest target version known to the runner, or * {@see SchemaVersion::zero()} when no migrations are registered. * * Useful for diagnostics that want to surface "schema is up to * date" vs. "N migrations pending" without running the pipeline. */ public function highestKnownVersion(): SchemaVersion { if ( $this->migrations === array() ) { return SchemaVersion::zero(); } return SchemaVersion::of( max( array_keys( $this->migrations ) ) ); } /** * Report whether {@see self::run()} has already been invoked on * this instance. * * Repeated calls to `run()` are safe (the second call will simply * discover no pending migrations), but callers that want to avoid * re-entering the pipeline may probe this flag first. */ public function hasRun(): bool { return $this->hasRun; } /** * Apply every pending migration in ascending target-version order. * * A migration is considered "pending" when its target version is * strictly greater than the currently-installed schema version as * reported by {@see SchemaVersionRepository::currentVersion()}. * * After each migration's {@see Migration::up()} returns without * throwing, the runner persists the migration's target version via * {@see SchemaVersionRepository::record()}. If `up()` throws, the * runner re-throws the original {@see Throwable} without touching * the schema version, so the site remains at the last successfully- * applied step and the next invocation resumes from there. * * Returns a {@see MigrationResult} describing the before / after * versions and the ordered list of applied migrations — including * the no-op case where every registered migration's target version * is already satisfied. * * @throws Throwable Re-thrown verbatim from a failing migration's * {@see Migration::up()}. Callers that need to * distinguish migration failures from other * exceptions should catch at this boundary. */ public function run(): MigrationResult { $this->hasRun = true; $start = $this->versions->currentVersion(); $current = $start; $applied = array(); foreach ( $this->migrations() as $migration ) { if ( ! $migration->targetVersion()->isGreaterThan( $current ) ) { continue; } $migration->up(); $this->versions->record( $migration->targetVersion() ); $current = $migration->targetVersion(); $applied[] = $migration; } return new MigrationResult( $start, $current, $applied ); } }