diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index acf634e2bab02..6819de6402427 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2926,6 +2926,55 @@ 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 recount 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, typically from its activation routine, + * the same way rewrite rules are flushed with flush_rewrite_rules(). + * + * Recalculating every post is proportional to the number of posts that have + * comments and can be expensive on large sites. Pass a specific list of post IDs + * to limit the work, or run it from a maintenance context such as WP-CLI. + * + * @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. + * @return int Number of posts whose comment count was recalculated. + */ +function wp_update_comment_counts( $post_ids = null ) { + global $wpdb; + + if ( null === $post_ids ) { + $post_ids = $wpdb->get_col( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments" ); + } + + $post_ids = array_unique( array_filter( array_map( 'absint', (array) $post_ids ) ) ); + + if ( empty( $post_ids ) ) { + return 0; + } + + $recalculated = 0; + foreach ( $post_ids as $post_id ) { + if ( wp_update_comment_count_now( $post_id ) ) { + ++$recalculated; + } + } + + 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..6ca08be44125f --- /dev/null +++ b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php @@ -0,0 +1,143 @@ +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 ); + + wp_update_comment_counts(); + + $this->assertSame( '1', get_comments_number( $post_a ) ); + $this->assertSame( '1', get_comments_number( $post_b ) ); + } + + /** + * 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 ) ); + } +}