$args Request args: `tables` and `rules` (find/replace/case_sensitive). * @return array */ public static function preview( array $args ): array { global $wpdb; $tables = Security::sanitize_tables( $args['tables'] ?? array() ); $rules = self::normalize_rules( $args['rules'] ?? array() ); if ( empty( $tables ) || empty( $rules ) ) { return array( 'summary' => array(), 'totals' => array( 'rows' => 0, 'replacements' => 0, ), ); } $summary = array(); $total_rows = 0; $total_replacements = 0; foreach ( $tables as $table ) { $columns = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), ARRAY_A ); if ( empty( $columns ) ) { continue; } foreach ( $columns as $column ) { $type = strtolower( (string) $column['Type'] ); if ( false === strpos( $type, 'char' ) && false === strpos( $type, 'text' ) && false === strpos( $type, 'blob' ) ) { continue; } $col_name = $column['Field']; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT %i AS val FROM %i LIMIT 200', $col_name, $table ), ARRAY_A ); $affected = 0; $repls = 0; $sample = null; foreach ( $rows as $row ) { $val = isset( $row['val'] ) ? (string) $row['val'] : ''; if ( '' === $val ) { continue; } $stats = array(); $new = Serialized_Replacer::replace_value( $val, $rules, $stats ); if ( $new !== $val ) { ++$affected; $repls += (int) ( $stats['replacements'] ?? 1 ); if ( null === $sample ) { $sample = array( 'before' => mb_substr( wp_strip_all_tags( $val ), 0, 80 ), 'after' => mb_substr( wp_strip_all_tags( (string) $new ), 0, 80 ), ); } } } if ( $affected > 0 ) { $summary[] = array( 'table' => $table, 'column' => $col_name, 'rows_affected' => $affected, 'replacements' => $repls, 'sample' => $sample, ); $total_rows += $affected; $total_replacements += $repls; } } } return array( 'summary' => $summary, 'totals' => array( 'rows' => $total_rows, 'replacements' => $total_replacements, ), ); } /** * Resumable apply: process a bounded number of rows per call (primary-key * ordered), honouring cancel + pause between chunks, and return the updated * checkpoint so the next step continues from the same table/offset. Mirrors * Database_Exporter::dump_batch()'s contract so it can be driven by the browser * step loop (no cron, no per-request timeout risk). Serialized values stay safe * via Serialized_Replacer. * * @param array $args { tables, rules }. * @param string|null $job_id Job to report progress / honour cancel+pause. * @param array $cp { ti, offset, totals, meta, tables, rules }. * @return array{ok:bool,done:bool,cancelled:bool,totals:array,checkpoint:array} */ public static function apply_batch( array $args, ?string $job_id, array $cp ): array { global $wpdb; // Validated tables + normalized rules are computed once and carried in the // checkpoint, so a step never re-sanitizes / re-parses them. $tables = ( isset( $cp['tables'] ) && is_array( $cp['tables'] ) && $cp['tables'] ) ? array_values( $cp['tables'] ) : Security::sanitize_tables( $args['tables'] ?? array() ); $rules = ( array_key_exists( 'rules', $cp ) && is_array( $cp['rules'] ) ) ? $cp['rules'] : self::normalize_rules( $args['rules'] ?? array() ); $totals = is_array( $cp['totals'] ?? null ) ? $cp['totals'] : array( 'rows' => 0, 'replacements' => 0, ); if ( empty( $tables ) || empty( $rules ) ) { return array( 'ok' => true, 'done' => true, 'cancelled' => false, 'totals' => $totals, 'checkpoint' => array( 'ti' => 0, 'offset' => 0, 'totals' => $totals, 'meta' => array(), 'tables' => $tables, 'rules' => $rules, ), ); } $settings = Helpers::get_settings(); $row_budget = max( 100, absint( $settings['sql_rows_per_batch'] ) ); $deadline = time() + Batch_Settings::time_budget( 'find_replace' ); $total_tables = count( $tables ); $ti = (int) ( $cp['ti'] ?? 0 ); $offset = (int) ( $cp['offset'] ?? 0 ); $meta = is_array( $cp['meta'] ?? null ) ? $cp['meta'] : array(); $processed = 0; while ( $ti < $total_tables ) { if ( Helpers::is_job_cancelled( $job_id ) ) { return array( 'ok' => true, 'done' => false, 'cancelled' => true, 'totals' => $totals, 'checkpoint' => array( 'ti' => $ti, 'offset' => $offset, 'totals' => $totals, 'meta' => $meta, 'tables' => $tables, 'rules' => $rules, ), ); } if ( Helpers::is_job_paused( $job_id ) ) { break; } $table = $tables[ $ti ]; if ( ! isset( $meta[ $table ] ) ) { $meta[ $table ] = self::table_meta( $table ); } $m = $meta[ $table ]; if ( ! empty( $m['skip'] ) || $offset >= (int) ( $m['count'] ?? 0 ) ) { ++$ti; $offset = 0; continue; } $pk = (string) $m['pk']; $text_cols = is_array( $m['cols'] ?? null ) ? $m['cols'] : array(); // pk-ordered paging is stable across chunks: UPDATE never changes the pk. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY %i ASC LIMIT %d OFFSET %d', $table, $pk, $row_budget, $offset ), ARRAY_A ); foreach ( (array) $rows as $row ) { $updates = array(); $formats = array(); $changed = false; foreach ( $text_cols as $col ) { if ( ! isset( $row[ $col ] ) ) { continue; } $old = (string) $row[ $col ]; if ( '' === $old ) { continue; } $stats = array(); $new = Serialized_Replacer::replace_value( $old, $rules, $stats ); if ( $new !== $old ) { $updates[ $col ] = $new; $formats[] = '%s'; $totals['replacements'] += (int) ( $stats['replacements'] ?? 1 ); $changed = true; } } if ( $changed && isset( $row[ $pk ] ) ) { $wpdb->update( $table, $updates, array( $pk => $row[ $pk ] ), $formats, array( '%s' ) ); ++$totals['rows']; } } $offset += $row_budget; $processed += count( (array) $rows ); if ( $offset >= (int) $m['count'] ) { ++$ti; $offset = 0; } if ( $job_id ) { // Progress (and its phase band) is owned by the caller; this only // reports the live table/row position. $current = $ti < $total_tables ? $tables[ $ti ] : ''; Helpers::update_job( $job_id, array( 'status' => 'running', /* translators: %s: database table name */ 'message' => '' !== $current ? sprintf( __( 'Replacing in %s', 'spinx-quick-migrate' ), $current ) : __( 'Replacing…', 'spinx-quick-migrate' ), 'current_item' => $current, 'processed' => $ti, 'total' => $total_tables, ) ); } if ( $processed >= $row_budget || time() >= $deadline ) { break; } } $done = ( $ti >= $total_tables ); if ( $done ) { Logger::log( 'info', 'Find and replace apply complete', $totals ); } return array( 'ok' => true, 'done' => $done, 'cancelled' => false, 'totals' => $totals, 'checkpoint' => array( 'ti' => $ti, 'offset' => $offset, 'totals' => $totals, 'meta' => $meta, 'tables' => $tables, 'rules' => $rules, ), ); } /** * Per-table metadata for apply_batch (resolved once, cached in the checkpoint): * primary key, replaceable text/blob columns, and row count. Tables with no * primary key or no text columns are flagged to skip (can't safely update). * * @param string $table Table name. * @return array{skip:bool,pk?:string,cols?:array,count?:int} */ public static function table_meta( string $table ): array { global $wpdb; $columns = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), ARRAY_A ); if ( empty( $columns ) ) { return array( 'skip' => true ); } $primary = $wpdb->get_row( $wpdb->prepare( 'SHOW KEYS FROM %i WHERE Key_name = %s', $table, 'PRIMARY' ), ARRAY_A ); if ( empty( $primary['Column_name'] ) ) { return array( 'skip' => true ); } $text_cols = array(); foreach ( $columns as $column ) { $type = strtolower( (string) $column['Type'] ); if ( false !== strpos( $type, 'char' ) || false !== strpos( $type, 'text' ) || false !== strpos( $type, 'blob' ) ) { $text_cols[] = $column['Field']; } } if ( empty( $text_cols ) ) { return array( 'skip' => true ); } $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); return array( 'skip' => false, 'pk' => (string) $primary['Column_name'], 'cols' => $text_cols, 'count' => $count, ); } /** * Normalize user rules. * * @param array> $rules Raw find/replace rules. * @return array> */ private static function normalize_rules( array $rules ): array { $out = array(); foreach ( $rules as $rule ) { $find = isset( $rule['find'] ) ? sanitize_text_field( (string) $rule['find'] ) : ''; $replace = isset( $rule['replace'] ) ? (string) $rule['replace'] : ''; // Both source and destination are required; a blank either side is // skipped so we never overwrite values with an empty replacement. if ( '' === trim( $find ) || '' === trim( $replace ) ) { continue; } $out[] = array( 'find' => $find, 'replace' => $replace, 'case_sensitive' => ! empty( $rule['case_sensitive'] ), ); } return $out; } }