validate` when it is callable), so every datetime column in this * plugin points here instead. Fixing it per-column rather than at the write * sites is what covers `created_at`, which BerlinDB stamps itself before * running the same validation pass. * * --------------------------------------------------------------------------- * Contract * --------------------------------------------------------------------------- * Behaviour matches `Column::validate_datetime()` exactly — the * `CURRENT_TIMESTAMP` passthrough, the zero-date fallback for empty input, and * the zero-date fallback for unparseable input — differing ONLY in anchoring * the parse to UTC. * * BerlinDB falls back to `(string) $this->default`. A validator is invoked as * `call_user_func( $this->validate, $value )` with no column context, so the * fallback is the literal zero date here. That is the same value BerlinDB * would produce for our columns: none of them declares a `default`, so the * default is itself validated from an empty string down to the zero date, and * the emitted DDL is `default '0000-00-00 00:00:00'`. A future datetime column * that DOES declare a non-zero default must not use this validator without * revisiting this paragraph. * * @since 0.9.16 */ final class DatetimeColumn { /** * Default empty datetime — the value BerlinDB uses with NO_ZERO_DATE off. * * @since 0.9.16 */ public const EMPTY_DATETIME = '0000-00-00 00:00:00'; /** * Validate a datetime value, resolving it against UTC rather than PHP's * default timezone. * * Referenced from schemas as * `'validate' => array( DatetimeColumn::class, 'validate_utc' )`. * * @since 0.9.16 * * @param mixed $value Value to validate. * @return string A valid datetime value, or the zero date. */ public static function validate_utc( $value = '' ): string { $value = is_scalar( $value ) ? trim( (string) $value ) : ''; // MySQL constant — passed through untouched, as BerlinDB does. if ( 'CURRENT_TIMESTAMP' === strtoupper( $value ) ) { return 'CURRENT_TIMESTAMP'; } if ( '' === $value || self::EMPTY_DATETIME === $value ) { return self::EMPTY_DATETIME; } $timestamp = strtotime( self::anchor_to_utc( $value ) ); // Anchoring only ever helps a value that carries no timezone of its // own. It can still fail on an input `strtotime()` accepts in its bare // form but not with a suffix — a relative expression like "+3 days". // Retrying preserves BerlinDB's acceptance rather than narrowing it. // Such a value resolves against the default timezone, as it did // before; nothing in this plugin writes one, every producer being // `gmdate( 'Y-m-d H:i:s', ... )`. if ( false === $timestamp ) { $timestamp = strtotime( $value ); } if ( false === $timestamp ) { return self::EMPTY_DATETIME; } return gmdate( 'Y-m-d H:i:s', $timestamp ); } /** * Append a UTC marker to a value that does not already carry a timezone. * * Re-anchoring a value that states its own offset would be wrong — it * would move an instant the caller had already pinned — so a trailing * `Z`, `UTC`, `GMT`, or `±hh:mm` / `±hhmm` offset is left alone. * * @since 0.9.16 * * @param string $value Trimmed datetime string. * @return string */ private static function anchor_to_utc( string $value ): string { if ( (bool) preg_match( '/(?:Z|UTC|GMT|[+-]\d{2}:?\d{2})$/i', $value ) ) { return $value; } return $value . ' UTC'; } }