Commit 8dd6615403c for woocommerce
commit 8dd6615403c8196351a3f354f45fafe5e42a084d
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date: Wed Nov 11 08:29:34 2026 +0100
[Performance] Product reviews, optimize actualizing product average rating to avoid unnecessary products cache invalidation cascades (#70322)
Introduces \WC_Comments::maybe_update_product_average_rating to skip updates if possible, and skips one unnecessary SQL query when calculating the rating average.
diff --git a/plugins/woocommerce/changelog/performance-70269-product-cache-invalidation-on-clear-comments-transients b/plugins/woocommerce/changelog/performance-70269-product-cache-invalidation-on-clear-comments-transients
new file mode 100644
index 00000000000..e64d383c4ff
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-70269-product-cache-invalidation-on-clear-comments-transients
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Optimized actualizing product average rating to avoid unnecessary products cache invalidation cascades.
diff --git a/plugins/woocommerce/includes/class-wc-comments.php b/plugins/woocommerce/includes/class-wc-comments.php
index f2f2a6eac0b..ea7b3d44620 100644
--- a/plugins/woocommerce/includes/class-wc-comments.php
+++ b/plugins/woocommerce/includes/class-wc-comments.php
@@ -41,8 +41,7 @@ class WC_Comments {
add_action( 'comment_post', array( __CLASS__, 'add_comment_rating' ), 1 );
add_action( 'comment_moderation_recipients', array( __CLASS__, 'comment_moderation_recipients' ), 10, 2 );
- // Clear transients.
- add_action( 'wp_update_comment_count', array( __CLASS__, 'clear_transients' ) );
+ add_action( 'wp_update_comment_count', array( __CLASS__, 'maybe_update_product_average_rating' ) );
// Secure order notes.
add_filter( 'comments_clauses', array( __CLASS__, 'exclude_order_comments' ) );
@@ -258,7 +257,7 @@ class WC_Comments {
$post_id = isset( $_POST['comment_post_ID'] ) ? absint( $_POST['comment_post_ID'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Anonymous product reviews are submitted through the core comment form, which carries no WooCommerce nonce; the post ID is read through absint().
if ( $post_id ) {
- self::clear_transients( $post_id );
+ self::maybe_update_product_average_rating( $post_id );
}
}
}
@@ -283,9 +282,13 @@ class WC_Comments {
/**
* Ensure product average rating and review count is kept up to date.
*
+ * @deprecated 11.4.0
+ *
* @param int $post_id Post ID.
+ * @return void
*/
public static function clear_transients( $post_id ) {
+ wc_deprecated_function( __METHOD__, '11.4.0' );
$post_id = absint( $post_id );
if ( 0 === $post_id || 'product' !== get_post_type( $post_id ) ) {
return;
@@ -300,6 +303,36 @@ class WC_Comments {
}
}
+ /**
+ * Ensure product average rating and review count is kept up to date (the logic is subject of change; no BC guarantees).
+ *
+ * @internal
+ * @since 11.4.0
+ *
+ * @param int $post_id Post ID.
+ * @return void
+ */
+ public static function maybe_update_product_average_rating( $post_id ) {
+ $product_id = absint( $post_id );
+ $product = ( $product_id && 'product' === get_post_type( $product_id ) ) ? wc_get_product( $product_id ) : false;
+ // Performance note: save triggers products cache invalidation cascade, hence perform it only if necessary.
+ if ( $product instanceof WC_Product ) {
+ $reviews_count = (int) self::get_review_count_for_product( $product );
+ $ratings_stats = self::get_rating_counts_for_product( $product );
+ if ( $reviews_count !== $product->get_review_count( 'edit' ) || $ratings_stats != $product->get_rating_counts( 'edit' ) ) { // phpcs:ignore Universal.Operators.StrictComparisons -- performance optimization
+ $ratings_count = array_sum( $ratings_stats );
+ $weighted_sum = array_sum( array_map( static fn( $rating, $count ) => $rating * $count, array_keys( $ratings_stats ), $ratings_stats ) );
+ $ratings_average = $ratings_count ? number_format( $weighted_sum / $ratings_count, 2, '.', '' ) : '0.00';
+
+ $product->set_review_count( $reviews_count );
+ $product->set_rating_counts( $ratings_stats );
+ $product->set_average_rating( $ratings_average ); // @phpstan-ignore argument.type (false-positive)
+
+ $product->save();
+ }
+ }
+ }
+
/**
* Callback for 'wp_insert_comment' to delete the comment count cache if the comment is included in the count.
*
@@ -609,9 +642,10 @@ class WC_Comments {
* @return int
*/
public static function get_review_count_for_product( &$product ) {
- $counts = self::get_review_counts_for_product_ids( array( $product->get_id() ) );
+ $product_id = $product->get_id();
+ $counts = self::get_review_counts_for_product_ids( array( $product_id ) );
- return $counts[ $product->get_id() ];
+ return $counts[ $product_id ];
}
/**
@@ -624,24 +658,23 @@ class WC_Comments {
public static function get_rating_counts_for_product( &$product ) {
global $wpdb;
- $counts = array();
- $raw_counts = $wpdb->get_results(
+ $rows = $wpdb->get_results(
$wpdb->prepare(
- "
- SELECT meta_value, COUNT( * ) as meta_value_count FROM $wpdb->commentmeta
- LEFT JOIN $wpdb->comments ON $wpdb->commentmeta.comment_id = $wpdb->comments.comment_ID
- WHERE meta_key = 'rating'
- AND comment_post_ID = %d
- AND comment_approved = '1'
- AND meta_value > 0
- GROUP BY meta_value
- ",
+ "SELECT metas.meta_value AS rating, COUNT(*) AS count
+ FROM {$wpdb->commentmeta} metas
+ INNER JOIN {$wpdb->comments} comments ON metas.comment_id = comments.comment_ID
+ WHERE metas.meta_key = 'rating'
+ AND comments.comment_post_ID = %d
+ AND comments.comment_approved = '1'
+ AND metas.meta_value > 0
+ GROUP BY rating",
$product->get_id()
)
);
- foreach ( $raw_counts as $count ) {
- $counts[ $count->meta_value ] = absint( $count->meta_value_count );
+ $counts = array();
+ foreach ( $rows as $row ) {
+ $counts[ (int) $row->rating ] = (int) $row->count;
}
return $counts;
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
index ff07b391db8..7060e2169ae 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-reviews-v1-controller.php
@@ -333,7 +333,7 @@ class WC_REST_Product_Reviews_V1_Controller extends WC_REST_Controller {
* a rest_pre_insert_product_review callback can send the review to a different one.
*/
if ( ! empty( $request['rating'] ) && $product_review instanceof WP_Comment && '1' === $product_review->comment_approved ) {
- WC_Comments::clear_transients( (int) $product_review->comment_post_ID );
+ WC_Comments::maybe_update_product_average_rating( (int) $product_review->comment_post_ID );
}
$this->update_additional_fields_for_object( $product_review, $request );
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
index 27a8a2fa426..c3f491cc2eb 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-reviews-controller.php
@@ -487,7 +487,7 @@ class WC_REST_Product_Reviews_Controller extends WC_REST_Controller {
* review can change the aggregates.
*/
if ( ! empty( $request['rating'] ) && $review instanceof WP_Comment && '1' === $review->comment_approved ) {
- WC_Comments::clear_transients( (int) $review->comment_post_ID );
+ WC_Comments::maybe_update_product_average_rating( (int) $review->comment_post_ID );
}
/**
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 45c0d553b53..22df81362fb 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -2374,7 +2374,7 @@ function wc_update_500_fix_product_review_count() {
$current_review_count = intval( $product_data['meta_value'] );
if ( intval( $actual_review_counts[ $product_id ] ) !== $current_review_count ) {
- WC_Comments::clear_transients( $product_id );
+ WC_Comments::maybe_update_product_average_rating( $product_id );
}
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9a1a12d6f0f..97652e2e1e3 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -10446,12 +10446,6 @@ parameters:
count: 1
path: includes/class-wc-comments.php
- -
- message: '#^Method WC_Comments\:\:clear_transients\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/class-wc-comments.php
-
-
message: '#^Method WC_Comments\:\:delete_comments_count_cache\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index 66f478ef45d..2d45ae99904 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -361,7 +361,7 @@ class SubmissionHandler {
// Core recounts an approved comment before writing its meta, so recount once the rating is stored.
if ( 'approved' === $comment_status ) {
- WC_Comments::clear_transients( $review_post_id );
+ \WC_Comments::maybe_update_product_average_rating( $review_post_id );
}
$result['comment_id'] = (int) $comment_id;
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
index 0438e62ed2b..018947b16c7 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
@@ -361,7 +361,7 @@ test.describe( 'Product Reviews', () => {
page.locator( '.comment_container' ).first()
).toContainText( updatedReview );
await expect(
- page.getByLabel( `${ updatedRating } out of 5` )
+ page.getByLabel( `${ updatedRating } out of 5` ).first()
).toBeVisible();
} );
} );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
index 235dd7092e6..1dd54ccfc84 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
@@ -93,6 +93,8 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
* Test clear_transients with valid product ID.
*/
public function test_clear_transients_with_valid_product() {
+ $this->setExpectedDeprecated( 'WC_Comments::clear_transients' );
+
$product = WC_Helper_Product::create_simple_product();
$product_id = $product->get_id();
@@ -111,14 +113,48 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
$this->assertEquals( 1, $updated_product->get_review_count() );
}
+ /**
+ * @testdox maybe_update_product_average_rating updates review count and rating data when a new review is added.
+ */
+ public function test_maybe_update_product_average_rating_with_valid_product(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+ $review_id = ProductHelper::create_product_review( $product_id, 'Great product!' );
+
+ update_comment_meta( $review_id, 'rating', 5 );
+ WC_Comments::maybe_update_product_average_rating( $product_id );
+
+ $this->assertSame( 1, wc_get_product( $product_id )->get_review_count() );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox maybe_update_product_average_rating skips product save when review count has not changed.
+ */
+ public function test_maybe_update_product_average_rating_skips_save_when_review_count_unchanged(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+ $review_id = ProductHelper::create_product_review( $product_id, 'Great product!' );
+
+ update_comment_meta( $review_id, 'rating', 5 );
+ WC_Comments::maybe_update_product_average_rating( $product_id );
+
+ $save_count = did_action( 'woocommerce_update_product' );
+ WC_Comments::maybe_update_product_average_rating( $product_id );
+ $this->assertSame( $save_count, did_action( 'woocommerce_update_product' ), 'Product save should not fire when review count is unchanged.' );
+
+ $product->delete( true );
+ }
+
/**
* Test clear_transients with invalid product ID.
*/
public function test_clear_transients_with_invalid_product_id() {
+ $this->setExpectedDeprecated( 'WC_Comments::clear_transients' );
+
$invalid_product_id = 99999;
- // This should not cause any errors or exceptions.
- $this->expectNotToPerformAssertions();
WC_Comments::clear_transients( $invalid_product_id );
}
@@ -126,6 +162,8 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
* Test clear_transients with non-product post ID.
*/
public function test_clear_transients_with_non_product_post() {
+ $this->setExpectedDeprecated( 'WC_Comments::clear_transients' );
+
// Create a regular post (not a product).
$post_id = wp_insert_post(
array(
@@ -136,8 +174,6 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
)
);
- // This should not cause any errors or exceptions.
- $this->expectNotToPerformAssertions();
WC_Comments::clear_transients( $post_id );
// Clean up.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
index c60e1e9f1c0..cdd08a8bd71 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductRatingTest.php
@@ -43,7 +43,7 @@ class ProductRatingTest extends WC_Unit_Test_Case {
$product->set_reviews_allowed( $product_reviews_allowed );
$product->save();
- \WC_Comments::clear_transients( $product->get_id() );
+ \WC_Comments::maybe_update_product_average_rating( $product->get_id() );
clean_post_cache( $product->get_id() );
$product = wc_get_product( $product->get_id() );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php b/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php
index 662f63fb745..6fdd833b41d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Helpers/FixtureData.php
@@ -417,7 +417,7 @@ class FixtureData {
)
)
);
- \WC_Comments::clear_transients( $product_id );
+ \WC_Comments::maybe_update_product_average_rating( $product_id );
}
/**