db = $db; return; } global $wpdb; $this->db = $wpdb; } /** * Apply every pending migration in version order. * * @return list Versions applied in this run (empty when already up to date). * * @throws RuntimeException When a lock cannot be acquired or a migration fails. * * @since 0.1.0 */ public function migrate(): array { $this->ensure_history_table(); if ( ! $this->acquire_lock() ) { throw new RuntimeException( 'Could not acquire the Bookline migration lock.' ); } $applied = []; try { foreach ( $this->migrations() as $migration ) { if ( $this->has_applied( $migration->version() ) ) { continue; } $migration->up(); $this->record( $migration ); $applied[] = $migration->version(); } $latest = $this->latest_version(); if ( null !== $latest ) { update_option( self::OPTION_KEY, $latest, false ); } } catch ( Throwable $e ) { throw new RuntimeException( sprintf( 'Bookline migration failed: %s', esc_html( $e->getMessage() ) ), 0, $e ); } finally { $this->release_lock(); } return $applied; } /** * Current recorded database version option, or empty string. * * @since 0.1.0 */ public function current_version(): string { $value = get_option( self::OPTION_KEY, '' ); return is_string( $value ) ? $value : ''; } /** * Highest shipped migration version. * * @since 0.1.0 */ public function expected_version(): string { $latest = ''; foreach ( $this->migrations() as $migration ) { $latest = $migration->version(); } return $latest; } /** * Whether activate-time migrations have not yet reached this release. * * @since 0.1.0 */ public function needs_upgrade(): bool { $expected = $this->expected_version(); return '' === $expected || $this->current_version() !== $expected; } /** * Ordered list of schema migrations shipped with this release. * * @return list */ private function migrations(): array { return [ new M0001CreateCoreTables( $this->db ), new M0002BookingSlotLock( $this->db ), new M0003NotificationLogIdempotency( $this->db ), new M0004AutomationSchema( $this->db ), ]; } /** * Create the migrations history table if missing. */ private function ensure_history_table(): void { $sql = 'CREATE TABLE IF NOT EXISTS `{prefix}bookline_migrations` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `version` VARCHAR(50) NOT NULL, `name` VARCHAR(255) NOT NULL, `applied_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `version` (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci'; $this->exec_static( $sql ); } /** * Whether a version row already exists in the history table. * * @param string $version Migration version. */ private function has_applied( string $version ): bool { $sql = $this->db->prepare( 'SELECT COUNT(*) FROM %i WHERE `version` = %s', $this->db->prefix . self::HISTORY_TABLE, $version ); if ( ! is_string( $sql ) ) { return false; } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $count = $this->db->get_var( $sql ); return (int) $count > 0; } /** * Record a successful migration in history. * * @param MigrationInterface $migration Applied migration. */ private function record( MigrationInterface $migration ): void { $now = gmdate( 'Y-m-d H:i:s' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $this->db->insert( $this->db->prefix . self::HISTORY_TABLE, [ 'version' => $migration->version(), 'name' => $migration->description(), 'applied_at' => $now, ], [ '%s', '%s', '%s' ] ); } /** * Highest applied version string, or null when none. */ private function latest_version(): ?string { $sql = $this->db->prepare( 'SELECT `version` FROM %i ORDER BY `version` DESC LIMIT 1', $this->db->prefix . self::HISTORY_TABLE ); if ( ! is_string( $sql ) ) { return null; } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $version = $this->db->get_var( $sql ); return is_string( $version ) && '' !== $version ? $version : null; } /** * Acquire an exclusive migration lock via transient. */ private function acquire_lock(): bool { if ( get_transient( self::LOCK_KEY ) ) { return false; } return (bool) set_transient( self::LOCK_KEY, '1', self::LOCK_TTL ); } /** * Release the migration lock. */ private function release_lock(): void { delete_transient( self::LOCK_KEY ); } /** * Execute static SQL after substituting the table prefix placeholder. * * @param string $sql SQL containing `{prefix}` only — no other variables. */ private function exec_static( string $sql ): void { $statement = str_replace( '{prefix}', $this->db->prefix, $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- static DDL. $this->db->query( $statement ); } }