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.
*/