Commit cde33c6b4db for woocommerce

commit cde33c6b4dbeb0fc14d7872df7ca7e531d80a3ba
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Fri Sep 4 14:28:44 2026 -0400

    Aggregate the customer and review report totals in one query (#68302)

diff --git a/plugins/woocommerce/changelog/fix-wooplug-7557-report-aggregation-queries b/plugins/woocommerce/changelog/fix-wooplug-7557-report-aggregation-queries
new file mode 100644
index 00000000000..1f0dbce88be
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7557-report-aggregation-queries
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Count the customer and review report totals with aggregate queries instead of one query per bucket.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-customers-totals-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-customers-totals-controller.php
index 5e5e6797704..98058ff9d63 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-customers-totals-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-customers-totals-controller.php
@@ -39,6 +39,8 @@ class WC_REST_Report_Customers_Totals_Controller extends WC_REST_Reports_Control
 	 * @return array
 	 */
 	protected function get_reports() {
+		global $wpdb;
+
 		$users_count     = count_users();
 		$total_customers = 0;

@@ -50,23 +52,44 @@ class WC_REST_Report_Customers_Totals_Controller extends WC_REST_Reports_Control
 			$total_customers += (int) $total;
 		}

-		$customers_query = new WP_User_Query(
-			array(
-				'role__not_in' => array( 'administrator', 'shop_manager' ),
-				'number'       => 0,
-				'fields'       => 'ID',
-				'count_total'  => true,
-				'meta_query'   => array( // WPCS: slow query ok.
-					array(
-						'key'     => 'paying_customer',
-						'value'   => 1,
-						'compare' => '=',
+		// Same cache group and invalidation signal WP_User_Query used, so meta written outside WooCommerce still refreshes the total.
+		$cache_key    = 'wc_report_customers_totals_paying_' . get_current_blog_id() . '_' . wp_cache_get_last_changed( 'users' );
+		$total_paying = wp_cache_get( $cache_key, 'user-queries' );
+
+		if ( false === $total_paying ) {
+			/*
+			 * Let WP_User_Query build the role, capability and site scoping, then count with its
+			 * clauses. Running the query itself selects and sorts every matching user ID only to
+			 * throw them away: roughly 100KB of PHP memory per 1,000 paying customers.
+			 */
+			$customers_query = new WP_User_Query();
+			$customers_query->prepare_query(
+				array(
+					'role__not_in' => array( 'administrator', 'shop_manager' ),
+					'number'       => 0,
+					'fields'       => 'ID',
+					'count_total'  => true,
+					'meta_query'   => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The paying_customer flag lives only in usermeta, so the usermeta join is the only way to read it; the clauses are counted rather than materialised.
+						array(
+							'key'     => 'paying_customer',
+							'value'   => 1,
+							'compare' => '=',
+						),
 					),
-				),
-			)
-		);
+				)
+			);
+
+			$total_paying = $wpdb->get_var( "SELECT COUNT(*) {$customers_query->query_from} {$customers_query->query_where}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Both clauses are built and escaped by WP_User_Query::prepare_query().
+
+			// Never cache a failed count; a zeroed total would stick until the next user or user meta changed.
+			if ( null !== $total_paying ) {
+				$total_paying = (int) $total_paying;
+
+				wp_cache_set( $cache_key, $total_paying, 'user-queries' );
+			}
+		}

-		$total_paying = (int) $customers_query->get_total();
+		$total_paying = (int) $total_paying;

 		$data = array(
 			array(
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-reviews-totals-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-reviews-totals-controller.php
index 324a4aafd7a..34ea15894cd 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-reviews-totals-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-reviews-totals-controller.php
@@ -39,23 +39,92 @@ class WC_REST_Report_Reviews_Totals_Controller extends WC_REST_Reports_Controlle
 	 * @return array
 	 */
 	protected function get_reports() {
-		$data = array();
+		global $wpdb;

-		$query_data = array(
-			'count'      => true,
-			'post_type'  => 'product',
-			'meta_key'   => 'rating', // WPCS: slow query ok.
-			'meta_value' => '', // WPCS: slow query ok.
-		);
+		$counts = array_fill_keys( range( 1, 5 ), 0 );

-		for ( $i = 1; $i <= 5; $i++ ) {
-			$query_data['meta_value'] = $i;
+		// Same cache group and invalidation signal get_comments() used, so ratings written outside WooCommerce still refresh the totals.
+		$cache_key = 'wc_report_reviews_totals_' . wp_cache_get_last_changed( 'comment' );
+		$cached    = wp_cache_get( $cache_key, 'comment-queries' );
+
+		if ( is_array( $cached ) ) {
+			$counts = $cached;
+		} else {
+			/*
+			 * A single grouped aggregate in place of one COUNT query per rating. The clauses below are the
+			 * ones WP_Comment_Query::get_comment_ids() would build for the arguments this report used, and
+			 * they are passed through comments_clauses so WooCommerce's own callbacks -- the ones hiding
+			 * order notes, webhook deliveries and action logs -- and any extension's keep applying.
+			 *
+			 * Two other comment query hooks cannot apply here. comments_pre_query substitutes a list of
+			 * comments or a single count, neither of which describes a per-rating breakdown, and
+			 * pre_get_comments runs before any clause exists and lets callbacks set query vars that a
+			 * grouped aggregate has no way to honour.
+			 */
+			$comment_query = new WP_Comment_Query();
+			$comment_query->parse_query(
+				array(
+					'count'     => true,
+					'post_type' => 'product',
+					'meta_key'  => 'rating', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a query argument here; it tells comments_clauses callbacks which meta the aggregate below reads.
+					'status'    => 'all',
+				)
+			);
+
+			$clauses = array(
+				'fields'  => "{$wpdb->commentmeta}.meta_value AS rating, COUNT(*) AS total",
+				'join'    => "INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$wpdb->comments}.comment_post_ID"
+					. " INNER JOIN {$wpdb->commentmeta} ON {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id",
+
+				/*
+				 * Comments awaiting moderation count, as the 'all' status did; only spam and trashed ones drop
+				 * out. The 'note' type is excluded by WP_Comment_Query itself rather than by any filter, so it
+				 * is mirrored here. Ratings are compared as strings, as the meta query did, so '0' and unrated
+				 * comments fall out.
+				 */
+				'where'   => "{$wpdb->comments}.comment_approved IN ( '0', '1' )"
+					. " AND {$wpdb->comments}.comment_type NOT IN ( 'note' )"
+					. " AND {$wpdb->posts}.post_type = 'product'"
+					. " AND {$wpdb->commentmeta}.meta_key = 'rating'"
+					. " AND {$wpdb->commentmeta}.meta_value IN ( '1', '2', '3', '4', '5' )",
+				'orderby' => '',
+				'limits'  => '',
+				'groupby' => "{$wpdb->commentmeta}.meta_value",
+			);
+
+			/** This filter is documented in wp-includes/class-wp-comment-query.php */
+			$clauses = apply_filters_ref_array( 'comments_clauses', array( $clauses, &$comment_query ) );

+			$fields  = isset( $clauses['fields'] ) ? trim( $clauses['fields'] ) : '';
+			$join    = isset( $clauses['join'] ) ? trim( $clauses['join'] ) : '';
+			$where   = isset( $clauses['where'] ) ? trim( $clauses['where'] ) : '';
+			$groupby = isset( $clauses['groupby'] ) ? trim( $clauses['groupby'] ) : '';
+
+			// A callback that empties any of these would leave either invalid SQL or a total with no rating buckets.
+			if ( '' !== $fields && '' !== $join && '' !== $where && '' !== $groupby ) {
+				$rows = $wpdb->get_results( "SELECT {$fields} FROM {$wpdb->comments} {$join} WHERE {$where} GROUP BY {$groupby}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Every clause is either built above from literals and $wpdb table names or supplied by a comments_clauses callback, exactly as WP_Comment_Query assembles them.
+
+				foreach ( $rows as $row ) {
+					if ( isset( $row->rating, $row->total ) && isset( $counts[ (int) $row->rating ] ) ) {
+						$counts[ (int) $row->rating ] = (int) $row->total;
+					}
+				}
+
+				// Never cache a failed aggregate; a zeroed report would stick until the next comment changed.
+				if ( '' === $wpdb->last_error ) {
+					wp_cache_set( $cache_key, $counts, 'comment-queries' );
+				}
+			}
+		}
+
+		$data = array();
+
+		for ( $i = 1; $i <= 5; $i++ ) {
 			$data[] = array(
 				'slug'  => 'rated_' . $i . '_out_of_5',
 				/* translators: %s: average rating */
 				'name'  => sprintf( __( 'Rated %s out of 5', 'woocommerce' ), $i ),
-				'total' => (int) get_comments( $query_data ),
+				'total' => (int) $counts[ $i ],
 			);
 		}

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 836df98de44..ece0f380a1a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -30087,12 +30087,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version3/class-wc-rest-report-reviews-totals-controller.php

-		-
-			message: '#^Parameter \#1 \$args of function get_comments expects array\{author_email\?\: string, author_url\?\: string, author__in\?\: array\<int\>, author__not_in\?\: array\<int\>, comment__in\?\: array\<int\>, comment__not_in\?\: array\<int\>, count\?\: bool, date_query\?\: array, \.\.\.\}, array\{count\: true, post_type\: ''product'', meta_key\: ''rating'', meta_value\: int\<1, 5\>\} given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-report-reviews-totals-controller.php
-
 		-
 			message: '#^PHPDoc tag @extends has invalid value \(WC_REST_Report_Sales_V2_Controller\)\: Unexpected token "\\n ", expected ''\<'' at offset 128 on line 5$#'
 			identifier: phpDoc.parseError
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-customers-totals.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-customers-totals.php
index 32c1d12da23..c10e3f2ea02 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-customers-totals.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-customers-totals.php
@@ -31,60 +31,73 @@ class WC_Tests_API_Reports_Customers_Totals extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * Test getting all product reviews.
+	 * Fetch the endpoint and index the totals by slug.
+	 *
+	 * @return array
+	 */
+	private function get_totals_by_slug() {
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports/customers/totals' ) );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		return wp_list_pluck( $response->get_data(), 'total', 'slug' );
+	}
+
+	/**
+	 * Test getting the customer totals.
 	 *
 	 * @since 3.5.0
 	 */
 	public function test_get_reports() {
 		wp_set_current_user( $this->user );

-		$response        = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports/customers/totals' ) );
-		$report          = $response->get_data();
-		$users_count     = count_users();
-		$total_customers = 0;
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports/customers/totals' ) );
+		$report   = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 2, count( $report ) );
+		$this->assertEquals( 'paying', $report[0]['slug'] );
+		$this->assertEquals( 'Paying customer', $report[0]['name'] );
+		$this->assertEquals( 'non_paying', $report[1]['slug'] );
+		$this->assertEquals( 'Non-paying customer', $report[1]['name'] );
+	}
+
+	/**
+	 * Only customers whose paying_customer meta is exactly "1" count as paying, and
+	 * administrators and shop managers stay out of both totals.
+	 */
+	public function test_get_reports_counts_paying_customers() {
+		wp_set_current_user( $this->user );
+
+		// Read the totals first so the users added below have to invalidate them.
+		$before = $this->get_totals_by_slug();

-		foreach ( $users_count['avail_roles'] as $role => $total ) {
-			if ( in_array( $role, array( 'administrator', 'shop_manager' ), true ) ) {
-				continue;
-			}
+		$paying = $this->factory->user->create( array( 'role' => 'customer' ) );
+		update_user_meta( $paying, 'paying_customer', 1 );

-			$total_customers += (int) $total;
-		}
+		// Stored as a string and compared as one, so a padded value is not a match.
+		$padded = $this->factory->user->create( array( 'role' => 'customer' ) );
+		update_user_meta( $padded, 'paying_customer', '01' );

-		$customers_query = new WP_User_Query(
-			array(
-				'role__not_in' => array( 'administrator', 'shop_manager' ),
-				'number'       => 0,
-				'fields'       => 'ID',
-				'count_total'  => true,
-				'meta_query'   => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Test setup intentionally mirrors the legacy production query shape.
-					array(
-						'key'     => 'paying_customer',
-						'value'   => 1,
-						'compare' => '=',
-					),
-				),
-			)
-		);
+		$not_paying = $this->factory->user->create( array( 'role' => 'customer' ) );
+		update_user_meta( $not_paying, 'paying_customer', 0 );

-		$total_paying = (int) $customers_query->get_total();
+		// No paying_customer meta at all.
+		$this->factory->user->create( array( 'role' => 'customer' ) );

-		$data = array(
-			array(
-				'slug'  => 'paying',
-				'name'  => __( 'Paying customer', 'woocommerce' ),
-				'total' => $total_paying,
-			),
-			array(
-				'slug'  => 'non_paying',
-				'name'  => __( 'Non-paying customer', 'woocommerce' ),
-				'total' => $total_customers - $total_paying,
-			),
-		);
+		$paying_manager = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+		update_user_meta( $paying_manager, 'paying_customer', 1 );

-		$this->assertEquals( 200, $response->get_status() );
-		$this->assertEquals( 2, count( $report ) );
-		$this->assertEquals( $data, $report );
+		$paying_admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		update_user_meta( $paying_admin, 'paying_customer', 1 );
+
+		$after = $this->get_totals_by_slug();
+
+		// Only the one customer with paying_customer set to 1.
+		$this->assertSame( 1, $after['paying'] - $before['paying'] );
+
+		// The other three customers; the shop manager and the administrator are excluded by role.
+		$this->assertSame( 3, $after['non_paying'] - $before['non_paying'] );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-reviews-totals.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-reviews-totals.php
index 157f1997414..44419d1b167 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-reviews-totals.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/reports-reviews-totals.php
@@ -8,6 +8,13 @@

 class WC_Tests_API_Reports_Reviews_Totals extends WC_REST_Unit_Test_Case {

+	/**
+	 * Sequence number keeping each submitted review distinct from the last.
+	 *
+	 * @var int
+	 */
+	private $review_sequence = 0;
+
 	/**
 	 * Setup our test server, endpoints, and user info.
 	 */
@@ -30,40 +37,238 @@ class WC_Tests_API_Reports_Reviews_Totals extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( '/wc/v3/reports/reviews/totals', $routes );
 	}

+	/**
+	 * Submit a review the way the storefront does.
+	 *
+	 * wp_handle_comment_submission() posts comment_post_ID and rating and passes a default
+	 * comment_type, which is what lets WC_Comments::update_comment_type() promote the comment to a
+	 * review on preprocess_comment and WC_Comments::add_comment_rating() store the rating meta on
+	 * comment_post. The comment_type has to be passed: wp_new_comment() only defaults it after
+	 * preprocess_comment has run, so omitting it leaves WooCommerce's callback nothing to promote.
+	 *
+	 * @param int    $product_id Product being reviewed.
+	 * @param int    $rating     Rating from 1 to 5.
+	 * @param string $status     Comment status to settle on, 'approve' or 'hold'.
+	 * @return int
+	 */
+	private function submit_review_through_comment_form( $product_id, $rating, $status = 'approve' ) {
+		++$this->review_sequence;
+
+		$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The superglobal is saved and restored, not read as form data; the review form's fields are set below to drive WooCommerce's own comment hooks.
+
+		try {
+			$_POST['comment_post_ID'] = $product_id;
+			$_POST['rating']          = $rating;
+
+			$comment_id = wp_new_comment(
+				array(
+					'comment_post_ID'      => $product_id,
+					'comment_author'       => 'Storefront reviewer ' . $this->review_sequence,
+					'comment_author_email' => 'storefront' . $this->review_sequence . '@example.test',
+					'comment_author_url'   => '',
+					'comment_content'      => 'Storefront review ' . $this->review_sequence,
+					'comment_type'         => 'comment',
+					'comment_parent'       => 0,
+					'user_id'              => 0,
+				),
+				true
+			);
+		} finally {
+			$_POST = $original_post;
+		}
+
+		$this->assertNotWPError( $comment_id );
+
+		wp_set_comment_status( $comment_id, $status );
+
+		// Fail loudly if the writer path stops promoting the comment or stops storing the rating.
+		$this->assertSame( 'review', get_comment( $comment_id )->comment_type );
+		$this->assertEquals( $rating, get_comment_meta( $comment_id, 'rating', true ) );
+
+		return (int) $comment_id;
+	}
+
+	/**
+	 * Submit a review through the REST reviews endpoint.
+	 *
+	 * @param int $product_id Product being reviewed.
+	 * @param int $rating     Rating from 1 to 5.
+	 * @return int
+	 */
+	private function submit_review_through_rest( $product_id, $rating ) {
+		++$this->review_sequence;
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/reviews' );
+		$request->set_body_params(
+			array(
+				'product_id'     => $product_id,
+				'review'         => 'REST review ' . $this->review_sequence,
+				'reviewer'       => 'REST reviewer ' . $this->review_sequence,
+				'reviewer_email' => 'rest' . $this->review_sequence . '@example.test',
+				'rating'         => $rating,
+				'status'         => 'approved',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+
+		$comment_id = (int) $response->get_data()['id'];
+
+		$this->assertSame( 'review', get_comment( $comment_id )->comment_type );
+		$this->assertEquals( $rating, get_comment_meta( $comment_id, 'rating', true ) );
+
+		return $comment_id;
+	}
+
+	/**
+	 * Insert a comment directly, for states no writer path produces.
+	 *
+	 * @param int         $post_id Post the comment belongs to.
+	 * @param string|null $rating  Rating meta value, or null to store no rating at all.
+	 * @param array       $args    Overrides for the comment row.
+	 * @return int
+	 */
+	private function create_rated_comment( $post_id, $rating, $args = array() ) {
+		$comment_id = $this->factory->comment->create(
+			array_merge(
+				array(
+					'comment_post_ID'  => $post_id,
+					'comment_approved' => '1',
+					'comment_type'     => 'review',
+				),
+				$args
+			)
+		);
+
+		if ( null !== $rating ) {
+			add_comment_meta( $comment_id, 'rating', $rating );
+		}
+
+		return $comment_id;
+	}
+
+	/**
+	 * Fetch the endpoint and index the totals by slug.
+	 *
+	 * @return array
+	 */
+	private function get_totals_by_slug() {
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports/reviews/totals' ) );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		return wp_list_pluck( $response->get_data(), 'total', 'slug' );
+	}
+
 	/**
 	 * Test getting all product reviews.
 	 *
 	 * @since 3.5.0
 	 */
 	public function test_get_reports() {
-		global $wpdb;
 		wp_set_current_user( $this->user );

 		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/reports/reviews/totals' ) );
 		$report   = $response->get_data();
-		$data     = array();

-		$query_data = array(
-			'count'      => true,
-			'post_type'  => 'product',
-			'meta_key'   => 'rating', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Test setup intentionally mirrors the legacy production query shape.
-			'meta_value' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Test setup intentionally mirrors the legacy production query shape.
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 5, count( $report ) );
+
+		// Every bucket is reported, in order, even with nothing to count.
+		foreach ( $report as $index => $row ) {
+			$rating = $index + 1;
+
+			$this->assertEquals( 'rated_' . $rating . '_out_of_5', $row['slug'] );
+			$this->assertEquals( sprintf( 'Rated %s out of 5', $rating ), $row['name'] );
+			$this->assertSame( 0, $row['total'] );
+		}
+	}
+
+	/**
+	 * The totals count every rating a comment query would have counted, and nothing else.
+	 */
+	public function test_get_reports_counts_only_rated_product_comments() {
+		wp_set_current_user( $this->user );
+
+		$product = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_simple_product();
+		$page    = $this->factory->post->create( array( 'post_type' => 'page' ) );
+
+		// Read the empty totals first so the reviews added below have to invalidate them.
+		$this->assertSame( 0, $this->get_totals_by_slug()['rated_5_out_of_5'] );
+
+		// Reviews written the way real reviews are written.
+		$this->submit_review_through_comment_form( $product->get_id(), 5 );
+		$this->submit_review_through_comment_form( $product->get_id(), 5, 'hold' );
+		$this->submit_review_through_rest( $product->get_id(), 4 );
+
+		// States no writer path produces, inserted directly.
+		$this->create_rated_comment( $product->get_id(), '5', array( 'comment_approved' => 'spam' ) );
+		$this->create_rated_comment( $product->get_id(), '5', array( 'comment_approved' => 'trash' ) );
+		$this->create_rated_comment( $product->get_id(), '4', array( 'comment_type' => '' ) );
+		$this->create_rated_comment( $product->get_id(), '4', array( 'comment_type' => 'order_note' ) );
+		$this->create_rated_comment( $product->get_id(), '2', array( 'comment_type' => 'webhook_delivery' ) );
+		$this->create_rated_comment( $product->get_id(), '3', array( 'comment_type' => 'note' ) );
+		$this->create_rated_comment( $product->get_id(), '3', array( 'comment_type' => 'action_log' ) );
+		$this->create_rated_comment( $product->get_id(), '0' );
+		$this->create_rated_comment( $product->get_id(), '05' );
+		$this->create_rated_comment( $product->get_id(), null );
+		$this->create_rated_comment( $page, '3' );
+
+		/*
+		 * Five: the approved storefront review and the one still awaiting moderation; the spam and
+		 * trashed ones drop out. Four: the REST review and the plain comment; the order note drops
+		 * out. Three, two and one: nothing survives, because the note, action log and webhook types
+		 * are hidden, the rating '0' and '05' values do not match, one comment carries no rating at
+		 * all, and the last review sits on a page rather than a product. Empty buckets are still
+		 * reported.
+		 */
+		$this->assertSame(
+			array(
+				'rated_1_out_of_5' => 0,
+				'rated_2_out_of_5' => 0,
+				'rated_3_out_of_5' => 0,
+				'rated_4_out_of_5' => 2,
+				'rated_5_out_of_5' => 2,
+			),
+			$this->get_totals_by_slug()
 		);
+	}
+
+	/**
+	 * A third party narrowing the comment query through comments_clauses still narrows this report.
+	 */
+	public function test_get_reports_applies_comments_clauses_filter() {
+		wp_set_current_user( $this->user );

-		for ( $i = 1; $i <= 5; $i++ ) {
-			$query_data['meta_value'] = $i;
+		$product = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper::create_simple_product();

-			$data[] = array(
-				'slug'  => 'rated_' . $i . '_out_of_5',
-				/* translators: %s: average rating */
-				'name'  => sprintf( __( 'Rated %s out of 5', 'woocommerce' ), $i ),
-				'total' => (int) get_comments( $query_data ),
-			);
+		$this->submit_review_through_comment_form( $product->get_id(), 5 );
+		$this->create_rated_comment( $product->get_id(), '4', array( 'comment_type' => '' ) );
+
+		$this->assertSame( 1, $this->get_totals_by_slug()['rated_5_out_of_5'] );
+
+		$hide_reviews = static function ( $clauses ) {
+			$clauses['where'] .= " AND comment_type != 'review' ";
+
+			return $clauses;
+		};
+
+		add_filter( 'comments_clauses', $hide_reviews );
+
+		try {
+			// The filter does not move the comment last_changed value, so the cached totals have to go.
+			wp_cache_flush();
+
+			$totals = $this->get_totals_by_slug();
+		} finally {
+			remove_filter( 'comments_clauses', $hide_reviews );
 		}

-		$this->assertEquals( 200, $response->get_status() );
-		$this->assertEquals( count( $data ), count( $report ) );
-		$this->assertEquals( $data, $report );
+		// The review typed comment is filtered out, the plain comment is not.
+		$this->assertSame( 0, $totals['rated_5_out_of_5'] );
+		$this->assertSame( 1, $totals['rated_4_out_of_5'] );
 	}

 	/**