Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions features/search-replace.feature
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,45 @@ Feature: Do global search/replace
| key | value |
| header_image_data | {"url":"https:\/\/example.com\/foo.jpg"} |

@require-mysql
Scenario: Search and replace handles JSON-encoded URLs in post content
Given a WP install

When I run `wp post create --post_content='{"src":"http:\/\/example.com\/wp-content\/uploads\/fonts\/test.woff2","fontWeight":"400"}' --post_status=publish --porcelain`
Then save STDOUT as {POST_ID}

When I run `wp post get {POST_ID} --field=post_content`
Then STDOUT should contain:
"""
http:\/\/example.com
"""

When I run `wp search-replace 'http://example.com' 'http://newdomain.com' wp_posts --include-columns=post_content`
Then STDOUT should be a table containing rows:
| Table | Column | Replacements | Type |
| wp_posts | post_content | 1 | SQL |

When I run `wp post get {POST_ID} --field=post_content`
Then STDOUT should contain:
"""
http:\/\/newdomain.com
"""
And STDOUT should not contain:
"""
http:\/\/example.com
"""

When I run `wp search-replace 'http://newdomain.com' 'http://example.com' wp_posts --include-columns=post_content --precise`
Then STDOUT should be a table containing rows:
| Table | Column | Replacements | Type |
| wp_posts | post_content | 1 | PHP |

When I run `wp post get {POST_ID} --field=post_content`
Then STDOUT should contain:
"""
http:\/\/example.com
"""

@require-mysql
Scenario: Search and replace with quoted strings
Given a WP install
Expand Down
45 changes: 41 additions & 4 deletions src/Search_Replace_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -652,19 +652,39 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) {

$table_sql = self::esc_sql_ident( $table );
$col_sql = self::esc_sql_ident( $col );
$old_json = self::json_encode_strip_quotes( $old );
$new_json = self::json_encode_strip_quotes( $new );
$has_json = $old_json !== $old;

if ( $this->dry_run ) {
if ( $this->log_handle ) {
$count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new );
if ( $has_json ) {
$count += $this->log_sql_diff( $col, $primary_keys, $table, $old_json, $new_json );
}
} elseif ( $has_json ) {
// Single query with OR to avoid counting rows that match both forms twice.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
$count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s OR $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%', '%' . self::esc_like( $old_json ) . '%' ) );
} else {
// 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;", '%' . self::esc_like( $old ) . '%' ) );
$count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) );
}
} else {
if ( $this->log_handle ) {
$this->log_sql_diff( $col, $primary_keys, $table, $old, $new );
if ( $has_json ) {
$this->log_sql_diff( $col, $primary_keys, $table, $old_json, $new_json );
}
}
if ( $has_json ) {
// Single nested REPLACE handles both plain and JSON-encoded forms in one pass.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
$count = (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE(REPLACE($col_sql, %s, %s), %s, %s);", $old, $new, $old_json, $new_json ) );
} else {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
$count = (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) );
}
// 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 ) );
}

if ( $this->verbose && 'table' === $this->format ) {
Expand All @@ -686,8 +706,12 @@ private function php_handle_col( $col, $primary_keys, $table, $old, $new ) {
$base_key_condition = '';
$where_key = '';
if ( ! $this->regex ) {
$old_json = self::json_encode_strip_quotes( $old );
$base_key_condition = "$col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old ) . '%' );
$where_key = "WHERE $base_key_condition";
if ( $old_json !== $old ) {
$base_key_condition = "( $base_key_condition OR $col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old_json ) . '%' ) . ' )';
}
$where_key = "WHERE $base_key_condition";
}

$escaped_primary_keys = self::esc_sql_ident( $primary_keys );
Expand Down Expand Up @@ -917,6 +941,19 @@ private static function esc_like( $old ) {
return $old;
}

/**
* Returns the JSON-encoded representation of a string with the surrounding quotes stripped.
* This is used to also handle values stored as raw JSON in the database (e.g. WordPress font data).
* Returns the original string unchanged if JSON encoding fails (e.g. invalid UTF-8).
*
* @param string $str The string to encode.
* @return string The JSON-encoded string without surrounding quotes, or the original string on failure.
*/
public static function json_encode_strip_quotes( $str ) {
$encoded = json_encode( $str ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
return false !== $encoded ? substr( $encoded, 1, -1 ) : $str;
}

/**
* 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
Expand Down
17 changes: 17 additions & 0 deletions src/WP_CLI/SearchReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ class SearchReplacer {
*/
private $to;

/**
* @var string
*/
private $from_json;

/**
* @var string
*/
private $to_json;

/**
* @var bool
*/
Expand Down Expand Up @@ -78,6 +88,10 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals
$this->logging = $logging;
$this->clear_log_data();

// Compute JSON-encoded versions (stripping outer quotes) for handling raw JSON values in the database.
$this->from_json = \Search_Replace_Command::json_encode_strip_quotes( $from );
$this->to_json = \Search_Replace_Command::json_encode_strip_quotes( $to );

// Get the XDebug nesting level. Will be zero (no limit) if no value is set
$this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) );
}
Expand Down Expand Up @@ -204,6 +218,9 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis
$data = $result;
} else {
$data = str_replace( $this->from, $this->to, $data );
if ( $this->from_json !== $this->from ) {
$data = str_replace( $this->from_json, $this->to_json, $data );
}
}
if ( $this->logging && $old_data !== $data ) {
$this->log_data[] = $old_data;
Expand Down
Loading