Commit 1233d5a1074 for woocommerce

commit 1233d5a107469007b422d9ba84a0ac11d7d3f5f8
Author: Aarón Fas <yo@aaron.com.es>
Date:   Fri Nov 6 16:59:12 2026 +0100

    Add product review query ability (#70594)

    * Add product review queries to Core abilities

    Let connected agents read reviews, whole-scope rating summaries, and product rankings through Core without exposing reviewer identity. Preserve legacy and unrated review semantics while adding include filters and stable pagination.

    Cover permissions, privacy, filters, ranking limits, missing products, dates, and paging through the Abilities API.

    * Add changelog entry for product review queries

    Describe the new review and rating queries available to connected agents in the release notes.

    * Support extension fields on product review queries

    Let extensions add namespaced review data through the product_review resource and WP_Comment callbacks, matching the other extensible Core reads. Keep the response and schema unchanged while the feature is disabled.

    Verify per-review fields, schema registration, reviewer identity omission, and feature on/off behavior through the Abilities API.

    * Return HTTP 400 for invalid review rating ranges

    Mark reversed rating ranges as invalid client input so the REST API does not report them as server failures. Assert the converted REST response status to protect this behavior.

    * Use WordPress queries for product review reads

    Use WP_Comment_Query for the review list and matching counts, including native pagination and cache priming. Scope the legacy-review and text-only search clauses to that query, with a search-specific cache key and guaranteed filter cleanup.

    Separate review formatting and rating summaries from execution. Replace the generic SQL fetch helper with one focused aggregate query and use get_posts for product existence checks. Preserve moderation, empty-page totals, extension fields, and reviewer privacy with regression coverage.

    * Make product review ranking ties deterministic

    Order products with equal ratings and review counts by product ID so database row order and PHP sorting behavior cannot change tied rankings or the capped selection. Preserve the existing rating and review-count priorities.

    Cover reversed tied inputs, rating and count precedence, unrated products, and both ends of the 200-product output cap.

    * Omit unavailable review dates from ability output

    Keep the review date schema consistent with other Core abilities by declaring a date-time string and omitting unavailable dates. Document the output convention and cover the schema and missing-date behavior in the regression test.

diff --git a/plugins/woocommerce/changelog/add-product-reviews-query b/plugins/woocommerce/changelog/add-product-reviews-query
new file mode 100644
index 00000000000..5daa5af0718
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-reviews-query
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let connected agents query product reviews, rating summaries, and product rankings.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AGENTS.md b/plugins/woocommerce/src/Internal/Abilities/AGENTS.md
index 0ab2db0a4c2..fb9058527be 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AGENTS.md
+++ b/plugins/woocommerce/src/Internal/Abilities/AGENTS.md
@@ -21,3 +21,6 @@ Keep:
 - Calls that normalize a valid value, such as `sanitize_title()` on a slug or `wc_format_decimal()` on a price.
 - A cast that a WooCommerce function needs, such as `(int)` for `add_order_note()`.

+## Omit unavailable output fields
+
+Core ability output types are never nullable. Omit an unavailable field instead of returning `null`. Declare date fields as `string` with the `date-time` format.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index b055f228a0a..80a0dc8ee38 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -22,6 +22,7 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductsQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductTermsQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewsQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ShippingZonesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\TaxRatesQuery;

@@ -54,6 +55,7 @@ class AbilitiesLoader {
 		PaymentGatewaysQuery::class,
 		ProductsQuery::class,
 		ProductTermsQuery::class,
+		ProductReviewsQuery::class,
 		ProductCreate::class,
 		ProductDelete::class,
 		ProductUpdate::class,
@@ -112,7 +114,7 @@ class AbilitiesLoader {
 	 * @since 11.3.0
 	 */
 	public static function register_core_resources(): void {
-		foreach ( array( 'product', 'order', 'order_item', 'product_term' ) as $resource_name ) {
+		foreach ( array( 'product', 'order', 'order_item', 'product_term', 'product_review' ) as $resource_name ) {
 			ExtensibleAbilities::register_resource( $resource_name );
 		}
 	}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewsQuery.php
new file mode 100644
index 00000000000..d84cf5e4c58
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductReviewsQuery.php
@@ -0,0 +1,562 @@
+<?php
+/**
+ * Product reviews query ability.
+ *
+ * Reads product reviews and rating aggregates from the site's comments table.
+ * Reviewer identity is not included in the output.
+ *
+ * @package woocommerce
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * Queries product reviews and rating aggregates.
+ */
+class ProductReviewsQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @since 11.4.0
+	 * @return string
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/product-reviews-query';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @since 11.4.0
+	 * @return array
+	 */
+	public static function get_registration_args(): array {
+		$output_schema                                   = self::get_output_schema();
+		$output_schema['properties']['reviews']['items'] = ExtensibleAbilities::add_fields_schema( $output_schema['properties']['reviews']['items'], 'product_review' );
+
+		return array(
+			'label'               => __( 'Query product reviews', 'woocommerce' ),
+			'description'         => __( 'Read product reviews, ratings and moderation status. Search matches review text only, never reviewer identity. Summary and per-product ranking cover all reviews in the requested product/status scope, ignoring search, rating range and include. Unrated reviews have rating zero and do not affect averages. Ranking is worst first; above 200 products it keeps the 100 worst and 100 best rated. Text searches omit the ranking. Requested products without reviews and unknown product IDs are reported separately. Reviewer identity is withheld.', 'woocommerce' ),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => $output_schema,
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_query_reviews' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'destructive' => false,
+					'idempotent'  => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Check review read access.
+	 *
+	 * @since 11.4.0
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 */
+	public static function can_query_reviews( $input = null ): bool {
+		return current_user_can( 'manage_woocommerce' ) || current_user_can( 'moderate_comments' );
+	}
+
+	/**
+	 * Get the output schema.
+	 *
+	 * @return array
+	 */
+	private static function get_output_schema(): array {
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'status'                   => array( 'type' => 'string' ),
+				'count'                    => array( 'type' => 'integer' ),
+				'total'                    => array( 'type' => 'integer' ),
+				'page'                     => array( 'type' => 'integer' ),
+				'per_page'                 => array( 'type' => 'integer' ),
+				'total_pages'              => array( 'type' => 'integer' ),
+				'summary'                  => array(
+					'type'       => 'object',
+					'properties' => array(
+						'average_rating'   => array( 'type' => 'number' ),
+						'total_reviews'    => array( 'type' => 'integer' ),
+						'matching_reviews' => array( 'type' => 'integer' ),
+						'total_products'   => array( 'type' => 'integer' ),
+					),
+				),
+				'search_scope'             => array( 'type' => 'string' ),
+				'products_without_reviews' => array(
+					'type'  => 'array',
+					'items' => array( 'type' => 'integer' ),
+				),
+				'unknown_product_ids'      => array(
+					'type'  => 'array',
+					'items' => array( 'type' => 'integer' ),
+				),
+				'by_product'               => array(
+					'type'  => 'array',
+					'items' => array(
+						'type'       => 'object',
+						'properties' => array(
+							'product_id'     => array( 'type' => 'integer' ),
+							'product_name'   => array( 'type' => 'string' ),
+							'review_count'   => array( 'type' => 'integer' ),
+							'average_rating' => array( 'type' => 'number' ),
+						),
+					),
+				),
+				'reviews'                  => array(
+					'type'  => 'array',
+					'items' => array(
+						'type'       => 'object',
+						'properties' => array(
+							'id'           => array( 'type' => 'integer' ),
+							'product_id'   => array( 'type' => 'integer' ),
+							'product_name' => array( 'type' => 'string' ),
+							'date_created' => array(
+								'type'   => 'string',
+								'format' => 'date-time',
+							),
+							'rating'       => array( 'type' => 'integer' ),
+							'verified'     => array( 'type' => 'boolean' ),
+							'status'       => array( 'type' => 'string' ),
+							'review'       => array( 'type' => 'string' ),
+						),
+					),
+				),
+			),
+		);
+	}
+
+	/**
+	 * Moderation statuses mapped to the comment_approved values they cover.
+	 * Null means every status.
+	 */
+	private const STATUSES = array(
+		'approved' => '1',
+		'hold'     => '0',
+		'spam'     => 'spam',
+		'trash'    => 'trash',
+		'all'      => null,
+	);
+
+	private const STATUS_LABELS = array(
+		'1'     => 'approved',
+		'0'     => 'hold',
+		'spam'  => 'spam',
+		'trash' => 'trash',
+	);
+
+	private const DEFAULT_LIMIT = 20;
+
+	/**
+	 * Most per-product entries one call returns. Past this the caller gets both
+	 * ends of the ranking and summary.total_products says how many there are.
+	 */
+	private const MAX_PRODUCTS = 200;
+
+	/**
+	 * Get the input schema for this ability.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'product_ids' => array(
+					'type'        => 'array',
+					'description' => __( 'Only return reviews for these products, and scope the summary and the ranking to them. Omit it to cover every product.', 'woocommerce' ),
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+					'minItems'    => 1,
+					'maxItems'    => self::MAX_PRODUCTS,
+				),
+				'search'      => array(
+					'type'        => 'string',
+					'description' => __( 'Free-text search across review content.', 'woocommerce' ),
+				),
+				'status'      => array(
+					'type'        => 'string',
+					'description' => __( 'Review moderation status. "hold" is reviews awaiting moderation.', 'woocommerce' ),
+					'enum'        => array_keys( self::STATUSES ),
+					'default'     => 'approved',
+				),
+				'min_rating'  => array(
+					'type'        => 'integer',
+					'description' => __( 'Only return reviews with at least this star rating. Use with max_rating to find unhappy customers.', 'woocommerce' ),
+					'minimum'     => 1,
+					'maximum'     => 5,
+				),
+				'max_rating'  => array(
+					'type'        => 'integer',
+					'description' => __( 'Only return reviews with at most this star rating.', 'woocommerce' ),
+					'minimum'     => 1,
+					'maximum'     => 5,
+				),
+				'include'     => array(
+					'type'        => 'array',
+					'description' => __( 'Limit matching reviews to these review IDs. Summary and ranking still cover the product scope.', 'woocommerce' ),
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+					'minItems'    => 1,
+					'maxItems'    => 200,
+				),
+				'page'        => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+					'default' => 1,
+				),
+				'per_page'    => array(
+					'type'    => 'integer',
+					'minimum' => 1,
+					'maximum' => 200,
+					'default' => 20,
+				),
+			),
+			'default'              => array(),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Execute the review search.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param array $input The input parameters.
+	 * @return array|\WP_Error
+	 */
+	public static function execute( array $input = array() ) {
+		$status      = $input['status'] ?? 'approved';
+		$product_ids = array_values( array_unique( $input['product_ids'] ?? array() ) );
+		if ( ( $input['min_rating'] ?? 1 ) > ( $input['max_rating'] ?? 5 ) ) {
+			return new \WP_Error( 'woocommerce_ability_product_review_invalid_rating_range', __( 'min_rating must not exceed max_rating.', 'woocommerce' ), array( 'status' => 400 ) );
+		}
+
+		$scope = array(
+			'status'      => $status,
+			'product_ids' => $product_ids,
+		);
+
+		$filters           = $scope;
+		$filters['search'] = trim( $input['search'] ?? '' );
+
+		if ( isset( $input['min_rating'] ) || isset( $input['max_rating'] ) ) {
+			$filters['min_rating'] = $input['min_rating'] ?? 1;
+			$filters['max_rating'] = $input['max_rating'] ?? 5;
+		}
+
+		$filters['include'] = $input['include'] ?? array();
+		$page               = $input['page'] ?? 1;
+		$per_page           = $input['per_page'] ?? self::DEFAULT_LIMIT;
+		$matches            = self::query_reviews( $filters, $page, $per_page );
+		$total              = (int) $matches->found_comments;
+		$reviews            = array();
+		foreach ( $matches->comments as $comment ) {
+			$comment = get_comment( $comment );
+			if ( $comment ) {
+				$review = self::format_review( $comment, $scope );
+				if ( is_wp_error( $review ) ) {
+					return $review;
+				}
+				$reviews[] = $review;
+			}
+		}
+		$ratings    = self::get_rating_summary( $scope, $total );
+		$by_product = $ratings['by_product'];
+
+		$result = array(
+			'total'       => $total,
+			'page'        => $page,
+			'per_page'    => $per_page,
+			'total_pages' => (int) ceil( $total / $per_page ),
+			'status'      => $status,
+			'count'       => count( $reviews ),
+			'summary'     => $ratings['summary'],
+		);
+
+		if ( $product_ids ) {
+			$missing = array_values( array_diff( $product_ids, array_column( $by_product, 'product_id' ) ) );
+			$known   = $missing ? self::existing_product_ids( $missing ) : array();
+
+			$result['products_without_reviews'] = array_values( array_intersect( $missing, $known ) );
+
+			$unknown = array_values( array_diff( $missing, $known ) );
+			if ( $unknown ) {
+				$result['unknown_product_ids'] = $unknown;
+			}
+		}
+
+		if ( '' !== $filters['search'] ) {
+			$result['search_scope'] = 'review_text';
+		} else {
+			$result['by_product'] = self::rank( $by_product );
+		}
+
+		$result['reviews'] = $reviews;
+
+		return $result;
+	}
+
+	/**
+	 * Query review objects and the matching count using WordPress pagination and caches.
+	 *
+	 * @param array $filters Review filters.
+	 * @param int   $page Current page.
+	 * @param int   $per_page Page size.
+	 * @return \WP_Comment_Query
+	 */
+	private static function query_reviews( array $filters, int $page, int $per_page ): \WP_Comment_Query {
+		$args = array(
+			'post_type'                 => 'product',
+			'post__in'                  => $filters['product_ids'],
+			'comment__in'               => $filters['include'],
+			'type__in'                  => array( 'review', 'comment' ),
+			'parent'                    => 0,
+			'status'                    => 'all' === $filters['status'] ? 'any' : ( 'approved' === $filters['status'] ? 'approve' : $filters['status'] ),
+			'number'                    => $per_page,
+			'offset'                    => ( $page - 1 ) * $per_page,
+			'orderby'                   => array(
+				'comment_date_gmt' => 'DESC',
+				'comment_ID'       => 'DESC',
+			),
+			'no_found_rows'             => false,
+			'update_comment_post_cache' => true,
+			// The text-only search is added through SQL, so include it in the cache key.
+			'cache_domain'              => 'woocommerce-product-reviews-' . md5( $filters['search'] ),
+		);
+		if ( isset( $filters['min_rating'] ) ) {
+			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- WordPress stores review ratings in comment metadata.
+			$args['meta_query'] = array(
+				array(
+					'key'     => 'rating',
+					'value'   => array( $filters['min_rating'], $filters['max_rating'] ),
+					'compare' => 'BETWEEN',
+					'type'    => 'NUMERIC',
+				),
+			);
+		}
+
+		$query  = new \WP_Comment_Query();
+		$filter = static function ( array $clauses, \WP_Comment_Query $current ) use ( $query, $filters ): array {
+			if ( $current !== $query ) {
+				return $clauses;
+			}
+			global $wpdb;
+			// Modern reviews may be unrated; legacy plain comments require a rating.
+			$clauses['where'] .= " AND ( {$wpdb->comments}.comment_type = 'review' OR EXISTS (
+				SELECT 1 FROM {$wpdb->commentmeta} legacy_rating
+				WHERE legacy_rating.comment_id = {$wpdb->comments}.comment_ID
+				AND legacy_rating.meta_key = 'rating' AND legacy_rating.meta_value + 0 > 0
+			) )";
+			// WordPress's normal comment search also matches reviewer identity.
+			if ( '' !== $filters['search'] ) {
+				$clauses['where'] .= $wpdb->prepare( " AND {$wpdb->comments}.comment_content LIKE %s", '%' . $wpdb->esc_like( $filters['search'] ) . '%' );
+			}
+			return $clauses;
+		};
+		add_filter( 'comments_clauses', $filter, 10, 2 );
+		try {
+			$query->query( $args );
+			if ( ! $query->comments ) {
+				unset( $args['number'], $args['offset'] );
+				$args['count']         = true;
+				$query->found_comments = (int) $query->query( $args );
+			}
+			return $query;
+		} finally {
+			remove_filter( 'comments_clauses', $filter, 10 );
+		}
+	}
+
+	/**
+	 * Format a review without exposing reviewer identity.
+	 *
+	 * @param \WP_Comment $comment Review object.
+	 * @param array       $scope Product and status scope.
+	 * @return array|\WP_Error
+	 */
+	private static function format_review( \WP_Comment $comment, array $scope ) {
+		$review       = array(
+			'id'       => (int) $comment->comment_ID,
+			'rating'   => (int) get_comment_meta( (int) $comment->comment_ID, 'rating', true ),
+			'verified' => (bool) get_comment_meta( (int) $comment->comment_ID, 'verified', true ),
+			'review'   => wp_strip_all_tags( (string) $comment->comment_content ),
+		);
+		$date_created = self::format_date( $comment->comment_date_gmt );
+		if ( null !== $date_created ) {
+			$review['date_created'] = $date_created;
+		}
+		if ( 1 !== count( $scope['product_ids'] ) ) {
+			$review['product_id']   = (int) $comment->comment_post_ID;
+			$review['product_name'] = (string) get_post_field( 'post_title', (int) $comment->comment_post_ID, 'raw' );
+		}
+		if ( 'all' === $scope['status'] ) {
+			$review['status'] = self::STATUS_LABELS[ $comment->comment_approved ] ?? (string) $comment->comment_approved;
+		}
+		return ExtensibleAbilities::add_fields_to_object( $review, 'product_review', $comment );
+	}
+
+	/**
+	 * Aggregate all reviews in the product/status scope, independently of the page filters.
+	 *
+	 * @param array $scope Product and status scope.
+	 * @param int   $matching_reviews Total matching the page filters.
+	 * @return array
+	 */
+	private static function get_rating_summary( array $scope, int $matching_reviews ): array {
+		global $wpdb;
+		$where    = "p.post_type = 'product' AND c.comment_parent = 0
+			AND c.comment_type IN ( 'review', 'comment', '' )
+			AND ( c.comment_type = 'review' OR cm.meta_value > 0 )";
+		$params   = array();
+		$approved = self::STATUSES[ $scope['status'] ];
+		if ( null !== $approved ) {
+			$where   .= ' AND c.comment_approved = %s';
+			$params[] = $approved;
+		}
+		if ( $scope['product_ids'] ) {
+			$where .= ' AND c.comment_post_ID IN ( ' . implode( ', ', array_fill( 0, count( $scope['product_ids'] ), '%d' ) ) . ' )';
+			$params = array_merge( $params, $scope['product_ids'] );
+		}
+		$sql = "SELECT c.comment_post_ID, p.post_title, COUNT(*) AS total,
+			SUM( NULLIF( cm.meta_value + 0, 0 ) ) AS rating_sum, COUNT( NULLIF( cm.meta_value + 0, 0 ) ) AS rated
+			FROM {$wpdb->comments} c
+			INNER JOIN {$wpdb->posts} p ON p.ID = c.comment_post_ID
+			LEFT JOIN {$wpdb->commentmeta} cm ON cm.comment_id = c.comment_ID AND cm.meta_key = 'rating'
+			WHERE {$where} GROUP BY c.comment_post_ID, p.post_title";
+		if ( $params ) {
+			$sql = $wpdb->prepare( $sql, $params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Only internal clauses and placeholders are combined above.
+		}
+		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Filter values are prepared above.
+		$grouped       = $wpdb->get_results( $sql );
+		$total_reviews = 0;
+		$rating_sum    = 0.0;
+		$rated_reviews = 0;
+		$by_product    = array();
+		foreach ( $grouped as $row ) {
+			$total_reviews += (int) $row->total;
+			$rating_sum    += (float) $row->rating_sum;
+			$rated_reviews += (int) $row->rated;
+			$by_product[]   = array(
+				'product_id'     => (int) $row->comment_post_ID,
+				'product_name'   => (string) $row->post_title,
+				'review_count'   => (int) $row->total,
+				'average_rating' => $row->rated ? round( (float) $row->rating_sum / (int) $row->rated, 2 ) : 0.0,
+			);
+		}
+		return array(
+			'summary'    => array(
+				'average_rating'   => $rated_reviews ? round( $rating_sum / $rated_reviews, 2 ) : 0.0,
+				'total_reviews'    => $total_reviews,
+				'matching_reviews' => $matching_reviews,
+				'total_products'   => count( $by_product ),
+			),
+			'by_product' => $by_product,
+		);
+	}
+
+	/**
+	 * Find requested products regardless of their publication status.
+	 *
+	 * @param int[] $ids Candidate product IDs.
+	 * @return int[]
+	 */
+	private static function existing_product_ids( array $ids ): array {
+		return get_posts(
+			array(
+				'post_type'   => 'product',
+				'post_status' => array_keys( get_post_stati() ),
+				'post__in'    => $ids,
+				'numberposts' => -1,
+				'fields'      => 'ids',
+			)
+		);
+	}
+
+	/**
+	 * Render a GMT comment date as UTC, or null when there is no usable date.
+	 *
+	 * Imported and legacy comments can carry '0000-00-00 00:00:00', which
+	 * strtotime() rolls into year -1 rather than rejecting.
+	 *
+	 * @param string|null $date_gmt comment_date_gmt as stored.
+	 * @return string|null
+	 */
+	private static function format_date( ?string $date_gmt ): ?string {
+		$date_gmt  = trim( (string) $date_gmt );
+		$timestamp = '' === $date_gmt ? false : strtotime( $date_gmt . ' UTC' );
+
+		return $timestamp > 0 ? gmdate( 'Y-m-d\TH:i:s\Z', $timestamp ) : null;
+	}
+
+	/**
+	 * Order products by rating, review count, then product ID, and apply the cap.
+	 *
+	 * Unrated products sort last. Above the cap, retain both ends of the rated ranking.
+	 *
+	 * @param array<int, array<string, mixed>> $by_product One entry per product.
+	 * @return array<int, array<string, mixed>>
+	 */
+	private static function rank( array $by_product ): array {
+		usort(
+			$by_product,
+			static function ( $a, $b ) {
+				if ( ( 0.0 === $a['average_rating'] ) !== ( 0.0 === $b['average_rating'] ) ) {
+					return 0.0 === $a['average_rating'] ? 1 : -1;
+				}
+				if ( $a['average_rating'] === $b['average_rating'] ) {
+					if ( $a['review_count'] !== $b['review_count'] ) {
+						return $b['review_count'] - $a['review_count'];
+					}
+					return $a['product_id'] <=> $b['product_id'];
+				}
+				return $a['average_rating'] < $b['average_rating'] ? -1 : 1;
+			}
+		);
+
+		if ( count( $by_product ) <= self::MAX_PRODUCTS ) {
+			return $by_product;
+		}
+
+		$rated = array_values(
+			array_filter(
+				$by_product,
+				static function ( $entry ) {
+					return $entry['average_rating'] > 0.0;
+				}
+			)
+		);
+
+		if ( count( $rated ) <= self::MAX_PRODUCTS ) {
+			return $rated;
+		}
+
+		$half = intdiv( self::MAX_PRODUCTS, 2 );
+
+		return array_merge( array_slice( $rated, 0, $half ), array_slice( $rated, -$half ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewsQueryTest.php
new file mode 100644
index 00000000000..0c70f9d2cdb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductReviewsQueryTest.php
@@ -0,0 +1,451 @@
+<?php
+/**
+ * Product reviews query tests.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewsQuery;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+
+/**
+ * Tests review queries through the Abilities API.
+ */
+class ProductReviewsQueryTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var \WP_Ability
+	 */
+	private $sut;
+
+	/**
+	 * Set up an authorized caller.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$this->sut = new \WP_Ability( ProductReviewsQuery::get_name(), ProductReviewsQuery::get_registration_args() );
+	}
+
+	/**
+	 * Create a review fixture.
+	 *
+	 * @param int   $product Product ID.
+	 * @param int   $rating Star rating, or zero to omit it.
+	 * @param array $args Comment overrides.
+	 * @return int
+	 */
+	private function review( int $product, int $rating, array $args = array() ): int {
+		$id = self::factory()->comment->create(
+			array_merge(
+				array(
+					'comment_post_ID'      => $product,
+					'comment_type'         => 'review',
+					'comment_approved'     => '1',
+					'comment_content'      => '<p>Lovely item</p>',
+					'comment_author'       => 'Private Reviewer',
+					'comment_author_email' => 'private@example.test',
+					'comment_author_IP'    => '192.0.2.1',
+					'comment_date_gmt'     => '2026-10-01 12:00:00',
+				),
+				$args
+			)
+		);
+		if ( $rating ) {
+			update_comment_meta( $id, 'rating', $rating );
+		}
+		return $id;
+	}
+
+	/**
+	 * @testdox Should page matching reviews while keeping whole-scope ratings and protecting reviewer identity.
+	 */
+	public function test_ratings_paging_and_privacy(): void {
+		$product = \WC_Helper_Product::create_simple_product()->get_id();
+		$first   = $this->review( $product, 1 );
+		$second  = $this->review( $product, 5 );
+		$this->review( $product, 0 );
+		$input = array(
+			'product_ids' => array( $product ),
+			'per_page'    => 1,
+		);
+		$page1 = $this->sut->execute( $input );
+		$page2 = $this->sut->execute( array_merge( $input, array( 'page' => 2 ) ) );
+		$this->assertIsArray( $page1 );
+		$this->assertSame( 3, $page1['total'] );
+		$this->assertSame( 3, $page1['total_pages'] );
+		$this->assertNotSame( $page1['reviews'][0]['id'], $page2['reviews'][0]['id'] );
+		$this->assertSame( 3.0, $page1['summary']['average_rating'] );
+		$filtered = $this->sut->execute( array_merge( $input, array( 'max_rating' => 1 ) ) );
+		$this->assertSame( 1, $filtered['total'] );
+		$this->assertSame( $first, $filtered['reviews'][0]['id'] );
+		$this->assertSame( $page1['summary']['total_reviews'], $filtered['summary']['total_reviews'] );
+		$this->assertSame( 3.0, $filtered['by_product'][0]['average_rating'] );
+		$this->assertSame( 'Lovely item', $filtered['reviews'][0]['review'] );
+		$this->assertStringNotContainsString( 'Private Reviewer', wp_json_encode( $filtered ) );
+		$this->assertStringNotContainsString( 'private@example.test', wp_json_encode( $filtered ) );
+		foreach ( array( 'reviewer', 'reviewer_email', 'comment_author', 'comment_author_email', 'comment_author_IP' ) as $key ) {
+			$this->assertArrayNotHasKey( $key, $filtered['reviews'][0] );
+		}
+		$included = $this->sut->execute( array( 'include' => array( $second ) ) );
+		$this->assertSame( array( $second ), array_column( $included['reviews'], 'id' ) );
+	}
+
+	/**
+	 * @testdox Should distinguish unrated products from missing products and rank the whole scope.
+	 */
+	public function test_ranking_and_products_without_reviews(): void {
+		$worst = \WC_Helper_Product::create_simple_product()->get_id();
+		$best  = \WC_Helper_Product::create_simple_product()->get_id();
+		$empty = \WC_Helper_Product::create_simple_product()->get_id();
+		$this->review( $worst, 1 );
+		$this->review( $best, 5 );
+		$unknown = 99999999;
+		$result  = $this->sut->execute(
+			array(
+				'product_ids' => array( $best, $empty, $worst, $unknown ),
+				'per_page'    => 1,
+			)
+		);
+		$this->assertSame( array( $worst, $best ), array_column( $result['by_product'], 'product_id' ) );
+		$this->assertSame( array( $empty ), $result['products_without_reviews'] );
+		$this->assertSame( array( $unknown ), $result['unknown_product_ids'] );
+		$this->assertSame( 2, $result['summary']['total_products'] );
+	}
+
+	/**
+	 * @testdox Should count legacy rated comments and unrated reviews but exclude replies and other post types.
+	 */
+	public function test_review_scope(): void {
+		$product = \WC_Helper_Product::create_simple_product()->get_id();
+		$legacy  = $this->review( $product, 4, array( 'comment_type' => 'comment' ) );
+		$unrated = $this->review( $product, 0 );
+		$this->review( $product, 0, array( 'comment_type' => 'comment' ) );
+		$this->review( $product, 5, array( 'comment_parent' => $legacy ) );
+		$this->review( self::factory()->post->create(), 5 );
+		$held   = $this->review( $product, 2, array( 'comment_approved' => '0' ) );
+		$result = $this->sut->execute();
+		$this->assertSame( 2, $result['total'] );
+		$this->assertSame( 4.0, $result['summary']['average_rating'] );
+		$result = $this->sut->execute( array( 'status' => 'hold' ) );
+		$this->assertSame( array( $held ), array_column( $result['reviews'], 'id' ) );
+		$result = $this->sut->execute(
+			array(
+				'status'  => 'all',
+				'include' => array( $unrated ),
+			)
+		);
+		$this->assertSame( 'approved', $result['reviews'][0]['status'] );
+	}
+
+	/**
+	 * @testdox Should search review text only without changing the summary.
+	 */
+	public function test_search(): void {
+		$product = \WC_Helper_Product::create_simple_product()->get_id();
+		$this->review( $product, 5 );
+		$result = $this->sut->execute( array( 'search' => 'Private Reviewer' ) );
+		$this->assertSame( 0, $result['total'] );
+		$this->assertSame( 1, $result['summary']['total_reviews'] );
+		$this->assertSame( 'review_text', $result['search_scope'] );
+		$this->assertArrayNotHasKey( 'by_product', $result );
+		$result = $this->sut->execute( array( 'search' => 'Lovely' ) );
+		$this->assertSame( 1, $result['total'] );
+		$result = $this->sut->execute( array( 'search' => '0' ) );
+		$this->assertSame( 0, $result['total'] );
+	}
+
+	/**
+	 * @testdox Should reject invalid inputs and unauthorized callers.
+	 */
+	public function test_validation_and_permissions(): void {
+		foreach ( array( array( 'include' => array() ), array( 'product_ids' => array() ), array( 'per_page' => 0 ), array( 'page' => 0 ), array( 'min_rating' => 6 ), array( 'status' => 'invalid' ) ) as $input ) {
+			$this->assertWPError( $this->sut->execute( $input ) );
+		}
+		$result = $this->sut->execute(
+			array(
+				'min_rating' => 5,
+				'max_rating' => 1,
+			)
+		);
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_ability_product_review_invalid_rating_range', $result->get_error_code() );
+		$this->assertSame( 400, rest_convert_error_to_response( $result )->get_status() );
+		foreach ( array( 'customer', 'subscriber' ) as $role ) {
+			wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) );
+			$this->assertWPError( $this->sut->execute() );
+		}
+		wp_set_current_user( 0 );
+		$this->assertWPError( $this->sut->execute() );
+	}
+
+	/**
+	 * @testdox Should retain both ends of a large rating ranking and omit unrated products above the cap.
+	 */
+	public function test_large_ranking(): void {
+		for ( $i = 0; $i < 202; ++$i ) {
+			$product = self::factory()->post->create( array( 'post_type' => 'product' ) );
+			$this->review( $product, 100 > $i ? 1 : 5 );
+		}
+		$unrated = self::factory()->post->create( array( 'post_type' => 'product' ) );
+		$this->review( $unrated, 0 );
+		$result = $this->sut->execute( array( 'per_page' => 1 ) );
+		$this->assertIsArray( $result );
+		$this->assertSame( 203, $result['total'] );
+		$this->assertSame( 203, $result['summary']['total_products'] );
+		$this->assertCount( 200, $result['by_product'] );
+		$ratings = array_count_values( array_map( 'strval', array_column( $result['by_product'], 'average_rating' ) ) );
+		$this->assertSame(
+			array(
+				1 => 100,
+				5 => 100,
+			),
+			$ratings
+		);
+		$this->assertNotContains( $unrated, array_column( $result['by_product'], 'product_id' ) );
+	}
+
+	/**
+	 * @testdox Should break ranking ties by product ID, including at the output cap.
+	 */
+	public function test_ranking_ties_are_deterministic(): void {
+		$rank = new \ReflectionMethod( ProductReviewsQuery::class, 'rank' );
+		$rank->setAccessible( true );
+		$rows = array(
+			array(
+				'product_id'     => 3,
+				'average_rating' => 3.0,
+				'review_count'   => 1,
+			),
+			array(
+				'product_id'     => 2,
+				'average_rating' => 3.0,
+				'review_count'   => 2,
+			),
+			array(
+				'product_id'     => 1,
+				'average_rating' => 3.0,
+				'review_count'   => 1,
+			),
+			array(
+				'product_id'     => 4,
+				'average_rating' => 1.0,
+				'review_count'   => 1,
+			),
+			array(
+				'product_id'     => 5,
+				'average_rating' => 0.0,
+				'review_count'   => 3,
+			),
+		);
+		$this->assertSame( array( 4, 2, 1, 3, 5 ), array_column( $rank->invoke( null, $rows ), 'product_id' ) );
+		$rows     = array_map(
+			static fn( $id ) => array(
+				'product_id'     => $id,
+				'average_rating' => 3.0,
+				'review_count'   => 1,
+			),
+			range( 202, 1 )
+		);
+		$expected = array_merge( range( 1, 100 ), range( 103, 202 ) );
+		$this->assertSame( $expected, array_column( $rank->invoke( null, $rows ), 'product_id' ) );
+		$this->assertSame( $expected, array_column( $rank->invoke( null, array_reverse( $rows ) ), 'product_id' ) );
+	}
+
+	/**
+	 * @testdox Should format UTC dates and verified purchases, and handle missing dates and empty pages.
+	 */
+	public function test_dates_verified_and_empty_results(): void {
+		$schema = $this->sut->get_output_schema()['properties']['reviews']['items']['properties']['date_created'];
+		$this->assertSame( 'string', $schema['type'] );
+		$this->assertSame( 'date-time', $schema['format'] );
+		$product = \WC_Helper_Product::create_simple_product()->get_id();
+		$id      = $this->review( $product, 4 );
+		update_comment_meta( $id, 'verified', 1 );
+		$result = $this->sut->execute();
+		$this->assertIsArray( $result );
+		$this->assertSame( '2026-10-01T12:00:00Z', $result['reviews'][0]['date_created'] );
+		$this->assertTrue( $result['reviews'][0]['verified'] );
+		global $wpdb;
+		// WordPress repairs zero dates on update, but imports can store them directly.
+		$wpdb->update( $wpdb->comments, array( 'comment_date_gmt' => '0000-00-00 00:00:00' ), array( 'comment_ID' => $id ) );
+		clean_comment_cache( $id );
+		$result = $this->sut->execute();
+		$this->assertArrayNotHasKey( 'date_created', $result['reviews'][0] );
+		$result = $this->sut->execute( array( 'page' => 2 ) );
+		$this->assertSame( array(), $result['reviews'] );
+		$this->assertSame( 1, $result['total'] );
+		$result = $this->sut->execute( array( 'status' => 'spam' ) );
+		$this->assertSame( 0, $result['total'] );
+		$this->assertSame( 0, $result['total_pages'] );
+	}
+
+	/**
+	 * @testdox Should expose valid extension fields and propagate extension failures only while the feature is enabled.
+	 * @testWith ["valid", ""]
+	 *           ["invalid", "woocommerce_ability_field_output_invalid"]
+	 *           ["throws", "woocommerce_ability_extension_failed"]
+	 * @param string $behavior Extension callback behavior.
+	 * @param string $error_code Expected error for a failing callback.
+	 */
+	public function test_extension_fields( string $behavior, string $error_code ): void {
+		$saved = array();
+		foreach ( array( 'resources', 'fields' ) as $name ) {
+			$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+			$property->setAccessible( true );
+			$saved[ $name ] = $property->getValue();
+			$property->setValue( null, array() );
+		}
+		try {
+			update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+			$product  = \WC_Helper_Product::create_simple_product()->get_id();
+			$ids      = array( $this->review( $product, 1 ), $this->review( $product, 5 ) );
+			$input    = array( 'include' => $ids );
+			$baseline = ProductReviewsQuery::execute( $input );
+			$schema   = ProductReviewsQuery::get_registration_args()['output_schema'];
+			update_option( 'woocommerce_feature_extensible_abilities_enabled', 'yes' );
+			$this->assertSame( $baseline, ProductReviewsQuery::execute( $input ) );
+			$this->assertSame( $schema, ProductReviewsQuery::get_registration_args()['output_schema'] );
+			add_action( 'woocommerce_extensible_abilities_resources_init', array( AbilitiesLoader::class, 'register_core_resources' ), 0 );
+			do_action( 'woocommerce_extensible_abilities_resources_init' );
+			add_action(
+				'woocommerce_extensible_abilities_init',
+				static function () use ( $behavior ) {
+					ExtensibleAbilities::register_field(
+						array(
+							'resource'      => 'product_review',
+							'namespace'     => 'test-ext',
+							'field'         => 'review_id',
+							'output_schema' => array( 'type' => 'integer' ),
+							'get_callback'  => static function ( \WP_Comment $comment ) use ( $behavior ) {
+								if ( 'throws' === $behavior ) {
+									throw new \RuntimeException( 'Private extension failure details' );
+								}
+								return 'invalid' === $behavior ? 'not an integer' : (int) $comment->comment_ID;
+							},
+						)
+					);
+				}
+			);
+			do_action( 'woocommerce_extensible_abilities_init' );
+			$ability = new \WP_Ability( ProductReviewsQuery::get_name(), ProductReviewsQuery::get_registration_args() );
+			if ( $error_code ) {
+				$this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+			}
+			$result = $ability->execute( $input );
+			if ( $error_code ) {
+				$this->assertWPError( $result );
+				$this->assertSame( $error_code, $result->get_error_code() );
+				$this->assertSame( 500, rest_convert_error_to_response( $result )->get_status() );
+				$this->assertStringNotContainsString( 'Private extension failure details', $result->get_error_message() );
+				return;
+			}
+			$this->assertIsArray( $result );
+			$this->assertCount( 2, $result['reviews'] );
+			foreach ( $result['reviews'] as $review ) {
+				$this->assertSame( $review['id'], $review['extensions']['test-ext']['review_id'] );
+				$this->assertArrayNotHasKey( 'reviewer', $review );
+				$this->assertArrayNotHasKey( 'reviewer_email', $review );
+			}
+			$this->assertSame( 'integer', $ability->get_output_schema()['properties']['reviews']['items']['properties']['extensions']['properties']['test-ext']['properties']['review_id']['type'] );
+			update_option( 'woocommerce_feature_extensible_abilities_enabled', 'no' );
+			$this->assertSame( $baseline, ProductReviewsQuery::execute( $input ) );
+			$this->assertSame( $schema, ProductReviewsQuery::get_registration_args()['output_schema'] );
+		} finally {
+			foreach ( $saved as $name => $value ) {
+				$property = new \ReflectionProperty( ExtensibleAbilities::class, $name );
+				$property->setAccessible( true );
+				$property->setValue( null, $value );
+			}
+		}
+	}
+	/**
+	 * @testdox Should include spam and trash only when requested and preserve totals beyond the last page.
+	 */
+	public function test_moderation_statuses_and_empty_pages(): void {
+		$product = \WC_Helper_Product::create_simple_product()->get_id();
+		foreach ( array( '1', '0', 'spam', 'trash' ) as $status ) {
+			$this->review( $product, 4, array( 'comment_approved' => $status ) );
+		}
+		$all = $this->sut->execute( array( 'status' => 'all' ) );
+		$this->assertSame( 4, $all['total'] );
+		$this->assertCount( 4, $all['reviews'] );
+		foreach ( array( 'spam', 'trash' ) as $status ) {
+			$result = $this->sut->execute(
+				array(
+					'status'   => $status,
+					'page'     => 2,
+					'per_page' => 1,
+				)
+			);
+			$this->assertSame( array(), $result['reviews'] );
+			$this->assertSame( 1, $result['total'] );
+			$this->assertSame( 1, $result['total_pages'] );
+		}
+	}
+
+	/**
+	 * @testdox Should escape literal search characters and recognize requested trashed products without reviews.
+	 */
+	public function test_literal_search_and_trashed_products(): void {
+		$product = \WC_Helper_Product::create_simple_product()->get_id();
+		$id      = $this->review( $product, 3, array( 'comment_content' => '100%_match' ) );
+		$this->review( $product, 5, array( 'comment_content' => '100 other match' ) );
+		$result = $this->sut->execute( array( 'search' => '%_' ) );
+		$this->assertSame( array( $id ), array_column( $result['reviews'], 'id' ) );
+		$empty = \WC_Helper_Product::create_simple_product()->get_id();
+		wp_trash_post( $empty );
+		$result = $this->sut->execute( array( 'product_ids' => array( $empty ) ) );
+		$this->assertSame( array( $empty ), $result['products_without_reviews'] );
+		$this->assertArrayNotHasKey( 'unknown_product_ids', $result );
+	}
+	/**
+	 * @testdox Should leave nested comment queries unchanged and remove its query filter afterward.
+	 */
+	public function test_nested_comment_queries(): void {
+		$post_comment = self::factory()->comment->create(
+			array(
+				'comment_post_ID' => self::factory()->post->create(),
+				'comment_type'    => 'comment',
+			)
+		);
+		$product      = \WC_Helper_Product::create_simple_product()->get_id();
+		$this->review( $product, 5 );
+		$nested = null;
+		$filter = static function ( $clauses ) use ( &$nested, $post_comment ) {
+			if ( null === $nested ) {
+				$nested = array();
+				$nested = get_comments(
+					array(
+						'comment__in' => array( $post_comment ),
+						'status'      => 'all',
+					)
+				);
+			}
+			return $clauses;
+		};
+		add_filter( 'comments_clauses', $filter, 20 );
+		try {
+			$result = $this->sut->execute();
+			$this->assertSame( 1, $result['total'] );
+			$this->assertSame( array( $post_comment ), array_map( 'intval', wp_list_pluck( $nested, 'comment_ID' ) ) );
+		} finally {
+			remove_filter( 'comments_clauses', $filter, 20 );
+		}
+		$this->assertCount(
+			1,
+			get_comments(
+				array(
+					'comment__in'  => array( $post_comment ),
+					'status'       => 'all',
+					'cache_domain' => 'after-review-query',
+				)
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 5445589335b..d07ae5cc553 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -1063,7 +1063,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {

 		$resources = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
 		$resources->setAccessible( true );
-		$this->assertSame( array( 'product', 'order', 'order_item', 'product_term' ), array_keys( $resources->getValue() ) );
+		$this->assertSame( array( 'product', 'order', 'order_item', 'product_term', 'product_review' ), array_keys( $resources->getValue() ) );
 	}

 	/**