Commit e9a4409e91f for woocommerce

commit e9a4409e91f2ab7719610de2d4caef8fe9a3fefa
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Thu Oct 8 15:49:35 2026 +0700

    Fix attribute filter counts for missing product variations (#69280)

    * Fix attribute filter counts for missing product variations

    * Add changelog for accurate variable product filter counts

    * Refactor attribute counts to use generator capabilities

    * Preserve cached attribute counts with deferred product IDs

    * Simplify attribute counts and align Store API lookup filtering

    * Update changelog for attribute counts and results

    * Keep Store API attribute lookup query generation self-contained

    * Keep one focused attribute count regression in FilterData tests

    * Name the Store API attribute lookup argument explicitly

diff --git a/plugins/woocommerce/changelog/fix-attribute-count-missing-variations b/plugins/woocommerce/changelog/fix-attribute-count-missing-variations
new file mode 100644
index 00000000000..48c5b7b70c1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-attribute-count-missing-variations
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show accurate attribute filter counts and results for variable products with missing variations.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 35a3d0897be..170f7064401 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -69282,18 +69282,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Utilities/ProductQuery.php

-		-
-			message: '#^Offset ''tax_query'' does not exist on non\-empty\-array\<''date_query''\|''fields''\|''ignore_sticky_posts''\|''max_price''\|''min_price''\|''offset''\|''order''\|''orderby''\|''paged''\|''post__in''\|''post__not_in''\|''post_parent__in''\|''post_parent__not_in''\|''post_status''\|''post_type''\|''posts_per_page''\|''search''\|''sku''\|''slug''\|''stock_status'', mixed\>\.$#'
-			identifier: offsetAccess.notFound
-			count: 2
-			path: src/StoreApi/Utilities/ProductQuery.php
-
-		-
-			message: '#^Offset ''tax_query'' on non\-empty\-array\<''date_query''\|''fields''\|''ignore_sticky_posts''\|''max_price''\|''min_price''\|''offset''\|''order''\|''orderby''\|''paged''\|''post__in''\|''post__not_in''\|''post_parent__in''\|''post_parent__not_in''\|''post_status''\|''post_type''\|''posts_per_page''\|''search''\|''sku''\|''slug''\|''stock_status'', mixed\> in empty\(\) does not exist\.$#'
-			identifier: empty.offset
-			count: 2
-			path: src/StoreApi/Utilities/ProductQuery.php
-
 		-
 			message: '#^Parameter \#2 \$array of function implode expects array\<string\>, array\<array\|string\> given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
index 7cfb40a313b..63228c667ae 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
@@ -4,6 +4,7 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Internal\ProductFilters;

+use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
 use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
 use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;
 use WC_Cache_Helper;
@@ -266,8 +267,18 @@ class FilterData {
 			return $pre_filter_counts;
 		}

-		$transient_key = $this->get_transient_key( $query_vars, 'attribute', array( 'taxonomy' => $attribute_to_count ) );
-		$cached_data   = $this->get_cache( $transient_key );
+		$use_lookup_table  = 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' );
+		$hide_out_of_stock = 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' );
+		$transient_key     = $this->get_transient_key(
+			$query_vars,
+			'attribute',
+			array(
+				'taxonomy'          => $attribute_to_count,
+				'use_lookup_table'  => $use_lookup_table,
+				'hide_out_of_stock' => $hide_out_of_stock,
+			)
+		);
+		$cached_data       = $this->get_cache( $transient_key );

 		if ( ! empty( $cached_data ) ) {
 			return $cached_data;
@@ -279,18 +290,29 @@ class FilterData {
 		if ( $product_ids ) {
 			global $wpdb;

-			// Optimization note: We evaluated using wc_product_attributes_lookup but decided against it, as removing
-			// the posts table join in the query below produced better benchmarking results and required minimal changes.
-			$taxonomy_escaped    = esc_sql( wc_sanitize_taxonomy_name( $attribute_to_count ) );
-			$attribute_count_sql = "
-				SELECT COUNT( DISTINCT term_relationships.object_id ) as term_count, terms.term_id as term_count_id
-				FROM {$wpdb->term_relationships} AS term_relationships
-				INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
-				INNER JOIN {$wpdb->terms} AS terms USING( term_id )
-				WHERE term_relationships.object_id IN ( {$product_ids} )
-				AND term_taxonomy.taxonomy = '{$taxonomy_escaped}'
-				GROUP BY terms.term_id
-			";
+			$taxonomy_escaped = esc_sql( wc_sanitize_taxonomy_name( $attribute_to_count ) );
+			if ( $use_lookup_table ) {
+				$lookup_table        = wc_get_container()->get( LookupDataStore::class )->get_lookup_table_name();
+				$in_stock_clause     = $hide_out_of_stock ? ' AND in_stock = 1' : '';
+				$attribute_count_sql = "
+					SELECT COUNT( DISTINCT product_or_parent_id ) as term_count, term_id as term_count_id
+					FROM {$lookup_table}
+					WHERE product_or_parent_id IN ( {$product_ids} )
+					AND taxonomy = '{$taxonomy_escaped}'
+					{$in_stock_clause}
+					GROUP BY term_id
+				";
+			} else {
+				$attribute_count_sql = "
+					SELECT COUNT( DISTINCT term_relationships.object_id ) as term_count, terms.term_id as term_count_id
+					FROM {$wpdb->term_relationships} AS term_relationships
+					INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
+					INNER JOIN {$wpdb->terms} AS terms USING( term_id )
+					WHERE term_relationships.object_id IN ( {$product_ids} )
+					AND term_taxonomy.taxonomy = '{$taxonomy_escaped}'
+					GROUP BY terms.term_id
+				";
+			}

 			/**
 			 * We can't use $wpdb->prepare() here because using %s with
@@ -494,6 +516,7 @@ class FilterData {
 			md5(
 				wp_json_encode(
 					array(
+						'generator'   => get_class( $this->query_clauses ),
 						'query_vars'  => $this->normalize_query_vars( $query_vars ),
 						'params'      => $this->get_params_for_cache(),
 						'extra'       => $extra,
@@ -666,6 +689,7 @@ class FilterData {
 		$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . md5(
 			wp_json_encode(
 				array(
+					'generator'  => get_class( $this->query_clauses ),
 					'query_vars' => $this->normalize_query_vars( $query_vars ),
 					'params'     => $this->get_params_for_cache(),
 				)
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
index 3a99f6b416d..d3638f19e4b 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Enums\TaxDisplayMode;
 use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
+use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use WC_Tax;
@@ -158,7 +159,9 @@ class ProductQuery implements QueryClausesGenerator {
 				}
 			}

-			if ( 1 < count( $att_queries ) ) {
+			if ( 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' ) && 'product_variation' !== $args['post_type'] ) {
+				$args['attribute_lookup_query'] = $att_queries;
+			} elseif ( 1 < count( $att_queries ) ) {
 				// Add relation arg when using multiple attributes.
 				$relation    = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN';
 				$tax_query[] = array(
@@ -172,18 +175,10 @@ class ProductQuery implements QueryClausesGenerator {

 		// Build tax_query if taxonomies are set.
 		if ( ! empty( $tax_query ) && 'product_variation' !== $args['post_type'] ) {
-			if ( ! empty( $args['tax_query'] ) ) {
-				$args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore
-			} else {
-				$args['tax_query'] = $tax_query; // phpcs:ignore
-			}
+			$args['tax_query'] = $tax_query; // phpcs:ignore
 		} else {
 			// For product_variations we need to convert the tax_query to a meta_query.
-			if ( ! empty( $args['tax_query'] ) ) {
-				$args['meta_query'] = $this->convert_tax_query_to_meta_query( array_merge( $tax_query, $args['tax_query'] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
-			} else {
-				$args['meta_query'] = $this->convert_tax_query_to_meta_query( $tax_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
-			}
+			$args['meta_query'] = $this->convert_tax_query_to_meta_query( $tax_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
 		}

 		// Filter featured.
@@ -409,6 +404,64 @@ class ProductQuery implements QueryClausesGenerator {
 		return $last_modified;
 	}

+	/**
+	 * Apply attribute lookup filtering without depending on another query generator.
+	 *
+	 * Keep lookup eligibility aligned with ProductFilters\QueryClauses.
+	 *
+	 * @param array $args       Product query clauses.
+	 * @param array $attributes Attribute taxonomy queries.
+	 * @return array Updated clauses.
+	 */
+	private function add_attribute_lookup_clauses( array $args, array $attributes ): array {
+		global $wpdb;
+
+		$lookup_table    = wc_get_container()->get( LookupDataStore::class )->get_lookup_table_name();
+		$in_stock_clause = 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ? ' AND in_stock = 1' : '';
+		foreach ( $attributes as $attribute ) {
+			$term_field = 'term_id' === $attribute['field'] ? 'include' : 'slug';
+			$terms      = get_terms(
+				array(
+					'taxonomy'   => $attribute['taxonomy'],
+					'hide_empty' => false,
+					'fields'     => 'ids',
+					$term_field  => $attribute['terms'],
+				)
+			);
+			if ( ! is_array( $terms ) || ( 'AND' === $attribute['operator'] && count( $terms ) !== count( array_unique( (array) $attribute['terms'] ) ) ) ) {
+				$args['where'] .= ' AND 1=0';
+				continue;
+			}
+			if ( empty( $terms ) ) {
+				$args['where'] .= 'NOT IN' === $attribute['operator'] ? '' : ' AND 1=0';
+				continue;
+			}
+			$term_ids = implode( ',', wp_parse_id_list( $terms ) );
+			$count    = count( $terms );
+
+			// Table names are internal; term IDs and counts are integers.
+			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+			if ( 'AND' === $attribute['operator'] && $count > 1 ) {
+				$query = "
+					SELECT product_or_parent_id FROM {$lookup_table}
+					WHERE is_variation_attribute = 0 {$in_stock_clause} AND term_id IN ({$term_ids})
+					GROUP BY product_id HAVING COUNT(product_id) = {$count}
+					UNION
+					SELECT product_or_parent_id FROM {$lookup_table}
+					WHERE is_variation_attribute = 1 {$in_stock_clause} AND term_id IN ({$term_ids})
+					GROUP BY product_or_parent_id HAVING COUNT(DISTINCT term_id) = {$count}
+				";
+			} else {
+				$query = "SELECT product_or_parent_id FROM {$lookup_table} WHERE term_id IN ({$term_ids}) {$in_stock_clause}";
+			}
+			$operator = 'NOT IN' === $attribute['operator'] ? 'NOT IN' : 'IN';
+			// The derived table makes the filtering subquery execute once.
+			$args['where'] .= " AND {$wpdb->posts}.ID {$operator} ( SELECT product_or_parent_id FROM ({$query}) attribute_lookup )";
+			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+		}
+		return $args;
+	}
+
 	/**
 	 * Add in conditional search filters for products.
 	 *
@@ -419,6 +472,10 @@ class ProductQuery implements QueryClausesGenerator {
 	public function add_query_clauses( array $args, \WP_Query $wp_query ): array {
 		global $wpdb;

+		if ( $wp_query->get( 'attribute_lookup_query' ) ) {
+			$args = $this->add_attribute_lookup_clauses( $args, $wp_query->get( 'attribute_lookup_query' ) );
+		}
+
 		// SKU and slug lookups can return variations, so exclude any whose parent product is not published.
 		if ( in_array( 'product_variation', (array) $wp_query->get( 'post_type' ), true ) ) {
 			$args['where'] .= $wpdb->prepare(
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 86965de10ee..b6bac0a5ec6 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductCollectionData.php
@@ -196,6 +196,88 @@ class ProductCollectionData extends ControllerTestCase {
 		$this->assertTrue( property_exists( $data['attribute_counts'][0], 'count' ) );
 	}

+	/**
+	 * @testdox Store API counts and attribute filtering follow the lookup option.
+	 * @testWith [true]
+	 *           [false]
+	 * @param bool $lookup_enabled Whether lookup filtering is enabled.
+	 */
+	public function test_attribute_counts_follow_lookup_option( bool $lookup_enabled ): void {
+		update_option( 'woocommerce_attribute_lookup_enabled', $lookup_enabled ? 'yes' : 'no' );
+		$fixtures  = new FixtureData();
+		$attribute = $this->create_product_attribute( 'size', array( 'xs', 's' ) );
+		$product   = $fixtures->get_variable_product( array(), array( $attribute ) );
+		$variation = new \WC_Product_Variation();
+		$variation->set_parent_id( $product->get_id() );
+		$variation->set_attributes( array( 'pa_size' => 's-slug' ) );
+		$variation->set_regular_price( '10' );
+		$variation->save();
+		\WC_Product_Variable::sync( $product->get_id() );
+		wc_get_container()->get( \Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::class )->create_data_for_product( wc_get_product( $product->get_id() ) );
+
+		$response = $this->dispatch_collection_data_request(
+			array(
+				'calculate_attribute_counts' => array(
+					array(
+						'taxonomy'   => 'pa_size',
+						'query_type' => 'or',
+					),
+				),
+			)
+		);
+		$this->assertSame( 200, $response->get_status() );
+		$counts = wp_list_pluck( $response->get_data()['attribute_counts'], 'count', 'term' );
+		$this->assertSame( $lookup_enabled ? 0 : 1, $counts[ term_exists( 'xs', 'pa_size' )['term_id'] ] ?? 0 );
+
+		$cases = array(
+			array(
+				'slug'     => array( 'xs-slug' ),
+				'operator' => 'in',
+				'matches'  => ! $lookup_enabled,
+			),
+			array(
+				'slug'     => array( 'xs-slug' ),
+				'operator' => 'not_in',
+				'matches'  => $lookup_enabled,
+			),
+			array(
+				'term_id'  => array( $attribute['term_ids'][1] ),
+				'operator' => 'in',
+				'matches'  => true,
+			),
+			array(
+				'slug'     => array( 'xs-slug', 's-slug' ),
+				'operator' => 'and',
+				'matches'  => ! $lookup_enabled,
+			),
+			array(
+				'slug'     => array( 'unknown' ),
+				'operator' => 'in',
+				'matches'  => false,
+			),
+			array(
+				'slug'     => array( 'unknown' ),
+				'operator' => 'not_in',
+				'matches'  => true,
+			),
+			array(
+				'slug'     => array( 's-slug', 'unknown' ),
+				'operator' => 'and',
+				'matches'  => false,
+			),
+		);
+		foreach ( $cases as $case ) {
+			$matches = $case['matches'];
+			unset( $case['matches'] );
+			$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+			$request->set_param( 'include', array( $product->get_id() ) );
+			$request->set_param( 'attributes', array( array_merge( array( 'attribute' => 'pa_size' ), $case ) ) );
+			$response = rest_get_server()->dispatch( $request );
+			$this->assertSame( 200, $response->get_status() );
+			$this->assertSame( $matches ? array( $product->get_id() ) : array(), wp_list_pluck( $response->get_data(), 'id' ), wp_json_encode( $case ) );
+		}
+	}
+
 	/**
 	 * @testdox OR attribute counts remove the counted facet while preserving price filters.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
index 21a538693a9..cc5b1e9374c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
@@ -2,6 +2,7 @@

 namespace Automattic\WooCommerce\Tests\Internal\ProductFilters;

+use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
 use Automattic\WooCommerce\Internal\ProductFilters\FilterDataProvider;
 use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
 use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;
@@ -54,6 +55,33 @@ class FilterDataTest extends AbstractProductFiltersTest {
 		$this->taxonomy_hierarchy_data = $container->get( TaxonomyHierarchyData::class );
 	}

+	/**
+	 * @testdox Missing variations are excluded from lookup counts, including after changing the option.
+	 */
+	public function test_attribute_counts_exclude_missing_variations(): void {
+		$product                = wc_get_product( $this->products[4]->get_id() );
+		$blue                   = get_term_by( 'slug', 'blue-slug', 'pa_color' )->term_id;
+		$attributes             = $product->get_attributes();
+		$attributes['pa_color'] = clone $attributes['pa_color'];
+		$attributes['pa_color']->set_options( array_merge( $attributes['pa_color']->get_options(), array( $blue ) ) );
+		$product->set_attributes( $attributes );
+		$product->save();
+		wc_get_container()->get( LookupDataStore::class )->create_data_for_product( $product );
+		$query_vars = array(
+			'post_type' => 'product',
+			'post__in'  => array( $product->get_id() ),
+		);
+
+		foreach ( array(
+			'yes' => 0,
+			'no'  => 1,
+		) as $lookup => $expected ) {
+			update_option( 'woocommerce_attribute_lookup_enabled', $lookup );
+			$counts = $this->sut->get_attribute_counts( $query_vars, 'pa_color' );
+			$this->assertSame( $expected, $counts[ $blue ] ?? 0, 'Lookup option: ' . $lookup );
+		}
+	}
+
 	/**
 	 * @testdox Test price range without filter.
 	 */