diff --git a/features/search-replace-callback.feature b/features/search-replace-callback.feature new file mode 100644 index 00000000..373f37fb --- /dev/null +++ b/features/search-replace-callback.feature @@ -0,0 +1,187 @@ +Feature: Test search-replace --callback option + + @require-mysql + Scenario: Search replace with callback function + Given a WP install + And a callback-function.php file: + """ + [column_name => [values]]], where each + * table name maps to an array of column names, each of which maps to an array of + * string values to match in the WHERE clause. If set to false, no filtering is applied. + * + * @var array> + */ + private $where = []; + + /** + * @var string|false + */ + private $callback; + /** * Searches/replaces strings in the database. * @@ -182,9 +198,17 @@ class Search_Replace_Command extends WP_CLI_Command { * : Perform the replacement on specific columns. Use commas to * specify multiple columns. * + * [--where=] + * : Perform the replacement on specific rows. Use semi-colon to + * specify multiple specifications. + * format: [,table]:[column,...]:quoted-SQL-condition + * + * [--revisions] + * : Include revisions in the search/replace. Defaults to true; pass --no-revisions to exclude revisions (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 +217,14 @@ 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 . The callback is called as follows: + * call_user_func( 'callback', $data, $new, $search_regex, $opts ) + * * $data is the matched string + * * $new is the replacement string + * * $search_regex is the regex pattern (if applicable, null otherwise) + * * $opts is an array of options. + * * [--regex] * : Runs the search using a regular expression (without delimiters). * Warning: search-replace will take about 15-20x longer when using --regex. @@ -261,17 +293,17 @@ class Search_Replace_Command extends WP_CLI_Command { */ public function __invoke( $args, $assoc_args ) { global $wpdb; - $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->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 ) { @@ -317,12 +349,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 +463,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, true ) ) { + $columns = array_filter( + $columns, + function ( $e ) { + return '*' !== $e; + } + ); + if ( $this->include_columns ) { + WP_CLI::warning( 'Column wildcard (*) was passed to --where. 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 +539,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 +552,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 +564,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 ) ) { @@ -540,6 +615,14 @@ public function __invoke( $args, $assoc_args ) { } } + /** + * Exports a table's data with search and replace. + * + * @param string $table The table to export. + * @param string $old The string to search for. + * @param string $new The string to replace with. + * @return array A tuple containing the report array and the total number of rows. + */ private function php_export_table( $table, $old, $new ) { list( $primary_keys, $columns, $all_columns ) = self::get_columns( $table ); @@ -563,7 +646,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 +687,37 @@ 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 ) { + /** + * Performs a search and replace on a column using SQL. + * + * @param string $col The column to perform the search-replace on. + * @param string[] $primary_keys The primary keys of the table. + * @param string $table The table to perform the search-replace on. + * @param string $old The string to search for. + * @param string $new The string to replace with. + * @param string[] $clauses The WHERE clauses to apply. + * @return int The number of replacements made. + */ + 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 +727,32 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { return $count; } - private function php_handle_col( $col, $primary_keys, $table, $old, $new ) { + /** + * Performs a search and replace on a column using PHP. + * + * @param string $col The column to perform the search-replace on. + * @param string[] $primary_keys The primary keys of the table. + * @param string $table The table to perform the search-replace on. + * @param string $old The string to search for. + * @param string $new The string to replace with. + * @param string[] $additional_where The additional WHERE clauses to apply. + * @return int The number of replacements made. + */ + 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 +840,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 ) { @@ -743,6 +852,12 @@ static function ( $key ) { return $count; } + /** + * Writes a set of rows to the export file as SQL INSERT statements. + * + * @param string $table The table to write the rows for. + * @param array $rows The rows to write. + */ private function write_sql_row_fields( $table, $rows ) { global $wpdb; @@ -813,6 +928,12 @@ private function write_sql_row_fields( $table, $rows ) { } } + /** + * Gets the primary keys, text columns, and all columns for a table. + * + * @param string $table The table to get the columns for. + * @return array A tuple containing the primary keys, text columns, and all columns. + */ private static function get_columns( $table ) { global $wpdb; @@ -840,6 +961,12 @@ private static function get_columns( $table ) { return array( $primary_keys, $text_columns, $all_columns ); } + /** + * Checks if a column type is a text type. + * + * @param string $type The column type to check. + * @return bool Whether the column type is a text type. + */ private static function is_text_col( $type ) { foreach ( array( 'text', 'varchar' ) as $token ) { if ( false !== stripos( $type, $token ) ) { @@ -850,6 +977,12 @@ private static function is_text_col( $type ) { return false; } + /** + * Escapes a string for a MySQL LIKE statement. + * + * @param string $old The string to escape. + * @return string The escaped string. + */ private static function esc_like( $old ) { global $wpdb; @@ -945,17 +1078,18 @@ private function get_colors( $assoc_args, $colors ) { return $colors; } - /* + /** * Logs the difference between old match and new replacement for SQL replacement. * - * @param string $col Column being processed. - * @param array $primary_keys Primary keys for table. - * @param string $table Table being processed. - * @param string $old Old value to match. - * @param string $new New value to replace the old value with. + * @param string $col Column being processed. + * @param string[] $primary_keys Primary keys for table. + * @param string $table Table being processed. + * @param string $old Old value to match. + * @param string $new New value to replace the old value with. + * @param string[] $clauses The WHERE clauses to apply. * @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 +1100,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; @@ -986,15 +1121,15 @@ private function log_sql_diff( $col, $primary_keys, $table, $old, $new ) { return count( $results ); } - /* + /** * Logs the difference between old matches and new replacements at the end of a PHP (regex) replacement of a database row. * - * @param string $col Column being processed. - * @param array $keys Associative array (or object) of primary key names and their values for the row being processed. - * @param string $table Table being processed. - * @param string $old Old value to match. - * @param string $new New value to replace the old value with. - * @param array $log_data Array of data strings before replacements. + * @param string $col Column being processed. + * @param array|object $keys Associative array (or object) of primary key names and their values for the row being processed. + * @param string $table Table being processed. + * @param string $old Old value to match. + * @param string $new New value to replace the old value with. + * @param string[] $log_data Array of data strings before replacements. */ private function log_php_diff( $col, $keys, $table, $old, $new, $log_data ) { if ( $this->regex ) { @@ -1120,14 +1255,14 @@ static function ( $m ) use ( $matches ) { return array( $old_bits, $new_bits ); } - /* + /** * Outputs the log strings. * - * @param string $col Column being processed. - * @param array $keys Associative array (or object) of primary key names and their values for the row being processed. - * @param string $table Table being processed. - * @param array $old_bits Array of old match log strings. - * @param array $new_bits Array of new replacement log strings. + * @param string $col Column being processed. + * @param array|object $keys Associative array (or object) of primary key names and their values for the row being processed. + * @param string $table Table being processed. + * @param string[] $old_bits Array of old match log strings. + * @param string[] $new_bits Array of new replacement log strings. */ private function log_write( $col, $keys, $table, $old_bits, $new_bits ) { if ( ! $this->log_handle ) { @@ -1149,4 +1284,57 @@ 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" ); } + + /** + * Develops the WHERE specifications from a string. + * + * @param string|null $str_specs The string of WHERE specifications. + * @return array The WHERE specifications as an array. + */ + private function develop_where_specs( $str_specs ) { + global $wpdb; + $specs = array_filter( explode( ';', (string) $str_specs ) ); + $clauses = []; + foreach ( $specs as $spec ) { + $parts = array_map( 'trim', explode( ':', $spec, 3 ) ); + if ( count( $parts ) < 3 ) { + continue; + } + list( $tables, $cols, $conditions ) = $parts; + $tables = array_filter( array_map( 'trim', explode( ',', $tables ) ) ); + $cols = array_filter( array_map( 'trim', explode( ',', $cols ) ) ) ?: [ '*' ]; + foreach ( $tables as $table ) { + foreach ( $cols as $col ) { + $clauses[ empty( $wpdb->{$table} ) ? $table : $wpdb->{$table} ][ $col ][] = $conditions; + } + } + } + + return $clauses; + } + + /** + * Excludes revisions from the search-replace operation. + */ + private 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' ) . ')'; + } + + /** + * Gets the WHERE clauses for a given table and column. + * + * @param string $table The table to get the clauses for. + * @param string|null $column The column to get the clauses for. + * @return string[] The WHERE clauses. + */ + private 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..6c09c0f2 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -57,6 +57,13 @@ class SearchReplacer { */ private $max_recursion; + /** + * @var callable|string|bool Callback function or method to be invoked during search/replace operations. + * If set to a callable or a string (function name), this will be called for each replacement. + * If false, no callback is used. + */ + private $callback; + /** * @param string $from String we're looking to replace. * @param string $to What we want it to be replaced with. @@ -66,8 +73,9 @@ 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 callable|string|false $callback The callback function to invoke. */ - 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 +84,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 @@ -88,19 +97,28 @@ public function __construct( $from, $to, $recurse_objects = false, $regex = fals * Ignores any serialized objects unless $recurse_objects is set to true. * * @param array|string $data The data to operate on. + * @param array $opts Additional options for the replacement passed through to the callback + * An array like: ['table' => $table, 'col' => $col, 'key' => $primary_keys] * @param bool $serialised Does the value of $data need to be unserialized? + * @param array $opts Options for the callback. * - * @return array The original array with all elements replaced as needed. + * @return array|string 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. + * The main workhorse of the run method. + * + * @param mixed $data The data to operate on. + * @param bool $serialised Does the value of $data need to be unserialized? + * @param int $recursion_level Current recursion depth within the original data. + * @param array $visited_data Data that has been seen in previous recursion iterations. + * @param array $opts Options for the callback. + * @return mixed The original data with all elements replaced as needed. */ - 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 +210,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 +223,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, null, $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 returned false. Stopping operation.' ); + } elseif ( is_wp_error( $result ) ) { + $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.' ); + } + } + + $data = $result; if ( $this->logging && $old_data !== $data ) { $this->log_data[] = $old_data; } @@ -238,7 +279,7 @@ public function clear_log_data() { /** * Get the PCRE error constant name from an error value. * - * @param integer $error Error code. + * @param int|null $error Error code. * @return string Error constant name. */ private function preg_error_message( $error ) {