Your IP : 216.73.216.196
<?php
/**
* Core search and replace functionality for the Surf Link plugin.
*
* This class provides comprehensive search and replace functionality for WordPress databases,
* with support for serialized data, batch processing, and detailed reporting.
* It handles both regular search/replace operations and URL-specific replacements.
*
* @since 1.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class SURFL_HyperDBReplace
{
/**
* WordPress database object.
*
* @since 1.0.0
* @var wpdb
*/
private $wpdb;
/**
* Number of database rows to process in a single batch.
*
* @since 1.0.0
* @var int
*/
private $batch_size = 500;
/**
* Collection of operation reports.
*
* @since 1.0.0
* @var array
*/
private $reports = [];
/**
* Detailed metrics and statistics about the operations.
*
* @since 1.0.0
* @var array
*/
private $report_details = [];
/**
* Whether the search should be case insensitive.
*
* @since 1.0.0
* @var bool
*/
private $case_insensitive = false;
/**
* Initialize the search and replace functionality.
*
* Sets up the class properties and retrieves the global wpdb object.
*
* @since 1.0.0
*/
public function __construct()
{
global $wpdb;
$this->wpdb = $wpdb;
}
/**
* Process a search and replace operation across specified database tables.
*
* This is the main method that orchestrates the entire search and replace process.
* It handles special treatment for the options table, processes tables in batches,
* and collects detailed metrics about the operation.
*
* @since 1.0.0
* @param string $search The string to search for
* @param string $replace The replacement string
* @param array $tables Array of database table names to search in
* @param bool $dry_run If true, no actual changes are made
* @param bool $replace_guid Whether to include GUID columns in the replacement
* @param bool $case_insensitive Whether the search should be case insensitive
* @return array Detailed report of the operation
*/
public function process_replace($search, $replace, $tables, $dry_run = false, $replace_guid = false, $case_insensitive = false)
{
$this->case_insensitive = $case_insensitive; // Save the flag for use in other methods
// Reset/initialize report details.
$this->report_details = [];
// Separate out the options table (if selected) from the rest.
$non_options_tables = [];
$options_tables = [];
foreach ($tables as $table) {
if ($table === $this->wpdb->options) {
$options_tables[] = $table;
} else {
$non_options_tables[] = $table;
}
}
try {
// Process all tables except the options table first.
foreach ($non_options_tables as $table) {
if (!$this->table_exists($table)) {
throw new Exception("Invalid table: $table");
}
$primary_key = $this->get_primary_key($table);
$columns = $this->get_text_columns($table);
// If the user did not check the replace guid option, filter out any column named 'guid'
if (!$replace_guid) {
$columns = array_filter($columns, function ($col) {
return strtolower($col) !== 'guid';
});
}
// Initialize detailed metrics for this table.
$this->report_details[$table]['rows_scanned'] = 0;
$table_start = microtime(true);
$offset = 0;
while (true) {
$prepared_cols = implode(', ', $columns);
$prepared_query = $this->wpdb->prepare(
"SELECT $primary_key, $prepared_cols FROM $table LIMIT %d, %d",
$offset,
$this->batch_size
);
$rows = $this->wpdb->get_results(
$prepared_query,
ARRAY_A
);
if (empty($rows)) {
break;
}
$this->report_details[$table]['rows_scanned'] += count($rows);
$this->process_batch($table, $primary_key, $columns, $rows, $search, $replace, $dry_run);
$offset += $this->batch_size;
$this->free_memory();
}
$this->report_details[$table]['time'] = microtime(true) - $table_start;
}
// Process the options table(s) after all other tables.
foreach ($options_tables as $table) {
$table_start = microtime(true);
// For the options table, count all rows.
$rows = $this->wpdb->get_results("SELECT option_name, option_value FROM $table", ARRAY_A);
$this->report_details[$table]['rows_scanned'] = count($rows);
$this->process_options_table($table, $search, $replace, $dry_run);
$this->report_details[$table]['time'] = microtime(true) - $table_start;
}
} catch (Exception $e) {
error_log('HyperDBReplace Error: ' . $e->getMessage());
$this->reports['errors'][] = $e->getMessage();
}
// Append detailed report info.
$this->reports['details'] = $this->report_details;
if (isset($this->reports['success'])) {
$this->reports['success'] = array_filter($this->reports['success'], function ($msg) {
return trim($msg) !== '';
});
}
if (isset($this->reports['errors'])) {
$this->reports['errors'] = array_filter($this->reports['errors'], function ($msg) {
return trim($msg) !== '';
});
}
$this->reports = $this->reports;
return $this->reports;
}
/**
* Replace search string with replacement in a given value.
*
* Handles both regular strings and serialized data. For serialized data,
* it properly unserializes, performs the replacement deep in the structure,
* and reserializes the data safely.
*
* @since 1.0.0
* @param string $value The value to perform replacement on
* @param string $search The string to search for
* @param string $replace The replacement string
* @return string The value with replacements made
*/
private function replace_value($value, $search, $replace)
{
if ($this->is_serialized_data($value)) {
try {
$unserialized = unserialize($value, ['allowed_classes' => false]);
$modified = $this->deep_replace($search, $replace, $unserialized);
$reserialized = serialize($modified);
// Validate the reserialized data
if ($this->is_serialized_data($reserialized)) {
return $reserialized;
}
return $value; // Fallback if reserialization fails
} catch (Exception $e) {
error_log('Serialization error in replace_value: ' . $e->getMessage());
return $value;
}
}
return $this->case_insensitive
? str_ireplace($search, $replace, $value)
: str_replace($search, $replace, $value);
}
/**
* Process the WordPress options table with special handling.
*
* Options tables require special handling due to their unique structure and importance.
* This method processes the options table, with 'siteurl' option being processed last
* to maintain site integrity.
*
* @since 1.0.0
* @param string $table The name of the options table
* @param string $search The string to search for
* @param string $replace The replacement string
* @param bool $dry_run Whether this is a dry run (no actual changes)
* @throws Exception If database update fails
*/
private function process_options_table($table, $search, $replace, $dry_run)
{
if (!$dry_run) {
// Begin transaction only if updating the DB.
$this->wpdb->query('START TRANSACTION');
}
try {
// Retrieve all rows from the options table.
$rows = $this->wpdb->get_results("SELECT option_name, option_value FROM $table", ARRAY_A);
$siteurlRow = null;
// Process all options except for 'siteurl'.
foreach ($rows as $row) {
if ($row['option_name'] === 'siteurl') {
$siteurlRow = $row;
continue;
}
$original = $row['option_value'];
$modified = $this->replace_value($original, $search, $replace);
// Update only if a change was made.
if ($modified !== $original) {
// Count occurrences in the original value.
$occ = $this->count_occurrences($search, $original);
if (!isset($this->report_details[$table]['columns']['option_value'])) {
$this->report_details[$table]['columns']['option_value'] = ['occurrences' => 0];
}
$this->report_details[$table]['columns']['option_value']['occurrences'] += $occ;
if (!$dry_run) {
$result = $this->wpdb->update(
$table,
['option_value' => $modified],
['option_name' => $row['option_name']]
);
if ($result === false) {
throw new Exception("Failed to update option '" . $row['option_name'] . "' in table $table: " . $this->wpdb->last_error);
}
}
$this->reports[$table] = ($this->reports[$table] ?? 0) + 1;
}
}
// Process the 'siteurl' option last.
if ($siteurlRow !== null) {
$original = $siteurlRow['option_value'];
$modified = $this->replace_value($original, $search, $replace);
if ($modified !== $original) {
$occ = $this->count_occurrences($search, $original);
if (!isset($this->report_details[$table]['columns']['option_value'])) {
$this->report_details[$table]['columns']['option_value'] = ['occurrences' => 0];
}
$this->report_details[$table]['columns']['option_value']['occurrences'] += $occ;
if (!$dry_run) {
$result = $this->wpdb->update(
$table,
['option_value' => $modified],
['option_name' => 'siteurl']
);
if ($result === false) {
throw new Exception("Failed to update option 'siteurl' in table $table: " . $this->wpdb->last_error);
}
}
$this->reports[$table] = ($this->reports[$table] ?? 0) + 1;
}
}
if (!$dry_run) {
$this->wpdb->query('COMMIT');
}
} catch (Exception $e) {
if (!$dry_run) {
$this->wpdb->query('ROLLBACK');
}
throw $e;
}
}
/**
* Process a batch of database rows for search and replace operation.
*
* Uses SQL CASE statements for efficient batch updates, optimizing
* the database operations and minimizing the number of queries.
*
* @since 1.0.0
* @param string $table The database table name
* @param string $primary_key The primary key column name
* @param array $columns Array of column names to process
* @param array $rows Array of database rows to process
* @param string $search The string to search for
* @param string $replace The replacement string
* @param bool $dry_run Whether this is a dry run (no actual changes)
* @throws Exception If update fails or primary key is not found
*/
private function process_batch($table, $primary_key, $columns, $rows, $search, $replace, $dry_run)
{
$case_statements = [];
$ids = [];
foreach ($rows as $row) {
if (!isset($row[$primary_key])) {
throw new Exception("Primary key $primary_key not found in table $table");
}
foreach ($columns as $col) {
$original = $row[$col];
$is_serialized = $this->is_serialized_data($original);
if ($is_serialized) {
$unserialized = @unserialize($original);
if ($unserialized !== false) {
$modified_data = $this->deep_replace($search, $replace, $unserialized, $this->case_insensitive);
$modified = serialize($modified_data);
} else {
// On failure to unserialize, fall back gracefully.
$modified = $original;
}
} else {
$modified = $this->case_insensitive
? str_ireplace($search, $replace, $original)
: str_replace($search, $replace, $original);
}
// Only prepare a CASE update if the value has been modified.
if ($original !== $modified) {
// Count occurrences in the original cell.
$occ = $this->count_occurrences($search, $original);
if (!isset($this->report_details[$table]['columns'][$col])) {
$this->report_details[$table]['columns'][$col] = ['occurrences' => 0];
}
$this->report_details[$table]['columns'][$col]['occurrences'] += $occ;
if (!$dry_run) {
$case_statements[$col][] = $this->wpdb->prepare(
"WHEN %d THEN %s",
$row[$primary_key],
$modified
);
}
$ids[] = $row[$primary_key];
}
}
}
if (!empty($case_statements)) {
if (!$dry_run) {
$this->wpdb->query('START TRANSACTION');
foreach ($case_statements as $col => $cases) {
$_table = $this->sanitize_identifier($table);
$_primary_key = $this->sanitize_identifier($primary_key);
$_col = $this->sanitize_identifier($col);
$sql = "UPDATE $_table SET $_col = CASE $_primary_key
" . implode(' ', $cases) . "
END WHERE $_primary_key IN (" . implode(',', array_unique($ids)) . ")";
$result = $this->wpdb->query($sql);
if ($result === false) {
$this->wpdb->query('ROLLBACK');
throw new Exception("Update failed for column $col in table $table: " . $this->wpdb->last_error);
}
}
$this->wpdb->query('COMMIT');
}
$this->reports[$table] = ($this->reports[$table] ?? 0) + count($ids);
}
}
/**
* Sanitize a database identifier (table or column name).
*
* Ensures identifiers are safe to use in SQL queries by checking
* for valid characters and backtick-escaping them.
*
* @since 1.0.0
* @param string $identifier The identifier to sanitize
* @return string Sanitized and backtick-wrapped identifier
* @throws Exception If identifier contains invalid characters
*/
private function sanitize_identifier($identifier)
{
// Allow only alphanumeric characters and underscores
if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
throw new Exception("Invalid identifier: " . $identifier);
}
return "`" . $identifier . "`";
}
/**
* Get the primary key column name for a database table.
*
* Determines the primary key by querying the database schema information.
* Falls back to reasonable defaults if no primary key is found.
*
* @since 1.0.0
* @param string $table The database table name
* @return string The primary key column name
*/
private function get_primary_key($table)
{
// Remove prefix if using $wpdb->prefix
$clean_table = str_replace($this->wpdb->prefix, '', $table);
$result = $this->wpdb->get_row(
$this->wpdb->prepare(
"SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = %s
AND COLUMN_KEY = 'PRI'
LIMIT 1",
$table
)
);
if ($result && isset($result->COLUMN_NAME)) {
return $result->COLUMN_NAME;
}
// Fallback for tables without explicit primary key
$columns = $this->wpdb->get_col("DESCRIBE $table", 0);
return $columns[0] ?? 'id'; // Final fallback
}
/**
* Get all text-type columns from a database table.
*
* Identifies columns suitable for text search and replace operations
* by querying the database schema information.
*
* @since 1.0.0
* @param string $table The database table name
* @return array Array of column names
*/
private function get_text_columns($table)
{
$columns = $this->wpdb->get_results(
$this->wpdb->prepare(
"SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_SET_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = %s
AND DATA_TYPE IN ('varchar', 'text', 'tinytext', 'mediumtext', 'longtext', 'char', 'enum')
AND CHARACTER_SET_NAME IS NOT NULL",
$table
),
ARRAY_A
);
$valid_columns = [];
foreach ($columns as $col) {
// Exclude serialized meta fields unless they are serialized
if (strpos($col['COLUMN_NAME'], 'meta_value') === false) {
$valid_columns[] = $col['COLUMN_NAME'];
} else {
if ($this->is_serialized_column($col)) {
$valid_columns[] = $col['COLUMN_NAME'];
}
}
}
return $valid_columns;
}
/**
* Check if a column is likely to contain serialized data.
*
* @since 1.0.0
* @param array $column_info Column information from database schema
* @return bool Whether the column likely contains serialized data
*/
private function is_serialized_column($column_info)
{
// Smart serialization detection
return ($column_info['DATA_TYPE'] === 'varchar' && $column_info['CHARACTER_SET_NAME'] === null)
|| in_array($column_info['DATA_TYPE'], ['text', 'longtext']);
}
/**
* Check if data is serialized PHP data.
*
* Uses multiple checks to reliably detect serialized data,
* including pattern matching and unserialization verification.
*
* @since 1.0.0
* @param mixed $data The data to check
* @return bool Whether the data is serialized
*/
private function is_serialized_data($data)
{
// First check if the data is a string
if (!is_string($data)) {
return false;
}
$data = trim($data);
// Check for serialized data pattern
if (preg_match('/^([adObis]:|N;)/', $data)) {
// Verify serialization integrity with strict checking
try {
$unserialized = unserialize($data, ['allowed_classes' => false]);
// Additional check to prevent false positives
if ($unserialized === false && $data !== 'b:0;') {
return false;
}
return true;
} catch (Exception $e) {
error_log(esc_html__('Serialization error: ', 'surflink') . esc_html($e->getMessage()));
return false;
}
}
return false;
}
/**
* Perform deep replacement in complex data structures.
*
* Recursively replaces search string with replacement in arrays and objects.
*
* @since 1.0.0
* @param string $search The string to search for
* @param string $replace The replacement string
* @param mixed $data The data structure to perform replacement on
* @param bool $insensitive Whether the search should be case insensitive
* @return mixed The data structure with replacements made
*/
private function deep_replace($search, $replace, $data, $insensitive = true)
{
if (is_array($data)) {
foreach ($data as $key => $value) {
$data[$key] = $this->deep_replace($search, $replace, $value, $insensitive);
}
} elseif (is_object($data)) {
if ($data instanceof \__PHP_Incomplete_Class) {
// Skip incomplete objects
return $data;
}
foreach ($data as $key => $value) {
$data->$key = $this->deep_replace($search, $replace, $value, $insensitive);
}
} else {
$data = $insensitive
? str_ireplace($search, $replace, (string) $data)
: str_replace($search, $replace, (string) $data);
}
return $data;
}
/**
* Count occurrences of a search string in data.
*
* Recursively counts occurrences in arrays, objects, and scalar values.
* Handles case sensitivity based on the class property.
*
* @since 1.0.0
* @param string $search The string to search for
* @param mixed $data The data to search in (can be array, object, or scalar)
* @return int Number of occurrences found
*/
private function count_occurrences($search, $data)
{
if (is_array($data)) {
$count = 0;
foreach ($data as $item) {
$count += $this->count_occurrences($search, $item);
}
return $count;
} elseif (is_object($data)) {
$count = 0;
foreach (get_object_vars($data) as $item) {
$count += $this->count_occurrences($search, $item);
}
return $count;
}
return $this->case_insensitive
? substr_count(strtolower((string) $data), strtolower($search))
: substr_count((string) $data, $search);
}
/**
* Check if a table exists in the database.
*
* @since 1.0.0
* @param string $table The table name to check
* @return bool Whether the table exists
*/
private function table_exists($table)
{
return $this->wpdb->get_var(
$this->wpdb->prepare(
"SHOW TABLES LIKE %s",
$table
)
) === $table;
}
/**
* Free memory during batch processing.
*
* Performs garbage collection and memory optimization during
* long-running operations to prevent memory exhaustion.
*
* @since 1.0.0
*/
private function free_memory()
{
// Aggressive memory cleanup
if (function_exists('gc_mem_caches')) {
gc_mem_caches();
}
gc_collect_cycles();
// Reset WordPress query log
if (defined('SAVEQUERIES') && SAVEQUERIES) {
$this->wpdb->queries = [];
}
// Adjust batch size based on memory usage
$this->batch_size = max(100, min($this->batch_size, 1000));
if (memory_get_usage(true) > 64 * 1024 * 1024) {
$this->batch_size = floor($this->batch_size * 0.8);
}
}
/**
* Render the main search and replace admin interface.
*
* Creates and displays the form for standard search and replace operations
* across database tables, with options for dry run and case sensitivity.
*
* @since 1.0.0
*/
public function render_ui()
{
if (!current_user_can('manage_options')) {
wp_die('Access denied');
}
// Process the form submission
if (isset($_POST['process']) && check_admin_referer('hyperdb_replace')) {
$search = sanitize_text_field($_POST['search']);
$replace = sanitize_text_field($_POST['replace']);
$tables = isset($_POST['tables']) ? array_map('sanitize_text_field', $_POST['tables']) : [];
$valid_tables = $this->get_valid_tables($tables);
$dry_run = isset($_POST['dry_run']) && $_POST['dry_run'] == '1';
$replace_guid = isset($_POST['replace_guid']) && $_POST['replace_guid'] == '1';
$case_insensitive = isset($_POST['case_insensitive']) && $_POST['case_insensitive'] == '1';
$start_time = microtime(true);
$reports = $this->process_replace($search, $replace, $valid_tables, $dry_run, $replace_guid, $case_insensitive);
$elapsed_time = round(microtime(true) - $start_time, 4);
if (empty($reports['errors'])) {
$msg = $dry_run
? "Dry run completed in {$elapsed_time} seconds. No changes were made."
: "Operation completed in {$elapsed_time} seconds.";
$reports['success'][] = $msg;
}
$reports['dry_run'] = $dry_run;
$this->reports = $reports;
}
require_once SURFL_PATH . 'templates/surf-link-sr-report.php';
?>
<form method="post" action="">
<?php wp_nonce_field('hyperdb_replace'); ?>
<!-- Grid container for Group 1 and Group 2 -->
<div class="surfl-grid">
<!-- Group 1: Search, Replace & Tables -->
<div class="postbox surfl-group surfl-search-group">
<h2 class="hndle"><span><?php esc_html_e('Search & Replace Fields', 'surflink'); ?></span></h2>
<div class="surfl-sr-fields inside">
<label for="search"><?php esc_html_e('Search Query', 'surflink'); ?></label>
<input type="text" id="search" name="search" class="regular-text"
value="<?php echo isset($_POST['search']) ? esc_attr(sanitize_text_field($_POST['search'])) : ''; ?>"
required />
<p class="description">
<?php esc_html_e('Enter the string you want to search for.', 'surflink'); ?>
</p>
<label for="replace"><?php esc_html_e('Replace With', 'surflink'); ?></label>
<input type="text" id="replace" name="replace" class="regular-text"
value="<?php echo isset($_POST['replace']) ? esc_attr(sanitize_text_field($_POST['replace'])) : ''; ?>"
required />
<p class="description"><?php esc_html_e('Enter the replacement text.', 'surflink'); ?></p>
<label for="tables"><?php esc_html_e('Tables', 'surflink'); ?></label>
<?php
global $wpdb;
$all_tables = $wpdb->get_results("SHOW TABLES", ARRAY_N);
$all_table_statuses = $wpdb->get_results("SHOW TABLE STATUS", ARRAY_A);
$table_sizes = [];
if ($all_table_statuses) {
foreach ($all_table_statuses as $status) {
$table_name = $status['Name'];
$size_bytes = $status['Data_length'] + $status['Index_length'];
$size_mb = number_format($size_bytes / (1024 * 1024), 2);
$table_sizes[$table_name] = $size_mb;
}
}
if (SURFL_IS_MULTISITE) {
if (SURFL_IS_MAIN_SITE) {
$base_prefix = $wpdb->base_prefix;
$all_tables = array_filter($all_tables, function ($table_row) use ($base_prefix) {
return (bool) preg_match('/^' . preg_quote($base_prefix, '/') . '(?!\d)/', $table_row[0]);
});
} else {
$prefix = $wpdb->prefix;
$all_tables = array_filter($all_tables, function ($table_row) use ($prefix) {
return strpos($table_row[0], $prefix) === 0;
});
}
}
?>
<select name="tables[]" id="tables" multiple="multiple" style="width:100%;" required>
<?php foreach ($all_tables as $table): ?>
<?php
$table_name = $table[0];
$selected = (isset($_POST['tables']) && in_array($table_name, $_POST['tables'])) ? 'selected="selected"' : '';
?>
<option value="<?php echo esc_attr($table_name); ?>" <?php echo $selected; ?>>
<?php echo esc_html($table_name); ?>
(<?php echo isset($table_sizes[$table_name]) ? $table_sizes[$table_name] : '0.00'; ?>
MB)
</option>
<?php endforeach; ?>
</select>
<p class="description">
<?php esc_html_e('Select ( <span class="surfl-key">Ctrl</span>+<span class="surfl-key">Click</span> ) one or more tables for the search & replace operation.', 'surflink'); ?>
</p>
</div>
<div class="inside">
<?php submit_button(__('Run Search & Replace', 'surflink'), 'primary', 'process', true, 'style="background: #007cba;color: white;padding: 6px 16px;border-radius: 20px;font-size: 12px;font-weight: 600;position: relative;left: 64%;"'); ?>
</div>
</div>
<!-- Group 2: Options for Dry Run, Replace GUID, and Case Insensitive -->
<div class="postbox surfl-group surfl-options-group">
<h2 class="hndle"><span><?php esc_html_e('Options', 'surflink'); ?></span></h2>
<div class="inside">
<table class="form-table surfl-table">
<tr>
<th scope="row">
<label for="dry_run"><?php esc_html_e('Dry Run', 'surflink'); ?></label>
</th>
<td>
<input type="checkbox" id="dry_run" name="dry_run" value="1" <?php checked(!isset($_POST['process']) || (isset($_POST['dry_run']) && $_POST['dry_run'] == '1')); ?> />
<p class="description">
<?php esc_html_e('Checked by default. Uncheck to perform actual updates.', 'surflink'); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="replace_guid"><?php esc_html_e('Replace GUID', 'surflink'); ?></label>
</th>
<td>
<input type="checkbox" id="replace_guid" name="replace_guid" value="1" <?php checked(isset($_POST['replace_guid']) && $_POST['replace_guid'] == '1'); ?> />
<p class="description">
<?php esc_html_e('Replace GUID values (use with caution).', 'surflink'); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="case_insensitive"><?php esc_html_e('Case Insensitive', 'surflink'); ?></label>
</th>
<td>
<input type="checkbox" id="case_insensitive" name="case_insensitive" value="1" <?php checked(isset($_POST['case_insensitive']) && $_POST['case_insensitive'] == '1'); ?> />
<p class="description">
<?php esc_html_e('Unchecked by default. Check to perform a case-insensitive search & replace.', 'surflink'); ?>
</p>
</td>
</tr>
</table>
</div>
</div>
</div>
</form>
</div>
</div>
<?php
}
/**
* Get valid table names from user input.
*
* Validates and filters the table names to ensure they exist and are safe.
*
* @since 1.0.0
* @param array $tables Array of table names to validate
* @return array Filtered array of valid table names
*/
private function get_valid_tables($tables)
{
$valid_tables = [];
foreach ($tables as $table) {
// Validate table name to allow only letters, numbers, and underscores
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
continue;
}
if ($this->table_exists($table)) {
$valid_tables[] = $table;
}
}
return $valid_tables;
}
/**
* Process URL-specific search and replace operations.
*
* Handles targeted search and replace operations specifically for URLs
* across different parts of the WordPress database based on predefined tasks.
*
* @since 1.0.0
* @param string $search The URL string to search for
* @param string $replace The replacement URL string
* @param array $tasks Array of task definitions
* @param bool $dry_run Whether this is a dry run (no actual changes)
* @param bool $case_insensitive Whether the search should be case insensitive
* @return array Operation report
*/
public function process_url_replace($search, $replace, $tasks, $dry_run = false, $case_insensitive = false)
{
$this->case_insensitive = $case_insensitive;
$this->report_details = [];
error_log('search: ' . $search);
error_log('replace: ' . $replace);
error_log('dry_run: ' . ($dry_run ? 'true' : 'false'));
error_log('case_insensitive: ' . ($case_insensitive ? 'true' : 'false'));
try {
foreach ($tasks as $task) {
$table = $task['table'];
$column = $task['column'];
$where = $task['where'];
if (!$this->table_exists($table)) {
continue;
}
$primary_key = $this->get_primary_key($table);
if (!$this->column_exists($table, $column)) {
continue;
}
if (!isset($this->report_details[$table])) {
$this->report_details[$table] = [
'rows_scanned' => 0,
'time' => 0,
'columns' => [],
];
}
if (!isset($this->report_details[$table]['columns'][$column])) {
$this->report_details[$table]['columns'][$column] = [
'occurrences' => 0,
];
}
$table_start = microtime(true);
$offset = 0;
while (true) {
$query = "SELECT $primary_key, $column FROM $table";
$args = array();
if (!empty($where)) {
$query .= " WHERE $where";
$args[] = 'attachment';
}
$query .= " LIMIT %d, %d";
$args[] = $offset;
$args[] = $this->batch_size;
error_log('query: ' . $query);
// Prepare and execute the query safely
$rows = $this->wpdb->get_results($this->wpdb->prepare($query, $args), ARRAY_A);
if (empty($rows)) {
break;
}
$this->report_details[$table]['rows_scanned'] += count($rows);
$this->process_task_batch($table, $primary_key, $column, $rows, $search, $replace, $dry_run);
$offset += $this->batch_size;
$this->free_memory();
}
$this->report_details[$table]['time'] += microtime(true) - $table_start;
}
} catch (Exception $e) {
error_log('HyperDBReplace Error: ' . $e->getMessage());
$this->reports['errors'][] = $e->getMessage();
}
$this->reports['details'] = $this->report_details;
if (isset($this->reports['success'])) {
$this->reports['success'] = array_filter($this->reports['success'], function ($msg) {
return trim($msg) !== '';
});
}
if (isset($this->reports['errors'])) {
$this->reports['errors'] = array_filter($this->reports['errors'], function ($msg) {
return trim($msg) !== '';
});
}
return $this->reports;
}
/**
* Generate task definitions based on user-selected categories.
*
* Creates an array of task definitions for the URL replacement operation
* based on predefined categories like content, attachments, links, etc.
*
* @since 1.0.0
* @param array $categories Selected categories for URL replacement
* @return array Array of task definitions
*/
private function generate_tasks($categories)
{
global $wpdb;
$tasks = [];
foreach ($categories as $category) {
switch ($category) {
case 'contents':
$tasks[] = [
'table' => $wpdb->posts,
'column' => 'post_content',
'where' => '',
];
break;
case 'attachments':
$tasks[] = [
'table' => $wpdb->posts,
'column' => 'guid',
'where' => "post_type = %s",
];
break;
case 'links':
$tasks[] = [
'table' => $wpdb->links,
'column' => 'link_url',
'where' => '',
];
break;
case 'custom': // New Custom category
$tasks[] = [
'table' => $wpdb->postmeta,
'column' => 'meta_value',
'where' => '',
];
break;
case 'guids': // New GUIDs category
$tasks[] = [
'table' => $wpdb->posts,
'column' => 'guid',
'where' => '',
];
break;
}
}
$valid_tasks = [];
foreach ($tasks as $task) {
if ($this->table_exists($task['table'])) {
$valid_tasks[] = $task;
}
}
return $valid_tasks;
}
/**
* Check if a column exists in a given table.
*
* @since 1.0.0
* @param string $table The database table name
* @param string $column The column name to check
* @return bool Whether the column exists
*/
private function column_exists($table, $column)
{
$result = $this->wpdb->get_row(
$this->wpdb->prepare(
"SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = %s
AND COLUMN_NAME = %s",
$table,
$column
)
);
return !is_null($result);
}
/**
* Process a batch of database rows for a specific URL replace task.
*
* Similar to process_batch() but optimized for single-column operations
* in URL replacement tasks.
*
* @since 1.0.0
* @param string $table The database table name
* @param string $primary_key The primary key column name
* @param string $column The column to perform replacements on
* @param array $rows The database rows to process
* @param string $search The URL string to search for
* @param string $replace The replacement URL string
* @param bool $dry_run Whether this is a dry run (no actual changes)
* @throws Exception If update fails
*/
private function process_task_batch($table, $primary_key, $column, $rows, $search, $replace, $dry_run)
{
$case_statements = [];
$ids = [];
foreach ($rows as $row) {
if (!isset($row[$primary_key])) {
throw new Exception("Primary key $primary_key not found in table $table");
}
$original = $row[$column];
$modified = $this->replace_value($original, $search, $replace);
if ($original !== $modified) {
$occ = $this->count_occurrences($search, $original);
$this->report_details[$table]['columns'][$column]['occurrences'] += $occ;
if (!$dry_run) {
$case_statements[] = $this->wpdb->prepare(
"WHEN %d THEN %s",
$row[$primary_key],
$modified
);
}
$ids[] = $row[$primary_key];
}
}
if (!empty($case_statements)) {
if (!$dry_run) {
$this->wpdb->query('START TRANSACTION');
$_table = $this->sanitize_identifier($table);
$_primary_key = $this->sanitize_identifier($primary_key);
$_column = $this->sanitize_identifier($column);
$sql = "UPDATE $_table SET $_column = CASE $_primary_key
" . implode(' ', $case_statements) . "
END WHERE $_primary_key IN (" . implode(',', array_unique($ids)) . ")";
$result = $this->wpdb->query($sql);
if ($result === false) {
$this->wpdb->query('ROLLBACK');
throw new Exception("Update failed for column $column in table $table: " . $this->wpdb->last_error);
}
$this->wpdb->query('COMMIT');
}
$this->reports[$table] = ($this->reports[$table] ?? 0) + count($ids);
}
}
/**
* Render the URL-specific search and replace admin interface.
*
* Creates and displays the form for URL-specific search and replace operations
* with predefined categories like content, attachments, links, etc.
*
* @since 1.0.0
*/
public function render_url_replace_ui()
{
if (!current_user_can('manage_options')) {
wp_die('Access denied');
}
if (isset($_POST['process']) && check_admin_referer('hyperdb_replace')) {
$search = sanitize_text_field($_POST['search']);
$replace = sanitize_text_field($_POST['replace']);
$categories = isset($_POST['categories']) ? array_map('sanitize_text_field', $_POST['categories']) : [];
$tasks = $this->generate_tasks($categories);
$dry_run = isset($_POST['dry_run']) && $_POST['dry_run'] == '1';
$case_insensitive = isset($_POST['case_insensitive']) && $_POST['case_insensitive'] == '1';
$start_time = microtime(true);
$reports = $this->process_url_replace($search, $replace, $tasks, $dry_run, $case_insensitive);
$elapsed_time = round(microtime(true) - $start_time, 4);
if (empty($reports['errors'])) {
$msg = $dry_run
? "Dry run completed in {$elapsed_time} seconds. No changes were made."
: "Operation completed in {$elapsed_time} seconds.";
$reports['success'][] = $msg;
}
$reports['dry_run'] = $dry_run;
$this->reports = $reports;
}
require_once SURFL_PATH . 'templates/surf-link-sr-report.php';
?>
<form method="post" action="">
<?php wp_nonce_field('hyperdb_replace'); ?>
<div class="surfl-grid">
<div class="postbox surfl-group surfl-search-group">
<h2 class="hndle"><span><?php esc_html_e('Easy Url Update', 'surflink'); ?></span></h2>
<div class="inside surfl-sr-fields">
<label for="search"><?php esc_html_e('OLD URL', 'surflink'); ?></label>
<input type="text" id="search" name="search" class="regular-text" required />
<label for="replace"><?php esc_html_e('NEW URL', 'surflink'); ?></label>
<input type="text" id="replace" name="replace" class="regular-text" required />
<label><?php esc_html_e('Categories', 'surflink'); ?></label>
<div class="surfl-checkbox-group">
<div class="surfl-checkbox-option">
<input type="checkbox" id="cat-contents" name="categories[]" value="contents" />
<div>
<label for="cat-contents"><?php esc_html_e('Contents', 'surflink'); ?></label>
<p class="description">
<?php esc_html_e('Search in contents <br> (Posts, Pages, Custom Post Types, Revisions.)', 'surflink'); ?>
</p>
</div>
</div>
<div class="surfl-checkbox-option">
<input type="checkbox" id="cat-attachments" name="categories[]" value="attachments" />
<div>
<label for="cat-attachments"><?php esc_html_e('Attachments', 'surflink'); ?></label>
<p class="description">
<?php esc_html_e('Search in attachments (images, documents, general media)', 'surflink'); ?>
</p>
</div>
</div>
<div class="surfl-checkbox-option">
<input type="checkbox" id="cat-links" name="categories[]" value="links" />
<div>
<label for="cat-links"><?php esc_html_e('Links', 'surflink'); ?></label>
<p class="description"><?php esc_html_e('Search in links URL', 'surflink'); ?></p>
</div>
</div>
<div class="surfl-checkbox-option">
<input type="checkbox" id="cat-custom" name="categories[]" value="custom" />
<div>
<label
for="cat-custom"><?php esc_html_e('Custom (post meta values)', 'surflink'); ?></label>
<p class="description">
<?php esc_html_e('Search in custom fields and meta boxes', 'surflink'); ?>
</p>
</div>
</div>
<div class="surfl-checkbox-option">
<input type="checkbox" id="cat-guids" name="categories[]" value="guids" />
<div>
<label for="cat-guids"><?php esc_html_e('GUIDs', 'surflink'); ?></label>
<p class="description">
<?php esc_html_e('Search in Post GUIDs (use with caution).', 'surflink'); ?>
</p>
</div>
</div>
</div>
<?php submit_button(__('Run Search & Replace', 'surflink'), 'primary', 'process', true); ?>
</div>
</div>
<div class="postbox surfl-group surfl-options-group">
<h2 class="hndle"><span><?php esc_html_e('Options', 'surflink'); ?></span></h2>
<div class="inside">
<table class="form-table surfl-table">
<tr>
<th scope="row"><label for="dry_run"><?php esc_html_e('Dry Run', 'surflink'); ?></label></th>
<td>
<input type="checkbox" id="dry_run" name="dry_run" value="1" checked />
</td>
</tr>
<tr>
<th scope="row"><label
for="case_insensitive"><?php esc_html_e('Case Insensitive', 'surflink'); ?></label></th>
<td>
<input type="checkbox" id="case_insensitive" name="case_insensitive" value="1" />
</td>
</tr>
</table>
</div>
</div>
</div>
</form>
</div>
</div>
<?php
}
}
?>