migrations_dir = dirname(__FILE__) . '/migrations'; } /** * Get current database version * * @return string Current DB version (defaults to '0.0') */ public function get_current_version() { return get_option(self::DB_VERSION_OPTION, '0.0'); } /** * Get required database version * * @return string Required DB version from plugin constant */ public function get_required_version() { return defined('RFCC_DB_VERSION') ? RFCC_DB_VERSION : '1.0'; } /** * Check if migrations are needed * * @return bool True if migrations needed */ public function needs_migration() { return version_compare($this->get_current_version(), $this->get_required_version(), '<'); } /** * Run all pending migrations * * @param bool $force Force migrations even if version matches * @return array Results array with success status and log */ public function run_migrations($force = false) { $current_version = $this->get_current_version(); $required_version = $this->get_required_version(); $this->log("Starting migration check..."); $this->log("Current DB version: {$current_version}"); $this->log("Required DB version: {$required_version}"); if (!$force && !$this->needs_migration()) { $this->log("No migrations needed."); return [ 'success' => true, 'log' => $this->migration_log, 'migrations_run' => 0 ]; } // Create backup before migrations if ($this->create_backup) { $this->create_options_backup(); } // Get all migration files $migrations = $this->get_migration_files(); if (empty($migrations)) { $this->log("No migration files found."); return [ 'success' => true, 'log' => $this->migration_log, 'migrations_run' => 0 ]; } $migrations_run = 0; $errors = []; // Run each migration in sequence foreach ($migrations as $version => $file) { // Skip if migration already applied if (!$force && version_compare($current_version, $version, '>=')) { $this->log("Skipping migration {$version} (already applied)"); continue; } // Skip if migration is beyond required version if (version_compare($version, $required_version, '>')) { $this->log("Skipping migration {$version} (beyond required version)"); continue; } $this->log("Running migration {$version}..."); try { $result = $this->run_migration_file($file, $version); if ($result['success']) { $migrations_run++; $this->log("✓ Migration {$version} completed successfully"); // Update DB version after successful migration update_option(self::DB_VERSION_OPTION, $version); $current_version = $version; } else { $error_msg = "✗ Migration {$version} failed: " . $result['error']; $this->log($error_msg); $errors[] = $error_msg; // Stop on first error break; } } catch (Exception $e) { $error_msg = "✗ Migration {$version} threw exception: " . $e->getMessage(); $this->log($error_msg); $errors[] = $error_msg; // Stop on first error break; } } // Final version update if we reached required version if (empty($errors) && version_compare($current_version, $required_version, '>=')) { update_option(self::DB_VERSION_OPTION, $required_version); $this->log("Database version updated to {$required_version}"); } $success = empty($errors); if ($success) { $this->log("All migrations completed successfully!"); } else { $this->log("Migration stopped due to errors."); } return [ 'success' => $success, 'log' => $this->migration_log, 'migrations_run' => $migrations_run, 'errors' => $errors ]; } /** * Get all migration files sorted by version * * @return array Array of version => filepath */ private function get_migration_files() { if (!is_dir($this->migrations_dir)) { return []; } $files = glob($this->migrations_dir . '/migration-*.php'); $migrations = []; foreach ($files as $file) { // Extract version from filename (e.g., migration-1.0.php) if (preg_match('/migration-([0-9.]+)\.php$/', basename($file), $matches)) { $version = $matches[1]; $migrations[$version] = $file; } } // Sort by version uksort($migrations, 'version_compare'); return $migrations; } /** * Run a single migration file * * @param string $file Migration file path * @param string $version Version number * @return array Result with success and optional error */ private function run_migration_file($file, $version) { if (!file_exists($file)) { return [ 'success' => false, 'error' => "Migration file not found: {$file}" ]; } // Include the migration file // Migration files should return a callable or define a migration function $migration = include $file; if (!is_callable($migration)) { return [ 'success' => false, 'error' => "Migration file must return a callable function" ]; } // Execute migration try { $result = call_user_func($migration, $this); if (is_array($result)) { return $result; } // If migration returns true/false return [ 'success' => (bool) $result, 'error' => $result ? null : "Migration returned false" ]; } catch (Exception $e) { return [ 'success' => false, 'error' => $e->getMessage() ]; } } /** * Create backup of all rfcc_ options before migration */ private function create_options_backup() { global $wpdb; $this->log("Creating backup of plugin options..."); // Earlier backups (and import backups) are themselves rfcc_* options - // exclude them, otherwise every backup nests a copy of all previous // ones and the stored size grows exponentially with each migration. $options = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name NOT LIKE %s AND option_name NOT LIKE %s", $wpdb->esc_like('rfcc_') . '%', $wpdb->esc_like('rfcc_migration_backup_') . '%', $wpdb->esc_like('rfcc_pre_prod_import_backup_') . '%' ), ARRAY_A ); if (!empty($options)) { $backup_key = 'rfcc_migration_backup_' . time(); update_option($backup_key, $options, false); $this->log("Backup created: {$backup_key} (" . count($options) . " options)"); $this->prune_old_backups(); } } /** * Keep only the most recent migration backups. * * @param int $keep Number of backups to keep. */ private function prune_old_backups($keep = 5) { $backups = $this->get_backups(); // Newest first. foreach (array_slice($backups, $keep) as $backup) { delete_option($backup['backup_key']); $this->log("Removed old backup: {$backup['backup_key']}"); } } /** * Add a log entry * * @param string $message Log message */ public function log($message) { $timestamp = current_time('Y-m-d H:i:s'); $this->migration_log[] = "[{$timestamp}] {$message}"; } /** * Get migration log * * @return array Log entries */ public function get_log() { return $this->migration_log; } /** * Helper: Add option if it doesn't exist * * @param string $option Option name * @param mixed $value Default value * @param string $autoload Autoload setting * @return bool True if added, false if already exists */ public function add_option_if_missing($option, $value, $autoload = 'yes') { if (get_option($option) === false) { add_option($option, $value, '', $autoload); $this->log("Added option: {$option}"); return true; } return false; } /** * Helper: Update option with logging * * @param string $option Option name * @param mixed $value New value * @return bool Update result */ public function update_option_logged($option, $value) { $old_value = get_option($option); $result = update_option($option, $value); if ($result) { $this->log("Updated option: {$option}"); } return $result; } /** * Helper: Rename option * * @param string $old_name Old option name * @param string $new_name New option name * @return bool Success */ public function rename_option($old_name, $new_name) { $value = get_option($old_name); if ($value === false) { $this->log("Option {$old_name} not found, skipping rename"); return false; } // Add new option add_option($new_name, $value); // Delete old option delete_option($old_name); $this->log("Renamed option: {$old_name} → {$new_name}"); return true; } /** * Get available backups * * @return array Backup options with timestamps */ public function get_backups() { global $wpdb; $backups = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'rfcc_migration_backup_%' ORDER BY option_name DESC", ARRAY_A ); $result = []; foreach ($backups as $backup) { if (preg_match('/rfcc_migration_backup_(\d+)/', $backup['option_name'], $matches)) { $timestamp = $matches[1]; $options = maybe_unserialize($backup['option_value']); $result[] = [ 'backup_key' => $backup['option_name'], 'timestamp' => $timestamp, 'date' => wp_date('Y-m-d H:i:s', $timestamp), 'option_count' => is_array($options) ? count($options) : 0 ]; } } return $result; } /** * Restore from backup * * @param string $backup_key Backup option name * @return bool Success */ public function restore_backup($backup_key) { $backup_data = get_option($backup_key); if (!$backup_data || !is_array($backup_data)) { return false; } foreach ($backup_data as $option) { if (!is_array($option) || !isset($option['option_name'], $option['option_value'])) { continue; } // Only ever restore this plugin's own settings - never another // backup row (older backups nested earlier backups inside them). if (strpos($option['option_name'], 'rfcc_') !== 0 || strpos($option['option_name'], 'rfcc_migration_backup_') === 0 || strpos($option['option_name'], 'rfcc_pre_prod_import_backup_') === 0) { continue; } update_option($option['option_name'], maybe_unserialize($option['option_value'])); } return true; } }