From ea90c3842a921573e35734cc7f7a3033d88f16c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Droz?= Date: Thu, 16 Oct 2025 22:43:41 -0300 Subject: [PATCH 1/4] Added --where flag to finely select rows aimed for replacement Added --revision / --no-revision Added --callback Reroll wp-cli#104, wp-cli#128, wp-cli#184 Fixes wp-cli#125, wp-cli#127, wp-cli#142 --- features/search-replace-new-options.feature | 513 ++++++++++++++++++++ src/Search_Replace_Command.php | 155 +++++- src/WP_CLI/SearchReplacer.php | 38 +- 3 files changed, 673 insertions(+), 33 deletions(-) create mode 100644 features/search-replace-new-options.feature diff --git a/features/search-replace-new-options.feature b/features/search-replace-new-options.feature new file mode 100644 index 00000000..c2c918a9 --- /dev/null +++ b/features/search-replace-new-options.feature @@ -0,0 +1,513 @@ +Feature: Test new search-replace options (--callback, --revisions, --where) + + @require-mysql + Scenario: Search replace with callback function + Given a WP install + And a callback-function.php file: + """ + ] + * : Perform the replacement on specific rows. Use semi-colon to + * specify multiple specifictions. + * format: [,table]:[column,...]:quoted-SQL-condition + * + * [--revisions] + * : Default true. + * If false, then identical to --where='posts::post_status="publish";postmeta::post_id IN (SELECT ID FROM {posts} WHERE post_status="publish")' + * * [--precise] * : Force the use of PHP (instead of SQL) which is more thorough, - * but slower. + * but slower. If --callback is specified, --precise is inferred. * * [--recurse-objects] * : Enable recursing into objects to replace strings. Defaults to true; @@ -193,6 +212,9 @@ class Search_Replace_Command extends WP_CLI_Command { * [--verbose] * : Prints rows to the console as they're updated. * + * [--callback=] + * : Runs a user-specified function on each string that contains . is passed as the second argument and the regex string as the third if it exists: call_user_func( 'callback', $data, $new, $search_regex ). + * * [--regex] * : Runs the search using a regular expression (without delimiters). * Warning: search-replace will take about 15-20x longer when using --regex. @@ -268,10 +290,10 @@ 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->callback = Utils\get_flag_value( $assoc_args, 'callback', 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 ); - $default_regex_delimiter = false; if ( null !== $this->regex ) { @@ -317,12 +339,30 @@ public function __invoke( $args, $assoc_args ) { $this->skip_columns = explode( ',', Utils\get_flag_value( $assoc_args, 'skip-columns', '' ) ); $this->skip_tables = explode( ',', Utils\get_flag_value( $assoc_args, 'skip-tables', '' ) ); $this->include_columns = array_filter( explode( ',', Utils\get_flag_value( $assoc_args, 'include-columns', '' ) ) ); + $this->where = $this->develop_where_specs( Utils\get_flag_value( $assoc_args, 'where' ) ); + $revisions = Utils\get_flag_value( $assoc_args, 'revisions', true ); + if ( ! $revisions ) { + $this->no_revision(); + } if ( $old === $new && ! $this->regex ) { WP_CLI::warning( "Replacement value '{$old}' is identical to search value '{$new}'. Skipping operation." ); exit; } + if ( $this->callback ) { + // We must load WordPress as the function may depend on it. + WP_CLI::get_runner()->load_wordpress(); + if ( ! function_exists( $this->callback ) ) { + WP_CLI::error( 'The callback function does not exist. Skipping operation.' ); + } + + if ( false === $php_only ) { + WP_CLI::error( 'PHP is required to execute a callback function. --no-precise cannot be set.' ); + } + $php_only = true; + } + $export = Utils\get_flag_value( $assoc_args, 'export' ); if ( null !== $export ) { if ( $this->dry_run ) { @@ -413,6 +453,28 @@ public function __invoke( $args, $assoc_args ) { // Get table names based on leftover $args or supplied $assoc_args $tables = Utils\wp_get_table_names( $args, $assoc_args ); + // If a custom `where` conditions were passed, then exclude other tables from processing. + if ( $this->where ) { + $tables = array_intersect( $tables, array_keys( $this->where ) ); + $columns = []; + foreach ( array_values( $this->where ) as $_ => $cols ) { + $columns = array_merge( $columns, array_keys( $cols ) ); + } + if ( $columns ) { + if ( in_array( '*', $columns ) ) { + $columns = array_filter( + $columns, + function ( $e ) { + return $e !== '*'; + } + ); + if ( $this->include_columns ) { + WP_CLI::warning( 'Column-catch was passed to --where while. But --include-columns will still restrict replacements to columns: ' . implode( ',', $this->include_columns ) ); + } + } + $this->include_columns = array_merge( $this->include_columns, $columns ); + } + } foreach ( $tables as $table ) { @@ -467,6 +529,8 @@ public function __invoke( $args, $assoc_args ) { continue; } + $clauses = $this->get_clauses( $table, $col ); + if ( $this->verbose && 'count' !== $this->format ) { $this->start_time = microtime( true ); WP_CLI::log( sprintf( 'Checking: %s.%s', $table, $col ) ); @@ -478,8 +542,9 @@ public function __invoke( $args, $assoc_args ) { $col_sql = self::esc_sql_ident( $col ); $wpdb->last_error = ''; + $where = $clauses ? ' AND ' . implode( ' AND ', $clauses ) : ''; // 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 '^[aiO]:[1-9]' LIMIT 1" ); + $serial_row = $wpdb->get_row( "SELECT * FROM $table_sql WHERE $col_sql REGEXP '^[aiO]:[1-9]' $where LIMIT 1" ); // When the regex triggers an error, we should fall back to PHP if ( false !== strpos( $wpdb->last_error, 'ERROR 1139' ) ) { @@ -489,10 +554,10 @@ public function __invoke( $args, $assoc_args ) { if ( $php_only || $this->regex || null !== $serial_row ) { $type = 'PHP'; - $count = $this->php_handle_col( $col, $primary_keys, $table, $old, $new ); + $count = $this->php_handle_col( $col, $primary_keys, $table, $old, $new, $clauses ); } else { $type = 'SQL'; - $count = $this->sql_handle_col( $col, $primary_keys, $table, $old, $new ); + $count = $this->sql_handle_col( $col, $primary_keys, $table, $old, $new, $clauses ); } if ( $this->report && ( $count || ! $this->report_changed_only ) ) { @@ -563,7 +628,15 @@ private function php_export_table( $table, $old, $new ) { foreach ( $all_columns as $col ) { $value = $row->$col; if ( $value && ! in_array( $col, $primary_keys, true ) && ! in_array( $col, $this->skip_columns, true ) ) { - $new_value = $replacer->run( $value ); + $new_value = $replacer->run( + $value, + false, + [ + 'table' => $table, + 'col' => $col, + 'key' => $primary_keys, + ] + ); if ( $new_value !== $value ) { ++$col_counts[ $col ]; $value = $new_value; @@ -596,24 +669,26 @@ private function php_export_table( $table, $old, $new ) { return array( $table_report, $total_rows ); } - private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { + private function sql_handle_col( $col, $primary_keys, $table, $old, $new, $clauses ) { global $wpdb; $table_sql = self::esc_sql_ident( $table ); $col_sql = self::esc_sql_ident( $col ); if ( $this->dry_run ) { if ( $this->log_handle ) { - $count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new ); + $count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new, $clauses ); } else { + $where = $clauses ? ' AND ' . implode( ' AND ', $clauses ) : ''; // 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 = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s $where;", '%' . self::esc_like( $old ) . '%' ) ); } } else { if ( $this->log_handle ) { - $this->log_sql_diff( $col, $primary_keys, $table, $old, $new ); + $this->log_sql_diff( $col, $primary_keys, $table, $old, $new, $clauses ); } + $where = $clauses ? ' WHERE ' . implode( ' AND ', $clauses ) : ''; // 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 ) ); + $count = $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s) $where;", $old, $new ) ); } if ( $this->verbose && 'table' === $this->format ) { @@ -623,22 +698,21 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { return $count; } - private function php_handle_col( $col, $primary_keys, $table, $old, $new ) { + private function php_handle_col( $col, $primary_keys, $table, $old, $new, $additional_where ) { 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->callback ); $table_sql = self::esc_sql_ident( $table ); $col_sql = self::esc_sql_ident( $col ); - $base_key_condition = ''; - $where_key = ''; + $base_key_condition = $additional_where; if ( ! $this->regex ) { - $base_key_condition = "$col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old ) . '%' ); - $where_key = "WHERE $base_key_condition"; + $base_key_condition[] = "$col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old ) . '%' ); } + $where_key = $base_key_condition ? ' WHERE ' . implode( ' AND ', $base_key_condition ) : ''; $escaped_primary_keys = self::esc_sql_ident( $primary_keys ); $primary_keys_sql = implode( ',', $escaped_primary_keys ); $order_by_keys = array_map( @@ -726,13 +800,8 @@ static function ( $key ) { $next_key_conditions[] = '( ' . implode( ' AND ', $next_key_subconditions ) . ' )'; } - $where_key_conditions = array(); - if ( $base_key_condition ) { - $where_key_conditions[] = $base_key_condition; - } - $where_key_conditions[] = '( ' . implode( ' OR ', $next_key_conditions ) . ' )'; - - $where_key = 'WHERE ' . implode( ' AND ', $where_key_conditions ); + $base_key_condition[] = '( ' . implode( ' OR ', $next_key_conditions ) . ' )'; + $where_key = 'WHERE ' . implode( ' AND ', $base_key_condition ); } if ( $this->verbose && 'table' === $this->format ) { @@ -955,7 +1024,7 @@ private function get_colors( $assoc_args, $colors ) { * @param string $new New value to replace the old value with. * @return int Count of changed rows. */ - private function log_sql_diff( $col, $primary_keys, $table, $old, $new ) { + private function log_sql_diff( $col, $primary_keys, $table, $old, $new, $clauses ) { global $wpdb; if ( $primary_keys ) { $esc_primary_keys = implode( ', ', self::esc_sql_ident( $primary_keys ) ); @@ -966,9 +1035,10 @@ private function log_sql_diff( $col, $primary_keys, $table, $old, $new ) { $table_sql = self::esc_sql_ident( $table ); $col_sql = self::esc_sql_ident( $col ); + $where_sql = $clauses ? ' AND ' . implode( ' AND ', $clauses ) : ''; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident - $results = $wpdb->get_results( $wpdb->prepare( "SELECT {$primary_keys_sql}{$col_sql} FROM {$table_sql} WHERE {$col_sql} LIKE BINARY %s", '%' . self::esc_like( $old ) . '%' ), ARRAY_N ); + $results = $wpdb->get_results( $wpdb->prepare( "SELECT {$primary_keys_sql}{$col_sql} FROM {$table_sql} WHERE {$col_sql} LIKE BINARY %s {$where_sql}", '%' . self::esc_like( $old ) . '%' ), ARRAY_N ); if ( empty( $results ) ) { return 0; @@ -1149,4 +1219,37 @@ private function log_write( $col, $keys, $table, $old_bits, $new_bits ) { fwrite( $this->log_handle, "{$table_column_id_log}\n{$old_log}\n{$new_log}\n" ); } + + public function develop_where_specs( $str_specs ) { + global $wpdb; + $specs = array_filter( explode( ';', $str_specs ) ); + $clauses = []; + foreach ( $specs as $spec ) { + list( $tables, $cols, $conditions ) = explode( ':', $spec, 3 ); + $tables = array_filter( explode( ',', $tables ) ); + $cols = array_filter( explode( ',', $cols ) ) ? : [ '*' ]; + foreach ( $tables as $table ) { + foreach ( $cols as $col ) { + $clauses[ empty( $wpdb->{$table} ) ? $table : $wpdb->{$table} ][ $col ][] = $conditions; + } + } + } + + return $clauses; + } + + public function no_revision() { + global $wpdb; + $this->where[ $wpdb->posts ]['*'][] = self::esc_sql_ident( 'post_status' ) . '=' . self::esc_sql_value( 'publish' ); + $this->where[ $wpdb->postmeta ]['*'][] = self::esc_sql_ident( 'post_id' ) . ' IN ( SELECT ID FROM ' . $wpdb->posts . ' WHERE ' . self::esc_sql_ident( 'post_status' ) . '=' . self::esc_sql_value( 'publish' ) . ')'; + } + + public function get_clauses( $table, $column = null ) { + return array_filter( + array_merge( + $this->where[ $table ][ $column ] ?? [], + $this->where[ $table ]['*'] ?? [] + ) + ); + } } diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 8c5ee951..a4bb8108 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -67,7 +67,7 @@ class SearchReplacer { * @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, $regex = false, $regex_flags = '', $regex_delimiter = '/', $logging = false, $regex_limit = -1, $callback = false ) { $this->from = $from; $this->to = $to; $this->recurse_objects = $recurse_objects; @@ -76,6 +76,7 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals $this->regex_delimiter = $regex_delimiter; $this->regex_limit = $regex_limit; $this->logging = $logging; + $this->callback = $callback; $this->clear_log_data(); // Get the XDebug nesting level. Will be zero (no limit) if no value is set @@ -92,15 +93,15 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals * * @return array The original array with all elements replaced as needed. */ - public function run( $data, $serialised = false ) { - return $this->run_recursively( $data, $serialised ); + public function run( $data, $serialised = false, $opts = [] ) { + return $this->run_recursively( $data, $serialised, 0, [], $opts ); } /** * @param int $recursion_level Current recursion depth within the original data. * @param array $visited_data Data that has been seen in previous recursion iterations. */ - private function run_recursively( $data, $serialised, $recursion_level = 0, $visited_data = array() ) { + private function run_recursively( $data, $serialised, $recursion_level = 0, $visited_data = array(), $opts = [] ) { // some unseriliased data cannot be re-serialised eg. SimpleXMLElements try { @@ -192,7 +193,11 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis $search_regex .= $this->regex_delimiter; $search_regex .= $this->regex_flags; - $result = preg_replace( $search_regex, $this->to, $data, $this->regex_limit ); + if ( $this->callback ) { + $result = \call_user_func( $this->callback, $data, $this->to, $search_regex, $opts ); + } else { + $result = preg_replace( $search_regex, $this->to, $data, $this->regex_limit ); + } if ( null === $result || PREG_NO_ERROR !== preg_last_error() ) { \WP_CLI::warning( sprintf( @@ -201,10 +206,29 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis ) ); } - $data = $result; + } elseif ( $this->callback ) { + if ( strpos( $data, $this->from ) !== false ) { + $result = \call_user_func( $this->callback, $data, $this->to, $opts ); + } else { + // We can skip calling the function here. It must still be set so we don't remove text. + $result = $data; + } } else { - $data = str_replace( $this->from, $this->to, $data ); + $result = str_replace( $this->from, $this->to, $data ); + } + + if ( $this->callback ) { + if ( false === $result ) { + WP_CLI::error( 'The callback function return false. Stopping operation.' ); + } elseif ( is_wp_error( $result ) ) { + $message = $errors->get_error_message(); + WP_CLI::error( 'The callback function threw an error. Stopping operation. ' . $message ); + } elseif ( ! is_string( $result ) ) { + WP_CLI::error( 'The callback function did not return a string. Stopping operation.' ); + } } + + $data = $result; if ( $this->logging && $old_data !== $data ) { $this->log_data[] = $old_data; } From 0071c869a415f8208c4f8f87f5a0a7cfcbbe27dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Droz?= Date: Sun, 19 Oct 2025 00:55:29 -0300 Subject: [PATCH 2/4] phpcbf + features split --- features/search-replace-callback.feature | 129 ++++++++++ features/search-replace-revisions.feature | 106 ++++++++ ...s.feature => search-replace-where.feature} | 235 +----------------- src/Search_Replace_Command.php | 26 +- 4 files changed, 249 insertions(+), 247 deletions(-) create mode 100644 features/search-replace-callback.feature create mode 100644 features/search-replace-revisions.feature rename features/{search-replace-new-options.feature => search-replace-where.feature} (55%) diff --git a/features/search-replace-callback.feature b/features/search-replace-callback.feature new file mode 100644 index 00000000..cc1df240 --- /dev/null +++ b/features/search-replace-callback.feature @@ -0,0 +1,129 @@ +Feature: Test search-replace --callback option + + @require-mysql + Scenario: Search replace with callback function + Given a WP install + And a callback-function.php file: + """ + 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->callback = Utils\get_flag_value( $assoc_args, 'callback', 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 ); + $old = array_shift( $args ); + $new = array_shift( $args ); + $total = 0; + $report = array(); + $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->callback = Utils\get_flag_value( $assoc_args, 'callback', 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 ); $default_regex_delimiter = false; if ( null !== $this->regex ) { @@ -461,11 +461,11 @@ public function __invoke( $args, $assoc_args ) { $columns = array_merge( $columns, array_keys( $cols ) ); } if ( $columns ) { - if ( in_array( '*', $columns ) ) { + if ( in_array( '*', $columns, true ) ) { $columns = array_filter( $columns, function ( $e ) { - return $e !== '*'; + return '*' !== $e; } ); if ( $this->include_columns ) { From 82cbe76d437bc2727428726678e2f6413caa34ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Droz?= Date: Sun, 19 Oct 2025 01:03:25 -0300 Subject: [PATCH 3/4] phpstan --- src/WP_CLI/SearchReplacer.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index a4bb8108..4c8e17b3 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -57,6 +57,11 @@ class SearchReplacer { */ private $max_recursion; + /** + * @var bool + */ + private $callback; + /** * @param string $from String we're looking to replace. * @param string $to What we want it to be replaced with. @@ -219,12 +224,12 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis if ( $this->callback ) { if ( false === $result ) { - WP_CLI::error( 'The callback function return false. Stopping operation.' ); + \WP_CLI::error( 'The callback function return false. Stopping operation.' ); } elseif ( is_wp_error( $result ) ) { $message = $errors->get_error_message(); - WP_CLI::error( 'The callback function threw an error. Stopping operation. ' . $message ); + \WP_CLI::error( 'The callback function threw an error. Stopping operation. ' . $message ); } elseif ( ! is_string( $result ) ) { - WP_CLI::error( 'The callback function did not return a string. Stopping operation.' ); + \WP_CLI::error( 'The callback function did not return a string. Stopping operation.' ); } } From faef60ff1ff43ff8957000f9b26d8049e852a193 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 4 Nov 2025 19:10:28 +0100 Subject: [PATCH 4/4] PHPStan fix --- src/Search_Replace_Command.php | 2 +- src/WP_CLI/SearchReplacer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index a94afd3f..fa518aa2 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -120,7 +120,7 @@ class Search_Replace_Command extends WP_CLI_Command { private $start_time; /** - * @var string|false + * @var array>|false */ private $where; diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 4c8e17b3..76602944 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -226,7 +226,7 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis if ( false === $result ) { \WP_CLI::error( 'The callback function return false. Stopping operation.' ); } elseif ( is_wp_error( $result ) ) { - $message = $errors->get_error_message(); + $message = $result->get_error_message(); \WP_CLI::error( 'The callback function threw an error. Stopping operation. ' . $message ); } elseif ( ! is_string( $result ) ) { \WP_CLI::error( 'The callback function did not return a string. Stopping operation.' );