diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 9baee0ab6b3fd..2e7bf089c1623 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -44,7 +44,6 @@ includes: - tests/phpstan/baselines/method.childParameterType.neon - tests/phpstan/baselines/method.unused.neon - tests/phpstan/baselines/notIdentical.alwaysTrue.neon - - tests/phpstan/baselines/nullCoalesce.property.neon - tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon - tests/phpstan/baselines/offsetAccess.notFound.neon - tests/phpstan/baselines/offsetAssign.valueType.neon diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 88981421ff937..2b593c0addd6b 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -186,6 +186,7 @@ class WP_Query { * * @since 2.1.0 * @var int + * @phpstan-var int<0, max> */ public $max_num_pages = 0; @@ -3732,7 +3733,7 @@ private function set_found_posts( $query_vars, $limits ) { $this->found_posts = (int) apply_filters_ref_array( 'found_posts', array( $this->found_posts, &$this ) ); if ( ! empty( $limits ) ) { - $this->max_num_pages = (int) ceil( $this->found_posts / $query_vars['posts_per_page'] ); + $this->max_num_pages = max( 0, (int) ceil( $this->found_posts / (int) $query_vars['posts_per_page'] ) ); } } diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index eb6e185f5b49d..f91680a9ef44c 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4983,7 +4983,7 @@ function paginate_links( $args = '' ) { $url_parts = explode( '?', $pagenum_link ); // Get max pages and current page out of the current query, if available. - $total = $wp_query->max_num_pages ?? 1; + $total = $wp_query->max_num_pages; $current = get_query_var( 'paged' ) ? (int) get_query_var( 'paged' ) : 1; /* diff --git a/src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php b/src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php index c96b12c7e25f6..c1545c26b9105 100644 --- a/src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php +++ b/src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php @@ -209,7 +209,7 @@ public function get_max_num_pages( $object_subtype = '' ) { $query = new WP_Query( $args ); $min_num_pages = ( 'page' === $post_type && 'posts' === get_option( 'show_on_front' ) ) ? 1 : 0; - return isset( $query->max_num_pages ) ? max( $min_num_pages, $query->max_num_pages ) : 1; + return max( $min_num_pages, $query->max_num_pages ); } /** diff --git a/tests/phpstan/baselines/isset.property.neon b/tests/phpstan/baselines/isset.property.neon index 063b443d92227..5278005df2869 100644 --- a/tests/phpstan/baselines/isset.property.neon +++ b/tests/phpstan/baselines/isset.property.neon @@ -178,11 +178,6 @@ parameters: identifier: isset.property count: 1 path: ../../../src/wp-includes/rest-api/endpoints/class-wp-rest-font-families-controller.php - - - message: '#^Property WP_Query\:\:\$max_num_pages \(int\) in isset\(\) is not nullable\.$#' - identifier: isset.property - count: 1 - path: ../../../src/wp-includes/sitemaps/providers/class-wp-sitemaps-posts.php - message: '#^Property WP_Site\:\:\$domain \(string\) in isset\(\) is not nullable\.$#' identifier: isset.property diff --git a/tests/phpstan/baselines/nullCoalesce.property.neon b/tests/phpstan/baselines/nullCoalesce.property.neon deleted file mode 100644 index 0085e07d9614f..0000000000000 --- a/tests/phpstan/baselines/nullCoalesce.property.neon +++ /dev/null @@ -1,25 +0,0 @@ -# PHPStan baseline for the `nullCoalesce.property` errors in WordPress core. -# -# https://phpstan.org/error-identifiers/nullCoalesce.property -# -# 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=nullCoalesce.property -# -# which reruns the analysis with this file suppressed so the errors surface again. - -parameters: - ignoreErrors: - - - message: '#^Property WP_Query\:\:\$max_num_pages \(int\) on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.property - count: 1 - path: ../../../src/wp-includes/general-template.php diff --git a/tests/phpunit/tests/query/noFoundRows.php b/tests/phpunit/tests/query/noFoundRows.php index 7b4f66a416030..e9ffcd6581362 100644 --- a/tests/phpunit/tests/query/noFoundRows.php +++ b/tests/phpunit/tests/query/noFoundRows.php @@ -102,4 +102,62 @@ public function test_no_found_rows_default_with_postsperpage_minus1() { $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $q->request ); $this->assertSame( 1, $q->found_posts ); } + + /** + * Ensures {@see WP_Query::$max_num_pages} is not negative when the + * {@see 'found_posts'} filter returns a negative count. + * + * @ticket 65817 + */ + public function test_max_num_pages_is_not_negative_when_found_posts_is_filtered_negative() { + self::factory()->post->create_many( 3 ); + + add_filter( + 'found_posts', + static function (): int { + return -5; + } + ); + + $q = new WP_Query( + array( + 'post_type' => 'post', + 'posts_per_page' => 2, + ) + ); + + $this->assertSame( -5, $q->found_posts, 'Expected the filtered found_posts value to be kept.' ); + $this->assertSame( 0, $q->max_num_pages, 'Expected max_num_pages to be clamped to zero.' ); + } + + /** + * Ensures {@see WP_Query::$max_num_pages} is not negative when a + * {@see 'post_limits'} filter injects a non-empty `LIMIT` clause on a + * `posts_per_page = -1` query, which otherwise never runs the found-rows + * calculation and skips the `max_num_pages` division entirely. + * + * @ticket 65817 + */ + public function test_max_num_pages_is_not_negative_when_limits_are_filtered_with_postsperpage_minus1() { + self::factory()->post->create_many( 3 ); + + add_filter( + 'post_limits', + static function (): string { + return 'LIMIT 0, 2'; + } + ); + + $q = new WP_Query( + array( + 'post_type' => 'post', + 'posts_per_page' => -1, + ) + ); + + $this->assertIsString( $q->request ); + $this->assertStringContainsString( 'SQL_CALC_FOUND_ROWS', $q->request, 'Expected the filtered LIMIT to trigger the found-rows query.' ); + $this->assertSame( 3, $q->found_posts, 'Expected found_posts to reflect all matching rows.' ); + $this->assertSame( 0, $q->max_num_pages, 'Expected max_num_pages to be clamped to zero.' ); + } }