Skip to content
Open
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
97 changes: 91 additions & 6 deletions features/search-replace.feature
Original file line number Diff line number Diff line change
Expand Up @@ -303,17 +303,102 @@ Feature: Do global search/replace
http:\/\/example.com
"""

When I run `wp search-replace 'http://newdomain.com' 'http://example.com' wp_posts --include-columns=post_content --precise`
Scenario: Search and replace serialized array keys with --replace-keys
Given a WP install
And a setup-serialized-array-key.php file:
"""
<?php
update_option( 'replace_key_option', array( 'sr-key-old' => 'value' ) );
"""
And I run `wp eval-file setup-serialized-array-key.php`

When I run `wp search-replace sr-key-old sr-key-new --dry-run`
Then STDOUT should be a table containing rows:
| Table | Column | Replacements | Type |
| wp_posts | post_content | 1 | PHP |
| Table | Column | Replacements | Type |
| wp_options | option_value | 0 | PHP |

When I run `wp post get {POST_ID} --field=post_content`
Then STDOUT should contain:
When I run `wp search-replace sr-key-old sr-key-new --replace-keys --dry-run`
Then STDOUT should be a table containing rows:
| Table | Column | Replacements | Type |
| wp_options | option_value | 1 | PHP |

When I run `wp search-replace sr-key-old sr-key-new --replace-keys`
And I run `wp option get replace_key_option --format=json`
Then STDOUT should be JSON containing:
"""
http:\/\/example.com
{"sr-key-new":"value"}
"""

Scenario: Search and replace nested serialized array keys with --replace-keys
Given a WP install
And a setup-nested-serialized-array-key.php file:
"""
<?php
update_option(
'replace_nested_key_option',
array(
'outer' => array(
'sr-key-old' => 'value',
),
)
);
"""
And I run `wp eval-file setup-nested-serialized-array-key.php`

When I run `wp search-replace sr-key-old sr-key-new --replace-keys --dry-run`
Then STDOUT should be a table containing rows:
| Table | Column | Replacements | Type |
| wp_options | option_value | 1 | PHP |

When I run `wp search-replace sr-key-old sr-key-new --replace-keys`
And I run `wp option get replace_nested_key_option --format=json`
Then STDOUT should be JSON containing:
"""
{"outer":{"sr-key-new":"value"}}
"""

Scenario: Search and replace key and value with --replace-keys
Given a WP install
And a setup-key-and-value-option.php file:
"""
<?php
update_option( 'replace_key_and_value_option', array( 'sr-key-old' => 'sr-key-old' ) );
"""
And I run `wp eval-file setup-key-and-value-option.php`

When I run `wp search-replace sr-key-old sr-key-new --replace-keys --dry-run`
Then STDOUT should be a table containing rows:
| Table | Column | Replacements | Type |
| wp_options | option_value | 1 | PHP |

When I run `wp search-replace sr-key-old sr-key-new --replace-keys`
And I run `wp option get replace_key_and_value_option --format=json`
Then STDOUT should be JSON containing:
"""
{"sr-key-new":"sr-key-new"}
"""

Scenario: Search and replace colliding serialized array keys with --replace-keys
Given a WP install
And a setup-colliding-keys-option.php file:
"""
<?php
update_option(
'replace_colliding_keys_option',
array(
'sr-key-old' => 'from-old',
'sr-key-new' => 'existing',
)
);
"""
And I run `wp eval-file setup-colliding-keys-option.php`

When I run `wp search-replace sr-key-old sr-key-new --replace-keys`
And I run `wp option get replace_colliding_keys_option --format=json`
Then STDOUT should be JSON containing:
"""
{"sr-key-new":"from-old"}
"""
@require-mysql
Scenario: Search and replace with quoted strings
Given a WP install
Expand Down
15 changes: 12 additions & 3 deletions src/Search_Replace_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class Search_Replace_Command extends WP_CLI_Command {
*/
private $recurse_objects;

/**
* @var bool
*/
private $replace_keys;

/**
* @var bool
*/
Expand Down Expand Up @@ -214,6 +219,9 @@ class Search_Replace_Command extends WP_CLI_Command {
* : Enable recursing into objects to replace strings. Defaults to true;
* pass --no-recurse-objects to disable.
*
* [--replace-keys]
* : Enable replacing string keys in serialized arrays.
*
* [--verbose]
* : Prints rows to the console as they're updated.
*
Expand Down Expand Up @@ -287,7 +295,7 @@ class Search_Replace_Command extends WP_CLI_Command {
* fi
*
* @param array<string> $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, 'replace-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;
Expand Down Expand Up @@ -333,6 +341,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->replace_keys = Utils\get_flag_value( $assoc_args, 'replace-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 );
Expand Down Expand Up @@ -638,7 +647,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->replace_keys, $this->regex, $this->regex_flags, $this->regex_delimiter, false, $this->regex_limit );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the SearchReplacer constructor signature is updated to append $replace_keys at the end (to maintain backward compatibility), this call should be updated accordingly.

$replacer   = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, false, $this->regex_limit, $this->replace_keys );

$col_counts = array_fill_keys( $all_columns, 0 );
if ( $this->verbose && 'table' === $this->format ) {
$this->start_time = microtime( true );
Expand Down Expand Up @@ -743,7 +752,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->replace_keys, $this->regex, $this->regex_flags, $this->regex_delimiter, null !== $this->log_handle, $this->regex_limit );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the SearchReplacer constructor signature is updated to append $replace_keys at the end (to maintain backward compatibility), this call should be updated accordingly.

$replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, null !== $this->log_handle, $this->regex_limit, $this->replace_keys );


$table_sql = self::esc_sql_ident( $table );
$col_sql = self::esc_sql_ident( $col );
Expand Down
21 changes: 19 additions & 2 deletions src/WP_CLI/SearchReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ class SearchReplacer {
*/
private $recurse_objects;

/**
* @var bool
*/
private $replace_keys;

/**
* @var bool
*/
Expand Down Expand Up @@ -71,16 +76,18 @@ class SearchReplacer {
* @param string $from String we're looking to replace.
* @param string $to What we want it to be replaced with.
* @param bool $recurse_objects Should objects be recursively replaced?
* @param bool $replace_keys Should array keys be replaced?
* @param bool $regex Whether `$from` is a regular expression.
* @param string $regex_flags Flags for regular expression.
* @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.
*/
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, $replace_keys = false, $regex = false, $regex_flags = '', $regex_delimiter = '/', $logging = false, $regex_limit = -1 ) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The addition of the $replace_keys parameter in the middle of the constructor's argument list is a breaking change for the SearchReplacer class's public API. Any existing code calling this constructor with positional arguments (e.g., to set $regex or $logging) will now pass those values to the wrong parameters. It is recommended to append new parameters to the end of the argument list to maintain backward compatibility.

public function __construct( $from, $to, $recurse_objects = false, $regex = false, $regex_flags = '', $regex_delimiter = '/', $logging = false, $regex_limit = -1, $replace_keys = false ) {

$this->from = $from;
$this->to = $to;
$this->recurse_objects = $recurse_objects;
$this->replace_keys = $replace_keys;
$this->regex = $regex;
$this->regex_flags = $regex_flags;
$this->regex_delimiter = $regex_delimiter;
Expand Down Expand Up @@ -165,7 +172,17 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis
} elseif ( is_array( $data ) ) {
$keys = array_keys( $data );
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 );
if ( $this->replace_keys && is_string( $key ) ) {
$replaced_key = $this->run_recursively( $key, false, $recursion_level + 1, $visited_data );
if ( $replaced_key !== $key ) {
unset( $data[ $key ] );
$data[ $replaced_key ] = $value;
continue;
}
}

$data[ $key ] = $value;
}
Comment on lines +175 to 186
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Modifying the array in-place while iterating over its keys can lead to incorrect results and data loss. If a key is replaced with a name that exists later in the original array, the value associated with that new key will be overwritten before it is processed. Furthermore, the loop will eventually reach that new key and process the already-replaced value a second time.

To fix this, consider building a new array for the transformed data instead of modifying $data in-place. This ensures each original key-value pair is processed exactly once and correctly handles scenarios like key swaps.

Example fix:

$new_data = [];
foreach ( $data as $key => $value ) {
    $new_value = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data );
    $new_key   = $key;
    if ( $this->replace_keys && is_string( $key ) ) {
        $new_key = $this->run_recursively( $key, false, $recursion_level + 1, $visited_data );
    }
    $new_data[ $new_key ] = $new_value;
}
$data = $new_data;

} elseif ( $this->recurse_objects && ( is_object( $data ) || $data instanceof \__PHP_Incomplete_Class ) ) {
if ( $data instanceof \__PHP_Incomplete_Class ) {
Expand Down
Loading