Commit 7cce600feeb for woocommerce
commit 7cce600feeba8628bede0fedadbda2ec8f1b9a14
Author: Tung Du <dinhtungdu@gmail.com>
Date: Thu Oct 1 10:45:19 2026 +0700
Fix mixed-attribute AND filtering in lookup queries (#69195)
* fix: match multi-attribute AND filters across lookup rows
* chore: add mixed attribute filtering changelog
* Fix classic layered navigation counts for mixed AND attributes
diff --git a/plugins/woocommerce/changelog/fix-mixed-attribute-and-filtering b/plugins/woocommerce/changelog/fix-mixed-attribute-and-filtering
new file mode 100644
index 00000000000..91acdd508cb
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-mixed-attribute-and-filtering
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show products matching multiple selected variation and non-variation attributes.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9a3e882d3c8..48dcfe4212a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -61929,7 +61929,7 @@ parameters:
-
message: '#^Parameter \#1 \$args of function get_terms expects array\{taxonomy\?\: array\<string\>\|string, object_ids\?\: array\<int\>\|int, orderby\?\: string, order\?\: string, hide_empty\?\: bool\|int, include\?\: array\<int\>\|string, exclude\?\: array\<int\>\|string, exclude_tree\?\: array\<int\>\|string, \.\.\.\}, \(int\|string\) given\.$#'
identifier: argument.type
- count: 2
+ count: 1
path: src/Internal/ProductAttributesLookup/Filterer.php
-
diff --git a/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php b/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php
index 09e80bd6cab..df5a6d65c32 100644
--- a/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php
+++ b/plugins/woocommerce/src/Internal/ProductAttributesLookup/Filterer.php
@@ -92,8 +92,7 @@ class Filterer {
$in_stock_clause = '';
}
- $attribute_ids_for_and_filtering = array();
- $clauses = array();
+ $clauses = array();
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
@@ -106,7 +105,24 @@ class Filterer {
if ( 0 !== $count ) {
if ( $is_and_query && $count > 1 ) {
- $attribute_ids_for_and_filtering = array_merge( $attribute_ids_for_and_filtering, $term_ids_to_filter_by );
+ $clauses[] = "
+ {$clause_root}
+ SELECT product_or_parent_id
+ FROM {$this->lookup_table_name} lt
+ WHERE is_variation_attribute=0
+ {$in_stock_clause}
+ AND term_id in {$term_ids_to_filter_by_list}
+ GROUP BY product_id
+ HAVING COUNT(product_id)={$count}
+ UNION
+ SELECT product_or_parent_id
+ FROM {$this->lookup_table_name} lt
+ WHERE is_variation_attribute=1
+ {$in_stock_clause}
+ AND term_id in {$term_ids_to_filter_by_list}
+ GROUP BY product_or_parent_id
+ HAVING COUNT(DISTINCT term_id)={$count}
+ )";
} else {
$clauses[] = "
{$clause_root}
@@ -119,29 +135,6 @@ class Filterer {
}
}
- if ( ! empty( $attribute_ids_for_and_filtering ) ) {
- $count = count( $attribute_ids_for_and_filtering );
- $term_ids_to_filter_by_list = '(' . join( ',', $attribute_ids_for_and_filtering ) . ')';
- $clauses[] = "
- {$clause_root}
- SELECT product_or_parent_id
- FROM {$this->lookup_table_name} lt
- WHERE is_variation_attribute=0
- {$in_stock_clause}
- AND term_id in {$term_ids_to_filter_by_list}
- GROUP BY product_id
- HAVING COUNT(product_id)={$count}
- UNION
- SELECT product_or_parent_id
- FROM {$this->lookup_table_name} lt
- WHERE is_variation_attribute=1
- {$in_stock_clause}
- AND term_id in {$term_ids_to_filter_by_list}
- GROUP BY product_or_parent_id
- HAVING COUNT(DISTINCT term_id)={$count}
- )";
- }
-
if ( ! empty( $clauses ) ) {
// "temp" is needed because the extra derived tables require an alias.
$args['where'] .= ' AND (' . join( ' temp ) AND ', $clauses ) . ' temp ))';
@@ -304,19 +297,22 @@ class Filterer {
$attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes();
if ( ! empty( $attributes_to_filter_by ) ) {
- $and_term_ids = array();
-
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
if ( 'and' !== $data['query_type'] ) {
continue;
}
- $all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
- $term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
- $term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
- $and_term_ids = array_merge( $and_term_ids, $term_ids_to_filter_by );
- }
-
- if ( ! empty( $and_term_ids ) ) {
+ $all_terms = get_terms(
+ array(
+ 'taxonomy' => $taxonomy,
+ 'hide_empty' => false,
+ )
+ );
+ $term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
+ $and_term_ids = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
+
+ if ( empty( $and_term_ids ) ) {
+ continue;
+ }
$terms_count = count( $and_term_ids );
$term_ids_list = '(' . join( ',', $and_term_ids ) . ')';
// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php b/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
index 04164920a78..b9d13a099c6 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
@@ -236,8 +236,7 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
$in_stock_clause = '';
}
- $attribute_ids_for_and_filtering = array();
- $clauses = array();
+ $clauses = array();
// Get all terms for all attribute taxonomies in one query for better performance.
$all_terms_slugs = array();
@@ -277,7 +276,24 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
if ( 0 !== $count ) {
if ( $is_and_query && $count > 1 ) {
- $attribute_ids_for_and_filtering = array_merge( $attribute_ids_for_and_filtering, $term_ids_to_filter_by );
+ $clauses[] = "
+ {$clause_root}
+ SELECT product_or_parent_id
+ FROM {$this->get_lookup_table_name()} lt
+ WHERE is_variation_attribute=0
+ {$in_stock_clause}
+ AND term_id in {$term_ids_to_filter_by_list}
+ GROUP BY product_id
+ HAVING COUNT(product_id)={$count}
+ UNION
+ SELECT product_or_parent_id
+ FROM {$this->get_lookup_table_name()} lt
+ WHERE is_variation_attribute=1
+ {$in_stock_clause}
+ AND term_id in {$term_ids_to_filter_by_list}
+ GROUP BY product_or_parent_id
+ HAVING COUNT(DISTINCT term_id)={$count}
+ )";
} else {
$clauses[] = "
{$clause_root}
@@ -290,29 +306,6 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
}
}
- if ( ! empty( $attribute_ids_for_and_filtering ) ) {
- $count = count( $attribute_ids_for_and_filtering );
- $term_ids_to_filter_by_list = '(' . join( ',', $attribute_ids_for_and_filtering ) . ')';
- $clauses[] = "
- {$clause_root}
- SELECT product_or_parent_id
- FROM {$this->get_lookup_table_name()} lt
- WHERE is_variation_attribute=0
- {$in_stock_clause}
- AND term_id in {$term_ids_to_filter_by_list}
- GROUP BY product_id
- HAVING COUNT(product_id)={$count}
- UNION
- SELECT product_or_parent_id
- FROM {$this->get_lookup_table_name()} lt
- WHERE is_variation_attribute=1
- {$in_stock_clause}
- AND term_id in {$term_ids_to_filter_by_list}
- GROUP BY product_or_parent_id
- HAVING COUNT(DISTINCT term_id)={$count}
- )";
- }
-
if ( ! empty( $clauses ) ) {
// "temp" is needed because the extra derived tables require an alias.
$args['where'] .= ' AND (' . join( ' temp ) AND ', $clauses ) . ' temp ))';
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
index bc60b0a3f16..0a64a3533ff 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/FiltererTest.php
@@ -7,6 +7,8 @@ namespace Automattic\WooCommerce\Tests\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Internal\AttributesHelper;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\Filterer;
+use Automattic\WooCommerce\Internal\ProductFilters\FilterDataProvider;
+use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Enums\ProductStockStatus;
@@ -1270,6 +1272,98 @@ class FiltererTest extends \WC_Unit_Test_Case {
$this->assert_counters( 'Color', $expected_to_be_included_in_count ? array( 'Blue', 'Red', 'Green' ) : array(), $filter_type );
}
+ /**
+ * @testdox Multiple AND attributes match across variation and non-variation lookup rows.
+ *
+ * @testWith [["Red", "Blue"]]
+ * [["Red"]]
+ *
+ * @param string[] $color_terms Selected color terms.
+ */
+ public function test_and_filters_match_variation_and_non_variation_attributes( array $color_terms ): void {
+ $this->set_use_lookup_table( true );
+ $this->create_product_attribute( 'Color', array( 'Red', 'Blue' ) );
+ $this->create_product_attribute( 'Material', array( 'Cotton', 'Wool' ) );
+
+ $data = array(
+ 'variation_attributes' => array( 'Color' => array( 'Red', 'Blue' ) ),
+ 'non_variation_attributes' => array( 'Material' => array( 'Cotton', 'Wool' ) ),
+ 'variations' => array(
+ array(
+ 'in_stock' => true,
+ 'defining_attributes' => array( 'Color' => 'Red' ),
+ ),
+ array(
+ 'in_stock' => true,
+ 'defining_attributes' => array( 'Color' => 'Blue' ),
+ ),
+ ),
+ );
+ $matching_product = $this->create_variable_product( $data );
+
+ $data['non_variation_attributes']['Material'] = array( 'Cotton' );
+ $this->create_variable_product( $data );
+
+ $expected = array( $matching_product['id'] );
+ $this->assertSame(
+ $expected,
+ $this->do_product_request(
+ array(
+ 'Color' => $color_terms,
+ 'Material' => array( 'Cotton', 'Wool' ),
+ ),
+ array(
+ 'Color' => 'and',
+ 'Material' => 'and',
+ )
+ ),
+ 'Catalog must match both selected terms in each attribute.'
+ );
+
+ $chosen_attributes = array(
+ 'pa_color' => array(
+ 'terms' => array_map( 'wc_sanitize_taxonomy_name', $color_terms ),
+ 'query_type' => 'and',
+ ),
+ 'pa_material' => array(
+ 'terms' => array( 'cotton', 'wool' ),
+ 'query_type' => 'and',
+ ),
+ );
+ $query_clauses = wc_get_container()->get( QueryClauses::class );
+ $filter = static function ( $clauses ) use ( $query_clauses, $chosen_attributes ) {
+ return $query_clauses->add_attribute_clauses( $clauses, $chosen_attributes );
+ };
+ add_filter( 'posts_clauses', $filter );
+ try {
+ $filtered_ids = wc_get_products(
+ array(
+ 'include' => $this->product_ids,
+ 'limit' => -1,
+ 'return' => 'ids',
+ )
+ );
+ } finally {
+ remove_filter( 'posts_clauses', $filter );
+ }
+ $this->assertSame( $expected, $filtered_ids, 'Shared clauses must match the catalog result.' );
+
+ $counts = wc_get_container()->get( FilterDataProvider::class )->with( $query_clauses )->get_attribute_counts(
+ array(
+ 'post_type' => 'product',
+ 'post__in' => $this->product_ids,
+ 'filter_color' => implode( ',', array_map( 'wc_sanitize_taxonomy_name', $color_terms ) ),
+ 'query_type_color' => 'and',
+ 'filter_material' => 'cotton,wool',
+ 'query_type_material' => 'and',
+ ),
+ 'pa_material'
+ );
+ $this->assertSame( 1, $counts[ term_exists( 'Cotton', 'pa_material' )['term_id'] ] );
+ $this->assertSame( 1, $counts[ term_exists( 'Wool', 'pa_material' )['term_id'] ] );
+ $this->assert_counters( 'Material', array( 'Cotton', 'Wool' ), 'and' );
+ }
+
/**
* @testdox Products not in "publish" state aren't shown.
*