isGreaterThan($current)` than as `$a > $b` * against two opaquely-typed integers. * * 3. Parsing from the options table is normalized in one place. * WordPress may return the stored value as a string, an integer, * or the supplied default when the row is missing; {@see * self::fromStoredValue()} funnels every variant through a single * well-tested path. * * Instances are immutable. "Bumping" a version returns a new instance * via {@see self::next()} rather than mutating the receiver, so call * sites cannot accidentally share mutable state. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use InvalidArgumentException; /** * Immutable value object representing a database schema revision. * * See the file-level docblock for the persistence contract and the * rationale for preferring this type over a bare integer. */ final class SchemaVersion { /** * Canonical sentinel representing "no schema installed yet". * * Fresh WordPress environments do not have the * `affilun_db_schema_version` option populated; the migration runner * treats that state as {@see self::zero()} so every migration whose * target version is ≥ 1 is considered pending. */ private const ZERO = 0; /** * Non-negative integer version number. */ private int $version; /** * Construct a schema version from a validated integer. * * Direct construction is allowed but most call sites should prefer * the named constructors ({@see self::of()}, {@see self::zero()}, * {@see self::fromStoredValue()}) because they document intent at * the call site. * * @param int $version Non-negative integer version number. * * @throws InvalidArgumentException When `$version` is negative. */ public function __construct( int $version ) { if ( $version < 0 ) { $message = sprintf( 'Schema version must be a non-negative integer, got %d.', $version ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->version = $version; } /** * Named constructor for an explicit non-negative version. * * Reads more clearly at call sites than direct `new SchemaVersion(...)` * construction, especially when the literal value expresses intent * (for example `SchemaVersion::of( 1 )` for the initial release). * * @param int $version Non-negative integer version number. * * @throws InvalidArgumentException When `$version` is negative. */ public static function of( int $version ): self { return new self( $version ); } /** * Return the canonical sentinel for "no schema installed yet". * * Used by the migration runner when the `affilun_db_schema_version` * option is missing, so that every migration whose target version * is ≥ 1 is correctly treated as pending. */ public static function zero(): self { return new self( self::ZERO ); } /** * Normalize a value read from the options table into a SchemaVersion. * * WordPress's `get_option()` may return: * * - The caller-supplied default (typically `false` or `null`) when * the row is missing. In that case this method returns * {@see self::zero()}. * * - An integer, when the value was written by this plugin. * * - A numeric string, when another layer (cache, serialization, * external tooling) has round-tripped the value through a * string representation. * * Every other input shape is a programming error and raises an * {@see InvalidArgumentException} rather than being silently coerced, * so migration-path bugs surface loudly in tests. * * @param mixed $stored Raw value returned by the option store. * * @throws InvalidArgumentException When `$stored` cannot be * interpreted as a non-negative * integer. */ public static function fromStoredValue( $stored ): self { if ( $stored === false || $stored === null || $stored === '' ) { return self::zero(); } if ( is_int( $stored ) ) { return new self( $stored ); } if ( is_string( $stored ) && preg_match( '/^-?\d+$/', $stored ) === 1 ) { return new self( (int) $stored ); } $rendered = is_scalar( $stored ) ? sprintf( '%s(%s)', gettype( $stored ), (string) $stored ) : gettype( $stored ); $message = sprintf( 'Schema version option must be a non-negative integer or numeric string, got %s.', $rendered ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } /** * Raw integer representation of the version. * * Used when writing the value back to the options table and when * the diagnostics surface needs to report the current schema * revision to operators. */ public function toInt(): int { return $this->version; } /** * Report whether this version represents a fresh, uninitialized * schema (equivalent to {@see self::zero()}). */ public function isZero(): bool { return $this->version === self::ZERO; } /** * Report whether this version equals another version by value. * * @param self $other Other version to compare against. */ public function equals( self $other ): bool { return $this->version === $other->version; } /** * Report whether this version is strictly greater than another. * * Used by the migration runner to decide whether a migration's * target version is newer than the currently-installed schema. * * @param self $other Other version to compare against. */ public function isGreaterThan( self $other ): bool { return $this->version > $other->version; } /** * Report whether this version is strictly less than another. * * @param self $other Other version to compare against. */ public function isLessThan( self $other ): bool { return $this->version < $other->version; } /** * Return a new instance one version ahead of this one. * * Convenience for call sites that bump the version in-place after * applying a migration. The receiver is left unchanged because * SchemaVersion instances are immutable. */ public function next(): self { return new self( $this->version + 1 ); } /** * Human-readable representation used in log / diagnostics output. * * The format is deliberately terse (`"v7"`) so it composes cleanly * into longer messages such as `"migrating from v6 to v7"`. */ public function __toString(): string { return 'v' . $this->version; } }