internalOptions->internal->lastActiveVersion; // Acquire a database lock to prevent race conditions when multiple concurrent // requests (e.g., page load + AJAX) trigger the migration simultaneously. // Without this lock, parallel requests can pass the column existence checks // before either has added the column, causing "Duplicate column name" errors. if ( ! aioseo()->core->db->acquireLock( 'aioseo_pre_updates', 0 ) ) { return; } if ( version_compare( $lastActiveVersion, '4.9.7', '<' ) ) { $this->addIsObjectColumnToCache(); $this->cleanCacheTable(); // Clean duplicate entries before schema changes $this->updateCacheTable(); // update the cache table to use the new name column $this->createCacheTable(); // Run dbDelta first to add the 'name' column $this->updateCrawlCleanupLogsTable(); $this->createCrawlCleanupLogsTable(); aioseo()->core->cache->delete( 'db_schema' ); } // The legacy `< 4.9.7.1` block that used to drop ndx_aioseo_cache_key and // ndx_aioseo_crawl_cleanup_blocked_args_key_value_hash lived here. It was // gated on lastActiveVersion, which Updates::updateLatestVersion() bumps // regardless of whether the migration actually completed — leaving a // non-trivial set of sites with the version flag advanced but the legacy // indexes still present, silently corrupting cache writes via ON DUPLICATE // KEY UPDATE collisions on `key=''`. Ownership of that repair moved to // {@see \AIOSEO\Plugin\Common\Main\Migrations\Definitions\DropLegacyCacheIndexes}, // where the MigrationRunner uses verify() as the truth signal and keeps // retrying until the indexes are confirmed gone. } /** * Updates the cache table to use the new name column. * * NOTE: This method uses raw SQL queries to check table/column existence instead of * the Database helper methods (tableExists/columnExists) because those methods rely on * the cache system, which is exactly what we're trying to migrate here. * * @since 4.9.7 * * @return void */ private function updateCacheTable() { $db = aioseo()->core->db->db; $tableName = $db->prefix . 'aioseo_cache'; // Check if table exists using raw SQL (bypass cache to avoid circular dependency) $tableExists = $db->get_var( $db->prepare( 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $tableName ) ); if ( empty( $tableExists ) ) { return; } // Check if 'key' column exists using raw SQL (bypass cache) $keyColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'key'", $tableName ) ); if ( ! empty( $keyColumnExists ) ) { // set key column as nullable to avoid retro compatibility issues aioseo()->core->db->execute( "ALTER TABLE {$tableName} MODIFY COLUMN `key` varchar(80) DEFAULT NULL" ); } } /** * Updates the crawl cleanup logs table to use the new param column. * * NOTE: This method uses raw SQL queries to check table/column existence instead of * the Database helper methods (tableExists/columnExists) because those methods rely on * the cache system, which is exactly what we're trying to migrate here. * * Each operation checks its precondition immediately before executing to handle * cases where the migration may have partially run before. * * @since 4.9.7 * * @return void */ private function updateCrawlCleanupLogsTable() { $db = aioseo()->core->db->db; // Process aioseo_crawl_cleanup_logs table $logsTableName = $db->prefix . 'aioseo_crawl_cleanup_logs'; // Check if logs table exists using raw SQL $logsTableExists = $db->get_var( $db->prepare( 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $logsTableName ) ); if ( ! empty( $logsTableExists ) ) { // Add the param column if it doesn't exist (check immediately before operation) $paramColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'param'", $logsTableName ) ); if ( empty( $paramColumnExists ) ) { aioseo()->core->db->execute( "ALTER TABLE {$logsTableName} ADD COLUMN `param` TEXT NOT NULL AFTER `slug`" ); } // Check if 'key' column exists immediately before trying to use $logsKeyColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'key'", $logsTableName ) ); if ( ! empty( $logsKeyColumnExists ) ) { // Migrate data from key to param if needed aioseo()->core->db->execute( "UPDATE {$logsTableName} SET `param` = `key` WHERE `param` IS NULL OR `param` = ''" ); // set key column as nullable to avoid retro compatibility issues aioseo()->core->db->execute( "ALTER TABLE {$logsTableName} MODIFY COLUMN `key` TEXT DEFAULT NULL" ); } } // Process aioseo_crawl_cleanup_blocked_args table $blockedArgsTableName = $db->prefix . 'aioseo_crawl_cleanup_blocked_args'; // Check if blocked args table exists using raw SQL $blockedArgsTableExists = $db->get_var( $db->prepare( 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $blockedArgsTableName ) ); if ( empty( $blockedArgsTableExists ) ) { return; } // Add the param column if it doesn't exist (check immediately before operation) $paramColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'param'", $blockedArgsTableName ) ); if ( empty( $paramColumnExists ) ) { aioseo()->core->db->execute( "ALTER TABLE {$blockedArgsTableName} ADD COLUMN `param` TEXT DEFAULT NULL AFTER `id`" ); } // Check if 'key' column exists immediately before trying to use $keyColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'key'", $blockedArgsTableName ) ); if ( ! empty( $keyColumnExists ) ) { // Migrate data from key to param. aioseo()->core->db->execute( "UPDATE {$blockedArgsTableName} SET `param` = `key` WHERE `param` IS NULL OR `param` = ''" ); } // Add the param_value_hash column if it doesn't exist (check immediately before operation) $paramValueHashColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'param_value_hash'", $blockedArgsTableName ) ); if ( empty( $paramValueHashColumnExists ) ) { aioseo()->core->db->execute( "ALTER TABLE {$blockedArgsTableName} ADD COLUMN `param_value_hash` varchar(40) DEFAULT NULL" ); } // Check if 'key_value_hash' column exists immediately before trying to use $keyValueHashColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'key_value_hash'", $blockedArgsTableName ) ); if ( ! empty( $keyValueHashColumnExists ) ) { // Migrate data from key_value_hash to param_value_hash. aioseo()->core->db->execute( "UPDATE {$blockedArgsTableName} SET `param_value_hash` = `key_value_hash` WHERE `param_value_hash` IS NULL OR `param_value_hash` = ''" ); } } /** * Cleans the cache table before schema changes. * * Removes duplicate and empty entries that would prevent adding a UNIQUE KEY constraint. * * NOTE: This method uses raw SQL queries to check table/column existence instead of * the Database helper methods (tableExists/columnExists) because those methods rely on * the cache system, which is exactly what we're trying to migrate here. * * @since 4.9.7 * * @return void */ private function cleanCacheTable() { $db = aioseo()->core->db->db; $tableName = $db->prefix . 'aioseo_cache'; // Check if table exists using raw SQL (bypass cache to avoid circular dependency) $tableExists = $db->get_var( $db->prepare( 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $tableName ) ); if ( empty( $tableExists ) ) { return; } // Only truncate if the table still has the old 'key' column, meaning this is an // upgrade that needs cleaning before adding the UNIQUE KEY on 'name'. // Fresh installs and already-migrated tables don't need cleaning and may contain // important cache entries like 'activation_redirect' for the setup wizard. $keyColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'key'", $tableName ) ); if ( empty( $keyColumnExists ) ) { return; } aioseo()->core->db->execute( "TRUNCATE TABLE {$tableName}" ); } /** * Creates a new aioseo_cache table. * * Now uses dbDelta to update DB schema instead of manual table creation. * * @since 4.1.5 * * @return void */ public function createCacheTable() { if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } // Truncate existing rows BEFORE dbDelta to prevent "Duplicate entry" errors // when adding the UNIQUE KEY on the new 'name' column. Without this, dbDelta adds // the column with default '' for all existing rows, causing duplicate key violations. // Only needed when upgrading from an old schema that has the 'key' column. $db = aioseo()->core->db->db; $tableName = $db->prefix . 'aioseo_cache'; $keyColumnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'key'", $tableName ) ); if ( ! empty( $keyColumnExists ) ) { aioseo()->core->db->execute( "TRUNCATE TABLE {$tableName}" ); } // Use dbDelta to create the cache table based on schema definition. $schema = aioseo()->dbSchema->getCacheTableSchema(); dbDelta( $schema ); // Only clear cache if we migrated from the old schema, to avoid wiping // important cache entries like 'activation_redirect' on fresh installs. if ( ! empty( $keyColumnExists ) ) { aioseo()->core->cache->clearPrefix( '' ); } } /** * Creates a new aioseo_crawl_cleanup_logs table. * * Now uses dbDelta to update DB schema instead of manual table creation. * * @since 4.9.7 * * @return void */ public function createCrawlCleanupLogsTable() { if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } // Use dbDelta to create the crawl cleanup logs and blocked args tables based on schema definition $schemas = [ aioseo()->dbSchema->getCrawlCleanupLogsTableSchema(), aioseo()->dbSchema->getCrawlCleanupBlockedArgsTableSchema() ]; dbDelta( $schemas ); } /** * Adds the is_object column to the cache table. * * NOTE: This method uses raw SQL queries instead of the Cache class methods * (clear/delete) because those methods rely on the 'name' column which may * not exist yet during the migration from 'key' to 'name'. * * @since 4.9.1 * * @return void */ public function addIsObjectColumnToCache() { $db = aioseo()->core->db->db; $tableName = $db->prefix . 'aioseo_cache'; // Check if table exists using raw SQL (on fresh installs, the table won't exist yet). $tableExists = $db->get_var( $db->prepare( 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $tableName ) ); if ( empty( $tableExists ) ) { return; } // Check if column exists using raw SQL (bypass cache completely), otherwise we will get errors $columnExists = $db->get_var( $db->prepare( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'is_object'", $tableName ) ); if ( empty( $columnExists ) ) { // Try to acquire a lock to prevent race conditions (0 timeout = don't wait) if ( ! aioseo()->core->db->acquireLock( 'aioseo_add_is_object_column', 0 ) ) { return; } aioseo()->core->db->execute( "ALTER TABLE {$tableName} ADD `is_object` TINYINT(1) DEFAULT 0 AFTER `value`" ); // Clear the cache using raw SQL since existing entries won't have the is_object flag. // We use raw SQL because the Cache class methods rely on the 'name' column which // may not exist yet during the migration from 'key' to 'name'. aioseo()->core->db->execute( "TRUNCATE TABLE {$tableName}" ); aioseo()->core->db->releaseLock( 'aioseo_add_is_object_column' ); } } }