prepare() before execution. namespace Affilun\Infrastructure\Persistence; use RuntimeException; /** * Copies data from pre-0.7.0 `afl_*` storage into the `affilun_*` storage. */ final class LegacyPrefixDataMigrator { public const OPTION_KEY = 'affilun_legacy_prefix_migrated'; /** * @var list */ private const TABLE_SUFFIXES = array( 'audit_events', 'click_events', 'click_rollups', 'embed_items', 'embeds', 'logs', 'product_meta', 'product_urls', 'products', 'sync_jobs', 'top_affiliate_candidates', ); /** * @var array */ private const OPTION_MAP = array( 'afl_affiliate_routing_templates' => 'affilun_affiliate_routing_templates', 'afl_backfill_amazon_last_search_at' => 'affilun_backfill_amazon_last_search_at', 'afl_click_counts' => 'affilun_click_counts', 'afl_click_events' => 'affilun_click_events', 'afl_cloud_ai_scan_no_candidate_posts' => 'affilun_cloud_ai_scan_no_candidate_posts', 'afl_db_schema_version' => 'affilun_db_schema_version', 'afl_link_health_cron_state' => 'affilun_link_health_cron_state', 'afl_settings' => 'affilun_settings', 'afl_settings_secrets' => 'affilun_settings_secrets', 'afl_sync_worker_id' => 'affilun_sync_worker_id', ); /** * WordPress database adapter. */ private object $wpdb; /** * Construct the migrator. * * @param object $wpdb WordPress database adapter. */ public function __construct( object $wpdb ) { $this->wpdb = $wpdb; } /** * Copy legacy data once. * * @return array */ public function run(): array { if ( $this->optionExists( self::OPTION_KEY ) ) { return array( 'status' => 'already_migrated' ); } $result = array( 'status' => 'completed', 'tables' => array(), 'options' => array(), 'posts' => array(), ); foreach ( self::TABLE_SUFFIXES as $suffix ) { $result['tables'][ $suffix ] = $this->copyTableIfNeeded( $suffix ); } foreach ( self::OPTION_MAP as $oldKey => $newKey ) { $result['options'][ $newKey ] = $this->copyOptionIfNeeded( $oldKey, $newKey ); } $result['posts'] = $this->migratePostContentMarkup(); $encoded = wp_json_encode( $result ); if ( ! is_string( $encoded ) ) { $encoded = '{"status":"completed"}'; } $this->insertOption( self::OPTION_KEY, $encoded, 'off' ); return $result; } /** * Copy one legacy table when the new table exists but is empty. * * @param string $suffix Bare table suffix after the plugin prefix. * * @return array */ private function copyTableIfNeeded( string $suffix ): array { $old = $this->tableName( 'afl_' . $suffix ); $new = $this->tableName( 'affilun_' . $suffix ); if ( ! $this->tableExists( $old ) || ! $this->tableExists( $new ) ) { return array( 'status' => 'missing_table' ); } $oldCount = $this->tableCount( $old ); $newCount = $this->tableCount( $new ); if ( $oldCount <= 0 ) { return array( 'status' => 'legacy_empty', 'old_count' => $oldCount, 'new_count' => $newCount, ); } if ( $newCount > 0 ) { return array( 'status' => 'kept_existing', 'old_count' => $oldCount, 'new_count' => $newCount, ); } $sql = sprintf( 'INSERT INTO `%s` SELECT * FROM `%s`', self::escapeIdentifier( $new ), self::escapeIdentifier( $old ) ); $this->query( $sql ); return array( 'status' => 'copied', 'old_count' => $oldCount, 'new_count' => $this->tableCount( $new ), ); } /** * Copy one legacy option when the new option does not exist. * * @param string $oldKey Legacy option name. * @param string $newKey New option name. */ private function copyOptionIfNeeded( string $oldKey, string $newKey ): string { if ( $this->optionExists( $newKey ) ) { return 'kept_existing'; } $row = $this->optionRow( $oldKey ); if ( $row === null ) { return 'legacy_missing'; } $this->insertOption( $newKey, $row['option_value'], $row['autoload'] ); return 'copied'; } /** * Apply the WordPress table prefix to a logical name. * * @param string $logicalName Logical table name. */ private function tableName( string $logicalName ): string { $prefix = ''; if ( property_exists( $this->wpdb, 'prefix' ) && is_string( $this->wpdb->prefix ) ) { $prefix = $this->wpdb->prefix; } return $prefix . $logicalName; } /** * Resolve the physical options table name. */ private function optionsTable(): string { if ( property_exists( $this->wpdb, 'options' ) && is_string( $this->wpdb->options ) && $this->wpdb->options !== '' ) { return $this->wpdb->options; } return $this->tableName( 'options' ); } /** * Resolve the physical posts table name. */ private function postsTable(): string { if ( property_exists( $this->wpdb, 'posts' ) && is_string( $this->wpdb->posts ) && $this->wpdb->posts !== '' ) { return $this->wpdb->posts; } return $this->tableName( 'posts' ); } /** * Rewrite legacy block and shortcode markup in post content. * * @return array */ private function migratePostContentMarkup(): array { $postsTable = $this->postsTable(); $before = $this->legacyPostMarkupCount( $postsTable ); if ( $before <= 0 ) { return array( 'before' => 0, 'updated' => 0, 'after' => 0, ); } $replacedContent = "REPLACE(REPLACE(post_content, 'wp:afl/product-card', 'wp:affilun/product-card'), '[afl_product_card', '[affilun_product_card')"; $whereLegacy = "post_content LIKE '%%wp:afl/product-card%%' OR post_content LIKE '%%[afl_product_card%%'"; $sql = sprintf( 'UPDATE `%s` SET post_content = %s WHERE %s', self::escapeIdentifier( $postsTable ), $replacedContent, $whereLegacy ); $this->query( $sql ); $after = $this->legacyPostMarkupCount( $postsTable ); return array( 'before' => $before, 'updated' => max( 0, $before - $after ), 'after' => $after, ); } /** * Count posts containing legacy affilun markup. * * @param string $postsTable Physical posts table. */ private function legacyPostMarkupCount( string $postsTable ): int { $whereLegacy = "post_content LIKE '%%wp:afl/product-card%%' OR post_content LIKE '%%[afl_product_card%%'"; $sql = sprintf( 'SELECT COUNT(*) FROM `%s` WHERE %s', self::escapeIdentifier( $postsTable ), $whereLegacy ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Identifier is escaped via escapeIdentifier(). return (int) $this->wpdb->get_var( $sql ); } /** * Check whether a table exists. * * @param string $table Physical table name. */ private function tableExists( string $table ): bool { $sql = $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $table ); if ( ! is_string( $sql ) ) { throw new RuntimeException( 'affilun legacy prefix migration failed to prepare SQL.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared immediately above. $found = $this->wpdb->get_var( $sql ); return is_string( $found ) && $found === $table; } /** * Count rows in a physical table. * * @param string $table Physical table name. */ private function tableCount( string $table ): int { $sql = sprintf( 'SELECT COUNT(*) FROM `%s`', self::escapeIdentifier( $table ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Identifier is escaped via escapeIdentifier(). return (int) $this->wpdb->get_var( $sql ); } /** * Check whether an option exists. * * @param string $key Option name. */ private function optionExists( string $key ): bool { $sql = $this->wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE option_name = %s', $this->optionsTable(), $key ); if ( ! is_string( $sql ) ) { throw new RuntimeException( 'affilun legacy prefix migration failed to prepare SQL.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared immediately above. return (int) $this->wpdb->get_var( $sql ) > 0; } /** * Load a raw option row. * * @param string $key Option name. * * @return array{option_value:mixed,autoload:string}|null */ private function optionRow( string $key ): ?array { $sql = $this->wpdb->prepare( 'SELECT option_value, autoload FROM %i WHERE option_name = %s', $this->optionsTable(), $key ); if ( ! is_string( $sql ) ) { throw new RuntimeException( 'affilun legacy prefix migration failed to prepare SQL.' ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared immediately above. $row = $this->wpdb->get_row( $sql, ARRAY_A ); if ( ! is_array( $row ) || ! array_key_exists( 'option_value', $row ) ) { return null; } return array( 'option_value' => $row['option_value'], 'autoload' => is_string( $row['autoload'] ?? null ) ? $row['autoload'] : 'auto', ); } /** * Insert an option row. * * @param string $key Option name. * @param mixed $value Option value. * @param string $autoload Autoload flag. */ private function insertOption( string $key, $value, string $autoload ): void { $inserted = $this->wpdb->insert( $this->optionsTable(), array( 'option_name' => $key, 'option_value' => $value, 'autoload' => $autoload, ) ); if ( $inserted === false ) { throw new RuntimeException( 'Failed to insert affilun legacy prefix migration option.' ); } } /** * Execute an already escaped SQL statement. * * @param string $sql SQL statement. */ private function query( string $sql ): void { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Callers provide escaped SQL. $result = $this->wpdb->query( $sql ); if ( $result === false ) { throw new RuntimeException( 'Failed to copy affilun legacy prefix data.' ); } } /** * Escape backticks inside a SQL identifier. * * @param string $identifier SQL identifier. */ private static function escapeIdentifier( string $identifier ): string { return str_replace( '`', '``', $identifier ); } }