diff --git a/.github/workflows/phpstan-static-analysis.yml b/.github/workflows/phpstan-static-analysis.yml index 3258b56ff3baa..1c461a9ac54dd 100644 --- a/.github/workflows/phpstan-static-analysis.yml +++ b/.github/workflows/phpstan-static-analysis.yml @@ -19,6 +19,9 @@ on: - 'phpstan.neon.dist' - 'tests/phpstan/base.neon' - 'tests/phpstan/baselines/**' + # These files configure the tests of the PHPStan extensions. + - 'tests/phpstan/phpunit.xml.dist' + - 'tests/phpstan/Tests/tests.neon' # Confirm any changes to relevant workflow files. - '.github/workflows/phpstan-static-analysis.yml' - '.github/workflows/reusable-phpstan-static-analysis-v1.yml' diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index 321f98189cdf9..843000bb660e0 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -30,6 +30,7 @@ jobs: # - Logs debug information. # - Installs Composer dependencies. # - Make Composer packages available globally. + # - Runs the tests of the WordPress-specific PHPStan extensions. # - Builds WordPress. # - Configures caching for PHPStan static analysis scans. # - Runs PHPStan static analysis. @@ -75,6 +76,12 @@ jobs: - name: Make Composer packages available globally run: echo "${PWD}/vendor/bin" >> "$GITHUB_PATH" + # The extensions in `tests/phpstan` are tested against fixtures rather than against + # core, so a defect in one is reported here as a failing test rather than as a + # change in what the analysis below reports. + - name: Run the tests of the PHPStan extensions + run: composer run phpstan:test + # The directory holds more than the analysis results. PHPStan also stores what it read out of # each source file there, the docblocks and signatures it found, keyed by that file's contents # and nothing else. The extensions in `tests/phpstan` change what reading a file yields without diff --git a/composer.json b/composer.json index e20f4c7806b6e..81037a4be45cb 100644 --- a/composer.json +++ b/composer.json @@ -77,6 +77,7 @@ "scripts": { "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G", "phpstan:baselines": [ "Composer\\Config::disableProcessTimeout", "@php ./tests/phpstan/generate-baselines.php" ], + "phpstan:test": "@php ./vendor/phpunit/phpunit/phpunit -c tests/phpstan/phpunit.xml.dist", "compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source", "format": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf --report=summary,source", "lint": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --report=summary,source", diff --git a/package.json b/package.json index d8e955f0d9809..130c7b9278843 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan --", "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --", + "typecheck:php:test": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:test --", "gutenberg:copy": "node tools/gutenberg/copy.js", "gutenberg:verify": "node tools/gutenberg/utils.js", "gutenberg:download": "node tools/gutenberg/download.js && npm run build:gutenberg" diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 9f15c0c3670d6..1769542298f25 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -125,7 +125,11 @@ ############################################################################# --> - + + + + + /tests/phpunit/* - - warning - warning @@ -313,9 +314,6 @@ - - /tests/phpunit/tests/db\.php - /tests/phpunit/tests/db\.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 738e7d1721d2e..9a887bde60196 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -24,6 +24,8 @@ includes: # Regenerate with `composer phpstan:baselines`, which rewrites both the files # and the list between the markers. Do not edit that list by hand. # phpstan:baselines start + - tests/phpstan/baselines/WordPress.DB.PreparedSQL.InterpolatedNotPrepared.neon + - tests/phpstan/baselines/WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare.neon - tests/phpstan/baselines/argument.type.neon - tests/phpstan/baselines/argument.unresolvableType.neon - tests/phpstan/baselines/assign.propertyType.neon diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 17a4a190cdbbb..ffc0d58a77467 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3810,7 +3810,6 @@ public function should_suggest_persistent_object_cache() { // With InnoDB the `TABLE_ROWS` are estimates, which are accurate enough and faster to retrieve than individual `COUNT()` queries. $results = $wpdb->get_results( $wpdb->prepare( - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- This query cannot use interpolation. "SELECT TABLE_NAME AS 'table', TABLE_ROWS AS 'rows', SUM(data_length + index_length) as 'bytes' FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ('$table_names') GROUP BY TABLE_NAME;", DB_NAME ), diff --git a/src/wp-admin/includes/export.php b/src/wp-admin/includes/export.php index aa3753cb24b4c..f492a76adb73f 100644 --- a/src/wp-admin/includes/export.php +++ b/src/wp-admin/includes/export.php @@ -108,7 +108,6 @@ function export_wp( $args = array() ) { $post_types = get_post_types( array( 'can_export' => true ) ); $esses = array_fill( 0, count( $post_types ), '%s' ); - // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $where = $wpdb->prepare( "{$wpdb->posts}.post_type IN (" . implode( ',', $esses ) . ')', $post_types ); } diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index d175c65e3649c..d862621d3c1b1 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -591,7 +591,7 @@ function populate_options( array $options = array() ) { ); $keys = "'" . implode( "', '", array_keys( $options ) ) . "'"; - $existing_options = $wpdb->get_col( "SELECT option_name FROM $wpdb->options WHERE option_name in ( $keys )" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $existing_options = $wpdb->get_col( "SELECT option_name FROM $wpdb->options WHERE option_name in ( $keys )" ); $insert = ''; diff --git a/src/wp-admin/install-helper.php b/src/wp-admin/install-helper.php index d9aaaba58f932..b7aa0b720f24d 100644 --- a/src/wp-admin/install-helper.php +++ b/src/wp-admin/install-helper.php @@ -91,7 +91,6 @@ function maybe_create_table( $table_name, $create_ddl ) { function maybe_add_column( $table_name, $column_name, $create_ddl ) { global $wpdb; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cannot be prepared. Fetches columns for table names. foreach ( $wpdb->get_col( "DESC $table_name", 0 ) as $column ) { if ( $column === $column_name ) { return true; @@ -103,7 +102,6 @@ function maybe_add_column( $table_name, $column_name, $create_ddl ) { $wpdb->query( $create_ddl ); // We cannot directly tell whether this succeeded! - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cannot be prepared. Fetches columns for table names. foreach ( $wpdb->get_col( "DESC $table_name", 0 ) as $column ) { if ( $column === $column_name ) { return true; @@ -129,7 +127,6 @@ function maybe_add_column( $table_name, $column_name, $create_ddl ) { function maybe_drop_column( $table_name, $column_name, $drop_ddl ) { global $wpdb; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cannot be prepared. Fetches columns for table names. foreach ( $wpdb->get_col( "DESC $table_name", 0 ) as $column ) { if ( $column === $column_name ) { @@ -138,7 +135,6 @@ function maybe_drop_column( $table_name, $column_name, $drop_ddl ) { $wpdb->query( $drop_ddl ); // We cannot directly tell whether this succeeded! - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cannot be prepared. Fetches columns for table names. foreach ( $wpdb->get_col( "DESC $table_name", 0 ) as $column ) { if ( $column === $column_name ) { return false; @@ -185,7 +181,6 @@ function check_column( $table_name, $col_name, $col_type, $is_null = null, $key $diffs = 0; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cannot be prepared. Fetches columns for table names. $results = $wpdb->get_results( "DESC $table_name" ); foreach ( $results as $row ) { diff --git a/src/wp-includes/class-wp-comment-query.php b/src/wp-includes/class-wp-comment-query.php index ad706ba532114..b0f61b4ff54fa 100644 --- a/src/wp-includes/class-wp-comment-query.php +++ b/src/wp-includes/class-wp-comment-query.php @@ -864,7 +864,6 @@ protected function get_comment_ids() { // $field_value may be an array. $esses = array_fill( 0, count( (array) $field_value ), '%s' ); - // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $this->sql_clauses['where'][ $field_name ] = $wpdb->prepare( " {$wpdb->posts}.{$field_name} IN (" . implode( ',', $esses ) . ')', $field_value ); } } @@ -887,7 +886,6 @@ protected function get_comment_ids() { $esses = array_fill( 0, count( $q_values ), '%s' ); - // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $this->sql_clauses['where'][ $field_name ] = $wpdb->prepare( " {$wpdb->posts}.{$field_name} IN (" . implode( ',', $esses ) . ')', $q_values ); } } diff --git a/src/wp-includes/class-wp-meta-query.php b/src/wp-includes/class-wp-meta-query.php index 67e2d3d27ee0b..5fd1da658f242 100644 --- a/src/wp-includes/class-wp-meta-query.php +++ b/src/wp-includes/class-wp-meta-query.php @@ -668,11 +668,11 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) switch ( $meta_compare_key ) { case '=': case 'EXISTS': - $where = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $where = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); break; case 'LIKE': $meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%'; - $where = $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $where = $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); break; case 'IN': $meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ')'; @@ -688,7 +688,7 @@ public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) $cast = ''; $meta_key = "$alias.meta_key"; } - $where = $wpdb->prepare( "$meta_key $operator $cast %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $where = $wpdb->prepare( "$meta_key $operator $cast %s", trim( $clause['key'] ) ); break; case '!=': diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b6847266eb162..cf14301ae8f59 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4604,7 +4604,7 @@ function _wp_batch_update_comment_type() { "UPDATE {$wpdb->comments} SET comment_type = 'comment' WHERE comment_type = '' - AND comment_ID IN ({$comment_id_list})" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + AND comment_ID IN ({$comment_id_list})" ); // Make sure to clean the comment cache. diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index aabf7bc69db79..0b651fc99cb73 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -1974,7 +1974,7 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo $join = apply_filters( "get_{$adjacent}_post_join", $join, $in_same_term, $excluded_terms, $taxonomy, $post ); // Prepare the where clause for the adjacent post query. - $where_prepared = $wpdb->prepare( "WHERE (p.post_date $comparison_operator %s OR (p.post_date = %s AND p.ID $comparison_operator %d)) AND p.post_type = %s $where", $current_post_date, $current_post_date, $post->ID, $post->post_type ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $comparison_operator is a string literal, either '<' or '>'. + $where_prepared = $wpdb->prepare( "WHERE (p.post_date $comparison_operator %s OR (p.post_date = %s AND p.ID $comparison_operator %d)) AND p.post_type = %s $where", $current_post_date, $current_post_date, $post->ID, $post->post_type ); /** * Filters the WHERE clause in the SQL for an adjacent post query. diff --git a/src/wp-includes/ms-site.php b/src/wp-includes/ms-site.php index 71b9823728d7a..170713f82f3bd 100644 --- a/src/wp-includes/ms-site.php +++ b/src/wp-includes/ms-site.php @@ -840,7 +840,7 @@ function wp_uninitialize_site( $site_id ) { $drop_tables = apply_filters( 'wpmu_drop_tables', $tables, $site->id ); foreach ( (array) $drop_tables as $table ) { - $wpdb->query( "DROP TABLE IF EXISTS `$table`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( "DROP TABLE IF EXISTS `$table`" ); } /** diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index efe5709c632de..02bdbc44f16b9 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -440,7 +440,6 @@ function wp_set_option_autoload_values( array $options ) { * Determine the relevant options that do not already use the given autoload value. * If no options are returned, no need to update. */ - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $options_to_update = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM $wpdb->options $where", $where_args ) ); if ( ! $options_to_update ) { return $results; diff --git a/src/wp-includes/update.php b/src/wp-includes/update.php index b70220b79067c..11897a9e4296b 100644 --- a/src/wp-includes/update.php +++ b/src/wp-includes/update.php @@ -119,7 +119,6 @@ function wp_version_check( $extra_stats = array(), $force_check = false ) { $table_names = implode( "','", $wpdb->tables() ); $myisam_tables = $wpdb->get_results( $wpdb->prepare( - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- This query cannot use interpolation. "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ('$table_names') AND ENGINE = %s;", DB_NAME, 'MyISAM' diff --git a/tests/phpstan/PreparedSQLPlaceholdersRule.php b/tests/phpstan/PreparedSQLPlaceholdersRule.php new file mode 100644 index 0000000000000..58a3be231c426 --- /dev/null +++ b/tests/phpstan/PreparedSQLPlaceholdersRule.php @@ -0,0 +1,376 @@ +prepare()` call. + * + * A port of the `UnfinishedPrepare` check of the WordPress Coding Standards' + * `WordPress.DB.PreparedSQLPlaceholders` sniff, which it replaces. The error carries + * that code as its identifier. + * + * @link https://github.com/WordPress/WordPress-Coding-Standards/blob/3.4.1/WordPress/Sniffs/DB/PreparedSQLPlaceholdersSniff.php + * + * @package WordPress + */ + +declare(strict_types=1); + +namespace WordPress\PHPStan; + +use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\BinaryOp\Concat; +use PhpParser\Node\Expr\CallLike; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\InterpolatedStringPart; +use PhpParser\Node\Name; +use PhpParser\Node\Scalar\InterpolatedString; +use PhpParser\Node\Scalar\String_; +use PHPStan\Analyser\Scope; +use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; + +/** + * Reports a `$wpdb->prepare()` call that is passed replacements but whose query + * holds no placeholder for them. + * + * The one exception is a query building its placeholders for an `IN` clause with + * `implode( ',', array_fill( 0, count( $values ), '%s' ) )`, which the sniff + * recognizes as a placeholder of variable length. + * + * @implements Rule + */ +final class PreparedSQLPlaceholdersRule implements Rule { + + /** + * Error identifier, the code of the sniff check this rule ports. + */ + public const IDENTIFIER = 'WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare'; + + /** + * Matches a placeholder `$wpdb->prepare()` supports, as the sniff defines it. + * + * Written without delimiters and modifiers so that it can be embedded; use it + * with the `x` modifier. + */ + public const PLACEHOLDER_REGEX = '(?: + (? + */ + public function processNode( Node $node, Scope $scope ): array { + $call = WpdbCall::fromNode( $node, $scope, array( 'prepare' ) ); + if ( null === $call ) { + return array(); + } + + $query = $call->getQueryArg(); + if ( null === $query ) { + return array(); + } + + $found = array( + 'text' => false, + 'placeholders' => 0, + 'in_clauses' => 0, + ); + $this->scan( $query->value, $found ); + + // A query passed in as a variable or a function call has nothing to examine. + if ( ! $found['text'] ) { + return array(); + } + + if ( 0 !== $found['placeholders'] ) { + return array(); + } + + // A query without placeholders needs no replacements, and the sniff warns + // about preparing it at all. Replacements passed anyway are what is reported here. + if ( 1 === count( $call->getArgs() ) || 0 !== $found['in_clauses'] ) { + return array(); + } + + return array( + RuleErrorBuilder::message( + sprintf( + 'Replacement variables found, but no valid placeholders found in the query passed to %s.', + $call->getDisplay() + ) + ) + ->identifier( self::IDENTIFIER ) + ->line( $query->value->getEndLine() ) + ->build(), + ); + } + + /** + * Walks the query expression, counting what the sniff counts. + * + * @param Node $node Node within the query expression. + * @param array{text: bool, placeholders: int, in_clauses: int} $found Running counts. + */ + private function scan( Node $node, array &$found ): void { + if ( $node instanceof String_ ) { + $this->scanText( $node->value, $found ); + return; + } + + if ( $node instanceof InterpolatedString ) { + // Only the query text is examined; what an embedded variable holds is unknown. + $this->scanText( self::stripEmbeds( $node ), $found ); + return; + } + + if ( $node instanceof Variable ) { + return; + } + + if ( $node instanceof Concat ) { + $this->scanConcatenation( self::flattenConcatenation( $node ), $found ); + return; + } + + if ( $node instanceof FuncCall && self::isFunction( $node, 'sprintf' ) ) { + $this->scanSprintf( $node, $found ); + return; + } + + $this->scanChildren( $node, $found ); + } + + /** + * Walks the children of a node. + * + * @param Node $node Node. + * @param array{text: bool, placeholders: int, in_clauses: int} $found Running counts. + */ + private function scanChildren( Node $node, array &$found ): void { + foreach ( $node->getSubNodeNames() as $name ) { + $child = $node->$name; + + if ( $child instanceof Node ) { + $this->scan( $child, $found ); + } elseif ( is_array( $child ) ) { + foreach ( $child as $item ) { + if ( $item instanceof Node ) { + $this->scan( $item, $found ); + } + } + } + } + } + + /** + * Counts the placeholders and `IN` clauses in a piece of query text. + * + * @param string $text Query text. + * @param array{text: bool, placeholders: int, in_clauses: int} $found Running counts. + */ + private function scanText( string $text, array &$found ): void { + $found['text'] = true; + $found['placeholders'] += (int) preg_match_all( '`' . self::PLACEHOLDER_REGEX . '`x', $text ); + $found['in_clauses'] += (int) preg_match_all( self::IN_CLAUSE_REGEX, $text ); + } + + /** + * Walks the operands of a concatenation. + * + * An `implode()` building the placeholders of an `IN` clause, concatenated + * right after text ending in `IN (`, is a valid clause rather than text to + * count placeholders in. + * + * @param list $operands Operands, in order. + * @param array{text: bool, placeholders: int, in_clauses: int} $found Running counts. + */ + private function scanConcatenation( array $operands, array &$found ): void { + foreach ( $operands as $index => $operand ) { + if ( $index > 0 + && $operand instanceof FuncCall + && self::isFunction( $operand, 'implode' ) + && self::endsWithInClauseOpening( $operands[ $index - 1 ] ) + && self::isPlaceholderImplode( $operand ) + ) { + ++$found['in_clauses']; + continue; + } + + $this->scan( $operand, $found ); + } + } + + /** + * Walks a `sprintf()` call: its format is query text, its other arguments are not. + * + * A `sprintf()` passed named arguments cannot be analyzed, since the function + * is variadic, and is left alone entirely. + * + * @param FuncCall $node The `sprintf()` call. + * @param array{text: bool, placeholders: int, in_clauses: int} $found Running counts. + */ + private function scanSprintf( FuncCall $node, array &$found ): void { + $args = $node->getArgs(); + + foreach ( $args as $arg ) { + if ( null !== $arg->name ) { + return; + } + } + + if ( isset( $args[0] ) ) { + $this->scan( $args[0], $found ); + } + } + + /** + * Flattens a chain of concatenations into its operands, in order. + * + * @param Concat $node Concatenation. + * @return list + */ + private static function flattenConcatenation( Concat $node ): array { + $operands = array(); + + while ( true ) { + array_unshift( $operands, $node->right ); + + if ( ! $node->left instanceof Concat ) { + array_unshift( $operands, $node->left ); + break; + } + + $node = $node->left; + } + + return $operands; + } + + /** + * Determines whether an operand is query text ending in an opening `IN (`. + * + * @param Expr $operand Operand preceding an `implode()` call. + * @return bool + */ + private static function endsWithInClauseOpening( Expr $operand ): bool { + if ( $operand instanceof String_ ) { + $text = $operand->value; + } elseif ( $operand instanceof InterpolatedString ) { + $text = self::stripEmbeds( $operand ); + } else { + return false; + } + + return 1 === preg_match( self::IN_CLAUSE_OPENING_REGEX, $text ); + } + + /** + * Determines whether an `implode()` call builds a list of value placeholders: + * `implode( ',', array_fill( 0, count( $values ), '%s' ) )`. + * + * The sniff accepts `','` and `', '` as separator, and a single unquoted + * value placeholder as what is repeated. An identifier placeholder is not a + * value and is not accepted. + * + * @param FuncCall $implode The `implode()` call. + * @return bool + */ + private static function isPlaceholderImplode( FuncCall $implode ): bool { + $args = $implode->getArgs(); + if ( 2 !== count( $args ) ) { + return false; + } + + $separator = WpdbCall::findArg( $args, 1, 'separator' ); + if ( null === $separator + || ! $separator->value instanceof String_ + || ! in_array( $separator->value->value, array( ',', ', ' ), true ) + ) { + return false; + } + + $array = WpdbCall::findArg( $args, 2, 'array' ); + if ( null === $array || ! $array->value instanceof FuncCall || ! self::isFunction( $array->value, 'array_fill' ) ) { + return false; + } + + $value = WpdbCall::findArg( $array->value->getArgs(), 3, 'value' ); + if ( null === $value || ! $value->value instanceof String_ ) { + return false; + } + + return 1 === preg_match( '`^%[dfFs]$`', $value->value->value ); + } + + /** + * Determines whether a call is to the named function, in any namespace. + * + * @param FuncCall $node Call. + * @param string $name Function name, in lowercase. + * @return bool + */ + private static function isFunction( FuncCall $node, string $name ): bool { + return $node->name instanceof Name && strtolower( $node->name->getLast() ) === $name; + } + + /** + * Returns the literal text of an interpolated string, without its embedded + * variables. + * + * @param InterpolatedString $node String. + * @return string + */ + private static function stripEmbeds( InterpolatedString $node ): string { + $text = ''; + + foreach ( $node->parts as $part ) { + if ( $part instanceof InterpolatedStringPart ) { + $text .= $part->value; + } + } + + return $text; + } +} diff --git a/tests/phpstan/PreparedSQLRule.php b/tests/phpstan/PreparedSQLRule.php new file mode 100644 index 0000000000000..c142b5e0afa9e --- /dev/null +++ b/tests/phpstan/PreparedSQLRule.php @@ -0,0 +1,221 @@ +posts`. + * + * Strings inside a call to a function that escapes its argument for SQL, such as + * `esc_sql()`, are not examined, and neither is a nested `$wpdb` call, which is + * examined on its own. + * + * @implements Rule + */ +final class PreparedSQLRule implements Rule { + + /** + * Error identifier, the code of the sniff check this rule ports. + */ + public const IDENTIFIER = 'WordPress.DB.PreparedSQL.InterpolatedNotPrepared'; + + /** + * Functions whose call, arguments included, is not examined: those that escape + * a value for use in a query, and those whose result is escaped already. + */ + private const SKIPPED_FUNCTIONS = array( + 'absint', + 'esc_sql', + 'floatval', + 'intval', + 'like_escape', + 'count', + ); + + /** + * Returns the node type this rule processes. + * + * @return string + */ + public function getNodeType(): string { + return CallLike::class; + } + + /** + * Processes a call node. + * + * @param Node $node Call node. + * @param Scope $scope Analysis scope. + * @return list + */ + public function processNode( Node $node, Scope $scope ): array { + $call = WpdbCall::fromNode( $node, $scope, WpdbCall::QUERY_METHODS ); + if ( null === $call ) { + return array(); + } + + $query = $call->getQueryArg(); + if ( null === $query ) { + return array(); + } + + $errors = array(); + $this->scan( $query->value, $scope, $call, $errors ); + + return $errors; + } + + /** + * Walks the query expression, reporting the interpolated variables it finds. + * + * @param Node $node Node within the query expression. + * @param Scope $scope Analysis scope. + * @param WpdbCall $call The call whose query is examined. + * @param list $errors Errors found so far. + */ + private function scan( Node $node, Scope $scope, WpdbCall $call, array &$errors ): void { + if ( $node instanceof InterpolatedString ) { + foreach ( $node->parts as $part ) { + if ( ! $part instanceof Expr || self::isWpdbMember( $part, $scope ) ) { + continue; + } + + $errors[] = RuleErrorBuilder::message( + sprintf( + 'Use placeholders and $wpdb->prepare(); found interpolated variable %s in the query passed to %s.', + WpdbCall::print( $part ), + $call->getDisplay() + ) + ) + ->identifier( self::IDENTIFIER ) + ->line( $part->getStartLine() ) + ->build(); + } + + return; + } + + // A nested `$wpdb` call is examined when its own node is processed. + if ( null !== WpdbCall::fromNode( $node, $scope, WpdbCall::QUERY_METHODS ) ) { + return; + } + + if ( self::isSkippedCall( $node ) ) { + return; + } + + foreach ( $node->getSubNodeNames() as $name ) { + $child = $node->$name; + + if ( $child instanceof Node ) { + $this->scan( $child, $scope, $call, $errors ); + } elseif ( is_array( $child ) ) { + foreach ( $child as $item ) { + if ( $item instanceof Node ) { + $this->scan( $item, $scope, $call, $errors ); + } + } + } + } + } + + /** + * Determines whether a node is a call to a function whose call is not examined. + * + * The sniff recognizes the function by name wherever it appears, so a method of + * the same name is skipped as well. + * + * @param Node $node Node. + * @return bool + */ + private static function isSkippedCall( Node $node ): bool { + if ( $node instanceof FuncCall ) { + $name = $node->name instanceof Name ? $node->name->getLast() : null; + } elseif ( $node instanceof MethodCall || $node instanceof NullsafeMethodCall || $node instanceof StaticCall ) { + $name = $node->name instanceof Identifier ? $node->name->toString() : null; + } else { + return false; + } + + return null !== $name && in_array( strtolower( $name ), self::SKIPPED_FUNCTIONS, true ); + } + + /** + * Determines whether an interpolated expression reads a member of the database + * object, such as `$wpdb->posts` or `{$wpdb->get_blog_prefix()}`. + * + * The database object itself, interpolated bare, is not a member. + * + * @param Expr $expr Interpolated expression. + * @param Scope $scope Analysis scope. + * @return bool + */ + private static function isWpdbMember( Expr $expr, Scope $scope ): bool { + // The `${expr}` syntax interpolates the value of a variable named by the expression. + if ( $expr instanceof Variable && $expr->name instanceof Expr ) { + $expr = $expr->name; + } + + while ( true ) { + if ( $expr instanceof ArrayDimFetch ) { + $expr = $expr->var; + continue; + } + + if ( ! $expr instanceof PropertyFetch + && ! $expr instanceof NullsafePropertyFetch + && ! $expr instanceof MethodCall + && ! $expr instanceof NullsafeMethodCall + ) { + return false; + } + + $object = $expr->var; + + // `${wpdb->posts}` names the variable with a constant. + if ( $object instanceof ConstFetch && 'wpdb' === $object->name->toString() ) { + return true; + } + + if ( WpdbCall::isWpdb( $object, $scope ) ) { + return true; + } + + $expr = $object; + } + } +} diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index e263cd409036d..f9e614e217892 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -186,6 +186,35 @@ These identifiers are specific to WordPress, and can be ignored or baselined lik | `wordpress.hookDocReferenceHookMissing` | The referenced file exists, but documents no hook of that name. Either the reference is stale, or the canonical docblock has moved. | | `wordpress.hookParamCountMismatch` | The call passes a different number of arguments than the docblock documents `@param` tags for. Passing fewer risks an `ArgumentCountError` in a callback registered for the documented count; passing more silently drops the extra argument and leaves the documentation misleading. | +### Checks ported from the WordPress Coding Standards + +Two checks of the `WordPress.DB` sniffs are implemented as PHPStan rules, on the syntax tree rather than on tokens. They replace those checks, which are disabled in `phpcs.xml.dist`. Each reports under the code of the check it ports, which is the identifier to use with `@phpstan-ignore`. Comments written for PHP_CodeSniffer, such as `phpcs:ignore`, have no effect on them: + +| Identifier | What it means | +| --- | --- | +| `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` | A variable is interpolated into a double-quoted string or heredoc passed as the query of `$wpdb->query()`, `get_var()`, `get_col()`, `get_row()`, `get_results()` or `prepare()`. A property of the database object, such as `$wpdb->posts`, is allowed. Reported on the line the variable is on. | +| `WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare` | `$wpdb->prepare()` is passed replacements, but the query holds no placeholder for them. Building the placeholders of an `IN` clause with `implode( ',', array_fill( 0, count( $values ), '%s' ) )` is recognized. Reported on the last line of the query. | + +They go a little further than the sniffs in two ways. The database object is recognized by type as well as by name, so a property documented as a `wpdb` is examined like `$wpdb` is. And a query passed by name, as `query:`, is examined like one passed by position. + +### Testing the extensions + +The extensions have tests of their own under [`Tests/`](Tests), built on PHPStan's test harness. The two rules are run over fixture files and the errors they report are compared with the ones expected. The visitors and the return type extension are checked through the types PHPStan infers for the `assertType()` calls in their fixtures. Reference comments resolve against a small WordPress tree of fixtures in [`Tests/data/wordpress`](Tests/data/wordpress) rather than against `src`, so a broken reference can be tested without one existing in core. + +Run them with: + +```bash +npm run typecheck:php:test +``` + +or, outside the Docker environment: + +```bash +composer phpstan:test +``` + +They also run in CI, ahead of the analysis itself. + ## Ignoring and baselining errors As we adopt PHPStan iteratively, you may be faced with false positives due to legacy code, or code that is not worth changing at this time. diff --git a/tests/phpstan/Tests/ApplyFiltersDynamicFunctionReturnTypeExtensionTest.php b/tests/phpstan/Tests/ApplyFiltersDynamicFunctionReturnTypeExtensionTest.php new file mode 100644 index 0000000000000..b4169ad0cdc8e --- /dev/null +++ b/tests/phpstan/Tests/ApplyFiltersDynamicFunctionReturnTypeExtensionTest.php @@ -0,0 +1,49 @@ + + */ + public function data_file_asserts(): iterable { + yield from self::gatherAssertTypes( __DIR__ . '/data/apply-filters.php' ); + yield from self::gatherAssertTypes( __DIR__ . '/data/ApplyFiltersTestClass.php' ); + } + + /** + * @dataProvider data_file_asserts + * + * @param string $assert_type Kind of assertion. + * @param string $file Fixture file. + * @param mixed ...$args Assertion arguments. + */ + public function test_file_asserts( string $assert_type, string $file, ...$args ): void { + $this->assertFileAsserts( $assert_type, $file, ...$args ); + } + + /** + * Returns the configuration registering the extension and the visitors it relies on. + * + * @return list + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } +} diff --git a/tests/phpstan/Tests/GlobalDocBlockVisitorTest.php b/tests/phpstan/Tests/GlobalDocBlockVisitorTest.php new file mode 100644 index 0000000000000..afadddefdadd2 --- /dev/null +++ b/tests/phpstan/Tests/GlobalDocBlockVisitorTest.php @@ -0,0 +1,49 @@ + + */ + public function data_file_asserts(): iterable { + yield from self::gatherAssertTypes( __DIR__ . '/data/global-docblock.php' ); + yield from self::gatherAssertTypes( __DIR__ . '/data/GlobalDocBlockTestClass.php' ); + } + + /** + * @dataProvider data_file_asserts + * + * @param string $assert_type Kind of assertion. + * @param string $file Fixture file. + * @param mixed ...$args Assertion arguments. + */ + public function test_file_asserts( string $assert_type, string $file, ...$args ): void { + $this->assertFileAsserts( $assert_type, $file, ...$args ); + } + + /** + * Returns the configuration registering the visitor. + * + * @return list + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } +} diff --git a/tests/phpstan/Tests/HashNotationTypeTest.php b/tests/phpstan/Tests/HashNotationTypeTest.php new file mode 100644 index 0000000000000..7ea3d4518af50 --- /dev/null +++ b/tests/phpstan/Tests/HashNotationTypeTest.php @@ -0,0 +1,47 @@ + + */ + public function data_file_asserts(): iterable { + yield from self::gatherAssertTypes( __DIR__ . '/data/hash-notation.php' ); + } + + /** + * @dataProvider data_file_asserts + * + * @param string $assert_type Kind of assertion. + * @param string $file Fixture file. + * @param mixed ...$args Assertion arguments. + */ + public function test_file_asserts( string $assert_type, string $file, ...$args ): void { + $this->assertFileAsserts( $assert_type, $file, ...$args ); + } + + /** + * Returns the configuration registering the visitor. + * + * @return list + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } +} diff --git a/tests/phpstan/Tests/HashNotationVisitorTest.php b/tests/phpstan/Tests/HashNotationVisitorTest.php new file mode 100644 index 0000000000000..14051ea837f8a --- /dev/null +++ b/tests/phpstan/Tests/HashNotationVisitorTest.php @@ -0,0 +1,535 @@ + $expected Tag lines the docblocks are expected to gain. + */ + public function test_derived_tags( string $code, array $expected ): void { + $this->assertSame( $expected, self::derived_tags( $code ) ); + } + + /** + * @return array}> + */ + public function data_derived_tags(): array { + return array( + 'param hash is open with optional keys' => array( + self::fn( + ' + * @param array $args { + * Arguments. + * + * @type string $post_type Post type. Default \'post\'. + * @type int $post_author Post author ID. + * } + ' + ), + array( '@phpstan-param array{post_type?: string, post_author?: int, ...} $args' ), + ), + 'return hash is sealed with required keys' => array( + self::fn( + ' + * @return array { + * @type string $name Name. + * @type int $count Count. Default 0. + * } + ' + ), + array( '@phpstan-return array{name: string, count: int}' ), + ), + 'numbered keys are required in a param hash' => array( + self::fn( + ' + * @param array $size { + * @type int $0 Width. + * @type int $1 Height. + * } + ', + '( $size )' + ), + array( '@phpstan-param array{0: int, 1: int, ...} $size' ), + ), + 'Optional. marks a return key optional' => array( + self::fn( + ' + * @return array { + * @type string $name Name. + * @type int $count Optional. Count. + * @type string $slash Optional self closing slash, which is prose. + * } + ' + ), + array( '@phpstan-return array{name: string, count?: int, slash: string}' ), + ), + 'Optional. marks a numbered param key optional' => array( + self::fn( + ' + * @param array $size { + * @type int $0 Width. + * @type int $1 Optional. Height. + * } + ', + '( $size )' + ), + array( '@phpstan-param array{0: int, 1?: int, ...} $size' ), + ), + 'a lone variadic entry describes the values' => array( + self::fn( + ' + * @param array $callbacks { + * @type callable ...$0 Callbacks. + * } + ', + '( $callbacks )' + ), + array( '@phpstan-param array $callbacks' ), + ), + 'a variadic entry beside named keys opens a return hash' => array( + self::fn( + ' + * @return array { + * @type string $name Name. + * @type mixed ...$1 The rest. + * } + ' + ), + array( '@phpstan-return array{name: string, ...}' ), + ), + 'nested hashes nest' => array( + self::fn( + ' + * @param array $args { + * @type array $labels { + * @type string $name Name. + * @type string $singular Singular name. + * } + * @type bool $public Whether public. + * } + ' + ), + array( '@phpstan-param array{labels?: array{name?: string, singular?: string, ...}, public?: bool, ...} $args' ), + ), + 'a nested hash in a return is sealed too' => array( + self::fn( + ' + * @return array { + * @type array $labels { + * @type string $name Name. + * } + * } + ' + ), + array( '@phpstan-return array{labels: array{name: string}}' ), + ), + 'keys that are not identifiers are quoted' => array( + self::fn( + ' + * @return array { + * @type string $mime-type MIME type. + * @type string $ext Extension. + * } + ' + ), + array( "@phpstan-return array{'mime-type': string, ext: string}" ), + ), + 'Array is array' => array( + self::fn( + ' + * @param Array $args { + * @type string $name Name. + * } + ' + ), + array( '@phpstan-param array{name?: string, ...} $args' ), + ), + 'the bare member of a union takes the shape' => array( + self::fn( + ' + * @param string|array $args { + * @type string $name Name. + * } + * @return array|WP_Error { + * @type string $name Name. + * } + ' + ), + array( + '@phpstan-param string|array{name?: string, ...} $args', + '@phpstan-return array{name: string}|WP_Error', + ), + ), + 'a class takes the shape through an intersection' => array( + self::fn( + ' + * @return stdClass { + * @type string $name Name. + * } + ' + ), + array( '@phpstan-return stdClass&object{name: string}' ), + ), + 'an intersection inside a union is parenthesized' => array( + self::fn( + ' + * @return stdClass|false { + * @type string $name Name. + * } + ' + ), + array( '@phpstan-return (stdClass&object{name: string})|false' ), + ), + 'a bare object return is a shape' => array( + self::fn( + ' + * @return object { + * @type string $name Name. + * } + ' + ), + array( '@phpstan-return object{name: string}' ), + ), + 'a bare object param would have to stay open, so is skipped' => array( + self::fn( + ' + * @param object $args { + * @type string $name Name. + * } + ' + ), + array(), + ), + 'a class param stays open through the class' => array( + self::fn( + ' + * @param stdClass $args { + * @type string $name Name. + * } + ' + ), + array( '@phpstan-param stdClass&object{name?: string} $args' ), + ), + 'two bare members leave the target a guess' => array( + self::fn( + ' + * @return array|object { + * @type string $name Name. + * } + ' + ), + array(), + ), + 'two classes and no bare member leave the target a guess' => array( + self::fn( + ' + * @return WP_Post|WP_Term { + * @type string $name Name. + * } + ' + ), + array(), + ), + 'a type more specific than the hash is left alone' => array( + self::fn( + ' + * @param array $args { + * @type string $name Name. + * } + ' + ), + array(), + ), + 'a PHPDoc keyword is not a class' => array( + self::fn( + ' + * @return mixed { + * @type string $name Name. + * } + ' + ), + array(), + ), + 'a hand-written counterpart wins for its tag only' => array( + self::fn( + ' + * @param array $args { + * @type string $name Name. + * } + * @param array $other { + * @type string $name Name. + * } + * @phpstan-param array{name: non-empty-string} $args + * @return array { + * @type string $name Name. + * } + * @phpstan-return array{name: non-empty-string} + ', + '( $args, $other )' + ), + array( '@phpstan-param array{name?: string, ...} $other' ), + ), + 'a multi-line hand-written counterpart covers the name it ends with' => array( + self::fn( + ' + * @param array $args { + * @type string $name Name. + * } + * @phpstan-param array{ + * name: non-empty-string, + * } $args + ' + ), + array(), + ), + 'a conditional return naming a parameter is not a shape for it' => array( + self::fn( + ' + * @param array $args { + * @type string $name Name. + * } + * @phpstan-return ($args is array ? int : string) + ' + ), + array( '@phpstan-param array{name?: string, ...} $args' ), + ), + 'a by-reference parameter is skipped' => array( + self::fn( + ' + * @param array $args { + * @type string $name Name. + * } + ', + '( &$args )' + ), + array(), + ), + 'a param hash without a variable is skipped' => array( + self::fn( + ' + * @param array { + * @type string $name Name. + * } + ' + ), + array(), + ), + 'an unclosed hash is skipped' => array( + self::fn( + ' + * @param array $args { + * @type string $name Name. + * @return void + ' + ), + array(), + ), + 'an entry without a name is skipped' => array( + self::fn( + ' + * @param array $args { + * @type string Name. + * } + ' + ), + array(), + ), + 'an entry whose type does not parse is skipped' => array( + self::fn( + ' + * @param array $args { + * @type string, $name Name. + * } + ' + ), + array(), + ), + 'an entry with a nullable type is kept' => array( + self::fn( + ' + * @param array $args { + * @type ?string $name Name. + * @type (string|null)[] $names Names. + * } + ' + ), + array( '@phpstan-param array{name?: ?string, names?: (string|null)[], ...} $args' ), + ), + 'a var hash is left alone' => array( + ' array( + ' array( + self::fn( + ' + * @param array $args Arguments. See the @type tags elsewhere. + * @return array + ' + ), + array(), + ), + ); + } + + /** + * Tests that the rewritten docblock keeps everything it had, and gains its tags + * before the closing marker. + */ + public function test_rewritten_docblock_keeps_the_original_text(): void { + $code = self::fn( + ' + * Does a thing. + * + * @since 1.0.0 + * + * @param array $args { + * @type string $name Name. + * } + * @return void + ' + ); + + $docblocks = self::docblocks_after_visiting( $code ); + + $this->assertCount( 1, $docblocks ); + $this->assertStringContainsString( "Does a thing.\n", $docblocks[0] ); + $this->assertStringContainsString( "@since 1.0.0\n", $docblocks[0] ); + $this->assertStringContainsString( "@return void\n", $docblocks[0] ); + $this->assertStringEndsWith( " * @phpstan-param array{name?: string, ...} \$args\n */", $docblocks[0] ); + } + + /** + * Wraps a docblock body in a function declaration. + * + * @param string $body Docblock lines, each starting with ` * `. + * @param string $signature Parameter list. + * @return string + */ + private static function fn( string $body, string $signature = '( $args )' ): string { + return " + */ + private static function derived_tags( string $code ): array { + $before = self::phpstan_lines( self::docblocks( $code, false ) ); + $after = self::phpstan_lines( self::docblocks_after_visiting( $code ) ); + + return array_values( array_diff( $after, $before ) ); + } + + /** + * Returns the docblocks in a snippet after the visitor has run. + * + * @param string $code PHP source. + * @return list + */ + private static function docblocks_after_visiting( string $code ): array { + return self::docblocks( $code, true ); + } + + /** + * Returns the text of every docblock in a snippet. + * + * @param string $code PHP source. + * @param bool $visit Whether to run the visitor first. + * @return list + */ + private static function docblocks( string $code, bool $visit ): array { + $stmts = ( new ParserFactory() )->createForHostVersion()->parse( $code ); + self::assertNotNull( $stmts ); + + $collector = new class() extends NodeVisitorAbstract { + /** + * @var list + */ + public array $docblocks = array(); + + public function enterNode( Node $node ): ?Node { + $doc = $node->getDocComment(); + if ( null !== $doc ) { + $this->docblocks[] = $doc->getText(); + } + return null; + } + }; + + $traverser = new NodeTraverser(); + if ( $visit ) { + $traverser->addVisitor( new HashNotationVisitor() ); + } + $traverser->addVisitor( $collector ); + $traverser->traverse( $stmts ); + + return $collector->docblocks; + } + + /** + * Extracts the `@phpstan-*` tag lines from docblocks, without their furniture. + * + * @param list $docblocks Docblock texts. + * @return list + */ + private static function phpstan_lines( array $docblocks ): array { + $lines = array(); + foreach ( $docblocks as $docblock ) { + if ( preg_match_all( '#^\s*\*\s*(@phpstan-\S+ .*)$#m', $docblock, $matches ) > 0 ) { + foreach ( $matches[1] as $line ) { + $lines[] = rtrim( $line ); + } + } + } + return $lines; + } +} diff --git a/tests/phpstan/Tests/HookDocBlockTest.php b/tests/phpstan/Tests/HookDocBlockTest.php new file mode 100644 index 0000000000000..4a7975758ec5b --- /dev/null +++ b/tests/phpstan/Tests/HookDocBlockTest.php @@ -0,0 +1,104 @@ + + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } + + /** + * @dataProvider data_hook_names + * + * @param string $call Hook call expression. + * @param bool $identifiable Whether the hook name carries literal text. + * @param string $display How the name is rendered in messages. + */ + public function test_hook_names( string $call, bool $identifiable, string $display ): void { + $node = self::call( $call ); + + $this->assertSame( $identifiable, HookDocBlock::hasIdentifiableHookName( $node ) ); + $this->assertSame( $display, HookDocBlock::getHookNameDisplay( $node ) ); + } + + /** + * @return array + */ + public function data_hook_names(): array { + return array( + 'literal' => array( "apply_filters( 'the_content', \$c )", true, 'the_content' ), + 'interpolated with literal' => array( 'apply_filters( "pre_option_{$option}", $v )', true, 'pre_option_{$option}' ), + 'interpolated without literal' => array( 'apply_filters( "{$hook}", $v )', false, '{$hook}' ), + 'interpolated separators only' => array( 'apply_filters( "{$taxonomy}_{$field}", $v )', true, '{$taxonomy}_{$field}' ), + 'interpolated property fetch' => array( 'apply_filters( "manage_{$this->screen->id}_columns", $v )', true, 'manage_{...}_columns' ), + 'concatenated with literal' => array( "apply_filters( 'prefix_' . \$name, \$v )", true, 'prefix_$name' ), + 'concatenated variables' => array( 'apply_filters( $a . $b, $v )', false, '$a$b' ), + 'variable' => array( 'apply_filters( $hook_name, $v )', false, '$hook_name' ), + 'other expression' => array( 'apply_filters( wp_tests_name(), $v )', false, '...' ), + 'no arguments' => array( 'apply_filters()', false, '' ), + ); + } + + /** + * Tests that a filter resolving to a docblock without parameters is flagged, + * and an action is not. + */ + public function test_is_filter_missing_param_docs(): void { + $without = array( + 'kind' => 'inline', + 'resolved' => null, + 'paramCount' => 0, + 'problem' => null, + ); + $with = array_merge( $without, array( 'paramCount' => 1 ) ); + $unknown = array_merge( $without, array( 'paramCount' => null ) ); + + $this->assertTrue( HookDocBlock::isFilterMissingParamDocs( self::call( "apply_filters( 'a', \$v )" ), $without ) ); + $this->assertTrue( HookDocBlock::isFilterMissingParamDocs( self::call( "apply_filters_ref_array( 'a', \$v )" ), $without ) ); + $this->assertTrue( HookDocBlock::isFilterMissingParamDocs( self::call( "apply_filters_deprecated( 'a', \$v, '1.0' )" ), $without ) ); + $this->assertFalse( HookDocBlock::isFilterMissingParamDocs( self::call( "do_action( 'a', \$v )" ), $without ) ); + $this->assertFalse( HookDocBlock::isFilterMissingParamDocs( self::call( "apply_filters( 'a', \$v )" ), $with ) ); + $this->assertFalse( HookDocBlock::isFilterMissingParamDocs( self::call( "apply_filters( 'a', \$v )" ), $unknown ) ); + } + + /** + * Parses a hook call expression. + * + * @param string $call PHP expression. + * @return FuncCall + */ + private static function call( string $call ): FuncCall { + $stmts = ( new ParserFactory() )->createForHostVersion()->parse( 'findInstanceOf( $stmts, FuncCall::class ); + self::assertNotEmpty( $calls ); + + return $calls[0]; + } +} diff --git a/tests/phpstan/Tests/HookDocsVisitorTest.php b/tests/phpstan/Tests/HookDocsVisitorTest.php new file mode 100644 index 0000000000000..f238589d5c442 --- /dev/null +++ b/tests/phpstan/Tests/HookDocsVisitorTest.php @@ -0,0 +1,165 @@ + apply_filters( 'first_item', $value ), + 'second' => apply_filters( 'second_item', $value ), + ); + + /** + * Function docblock. + */ + function wp_tests_fn( $value ) { + return apply_filters( 'in_function', $value ); + } + + /** + * Argument docblock. + */ + wp_tests_fn( + /** + * Inner argument docblock. + */ + apply_filters( 'in_argument', $value ), + apply_filters( 'sibling_argument', $value ) + ); + + /** This filter is documented in wp-includes/canonical.php */ + $value = apply_filters( 'referenced', $value ); + PHP; + + $this->assertSame( + array( + 'on_statement' => 'Statement docblock.', + 'nested_in_statement' => 'Nested docblock.', + 'undocumented' => null, + 'in_condition' => 'Condition docblock.', + 'in_body' => null, + 'first_item' => 'First item docblock.', + 'second_item' => null, + 'in_function' => null, + 'in_argument' => 'Inner argument docblock.', + 'sibling_argument' => 'Argument docblock.', + 'referenced' => 'This filter is documented in wp-includes/canonical.php', + ), + self::attached_docblocks( $code ) + ); + } + + /** + * Tests that state does not leak from one traversal into the next. + */ + public function test_state_is_reset_between_traversals(): void { + $parser = ( new ParserFactory() )->createForHostVersion(); + $visitor = new HookDocsVisitor(); + $traverser = new NodeTraverser(); + $traverser->addVisitor( $visitor ); + + $documented = $parser->parse( "assertNotNull( $documented ); + $traverser->traverse( $documented ); + + $undocumented = $parser->parse( "assertNotNull( $undocumented ); + $traverser->traverse( $undocumented ); + + $calls = ( new NodeFinder() )->findInstanceOf( $undocumented, FuncCall::class ); + $this->assertCount( 1, $calls ); + $this->assertNull( $calls[0]->getAttribute( 'latestDocComment' ) ); + } + + /** + * Returns the summary line of the docblock attached to each hook call, keyed + * by hook name. + * + * @param string $code PHP source. + * @return array + */ + private static function attached_docblocks( string $code ): array { + $stmts = ( new ParserFactory() )->createForHostVersion()->parse( $code ); + self::assertNotNull( $stmts ); + + $traverser = new NodeTraverser(); + $traverser->addVisitor( new HookDocsVisitor() ); + $traverser->traverse( $stmts ); + + $result = array(); + foreach ( ( new NodeFinder() )->findInstanceOf( $stmts, FuncCall::class ) as $call ) { + $args = $call->getArgs(); + if ( ! isset( $args[0] ) || ! $args[0]->value instanceof String_ || 'wp_tests_fn' === (string) $call->name ) { + continue; + } + + $doc = $call->getAttribute( 'latestDocComment' ); + self::assertTrue( null === $doc || $doc instanceof Doc ); + + $result[ $args[0]->value->value ] = null === $doc ? null : self::summary( $doc ); + } + + return $result; + } + + /** + * Returns the first line of prose in a docblock. + * + * @param Doc $doc Docblock. + * @return string + */ + private static function summary( Doc $doc ): string { + $text = preg_replace( '#^/\*\*\s*|\s*\*/$#', '', $doc->getText() ); + $text = preg_replace( '#^\s*\*\s?#m', '', (string) $text ); + + return trim( (string) $text ); + } +} diff --git a/tests/phpstan/Tests/HookDocumentationRuleTest.php b/tests/phpstan/Tests/HookDocumentationRuleTest.php new file mode 100644 index 0000000000000..bce2fe671a7ba --- /dev/null +++ b/tests/phpstan/Tests/HookDocumentationRuleTest.php @@ -0,0 +1,131 @@ + + */ +final class HookDocumentationRuleTest extends RuleTestCase { + + /** + * Returns the rule under test, with its documentation resolved against the + * fixture WordPress root. + * + * @return HookDocumentationRule + */ + protected function getRule(): Rule { + return new HookDocumentationRule( self::getContainer()->getByType( HookDocBlock::class ) ); + } + + /** + * Returns the configuration registering the visitors and services the rule relies on. + * + * @return list + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } + + /** + * Tests the errors reported for a file outside the WordPress root, whose + * reference comments resolve against the root directly. + */ + public function test_rule(): void { + $missing = 'is not preceded by a docblock documenting the hook, nor by a "This filter/action is documented in " reference comment.'; + $no_params = 'is preceded by a docblock that documents no parameters. A filter is documented with a `@param` tag for the value being filtered, plus one for each further argument passed.'; + + $this->analyse( + array( __DIR__ . '/data/hook-documentation.php' ), + array( + array( 'apply_filters() call for hook "undocumented_filter" ' . $missing, 15 ), + array( 'do_action() call for hook "undocumented_action" ' . $missing, 17 ), + array( 'apply_filters() call for hook "paramless_filter" ' . $no_params, 34 ), + array( 'apply_filters() call for hook "var_annotated_filter" ' . $no_params, 37 ), + array( 'apply_filters() call for hook "documented_filter" references documentation in "wp-includes/missing.php", but no such file exists in the tree being analyzed.', 46 ), + array( 'apply_filters() call for hook "documented_filter" references documentation in "wp-includes/other.php", but no documented "documented_filter" hook is found there.', 49 ), + array( 'apply_filters() call for hook "only_referenced_here" references documentation in "wp-includes/canonical.php", but no documented "only_referenced_here" hook is found there.', 52 ), + array( 'apply_filters() call for hook "foo_bar" references documentation in "wp-includes/canonical.php", but no documented "foo_bar" hook is found there.', 61 ), + array( 'apply_filters() call for hook "{$prefix}_bar" references documentation in "wp-includes/canonical.php", but no documented "{$prefix}_bar" hook is found there.', 64 ), + array( 'apply_filters() call for hook "pre_option_{$option}" ' . $missing, 73 ), + array( 'apply_filters() call for hook "prefix_$context" ' . $missing, 75 ), + array( 'apply_filters() call for hook "second_item" ' . $missing, 93 ), + array( 'apply_filters_deprecated() call for hook "old_filter" ' . $missing, 103 ), + array( 'do_action_ref_array() call for hook "ref_action" ' . $missing, 105 ), + array( 'apply_filters() call for hook "inside_function" ' . $missing, 120 ), + ) + ); + } + + /** + * Tests that a reference is resolved by walking up from the file holding it, + * so a theme template can reference a sibling as well as a core file. + */ + public function test_references_resolve_relative_to_the_referencing_file(): void { + $this->analyse( + array( __DIR__ . '/data/wordpress/wp-content/themes/wptests/index.php' ), + array( + array( 'do_action() call for hook "wptests_missing_theme_hook" references documentation in "author.php", but no documented "wptests_missing_theme_hook" hook is found there.', 15 ), + array( 'apply_filters() call for hook "documented_filter" references documentation in "single.php", but no such file exists in the tree being analyzed.', 18 ), + ) + ); + } + + /** + * Tests that a reference written with a dot segment resolves inside the tree, + * and that one leading outside the tree is reported as missing. + */ + public function test_references_never_resolve_outside_the_root(): void { + $this->analyse( + array( __DIR__ . '/data/wordpress/wp-admin/admin.php' ), + array( + array( 'apply_filters() call for hook "outside_filter" references documentation in "../outside.php", but no such file exists in the tree being analyzed.', 19 ), + ) + ); + } + + /** + * Tests that each kind of problem is reported under its own identifier. + */ + public function test_error_identifiers(): void { + $errors = $this->gatherAnalyserErrors( array( __DIR__ . '/data/hook-documentation.php' ) ); + + $identifiers = array(); + foreach ( $errors as $error ) { + $identifiers[ (int) $error->getLine() ] = $error->getIdentifier(); + } + + $this->assertSame( + array( + 15 => 'wordpress.hookDocMissing', + 17 => 'wordpress.hookDocMissing', + 34 => 'wordpress.hookDocNoParams', + 37 => 'wordpress.hookDocNoParams', + 46 => 'wordpress.hookDocReferenceFileMissing', + 49 => 'wordpress.hookDocReferenceHookMissing', + 52 => 'wordpress.hookDocReferenceHookMissing', + 61 => 'wordpress.hookDocReferenceHookMissing', + 64 => 'wordpress.hookDocReferenceHookMissing', + 73 => 'wordpress.hookDocMissing', + 75 => 'wordpress.hookDocMissing', + 93 => 'wordpress.hookDocMissing', + 103 => 'wordpress.hookDocMissing', + 105 => 'wordpress.hookDocMissing', + 120 => 'wordpress.hookDocMissing', + ), + $identifiers + ); + } +} diff --git a/tests/phpstan/Tests/HookParamCountRuleTest.php b/tests/phpstan/Tests/HookParamCountRuleTest.php new file mode 100644 index 0000000000000..5982bd52c1538 --- /dev/null +++ b/tests/phpstan/Tests/HookParamCountRuleTest.php @@ -0,0 +1,76 @@ + + */ +final class HookParamCountRuleTest extends RuleTestCase { + + /** + * Returns the rule under test, with its documentation resolved against the + * fixture WordPress root. + * + * @return HookParamCountRule + */ + protected function getRule(): Rule { + return new HookParamCountRule( self::getContainer()->getByType( HookDocBlock::class ) ); + } + + /** + * Returns the configuration registering the visitors and services the rule relies on. + * + * @return list + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } + + /** + * Tests the mismatches reported, and the calls left alone. + */ + public function test_rule(): void { + $this->analyse( + array( __DIR__ . '/data/hook-param-count.php' ), + array( + array( 'apply_filters() for hook "two_params_short" provides 1 argument, but the hook is documented with 2 parameters.', 22 ), + array( 'apply_filters() for hook "one_param_long" provides 2 arguments, but the hook is documented with 1 parameter.', 29 ), + array( 'do_action() for hook "no_params_extra" provides 1 argument, but its docblock documents no parameters.', 39 ), + array( 'do_action() for hook "no_params_extra_two" provides 2 arguments, but its docblock documents no parameters.', 44 ), + array( 'do_action_ref_array() for hook "ref_array_long" provides 2 arguments, but the hook is documented with 1 parameter.', 51 ), + array( 'apply_filters_deprecated() for hook "deprecated_short" provides 1 argument, but the hook is documented with 2 parameters.', 59 ), + array( 'apply_filters() for hook "spread_known_long" provides 3 arguments, but the hook is documented with 1 parameter.', 90 ), + array( 'apply_filters() for hook "documented_filter" provides 1 argument, but the hook is documented with 2 parameters.', 113 ), + array( 'do_action() for hook "documented_action" provides 2 arguments, but the hook is documented with 1 parameter.', 116 ), + array( 'apply_filters() for hook "index_template_hierarchy" provides 2 arguments, but the hook is documented with 1 parameter.', 119 ), + array( 'apply_filters() for hook "malformed_tag" provides 2 arguments, but the hook is documented with 1 parameter.', 135 ), + array( 'do_action() for hook "wp_tests_{$type}_fired" provides 2 arguments, but the hook is documented with 1 parameter.', 144 ), + ) + ); + } + + /** + * Tests that every mismatch is reported under the same identifier. + */ + public function test_error_identifier(): void { + $errors = $this->gatherAnalyserErrors( array( __DIR__ . '/data/hook-param-count.php' ) ); + + $this->assertNotEmpty( $errors ); + + foreach ( $errors as $error ) { + $this->assertSame( 'wordpress.hookParamCountMismatch', $error->getIdentifier(), sprintf( 'Line %d', $error->getLine() ) ); + } + } +} diff --git a/tests/phpstan/Tests/PreparedSQLPlaceholdersRuleTest.php b/tests/phpstan/Tests/PreparedSQLPlaceholdersRuleTest.php new file mode 100644 index 0000000000000..91fb1f6d90157 --- /dev/null +++ b/tests/phpstan/Tests/PreparedSQLPlaceholdersRuleTest.php @@ -0,0 +1,112 @@ + + */ +final class PreparedSQLPlaceholdersRuleTest extends RuleTestCase { + + /** + * Returns the rule under test. + * + * @return PreparedSQLPlaceholdersRule + */ + protected function getRule(): Rule { + return new PreparedSQLPlaceholdersRule(); + } + + /** + * Returns the configuration the fixtures are analyzed under. + * + * @return list + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } + + /** + * Tests the fixture, whose first part is the sniff's own test file. The lines + * reported there are the ones PHP_CodeSniffer reports for the + * `UnfinishedPrepare` code. + */ + public function test_rule(): void { + $this->analyse( + array( __DIR__ . '/data/prepared-sql-placeholders.php' ), + self::expected( + array( + 23 => '$wpdb->prepare()', + 33 => '$wpdb->prepare()', + 34 => '$wpdb->prepare()', + 66 => '$wpdb->prepare()', + 68 => '$wpdb->prepare()', + 234 => '$wpdb->prepare()', + 246 => '$wpdb->prepare()', + 354 => '$wpdb->prepare()', + // The sniff's test file ends above; the rest extends it. + 542 => '$wpdb->prepare()', + 543 => '$wpdb->prepare()', + 544 => '$wpdb->prepare()', + 545 => '$wpdb->prepare()', + 546 => '$wpdb?->prepare()', + 547 => 'wpdb::prepare()', + 552 => '$wpdb->prepare()', + 559 => '$wpdb->prepare()', + 564 => '$wpdb->prepare()', + 572 => '$wpdb->prepare()', + 574 => '$wpdb->prepare()', + 580 => '$wpdb->prepare()', + 581 => '$wpdb->prepare()', + 582 => '$wpdb->prepare()', + 583 => '$wpdb->prepare()', + 594 => '$wpdb->prepare()', + 595 => '$wpdb->prepare()', + 610 => '$db->prepare()', + ) + ) + ); + } + + /** + * Tests that every error carries the sniff code as its identifier. + */ + public function test_error_identifier(): void { + $errors = $this->gatherAnalyserErrors( array( __DIR__ . '/data/prepared-sql-placeholders.php' ) ); + + $this->assertNotEmpty( $errors ); + + foreach ( $errors as $error ) { + $this->assertSame( PreparedSQLPlaceholdersRule::IDENTIFIER, $error->getIdentifier(), sprintf( 'Line %d', $error->getLine() ) ); + } + } + + /** + * Builds the expected errors from the call reported on each line. + * + * @param array $lines Call per line. + * @return list + */ + private static function expected( array $lines ): array { + $expected = array(); + + foreach ( $lines as $line => $call ) { + $expected[] = array( + sprintf( 'Replacement variables found, but no valid placeholders found in the query passed to %s.', $call ), + $line, + ); + } + + return $expected; + } +} diff --git a/tests/phpstan/Tests/PreparedSQLRuleTest.php b/tests/phpstan/Tests/PreparedSQLRuleTest.php new file mode 100644 index 0000000000000..bea1ec5a0ec1f --- /dev/null +++ b/tests/phpstan/Tests/PreparedSQLRuleTest.php @@ -0,0 +1,133 @@ + + */ +final class PreparedSQLRuleTest extends RuleTestCase { + + /** + * Returns the rule under test. + * + * @return PreparedSQLRule + */ + protected function getRule(): Rule { + return new PreparedSQLRule(); + } + + /** + * Returns the configuration the fixtures are analyzed under. + * + * @return list + */ + public static function getAdditionalConfigFiles(): array { + return array( __DIR__ . '/tests.neon' ); + } + + /** + * Tests the fixture, whose first part is the sniff's own test file. The lines + * reported there are the ones PHP_CodeSniffer reports for the + * `InterpolatedNotPrepared` code. + */ + public function test_rule(): void { + $this->analyse( + array( + __DIR__ . '/data/prepared-sql.php', + __DIR__ . '/data/PreparedSQLTestClass.php', + ), + self::expected( + array( + 4 => array( '$_GET[\'title\']', '$wpdb->query()' ), + 5 => array( '$var', '$wpdb->query()' ), + 7 => array( '$_GET[\'title\']', '$wpdb->prepare()' ), + 8 => array( '$var', '$wpdb->prepare()' ), + 12 => array( '$escaped_var', '$wpdb->query()' ), + 16 => array( '$_GET[\'foo\']', '$wpdb->prepare()' ), + 17 => array( '$_GET[\'foo\']', '$wpdb->prepare()' ), + 18 => array( '$_GET[\'foo\']', '$wpdb->prepare()' ), + 54 => array( '$_GET[\'foo\']', '$wpdb->prepare()' ), + 64 => array( '$foo', '$wpdb->query()' ), + 71 => array( '$var', '$wpdb->query()' ), + 106 => array( '$title->sub()', '$wpdb->query()' ), + 107 => array( '${\\title->sub}', '$wpdb->query()' ), + 108 => array( '${\\title->{$sub}}', '$wpdb->query()' ), + 109 => array( '${\\title->{${\'sub\'}}}', '$wpdb->query()' ), + 112 => array( '$wpdb', '$wpdb->query()' ), + // The sniff's test file ends above; the rest extends it. + // Line 39 is in PreparedSQLTestClass.php. + 39 => array( '$foo', '$this->db->query()' ), + 170 => array( '$foo', '$wpdb->query()' ), + 178 => array( '$foo', '$wpdb->query()' ), + 184 => array( '$author', '$wpdb->query()' ), + 185 => array( '$status', '$wpdb->query()' ), + 190 => array( '$foo', '$wpdb->query()' ), + 191 => array( '$foo', '$wpdb->prepare()' ), + 203 => array( '$foo', '$wpdb->query()' ), + 204 => array( '$foo', '$wpdb->query()' ), + 205 => array( array( '$foo', '$wpdb->query()' ), array( '$bar', '$wpdb->query()' ) ), + 213 => array( '$foo', '$wpdb->prepare()' ), + 214 => array( array( '$foo', '$wpdb->prepare()' ), array( '$baz', '$wpdb->get_var()' ) ), + 218 => array( '$wpdb[\'posts\']', '$wpdb->query()' ), + 219 => array( '$db->posts', '$wpdb->query()' ), + 226 => array( '$foo', '$db->query()' ), + 228 => array( '$foo', '$db?->get_row()' ), + 238 => array( '$part', '$wpdb->query()' ), + ) + ) + ); + } + + /** + * Tests that every error carries the sniff code as its identifier. + */ + public function test_error_identifier(): void { + $errors = $this->gatherAnalyserErrors( array( __DIR__ . '/data/prepared-sql.php' ) ); + + $this->assertNotEmpty( $errors ); + + foreach ( $errors as $error ) { + $this->assertSame( PreparedSQLRule::IDENTIFIER, $error->getIdentifier(), sprintf( 'Line %d', $error->getLine() ) ); + } + } + + /** + * Builds the expected errors from the variable and call reported on each line. + * + * @param array> $lines Variable and call per line, or several. + * @return list + */ + private static function expected( array $lines ): array { + $expected = array(); + + foreach ( $lines as $line => $reports ) { + if ( is_string( $reports[0] ) ) { + $reports = array( $reports ); + } + + foreach ( $reports as $report ) { + $expected[] = array( + sprintf( + 'Use placeholders and $wpdb->prepare(); found interpolated variable %s in the query passed to %s.', + $report[0], + $report[1] + ), + $line, + ); + } + } + + return $expected; + } +} diff --git a/tests/phpstan/Tests/bootstrap.php b/tests/phpstan/Tests/bootstrap.php new file mode 100644 index 0000000000000..725d7b3ed4897 --- /dev/null +++ b/tests/phpstan/Tests/bootstrap.php @@ -0,0 +1,17 @@ +" references against, in place of `src`. + * Read by tests.neon, which has no other way to learn where this directory is. + */ +putenv( 'WP_PHPSTAN_TESTS_WORDPRESS_ROOT=' . __DIR__ . '/data/wordpress' ); + +require_once dirname( __DIR__, 3 ) . '/vendor/autoload.php'; diff --git a/tests/phpstan/Tests/data/ApplyFiltersTestClass.php b/tests/phpstan/Tests/data/ApplyFiltersTestClass.php new file mode 100644 index 0000000000000..2210079675cbe --- /dev/null +++ b/tests/phpstan/Tests/data/ApplyFiltersTestClass.php @@ -0,0 +1,75 @@ +db->query( "SELECT * FROM {$this->db->posts} WHERE ID = $foo" ); // Bad. + $this->db->query( "SELECT * FROM {$this->db->posts} WHERE ID = 1" ); // OK. + $this->other->query( "SELECT * FROM {$this->other->posts} WHERE ID = $foo" ); // OK, not the database object. + $this->db->prepare( 'SELECT * FROM `table`', $foo ); // Not this rule's concern. + } +} diff --git a/tests/phpstan/Tests/data/apply-filters.php b/tests/phpstan/Tests/data/apply-filters.php new file mode 100644 index 0000000000000..d042601bac809 --- /dev/null +++ b/tests/phpstan/Tests/data/apply-filters.php @@ -0,0 +1,205 @@ +slug ) ? apply_filters( 'editable_slug', $tag->slug, $tag ) : 123; +assertType( '123|string', $slug ); + +/** + * Return type for `apply_filters_ref_array()`. + * + * @param string $foo Hello. + * @param int $bar World. + */ +$value = apply_filters_ref_array( 'foo', array( 'Hello', 123 ) ); +assertType( 'string', $value ); + +/** + * Return type for `apply_filters_deprecated()`. + * + * @param string $foo Hello. + * @param bool $bar World. + */ +$value = apply_filters_deprecated( 'foo', array( 'Hello', true ), '6.0.0' ); +assertType( 'string', $value ); + +/** + * An action is not a filter: its docblock does not type its return value, which + * stays whatever `do_action()` itself declares. + * + * @param string $foo Hello. + */ +$value = do_action( 'foo', 'Hello' ); +assertType( 'mixed', $value ); + +/** This filter is documented in wp-includes/missing.php */ +$value = apply_filters( 'documented_filter', 123 ); +assertType( 'mixed', $value ); + +/** This filter is documented in wp-includes/canonical.php */ +$value = apply_filters( 'documented_filter', 123, $context ); +assertType( 'string', $value ); + +/** This filter is documented in wp-includes/canonical.php */ +$value = apply_filters( 'not_documented_there', 123 ); +assertType( 'mixed', $value ); + +/** This filter is documented in wp-includes/canonical.php */ +$value = apply_filters( 'index_template_hierarchy', array() ); +assertType( 'array', $value ); + +/** This filter is documented in wp-includes/canonical.php */ +$value = apply_filters( 'documented_hash_filter', array() ); +assertType( 'array{post_type?: string, per_page?: int, ...}', $value ); + +/** This filter is documented in wp-includes/canonical.php */ +$value = apply_filters( $hook_name, 123 ); +assertType( 'mixed', $value ); diff --git a/tests/phpstan/Tests/data/functions.php b/tests/phpstan/Tests/data/functions.php new file mode 100644 index 0000000000000..176536568cf7d --- /dev/null +++ b/tests/phpstan/Tests/data/functions.php @@ -0,0 +1,19 @@ + $wp_tests_map A type containing whitespace. + */ +function wp_tests_documented_globals(): void { + global $wpdb, $pagenow, $wp_tests_ids, $wp_tests_map, $wp_tests_undocumented; + + assertType( 'wpdb', $wpdb ); + assertType( 'string', $pagenow ); + assertType( 'array|null', $wp_tests_ids ); + assertType( 'array', $wp_tests_map ); + assertType( 'mixed', $wp_tests_undocumented ); +} + +/** + * Documents nothing. + */ +function wp_tests_no_global_tags(): void { + global $wpdb; + + assertType( 'mixed', $wpdb ); +} + +function wp_tests_no_docblock(): void { + global $wpdb; + + assertType( 'mixed', $wpdb ); +} + +/** + * A hand-written `@var` on the statement is left alone, per variable. + * + * @global string $pagenow Current admin page. + * @global string $wp_tests_other Something else. + */ +function wp_tests_handwritten_var(): void { + /** @var int $pagenow */ + global $pagenow, $wp_tests_other; + + assertType( 'int', $pagenow ); + assertType( 'string', $wp_tests_other ); +} + +/** + * A `@global` tag on the statement itself takes precedence. + * + * @global string $pagenow Current admin page. + */ +function wp_tests_statement_level_tag(): void { + /** @global int $pagenow */ + global $pagenow; + + assertType( 'int', $pagenow ); +} + +/** + * A closure is its own frame, documented or not. + * + * @global string $pagenow Current admin page. + */ +function wp_tests_closure_frames(): void { + $undocumented = function (): void { + global $pagenow; + + assertType( 'mixed', $pagenow ); + }; + + /** + * A docblock on the assignment belongs to the statement, not to the closure. + * + * @global int $pagenow Current admin page, as the closure sees it. + */ + $documented_statement = function (): void { + global $pagenow; + + assertType( 'mixed', $pagenow ); + }; + + $documented_import = function (): void { + /** @global int $pagenow Current admin page, as the closure sees it. */ + global $pagenow; + + assertType( 'int', $pagenow ); + }; + + global $pagenow; + + assertType( 'string', $pagenow ); +} + +/** + * A tag for a variable the function does not import changes nothing. + * + * @global string $pagenow Current admin page. + */ +function wp_tests_unused_tag(): void { + global $wpdb; + + assertType( 'mixed', $wpdb ); +} + +/** @global \WP_Query $wp_query WordPress Query object. */ +global $wp_query; + +assertType( 'WP_Query', $wp_query ); + +global $wp_the_query; + +assertType( 'mixed', $wp_the_query ); diff --git a/tests/phpstan/Tests/data/hash-notation.php b/tests/phpstan/Tests/data/hash-notation.php new file mode 100644 index 0000000000000..a335fc203fb59 --- /dev/null +++ b/tests/phpstan/Tests/data/hash-notation.php @@ -0,0 +1,118 @@ + 'x', + 'count' => 1, + ); +} + +/** + * A hash on a class produces an intersection. + * + * @return stdClass { + * @type string $name Name. + * } + */ +function wp_tests_hash_object_return() { + $object = new stdClass(); + $object->name = 'x'; + + return $object; +} + +/** + * The bare member of a union takes the shape. + * + * @return array|WP_Error { + * @type string $name Name. + * } + */ +function wp_tests_hash_union_return() { + return array( 'name' => 'x' ); +} + +/** + * A hand-written `@phpstan-return` wins. + * + * @return array { + * @type string $name Name. + * } + * @phpstan-return array{name: non-empty-string} + */ +function wp_tests_hash_handwritten_return() { + return array( 'name' => 'x' ); +} + +/** + * A by-reference parameter keeps its declared type. + * + * @param array $args { + * @type string $name Name. + * } + */ +function wp_tests_hash_by_reference( &$args ) { + assertType( 'array', $args ); +} + +/** + * A hook documented with a hash is typed from the shape. + * + * @param array $args { + * @type string $post_type Post type. + * } + */ +function wp_tests_hash_hook( $args ) { + /** + * Filters the arguments. + * + * @param array $args { + * Arguments. + * + * @type string $post_type Post type. + * @type int $per_page Per page. + * } + */ + $args = apply_filters( 'wp_tests_hash_hook', $args ); + + assertType( 'array{post_type?: string, per_page?: int, ...}', $args ); +} + +/** + * The return shapes as a caller sees them. + */ +function wp_tests_hash_call_sites() { + assertType( 'array{name: string, count: int}', wp_tests_hash_param_and_return( array() ) ); + assertType( 'object{name: string}&stdClass', wp_tests_hash_object_return() ); + assertType( 'array{name: string}|WP_Error', wp_tests_hash_union_return() ); + assertType( 'array{name: non-empty-string}', wp_tests_hash_handwritten_return() ); +} diff --git a/tests/phpstan/Tests/data/hook-documentation.php b/tests/phpstan/Tests/data/hook-documentation.php new file mode 100644 index 0000000000000..6f6f4fabbaba9 --- /dev/null +++ b/tests/phpstan/Tests/data/hook-documentation.php @@ -0,0 +1,121 @@ + apply_filters( 'first_item', $value ), + 'second' => apply_filters( 'second_item', $value ), +); + +/** + * Filters a value passed through another call. + * + * @param string $value Value. + */ +$value = wp_tests_return_value( apply_filters( 'wrapped_filter', $value ) ); + +$value = apply_filters_deprecated( 'old_filter', array( $value ), '6.0.0' ); + +do_action_ref_array( 'ref_action', array( $value ) ); + +/** + * Fires a deprecated action. + * + * @param string $value Value. + */ +do_action_deprecated( 'old_action', array( $value ), '6.0.0' ); + +/** + * This docblock belongs to the function, not to the hook inside it. + * + * @param string $value Value. + */ +function wp_tests_function_docblock_does_not_reach_hook( $value ) { + return apply_filters( 'inside_function', $value ); +} diff --git a/tests/phpstan/Tests/data/hook-param-count.php b/tests/phpstan/Tests/data/hook-param-count.php new file mode 100644 index 0000000000000..bbcdfe1619c4d --- /dev/null +++ b/tests/phpstan/Tests/data/hook-param-count.php @@ -0,0 +1,144 @@ +prepare( $sql, $replacements ); // OK - no query available to examine - this will be handled by the PreparedSQL sniff. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", 1, "admin" ); // OK. +$sql = $wpdb?->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", array( 1, "admin" ) ); // OK. +$sql = $wpdb->prepare( 'SELECT * FROM `table` WHERE `column` = %s AND `field` = %d', 'foo', 1337 ); // OK. +$sql = $wpdb->prepare( 'SELECT DATE_FORMAT(`field`, "%%c") FROM `table` WHERE `column` = %s', 'foo' ); // OK. + +/* + * No placeholders, no need to use prepare(). + */ +$sql = $wpdb->prepare( 'SELECT * FROM `table`' ); // Warning. +$sql = $wpdb->prepare( 'SELECT * FROM `table` WHERE id = ' . $id ); // OK - this will be handled by the PreparedSQL sniff. +$sql = $wpdb->prepare( "SELECT * FROM `table` WHERE id = $id" ); // OK - this will be handled by the PreparedSQL sniff. +$sql = $wpdb->prepare( "SELECT * FROM `table` WHERE id = {$id['some%sing']}" ); // OK - this will be handled by the PreparedSQL sniff. +$sql = $wpdb?->prepare( 'SELECT * FROM ' . $wpdb->users ); // Warning. +$sql = $wpdb->prepare( "SELECT * FROM `{$wpdb->users}`" ); // Warning. +$sql = $wpdb->prepare( "SELECT * FROM `{$wpdb->users}` WHERE id = $id" ); // OK - this will be handled by the PreparedSQL sniff. + +/* + * No placeholders found, but replacement variable(s) are being passed. + */ +$sql = $wpdb->prepare( 'SELECT * FROM `table`', $something ); // Warning. + +/* + * Test passing invalid replacement placeholder. + */ +$sql = $wpdb->prepare( '%d %1$e %%% % %A %h', 1 ); // Bad x 5. +$sql = $wpdb->prepare( '%%%s', 1 ); // OK. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb?->users WHERE id = %1\$d AND user_login = %2\$s", 1, "admin" ); // OK. 2 x warning for unquoted complex placeholders. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb?->users WHERE id = %01.2f AND user_login = %10.10X", 1, "admin" ); // Bad x 1 + 1 warning unquoted complex placeholders + 1 warning nr of replacements. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %'.09F AND user_login = %1\$04x", 1, "admin" ); // Bad x 1 + 1 warning unquoted complex placeholders + 1 warning nr of replacements. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = \"%1\$c\" AND user_login = '%2\$e'", 1, "admin" ); // Bad x 2 + 1 warning. +$sql = $wpdb->prepare( 'SELECT * FROM ' . $wpdb?->users . ' WHERE id = \'%1\$b\' AND user_login = "%2\$o"', 1, "admin" ); // Bad x 2 + 1 warning. + +/* + * Test passing quoted simple replacement placeholder and unquoted complex placeholder. + */ +$sql = $wpdb->prepare( '"%f"', 1.1 ); // Bad. +$sql = $wpdb->prepare( 'SELECT * FROM `table` WHERE `field` = \'%s\'', 'string' ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM `table` WHERE `id` = \"%d\"", 1 ); // Bad. +$sql = $wpdb->prepare( <<users, 1, 'user_login', "admin" +); // Warning x 3, unquoted complex placeholder. + +/* + * Test passing an incorrect amount of replacement parameters. + */ +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s" ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", 1 ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", 1, "admin", $variable ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", array( 1 ) ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", [1, "admin", $variable] ); // Bad. + +$replacements = [1, "admin", $variable]; +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", $replacements ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s", $replacements ); // Bad - old-style ignore comment. WPCS: PreparedSQLPlaceholders replacement count OK. + +// Valid test case as found in WP core /wp-admin/includes/export.php +$esses = array_fill( 0, count($post_types), '%s' ); +$where = $wpdb->prepare( "{$wpdb->posts}.post_type IN (" . implode( ',', $esses ) . ')', $post_types ); // Warning. +// Testing that ignore comment works for this mismatch too. +$where = $wpdb->prepare( "{$wpdb->posts}.post_type IN (" . implode( ',', $esses ) . ')', $post_types ); // Bad - old-style ignore comment. WPCS: PreparedSQLPlaceholders replacement count OK. + +/* + * Test correctly recognizing queries using IN in combination with dynamic placeholder creation. + */ +// The proper way to write a query using `IN` won't throw a warning: +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', array_fill( 0, count($post_types), '%s' ) ) + ), + $post_types +); // OK. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s) + AND {$wpdb->posts}.post_status IN (%s)", + implode( ',', array_fill( 0, count($post_types), '%s' ), ), + IMPLODE( ',', Array_Fill( 0, count($post_statusses), '%s' ), ), + ), + array_merge( $post_types, $post_statusses, ), +); // OK. + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (" + . implode( ',', array_fill( 0, count($post_types), '%s' ) ) + . ") AND {$wpdb->posts}.post_status IN (" + . implode( ',', array_fill( 0, count($post_statusses), '%s' ) ) + . ')', + array_merge( $post_types, $post_statusses ) +); // OK. + +$query = $wpdb->prepare( + sprintf( + 'SELECT COUNT(ID) + FROM `%s` + WHERE ID IN (%s) + AND post_status = "publish"', + $wpdb->posts, + implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) + ) . ' AND post_type = %s', + array_merge( $post_ids, array( $this->get_current_post_type() ) ), +); // OK. + +$results = $wpdb->get_results( + $wpdb->prepare( ' + SELECT ID + FROM ' . $wpdb->posts . ' + WHERE ID NOT IN( SELECT post_id FROM ' . $wpdb?->postmeta . ' WHERE meta_key = %s AND meta_value = %s ) + AND post_status in( "future", "draft", "pending", "private", "publish" ) + AND post_type in( ' . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ' ) + LIMIT %d', + $replacements + ), + ARRAY_A +); // OK. + +$query = $wpdb->prepare( + sprintf( + 'SELECT COUNT(ID) + FROM `%s` + WHERE ID in (%s) + AND post_status = "publish"', + $wpdb->posts, + implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) + ) . ' AND post_type = %s', + array_merge( $post_ids, array( $this->get_current_post_type() ) ), + $another +); // Error - second replacement param is incorrect, with a variable nr of placeholders you always need to pass a replacement array. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN ('%s')", + implode( ',', array_fill( 0, count($post_types), '%s' ) ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad x 2 - %s is quoted, so this won't work properly, will throw incorrect nr of replacements error + quotes found. + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (\"" + . implode( ',', array_fill( 0, count($post_types), '%s' ) ) + . "\") AND {$wpdb->posts}.post_status IN ('" + . \implode( ',', array_fill( 0, count($post_statusses), '%s' ) ) + . '\')', + array_merge( $post_types, $post_statusses ) +); // Bad x 2 - quotes between the () for the IN. + +/* + * Test distinguishing wildcard _ and %'s in LIKE statements. + */ +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_content LIKE %s", $like ); // OK. +$sql = $wpdb->prepare( 'SELECT * FROM ' . $wpdb->posts . ' WHERE post_content LIKE \'a string\'' ); // Warning x 2. Like without wildcard, no need for prepare. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_content LIKE 'a string'" ); // Warning x 2. Like without wildcard, no need for prepare. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_content LIKE '%a string' AND post_status = %s", $status ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_content LIKE 'a string%' AND post_status = %s", $status ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_content LIKE '%a string%' AND post_status = %s", $status ); // Bad. +$sql = $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_content LIKE 'a_string' AND post_status = %s", $status ); // Bad. + +// Some VALID test cases as found in plugins published on WP.org. +$comment_id = $wpdb->get_var($wpdb->prepare('SELECT comment_ID FROM ' . $wpdb->comments . ' WHERE comment_post_ID = %d AND comment_agent LIKE %s', intval($post->ID), 'Disqus/1.0:' . $comment_id)); // OK. + +$sql = $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s", self::CACHE_KEY_PREFIX . '%' ); // OK. + +$wpdb->query($wpdb->prepare('UPDATE '.$wpdb->prefix.'posts SET post_content = REPLACE(post_content, %s, %s) WHERE post_type = "page" AND post_content LIKE %s', $meta_before, $meta_after, '%'.$wpdb->esc_like($meta_before).'%')); // OK. + +$query = $wpdb->prepare("DELETE FROM {$wpdb->prefix}options WHERE `option_name` LIKE %s OR `option_name` LIKE %s", "%{$transient_name}%", "%{$transient_timeout_name}%"); // OK. + +// Some INVALID test cases as found in plugins published on WP.org. +$wpdb->prepare( "UPDATE $wpdb->posts SET post_status = 'pending' WHERE (post_type LIKE 'product_variation' or post_type LIKE 'product') AND NOT ID IN (". implode(",", $imported_ids ) .")"); // Error x 1 for `product_variation`; warning x 1 for wrong use of LIKE with `product`; the PreparedSQL sniff will also kick in and throw an error about `$imported_ids`. + +$attachment = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE guid LIKE '%%%s%%' LIMIT 1;", $img_url ) ); // Bad. + +$result = $wpdb->get_col($wpdb->prepare("SELECT guid FROM $wpdb->posts WHERE guid LIKE '%%%s' and post_parent=%d;", $atts['model'], $post->ID )); // Bad. + +$comments = $wpdb->get_results( $wpdb->prepare("SELECT * FROM $wpdb->comments WHERE comment_post_ID = %d AND comment_agent NOT LIKE 'Disqus/%%'", $post->ID) ); // Bad. + +$sql = $wpdb->prepare( "SELECT count(*) FROM $this->fontsTable WHERE name LIKE '%%%s%%' OR status LIKE '%%%s%%' OR metadata LIKE '%%%s%%'", $search, $search, $search ); // Bad x 3, the PreparedSQL sniff will also kick in and throw an error about `$this`. + +$additional_where = $wpdb->prepare(' AND (network like "%%%s%%" OR ProgramTitle like "%%%s%%" OR TransactionStatus like "%%%s%%" ) ', $search, $search, $search); // Bad x 3. + +$robots_query = $wpdb->prepare( "SELECT name FROM $robots_table WHERE %s LIKE concat('%%',name,'%%')", $http_user_agent ); // Bad, the PreparedSQL sniff will also kick in. + +$sql = $wpdb->prepare('SELECT * FROM ' . $wpdb->avatar_privacy . ' WHERE email LIKE "%s"', $email); // Bad (quotes). + +$res = $wpdb->query( $wpdb->prepare( 'UPDATE ' . $wpdb->posts . ' SET post_name="feed" WHERE post_name LIKE "feed-%" AND LENGTH(post_name)=6 AND post_type=%s', BAWAS_POST_TYPE ) ); // Bad. + +$sql = $wpdb->prepare( "SELECT ID FROM $wpdb->users AS us INNER JOIN $wpdb->usermeta AS mt ON ( us.ID = mt.user_id ) WHERE ( mt.meta_key = 'bbp_last_login' AND mt.meta_value < %s ) AND user_id IN ( SELECT user_id FROM $wpdb->usermeta AS mt WHERE (mt.meta_key = '{$wpdb->prefix}capabilities' AND mt.meta_value LIKE '%%bbp_user%%' ))", $beforegmdate ); // Bad. + +$paged_events = $wpdb->get_results( $wpdb->prepare( "SELECT id as event_id FROM {$bp->events->table_name} WHERE ( name LIKE '{$filter}%%' OR description LIKE '{$filter}%%' ) AND id IN ({$gids}) " . $oldevents . " {$pag_sql}" ) ); // Bad x 2, the PreparedSQL sniff will also kick in and throw six errors. + +$query = $wpdb->prepare( "SELECT language_code FROM {$wpdb->prefix}icl_translations WHERE element_type LIKE '%%post_%%' AND element_id = %d", $post_ID ); // Bad. + +$postID = $wpdb->get_var($wpdb->prepare("SELECT `postID` FROM `".EPDataBase::$table_name."` WHERE `path` like '".$filePath."';")); // OK, the PreparedSQL sniff will kick in and throw four errors. + +$wpdb->query($wpdb->prepare("show tables like '$this->table_name'")) > 0; // OK, the PreparedSQL sniff will kick in. + +$wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->wp_options WHERE option_name LIKE '%widget_gigya%'" ) ); // Bad. + +$where .= $wpdb->prepare( " AND `name` LIKE '%%%%" . '%s' . "%%%%' ", $args['name'] ); // Bad x 2. + +$wpdb->query($wpdb->prepare("delete from wp_postmeta where post_id = $target_postId AND meta_key like 'google_snippets'")); // Bad, the PreparedSQL sniff will also kick in and throw an error about `$target_postId`. + +// phpcs:set WordPress.DB.PreparedSQLPlaceholders minimum_wp_version 6.2 + +$wpdb->prepare( 'WHERE %i = %s', $field, $value ); // OK. +$wpdb->prepare( 'WHERE %i = %s', $value ); // ReplacementsWrongNumber. +$wpdb->prepare( 'WHERE %i = %x', $field, $value ); // UnsupportedPlaceholder & ReplacementsWrongNumber. +$wpdb->prepare( 'WHERE %i = %2$s', $field, $value ); // UnquotedComplexPlaceholder. +$wpdb->prepare( 'WHERE %i = %10s', $field, $value ); // UnquotedComplexPlaceholder. +$wpdb->prepare( 'WHERE %i = %2$-10s', $field, $value ); // UnquotedComplexPlaceholder. + +$wpdb->prepare( 'WHERE %i = "%s"', $field, $value ); // QuotedSimplePlaceholder. +$wpdb->prepare( 'WHERE "%i" = %s', $field, $value ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( "WHERE \"%i\" = %s", $field, $value ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( "WHERE '%i' = %s", $field, $value ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE \'%i\' = %s', $field, $value ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE `%i` = %s', $field, $value ); // QuotedIdentifierPlaceholder. + +$wpdb->prepare( "WHERE '%10i' IS NULL", $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE "%10i" IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE \'%1$i\' IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( "WHERE \"%10i\" IS NULL", $field ); // QuotedIdentifierPlaceholder. + +$wpdb->prepare( 'WHERE %1$i IS NULL', $field ); // OK. +$wpdb->prepare( 'WHERE %10i IS NULL', $field ); // OK. +$wpdb->prepare( 'WHERE % i IS NULL', $field ); // UnescapedLiteral & UnfinishedPrepare (while this is valid, should avoid). +$wpdb->prepare( 'WHERE %1$-10i IS NULL', $field ); // OK. +$wpdb->prepare( 'WHERE %1$-10.3i IS NULL', $field ); // OK. +$wpdb->prepare( 'WHERE %1$+10.3i IS NULL', $field ); // OK. +$wpdb->prepare( 'WHERE %1$ 10.3i IS NULL', $field ); // UnsupportedPlaceholder (parsed as "%1$", which is valid, but should avoid). +$wpdb->prepare( 'WHERE %1$010.3i IS NULL', $field ); // OK. +$wpdb->prepare( "WHERE %1$'x10.3i IS NULL", $field ); // OK. + +$wpdb->prepare( 'WHERE %.2i IS NULL', 'a``b' ); // Currently ignore, but it might cause a problem (most likely a parse error) "WHERE `a`` IS NULL". + +$wpdb->prepare( 'WHERE `%1$i` IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE `%10i` IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE `% i` IS NULL', $field ); // UnescapedLiteral & UnfinishedPrepare (if RegEx matched, then it should be QuotedIdentifierPlaceholder). +$wpdb->prepare( 'WHERE `%1$-10i` IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE `%1$-10.3i` IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE `%1$+10.3i` IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( 'WHERE `%1$ 10.3i` IS NULL', $field ); // QuotedIdentifierPlaceholder, and UnsupportedPlaceholder (parsed as "%1$", which is valid, but should avoid). +$wpdb->prepare( 'WHERE `%1$010.3i` IS NULL', $field ); // QuotedIdentifierPlaceholder. +$wpdb->prepare( "WHERE `%1$'x10.3i` IS NULL", $field ); // QuotedIdentifierPlaceholder. + +$wpdb->prepare( 'SELECT ID FROM `%2$i` WHERE `%1$i` = "%3$s"', $field, $wpdb->posts, $value ); // QuotedIdentifierPlaceholder (x2). + +$wpdb->prepare( 'SELECT ID FROM %i.%i WHERE %i = "false"', $db, $table, $field ); // OK. + +$wpdb->prepare( + sprintf( + 'xxx IN (%s)', + implode( ',', array_fill( 0, count($fields), '%i' ) ) + ), + $fields +); // ReplacementsWrongNumber + IdentifierWithinIN. + +$wpdb->prepare( 'xxx IN ( ' . implode( ',', array_fill( 0, count( $post_types ), + '%i' ) ) . ' )', + $fields +); // IdentifierWithinIN. + +$wpdb->prepare( ' + xxx IN ( ' . implode( ',', array_fill( 0, count( $post_types ), "%i" ) ) . ' )', + $fields +); // IdentifierWithinIN. + +// phpcs:set WordPress.DB.PreparedSQLPlaceholders minimum_wp_version 5.8 + +$wpdb->prepare( 'WHERE %1$+10.3i = %s', $field, $value ); // UnsupportedIdentifierPlaceholder. +$wpdb->prepare( "WHERE '%10i' IS NULL", $field ); // UnsupportedIdentifierPlaceholder + QuotedIdentifierPlaceholder. +$wpdb->prepare( 'xxx IN ( ' . implode( ',', array_fill( 0, count( $post_types ), '%i' ) ) . ' )', $fields ); // UnsupportedIdentifierPlaceholder + IdentifierWithinIN. + +// phpcs:set WordPress.DB.PreparedSQLPlaceholders minimum_wp_version + +$wpdb->prepare(); // Ignore. + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (\"" + . implode() + . "\") AND {$wpdb->posts}.post_id = %s", + $post_id +); // Bad, dynamic placeholder generation quotes with invalid implode call (no params). + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_status IN ('" + . implode( ',' ) + . '\') AND {$wpdb->posts}.post_id = %s', + $post_id +); // Bad, dynamic placeholder generation quotes with invalid implode call (missing second param). + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_status IN ('" + . implode( '|', array_fill( 0, count($post_stati), '%s' ) ) + . '\') AND {$wpdb->posts}.post_id = %s', + $post_id +); // Bad x2, dynamic placeholder generation quotes with invalid implode call (separator parameter does not contain expected ',' value). + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (\"" + . implode( ',', $array_fill ) + . "\") AND {$wpdb->posts}.post_id = %s", + $post_id +); // Bad, dynamic placeholder generation quotes with invalid implode call (array param is not call to array_fill). + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (\"" + . implode( ',', array_fill() ) + . "\") AND {$wpdb->posts}.post_id = %s", + $post_id +); // Bad, dynamic placeholder generation quotes with invalid implode call (array param is call to array_fill() without params). + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (\"" + . implode( ',', array_fill( 0, count($post_types) ) ) + . "\") AND {$wpdb->posts}.post_id = %s", + $post_id +); // Bad, dynamic placeholder generation quotes with invalid implode call (array param is call to array_fill(), but missing third param). + +// Safeguard that short array as $args is handled correctly. +$wpdb->get_col( + $wpdb->prepare( + "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s LIMIT %d", + [ 'taxonomy_name', $limit ] + ) // Ok. +); + +// Disregard comments in the implode() $separator param. +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',' /*comment*/, array_fill( 0, count($post_types), '%s' ) ) + ), + $post_types +); // OK. + +// Disregard comments in the array_fill() $value param. +// Also test handling of %i when minimum supported WP version is not high enough yet. +// phpcs:set WordPress.DB.PreparedSQLPlaceholders minimum_wp_version 6.0 +$wpdb->prepare( 'xxx IN ( ' . implode( ',', array_fill( 0, count( $post_types ), '%i' /*comment*/ ) ) . ' )', $fields ); // IdentifierWithinIN. +// phpcs:set WordPress.DB.PreparedSQLPlaceholders minimum_wp_version + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (" + . implode( ',', array_fill( 0, count($post_types), '%C' ) ) + . ')', + array_merge( $post_types ) +); // Bad x 2, UnsupportedPlaceholder + UnfinishedPrepare. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', array_fill( 0, count($post_types), '%C' ) ) + ), + $post_types +); // Bad, ReplacementsWrongNumber due to unrecognized placeholder in array_fill(). + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (" + . implode( ',', array_fill( 0, count($post_types), /*comment*/ '%s' ) ) + . ')', + array_merge( $post_types ) +); // OK. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', array_fill( 0, count($post_types), "%s" /*comment*/ ) ) + ), + $post_types +); // OK. + +// Safeguard that FQN function calls to implode() and array_fill() are handled correctly. +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + \implode( ',', array_fill( 0, count($post_types), '%s' ) ) + ), + $post_types +); // OK. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', \array_fill( 0, count($post_types), '%s' ) ) + ), + $post_types +); // OK. + +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (" + . implode( ',', \array_fill( 0, count($post_types), '%s' ) ) + . ") AND {$wpdb->posts}.post_status IN (" + . implode( ',', \array_fill( 0, count($post_statusses), '%s' ) ) + . ')', + array_merge( $post_types, $post_statusses ) +); // OK. + +/* + * Safeguard support for PHP 8.0+ named parameters. + */ +// WPDB::prepare() with named params. Named args not supported with ...$args, but that's not the concern of this sniff. +$query = $wpdb->prepare( + args : $replacements, + query : 'SELECT ID + FROM ' . $wpdb->posts . ' + WHERE post_type = %s', +); // OK, named args not supported with ...$args, but that's not the concern of this sniff. + +$query = $wpdb->prepare( + query : 'SELECT ID + FROM ' . $wpdb->posts . ' + WHERE post_type = %s', +); // Bad, missing replacements. + +$query = $wpdb->prepare( + args : $replacements, + queri : 'SELECT ID + FROM ' . $wpdb->posts . ' + WHERE post_type = %s', +); // Ignore, incorrect name used for named param `query`, so param not recognized. + +// Implode() with named params. +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( array: array_fill( 0, count( $post_types ), '%s' ), separator: ',', ), + ), + array_merge( $post_types, $post_statusses ) +); // Okay. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( array: $something, separator: ',', ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad - will throw incorrect nr of replacements error - `array` param is not an array_fill() function call. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( arrays: array_fill( 0, count( $post_types ), '%s' ), separator: ',', ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad - will throw incorrect nr of replacements error - incorrect name used for named param `array`, so param not recognized. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( separator: ',', ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad - will throw incorrect nr of replacements error - missing `array` param. + +// Array_fill() with named params. +$where = $wpdb->prepare( + "{$wpdb->posts}.post_type IN (" + . implode( ',', array_fill( start_index: 0, count: count($post_types), value: '%s' ) ) // Expected order. + . ") AND {$wpdb->posts}.post_status IN (" + . implode( ',', array_fill( value: '%s', start_index: 0, count: count($post_statusses), ) ) // Unconventional order. + . ')', + array_merge( $post_types, $post_statusses ) +); // OK. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', array_fill( start_index: 0, count: count($post_types) ) ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad - will throw incorrect nr of replacements error - missing $value param in array_fill(). + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', array_fill( start_index: 0, values: '%s', count: count($post_types) ) ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad - will throw incorrect nr of replacements error - incorrect $values param name in array_fill(). + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', array_fill( value: 's', start_index: 0, count: count($post_types) ) ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad - will throw incorrect nr of replacements error - $value param name in array_fill() does not contain placeholder. + +$where = $wpdb->prepare( + sprintf( + "{$wpdb->posts}.post_type IN (%s)", + implode( ',', array_fill( value: $s, start_index: 0, count: count($post_types) ) ), + ), + array_merge( $post_types, $post_statusses ) +); // Bad - will throw incorrect nr of replacements error - $value param name in array_fill() does not contain plain text placeholder. + +// Sprintf() with named params. This is invalid as variadic functions do not support named params. +// The sniff will ignore the sprintf() as it cannot be analyzed correctly. +$where = $wpdb->prepare( + sprintf( + values: implode( ',', array_fill( 0, count($post_types), '%s' ) ), + format: "{$wpdb->posts}.post_type IN ('%s')", + ), + array_merge( $post_types, $post_statusses ) +); // OK, well not really, but not something we can reliably analyze. + +/* + * Safeguard handling of $wpdb->prepare as PHP 8.1+ first class callable. + */ +$callback = $wpdb->prepare(...); // OK. + +/* + * Safeguard correct handling of all types of namespaced calls to the wpdb::prepare() method. + * + * Note that calling wpdb::prepare() statically will result in an error. Still, the tests are included here since the + * sniff handles those calls. + * + * Related to: https://github.com/WordPress/WordPress-Coding-Standards/issues/2710. + */ +$sql = \wpdb::prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s" ); // Error. +$sql = \WPDB::prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s" ); // Error. +$sql = MyNamespace\wpdb::prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s" ); // OK. +$sql = \MyNamespace\WPDB::prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s" ); // OK. +$sql = namespace\wpdb::prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s" ); // Ok. The sniff should start flagging this once it can resolve relative namespaces as this test file is not namespaced. +$sql = namespace\Sub\wpdb::prepare( "SELECT * FROM $wpdb->users WHERE id = %d AND user_login = %s" ); // OK. + +/* + * The fixture above is WordPress-Coding-Standards/WordPress/Tests/DB/PreparedSQLPlaceholdersUnitTest.inc + * at 3.4.1, MIT license. What follows extends it. + */ + +// Replacements passed in every way the method accepts them. +$wpdb->prepare( 'SELECT * FROM `table`', array( $something ) ); // Warning. +$wpdb->prepare( 'SELECT * FROM `table`', ...$replacements ); // Warning. +$wpdb->prepare( query: 'SELECT * FROM `table`', args: $something ); // Warning. +$wpdb->prepare( 'SELECT * FROM `table`', $something, $other ); // Warning. +$wpdb?->prepare( 'SELECT * FROM `table`', $something ); // Warning. +wpdb::prepare( 'SELECT * FROM `table`', $something ); // Warning. + +// The query is reported on its last line. +$wpdb->prepare( + 'SELECT * + FROM `table`', + $something +); // Warning. + +$wpdb->prepare( <<prepare( + 'SELECT * FROM `table`' + , $something +); // Warning. + +// Placeholders anywhere in the query text satisfy the check. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id = ' . ( $condition ? '%d' : '%s' ), $something ); // OK. +$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE id = " . '%d', $something ); // OK. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id = %i', $something ); // OK. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id = %%', $something ); // Warning, a literal percent sign is no placeholder. +$wpdb->prepare( 'SELECT * FROM `table` WHERE name = "%s"', $something ); // OK here, reported by the sniff as QuotedSimplePlaceholder. +$wpdb->prepare( "SELECT * FROM `table` WHERE id = {$id['%d']}", $something ); // Warning, a placeholder in an embedded variable is not in the query. + +// An `IN` clause with a single placeholder, or with a valid dynamic placeholder list. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN (%s)', $ids ); // OK. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ); // OK. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ',', array_fill( 0, count( $ids ), '%F' ) ) . ' )', $ids ); // OK. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ',', array_fill( 0, count( $ids ), $placeholder ) ) . ' )', $ids ); // Warning, the repeated value is not a literal placeholder. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ',', array_fill( 0, count( $ids ), $wpdb->placeholder_escape() ) ) . ' )', $ids ); // Warning, as above. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ',', array_fill( 0, count( $ids ), '%C' ) ) . ' )', $ids ); // Warning, the repeated value is not a placeholder. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ',', $placeholders ) . ' )', $ids ); // Warning, the list is not built with array_fill(). + +// An implode() that is not recognized is query text like any other, and so is the +// placeholder repeated by its array_fill(). These are the sniff's own blind spots. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ';', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ); // OK here, though the separator is not a comma. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN ( ' . implode( ',', array_fill( 0, count( $ids ), '%i' ) ) . ' )', $ids ); // OK here, though an identifier placeholder is not a value. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id = ( ' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ' )', $ids ); // OK here, though not an IN clause. +$wpdb->prepare( implode( ',', array_fill( 0, count( $ids ), '%d' ) ), $ids ); // OK here, though the implode() follows no IN clause. +$wpdb->prepare( 'SELECT * FROM `table` WHERE id IN (' . join( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ); // OK here, though join() is not recognized. + +// A sprintf() formats the query; its arguments are not query text. +$wpdb->prepare( sprintf( 'SELECT * FROM `table` WHERE id = %%d' ), $id ); // Warning, `%%d` is a literal in the format. +$wpdb->prepare( sprintf( 'SELECT * FROM `table` WHERE id = ', '%d' ), $id ); // Warning, the placeholder is in an argument, which is not query text. +$wpdb->prepare( sprintf( 'SELECT * FROM `%s` WHERE id = 1', $wpdb->posts ), $id ); // OK here, a sprintf() placeholder passes for one of prepare()'s. +$wpdb->prepare( sprintf( format: 'SELECT * FROM `%s`', values: $wpdb->posts ), $id ); // OK, a sprintf() with named arguments is not analyzed. + +// A query that holds no text has nothing to examine. +$wpdb->prepare( $query, $something ); // OK. +$wpdb->prepare( $this->get_query(), $something ); // OK. +$wpdb->prepare( $query . $suffix, $something ); // OK. +$wpdb->prepare( ...$args ); // OK. + +// The database object recognized by its type rather than its name. +/** + * @param wpdb $db Database. + */ +function wp_tests_placeholders_typed( wpdb $db, $something ) { + $db->prepare( 'SELECT * FROM `table`', $something ); // Warning. +} diff --git a/tests/phpstan/Tests/data/prepared-sql.php b/tests/phpstan/Tests/data/prepared-sql.php new file mode 100644 index 0000000000000..ed27adbfe225e --- /dev/null +++ b/tests/phpstan/Tests/data/prepared-sql.php @@ -0,0 +1,238 @@ +query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . $_GET['title'] . "';" ); // Bad. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '{$_GET['title']}';" ); // Bad. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '$var';" ); // Bad. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE 'Hello World!';" ); // Ok. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '{$_GET['title']}';" ) ); // Bad. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '$var';" ) ); // Bad. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE %s;", $_GET['title'] ) ); // Ok. + +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . $escaped_var . "';" ); // Bad: old-style ignore comment. WPCS: unprepared SQL OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '{$escaped_var}';" ); // Bad: old-style ignore comment. WPCS: unprepared SQL OK. + +$wpdb->query( $wpdb->prepare( "SELECT SUBSTRING( post_name, %d + 1 ) REGEXP '^[0-9]+$'", array( 123 ) ) ); // Ok. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title = 'The \$_GET var can be evil.' AND ID = %s", array( 123 ) ) ); // Ok. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title = 'The $_GET[foo] var is evil.' AND ID = %s", array( 123 ) ) ); // Bad. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title = 'The \\$_GET[foo]// var is evil again.' AND ID = %s", array( 123 ) ) ); // Bad. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title = 'The \$_GET var can be evil, but $_GET[foo] var is evil.' AND ID = %s", array( 123 ) ) ); // Bad. + +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Bad. +$wpdb->query( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ) ); // Bad. + +$wpdb->query( "SELECT * FROM " . $wpdb->posts . " WHERE post_title LIKE 'foo';" ); // Ok. + +// All OK. +$all_post_meta = $wpdb->get_results( $wpdb->prepare( sprintf( + 'SELECT `post_id`, `meta_value` FROM `%s` WHERE `meta_key` = "sort_order" AND `post_id` IN (%s)', + $wpdb->postmeta, + implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) +), $post_ids ) ); + +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . esc_sql( $foo ) . "';" ); // Ok. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE ID = " . ABSINT( $foo ) . ";" ); // Ok. + +// Test multi-line strings. +$all_post_meta = $wpdb->get_results( $wpdb->prepare( sprintf( + 'SELECT `post_id`, `meta_value` + FROM `%s` + WHERE `meta_key` = "sort_order" + AND `post_id` IN (%s)', + $wpdb->postmeta, + \implode( ',', \array_fill( 0, \count( $post_ids ), '%d' ) ) +), $post_ids ) ); // Ok. + +$wpdb->query( " + SELECT * + FROM $wpdb->posts + WHERE post_title LIKE '" . esc_sql( $foo ) . "';" +); // Ok. + +$wpdb->query( $wpdb->prepare( " + SELECT * + FROM $wpdb->posts + WHERE post_title = 'The \\$_GET[foo]// var is evil again.' + AND ID = %s", + array( 123 ) +) ); // Bad. + + +// Test heredoc & nowdoc for query. +$wpdb->query( <<posts} + WHERE ID = {$foo}; +EOT +); // Bad. + +$wpdb->query( <<<"HD" + SELECT * + FROM {$wpdb->posts} + WHERE post_title LIKE '{$var}'; +HD +); // Bad. + +$all_post_meta = $wpdb->get_results( $wpdb->prepare( sprintf( <<<'ND' + SELECT `post_id`, `meta_value` + FROM `%s` + WHERE `meta_key` = "sort_order" + AND `post_id` IN (%s) +ND + , $wpdb->postmeta, + IMPLODE( ',', array_fill( 0, COUNT( $post_ids ), '%d' ) ) +), $post_ids ) ); // OK. + +wpdb::prepare( "SELECT * FROM $wpdb?->posts WHERE post_title LIKE '" . foo() . "';" ); // Bad. + +$wpdb->query( // Some arbitrary comment. + "SELECT * + FROM $wpdb->posts + WHERE post_title LIKE '" . $escaped_var . "';" +); // Bad x 1. + +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE ID = " . (int) $foo . ";" ); // Ok. + +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . (float) $foo . ";" ); // Ok. + +$wpdb->query( + "SELECT * FROM $wpdb->posts + WHERE ID = " + . absint // Ok. + ( $foo ) + . ";" +); + +// Test handling of more complex embedded variables and expressions. +$wpdb->query( "SELECT * FROM {$wpdb->bar()} WHERE post_title LIKE '{$title->sub()}';" ); // Bad x 1. +$wpdb->query( "SELECT * FROM ${wpdb->bar} WHERE post_title LIKE '${title->sub}';" ); // Bad x 1. +$wpdb->query( "SELECT * FROM ${wpdb->{$baz}} WHERE post_title LIKE '${title->{$sub}}';" ); // Bad x 1. +$wpdb->query( "SELECT * FROM ${wpdb->{${'a'}}} WHERE post_title LIKE '${title->{${'sub'}}}';" ); // Bad x 1. + +// More defensive variable checking +$wpdb->query( "SELECT * FROM $wpdb" ); // Bad x 1, $wpdb on its own is not valid. + +$wpdb + -> /*comment*/ query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . $_GET['title'] . "';" ); // Bad. + +$wpdb?->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . (int) $foo . "';" ); // OK. +$wpdb?->query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Bad. + +WPDB::prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Bad. +$wpdb->Query( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Bad. + +// The sniff's fixture has a line here that is not valid PHP: `" . {$foo} . "`. Left out so the line numbers below still match. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . (array) $foo . ";" ); // Bad - on $foo, not on the (array). +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . -10 . ";" ); // OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . +1.0 . ";" ); // OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . 10 / 2.5 . ";" ); // OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . ++$foo . ";" ); // Bad - on $foo, not on the ++. + +// Safeguard handling of PHP 8.0+ nullsafe object operators found within a query. +$wpdb->query( $wpdb->prepare( 'SELECT * FROM ' . $wpdb::TABLE_NAME . " WHERE post_title LIKE '%s';", '%something' ) ); // OK. +$wpdb->query( $wpdb->prepare( 'SELECT * FROM ' . $notwpdb?->posts . " WHERE post_title LIKE '%s';", '%something' ) ); // Bad. + +// Safeguard handling of PHP 7.4+ numeric literals with undersocres and PHP 8.1 explicit octals. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . 10_000 . ";" ); // OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE value = " . 0o34 . ";" ); // OK. + +// Not a method call. +$wpdb = new WPDB(); +$foo = $wpdb->propertyAccess; +echo $wpdb::CONSTANT_NAME; + +// Not an identifiable method call. +$wpdb->{$methodName}('query'); + +/* + * Safeguard correct handling of all types of namespaced calls to the wpdb::prepare() method. + * + * Note that calling wpdb::prepare() statically will result in an error. Still, the tests are included here since the + * sniff handles those calls. + * + * Related to: https://github.com/WordPress/WordPress-Coding-Standards/issues/2710. + */ +\wpdb::prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Bad. +\WPDB::prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Bad. +MyNamespace\wpdb::prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Ok. +\MyNamespace\WPDB::prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Ok. +namespace\wpdb::prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Ok. The sniff should start flagging this once it can resolve relative namespaces as this test file is not namespaced. +namespace\Sub\wpdb::prepare( "SELECT * FROM $wpdb->posts WHERE post_title LIKE '" . foo() . "';" ); // Ok. + +/* + * The fixture above is WordPress-Coding-Standards/WordPress/Tests/DB/PreparedSQLUnitTest.1.inc + * at 3.4.1, MIT license. What follows extends it. + */ + +// Flexible heredoc, from PreparedSQLUnitTest.2.inc. +$wpdb->query( <<posts} + WHERE ID = {$foo}; + EOT +); // Bad. + +// A docblock does not reach a hook, and neither does it reach a query. +/** + * @param string $foo Foo. + */ +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE ID = $foo" ); // Bad. + +// Each interpolated variable is reported, on the line it is on. +$wpdb->query( + "SELECT * + FROM $wpdb->posts + WHERE post_author = $author + AND post_status = {$status} + AND post_type = $wpdb->prefix" +); // Bad x 2. + +// A query passed by name is examined like one passed by position. +$wpdb->query( query: "SELECT * FROM $wpdb->posts WHERE ID = $foo" ); // Bad. +$wpdb->prepare( args: array( $foo ), query: "SELECT * FROM $wpdb->posts WHERE ID = $foo" ); // Bad. + +// Only the query is examined. +$wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE ID = %d", "$foo" ); // OK. +$wpdb->get_results( "SELECT * FROM $wpdb->posts", "$output" ); // OK. + +// A query that is not a string literal has nothing interpolated. +$wpdb->query( $query ); // OK. +$wpdb->query( ...$args ); // OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE ID = " . $foo ); // OK here, reported by the sniff as NotPrepared. + +// Strings nested in other calls within the query are examined. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE ID IN (" . implode( ',', array( "$foo", 'bar' ) ) . ")" ); // Bad. +$wpdb->query( strtoupper( "SELECT * FROM $wpdb->posts WHERE ID = $foo" ) ); // Bad. +$wpdb->query( $condition ? "SELECT * FROM $wpdb->posts WHERE ID = $foo" : "SELECT * FROM $wpdb->posts WHERE ID = $bar" ); // Bad x 2. + +// Strings nested in a call to an escaping function are not. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE post_title = '" . esc_sql( "$foo" ) . "'" ); // OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE ID = " . intval( "$foo" ) ); // OK. +$wpdb->query( "SELECT * FROM $wpdb->posts WHERE ID = " . $this->count( "$foo" ) ); // OK. + +// A nested query call is examined once, as its own call. +$wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE ID = $foo AND post_status = %s", $status ) ); // Bad x 1. +$wpdb->get_var( "SELECT * FROM ( " . $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE ID = $foo", $bar ) . " ) AS t WHERE t.ID = $baz" ); // Bad x 2. + +// Members of the database object, however they are reached. +$wpdb->query( "SELECT * FROM {$wpdb->posts} WHERE ID = {$wpdb?->blogid} AND x = {$wpdb->tables['posts']} AND y = {$wpdb->get_blog_prefix( 1 )}posts" ); // OK. +$wpdb->query( "SELECT * FROM {$wpdb['posts']}" ); // Bad, not a property. +$wpdb->query( "SELECT * FROM {$db->posts}" ); // Bad, not the database object by name or by type. + +// The database object recognized by its type rather than its name. +/** + * @param wpdb $db Database. + */ +function wp_tests_prepared_sql_typed( wpdb $db, $foo ) { + $db->query( "SELECT * FROM $db->posts WHERE ID = $foo" ); // Bad. + $db->query( "SELECT * FROM $db->posts WHERE ID = 1" ); // OK. + $db?->get_row( "SELECT * FROM $db->posts WHERE ID = $foo" ); // Bad. +} + +// A property documented as the database object is examined too; see PreparedSQLTestClass.php. + +// Other methods are not examined. +$wpdb->esc_like( "$foo" ); // OK. +$wpdb->insert( "$table", array( 'ID' => $foo ) ); // OK. + +// A string in a closure within the query is examined, as any other token. +$wpdb->query( array_reduce( $parts, function ( $carry, $part ) use ( $wpdb ) { return $carry . " OR post_name = $part"; }, "SELECT * FROM $wpdb->posts WHERE 1 = 0" ) ); // Bad. diff --git a/tests/phpstan/Tests/data/wordpress/wp-admin/admin.php b/tests/phpstan/Tests/data/wordpress/wp-admin/admin.php new file mode 100644 index 0000000000000..6d6bc03f8d1e7 --- /dev/null +++ b/tests/phpstan/Tests/data/wordpress/wp-admin/admin.php @@ -0,0 +1,22 @@ +query( ... )`, `$wpdb?->prepare( ... )` or `wpdb::prepare( ... )`. + * + * The WordPress Coding Standards recognize the receiver by name: the `$wpdb` + * variable, or the global `wpdb` class called statically. Those are recognized + * here too, and so is any other expression PHPStan knows to be a `wpdb`, such as + * a property documented as one. + */ +final class WpdbCall { + + /** + * The `$wpdb` methods that take a query as their first parameter. + */ + public const QUERY_METHODS = array( + 'get_var', + 'get_col', + 'get_row', + 'get_results', + 'prepare', + 'query', + ); + + /** + * Method name, in lowercase. + * + * @var string + */ + private string $method; + + /** + * Arguments passed, in order. + * + * @var list + */ + private array $args; + + /** + * The call as it is written, for messages, such as `$wpdb->query()`. + * + * @var string + */ + private string $display; + + /** + * Constructor. + * + * @param string $method Method name, in lowercase. + * @param list $args Arguments passed, in order. + * @param string $display The call as it is written. + */ + private function __construct( string $method, array $args, string $display ) { + $this->method = $method; + $this->args = $args; + $this->display = $display; + } + + /** + * Recognizes a call to one of the given `$wpdb` methods. + * + * @param Node $node Node being analyzed. + * @param Scope $scope Analysis scope. + * @param list $methods Method names to recognize, in lowercase. + * @return self|null Null when the node is not such a call. + */ + public static function fromNode( Node $node, Scope $scope, array $methods ): ?self { + // PHPStan analyzes a nullsafe call as a plain call too, marked by an attribute, + // and hands rules both. Only the plain one is examined, so each call is examined once. + if ( $node instanceof NullsafeMethodCall ) { + return null; + } + + if ( $node instanceof MethodCall ) { + if ( ! $node->name instanceof Identifier || $node->isFirstClassCallable() ) { + return null; + } + + $method = strtolower( $node->name->toString() ); + if ( ! in_array( $method, $methods, true ) || ! self::isWpdb( $node->var, $scope ) ) { + return null; + } + + $operator = true === $node->getAttribute( 'virtualNullsafeMethodCall' ) ? '?->' : '->'; + + return new self( $method, $node->getArgs(), self::print( $node->var ) . $operator . $node->name->toString() . '()' ); + } + + if ( $node instanceof StaticCall ) { + if ( ! $node->name instanceof Identifier || ! $node->class instanceof Name || $node->isFirstClassCallable() ) { + return null; + } + + $method = strtolower( $node->name->toString() ); + if ( ! in_array( $method, $methods, true ) || 'wpdb' !== strtolower( $scope->resolveName( $node->class ) ) ) { + return null; + } + + return new self( $method, $node->getArgs(), 'wpdb::' . $node->name->toString() . '()' ); + } + + return null; + } + + /** + * Determines whether an expression is the database object. + * + * @param Expr $expr Expression. + * @param Scope $scope Analysis scope. + * @return bool + */ + public static function isWpdb( Expr $expr, Scope $scope ): bool { + if ( $expr instanceof Variable && 'wpdb' === $expr->name ) { + return true; + } + + return ( new ObjectType( 'wpdb' ) )->isSuperTypeOf( $scope->getType( $expr ) )->yes(); + } + + /** + * Returns the method name, in lowercase. + * + * @return string + */ + public function getMethod(): string { + return $this->method; + } + + /** + * Returns the arguments passed, in order. + * + * @return list + */ + public function getArgs(): array { + return $this->args; + } + + /** + * Returns the call as it is written, for messages. + * + * @return string + */ + public function getDisplay(): string { + return $this->display; + } + + /** + * Returns the argument holding the query. + * + * That is the first argument, or the one named `query`. An unpacked argument + * carries no query to examine. + * + * @return Arg|null + */ + public function getQueryArg(): ?Arg { + $arg = self::findArg( $this->args, 1, 'query' ); + + return null === $arg || $arg->unpack ? null : $arg; + } + + /** + * Finds an argument by position or by name, as PHPCSUtils does. + * + * @param list $args Arguments passed, in order. + * @param int $position 1-based position among the positional arguments. + * @param string $name Parameter name, for a named argument. + * @return Arg|null + */ + public static function findArg( array $args, int $position, string $name ): ?Arg { + foreach ( $args as $arg ) { + if ( null !== $arg->name && $arg->name->toString() === $name ) { + return $arg; + } + } + + $positional = 0; + foreach ( $args as $arg ) { + if ( null !== $arg->name ) { + break; + } + + ++$positional; + if ( $positional === $position ) { + return $arg; + } + } + + return null; + } + + /** + * Prints an expression as PHP source. + * + * @param Expr $expr Expression. + * @return string + */ + public static function print( Expr $expr ): string { + return ( new Standard() )->prettyPrintExpr( $expr ); + } +} diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 1c102ca47e7da..b5b56907518ab 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -59,6 +59,18 @@ services: tags: - phpstan.rules.rule + # Ports of checks from the WordPress Coding Standards, which replace those sniffs. + # Each reports under the sniff code it ports. + - + class: WordPress\PHPStan\PreparedSQLRule + tags: + - phpstan.rules.rule + + - + class: WordPress\PHPStan\PreparedSQLPlaceholdersRule + tags: + - phpstan.rules.rule + # Runs the visitors above over every file PHPStan parses, not only the ones it # analyzes. # @@ -305,6 +317,10 @@ parameters: - ApplyFiltersDynamicFunctionReturnTypeExtension.php - HookDocumentationRule.php - HookParamCountRule.php + - WpdbCall.php + - PreparedSQLRule.php + - PreparedSQLPlaceholdersRule.php + - Tests bootstrapFiles: - bootstrap.php scanFiles: @@ -312,6 +328,8 @@ parameters: - ../../src/wp-admin/includes/ms.php excludePaths: analyseAndScan: + # Fixtures for the tests of the extensions above, which carry the errors those tests expect. + - Tests/data # Locally installed npm dependencies, such as in bundled themes, which may include PHP files. - */node_modules/* # Composer dependencies of themes and plugins, which are not part of core. diff --git a/tests/phpstan/baselines/WordPress.DB.PreparedSQL.InterpolatedNotPrepared.neon b/tests/phpstan/baselines/WordPress.DB.PreparedSQL.InterpolatedNotPrepared.neon new file mode 100644 index 0000000000000..15aee82161147 --- /dev/null +++ b/tests/phpstan/baselines/WordPress.DB.PreparedSQL.InterpolatedNotPrepared.neon @@ -0,0 +1,485 @@ +# PHPStan baseline for the `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/WordPress.DB.PreparedSQL.InterpolatedNotPrepared +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=WordPress.DB.PreparedSQL.InterpolatedNotPrepared +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$extra_checks in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/class-wp-list-table.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$sticky_posts in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table_names in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/class-wp-site-health.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$date_field in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/comment.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$post_id_in in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/comment.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$and in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$in_placeholder in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$join in the query passed to \$wpdb\-\>get_col\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$where in the query passed to \$wpdb\-\>get_col\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$where in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$ids_string in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$ids_string in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/media.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$keys in the query passed to \$wpdb\-\>get_col\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/schema.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$add_hours in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 4 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$add_minutes in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 4 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$allowed_length in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$catwhere in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$dupe_ids in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$i in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$index in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 3 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$meta_keys in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$now in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$old in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$select in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$start in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 3 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$wpdb\-\>get_row\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 4 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table_name in the query passed to \$wpdb\-\>get_col\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$tables\[''sitecategories''\] in the query passed to \$wpdb\-\>get_var\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-admin/includes/upgrade.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$post_types_to_delete in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/includes/user.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table_name in the query passed to \$wpdb\-\>get_col\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 4 + path: ../../../src/wp-admin/install-helper.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table_name in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/install-helper.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$prefix in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-admin/setup-config.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$field_name in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 3 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$compare in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$gt in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$lt in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-date-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$alias in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 6 + path: ../../../src/wp-includes/class-wp-meta-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$cast in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-meta-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$meta_key in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-meta-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$operator in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-meta-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$this\-\>meta_id_column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wp-meta-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$this\-\>primary_id_column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wp-meta-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$this\-\>primary_table in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wp-meta-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-network-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$like_op in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$query_vars\[''comment_count''\]\[''compare''\] in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$search_column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-site-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$operator in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$this\-\>primary_id_column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$this\-\>primary_table in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-tax-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wp-user-query.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$db_field in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wp-user.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$charset in the query passed to \$this\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$connection_charset in the query passed to \$this\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$length in the query passed to \$this\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$this\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/class-wpdb.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$check_column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$comment_id_list in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/functions.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$comparison_operator in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$where in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/link-template.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$column in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 2 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$id_column in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$id_column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 3 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$id_list in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 6 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$type_column in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 3 + path: ../../../src/wp-includes/meta.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$prefix in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/ms-functions.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table in the query passed to \$wpdb\-\>query\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/ms-site.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$where in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/option.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$list in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/pluggable.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$add_seconds_server in the query passed to \$wpdb\-\>get_var\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$and in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$and in the query passed to \$wpdb\-\>get_var\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$comments_in in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/post.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$field in the query passed to \$wpdb\-\>get_var\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 6 + path: ../../../src/wp-includes/post.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$post_types in the query passed to \$wpdb\-\>get_var\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 3 + path: ../../../src/wp-includes/post.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$in_delete_tt_ids in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$in_tt_ids in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$shared_term_ids in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$tt_ids in the query passed to \$wpdb\-\>get_results\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$table_names in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/update.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$blog_prefix in the query passed to \$wpdb\-\>get_col\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$blog_prefix in the query passed to \$wpdb\-\>get_row\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$prefix in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/user.php + - + message: '#^Use placeholders and \$wpdb\-\>prepare\(\); found interpolated variable \$select_count in the query passed to \$wpdb\-\>get_row\(\)\.$#' + identifier: WordPress.DB.PreparedSQL.InterpolatedNotPrepared + count: 1 + path: ../../../src/wp-includes/user.php diff --git a/tests/phpstan/baselines/WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare.neon b/tests/phpstan/baselines/WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare.neon new file mode 100644 index 0000000000000..6fd87fa4c8b6b --- /dev/null +++ b/tests/phpstan/baselines/WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare.neon @@ -0,0 +1,35 @@ +# PHPStan baseline for the `WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare` errors in WordPress core. +# +# https://phpstan.org/error-identifiers/WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare +# +# Each entry is scoped to a single file and carries an exact occurrence count, +# so that a new instance is reported as a new error rather than being absorbed +# silently. Fixing an occurrence therefore means decrementing or removing its +# entry here as part of the same change. +# +# The goal is to empty this file and delete it, along with the `includes` entry +# for it in phpstan.neon.dist. +# +# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with +# +# composer phpstan:baselines -- --identifier=WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare +# +# which reruns the analysis with this file suppressed so the errors surface again. + +parameters: + ignoreErrors: + - + message: '#^Replacement variables found, but no valid placeholders found in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + count: 3 + path: ../../../src/wp-admin/includes/export.php + - + message: '#^Replacement variables found, but no valid placeholders found in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + count: 2 + path: ../../../src/wp-includes/class-wp-comment-query.php + - + message: '#^Replacement variables found, but no valid placeholders found in the query passed to \$wpdb\-\>prepare\(\)\.$#' + identifier: WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare + count: 1 + path: ../../../src/wp-includes/option.php diff --git a/tests/phpstan/phpunit.xml.dist b/tests/phpstan/phpunit.xml.dist new file mode 100644 index 0000000000000..837cf6abd0236 --- /dev/null +++ b/tests/phpstan/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + Tests + + + diff --git a/tests/phpunit/includes/functions.php b/tests/phpunit/includes/functions.php index cc39bbc52a62d..8a4a21a8a8014 100644 --- a/tests/phpunit/includes/functions.php +++ b/tests/phpunit/includes/functions.php @@ -134,7 +134,6 @@ function _delete_all_data() { $wpdb->term_relationships, $wpdb->termmeta, ) as $table ) { - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DELETE FROM {$table}" ); } @@ -142,7 +141,6 @@ function _delete_all_data() { $wpdb->terms, $wpdb->term_taxonomy, ) as $table ) { - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DELETE FROM {$table} WHERE term_id != 1" ); } diff --git a/tests/phpunit/includes/install.php b/tests/phpunit/includes/install.php index 33af31693e386..addebca6c5168 100644 --- a/tests/phpunit/includes/install.php +++ b/tests/phpunit/includes/install.php @@ -64,12 +64,10 @@ $wpdb->query( 'SET foreign_key_checks = 0' ); foreach ( $wpdb->tables() as $table => $prefixed_table ) { - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS $prefixed_table" ); } foreach ( $wpdb->tables( 'ms_global' ) as $table => $prefixed_table ) { - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS $prefixed_table" ); // We need to create references to ms global tables. diff --git a/tests/phpunit/includes/utils.php b/tests/phpunit/includes/utils.php index a99e640ced4b9..6585f0b8cdfec 100644 --- a/tests/phpunit/includes/utils.php +++ b/tests/phpunit/includes/utils.php @@ -544,7 +544,6 @@ function drop_tables() { global $wpdb; $tables = $wpdb->get_col( 'SHOW TABLES;' ); foreach ( $tables as $table ) { - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); } } diff --git a/tests/phpunit/tests/admin/includesSchema.php b/tests/phpunit/tests/admin/includesSchema.php index f6c96169659a5..f71f1938dff10 100644 --- a/tests/phpunit/tests/admin/includesSchema.php +++ b/tests/phpunit/tests/admin/includesSchema.php @@ -54,7 +54,6 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { $charset_collate = $wpdb->get_charset_collate(); $max_index_length = 191; - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( " CREATE TABLE {$options} ( @@ -93,7 +92,6 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { ) {$charset_collate} " ); - // phpcs:enable } /** @@ -106,11 +104,9 @@ public static function wpTearDownAfterClass() { $blogmeta = self::$blogmeta; $sitemeta = self::$sitemeta; - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS {$options}" ); $wpdb->query( "DROP TABLE IF EXISTS {$blogmeta}" ); $wpdb->query( "DROP TABLE IF EXISTS {$sitemeta}" ); - // phpcs:enable } /** diff --git a/tests/phpunit/tests/comment/wpBatchUpdateCommentType.php b/tests/phpunit/tests/comment/wpBatchUpdateCommentType.php index d3aa7a4fda7d9..b418737d3cc98 100644 --- a/tests/phpunit/tests/comment/wpBatchUpdateCommentType.php +++ b/tests/phpunit/tests/comment/wpBatchUpdateCommentType.php @@ -20,7 +20,7 @@ public function test__wp_batch_update_comment_type() { "UPDATE {$wpdb->comments} SET comment_type = '' WHERE comment_type = 'comment' - AND comment_ID in ({$comment_id_list})" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + AND comment_ID in ({$comment_id_list})" ); clean_comment_cache( $comment_ids ); diff --git a/tests/phpunit/tests/db.php b/tests/phpunit/tests/db.php index 50ab02b260d1d..7e897363fe2ab 100644 --- a/tests/phpunit/tests/db.php +++ b/tests/phpunit/tests/db.php @@ -394,7 +394,6 @@ public function test_prepare_without_arguments() { global $wpdb; $id = 0; // This, obviously, is an incorrect prepare. - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $prepared = $wpdb->prepare( "SELECT * FROM $wpdb->users WHERE id = $id", $id ); $this->assertSame( "SELECT * FROM $wpdb->users WHERE id = 0", $prepared ); } @@ -2278,7 +2277,6 @@ public function test_esc_sql_with_unsupported_placeholder_type() { global $wpdb; $sql = $wpdb->prepare( ' %s %1$c ', 'foo' ); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $sql = $wpdb->prepare( " $sql %s ", 'foo' ); $this->assertSame( " 'foo' {$wpdb->placeholder_escape()}1\$c 'foo' ", $sql ); diff --git a/tests/phpunit/tests/db/dbDelta.php b/tests/phpunit/tests/db/dbDelta.php index 8117d0b75c5ff..beee6d964c342 100644 --- a/tests/phpunit/tests/db/dbDelta.php +++ b/tests/phpunit/tests/db/dbDelta.php @@ -77,7 +77,6 @@ public function set_up() { KEY key_1 (column_1(%d)), KEY compound_key (id,column_1(%d)), FULLTEXT KEY fulltext_key (column_1)' . - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ") {$this->db_engine} ", $this->max_index_length, @@ -581,7 +580,6 @@ public function test_fulltext_index() { protected function assertTableRowHasValue( $column, $value, $table ) { global $wpdb; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_row = $wpdb->get_row( "select $column from {$table} where $column = '$value'", ARRAY_A ); $this->assertSame( array( $column => $value ), $table_row ); @@ -596,7 +594,6 @@ protected function assertTableRowHasValue( $column, $value, $table ) { protected function assertTableHasColumn( $column, $table ) { global $wpdb; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_fields = $wpdb->get_results( "DESCRIBE $table" ); $this->assertCount( 1, wp_list_filter( $table_fields, array( 'Field' => $column ) ) ); @@ -613,7 +610,6 @@ protected function assertTableHasColumn( $column, $table ) { protected function assertTableHasPrimaryKey( $column, $table ) { global $wpdb; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_indices = $wpdb->get_results( "SHOW INDEX FROM $table" ); $this->assertCount( @@ -639,7 +635,6 @@ protected function assertTableHasNotColumn( $column, $table ) { global $wpdb; - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_fields = $wpdb->get_results( "DESCRIBE $table" ); $this->assertCount( 0, wp_list_filter( $table_fields, array( 'Field' => $column ) ) ); @@ -666,12 +661,10 @@ public function test_truncated_index() { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( $create ); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $index = $wpdb->get_row( "SHOW INDEXES FROM $table_name WHERE Key_name='a_key';" ); $actual = dbDelta( $create, false ); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS $table_name;" ); if ( 191 !== $index->Sub_part ) { diff --git a/tests/phpunit/tests/multisite/site.php b/tests/phpunit/tests/multisite/site.php index 3e40955c18920..6b3182c0d481e 100644 --- a/tests/phpunit/tests/multisite/site.php +++ b/tests/phpunit/tests/multisite/site.php @@ -169,7 +169,6 @@ public function test_created_site_details() { foreach ( $wpdb->tables( 'blog', false ) as $table ) { $suppress = $wpdb->suppress_errors(); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_fields = $wpdb->get_results( "DESCRIBE $prefix$table;" ); $wpdb->suppress_errors( $suppress ); @@ -178,7 +177,6 @@ public function test_created_site_details() { $this->assertNotEmpty( $table_fields ); // And the table should not be empty, unless commentmeta, termmeta, or links. - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $result = $wpdb->get_results( "SELECT * FROM $prefix$table LIMIT 1" ); if ( 'commentmeta' === $table || 'termmeta' === $table || 'links' === $table ) { @@ -252,7 +250,6 @@ public function test_data_in_tables_after_wpmu_delete_blog_drop_false() { foreach ( $wpdb->tables( 'blog', false ) as $table ) { $suppress = $wpdb->suppress_errors(); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_fields = $wpdb->get_results( "DESCRIBE $prefix$table;" ); $wpdb->suppress_errors( $suppress ); @@ -293,7 +290,6 @@ public function test_data_in_tables_after_wpmu_delete_blog_drop_true() { foreach ( $wpdb->tables( 'blog', false ) as $table ) { $suppress = $wpdb->suppress_errors(); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_fields = $wpdb->get_results( "DESCRIBE $prefix$table;" ); $wpdb->suppress_errors( $suppress ); @@ -334,7 +330,6 @@ public function test_data_in_tables_after_wpmu_delete_blog_main_site_drop_true() foreach ( $wpdb->tables( 'blog', false ) as $table ) { $suppress = $wpdb->suppress_errors(); - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $table_fields = $wpdb->get_results( "DESCRIBE $prefix$table;" ); $wpdb->suppress_errors( $suppress );