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