From e9440f33986176485cb067c048512474c705f013 Mon Sep 17 00:00:00 2001 From: Nick Chomey <88559987+nickchomey@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:40:49 -0600 Subject: [PATCH 1/2] feat: add opt-in --recurse-keys flag for serialized keys Serialized array keys and object property names now pass through the same replacement branch as values when --recurse-keys is set. Default behavior is unchanged. --- features/search-replace.feature | 122 ++++++++++++++++++++++++++++++++ src/Search_Replace_Command.php | 16 ++++- src/WP_CLI/SearchReplacer.php | 111 ++++++++++++++++++++--------- 3 files changed, 212 insertions(+), 37 deletions(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index 5123dded..ab408608 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -1592,3 +1592,125 @@ Feature: Do global search/replace """ Table is read-only """ + + @require-mysql + Scenario: Serialized array keys are left alone by default + Given a WP install + And a setup-recurse-keys.php file: + """ + 'https://old.example.com/value', 'nested' => array( 'https://old.example.com/inner' => 'plain' ) ) ); + """ + And a check-recurse-keys.php file: + """ + 'https://old.example.com/value', 'nested' => array( 'https://old.example.com/inner' => 'plain' ) ) ); + """ + And a check-recurse-keys.php file: + """ + 'https://old.example.com/value' ) ); + """ + And a check-recurse-keys-object.php file: + """ + $args Positional arguments. - * @param array{'old'?: string, 'new'?: string, 'dry-run'?: bool, 'network'?: bool, 'all-tables-with-prefix'?: bool, 'all-tables'?: bool, 'export'?: string, 'export_insert_size'?: string, 'skip-tables'?: string, 'skip-columns'?: string, 'include-columns'?: string, 'precise'?: bool, 'recurse-objects'?: bool, 'verbose'?: bool, 'regex'?: bool, 'regex-flags'?: string, 'regex-delimiter'?: string, 'regex-limit'?: string, 'format': string, 'report'?: bool, 'report-changed-only'?: bool, 'log'?: string, 'before_context'?: string, 'after_context'?: string} $assoc_args Associative arguments. + * @param array{'old'?: string, 'new'?: string, 'dry-run'?: bool, 'network'?: bool, 'all-tables-with-prefix'?: bool, 'all-tables'?: bool, 'export'?: string, 'export_insert_size'?: string, 'skip-tables'?: string, 'skip-columns'?: string, 'include-columns'?: string, 'precise'?: bool, 'recurse-objects'?: bool, 'recurse-keys'?: bool, 'verbose'?: bool, 'regex'?: bool, 'regex-flags'?: string, 'regex-delimiter'?: string, 'regex-limit'?: string, 'format': string, 'report'?: bool, 'report-changed-only'?: bool, 'log'?: string, 'before_context'?: string, 'after_context'?: string} $assoc_args Associative arguments. */ public function __invoke( $args, $assoc_args ) { global $wpdb; @@ -337,6 +346,7 @@ public function __invoke( $args, $assoc_args ) { $this->dry_run = Utils\get_flag_value( $assoc_args, 'dry-run', false ); $php_only = Utils\get_flag_value( $assoc_args, 'precise', false ); $this->recurse_objects = Utils\get_flag_value( $assoc_args, 'recurse-objects', true ); + $this->recurse_keys = Utils\get_flag_value( $assoc_args, 'recurse-keys', false ); $this->verbose = Utils\get_flag_value( $assoc_args, 'verbose', false ); $this->format = Utils\get_flag_value( $assoc_args, 'format' ); $this->regex = Utils\get_flag_value( $assoc_args, 'regex', false ); @@ -642,7 +652,7 @@ private function php_export_table( $table, $old, $new ) { 'chunk_size' => $chunk_size, ); - $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, false, $this->regex_limit ); + $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, false, $this->regex_limit, $this->recurse_keys ); $col_counts = array_fill_keys( $all_columns, 0 ); if ( $this->verbose && 'table' === $this->format ) { $this->start_time = microtime( true ); @@ -747,7 +757,7 @@ private function php_handle_col( $col, $primary_keys, $table, $old, $new ) { global $wpdb; $count = 0; - $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, null !== $this->log_handle, $this->regex_limit ); + $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, null !== $this->log_handle, $this->regex_limit, $this->recurse_keys ); $table_sql = self::esc_sql_ident( $table ); $col_sql = self::esc_sql_ident( $col ); diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 5e3ff77b..2b913890 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -32,6 +32,11 @@ class SearchReplacer { */ private $recurse_objects; + /** + * @var bool + */ + private $recurse_keys; + /** * @var bool */ @@ -81,11 +86,13 @@ class SearchReplacer { * @param string $regex_delimiter Delimiter for regular expression. * @param bool $logging Whether logging. * @param integer $regex_limit The maximum possible replacements for each pattern in each subject string. + * @param bool $recurse_keys Should array keys and object property names be recursively replaced? */ - public function __construct( $from, $to, $recurse_objects = false, $regex = false, $regex_flags = '', $regex_delimiter = '/', $logging = false, $regex_limit = -1 ) { + public function __construct( $from, $to, $recurse_objects = false, $regex = false, $regex_flags = '', $regex_delimiter = '/', $logging = false, $regex_limit = -1, $recurse_keys = false ) { $this->from = $from; $this->to = $to; $this->recurse_objects = $recurse_objects; + $this->recurse_keys = $recurse_keys; $this->regex = $regex; $this->regex_flags = $regex_flags; $this->regex_delimiter = $regex_delimiter; @@ -180,10 +187,17 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis if ( false !== $unserialized ) { $data = $this->run_recursively( $unserialized, true, $recursion_level + 1 ); } elseif ( is_array( $data ) ) { - $keys = array_keys( $data ); + $keys = array_keys( $data ); + $replaced = array(); foreach ( $keys as $key ) { - $data[ $key ] = $this->run_recursively( $data[ $key ], false, $recursion_level + 1, $visited_data ); + $value = $this->run_recursively( $data[ $key ], false, $recursion_level + 1, $visited_data ); + $new_key = $key; + if ( $this->recurse_keys && is_string( $key ) ) { + $new_key = $this->replace_string( $key ); + } + $replaced[ $new_key ] = $value; } + $data = $replaced; } elseif ( $this->recurse_objects && ( is_object( $data ) || $data instanceof \__PHP_Incomplete_Class ) ) { if ( $data instanceof \__PHP_Incomplete_Class ) { $array = new ArrayObject( $data ); @@ -196,12 +210,26 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis } else { try { $is_array_access = $data instanceof \ArrayAccess; + $properties = array(); foreach ( $data as $key => $value ) { - $value = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data ); + $properties[ $key ] = $value; + } + foreach ( $properties as $key => $value ) { + $value = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data ); + $new_key = $key; + if ( $this->recurse_keys && is_string( $key ) ) { + $new_key = $this->replace_string( $key ); + } if ( $is_array_access ) { - $data[ $key ] = $value; + if ( $new_key !== $key ) { + unset( $data[ $key ] ); + } + $data[ $new_key ] = $value; } else { - $data->$key = $value; + if ( $new_key !== $key ) { + unset( $data->$key ); + } + $data->$new_key = $value; } } } catch ( \Error $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound @@ -220,34 +248,7 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis } } } elseif ( is_string( $data ) ) { - if ( $this->logging ) { - $old_data = $data; - } - if ( $this->regex ) { - $search_regex = $this->regex_delimiter; - $search_regex .= $this->from; - $search_regex .= $this->regex_delimiter; - $search_regex .= $this->regex_flags; - - $result = preg_replace( $search_regex, $this->to, $data, $this->regex_limit ); - if ( null === $result || PREG_NO_ERROR !== preg_last_error() ) { - \WP_CLI::warning( - sprintf( - 'The provided regular expression threw a PCRE error - %s', - $this->preg_error_message( $result ) - ) - ); - } - $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; - } + $data = $this->replace_string( $data ); } if ( $serialised ) { @@ -260,6 +261,48 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis return $data; } + /** + * Replace occurrences of the search string within a single string. + * + * Used for values as well as array keys and object property names when + * key recursion is enabled. + * + * @param string $data The string to operate on. + * @return string The string with all replacements made as needed. + */ + private function replace_string( $data ) { + if ( $this->logging ) { + $old_data = $data; + } + if ( $this->regex ) { + $search_regex = $this->regex_delimiter; + $search_regex .= $this->from; + $search_regex .= $this->regex_delimiter; + $search_regex .= $this->regex_flags; + + $result = preg_replace( $search_regex, $this->to, $data, $this->regex_limit ); + if ( null === $result || PREG_NO_ERROR !== preg_last_error() ) { + \WP_CLI::warning( + sprintf( + 'The provided regular expression threw a PCRE error - %s', + $this->preg_error_message( $result ) + ) + ); + } + $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; + } + + return $data; + } + /** * Gets existing data saved for this run when logging. * @return string[] Array of data strings, prior to replacements. From 244e8959d9b5f2e5f54ab324973cd537a64cfc03 Mon Sep 17 00:00:00 2001 From: Nick Chomey <88559987+nickchomey@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:07:50 -0600 Subject: [PATCH 2/2] fix(search-replace): preserve references and skip on key collisions --- features/search-replace.feature | 109 +++++++++++++++++++++++ src/WP_CLI/SearchReplacer.php | 151 ++++++++++++++++++++++++++------ 2 files changed, 232 insertions(+), 28 deletions(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index ab408608..5d63a16c 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -1714,3 +1714,112 @@ Feature: Do global search/replace """ old.example.com """ + + @require-mysql + Scenario: Refuse to rename colliding array keys with --recurse-keys + Given a WP install + And a setup-recurse-keys-collision.php file: + """ + 'v1', 'https://new.example.com/key' => 'v2' ), false ); + """ + And a check-recurse-keys-collision.php file: + """ + 'v1', 'https://new.example.com/prop' => 'v2' ), false ); + """ + And a check-recurse-keys-object-collision.php file: + """ + &$shared, 'b' => &$shared ), false ); + """ + And a check-recurse-keys-refs.php file: + """ + get_var( "SELECT option_value FROM {$GLOBALS['wpdb']->options} WHERE option_name = 'recurse_keys_ref_test'" ); + $value = get_option( 'recurse_keys_ref_test' ); + echo json_encode( $value, JSON_UNESCAPED_SLASHES ), "\n"; + echo false !== strpos( $raw, 'R:2;' ) ? 'HASREF' : 'NOREF'; + """ + And I run `wp eval-file setup-recurse-keys-refs.php` + + When I run `wp search-replace 'https://old.example.com' 'https://new.example.com' --recurse-keys` + Then STDOUT should contain: + """ + Success: + """ + + When I run `wp eval-file check-recurse-keys-refs.php` + Then STDOUT should contain: + """ + "https://new.example.com/a":"https://new.example.com/shared" + """ + And STDOUT should contain: + """ + HASREF + """ diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 2b913890..46e806cf 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -187,17 +187,14 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis if ( false !== $unserialized ) { $data = $this->run_recursively( $unserialized, true, $recursion_level + 1 ); } elseif ( is_array( $data ) ) { - $keys = array_keys( $data ); - $replaced = array(); - foreach ( $keys as $key ) { - $value = $this->run_recursively( $data[ $key ], false, $recursion_level + 1, $visited_data ); - $new_key = $key; - if ( $this->recurse_keys && is_string( $key ) ) { - $new_key = $this->replace_string( $key ); + if ( $this->recurse_keys ) { + $data = $this->replace_array_keys( $data, $recursion_level, $visited_data ); + } else { + $keys = array_keys( $data ); + foreach ( $keys as $key ) { + $data[ $key ] = $this->run_recursively( $data[ $key ], false, $recursion_level + 1, $visited_data ); } - $replaced[ $new_key ] = $value; } - $data = $replaced; } elseif ( $this->recurse_objects && ( is_object( $data ) || $data instanceof \__PHP_Incomplete_Class ) ) { if ( $data instanceof \__PHP_Incomplete_Class ) { $array = new ArrayObject( $data ); @@ -210,26 +207,16 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis } else { try { $is_array_access = $data instanceof \ArrayAccess; - $properties = array(); - foreach ( $data as $key => $value ) { - $properties[ $key ] = $value; - } - foreach ( $properties as $key => $value ) { - $value = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data ); - $new_key = $key; - if ( $this->recurse_keys && is_string( $key ) ) { - $new_key = $this->replace_string( $key ); - } - if ( $is_array_access ) { - if ( $new_key !== $key ) { - unset( $data[ $key ] ); + if ( $this->recurse_keys ) { + $data = $this->replace_object_property_names( $data, $is_array_access, $recursion_level, $visited_data ); + } else { + foreach ( $data as $key => $value ) { + $value = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data ); + if ( $is_array_access ) { + $data[ $key ] = $value; + } else { + $data->$key = $value; } - $data[ $new_key ] = $value; - } else { - if ( $new_key !== $key ) { - unset( $data->$key ); - } - $data->$new_key = $value; } } } catch ( \Error $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound @@ -303,6 +290,114 @@ private function replace_string( $data ) { return $data; } + /** + * Recurse into an array, replacing string keys as well as values. + * + * Renamed keys keep their original position. Shared references between + * elements are preserved by iterating by reference. When two keys would + * map onto the same key, the array is left unchanged and a warning is + * emitted instead of silently discarding a value. + * + * @param array $data The array to process. + * @param int $recursion_level Current recursion depth. + * @param array $visited_data Previously seen arrays and objects. + * @return array The processed array. + */ + private function replace_array_keys( $data, $recursion_level, $visited_data ) { + $renames = array(); + foreach ( array_keys( $data ) as $key ) { + $renames[ $key ] = is_string( $key ) ? $this->replace_string( $key ) : $key; + } + if ( count( array_unique( $renames, SORT_REGULAR ) ) !== count( $renames ) ) { + \WP_CLI::warning( 'Skipping key replacement for an array: two keys would map onto the same key and a value would be lost.' ); + return $data; + } + $replaced = array(); + foreach ( $data as $key => &$value ) { + $value = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data ); + $replaced[ $renames[ $key ] ] =& $value; + } + unset( $value ); + return $replaced; + } + + /** + * Recurse into an object, replacing string property names as well as values. + * + * When two property names would map onto the same name, the object is + * left unchanged and a warning is emitted instead of silently + * discarding a value. + * + * @param object $data The object to process. + * @param bool $is_array_access Whether the object implements ArrayAccess. + * @param int $recursion_level Current recursion depth. + * @param array $visited_data Previously seen arrays and objects. + * @return object The processed object. + */ + private function replace_object_property_names( $data, $is_array_access, $recursion_level, $visited_data ) { + $renames = array(); + foreach ( $data as $key => $value ) { + $renames[ $key ] = is_string( $key ) ? $this->replace_string( $key ) : $key; + } + if ( count( array_unique( $renames, SORT_REGULAR ) ) !== count( $renames ) ) { + \WP_CLI::warning( 'Skipping key replacement for an object: two property names would map onto the same name and a value would be lost.' ); + return $data; + } + foreach ( $renames as $key => $new_key ) { + $value = $this->run_recursively( $this->read_property( $data, $is_array_access, $key ), false, $recursion_level + 1, $visited_data ); + if ( $new_key !== $key ) { + $this->remove_property( $data, $is_array_access, $key ); + } + $this->write_property( $data, $is_array_access, $new_key, $value ); + } + return $data; + } + + /** + * Read one property or offset from an object or ArrayAccess instance. + * + * @param object $data The object to read from. + * @param bool $is_array_access Whether the object implements ArrayAccess. + * @param string $key The property name or offset. + * @return mixed The property value. + */ + private function read_property( $data, $is_array_access, $key ) { + return $is_array_access ? $data[ $key ] : $data->$key; + } + + /** + * Remove one property or offset from an object or ArrayAccess instance. + * + * @param object $data The object to modify, by reference. + * @param bool $is_array_access Whether the object implements ArrayAccess. + * @param string $key The property name or offset. + * @return void + */ + private function remove_property( &$data, $is_array_access, $key ) { + if ( $is_array_access ) { + unset( $data[ $key ] ); + } else { + unset( $data->$key ); + } + } + + /** + * Write one property or offset on an object or ArrayAccess instance. + * + * @param object $data The object to modify, by reference. + * @param bool $is_array_access Whether the object implements ArrayAccess. + * @param string $key The property name or offset. + * @param mixed $value The value to write. + * @return void + */ + private function write_property( &$data, $is_array_access, $key, $value ) { + if ( $is_array_access ) { + $data[ $key ] = $value; + } else { + $data->$key = $value; + } + } + /** * Gets existing data saved for this run when logging. * @return string[] Array of data strings, prior to replacements.