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'] );
}
/**