Commit 1b3f5b7cba1 for woocommerce

commit 1b3f5b7cba195e498a9de92bceeb64e464fb432f
Author: Abdalsalaam Halawa <abdalsalaamnafez@gmail.com>
Date:   Wed Jun 24 07:13:31 2026 +0300

    Store API: limit and de-duplicate products/collection-data count requests (#65420)

    * Store API: bound and de-duplicate collection-data count requests

    Cap the calculate_attribute_counts and calculate_taxonomy_counts parameters at 25 entries (filterable) and de-duplicate the requested taxonomies, so a single products/collection-data request can no longer trigger an unbounded number of full-collection aggregate queries.

    * Store API: route attribute counts through cached filter-data provider

    Reuse the shared FilterDataProvider path already used for taxonomy counts so attribute counts benefit from its transient cache and invalidation, and remove the duplicated hand-built query.

    * Store API: test collection-data count bounds, de-duplication and caching

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Store API: de-duplicate collection-data counts across or/and query types

    Drop taxonomies already handled by the "or" branch from the "and" list so a
    taxonomy requested with both query types is counted once instead of producing
    duplicate entries in the response.

    Also harden the cached-path test to assert a real cache entry and correct
    counts, and clarify the attribute-count union comment.

    * Normalize taxonomy names before de-duplicating count requests

    The de-duplication in products/collection-data ran array_unique() on the raw
    request strings, while wc_sanitize_taxonomy_name() only normalized later when
    building the SQL and cache key. Textual variants of the same taxonomy (e.g.
    differing case or surrounding whitespace) therefore slipped past the dedup,
    producing duplicate entries in the attribute_counts response and separate
    cache keys for the same underlying taxonomy.

    Sanitize the taxonomy name before de-duplicating in both calculate_attribute_counts
    and calculate_taxonomy_counts so each distinct taxonomy collapses to a single
    query keyed by its canonical name. The or-branch attribute-removal comparison is
    sanitized on both sides to stay consistent.

    Add tests covering case and whitespace variants for both count parameters.

    * Skip invalid taxonomies in attribute counts to avoid wasted queries

    In the products/collection-data attribute-counts path, validate each requested
    taxonomy before queuing a count query. Numeric attribute IDs are resolved to
    their taxonomy name first (preserving the existing numeric-ID input supported by
    get_attribute_counts), and any value that is not a registered product attribute
    taxonomy is skipped via taxonomy_is_product_attribute(). This avoids running a
    full-collection aggregate query for non-existent or non-attribute taxonomies.

    The check is in-memory (no extra DB queries).

    Add tests covering non-attribute/non-existent taxonomies and numeric attribute IDs.

    * Filter calculate_taxonomy_counts to existing taxonomies only

    After normalizing the requested taxonomy names, keep only registered taxonomies
    via taxonomy_exists() so non-existent ones are dropped before querying instead of
    triggering wasted full-collection aggregate queries. The check is in-memory.

    Add a test covering non-existent taxonomies in calculate_taxonomy_counts.

    * Hardcode the count entries cap instead of exposing a filter

    Drop the woocommerce_store_api_collection_data_counts_max_items filter and the
    get_counts_max_items() method, and use the COUNTS_MAX_ITEMS constant (25)
    directly as the maxItems schema bound for calculate_attribute_counts and
    calculate_taxonomy_counts. A realistic store does not request counts for more
    than 25 attributes/taxonomies in a single request, so the cap does not need to
    be configurable.

    Remove the now-obsolete filterable-cap test.

    * fix: stop collapsing Store API attribute counts across query types

    The products/collection-data endpoint dropped a taxonomy from the "and"
    list when the same taxonomy was also requested with "or" (via array_diff).
    The two query types return different counts: the "or" branch removes the
    active attribute filter before counting while the "and" branch keeps it,
    so collapsing them discarded a genuinely distinct result.

    Keep array_unique per query type to drop real same-type duplicates, but
    stop merging across types. Replace the cross-type test, which passed only
    because it set no active filter (so "or" and "and" coincided), with one
    that sets an active filter and proves both types are counted independently.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * fix: update PHPStan baseline

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Co-authored-by: Tung Du <dinhtungdu@gmail.com>

diff --git a/plugins/woocommerce/changelog/65420-fix-store-api-collection-data-dos b/plugins/woocommerce/changelog/65420-fix-store-api-collection-data-dos
new file mode 100644
index 00000000000..6563df9043e
--- /dev/null
+++ b/plugins/woocommerce/changelog/65420-fix-store-api-collection-data-dos
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: limit and de-duplicate the attribute and taxonomy count requests accepted by the products/collection-data endpoint to prevent a single request from triggering an excessive number of database queries.
\ No newline at end of file
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index ea11b85e271..52d25f58cee 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -70879,12 +70879,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/ProductCollectionData.php

-		-
-			message: '#^If condition is always true\.$#'
-			identifier: if.alwaysTrue
-			count: 1
-			path: src/StoreApi/Routes/V1/ProductCollectionData.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\ProductCollectionData\:\:get_route_response\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductCollectionData.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductCollectionData.php
index 90a80ad0913..a4fe92eca4f 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductCollectionData.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductCollectionData.php
@@ -24,6 +24,15 @@ class ProductCollectionData extends AbstractRoute {
 	 */
 	const SCHEMA_TYPE = 'product-collection-data';

+	/**
+	 * Default maximum number of entries accepted in the `calculate_attribute_counts` and
+	 * `calculate_taxonomy_counts` parameters. Each entry triggers a full-collection aggregate
+	 * query, so this bounds the per-request query fan-out. Matches the batch route's request cap.
+	 *
+	 * @var int
+	 */
+	const COUNTS_MAX_ITEMS = 25;
+
 	/**
 	 * Get the path of this REST route.
 	 *
@@ -106,15 +115,40 @@ class ProductCollectionData extends AbstractRoute {
 			$taxonomy__and_queries = [];

 			foreach ( $request['calculate_attribute_counts'] as $attributes_to_count ) {
-				if ( ! empty( $attributes_to_count['taxonomy'] ) ) {
-					if ( empty( $attributes_to_count['query_type'] ) || 'or' === $attributes_to_count['query_type'] ) {
-						$taxonomy__or_queries[] = $attributes_to_count['taxonomy'];
-					} else {
-						$taxonomy__and_queries[] = $attributes_to_count['taxonomy'];
-					}
+				if ( empty( $attributes_to_count['taxonomy'] ) ) {
+					continue;
+				}
+
+				// Normalize to the canonical taxonomy name before deduping so textual variants
+				// (e.g. differing case or surrounding whitespace) collapse to a single query.
+				$taxonomy = wc_sanitize_taxonomy_name( $attributes_to_count['taxonomy'] );
+
+				// Resolve numeric attribute IDs (e.g. "3") to their taxonomy name (e.g. "pa_color").
+				if ( is_numeric( $taxonomy ) ) {
+					$taxonomy = wc_attribute_taxonomy_name_by_id( (int) $taxonomy );
+				}
+
+				// Skip anything that is not a registered product attribute taxonomy so non-existent
+				// or non-attribute taxonomies do not trigger wasted full-collection queries.
+				if ( ! taxonomy_is_product_attribute( $taxonomy ) ) {
+					continue;
+				}
+
+				if ( empty( $attributes_to_count['query_type'] ) || 'or' === $attributes_to_count['query_type'] ) {
+					$taxonomy__or_queries[] = $taxonomy;
+				} else {
+					$taxonomy__and_queries[] = $taxonomy;
 				}
 			}

+			// Deduplicate within each query type so the same taxonomy requested multiple times with
+			// the same query type is counted with a single query. The "or" and "and" query types are
+			// counted independently and are not merged across types: the "or" branch removes the active
+			// attribute filter before counting while the "and" branch keeps it, so for the same taxonomy
+			// the two counts can legitimately differ.
+			$taxonomy__or_queries  = array_unique( $taxonomy__or_queries );
+			$taxonomy__and_queries = array_unique( $taxonomy__and_queries );
+
 			$data['attribute_counts'] = [];
 			// Or type queries need special handling because the attribute, if set, needs removing from the query first otherwise counts would not be correct.
 			if ( $taxonomy__or_queries ) {
@@ -126,7 +160,8 @@ class ProductCollectionData extends AbstractRoute {
 						$filter_attributes = array_filter(
 							$filter_attributes,
 							function ( $query ) use ( $taxonomy ) {
-								return $query['attribute'] !== $taxonomy;
+								// $taxonomy is already sanitized, so sanitize the active attribute too for a like-for-like comparison.
+								return wc_sanitize_taxonomy_name( $query['attribute'] ) !== $taxonomy;
 							}
 						);
 					}
@@ -169,7 +204,10 @@ class ProductCollectionData extends AbstractRoute {
 		}

 		if ( ! empty( $request['calculate_taxonomy_counts'] ) ) {
-			$taxonomies              = $request['calculate_taxonomy_counts'];
+			// Normalize to the canonical taxonomy name before deduping so textual variants
+			// (e.g. differing case or surrounding whitespace) collapse to a single query, and keep
+			// only registered taxonomies so non-existent ones do not trigger wasted queries.
+			$taxonomies              = array_unique( array_filter( array_map( 'wc_sanitize_taxonomy_name', $request['calculate_taxonomy_counts'] ), 'taxonomy_exists' ) );
 			$data['taxonomy_counts'] = [];

 			if ( $taxonomies ) {
@@ -229,6 +267,7 @@ class ProductCollectionData extends AbstractRoute {
 				],
 			],
 			'default'     => [],
+			'maxItems'    => self::COUNTS_MAX_ITEMS,
 		];

 		$params['calculate_rating_counts'] = [
@@ -245,6 +284,7 @@ class ProductCollectionData extends AbstractRoute {
 				'description' => __( 'Taxonomy name.', 'woocommerce' ),
 			],
 			'default'     => [],
+			'maxItems'    => self::COUNTS_MAX_ITEMS,
 		];

 		return $params;
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php
index cfec9efe42a..9cd6350fcb5 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php
@@ -119,57 +119,42 @@ class ProductQueryFilters {
 	 * @return array termId=>count pairs.
 	 */
 	public function get_attribute_counts( $request, $attributes = [] ) {
-		global $wpdb;
-
 		// Remove paging and sorting params from the request.
 		$request->set_param( 'page', null );
 		$request->set_param( 'per_page', null );
 		$request->set_param( 'order', null );
 		$request->set_param( 'orderby', null );

-		// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
+		// Convert request to query_vars for FilterData.
 		$product_query = new ProductQuery();
-
-		add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
-		add_filter( 'posts_pre_query', '__return_empty_array' );
-
-		$query_args                   = $product_query->prepare_objects_query( $request );
-		$query_args['no_found_rows']  = true;
-		$query_args['posts_per_page'] = -1;
-		$query                        = new \WP_Query();
-		$result                       = $query->query( $query_args );
-		$product_query_sql            = $query->request;
-
-		remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
-		remove_filter( 'posts_pre_query', '__return_empty_array' );
+		$query_vars    = $product_query->prepare_objects_query( $request );

 		if ( count( $attributes ) === count( array_filter( $attributes, 'is_numeric' ) ) ) {
 			$attributes = array_map( 'wc_attribute_taxonomy_name_by_id', wp_parse_id_list( $attributes ) );
 		}

-		$attributes_to_count     = array_map(
-			function ( $attribute ) {
-				$attribute = wc_sanitize_taxonomy_name( $attribute );
-				return esc_sql( $attribute );
-			},
-			$attributes
-		);
-		$attributes_to_count_sql = 'AND term_taxonomy.taxonomy IN (\'' . implode( '\',\'', $attributes_to_count ) . '\')';
-
-		$attribute_count_sql = "
-			SELECT COUNT( DISTINCT posts.ID ) as term_count, terms.term_id as term_count_id
-			FROM {$wpdb->posts} AS posts
-			INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id
-			INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
-			INNER JOIN {$wpdb->terms} AS terms USING( term_id )
-			WHERE posts.ID IN ( {$product_query_sql} )
-			{$attributes_to_count_sql}
-			GROUP BY terms.term_id
-		";
+		// Use FilterData with ProductQuery as QueryClausesGenerator. This shares the cached,
+		// invalidation-aware filter-data path used by get_taxonomy_counts() and the product filter blocks.
+		$container = wc_get_container();

-		$results = $wpdb->get_results( $attribute_count_sql ); // phpcs:ignore
+		$filter_data_provider = $container->get( \Automattic\WooCommerce\Internal\ProductFilters\FilterDataProvider::class );
+		$filter_data          = $filter_data_provider->with( $product_query );
+
+		$all_counts = array();

-		return array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
+		// Get counts for each attribute taxonomy individually so each is cached separately.
+		foreach ( $attributes as $attribute ) {
+			$taxonomy = wc_sanitize_taxonomy_name( $attribute );
+			if ( ! $taxonomy ) {
+				continue;
+			}
+			$counts = $filter_data->get_attribute_counts( $query_vars, $taxonomy );
+			// Each attribute taxonomy owns a disjoint set of term IDs, so the union operator safely
+			// merges the term_id => count pairs without colliding or overwriting between taxonomies.
+			$all_counts = $all_counts + $counts;
+		}
+
+		return $all_counts;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
index f89603de832..a4deb5e552d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
@@ -289,6 +289,490 @@ class ProductCollectionData extends ControllerTestCase {
 		$this->assertArrayHasKey( 'calculate_taxonomy_counts', $params );
 	}

+	/**
+	 * @testdox The count array params declare a default maxItems bound to limit query fan-out.
+	 */
+	public function test_count_params_declare_default_max_items() {
+		$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+		$controller = $routes->get( 'product-collection-data' );
+		$params     = $controller->get_collection_params();
+
+		$this->assertArrayHasKey( 'maxItems', $params['calculate_attribute_counts'], 'calculate_attribute_counts must be bounded.' );
+		$this->assertArrayHasKey( 'maxItems', $params['calculate_taxonomy_counts'], 'calculate_taxonomy_counts must be bounded.' );
+		$this->assertSame( 25, $params['calculate_attribute_counts']['maxItems'], 'Default attribute-counts cap should be 25.' );
+		$this->assertSame( 25, $params['calculate_taxonomy_counts']['maxItems'], 'Default taxonomy-counts cap should be 25.' );
+	}
+
+	/**
+	 * @testdox An oversized calculate_attribute_counts array is rejected with HTTP 400.
+	 */
+	public function test_calculate_attribute_counts_rejects_oversized_array() {
+		$request  = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$too_many = array_fill(
+			0,
+			26,
+			array(
+				'taxonomy'   => 'pa_size',
+				'query_type' => 'or',
+			)
+		);
+		$request->set_param( 'calculate_attribute_counts', $too_many );
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status(), 'More than 25 attribute-count entries should be rejected.' );
+		$this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox An oversized calculate_taxonomy_counts array is rejected with HTTP 400.
+	 */
+	public function test_calculate_taxonomy_counts_rejects_oversized_array() {
+		$request  = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$too_many = array_fill( 0, 26, 'product_cat' );
+		$request->set_param( 'calculate_taxonomy_counts', $too_many );
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status(), 'More than 25 taxonomy-count entries should be rejected.' );
+		$this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox An array exactly at the cap is accepted.
+	 */
+	public function test_calculate_taxonomy_counts_at_cap_is_accepted() {
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$at_cap  = array_fill( 0, 25, 'product_cat' );
+		$request->set_param( 'calculate_taxonomy_counts', $at_cap );
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status(), 'Exactly 25 entries should be accepted.' );
+	}
+
+	/**
+	 * @testdox Repeating the same attribute taxonomy is de-duplicated to a single set of counts.
+	 */
+	public function test_calculate_attribute_counts_deduplicates_taxonomies() {
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+
+		$single_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$single_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$single = rest_get_server()->dispatch( $single_request )->get_data();
+
+		$repeated_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$repeated_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$repeated = rest_get_server()->dispatch( $repeated_request )->get_data();
+
+		$this->assertNotEmpty( $single['attribute_counts'], 'Baseline single-taxonomy request should return counts.' );
+		$this->assertEquals(
+			$single['attribute_counts'],
+			$repeated['attribute_counts'],
+			'Requesting the same taxonomy multiple times must not duplicate or alter the counts.'
+		);
+	}
+
+	/**
+	 * @testdox The same attribute requested with both "or" and "and" query types is counted separately for each type.
+	 */
+	public function test_calculate_attribute_counts_keeps_query_types_separate() {
+		$fixtures = new FixtureData();
+
+		// Two products with different sizes so that an active filter makes the "or" and "and" counts diverge.
+		$large_product = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $large_product, 'pa_size', 'large', 'large' );
+
+		$small_product = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $small_product, 'pa_size', 'small', 'small' );
+
+		// Shopper has selected "large". "or" counts ignore that selection (faceted what-if counts) while
+		// "and" counts respect it, so the two query types must produce different counts for pa_size.
+		$active_filter = array(
+			array(
+				'attribute' => 'pa_size',
+				'operator'  => 'in',
+				'slug'      => array( 'large' ),
+			),
+		);
+
+		$get_counts = function ( array $entries ) use ( $active_filter ) {
+			$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+			$request->set_param( 'attributes', $active_filter );
+			$request->set_param( 'calculate_attribute_counts', $entries );
+
+			return rest_get_server()->dispatch( $request )->get_data()['attribute_counts'];
+		};
+
+		$or_only  = $get_counts(
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$and_only = $get_counts(
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'and',
+				),
+			)
+		);
+		$both     = $get_counts(
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'and',
+				),
+			)
+		);
+
+		$this->assertNotEmpty( $or_only, 'The "or" request should return counts.' );
+		$this->assertNotEmpty( $and_only, 'The "and" request should return counts.' );
+		$this->assertNotEquals(
+			$or_only,
+			$and_only,
+			'For the same taxonomy, "or" and "and" must produce different counts when it is an active filter.'
+		);
+		$this->assertCount(
+			count( $or_only ) + count( $and_only ),
+			$both,
+			'Requesting both query types must keep both result sets, not collapse them into one.'
+		);
+	}
+
+	/**
+	 * @testdox Attribute taxonomies are normalized before dedup so case and whitespace variants collapse to one entry.
+	 */
+	public function test_calculate_attribute_counts_normalizes_taxonomy_before_dedup() {
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+
+		$single_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$single_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$single = rest_get_server()->dispatch( $single_request )->get_data();
+
+		$variants_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$variants_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+				array(
+					'taxonomy'   => ' pa_size ',
+					'query_type' => 'or',
+				),
+				array(
+					'taxonomy'   => 'PA_SIZE',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$variants = rest_get_server()->dispatch( $variants_request )->get_data();
+
+		$this->assertNotEmpty( $single['attribute_counts'], 'Baseline single-taxonomy request should return counts.' );
+		$this->assertEquals(
+			$single['attribute_counts'],
+			$variants['attribute_counts'],
+			'Case and whitespace variants of the same taxonomy must be counted once, not duplicated.'
+		);
+	}
+
+	/**
+	 * @testdox Non-attribute and non-existent taxonomies are skipped in calculate_attribute_counts.
+	 */
+	public function test_calculate_attribute_counts_skips_invalid_taxonomies() {
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+
+		$baseline_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$baseline_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$baseline = rest_get_server()->dispatch( $baseline_request )->get_data();
+
+		$with_junk_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$with_junk_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+				array(
+					// Exists as a taxonomy but is not a product attribute.
+					'taxonomy'   => 'product_cat',
+					'query_type' => 'or',
+				),
+				array(
+					// Not a registered taxonomy at all.
+					'taxonomy'   => 'pa_does_not_exist',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$with_junk = rest_get_server()->dispatch( $with_junk_request )->get_data();
+
+		$this->assertNotEmpty( $baseline['attribute_counts'], 'Baseline attribute request should return counts.' );
+		$this->assertEquals(
+			$baseline['attribute_counts'],
+			$with_junk['attribute_counts'],
+			'Non-attribute and non-existent taxonomies must be skipped, not counted.'
+		);
+	}
+
+	/**
+	 * @testdox A numeric attribute ID resolves to the same counts as its taxonomy name.
+	 */
+	public function test_calculate_attribute_counts_accepts_numeric_attribute_id() {
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+
+		$attribute_id = wc_attribute_taxonomy_id_by_name( 'pa_size' );
+		$this->assertNotEmpty( $attribute_id, 'Test attribute should resolve to an ID.' );
+
+		$by_name_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$by_name_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$by_name = rest_get_server()->dispatch( $by_name_request )->get_data();
+
+		$by_id_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$by_id_request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => (string) $attribute_id,
+					'query_type' => 'or',
+				),
+			)
+		);
+		$by_id = rest_get_server()->dispatch( $by_id_request )->get_data();
+
+		$this->assertNotEmpty( $by_name['attribute_counts'], 'Baseline name-based request should return counts.' );
+		$this->assertEquals(
+			$by_name['attribute_counts'],
+			$by_id['attribute_counts'],
+			'A numeric attribute ID must resolve to the same counts as its taxonomy name.'
+		);
+	}
+
+	/**
+	 * @testdox Taxonomies are normalized before dedup so case and whitespace variants collapse to one entry.
+	 */
+	public function test_calculate_taxonomy_counts_normalizes_taxonomy_before_dedup() {
+		$category = wp_insert_term( 'Normalized Category', 'product_cat' );
+		wp_set_post_terms( $this->products[0]->get_id(), array( $category['term_id'] ), 'product_cat' );
+
+		$single_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$single_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat' ) );
+		$single = rest_get_server()->dispatch( $single_request )->get_data();
+
+		$variants_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$variants_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat', ' product_cat ', 'PRODUCT_CAT' ) );
+		$variants = rest_get_server()->dispatch( $variants_request )->get_data();
+
+		$this->assertNotEmpty( $single['taxonomy_counts'], 'Baseline single-taxonomy request should return counts.' );
+		$this->assertEquals(
+			$single['taxonomy_counts'],
+			$variants['taxonomy_counts'],
+			'Case and whitespace variants of the same taxonomy must be counted once, not duplicated.'
+		);
+	}
+
+	/**
+	 * @testdox Non-existent taxonomies are skipped in calculate_taxonomy_counts.
+	 */
+	public function test_calculate_taxonomy_counts_skips_nonexistent_taxonomies() {
+		$category = wp_insert_term( 'Skip Test Category', 'product_cat' );
+		wp_set_post_terms( $this->products[0]->get_id(), array( $category['term_id'] ), 'product_cat' );
+
+		$baseline_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$baseline_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat' ) );
+		$baseline = rest_get_server()->dispatch( $baseline_request )->get_data();
+
+		$with_junk_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$with_junk_request->set_param( 'calculate_taxonomy_counts', array( 'product_cat', 'does_not_exist_tax', 'another_missing_tax' ) );
+		$with_junk = rest_get_server()->dispatch( $with_junk_request )->get_data();
+
+		$this->assertNotEmpty( $baseline['taxonomy_counts'], 'Baseline taxonomy request should return counts.' );
+		$this->assertEquals(
+			$baseline['taxonomy_counts'],
+			$with_junk['taxonomy_counts'],
+			'Non-existent taxonomies must be skipped, not counted.'
+		);
+	}
+
+	/**
+	 * @testdox Attribute counts are computed through the cached filter-data path.
+	 */
+	public function test_calculate_attribute_counts_uses_filter_data_cache() {
+		global $wpdb;
+
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+
+		// Clear any filter-data transients left by other requests so this assertion is isolated. The
+		// entry-count counter is excluded so the assertion below proves a real data entry was written.
+		$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_wc_filter_data_%' AND option_name <> '_transient_wc_filter_data_entry_count'" );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+		$request->set_param(
+			'calculate_attribute_counts',
+			array(
+				array(
+					'taxonomy'   => 'pa_size',
+					'query_type' => 'or',
+				),
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		// The returned counts must be correct, not merely present.
+		$this->assertNotEmpty( $data['attribute_counts'], 'Attribute counts should be returned.' );
+
+		// A data-cache entry (not just the entry-count counter) must have been written.
+		$cached_entries = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '_transient_wc_filter_data_%' AND option_name <> '_transient_wc_filter_data_entry_count'" );
+		$this->assertGreaterThan(
+			0,
+			$cached_entries,
+			'Attribute counts should be written to the shared filter-data cache (proves the cached path is used).'
+		);
+	}
+
+	/**
+	 * @testdox Repeated attribute-count requests return stable, correct counts (cache does not corrupt results).
+	 */
+	public function test_calculate_attribute_counts_stable_across_repeated_requests() {
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_variable_product(
+			array(),
+			array(
+				$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
+			)
+		);
+		$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
+
+		$make_request = function () {
+			$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
+			$request->set_param(
+				'calculate_attribute_counts',
+				array(
+					array(
+						'taxonomy'   => 'pa_size',
+						'query_type' => 'or',
+					),
+				)
+			);
+			return rest_get_server()->dispatch( $request )->get_data();
+		};
+
+		$first  = $make_request();
+		$second = $make_request();
+
+		$this->assertNotEmpty( $first['attribute_counts'], 'First request should return counts.' );
+		$this->assertEquals(
+			$first['attribute_counts'],
+			$second['attribute_counts'],
+			'Repeated identical requests must return identical counts.'
+		);
+	}
+
 	/**
 	 * Test schema matches responses.
 	 */