diff --git a/features/search-replace.feature b/features/search-replace.feature index 062a67e0..d6feee8a 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -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 diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index 520ab8cc..bbad1bdf 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -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 ) { @@ -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 ); @@ -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 diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 8c5ee951..6f3bea9f 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -17,6 +17,16 @@ class SearchReplacer { */ private $to; + /** + * @var string + */ + private $from_json; + + /** + * @var string + */ + private $to_json; + /** * @var bool */ @@ -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' ) ); } @@ -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;