From 98e451b6e19944df5775dedfc0a58c4dcae3a8fa Mon Sep 17 00:00:00 2001 From: Ratnesh Jaiswal Date: Thu, 9 Apr 2026 16:30:47 +0530 Subject: [PATCH 1/2] Add JSON handling for search/replace in custom tables and nested JSON --- features/search-replace.feature | 69 +++++++++++++++++++++++++++++++++ src/Search_Replace_Command.php | 8 ++++ src/WP_CLI/SearchReplacer.php | 15 ++++++- 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index 325b6e2c..326118ff 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -1637,3 +1637,72 @@ Feature: Do global search/replace """ Table is read-only """ + + @require-mysql + Scenario: Search and replace handles JSON-encoded URLs in custom tables + Given a WP install + And I run `wp db query "CREATE TABLE wp_json_test ( id int(11) unsigned NOT NULL AUTO_INCREMENT, meta TEXT, PRIMARY KEY (id) ) ENGINE=InnoDB;"` + And I run `wp db query "INSERT INTO wp_json_test (meta) VALUES ('{\"confirmations\":{\"1\":{\"url\":\"https:\\/\\/oldsite.com\\/confirmation-page\",\"type\":\"redirect\"}}}');"` + + When I run `wp search-replace 'https://oldsite.com' 'https://newsite.com' wp_json_test` + Then STDOUT should be a table containing rows: + | Table | Column | Replacements | Type | + | wp_json_test | meta | 1 | PHP | + + When I run `wp db query "SELECT meta FROM wp_json_test WHERE id = 1" --skip-column-names` + Then STDOUT should contain: + """ + https:\/\/newsite.com\/confirmation-page + """ + And STDOUT should not contain: + """ + https:\/\/oldsite.com + """ + + @require-mysql + Scenario: Search and replace handles nested JSON (JSON within serialized data) + Given a WP install + And a setup-nested-json.php file: + """ + json_encode( array( + 'url' => 'https://oldsite.com/page', + 'name' => 'Test', + ) ), + ); + update_option( 'nested_json_test', $data ); + """ + And I run `wp eval-file setup-nested-json.php` + + When I run `wp search-replace 'https://oldsite.com' 'https://newsite.com' wp_options --include-columns=option_value` + Then STDOUT should be a table containing rows: + | Table | Column | Replacements | Type | + | wp_options | option_value | 1 | PHP | + + When I run `wp option get nested_json_test --format=json` + Then STDOUT should contain: + """ + newsite.com + """ + And STDOUT should not contain: + """ + oldsite.com + """ + + @require-mysql + Scenario: Search and replace detects JSON columns for PHP mode automatically + Given a WP install + And I run `wp db query "CREATE TABLE wp_json_detect ( id int(11) unsigned NOT NULL AUTO_INCREMENT, data TEXT, PRIMARY KEY (id) ) ENGINE=InnoDB;"` + And I run `wp db query "INSERT INTO wp_json_detect (data) VALUES ('{\"site_url\":\"https:\\/\\/old.example.com\\/path\"}');"` + + When I run `wp search-replace 'https://old.example.com' 'https://new.example.com' wp_json_detect` + Then STDOUT should be a table containing rows: + | Table | Column | Replacements | Type | + | wp_json_detect | data | 1 | PHP | + + When I run `wp db query "SELECT data FROM wp_json_detect WHERE id = 1" --skip-column-names` + Then STDOUT should contain: + """ + new.example.com + """ diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index c37ad0df..d38dd71d 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -562,6 +562,14 @@ public function __invoke( $args, $assoc_args ) { if ( false !== strpos( $wpdb->last_error, 'ERROR 1139' ) ) { $serial_row = true; } + + // Also detect JSON objects/arrays so the PHP path can decode, + // recurse into, and re-encode them — handling nested escaped + // URLs that a simple SQL REPLACE cannot reach. + if ( null === $serial_row ) { + // 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 '^[\\\\[{]' LIMIT 1" ); + } } if ( $php_only || $this->regex || null !== $serial_row ) { diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 6f3bea9f..756dc5d0 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -200,7 +200,20 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis if ( $this->logging ) { $old_data = $data; } - if ( $this->regex ) { + + // Try to decode as a JSON object or array and recurse into the + // decoded structure. This properly handles URLs stored inside + // JSON-encoded columns (e.g. Gravity Forms confirmations, block + // editor font data), including nested JSON where slashes are + // double-escaped. + $json_decoded = json_decode( $data, true ); + if ( null !== $json_decoded && is_array( $json_decoded ) ) { + $json_decoded = $this->run_recursively( $json_decoded, false, $recursion_level + 1, $visited_data ); + $json_result = json_encode( $json_decoded ); + if ( false !== $json_result ) { + $data = $json_result; + } + } elseif ( $this->regex ) { $search_regex = $this->regex_delimiter; $search_regex .= $this->from; $search_regex .= $this->regex_delimiter; From 791e445370e1cc92b7a991813684e7a8f6306e3a Mon Sep 17 00:00:00 2001 From: Ratnesh Jaiswal Date: Thu, 9 Apr 2026 17:01:03 +0530 Subject: [PATCH 2/2] Fix CI failures: update test expectations and add --all-tables-with-prefix for custom tables - Update JSON post content test to expect PHP type (JSON detection now routes to PHP mode) - Add --all-tables-with-prefix flag for custom table tests so WP-CLI can find them - Fix double replacement by making JSON decode path and str_replace/regex mutually exclusive Co-Authored-By: Claude Opus 4.6 (1M context) --- features/search-replace.feature | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index 326118ff..8edb6d31 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -292,7 +292,7 @@ Feature: Do global search/replace 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 | + | wp_posts | post_content | 1 | PHP | When I run `wp post get {POST_ID} --field=post_content` Then STDOUT should contain: @@ -1644,7 +1644,7 @@ Feature: Do global search/replace And I run `wp db query "CREATE TABLE wp_json_test ( id int(11) unsigned NOT NULL AUTO_INCREMENT, meta TEXT, PRIMARY KEY (id) ) ENGINE=InnoDB;"` And I run `wp db query "INSERT INTO wp_json_test (meta) VALUES ('{\"confirmations\":{\"1\":{\"url\":\"https:\\/\\/oldsite.com\\/confirmation-page\",\"type\":\"redirect\"}}}');"` - When I run `wp search-replace 'https://oldsite.com' 'https://newsite.com' wp_json_test` + When I run `wp search-replace 'https://oldsite.com' 'https://newsite.com' wp_json_test --all-tables-with-prefix` Then STDOUT should be a table containing rows: | Table | Column | Replacements | Type | | wp_json_test | meta | 1 | PHP | @@ -1696,7 +1696,7 @@ Feature: Do global search/replace And I run `wp db query "CREATE TABLE wp_json_detect ( id int(11) unsigned NOT NULL AUTO_INCREMENT, data TEXT, PRIMARY KEY (id) ) ENGINE=InnoDB;"` And I run `wp db query "INSERT INTO wp_json_detect (data) VALUES ('{\"site_url\":\"https:\\/\\/old.example.com\\/path\"}');"` - When I run `wp search-replace 'https://old.example.com' 'https://new.example.com' wp_json_detect` + When I run `wp search-replace 'https://old.example.com' 'https://new.example.com' wp_json_detect --all-tables-with-prefix` Then STDOUT should be a table containing rows: | Table | Column | Replacements | Type | | wp_json_detect | data | 1 | PHP |