diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index cef3413cd8629..272b5d90cea36 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -3328,6 +3328,141 @@ function wp_update_comment_count_now( $post_id ) { return true; } +/** + * Recalculates the stored comment count for one or more posts. + * + * Each post's `comment_count` is recomputed with wp_update_comment_count_now(), + * so the result honors the {@see 'default_excluded_comment_types'} filter. + * + * This is the bulk counterpart to wp_update_comment_count(): the stored count + * is only refreshed for a post when its comments change, so an existing count can + * become stale after the set of excluded comment types changes (for example when + * a plugin registers a type that opts out of default listings). A plugin that + * changes that set should call this once, the same way rewrite rules are flushed + * with flush_rewrite_rules(). + * + * Cached comment query results are bumped through the comment `last_changed` key + * when there is at least one post to visit, so that queries cached alongside the + * previous counts are not served afterwards. + * + * Recalculating every post is proportional to the number of posts that have + * comments and can be expensive on large sites. Each recalculation also fires + * the usual post-update hooks (`wp_update_comment_count`, `edit_post`), so cache + * purgers and search indexers run once per post. Pass a specific list of post IDs + * to limit the work. A full recount on a large site does not belong in a plugin + * activation routine, which runs in a normal web request and will hit + * `max_execution_time` partway through: schedule it with wp_schedule_single_event() + * or run it from WP-CLI instead. Stopping partway is safe - every post visited + * before the stop has a correct count, and the operation is idempotent, so a + * re-run simply redoes the earlier posts. + * + * Counts are written immediately: this does not participate in + * wp_defer_comment_counting(). + * + * There is no capability check, matching the rest of this family. Anything that + * exposes it to a request has to perform its own capability and nonce checks. + * + * @since 7.1.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. Post IDs that do not + * exist are skipped and are not included in the count. + */ +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; + } + + wp_cache_set_last_changed( 'comment' ); + + /** + * Filters how many posts wp_update_comment_counts() recalculates per batch. + * + * Lower it to spread a large recount over shorter queries, or raise it to trade + * memory for fewer round trips. + * + * @since 7.1.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 every post that has at least one comment row, plus every post with + * a nonzero stored count (whose comment rows may all have been deleted), in + * keyset batches so the full ID list is never materialized in memory. + * + * Each arm carries its own ORDER BY and LIMIT. MySQL cannot push the outer + * ones into a parenthesized UNION arm, so without them every iteration + * materializes all remaining rows into a temp table before taking a batch, + * and the posts arm scans the whole remaining table because comment_count is + * unindexed. Limiting the arms is safe: 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 + ) + ); + + 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..5aad45e1cbbff --- /dev/null +++ b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php @@ -0,0 +1,283 @@ +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() { + $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() { + $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() { + $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() { + $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() { + $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() { + $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() { + $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() { + $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 ) { + $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' ) ); + } + + /** + * 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() { + $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 ) ); + } + } + + /** + * 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() { + $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 ( $types ) { + $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 ) ); + } +}