From 446e05b2702b7720e67688c7c3ef114af4f5c188 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 10 Jul 2025 17:10:50 +0200 Subject: [PATCH] PHPStan level 3 --- phpstan.neon.dist | 10 ++ src/Search_Replace_Command.php | 219 ++++++++++++++++++++++++--------- src/WP_CLI/SearchReplacer.php | 43 ++++++- 3 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 phpstan.neon.dist diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..9b1f35e2 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,10 @@ +parameters: + level: 3 + paths: + - src + - search-replace-command.php + scanDirectories: + - vendor/wp-cli/wp-cli/php + scanFiles: + - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + treatPhpDocTypesAsCertain: false diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index cc5178b7..6e3f85cf 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -9,28 +9,114 @@ class Search_Replace_Command extends WP_CLI_Command { + /** + * @var bool + */ private $dry_run; + + /** + * @var false|resource + */ private $export_handle = false; + + /** + * @var int + */ private $export_insert_size; + + /** + * @var bool + */ private $recurse_objects; + + /** + * @var bool + */ private $regex; + + /** + * @var string + */ private $regex_flags; + + /** + * @var string + */ private $regex_delimiter; + + /** + * @var int + */ private $regex_limit = -1; + + /** + * @var string[] + */ private $skip_tables; + + /** + * @var string[] + */ private $skip_columns; + + /** + * @var string[] + */ private $include_columns; + + /** + * @var string + */ private $format; + + /** + * @var bool + */ private $report; + + /** + * @var bool + */ private $verbose; + /** + * @var bool + */ private $report_changed_only; - private $log_handle = null; + + /** + * @var false|resource + */ + private $log_handle = null; + + /** + * @var int + */ private $log_before_context = 40; - private $log_after_context = 40; - private $log_prefixes = array( '< ', '> ' ); + + /** + * @var int + */ + private $log_after_context = 40; + + /** + * @var array{0: string, 1: string} + */ + private $log_prefixes = array( '< ', '> ' ); + + /** + * @var array> + */ private $log_colors; + + /** + * @var string|false + */ private $log_encoding; + + /** + * @var float + */ private $start_time; /** @@ -169,6 +255,9 @@ class Search_Replace_Command extends WP_CLI_Command { * else * wp search-replace 'http://example.com' 'http://example.test' --recurse-objects --skip-columns=guid --skip-tables=wp_users * fi + * + * @param array $args Positional arguments. + * @param array{'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. */ public function __invoke( $args, $assoc_args ) { global $wpdb; @@ -176,17 +265,18 @@ public function __invoke( $args, $assoc_args ) { $new = array_shift( $args ); $total = 0; $report = array(); - $this->dry_run = Utils\get_flag_value( $assoc_args, 'dry-run' ); - $php_only = Utils\get_flag_value( $assoc_args, 'precise' ); + $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' ); + $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 ) { - $default_regex_delimiter = false; - $this->regex_flags = Utils\get_flag_value( $assoc_args, 'regex-flags', false ); - $this->regex_delimiter = Utils\get_flag_value( $assoc_args, 'regex-delimiter', '' ); + $this->regex_flags = Utils\get_flag_value( $assoc_args, 'regex-flags', false ); + $this->regex_delimiter = Utils\get_flag_value( $assoc_args, 'regex-delimiter', '' ); if ( '' === $this->regex_delimiter ) { $this->regex_delimiter = chr( 1 ); $default_regex_delimiter = true; @@ -213,7 +303,7 @@ public function __invoke( $args, $assoc_args ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Preventing a warning when testing the regex. if ( false === @preg_match( $search_regex, '' ) ) { $error = error_get_last(); - $preg_error_message = ( ! empty( $error ) && array_key_exists( 'message', $error ) ) ? "\n{$error['message']}." : ''; + $preg_error_message = ! empty( $error ) ? "\n{$error['message']}." : ''; if ( $default_regex_delimiter ) { $flags_msg = $this->regex_flags ? "flags '$this->regex_flags'" : 'no flags'; $msg = "The regex pattern '$old' with default delimiter 'chr(1)' and {$flags_msg} fails."; @@ -242,16 +332,16 @@ public function __invoke( $args, $assoc_args ) { $this->export_handle = STDOUT; $this->verbose = false; } else { - $this->export_handle = @fopen( $assoc_args['export'], 'w' ); + $this->export_handle = @fopen( $export, 'w' ); if ( false === $this->export_handle ) { $error = error_get_last(); - WP_CLI::error( sprintf( 'Unable to open export file "%s" for writing: %s.', $assoc_args['export'], $error['message'] ) ); + WP_CLI::error( sprintf( 'Unable to open export file "%s" for writing: %s.', $export, $error['message'] ?? '(unknown error)' ) ); } } $export_insert_size = Utils\get_flag_value( $assoc_args, 'export_insert_size', 50 ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- See the code, this is deliberate. if ( (int) $export_insert_size == $export_insert_size && $export_insert_size > 0 ) { - $this->export_insert_size = $export_insert_size; + $this->export_insert_size = (int) $export_insert_size; } $php_only = true; } @@ -261,54 +351,53 @@ public function __invoke( $args, $assoc_args ) { if ( true === $log || '-' === $log ) { $this->log_handle = STDOUT; } else { - $this->log_handle = @fopen( $assoc_args['log'], 'w' ); + $this->log_handle = @fopen( $log, 'w' ); if ( false === $this->log_handle ) { $error = error_get_last(); - WP_CLI::error( sprintf( 'Unable to open log file "%s" for writing: %s.', $assoc_args['log'], $error['message'] ) ); + WP_CLI::error( sprintf( 'Unable to open log file "%s" for writing: %s.', $log, $error['message'] ?? '(unknown error)' ) ); } } - if ( $this->log_handle ) { - $before_context = Utils\get_flag_value( $assoc_args, 'before_context' ); - if ( null !== $before_context && preg_match( '/^[0-9]+$/', $before_context ) ) { - $this->log_before_context = (int) $before_context; - } - $after_context = Utils\get_flag_value( $assoc_args, 'after_context' ); - if ( null !== $after_context && preg_match( '/^[0-9]+$/', $after_context ) ) { - $this->log_after_context = (int) $after_context; - } + $before_context = Utils\get_flag_value( $assoc_args, 'before_context' ); + if ( null !== $before_context && preg_match( '/^[0-9]+$/', $before_context ) ) { + $this->log_before_context = (int) $before_context; + } - $log_prefixes = getenv( 'WP_CLI_SEARCH_REPLACE_LOG_PREFIXES' ); - if ( false !== $log_prefixes && preg_match( '/^([^,]*),([^,]*)$/', $log_prefixes, $matches ) ) { - $this->log_prefixes = array( $matches[1], $matches[2] ); - } + $after_context = Utils\get_flag_value( $assoc_args, 'after_context' ); + if ( null !== $after_context && preg_match( '/^[0-9]+$/', $after_context ) ) { + $this->log_after_context = (int) $after_context; + } - if ( STDOUT === $this->log_handle ) { - $default_log_colors = array( - 'log_table_column_id' => '%B', - 'log_old' => '%R', - 'log_new' => '%G', - ); - } else { - $default_log_colors = array( - 'log_table_column_id' => '', - 'log_old' => '', - 'log_new' => '', - ); - } + $log_prefixes = getenv( 'WP_CLI_SEARCH_REPLACE_LOG_PREFIXES' ); + if ( false !== $log_prefixes && preg_match( '/^([^,]*),([^,]*)$/', $log_prefixes, $matches ) ) { + $this->log_prefixes = array( $matches[1], $matches[2] ); + } - $log_colors = getenv( 'WP_CLI_SEARCH_REPLACE_LOG_COLORS' ); - if ( false !== $log_colors && preg_match( '/^([^,]*),([^,]*),([^,]*)$/', $log_colors, $matches ) ) { - $default_log_colors = array( - 'log_table_column_id' => $matches[1], - 'log_old' => $matches[2], - 'log_new' => $matches[3], - ); - } + if ( STDOUT === $this->log_handle ) { + $default_log_colors = array( + 'log_table_column_id' => '%B', + 'log_old' => '%R', + 'log_new' => '%G', + ); + } else { + $default_log_colors = array( + 'log_table_column_id' => '', + 'log_old' => '', + 'log_new' => '', + ); + } - $this->log_colors = $this->get_colors( $assoc_args, $default_log_colors ); - $this->log_encoding = 0 === strpos( $wpdb->charset, 'utf8' ) ? 'UTF-8' : false; + $log_colors = getenv( 'WP_CLI_SEARCH_REPLACE_LOG_COLORS' ); + if ( false !== $log_colors && preg_match( '/^([^,]*),([^,]*),([^,]*)$/', $log_colors, $matches ) ) { + $default_log_colors = array( + 'log_table_column_id' => $matches[1], + 'log_old' => $matches[2], + 'log_new' => $matches[3], + ); } + + $this->log_colors = $this->get_colors( $assoc_args, $default_log_colors ); + $this->log_encoding = 0 === strpos( $wpdb->charset, 'utf8' ) ? 'UTF-8' : false; } $this->report = Utils\get_flag_value( $assoc_args, 'report', true ); @@ -383,6 +472,8 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::log( sprintf( 'Checking: %s.%s', $table, $col ) ); } + $serial_row = false; + if ( ! $php_only && ! $this->regex ) { $col_sql = self::esc_sql_ident( $col ); $wpdb->last_error = ''; @@ -422,7 +513,7 @@ public function __invoke( $args, $assoc_args ) { } if ( 'count' === $this->format ) { - WP_CLI::line( $total ); + WP_CLI::line( (string) $total ); return; } @@ -698,8 +789,9 @@ private function write_sql_row_fields( $table, $rows ) { if ( method_exists( $wpdb, 'remove_placeholder_escape' ) ) { // since 4.8.3 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- verified inputs above - $sql = $wpdb->remove_placeholder_escape( $wpdb->prepare( $sql, array_values( $values ) ) ); + + // @phpstan-ignore method.nonObject + $sql = $wpdb->remove_placeholder_escape( $wpdb->prepare( $sql, array_values( $values ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- verified inputs above } else { // 4.8.2 or less // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- verified inputs above @@ -766,8 +858,9 @@ private static function esc_like( $old ) { // 4.0 $old = $wpdb->esc_like( $old ); } else { - // phpcs:ignore WordPress.WP.DeprecatedFunctions.like_escapeFound -- BC-layer for WP 3.9 or less. - $old = like_escape( esc_sql( $old ) ); // Note: this double escaping is actually necessary, even though `esc_like()` will be used in a `prepare()`. + // Note: this double escaping is actually necessary, even though `esc_like()` will be used in a `prepare()`. + // @phpstan-ignore function.deprecated + $old = like_escape( esc_sql( $old ) ); // phpcs:ignore WordPress.WP.DeprecatedFunctions.like_escapeFound -- BC-layer for WP 3.9 or less. } return $old; @@ -777,8 +870,9 @@ private static function esc_like( $old ) { * Escapes (backticks) MySQL identifiers (aka schema object names) - i.e. column names, table names, and database/index/alias/view etc names. * See https://dev.mysql.com/doc/refman/5.5/en/identifiers.html * - * @param string|array $idents A single identifier or an array of identifiers. - * @return string|array An escaped string if given a string, or an array of escaped strings if given an array of strings. + * @param string|string[] $idents A single identifier or an array of identifiers. + * @return string|string[] An escaped string if given a string, or an array of escaped strings if given an array of strings. + * @phpstan-return ($idents is string ? string : string[]) */ private static function esc_sql_ident( $idents ) { $backtick = static function ( $v ) { @@ -794,8 +888,9 @@ private static function esc_sql_ident( $idents ) { /** * Puts MySQL string values in single quotes, to avoid them being interpreted as column names. * - * @param string|array $values A single value or an array of values. - * @return string|array A quoted string if given a string, or an array of quoted strings if given an array of strings. + * @param string|string[] $values A single value or an array of values. + * @return string|string[] A quoted string if given a string, or an array of quoted strings if given an array of strings. + * @phpstan-return ($values is string ? string : string[]) */ private static function esc_sql_value( $values ) { $quote = static function ( $v ) { @@ -1035,6 +1130,10 @@ static function ( $m ) use ( $matches ) { * @param array $new_bits Array of new replacement log strings. */ private function log_write( $col, $keys, $table, $old_bits, $new_bits ) { + if ( ! $this->log_handle ) { + return; + } + $id_log = $keys ? ( ':' . implode( ',', (array) $keys ) ) : ''; $table_column_id_log = $this->log_colors['log_table_column_id'][0] . $table . '.' . $col . $id_log . $this->log_colors['log_table_column_id'][1]; diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 06fada15..8c5ee951 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -7,15 +7,54 @@ class SearchReplacer { + /** + * @var string + */ private $from; + + /** + * @var string + */ private $to; + + /** + * @var bool + */ private $recurse_objects; + + /** + * @var bool + */ private $regex; + + /** + * @var string + */ private $regex_flags; + + /** + * @var string + */ private $regex_delimiter; + + /** + * @var int + */ private $regex_limit; + + /** + * @var bool + */ private $logging; - private $log_data; + + /** + * @var string[] + */ + private $log_data = []; + + /** + * @var int + */ private $max_recursion; /** @@ -183,7 +222,7 @@ private function run_recursively( $data, $serialised, $recursion_level = 0, $vis /** * Gets existing data saved for this run when logging. - * @return array Array of data strings, prior to replacements. + * @return string[] Array of data strings, prior to replacements. */ public function get_log_data() { return $this->log_data;