Commit d18ed8d5a90 for woocommerce
commit d18ed8d5a90e451cd14e909d9e29764adaf5df40
Author: Tung Du <dinhtungdu@gmail.com>
Date: Thu Oct 1 10:45:19 2026 +0700
Refactor Product Collection QueryBuilder filter queries to use ProductFilters\QueryClauses (#68935)
* refactor: share Product Collection filter query clauses
* chore: add Product Collection filter changelog
* refactor: move rating filtering to shared clauses
* Fix duplicate Product Collection filter clauses
* fix: restore Product Collection rating tax query
* docs: explain Product Collection attribute count scope
* refactor: preserve Product Filter count behavior
* refactor: inline Product Collection rating query
* refactor: group Product Collection filters
* refactor: preserve Product Collection rating behavior
* refactor: split attribute clause strategies
* fix: preserve Product Filter attribute behavior
* fix: preserve Product Filter taxonomy behavior
* fix: remove obsolete Product Filters PHPStan baseline entry
diff --git a/plugins/woocommerce/changelog/fix-product-collection-query-clauses b/plugins/woocommerce/changelog/fix-product-collection-query-clauses
new file mode 100644
index 00000000000..235581b399c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-collection-query-clauses
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Migrate Product Collection filtering to the shared Product Filters QueryClauses implementation.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index e2d39619fec..9a3e882d3c8 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -62448,12 +62448,6 @@ parameters:
count: 1
path: src/Internal/ProductFilters/QueryClauses.php
- -
- message: '#^Variable \$chosen_attributes in empty\(\) always exists and is not falsy\.$#'
- identifier: empty.variable
- count: 1
- path: src/Internal/ProductFilters/QueryClauses.php
-
-
message: '#^Variable \$stock_statuses in empty\(\) always exists and is not falsy\.$#'
identifier: empty.variable
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 03240cc519a..f5cd17a0ec8 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection;
use Automattic\WooCommerce\Blocks\BlockTypes\AttributeFilter;
-use Automattic\WooCommerce\Blocks\BlockTypes\PriceFilter;
use Automattic\WooCommerce\Blocks\BlockTypes\RatingFilter;
-use Automattic\WooCommerce\Blocks\BlockTypes\StockFilter;
+use Automattic\WooCommerce\Internal\ProductFilters\Params;
+use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
use WP_Query;
use WC_Tax;
use Automattic\WooCommerce\Enums\CatalogSortOrder;
@@ -54,6 +54,13 @@ class QueryBuilder {
public function __construct() {
$this->valid_query_vars = $this->get_valid_query_vars();
add_filter( 'posts_clauses', array( $this, 'add_price_range_filter_posts_clauses' ), 10, 2 );
+ static $product_filter_query_clauses_callback;
+ if ( null === $product_filter_query_clauses_callback ) {
+ $product_filter_query_clauses_callback = static function ( $clauses, $query ) {
+ return self::apply_product_filter_query_clauses( $clauses, $query );
+ };
+ }
+ add_filter( 'posts_clauses', $product_filter_query_clauses_callback, 10, 2 );
}
/**
@@ -276,9 +283,6 @@ class QueryBuilder {
$price_query_args = $this->get_price_range_query_args( $query['priceRange'] ?? array() );
$handpicked_query = $this->get_handpicked_query( $query['handpicked_products'] ?? false );
- // We exclude applied filters to generate product ids for the filter blocks.
- $applied_filters_query = $is_exclude_applied_filters ? array() : $this->get_queries_by_applied_filters();
-
// Allow collections to provide their own query parameters.
$handlers = $this->collection_handler_store[ $collection_args['name'] ] ?? null;
if ( isset( $handlers['build_query'] ) ) {
@@ -293,18 +297,27 @@ class QueryBuilder {
$collection_query = array();
}
- return $this->merge_queries(
+ $final_query = $this->merge_queries(
$common_query_values,
$orderby_query,
$on_sale_query,
$stock_query,
$tax_query,
- $applied_filters_query,
$date_query,
$price_query_args,
$handpicked_query,
$collection_query
);
+
+ $final_query['isProductCollection'] = true;
+
+ if ( ! $is_exclude_applied_filters ) {
+ // Rating must be a tax query; the remaining filters are query vars consumed by QueryClauses.
+ $final_query = $this->merge_queries( $final_query, $this->get_filter_by_rating_query() );
+ $final_query = array_merge( $final_query, $this->get_applied_filter_query_vars() );
+ }
+
+ return $final_query;
}
/**
@@ -459,96 +472,6 @@ class QueryBuilder {
);
}
- /**
- * Return a query that filters products by price.
- *
- * @return array
- */
- private function get_filter_by_price_query() {
- $min_price = get_query_var( PriceFilter::MIN_PRICE_QUERY_VAR );
- $max_price = get_query_var( PriceFilter::MAX_PRICE_QUERY_VAR );
-
- $max_price_query = empty( $max_price ) ? array() : array(
- 'key' => '_price',
- 'value' => $max_price,
- 'compare' => '<=',
- 'type' => 'numeric',
- );
-
- $min_price_query = empty( $min_price ) ? array() : array(
- 'key' => '_price',
- 'value' => $min_price,
- 'compare' => '>=',
- 'type' => 'numeric',
- );
-
- if ( empty( $min_price_query ) && empty( $max_price_query ) ) {
- return array();
- }
-
- return array(
- // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'meta_query' => array(
- array(
- 'relation' => 'AND',
- $max_price_query,
- $min_price_query,
- ),
- ),
- );
- }
-
- /**
- * Return a query that filters products by attributes.
- *
- * @return array
- */
- private function get_filter_by_attributes_query() {
- $attributes_filter_query_args = $this->get_filter_by_attributes_query_vars();
-
- $queries = array_reduce(
- $attributes_filter_query_args,
- function ( $acc, $query_args ) {
- $attribute_name = $query_args['filter'];
- $attribute_query_type = $query_args['query_type'];
-
- $attribute_value = get_query_var( $attribute_name );
- $attribute_query = get_query_var( $attribute_query_type );
-
- if ( empty( $attribute_value ) ) {
- return $acc;
- }
-
- // It is necessary explode the value because $attribute_value can be a string with multiple values (e.g. "red,blue").
- $attribute_value = explode( ',', $attribute_value );
-
- $acc[] = array(
- 'taxonomy' => str_replace( AttributeFilter::FILTER_QUERY_VAR_PREFIX, 'pa_', $attribute_name ),
- 'field' => 'slug',
- 'terms' => $attribute_value,
- 'operator' => 'and' === $attribute_query ? 'AND' : 'IN',
- );
-
- return $acc;
- },
- array()
- );
-
- if ( empty( $queries ) ) {
- return array();
- }
-
- return array(
- // phpcs:ignore WordPress.DB.SlowDBQuery
- 'tax_query' => array(
- array(
- 'relation' => 'AND',
- $queries,
- ),
- ),
- );
- }
-
/**
* Get all the query args related to the filter by attributes block.
*
@@ -586,42 +509,6 @@ class QueryBuilder {
return $this->attributes_filter_query_args;
}
- /**
- * Return a query that filters products by stock status.
- *
- * @return array
- */
- private function get_filter_by_stock_status_query() {
- $filter_stock_status_values = get_query_var( StockFilter::STOCK_STATUS_QUERY_VAR );
-
- if ( empty( $filter_stock_status_values ) ) {
- return array();
- }
-
- $filtered_stock_status_values = array_filter(
- explode( ',', $filter_stock_status_values ),
- function ( $stock_status ) {
- return in_array( $stock_status, StockFilter::get_stock_status_query_var_values(), true );
- }
- );
-
- if ( empty( $filtered_stock_status_values ) ) {
- return array();
- }
-
- return array(
- // Ignoring the warning of not using meta queries.
- // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'meta_query' => array(
- array(
- 'key' => '_stock_status',
- 'value' => $filtered_stock_status_values,
- 'operator' => 'IN',
- ),
- ),
- );
- }
-
/**
* Return a query that filters products by rating.
*
@@ -661,66 +548,6 @@ class QueryBuilder {
);
}
- /**
- * Return a query that filters products by taxonomy terms.
- *
- * @since 10.6.0
- *
- * @return array
- */
- private function get_filter_by_taxonomy_query() {
-
- $container = wc_get_container();
- $params_handler = $container->get( \Automattic\WooCommerce\Internal\ProductFilters\Params::class );
- $taxonomy_params = $params_handler->get_param( 'taxonomy' );
-
- if ( empty( $taxonomy_params ) ) {
- return array();
- }
-
- $tax_queries = array();
-
- foreach ( $taxonomy_params as $taxonomy_slug => $param_key ) {
- $param_value = get_query_var( $param_key );
-
- // Adding is_string check to avoid invalid query parameters for the taxonomy.
- if ( ! is_string( $param_value ) || empty( $param_value ) ) {
- continue;
- }
-
- // Define $term_values by exploding the string.
- $term_values = explode( ',', $param_value );
-
- // Sanitize and filter (removes empty strings).
- $term_slugs = array_values( array_filter( array_map( 'sanitize_title', $term_values ) ) );
-
- if ( empty( $term_slugs ) ) {
- continue;
- }
-
- $tax_queries[] = array(
- 'taxonomy' => $taxonomy_slug,
- 'field' => 'slug',
- 'terms' => $term_slugs,
- 'operator' => 'IN',
- );
- }
-
- if ( empty( $tax_queries ) ) {
- return array();
- }
-
- return array(
- // phpcs:ignore WordPress.DB.SlowDBQuery
- 'tax_query' => array(
- array(
- 'relation' => 'AND',
- ...$tax_queries,
- ),
- ),
- );
- }
-
/**
* Merge two array recursively but replace the non-array values instead of
* merging them. The merging strategy:
@@ -787,18 +614,52 @@ class QueryBuilder {
}
/**
- * Return queries that are generated by query args.
+ * Get the active Product Filter URL query variables.
*
* @return array
*/
- private function get_queries_by_applied_filters() {
- return array(
- 'price_filter' => $this->get_filter_by_price_query(),
- 'attributes_filter' => $this->get_filter_by_attributes_query(),
- 'stock_status_filter' => $this->get_filter_by_stock_status_query(),
- 'rating_filter' => $this->get_filter_by_rating_query(),
- 'taxonomy_filter' => $this->get_filter_by_taxonomy_query(),
+ private function get_applied_filter_query_vars(): array {
+ $params = wc_get_container()->get( Params::class );
+ $filter_query_vars = array();
+ $registered_attribute_params = $params->get_param( 'attribute' );
+ $registered_query_type_params = array_map(
+ function ( $attribute_name ) {
+ return AttributeFilter::QUERY_TYPE_QUERY_VAR_PREFIX . $attribute_name;
+ },
+ array_keys( $registered_attribute_params )
);
+ $registered_attribute_param_keys = array_merge( array_values( $registered_attribute_params ), $registered_query_type_params );
+
+ foreach ( $params->get_param_keys() as $param_key ) {
+ if ( RatingFilter::RATING_QUERY_VAR === $param_key || in_array( $param_key, $registered_attribute_param_keys, true ) ) {
+ continue;
+ }
+
+ $value = get_query_var( $param_key );
+ if ( ( is_string( $value ) || is_numeric( $value ) ) && '' !== (string) $value ) {
+ $filter_query_vars[ $param_key ] = (string) $value;
+ }
+ }
+
+ foreach ( $this->get_filter_by_attributes_query_vars() as $attribute_name => $query_args ) {
+ $filter_value = get_query_var( $query_args['filter'] );
+ if ( ( ! is_string( $filter_value ) && ! is_numeric( $filter_value ) ) || '' === (string) $filter_value ) {
+ continue;
+ }
+
+ if ( ! is_string( $attribute_name ) ) {
+ $attribute_name = preg_replace( '/^(filter_|pa_)/', '', $query_args['filter'] );
+ }
+ $attribute_name = wc_sanitize_taxonomy_name( $attribute_name );
+ $query_type = get_query_var( $query_args['query_type'] );
+
+ $filter_query_vars[ AttributeFilter::FILTER_QUERY_VAR_PREFIX . $attribute_name ] = (string) $filter_value;
+ $filter_query_vars[ AttributeFilter::QUERY_TYPE_QUERY_VAR_PREFIX . $attribute_name ] = is_string( $query_type ) && in_array( $query_type, array( 'and', 'or' ), true )
+ ? $query_type
+ : 'or';
+ }
+
+ return $filter_query_vars;
}
/**
@@ -931,6 +792,26 @@ class QueryBuilder {
return $clauses;
}
+ /**
+ * Apply Product Filter clauses to Product Collection queries.
+ *
+ * @param array $clauses The query clauses.
+ * @param WP_Query $query The WP_Query instance.
+ * @return array
+ */
+ private static function apply_product_filter_query_clauses( $clauses, $query ) {
+ if ( ! ( $query->query_vars['isProductCollection'] ?? false ) ) {
+ return $clauses;
+ }
+
+ $query_clauses = wc_get_container()->get( QueryClauses::class );
+ if ( false !== has_filter( 'posts_clauses', array( $query_clauses, 'add_query_clauses' ) ) ) {
+ return $clauses;
+ }
+
+ return $query_clauses->add_query_clauses( $clauses, $query );
+ }
+
/**
* Get query for price filters when dealing with displayed taxes.
*
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php b/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
index d327b4703c9..04164920a78 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
@@ -168,7 +168,7 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
if ( isset( $price_range['min_price'] ) ) {
- $min_price_filter = intval( $price_range['min_price'] );
+ $min_price_filter = (float) wc_format_decimal( $price_range['min_price'] );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'max_price', '>=' );
@@ -178,7 +178,7 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
}
if ( isset( $price_range['max_price'] ) ) {
- $max_price_filter = intval( $price_range['max_price'] );
+ $max_price_filter = (float) wc_format_decimal( $price_range['max_price'] );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'min_price', '<=' );
@@ -210,6 +210,21 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
return $args;
}
+ if ( 'yes' !== get_option( 'woocommerce_attribute_lookup_enabled' ) ) {
+ return $this->add_attribute_taxonomy_clauses( $args, $chosen_attributes );
+ }
+
+ return $this->add_attribute_lookup_table_clauses( $args, $chosen_attributes );
+ }
+
+ /**
+ * Add attribute clauses using the product attributes lookup table.
+ *
+ * @param array $args Query args.
+ * @param array $chosen_attributes Chosen attributes.
+ * @return array
+ */
+ private function add_attribute_lookup_table_clauses( array $args, array $chosen_attributes ): array {
global $wpdb;
// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
@@ -293,6 +308,8 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
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}
)";
}
@@ -306,6 +323,93 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
return $args;
}
+ /**
+ * Add attribute clauses using WordPress taxonomy queries.
+ *
+ * @param array $args Query args.
+ * @param array $chosen_attributes Chosen attributes.
+ * @return array
+ */
+ private function add_attribute_taxonomy_clauses( array $args, array $chosen_attributes ): array {
+ global $wpdb;
+
+ $all_term_slugs = array();
+ foreach ( $chosen_attributes as $data ) {
+ if ( ! empty( $data['terms'] ) && is_array( $data['terms'] ) ) {
+ $all_term_slugs = array_merge( $all_term_slugs, $data['terms'] );
+ }
+ }
+
+ if ( empty( $all_term_slugs ) ) {
+ $args['where'] .= ' AND 1=0';
+ return $args;
+ }
+
+ $all_terms = get_terms(
+ array(
+ 'taxonomy' => array_keys( $chosen_attributes ),
+ 'slug' => $all_term_slugs,
+ 'hide_empty' => false,
+ )
+ );
+
+ if ( is_wp_error( $all_terms ) ) {
+ $args['where'] .= ' AND 1=0';
+ return $args;
+ }
+
+ $term_ids_by_taxonomy = array();
+ foreach ( $all_terms as $term ) {
+ $term_ids_by_taxonomy[ $term->taxonomy ][ $term->slug ] = (int) $term->term_id;
+ }
+
+ $clauses = array();
+ foreach ( $chosen_attributes as $taxonomy => $data ) {
+ $term_ids = array_values( array_intersect_key( $term_ids_by_taxonomy[ $taxonomy ] ?? array(), array_flip( $data['terms'] ) ) );
+ $is_and = 'and' === strtolower( $data['query_type'] );
+ $unique_terms = array_unique( $data['terms'] );
+
+ if ( empty( $term_ids ) || ( $is_and && count( $term_ids ) !== count( $unique_terms ) ) ) {
+ $args['where'] .= ' AND 1=0';
+ return $args;
+ }
+
+ $term_ids_list = '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+ if ( $is_and && count( $term_ids ) > 1 ) {
+ $clauses[] = $wpdb->prepare(
+ "{$wpdb->posts}.ID IN (
+ SELECT tr.object_id FROM {$wpdb->term_relationships} tr
+ INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
+ WHERE tt.taxonomy = %s
+ AND tt.term_id IN {$term_ids_list}
+ GROUP BY tr.object_id
+ HAVING COUNT(DISTINCT tt.term_id) = %d
+ )",
+ $taxonomy,
+ count( $term_ids )
+ );
+ } else {
+ $clauses[] = $wpdb->prepare(
+ "EXISTS (
+ SELECT 1 FROM {$wpdb->term_relationships} tr
+ INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
+ WHERE tr.object_id = {$wpdb->posts}.ID
+ AND tt.taxonomy = %s
+ AND tt.term_id IN {$term_ids_list}
+ )",
+ $taxonomy
+ );
+ }
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+ }
+
+ $args['where'] .= ' AND (' . implode( ' AND ', $clauses ) . ')';
+
+ return $args;
+ }
+
/**
* Add query clauses for taxonomy filter (e.g., product_cat, product_tag).
*
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 301ab506c2d..642e635be18 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
use Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection\Utils;
use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductCollectionMock;
use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
use WC_Helper_Product;
use WP_Query;
@@ -248,101 +249,59 @@ class QueryBuilder extends \WP_UnitTestCase {
}
/**
- * Test merging filter by max price queries.
+ * @testdox Should pass the maximum price filter to shared query clauses.
*/
- public function test_merging_filter_by_max_price_queries() {
+ public function test_merging_filter_by_max_price_queries(): void {
set_query_var( 'max_price', 100 );
$merged_query = Utils::initialize_merged_query( $this->block_instance );
- $this->assertContainsEquals(
- array(
- array(
- 'key' => '_price',
- 'value' => 100,
- 'compare' => '<=',
- 'type' => 'numeric',
- ),
- array(),
- 'relation' => 'AND',
- ),
- $merged_query['meta_query']
- );
+ $this->assertSame( '100', $merged_query['max_price'] );
+ $this->assertTrue( $merged_query['isProductCollection'] );
+ $this->assertEmpty( $merged_query['meta_query'] );
set_query_var( 'max_price', '' );
}
/**
- * Test merging filter by min price queries.
+ * @testdox Should pass the minimum price filter to shared query clauses.
*/
- public function test_merging_filter_by_min_price_queries() {
+ public function test_merging_filter_by_min_price_queries(): void {
set_query_var( 'min_price', 20 );
$merged_query = Utils::initialize_merged_query( $this->block_instance );
- $this->assertContainsEquals(
- array(
- array(),
- array(
- 'key' => '_price',
- 'value' => 20,
- 'compare' => '>=',
- 'type' => 'numeric',
- ),
- 'relation' => 'AND',
- ),
- $merged_query['meta_query']
- );
+ $this->assertSame( '20', $merged_query['min_price'] );
+ $this->assertEmpty( $merged_query['meta_query'] );
set_query_var( 'min_price', '' );
}
/**
- * Test merging filter by min and max price queries.
+ * @testdox Should pass both price filters to shared query clauses.
*/
- public function test_merging_filter_by_min_and_max_price_queries() {
+ public function test_merging_filter_by_min_and_max_price_queries(): void {
set_query_var( 'max_price', 100 );
set_query_var( 'min_price', 20 );
$merged_query = Utils::initialize_merged_query( $this->block_instance );
- $this->assertContainsEquals(
- array(
- array(
- 'key' => '_price',
- 'value' => 100,
- 'compare' => '<=',
- 'type' => 'numeric',
- ),
- array(
- 'key' => '_price',
- 'value' => 20,
- 'compare' => '>=',
- 'type' => 'numeric',
- ),
- 'relation' => 'AND',
- ),
- $merged_query['meta_query']
- );
+ $this->assertSame( '100', $merged_query['max_price'] );
+ $this->assertSame( '20', $merged_query['min_price'] );
+ $this->assertEmpty( $merged_query['meta_query'] );
set_query_var( 'max_price', '' );
set_query_var( 'min_price', '' );
}
/**
- * Test merging filter by stock status queries.
+ * @testdox Should pass stock filters to shared query clauses.
*/
- public function test_merging_filter_by_stock_status_queries() {
+ public function test_merging_filter_by_stock_status_queries(): void {
set_query_var( 'filter_stock_status', ProductStockStatus::IN_STOCK );
$merged_query = Utils::initialize_merged_query( $this->block_instance );
- $this->assertContainsEquals(
- array(
- 'operator' => 'IN',
- 'key' => '_stock_status',
- 'value' => array( ProductStockStatus::IN_STOCK ),
- ),
- $merged_query['meta_query']
- );
+ $this->assertSame( ProductStockStatus::IN_STOCK, $merged_query['filter_stock_status'] );
+ $this->assertEmpty( $merged_query['meta_query'] );
set_query_var( 'filter_stock_status', '' );
}
@@ -406,12 +365,61 @@ class QueryBuilder extends \WP_UnitTestCase {
set_query_var( 'filter_stock_status', ProductStockStatus::OUT_OF_STOCK );
- $merged_query = Utils::initialize_merged_query( $this->block_instance );
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
+ $additional_query_builder = new \Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\QueryBuilder();
+ unset( $additional_query_builder );
+
$query = new WP_Query( $merged_query );
$found_product_ids = wp_list_pluck( $query->posts, 'ID' );
$this->assertContains( $matching_product->get_id(), $found_product_ids, 'The out-of-stock target should be returned.' );
$this->assertNotContains( $non_matching_product->get_id(), $found_product_ids, 'The in-stock distractor should be excluded.' );
+ $this->assertSame( 1, substr_count( $query->request, 'wc_product_meta_lookup.stock_status IN' ), 'Multiple QueryBuilder instances should apply shared clauses once.' );
+ }
+
+ /**
+ * @testdox Decimal price filters are preserved by shared query clauses.
+ */
+ public function test_filter_by_decimal_price_returns_exact_products(): void {
+ $fixtures = new FixtureData();
+
+ $matching_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Decimal price target',
+ 'regular_price' => '10.25',
+ 'status' => 'publish',
+ )
+ );
+ $non_matching_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Decimal price distractor',
+ 'regular_price' => '10.75',
+ 'status' => 'publish',
+ )
+ );
+
+ set_query_var( 'max_price', '10.50' );
+
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
+ $query = new WP_Query( $merged_query );
+ $found_product_ids = wp_list_pluck( $query->posts, 'ID' );
+
+ $this->assertContains( $matching_product->get_id(), $found_product_ids, 'The product below the decimal maximum should be returned.' );
+ $this->assertNotContains( $non_matching_product->get_id(), $found_product_ids, 'The product above the decimal maximum should be excluded.' );
+ set_query_var( 'max_price', '' );
+ }
+
+ /**
+ * @testdox Invalid stock filters return no products through shared query clauses.
+ */
+ public function test_invalid_stock_filter_returns_no_products(): void {
+ set_query_var( 'filter_stock_status', 'invalid' );
+
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
+ $query = new WP_Query( $merged_query );
+
+ $this->assertEmpty( $query->posts );
+ set_query_var( 'filter_stock_status', '' );
}
/**
@@ -463,10 +471,9 @@ class QueryBuilder extends \WP_UnitTestCase {
}
/**
- * Test merging filter by stock status queries.
+ * @testdox Should normalize attribute filters for shared query clauses and default to OR.
*/
- public function test_merging_filter_by_attribute_queries() {
- // Mock the attribute data.
+ public function test_merging_filter_by_attribute_queries(): void {
$this->block_instance->set_attributes_filter_query_args(
array(
array(
@@ -481,97 +488,134 @@ class QueryBuilder extends \WP_UnitTestCase {
);
set_query_var( 'filter_color', 'blue' );
- set_query_var( 'query_type_color', 'or' );
set_query_var( 'filter_size', 'xl,xxl' );
set_query_var( 'query_type_size', 'and' );
$merged_query = Utils::initialize_merged_query( $this->block_instance );
- $tax_queries = $merged_query['tax_query'];
-
- $and_query = array();
- foreach ( $tax_queries as $tax_query ) {
- if ( isset( $tax_query['relation'] ) && 'AND' === $tax_query['relation'] ) {
- $and_query = $tax_query;
- }
- }
- // Check if the AND query is an array.
- $this->assertIsArray( $and_query );
+ $this->assertSame( 'blue', $merged_query['filter_color'] );
+ $this->assertSame( 'or', $merged_query['query_type_color'] );
+ $this->assertSame( 'xl,xxl', $merged_query['filter_size'] );
+ $this->assertSame( 'and', $merged_query['query_type_size'] );
- $attribute_queries = array();
- foreach ( $and_query as $and_query_item ) {
- if ( is_array( $and_query_item ) ) {
- $attribute_queries = $and_query_item;
- }
- }
+ set_query_var( 'filter_color', '' );
+ set_query_var( 'filter_size', '' );
+ set_query_var( 'query_type_size', '' );
+ }
- $this->assertContainsEquals(
+ /**
+ * @testdox Should normalize custom attribute parameter names for shared query clauses.
+ */
+ public function test_custom_attribute_query_vars_are_normalized(): void {
+ $this->block_instance->set_attributes_filter_query_args(
array(
- 'taxonomy' => 'pa_color',
- 'field' => 'slug',
- 'terms' => array( 'blue' ),
- 'operator' => 'IN',
- ),
- $attribute_queries
+ 'color' => array(
+ 'filter' => 'pa_color',
+ 'query_type' => 'custom_color_mode',
+ ),
+ )
);
- $this->assertContainsEquals(
- array(
- 'taxonomy' => 'pa_size',
- 'field' => 'slug',
- 'terms' => array( 'xl', 'xxl' ),
- 'operator' => 'AND',
- ),
- $attribute_queries
- );
+ set_query_var( 'pa_color', 'blue' );
+ set_query_var( 'custom_color_mode', 'and' );
- set_query_var( 'filter_color', '' );
- set_query_var( 'query_type_color', '' );
- set_query_var( 'filter_size', '' );
- set_query_var( 'query_type_size', '' );
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
+
+ $this->assertSame( 'blue', $merged_query['filter_color'] );
+ $this->assertSame( 'and', $merged_query['query_type_color'] );
+ $this->assertArrayNotHasKey( 'pa_color', $merged_query );
+
+ set_query_var( 'pa_color', '' );
+ set_query_var( 'custom_color_mode', '' );
}
/**
- * Test merging multiple filter queries.
+ * @testdox Should pass multiple shopper filters without building local SQL queries.
*/
- public function test_merging_multiple_filter_queries() {
+ public function test_merging_multiple_filter_queries(): void {
set_query_var( 'max_price', 100 );
set_query_var( 'min_price', 20 );
set_query_var( 'filter_stock_status', ProductStockStatus::IN_STOCK );
$merged_query = Utils::initialize_merged_query( $this->block_instance );
- $this->assertContainsEquals(
+ $this->assertSame( '100', $merged_query['max_price'] );
+ $this->assertSame( '20', $merged_query['min_price'] );
+ $this->assertSame( ProductStockStatus::IN_STOCK, $merged_query['filter_stock_status'] );
+ $this->assertEmpty( $merged_query['meta_query'] );
+
+ set_query_var( 'max_price', '' );
+ set_query_var( 'min_price', '' );
+ set_query_var( 'filter_stock_status', '' );
+ }
+
+ /**
+ * @testdox Filter count queries omit shopper filters but keep collection constraints.
+ */
+ public function test_filter_count_query_excludes_applied_filters(): void {
+ $query_builder = new \Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\QueryBuilder();
+ $query_builder->set_attributes_filter_query_args(
array(
- 'operator' => 'IN',
- 'key' => '_stock_status',
- 'value' => array( ProductStockStatus::IN_STOCK ),
- ),
- $merged_query['meta_query']
+ 'color' => array(
+ 'filter' => 'filter_color',
+ 'query_type' => 'query_type_color',
+ ),
+ )
);
- $this->assertContainsEquals(
+ set_query_var( 'max_price', '100' );
+ set_query_var( 'filter_stock_status', ProductStockStatus::IN_STOCK );
+ set_query_var( 'filter_color', 'blue' );
+ set_query_var( 'categories', 'accessories' );
+
+ $final_query = $query_builder->get_final_query_args(
+ array( 'name' => '' ),
array(
- array(
- 'key' => '_price',
- 'value' => 100,
- 'compare' => '<=',
- 'type' => 'numeric',
- ),
- array(
- 'key' => '_price',
- 'value' => 20,
- 'compare' => '>=',
- 'type' => 'numeric',
- ),
- 'relation' => 'AND',
+ 'meta_query' => array(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'post__in' => array(),
+ 'tax_query' => array(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
),
- $merged_query['meta_query']
+ array(
+ 'handpicked_products' => false,
+ 'on_sale' => false,
+ 'orderby' => '',
+ 'product_attributes' => array(),
+ 'queryId' => null,
+ 'stock_status' => array( ProductStockStatus::OUT_OF_STOCK ),
+ ),
+ true
);
+ $this->assertTrue( $final_query['isProductCollection'] );
+ $this->assertNotEmpty( $final_query['meta_query'], 'Saved collection stock constraints should remain.' );
+ $this->assertArrayNotHasKey( 'max_price', $final_query );
+ $this->assertArrayNotHasKey( 'filter_stock_status', $final_query );
+ $this->assertArrayNotHasKey( 'filter_color', $final_query );
+ $this->assertArrayNotHasKey( 'categories', $final_query );
+
set_query_var( 'max_price', '' );
- set_query_var( 'min_price', '' );
set_query_var( 'filter_stock_status', '' );
+ set_query_var( 'filter_color', '' );
+ set_query_var( 'categories', '' );
+ }
+
+ /**
+ * @testdox Product Filter count queries apply shared clauses only once.
+ */
+ public function test_filter_count_query_applies_shared_clauses_once(): void {
+ set_query_var( 'filter_stock_status', ProductStockStatus::IN_STOCK );
+ $query_args = Utils::initialize_merged_query( $this->block_instance );
+ $query_clauses = wc_get_container()->get( QueryClauses::class );
+
+ add_filter( 'posts_clauses', array( $query_clauses, 'add_query_clauses' ), 10, 2 );
+ try {
+ $query = new WP_Query( $query_args );
+ } finally {
+ remove_filter( 'posts_clauses', array( $query_clauses, 'add_query_clauses' ), 10 );
+ set_query_var( 'filter_stock_status', '' );
+ }
+
+ $this->assertSame( 1, substr_count( $query->request, 'wc_product_meta_lookup.stock_status IN' ) );
}
/**
@@ -1193,177 +1237,60 @@ class QueryBuilder extends \WP_UnitTestCase {
}
/**
- * Test merging filter queries by Category Slug (e.g. ?categories=accessories).
+ * @testdox Should pass category filters to shared query clauses.
*/
- public function test_merging_filter_by_category_slug() {
- // Set the URL query variables.
+ public function test_merging_filter_by_category_slug(): void {
set_query_var( 'categories', 'accessories' );
- // Execute the query builder.
- $merged_query = Utils::initialize_merged_query( $this->block_instance );
- $filter_clauses = $this->extract_filter_clauses( $merged_query['tax_query'] );
-
- // Assertions.
- $this->assertContainsEquals(
- array(
- 'taxonomy' => 'product_cat',
- 'field' => 'slug',
- 'terms' => array( 'accessories' ),
- 'operator' => 'IN',
- ),
- $filter_clauses,
- 'Should contain correct product_cat tax query using slug.'
- );
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
- // Clean up.
+ $this->assertSame( 'accessories', $merged_query['categories'] );
set_query_var( 'categories', '' );
}
/**
- * Test merging filter queries specifically for Tags.
- * Scenario: ?tags=tag-new (Slug)
+ * @testdox Should pass tag filters to shared query clauses.
*/
- public function test_merging_filter_by_tags() {
- // Set the URL query variables.
+ public function test_merging_filter_by_tags(): void {
set_query_var( 'tags', 'tag-new' );
- // Execute the query builder.
- $merged_query = Utils::initialize_merged_query( $this->block_instance );
- $filter_clauses = $this->extract_filter_clauses( $merged_query['tax_query'] );
-
- // Assertions.
- $this->assertContainsEquals(
- array(
- 'taxonomy' => 'product_tag',
- 'field' => 'slug',
- 'terms' => array( 'tag-new' ),
- 'operator' => 'IN',
- ),
- $filter_clauses,
- 'Should contain correct product_tag tax query with IN operator.'
- );
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
- // Clean up.
+ $this->assertSame( 'tag-new', $merged_query['tags'] );
set_query_var( 'tags', '' );
}
/**
- * Test merging filter queries for Categories, Tags, and Brands simultaneously.
- * Scenario: ?categories=accessories&tags=tag-new&brands=nike
+ * @testdox Should pass category, tag, and brand filters to shared query clauses.
*/
- public function test_merging_filter_by_all_taxonomies_together() {
- // Set the URL query variables.
+ public function test_merging_filter_by_all_taxonomies_together(): void {
set_query_var( 'categories', 'accessories' );
set_query_var( 'tags', 'tag-new' );
set_query_var( 'brands', 'nike' );
- // Execute the query builder.
- $merged_query = Utils::initialize_merged_query( $this->block_instance );
- $filter_clauses = $this->extract_filter_clauses( $merged_query['tax_query'] );
-
- // Assertions.
- // Verify Category.
- $this->assertContainsEquals(
- array(
- 'taxonomy' => 'product_cat',
- 'field' => 'slug',
- 'terms' => array( 'accessories' ),
- 'operator' => 'IN',
- ),
- $filter_clauses,
- 'Should contain correct product_cat tax query.'
- );
-
- // Verify Tag.
- $this->assertContainsEquals(
- array(
- 'taxonomy' => 'product_tag',
- 'field' => 'slug',
- 'terms' => array( 'tag-new' ),
- 'operator' => 'IN',
- ),
- $filter_clauses,
- 'Should contain correct product_tag tax query.'
- );
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
- // Verify Brand.
- $this->assertContainsEquals(
- array(
- 'taxonomy' => 'product_brand',
- 'field' => 'slug',
- 'terms' => array( 'nike' ),
- 'operator' => 'IN',
- ),
- $filter_clauses,
- 'Should contain correct product_brand tax query.'
- );
+ $this->assertSame( 'accessories', $merged_query['categories'] );
+ $this->assertSame( 'tag-new', $merged_query['tags'] );
+ $this->assertSame( 'nike', $merged_query['brands'] );
- // Clean up global state.
set_query_var( 'categories', '' );
set_query_var( 'tags', '' );
set_query_var( 'brands', '' );
}
/**
- * Test that the strictly string-based filter logic works and SAFELY ignores arrays.
- * Matches logic: if ( ! is_string($param_value) ) continue;
+ * @testdox Should ignore non-scalar taxonomy filter values.
*/
- public function test_filter_strict_string_handling() {
- // Scenario: Array Input (Should be IGNORED).
- // ?categories[]=hats.
+ public function test_filter_strict_string_handling(): void {
set_query_var( 'categories', array( 'hats' ) );
- // Execute.
- $merged_query = Utils::initialize_merged_query( $this->block_instance );
- $tax_queries = $merged_query['tax_query'] ?? array();
- $filter_clauses = $this->extract_filter_clauses( $tax_queries );
-
- // Assertion: The array input should have been ignored.
- $this->assertNotContainsEquals(
- array(
- 'taxonomy' => 'product_cat',
- 'field' => 'slug',
- 'terms' => array( 'hats' ),
- 'operator' => 'IN',
- ),
- $filter_clauses,
- 'Should not contain product_cat tax query because array input should be ignored.'
- );
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
- // Clean up.
+ $this->assertArrayNotHasKey( 'categories', $merged_query );
set_query_var( 'categories', '' );
}
- /**
- * Helper to extract filter clauses from the tax_query array.
- *
- * @param array $tax_queries The tax_query array from the merged query.
- * @return array The extracted filter clauses.
- */
- private function extract_filter_clauses( array $tax_queries ) {
-
- $and_query = array();
-
- // Find the 'AND' relation group where filters are stored.
- foreach ( $tax_queries as $tax_query ) {
- if ( isset( $tax_query['relation'] ) && 'AND' === $tax_query['relation'] ) {
- $and_query = $tax_query;
- break;
- }
- }
-
- $clauses = array();
- if ( ! empty( $and_query ) ) {
- foreach ( $and_query as $item ) {
- if ( is_array( $item ) ) {
- $clauses[] = $item;
- }
- }
- }
-
- return $clauses;
- }
-
/**
* @testdox Empty string values for perPage and offset fall back to defaults.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
index 34fca6f359b..21a538693a9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
@@ -172,6 +172,34 @@ class FilterDataTest extends AbstractProductFiltersTest {
);
}
+ /**
+ * @testdox Rating taxonomy queries constrain sibling stock counts.
+ */
+ public function test_get_stock_status_counts_with_rating_tax_query(): void {
+ $product_visibility_terms = wc_get_product_visibility_term_ids();
+ $wp_query = new \WP_Query(
+ array(
+ 'post_type' => 'product',
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ 'tax_query' => array(
+ array(
+ 'field' => 'term_taxonomy_id',
+ 'taxonomy' => 'product_visibility',
+ 'terms' => array( $product_visibility_terms['rated-5'] ),
+ 'rating_filter' => true,
+ ),
+ ),
+ )
+ );
+
+ $this->test_get_stock_status_counts_with(
+ $wp_query,
+ function ( $product_data ) {
+ return in_array( $product_data['name'], array( 'Product 1', 'Product 4' ), true );
+ }
+ );
+ }
+
/**
* @testdox Test rating counts without filter.
*/
@@ -256,10 +284,8 @@ class FilterDataTest extends AbstractProductFiltersTest {
/**
* @testdox Test attribute count with query_type set to `and`.
- * @todo Remove this test once the issue with `and` query type is fixed in https://github.com/woocommerce/woocommerce/pull/44825.
*/
public function test_get_attribute_counts_with_query_type_and() {
- $this->markTestSkipped( 'Skipping tests with query_type `and` because there is an issue with Filterer::filter_by_attribute_post_clauses that generate wrong clauses for `and`. We can fix the same issue in FilterClausesGenerator::add_attribute_clauses but doing so will make the attribute counts data doesnt match with current query. A fix for both methods is pending. See https://github.com/woocommerce/woocommerce/pull/44825.' );
$wp_query = new \WP_Query( array( 'post_type' => 'product' ) );
$wp_query->set( 'filter_color', 'blue-slug,green-slug' );
$wp_query->set( 'query_type_color', 'and' );
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
index c190a60942a..0256af2a4a5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
@@ -121,6 +121,31 @@ class QueryClausesTest extends AbstractProductFiltersTest {
$this->assertEqualsCanonicalizing( $expected_products_name, $received_products_name );
}
+ /**
+ * @testdox Decimal price boundaries are not truncated.
+ */
+ public function test_decimal_price_boundaries_are_not_truncated(): void {
+ $product = $this->fixture_data->get_simple_product(
+ array(
+ 'name' => 'Decimal price product',
+ 'regular_price' => 10.5,
+ )
+ );
+ $price_range = array(
+ 'min_price' => 10.5,
+ 'max_price' => 10.5,
+ );
+ $filter_callback = function ( $args ) use ( $price_range ) {
+ return $this->sut->add_price_clauses( $args, $price_range );
+ };
+
+ add_filter( 'posts_clauses', $filter_callback );
+ $received_products = wc_get_products( array() );
+ remove_filter( 'posts_clauses', $filter_callback );
+
+ $this->assertSame( array( $product->get_name() ), $this->get_data_from_products_array( $received_products ) );
+ }
+
/**
* @testdox Test the product query with post clauses containing stock clauses.
*
@@ -161,14 +186,19 @@ class QueryClausesTest extends AbstractProductFiltersTest {
* ["pa_color",["red-slug"],"or"]
* ["pa_color",["red-slug","not-exist-slug"],"or"]
* ["pa_color",["red-slug","green-slug"],"or"]
+ * ["pa_color",["red-slug","green-slug"],"and"]
+ * ["pa_color",["red-slug","blue-slug"],"and"]
+ * ["pa_color",["red-slug"],"or",false]
+ * ["pa_color",["red-slug","green-slug"],"and",false]
*
- * @todo Add tests for `and` query type once https://github.com/woocommerce/woocommerce/pull/44825 is merged.
- *
- * @param string $taxonomy Attribute taxonomy name.
- * @param string[] $terms Chosen terms' slug.
- * @param string $query_type Query type. Accepts 'and' or 'or'.
+ * @param string $taxonomy Attribute taxonomy name.
+ * @param string[] $terms Chosen terms' slug.
+ * @param string $query_type Query type. Accepts 'and' or 'or'.
+ * @param bool $lookup_enabled Whether to use the product attributes lookup table.
*/
- public function test_attribute_clauses_with( $taxonomy, $terms, $query_type ) {
+ public function test_attribute_clauses_with( $taxonomy, $terms, $query_type, $lookup_enabled = true ) {
+ update_option( 'woocommerce_attribute_lookup_enabled', $lookup_enabled ? 'yes' : 'no' );
+
$chosen_attributes = array(
$taxonomy => array(
'terms' => $terms,
@@ -218,6 +248,27 @@ class QueryClausesTest extends AbstractProductFiltersTest {
$this->assertEqualsCanonicalizing( $expected_products_name, $received_products_name );
}
+ /**
+ * @testdox Attribute lookup filtering ignores unknown terms in an AND query.
+ */
+ public function test_attribute_lookup_clauses_ignore_unknown_and_terms(): void {
+ $chosen_attributes = array(
+ 'pa_color' => array(
+ 'terms' => array( 'red-slug', 'not-exist-slug' ),
+ 'query_type' => 'and',
+ ),
+ );
+ $filter_callback = function ( $args ) use ( $chosen_attributes ) {
+ return $this->sut->add_attribute_clauses( $args, $chosen_attributes );
+ };
+
+ add_filter( 'posts_clauses', $filter_callback );
+ $received_products = $this->get_data_from_products_array( wc_get_products( array() ) );
+ remove_filter( 'posts_clauses', $filter_callback );
+
+ $this->assertSame( array( 'Product 5' ), $received_products );
+ }
+
/**
* Test the product query with post clauses containing taxonomy clauses.
*
@@ -268,6 +319,25 @@ class QueryClausesTest extends AbstractProductFiltersTest {
$this->assertEqualsCanonicalizing( $expected_products_name, $received_products_name );
}
+ /**
+ * @testdox Taxonomy filtering ignores selected taxonomies with no matching term.
+ */
+ public function test_taxonomy_clauses_ignore_taxonomies_without_matching_terms(): void {
+ $chosen_taxonomies = array(
+ 'product_cat' => array( 'cat-1' ),
+ 'product_tag' => array( 'not-exist-slug' ),
+ );
+ $filter_callback = function ( $args ) use ( $chosen_taxonomies ) {
+ return $this->sut->add_taxonomy_clauses( $args, $chosen_taxonomies );
+ };
+
+ add_filter( 'posts_clauses', $filter_callback );
+ $received_products = $this->get_data_from_products_array( wc_get_products( array() ) );
+ remove_filter( 'posts_clauses', $filter_callback );
+
+ $this->assertEqualsCanonicalizing( array( 'Product 1', 'Product 4', 'Product 5', 'Product 6' ), $received_products );
+ }
+
/**
* @testdox Non-string taxonomy filter values are ignored.
*/