base_prefix . self::TEMP_TAG . $token . '_' . $i; } /** * Displaced-original name (post-swap, dropped) for index $i. * * @param string $token Job token. * @param int $i Table index. * @return string */ public static function old_name( string $token, int $i ): string { global $wpdb; return $wpdb->base_prefix . self::OLD_TAG . $token . '_' . $i; } /** * Whether a table is one of this engine's staging/displaced tables (never offered * for export, backup or find & replace). * * @param string $table Table name. */ public static function is_staging_table( string $table ): bool { global $wpdb; return str_starts_with( $table, $wpdb->base_prefix . self::TEMP_TAG ) || str_starts_with( $table, $wpdb->base_prefix . self::OLD_TAG ); } /** * Create an empty staging copy of $orig (same columns, indexes, PK, auto-increment). * Drops any stale copy of the same name first. Returns the original's row count, or * a WP_Error. NOTE: CREATE TABLE LIKE does not copy foreign keys or triggers — rare on * WordPress, but custom schemas relying on them should be flagged. * * @param string $orig Original table name. * @param string $temp Staging copy table name. * @return int|\WP_Error */ public static function create_copy( string $orig, string $temp ) { global $wpdb; $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $temp ) ); $created = $wpdb->query( $wpdb->prepare( 'CREATE TABLE %i LIKE %i', $temp, $orig ) ); if ( false === $created ) { return new \WP_Error( 'spinxqm_stage_create', sprintf( /* translators: %s: table name */ __( 'Could not create a staging copy of %s.', 'spinx-quick-migrate' ), $orig ) ); } return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $orig ) ); } /** * Copy one keyset-bounded chunk of rows from $orig into $temp, ordered by the primary * key so it resumes cleanly across steps (uses MAX(pk) already in the copy as the * cursor — no offset drift). Server-side INSERT … SELECT (no PHP row buffering). * * @param string $orig Original table name. * @param string $temp Staging copy table name. * @param string $pk Primary key column. * @param int $budget Maximum rows to copy in this chunk. * @return array{done:bool,copied:int}|\WP_Error */ public static function copy_chunk( string $orig, string $temp, string $pk, int $budget ) { global $wpdb; $budget = max( 1, $budget ); $last = $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(%i) FROM %i', $pk, $temp ) ); if ( null === $last ) { $copied = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i SELECT * FROM %i ORDER BY %i ASC LIMIT %d', $temp, $orig, $pk, $budget ) ); } else { $copied = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i SELECT * FROM %i WHERE %i > %s ORDER BY %i ASC LIMIT %d', $temp, $orig, $pk, $last, $pk, $budget ) ); } if ( false === $copied ) { return new \WP_Error( 'spinxqm_stage_copy', sprintf( /* translators: %s: table name */ __( 'Could not copy rows for %s.', 'spinx-quick-migrate' ), $orig ) ); } return array( 'done' => ( (int) $copied < $budget ), 'copied' => (int) $copied, ); } /** * Verify a staging copy is sound before swapping it in: it exists and holds exactly the * row count captured when staging began (find/replace only UPDATEs, so counts must match). * * @param string $temp Staging copy table name. * @param int $expected_count Row count captured when staging began. * @return bool */ public static function verify( string $temp, int $expected_count ): bool { global $wpdb; $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $temp ) ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder if ( empty( $exists ) ) { return false; } $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $temp ) ); return $count === $expected_count; } /** * Atomically swap every original with its processed copy and drop the displaced * originals. A single multi-table RENAME is all-or-nothing: either every table flips * to its replaced copy at once, or nothing changes. * * @param array $pairs Original/staging/displaced table name sets. * @return true|\WP_Error */ public static function swap( array $pairs ) { global $wpdb; if ( empty( $pairs ) ) { return true; } $clauses = array(); $names = array(); $olds = array(); foreach ( $pairs as $p ) { // Clear any stale "old" name so RENAME has a clean target. $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $p['old'] ) ); // Two renames per pair: original → displaced name, processed copy → original. array_push( $clauses, '%i TO %i', '%i TO %i' ); array_push( $names, $p['orig'], $p['old'], $p['temp'], $p['orig'] ); $olds[] = $p['old']; } // Guard: every identifier must have a placeholder, or prepare() would silently // drop the extras and the swap would rename originals away without a replacement. if ( count( $names ) !== 2 * count( $clauses ) ) { return new \WP_Error( 'spinxqm_stage_swap', __( 'Atomic table swap failed: internal placeholder mismatch.', 'spinx-quick-migrate' ) ); } // One RENAME for every pair (all-or-nothing). The statement is built only from // literal "%i TO %i" placeholders; every identifier goes through prepare(). $ok = $wpdb->query( $wpdb->prepare( 'RENAME TABLE ' . implode( ', ', $clauses ), $names ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query is a fixed list of literal %i placeholders, prepared with the identifiers. if ( false === $ok ) { return new \WP_Error( 'spinxqm_stage_swap', sprintf( /* translators: %s: database error */ __( 'Atomic table swap failed: %s', 'spinx-quick-migrate' ), $wpdb->last_error ) ); } // Remove the displaced originals (best-effort — the swap already succeeded). $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS ' . implode( ', ', array_fill( 0, count( $olds ), '%i' ) ), $olds ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is a fixed list of literal %i placeholders, prepared with the identifiers. return true; } /** * Drop a set of staging tables (rollback after a failure/cancel before the swap). * * @param array $temps Staging table names to drop. */ public static function drop_tables( array $temps ): void { global $wpdb; foreach ( $temps as $t ) { if ( '' !== (string) $t ) { $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $t ) ); } } } /** * Housekeeping: drop staging/displaced tables left behind by an abandoned or * crashed find & replace job. Only tables created more than $min_age seconds ago * are dropped, so a running or paused job's copies are never touched (job state * expires long before the default 24 h). The age is compared on the database * server's own clock; tables with an unknown creation time are kept. * * @param int $min_age Minimum table age in seconds (0 = drop all). */ public static function cleanup_orphans( int $min_age = DAY_IN_SECONDS ): void { global $wpdb; foreach ( array( self::TEMP_TAG, self::OLD_TAG ) as $tag ) { $like = $wpdb->esc_like( $wpdb->base_prefix . $tag ) . '%'; if ( $min_age > 0 ) { $tables = $wpdb->get_col( $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s AND CREATE_TIME < ( NOW() - INTERVAL %d SECOND )', DB_NAME, $like, $min_age ) ); } else { $tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) ); } foreach ( (array) $tables as $t ) { if ( self::is_staging_table( (string) $t ) ) { $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $t ) ); } } } } }