diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index c4578c54fb9ed..cf3734d406509 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3410,6 +3410,116 @@ function wp_update_comment_count_now( $post_id ) { return true; } +/** + * Recalculates the stored comment count for one or more posts. + * + * Counts are recomputed with wp_update_comment_count_now(), so they honor the + * {@see 'default_excluded_comment_types'} filter. Call this once after changing + * the excluded types, the same way flush_rewrite_rules() is called after + * changing rewrite rules. + * + * Each recount fires the `wp_update_comment_count` and `edit_post` hooks and is + * written immediately, ignoring wp_defer_comment_counting(). A full recount on a + * large site is slow, so run it from WP-CLI or a scheduled event rather than an + * activation hook. It is idempotent and safe to interrupt. + * + * Bumps the comment `last_changed` cache key when there is a post to recount. + * Performs no capability checks. + * + * @since 7.3.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param int[]|int|null $post_ids Optional. Post ID or array of post IDs to recalculate. + * Default null, which recalculates every post that has + * at least one comment or a nonzero stored count. + * @return int Number of posts whose comment count was recalculated. Nonexistent + * post IDs are skipped. + */ +function wp_update_comment_counts( $post_ids = null ) { + global $wpdb; + + $recalculated = 0; + + if ( null !== $post_ids ) { + $post_ids = array_map( 'intval', (array) $post_ids ); + $post_ids = array_unique( + array_filter( + $post_ids, + static function ( $post_id ) { + return $post_id > 0; + } + ) + ); + + if ( ! $post_ids ) { + return 0; + } + + wp_cache_set_last_changed( 'comment' ); + + foreach ( $post_ids as $post_id ) { + if ( wp_update_comment_count_now( $post_id ) ) { + ++$recalculated; + } + } + + return $recalculated; + } + + /** + * Filters how many posts wp_update_comment_counts() recalculates per batch. + * + * @since 7.3.0 + * + * @param int $batch_size Number of posts to look up per query. Default 1000. + */ + $batch_size = (int) apply_filters( 'wp_update_comment_counts_batch_size', 1000 ); + + if ( $batch_size < 1 ) { + $batch_size = 1; + } + + /* + * Visit posts with comment rows plus posts with a nonzero stored count, in + * keyset batches. Each union arm needs its own ORDER BY and LIMIT because + * MySQL does not push the outer ones into the arms. The first N rows of the + * union of two ascending sets are always among the first N of each. + */ + $last_post_id = 0; + + do { + $batch = $wpdb->get_col( + $wpdb->prepare( + "( SELECT DISTINCT comment_post_ID AS post_id FROM {$wpdb->comments} WHERE comment_post_ID > %d ORDER BY comment_post_ID LIMIT %d ) + UNION + ( SELECT ID AS post_id FROM {$wpdb->posts} WHERE comment_count <> 0 AND ID > %d ORDER BY ID LIMIT %d ) + ORDER BY post_id + LIMIT %d", + $last_post_id, + $batch_size, + $last_post_id, + $batch_size, + $batch_size + ) + ); + + if ( $batch && 0 === $last_post_id ) { + wp_cache_set_last_changed( 'comment' ); + } + + foreach ( $batch as $post_id ) { + if ( wp_update_comment_count_now( (int) $post_id ) ) { + ++$recalculated; + } + } + + $last_post_id = $batch ? (int) end( $batch ) : 0; + } while ( count( $batch ) === $batch_size ); + + return $recalculated; +} + // // Ping and trackback functions. // diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCounts.php b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php new file mode 100644 index 0000000000000..e25d4ffcc06d9 --- /dev/null +++ b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php @@ -0,0 +1,353 @@ +update( $wpdb->posts, array( 'comment_count' => $count ), array( 'ID' => $post_id ) ); + clean_post_cache( $post_id ); + } + + /** + * @ticket 65537 + */ + public function test_returns_zero_when_there_is_nothing_to_recalculate(): void { + $this->assertSame( 0, wp_update_comment_counts( array() ) ); + $this->assertSame( 0, wp_update_comment_counts( 0 ) ); + } + + /** + * @ticket 65537 + */ + public function test_recalculates_only_the_given_posts(): void { + $post_a = self::factory()->post->create(); + $post_b = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_a, + 'comment_approved' => 1, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_b, + 'comment_approved' => 1, + ) + ); + + // Corrupt both stored counts, then recalculate only the first post. + $this->set_stored_count( $post_a, 99 ); + $this->set_stored_count( $post_b, 99 ); + + $recalculated = wp_update_comment_counts( $post_a ); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '1', get_comments_number( $post_a ) ); + $this->assertSame( '99', get_comments_number( $post_b ) ); + } + + /** + * @ticket 65537 + */ + public function test_deduplicates_repeated_post_ids(): void { + $post_id = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + $this->set_stored_count( $post_id, 99 ); + + $recalculated = wp_update_comment_counts( array( $post_id, $post_id ) ); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + + /** + * @ticket 65537 + */ + public function test_null_recalculates_all_posts_with_comments(): void { + $post_a = self::factory()->post->create(); + $post_b = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_a, + 'comment_approved' => 1, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_b, + 'comment_approved' => 1, + ) + ); + $this->set_stored_count( $post_a, 99 ); + $this->set_stored_count( $post_b, 99 ); + + $recalculated = wp_update_comment_counts(); + + $this->assertSame( 2, $recalculated ); + $this->assertSame( '1', get_comments_number( $post_a ) ); + $this->assertSame( '1', get_comments_number( $post_b ) ); + } + + /** + * Nonexistent, zero, and negative post IDs are skipped, not recounted. + * + * @ticket 65537 + */ + public function test_invalid_post_ids_are_skipped(): void { + $post_id = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + $this->set_stored_count( $post_id, 99 ); + + // A negative ID must not be silently coerced into a valid one. + $this->assertSame( 0, wp_update_comment_counts( array( -$post_id, 0, PHP_INT_MAX ) ) ); + $this->assertSame( '99', get_comments_number( $post_id ), 'The negated ID should not recount the positive post.' ); + } + + /** + * An explicit ID for a post with no remaining comment rows forces a stale + * stored count back to zero. + * + * @ticket 65537 + */ + public function test_explicit_id_resets_stale_count_on_commentless_post(): void { + $post_id = self::factory()->post->create(); + $this->set_stored_count( $post_id, 5 ); + + $this->assertSame( 1, wp_update_comment_counts( $post_id ) ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + } + + /** + * The null path also visits posts with a nonzero stored count but no + * remaining comment rows, which the comments table alone cannot reveal. + * + * @ticket 65537 + */ + public function test_null_path_resets_stale_count_on_commentless_post(): void { + $post_id = self::factory()->post->create(); + $this->set_stored_count( $post_id, 5 ); + + $recalculated = wp_update_comment_counts(); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + } + + /** + * Recounting bumps the comment last_changed key so cached query results + * from before the excluded set changed are invalidated. + * + * @ticket 65537 + */ + public function test_recount_invalidates_comment_query_cache(): void { + $post_id = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + + $before = wp_cache_get_last_changed( 'comment' ); + + wp_update_comment_counts( $post_id ); + + $this->assertNotSame( $before, wp_cache_get_last_changed( 'comment' ) ); + } + + /** + * A call with nothing to recount should not flush every cached comment query. + * + * @ticket 65537 + * + * @dataProvider data_no_op_recount_arguments + * + * @param int[] $post_ids Post IDs to pass to wp_update_comment_counts(). + */ + public function test_no_op_recount_leaves_the_comment_query_cache_alone( array $post_ids ): void { + $before = wp_cache_get_last_changed( 'comment' ); + + $this->assertSame( 0, wp_update_comment_counts( $post_ids ) ); + $this->assertSame( $before, wp_cache_get_last_changed( 'comment' ) ); + } + + /** + * The null path should not flush cached comment queries when no post needs a recount. + * + * @ticket 65537 + */ + public function test_null_path_with_nothing_to_recount_leaves_the_comment_query_cache_alone(): void { + self::factory()->post->create(); + + $before = wp_cache_get_last_changed( 'comment' ); + + $this->assertSame( 0, wp_update_comment_counts() ); + $this->assertSame( $before, wp_cache_get_last_changed( 'comment' ) ); + } + + /** + * Recounting every post bumps the comment last_changed key. + * + * @ticket 65537 + */ + public function test_null_path_recount_invalidates_comment_query_cache(): void { + $post_id = self::factory()->post->create(); + $this->set_stored_count( $post_id, 3 ); + + $before = wp_cache_get_last_changed( 'comment' ); + + wp_update_comment_counts(); + + $this->assertNotSame( $before, wp_cache_get_last_changed( 'comment' ) ); + } + + /** + * Data provider for test_no_op_recount_leaves_the_comment_query_cache_alone(). + * + * @return array> + */ + public function data_no_op_recount_arguments(): array { + return array( + 'empty list' => array( array() ), + 'only invalid IDs' => array( array( 0, -1 ) ), + ); + } + + /** + * The keyset loop has to advance across batches, and a post that has both comments + * and a stale nonzero count appears in both arms of the union. + * + * @ticket 65537 + */ + public function test_recount_crosses_batch_boundaries(): void { + $post_ids = self::factory()->post->create_many( 5 ); + + foreach ( $post_ids as $post_id ) { + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + + // Stale count, so each post is returned by both union arms. + $this->set_stored_count( $post_id, 7 ); + } + + add_filter( + 'wp_update_comment_counts_batch_size', + static function () { + return 2; + } + ); + + $this->assertSame( + 5, + wp_update_comment_counts(), + 'Every post should be visited exactly once across the batches.' + ); + + foreach ( $post_ids as $post_id ) { + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + } + + /** + * Posts found by only one arm of the union interleave across batch boundaries + * without being skipped. + * + * @ticket 65537 + */ + public function test_recount_interleaves_union_arms_across_batches(): void { + $post_ids = self::factory()->post->create_many( 6 ); + + foreach ( $post_ids as $index => $post_id ) { + if ( 0 === $index % 2 ) { + // Comments arm only: an unapproved comment leaves the stored count at 0. + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 0, + ) + ); + } else { + // Posts arm only: a stale count with no comment rows. + $this->set_stored_count( $post_id, 4 ); + } + } + + add_filter( + 'wp_update_comment_counts_batch_size', + static function () { + return 2; + } + ); + + $this->assertSame( 6, wp_update_comment_counts() ); + + foreach ( $post_ids as $post_id ) { + $this->assertSame( '0', get_comments_number( $post_id ) ); + } + } + + /** + * The headline scenario: a type that joins the excluded set after comments + * already exist must not keep inflating a previously stored count. + * + * @ticket 65537 + */ + public function test_recalculation_drops_a_newly_excluded_type(): void { + $post_id = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'review', + 'comment_approved' => 1, + ) + ); + + // Baseline: 'review' is counted, so the stored count is 1. + wp_update_comment_count_now( $post_id ); + $this->assertSame( '1', get_comments_number( $post_id ) ); + + // A plugin now excludes 'review'. The stored count stays stale until a recount. + $filter = static function ( array $types ): array { + $types[] = 'review'; + return $types; + }; + add_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + + $recalculated = wp_update_comment_counts( $post_id ); + + remove_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + } +}