350 lines
8.7 KiB
PHP
350 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace Blocksy\Database;
|
|
|
|
class SearchReplace {
|
|
private $dry_run = false;
|
|
|
|
public function invoke($args = []) {
|
|
global $wpdb;
|
|
|
|
$args = wp_parse_args($args, [
|
|
'old' => '',
|
|
'new' => '',
|
|
'dry_run' => true,
|
|
'tables' => null
|
|
]);
|
|
|
|
$old = $args['old'];
|
|
$new = $args['new'];
|
|
$this->dry_run = $args['dry_run'];
|
|
|
|
$skip_columns = [];
|
|
|
|
// never mess with hashed passwords
|
|
$skip_columns[] = 'user_pass';
|
|
|
|
$tables = $args['tables'];
|
|
|
|
if (! $tables) {
|
|
$tables = Utils::wp_get_relevant_table_names();
|
|
}
|
|
|
|
$result = [
|
|
'tables' => [],
|
|
];
|
|
|
|
foreach ($tables as $table) {
|
|
$table_sql = $this->esc_sql_ident($table);
|
|
|
|
list($primary_keys, $columns, $all_columns) = self::get_columns($table);
|
|
|
|
// since we'll be updating one row at a time,
|
|
// we need a primary key to identify the row
|
|
if (empty($primary_keys)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($columns as $col) {
|
|
if (in_array($col, $skip_columns, true)) {
|
|
continue;
|
|
}
|
|
|
|
$col_sql = $this->esc_sql_ident($col);
|
|
$wpdb->last_error = '';
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
|
|
$serial_row = $wpdb->get_row(
|
|
"SELECT * FROM $table_sql WHERE $col_sql REGEXP '^[aiO]:[1-9]' LIMIT 1"
|
|
);
|
|
|
|
// When the regex triggers an error, we should fall back to PHP
|
|
if (false !== strpos($wpdb->last_error, 'ERROR 1139')) {
|
|
$serial_row = true;
|
|
}
|
|
|
|
if (null !== $serial_row) {
|
|
$type = 'PHP';
|
|
|
|
$count = $this->php_handle_col(
|
|
$col,
|
|
$primary_keys,
|
|
$table,
|
|
$old,
|
|
$new
|
|
);
|
|
} else {
|
|
$type = 'SQL';
|
|
|
|
$count = $this->sql_handle_col(
|
|
$col,
|
|
$primary_keys,
|
|
$table,
|
|
$old,
|
|
$new
|
|
);
|
|
}
|
|
|
|
if (intval($count) > 0) {
|
|
if (! isset($result['tables'][$table])) {
|
|
$result['tables'][$table] = 0;
|
|
}
|
|
|
|
$result['tables'][$table] += intval($count);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
$total = 0;
|
|
|
|
foreach ($result['tables'] as $table => $count) {
|
|
$total += $count;
|
|
}
|
|
|
|
$result['total'] = $total;
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Escapes (backticks) MySQL identifiers (aka schema object names) - i.e. column names, table names, and database/index/alias/view etc names.
|
|
* See https://dev.mysql.com/doc/refman/5.5/en/identifiers.html
|
|
*
|
|
* @param string|array $idents A single identifier or an array of identifiers.
|
|
* @return string|array An escaped string if given a string, or an array of escaped strings if given an array of strings.
|
|
*/
|
|
private function esc_sql_ident($idents) {
|
|
$backtick = static function ($v) {
|
|
// Escape any backticks in the identifier by doubling.
|
|
return '`' . str_replace('`', '``', $v) . '`';
|
|
};
|
|
|
|
if (is_string($idents)) {
|
|
return $backtick($idents);
|
|
}
|
|
|
|
return array_map($backtick, $idents);
|
|
}
|
|
|
|
private function get_columns($table) {
|
|
global $wpdb;
|
|
|
|
$table_sql = $this->esc_sql_ident($table);
|
|
$primary_keys = array();
|
|
$text_columns = array();
|
|
$all_columns = array();
|
|
$suppress_errors = $wpdb->suppress_errors();
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
|
|
$results = $wpdb->get_results("DESCRIBE $table_sql");
|
|
|
|
if (! empty($results)) {
|
|
foreach ($results as $col) {
|
|
if ('PRI' === $col->Key) {
|
|
$primary_keys[] = $col->Field;
|
|
}
|
|
|
|
if ($this->is_text_col($col->Type)) {
|
|
$text_columns[] = $col->Field;
|
|
}
|
|
|
|
$all_columns[] = $col->Field;
|
|
}
|
|
}
|
|
|
|
$wpdb->suppress_errors($suppress_errors);
|
|
|
|
return array($primary_keys, $text_columns, $all_columns);
|
|
}
|
|
|
|
private function is_text_col($type) {
|
|
foreach (array('text', 'varchar') as $token) {
|
|
if (false !== stripos($type, $token)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function sql_handle_col($col, $primary_keys, $table, $old, $new) {
|
|
global $wpdb;
|
|
|
|
$table_sql = $this->esc_sql_ident($table);
|
|
$col_sql = $this->esc_sql_ident($col);
|
|
|
|
if ($this->dry_run) {
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
|
|
$count = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . $this->esc_like($old) . '%'
|
|
)
|
|
);
|
|
} else {
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
|
|
$count = $wpdb->query(
|
|
$wpdb->prepare(
|
|
"UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);",
|
|
$old,
|
|
$new
|
|
)
|
|
);
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
private function esc_like($old) {
|
|
global $wpdb;
|
|
|
|
// Remove notices in 4.0 and support backwards compatibility
|
|
if (method_exists($wpdb, 'esc_like')) {
|
|
// 4.0
|
|
$old = $wpdb->esc_like($old);
|
|
}
|
|
|
|
return $old;
|
|
}
|
|
|
|
private function php_handle_col($col, $primary_keys, $table, $old, $new) {
|
|
global $wpdb;
|
|
|
|
$count = 0;
|
|
$replacer = new SearchReplacer($old, $new, true);
|
|
|
|
$table_sql = $this->esc_sql_ident($table);
|
|
$col_sql = $this->esc_sql_ident($col);
|
|
|
|
$base_key_condition = "$col_sql" . $wpdb->prepare(
|
|
' LIKE BINARY %s',
|
|
'%' . $this->esc_like($old) . '%'
|
|
);
|
|
|
|
$where_key = "WHERE $base_key_condition";
|
|
|
|
$escaped_primary_keys = $this->esc_sql_ident($primary_keys);
|
|
$primary_keys_sql = implode(',', $escaped_primary_keys);
|
|
$order_by_keys = array_map(
|
|
static function ($key) {
|
|
return "{$key} ASC";
|
|
},
|
|
$escaped_primary_keys
|
|
);
|
|
|
|
$order_by_sql = 'ORDER BY ' . implode(',', $order_by_keys);
|
|
$limit = 1000;
|
|
|
|
// 2 errors:
|
|
// - WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
|
|
// - WordPress.CodeAnalysis.AssignmentInCondition -- no reason to do copy-paste for a single valid assignment in while
|
|
// phpcs:ignore
|
|
while ($rows = $wpdb->get_results("SELECT {$primary_keys_sql} FROM {$table_sql} {$where_key} {$order_by_sql} LIMIT {$limit}")) {
|
|
foreach ($rows as $keys) {
|
|
$where_sql = '';
|
|
|
|
foreach ((array) $keys as $k => $v) {
|
|
if ('' !== $where_sql) {
|
|
$where_sql .= ' AND ';
|
|
}
|
|
|
|
$where_sql .= $this->esc_sql_ident($k) . ' = ' . $this->esc_sql_value($v);
|
|
}
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
|
|
$col_value = $wpdb->get_var(
|
|
"SELECT {$col_sql} FROM {$table_sql} WHERE {$where_sql}"
|
|
);
|
|
|
|
if ('' === $col_value) {
|
|
continue;
|
|
}
|
|
|
|
$value = $replacer->run($col_value);
|
|
|
|
if ($value === $col_value) {
|
|
continue;
|
|
}
|
|
|
|
$count++;
|
|
|
|
if (! $this->dry_run) {
|
|
$update_where = array();
|
|
|
|
foreach ((array) $keys as $k => $v) {
|
|
$update_where[$k] = $v;
|
|
}
|
|
|
|
$wpdb->update($table, [$col => $value], $update_where);
|
|
}
|
|
}
|
|
|
|
// Because we are ordering by primary keys from least to greatest,
|
|
// we can exclude previous chunks from consideration by adding greater-than conditions
|
|
// to insist the next chunk's keys must be greater than the last of this chunk's keys.
|
|
$last_row = end($rows);
|
|
$next_key_conditions = array();
|
|
|
|
|
|
// NOTE: For a composite key (X, Y, Z), selecting the next rows requires the following conditions:
|
|
// ( X = lastX AND Y = lastY AND Z > lastZ ) OR
|
|
// ( X = lastX AND Y > lastY ) OR
|
|
// ( X > lastX )
|
|
for ($last_key_index = count($primary_keys) - 1; $last_key_index >= 0; $last_key_index--) {
|
|
$next_key_subconditions = array();
|
|
|
|
for ($i = 0; $i <= $last_key_index; $i++) {
|
|
$k = $primary_keys[$i];
|
|
$v = $last_row->{$k};
|
|
|
|
if ($i < $last_key_index) {
|
|
$next_key_subconditions[] = $this->esc_sql_ident($k) . ' = ' . $this->esc_sql_value($v);
|
|
} else {
|
|
$next_key_subconditions[] = $this->esc_sql_ident($k) . ' > ' . $this->esc_sql_value($v);
|
|
}
|
|
}
|
|
|
|
$next_key_conditions[] = '( ' . implode(' AND ', $next_key_subconditions) . ' )';
|
|
}
|
|
|
|
|
|
$where_key_conditions = array();
|
|
|
|
if ($base_key_condition) {
|
|
$where_key_conditions[] = $base_key_condition;
|
|
}
|
|
|
|
$where_key_conditions[] = '( ' . implode(' OR ', $next_key_conditions) . ' )';
|
|
|
|
$where_key = 'WHERE ' . implode(' AND ', $where_key_conditions);
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Puts MySQL string values in single quotes, to avoid them being interpreted as column names.
|
|
*
|
|
* @param string|array $values A single value or an array of values.
|
|
* @return string|array A quoted string if given a string, or an array of quoted strings if given an array of strings.
|
|
*/
|
|
private function esc_sql_value($values) {
|
|
$quote = static function ($v) {
|
|
// Don't quote integer values to avoid MySQL's implicit type conversion.
|
|
if (preg_match('/^[+-]?[0-9]{1,20}$/', $v)) { // MySQL BIGINT UNSIGNED max 18446744073709551615 (20 digits).
|
|
return esc_sql($v);
|
|
}
|
|
|
|
// Put any string values between single quotes.
|
|
return "'" . esc_sql($v) . "'";
|
|
};
|
|
|
|
if (is_array($values)) {
|
|
return array_map($quote, $values);
|
|
}
|
|
|
|
return $quote($values);
|
|
}
|
|
}
|
|
|