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: + """ + >|false + */ + private $where; + + /** + * @var bool + */ + private $callback; + /** * Searches/replaces strings in the database. * @@ -182,9 +192,18 @@ 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 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. @@ -261,17 +283,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 +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, true ) ) { + $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..76602944 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. @@ -67,7 +72,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 +81,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 +98,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 +198,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 +211,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 = $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; }