Skip to content

Commit d07f1fa

Browse files
Copilotswissspidy
andauthored
Handle JSON-encoded URLs in search-replace (#213)
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent ee115fd commit d07f1fa

File tree

3 files changed

+97
-4
lines changed

3 files changed

+97
-4
lines changed

features/search-replace.feature

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,45 @@ Feature: Do global search/replace
238238
| key | value |
239239
| header_image_data | {"url":"https:\/\/example.com\/foo.jpg"} |
240240

241+
@require-mysql
242+
Scenario: Search and replace handles JSON-encoded URLs in post content
243+
Given a WP install
244+
245+
When I run `wp post create --post_content='{"src":"http:\/\/example.com\/wp-content\/uploads\/fonts\/test.woff2","fontWeight":"400"}' --post_status=publish --porcelain`
246+
Then save STDOUT as {POST_ID}
247+
248+
When I run `wp post get {POST_ID} --field=post_content`
249+
Then STDOUT should contain:
250+
"""
251+
http:\/\/example.com
252+
"""
253+
254+
When I run `wp search-replace 'http://example.com' 'http://newdomain.com' wp_posts --include-columns=post_content`
255+
Then STDOUT should be a table containing rows:
256+
| Table | Column | Replacements | Type |
257+
| wp_posts | post_content | 1 | SQL |
258+
259+
When I run `wp post get {POST_ID} --field=post_content`
260+
Then STDOUT should contain:
261+
"""
262+
http:\/\/newdomain.com
263+
"""
264+
And STDOUT should not contain:
265+
"""
266+
http:\/\/example.com
267+
"""
268+
269+
When I run `wp search-replace 'http://newdomain.com' 'http://example.com' wp_posts --include-columns=post_content --precise`
270+
Then STDOUT should be a table containing rows:
271+
| Table | Column | Replacements | Type |
272+
| wp_posts | post_content | 1 | PHP |
273+
274+
When I run `wp post get {POST_ID} --field=post_content`
275+
Then STDOUT should contain:
276+
"""
277+
http:\/\/example.com
278+
"""
279+
241280
@require-mysql
242281
Scenario: Search and replace with quoted strings
243282
Given a WP install

src/Search_Replace_Command.php

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -652,19 +652,39 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) {
652652

653653
$table_sql = self::esc_sql_ident( $table );
654654
$col_sql = self::esc_sql_ident( $col );
655+
$old_json = self::json_encode_strip_quotes( $old );
656+
$new_json = self::json_encode_strip_quotes( $new );
657+
$has_json = $old_json !== $old;
658+
655659
if ( $this->dry_run ) {
656660
if ( $this->log_handle ) {
657661
$count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new );
662+
if ( $has_json ) {
663+
$count += $this->log_sql_diff( $col, $primary_keys, $table, $old_json, $new_json );
664+
}
665+
} elseif ( $has_json ) {
666+
// Single query with OR to avoid counting rows that match both forms twice.
667+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
668+
$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 ) . '%' ) );
658669
} else {
659670
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
660-
$count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) );
671+
$count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) );
661672
}
662673
} else {
663674
if ( $this->log_handle ) {
664675
$this->log_sql_diff( $col, $primary_keys, $table, $old, $new );
676+
if ( $has_json ) {
677+
$this->log_sql_diff( $col, $primary_keys, $table, $old_json, $new_json );
678+
}
679+
}
680+
if ( $has_json ) {
681+
// Single nested REPLACE handles both plain and JSON-encoded forms in one pass.
682+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
683+
$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 ) );
684+
} else {
685+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
686+
$count = (int) $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) );
665687
}
666-
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident
667-
$count = $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) );
668688
}
669689

670690
if ( $this->verbose && 'table' === $this->format ) {
@@ -686,8 +706,12 @@ private function php_handle_col( $col, $primary_keys, $table, $old, $new ) {
686706
$base_key_condition = '';
687707
$where_key = '';
688708
if ( ! $this->regex ) {
709+
$old_json = self::json_encode_strip_quotes( $old );
689710
$base_key_condition = "$col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old ) . '%' );
690-
$where_key = "WHERE $base_key_condition";
711+
if ( $old_json !== $old ) {
712+
$base_key_condition = "( $base_key_condition OR $col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old_json ) . '%' ) . ' )';
713+
}
714+
$where_key = "WHERE $base_key_condition";
691715
}
692716

693717
$escaped_primary_keys = self::esc_sql_ident( $primary_keys );
@@ -917,6 +941,19 @@ private static function esc_like( $old ) {
917941
return $old;
918942
}
919943

944+
/**
945+
* Returns the JSON-encoded representation of a string with the surrounding quotes stripped.
946+
* This is used to also handle values stored as raw JSON in the database (e.g. WordPress font data).
947+
* Returns the original string unchanged if JSON encoding fails (e.g. invalid UTF-8).
948+
*
949+
* @param string $str The string to encode.
950+
* @return string The JSON-encoded string without surrounding quotes, or the original string on failure.
951+
*/
952+
public static function json_encode_strip_quotes( $str ) {
953+
$encoded = json_encode( $str ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
954+
return false !== $encoded ? substr( $encoded, 1, -1 ) : $str;
955+
}
956+
920957
/**
921958
* Escapes (backticks) MySQL identifiers (aka schema object names) - i.e. column names, table names, and database/index/alias/view etc names.
922959
* See https://dev.mysql.com/doc/refman/5.5/en/identifiers.html

src/WP_CLI/SearchReplacer.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class SearchReplacer {
1717
*/
1818
private $to;
1919

20+
/**
21+
* @var string
22+
*/
23+
private $from_json;
24+
25+
/**
26+
* @var string
27+
*/
28+
private $to_json;
29+
2030
/**
2131
* @var bool
2232
*/
@@ -78,6 +88,10 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals
7888
$this->logging = $logging;
7989
$this->clear_log_data();
8090

91+
// Compute JSON-encoded versions (stripping outer quotes) for handling raw JSON values in the database.
92+
$this->from_json = \Search_Replace_Command::json_encode_strip_quotes( $from );
93+
$this->to_json = \Search_Replace_Command::json_encode_strip_quotes( $to );
94+
8195
// Get the XDebug nesting level. Will be zero (no limit) if no value is set
8296
$this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) );
8397
}
@@ -204,6 +218,9 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis
204218
$data = $result;
205219
} else {
206220
$data = str_replace( $this->from, $this->to, $data );
221+
if ( $this->from_json !== $this->from ) {
222+
$data = str_replace( $this->from_json, $this->to_json, $data );
223+
}
207224
}
208225
if ( $this->logging && $old_data !== $data ) {
209226
$this->log_data[] = $old_data;

0 commit comments

Comments
 (0)