Fix counters in nav filtering widgets for variable products.

After the change that registers variation attributes as terms
(in addition to reigstering them as post meta) it is now time
to modify the get_filtered_term_product_counts methods in
WC_Widget_Layered_Nav so that it works consistently for both
variable and non-variable products. The logic for the counters
is now as follows:

with OR operator:
- Simple products: count the attributes of all visible products
  (unchanged behavior).
- Variable products: count attributes corresponding to
  visible variations.

with AND operator:
- Simple products: count the attributes of visible products but only
  for products that have all the selected (unchanged behavior).
- Variable products: find all the products for which all the variations
  corresponding to the selected attributes exist and are visible,
  then count the attributes corresponding to the visible variations
  of those products.

A product is "visible" if it's published, not excluded for catalog,
and has stock. Additionally, a variable product will not be considered
visible if the parent product is not.
This commit is contained in:
Nestor Soriano 2020-04-30 16:49:36 +02:00
parent 9c6c0d73d8
commit 9de1306c21
2 changed files with 572 additions and 21 deletions

View File

@ -344,49 +344,85 @@ class WC_Widget_Layered_Nav extends WC_Widget {
protected function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type ) {
global $wpdb;
$tax_query = WC_Query::get_main_tax_query();
$meta_query = WC_Query::get_main_meta_query();
$main_tax_query = $this->get_main_tax_query();
$meta_query = $this->get_main_meta_query();
if ( 'or' === $query_type ) {
foreach ( $tax_query as $key => $query ) {
if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) {
unset( $tax_query[ $key ] );
$variable_tax_query_sql = array( 'where' => '' );
$non_variable_tax_query_sql = array( 'where' => '' );
$is_and_query = 'and' === $query_type;
foreach ( $main_tax_query as $key => $query ) {
if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) {
if ( $is_and_query ) {
$non_variable_tax_query_sql = $this->convert_tax_query_to_sql( array( $query ) );
$variable_tax_query_sql = $this->get_extra_tax_query_sql( $taxonomy, $query['terms'], 'IN' );
$selected_terms_count = count( $query['terms'] );
}
unset( $main_tax_query[ $key ] );
}
}
$meta_query = new WP_Meta_Query( $meta_query );
$tax_query = new WP_Tax_Query( $tax_query );
$meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
$tax_query_sql = $tax_query->get_sql( $wpdb->posts, 'ID' );
$needs_extra_query_for_variable_products = $is_and_query && isset( $selected_terms_count );
$exclude_variable_products_tax_query_sql = $this->get_extra_tax_query_sql( 'product_type', array( 'variable' ), 'NOT IN' );
$meta_query_sql = ( new WP_Meta_Query( $meta_query ) )->get_sql( 'post', $wpdb->posts, 'ID' );
$main_tax_query_sql = $this->convert_tax_query_to_sql( $main_tax_query );
$term_ids_sql = '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
// Generate query.
$query = array();
$query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) as term_count, terms.term_id as term_count_id";
$query['from'] = "FROM {$wpdb->posts}";
$query['join'] = "
INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id
INNER JOIN {$wpdb->term_relationships} ON {$wpdb->posts}.ID = {$wpdb->term_relationships}.object_id
INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
INNER JOIN {$wpdb->terms} AS terms USING( term_id )
" . $tax_query_sql['join'] . $meta_query_sql['join'];
{$main_tax_query_sql['join']} {$meta_query_sql['join']}"; // Not an omission, really no more JOINs required.
$variable_where_part = "
{$wpdb->posts}.post_type = 'product_variation'
AND NOT EXISTS (
SELECT ID FROM {$wpdb->posts} AS parent
WHERE parent.ID = {$wpdb->posts}.post_parent AND parent.post_status NOT IN ('publish')
)
{$variable_tax_query_sql['where']}
";
$variable_where_part_for_main_query = $needs_extra_query_for_variable_products ? '' : "OR ($variable_where_part)";
$search_sql = '';
$search = $this->get_main_search_query_sql();
if ( $search ) {
$search_sql = ' AND ' . $search;
}
$query['where'] = "
WHERE {$wpdb->posts}.post_type IN ( 'product' )
AND {$wpdb->posts}.post_status = 'publish'"
. $tax_query_sql['where'] . $meta_query_sql['where'] .
'AND terms.term_id IN (' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
WHERE
{$wpdb->posts}.post_status = 'publish'
{$main_tax_query_sql['where']} {$meta_query_sql['where']}
AND (
(
{$wpdb->posts}.post_type = 'product'
{$exclude_variable_products_tax_query_sql['where']}
{$non_variable_tax_query_sql['where']}
)
{$variable_where_part_for_main_query}
)
AND terms.term_id IN {$term_ids_sql}
{$search_sql}";
$search = WC_Query::get_main_search_query_sql();
$search = $this->get_main_search_query_sql();
if ( $search ) {
$query['where'] .= ' AND ' . $search;
}
$query['group_by'] = 'GROUP BY terms.term_id';
$query = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query );
$query = implode( ' ', $query );
$query_sql = implode( ' ', $query );
// We have a query - let's see if cached results of this query already exist.
$query_hash = md5( $query );
$query_hash = md5( $query_sql );
// Maybe store a transient of the count values.
$cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true );
@ -397,17 +433,119 @@ class WC_Widget_Layered_Nav extends WC_Widget {
}
if ( ! isset( $cached_counts[ $query_hash ] ) ) {
$results = $wpdb->get_results( $query, ARRAY_A ); // @codingStandardsIgnoreLine
$counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$results = $wpdb->get_results( $query_sql, ARRAY_A );
$counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
if ( $needs_extra_query_for_variable_products ) {
/**
* This query:
* 1. Finds out how many variable products have variations corresponding to all the attributes
* supplied in the filtering request (all the variations must be visible).
* 2. Then, it returns the term_taxonomy_id of all the visible variations for all the products
* found in the previous step.
*
* Each term_taxonomy_id is returned as many times as it's found, this is required for the proper
* generation of the counts.
*/
$variable_count_query_sql = "
SELECT term_taxonomy_id FROM {$wpdb->term_relationships}
INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$wpdb->term_relationships}.object_id
where {$wpdb->posts}.post_parent in (
SELECT {$wpdb->posts}.post_parent FROM {$wpdb->posts}
{$query['join']}
WHERE
{$wpdb->posts}.post_status = 'publish'
{$main_tax_query_sql['where']} {$meta_query_sql['where']}
AND (
{$variable_where_part}
)
{$variable_tax_query_sql['where']}
GROUP BY post_parent HAVING COUNT(1)>={$selected_terms_count}
)
AND term_taxonomy_id IN {$term_ids_sql}
{$main_tax_query_sql['where']}
";
$term_ids_result = $wpdb->get_results( $variable_count_query_sql, ARRAY_N );
foreach ( $term_ids_result as $result ) {
$term_id = $result[0];
$counts[ $term_id ] = array_key_exists( $term_id, $counts ) ? $counts[ $term_id ] + 1 : 1;
}
}
$cached_counts[ $query_hash ] = $counts;
if ( true === $cache ) {
set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, DAY_IN_SECONDS );
}
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
return array_map( 'absint', (array) $cached_counts[ $query_hash ] );
}
/**
* Wrapper for WC_Query::get_main_tax_query() to ease unit testing.
*
* @return array
*/
protected function get_main_tax_query() {
return WC_Query::get_main_tax_query();
}
/**
* Wrapper for WC_Query::get_main_search_query_sql() to ease unit testing.
*
* @return string
*/
protected function get_main_search_query_sql() {
return WC_Query::get_main_search_query_sql();
}
/**
* Wrapper for WC_Query::get_main_search_queryget_main_meta_query to ease unit testing.
*
* @return array
*/
protected function get_main_meta_query() {
return WC_Query::get_main_meta_query();
}
/**
* Get a tax query SQL for a given set of taxonomy, terms and operator.
* Uses an intermediate WP_Tax_Query object.
*
* @param string $taxonomy Taxonomy name.
* @param array $terms Terms to include in the query.
* @param string $operator Query operator, as supported by WP_Tax_Query; e.g. "NOT IN".
*
* @return array
*/
private function get_extra_tax_query_sql( $taxonomy, $terms, $operator ) {
$query = array(
array(
'taxonomy' => $taxonomy,
'field' => 'slug',
'terms' => $terms,
'operator' => $operator,
'include_children' => false,
),
);
return $this->convert_tax_query_to_sql( $query );
}
/**
* Convert a tax query array to SQL using an intermediate WP_Tax_Query object.
*
* @param array $query Query array in the same format accepted by WP_Tax_Query constructor.
*
* @return array Query SQL as returned by WP_Tax_Query->get_sql.
*/
private function convert_tax_query_to_sql( $query ) {
global $wpdb;
return ( new WP_Tax_Query( $query ) )->get_sql( $wpdb->posts, 'ID' );
}
/**
* Show list based layered nav.
*

View File

@ -0,0 +1,413 @@
<?php
/**
* Testing WC_Widget_Layered_Nav functionality.
*
* @package WooCommerce/Tests/Widgets
*/
/**
* Class for testing WC_Widget_Layered_Nav functionality.
*/
class WC_Tests_Widget_Layered_Nav extends WC_Unit_Test_Case {
/**
* Get an instance of the tested widget, and simulate filtering in the incoming request.
*
* @param string $filter_operation Operation supplied in the filter, 'or' or 'and'.
* @param array $filter_colors Slugs of the colors supplied in the filters.
*
* @return WC_Widget_Layered_Nav An instance of WC_Widget_Layered_Nav ready to test.
*/
private function get_widget( $filter_operation, $filter_colors = array() ) {
$tax_query = array(
'relation' => 'and',
0 => array(
'taxonomy' => 'product_visibility',
'terms' => array(
get_term_by( 'slug', 'outofstock', 'product_visibility' )->term_taxonomy_id,
get_term_by( 'slug', 'exclude-from-catalog', 'product_visibility' )->term_taxonomy_id,
),
'field' => 'term_taxonomy_id',
'operator' => 'NOT IN',
),
);
if ( ! empty( $filter_colors ) ) {
array_push(
$tax_query,
array(
'taxonomy' => 'pa_color',
'terms' => $filter_colors,
'field' => 'slug',
'operator' => $filter_operation,
)
);
}
$sut = $this
->getMockBuilder( WC_Widget_Layered_Nav::class )
->setMethods( array( 'get_main_tax_query', 'get_main_meta_query', 'get_main_search_query_sql' ) )
->getMock();
$sut->method( 'get_main_tax_query' )->willReturn( $tax_query );
$sut->method( 'get_main_meta_query' )->willReturn( array() );
$sut->method( 'get_main_search_query_sql' )->willReturn( null );
return $sut;
}
/**
* Create a simple or variable product that has color attributes.
* If a variable product is created, a variation will be created for each color.
*
* @param string $name Name of the product.
* @param array $colors_in_stock Slugs of the colors whose variations will have stock. If null, a simple product is created.
* @param array $colors_disabled Slugs of the colors whose variations will be disabled, N/A for a simple product.
*
* @return WC_Product_Simple|WC_Product_Variable The created product.
*/
private function create_colored_product( $name, $colors_in_stock, $colors_disabled = array() ) {
$create_as_simple = is_null( $colors_in_stock );
$main_product = $create_as_simple ? new WC_Product_Simple() : new WC_Product_Variable();
$main_product->set_props(
array(
'name' => $name,
'sku' => 'SKU for' . $name,
)
);
$existing_colors = array( 'black', 'brown', 'blue', 'green', 'pink', 'yellow' );
$attributes = array( WC_Helper_Product::create_product_attribute_object( 'color', $existing_colors ) );
$main_product->set_attributes( $attributes );
$main_product->save();
if ( $create_as_simple ) {
return $main_product;
}
$variation_objects = array();
foreach ( $existing_colors as $color ) {
$variation_object = WC_Helper_Product::create_product_variation_object(
$main_product->get_id(),
"SKU for $color $name",
10,
array( 'pa_color' => $color )
);
if ( ! in_array( $color, $colors_in_stock, true ) ) {
$variation_object->set_stock_status( 'outofstock' );
}
$variation_object->save();
if ( in_array( $color, $colors_disabled, true ) ) {
wp_update_post(
array(
'ID' => $variation_object->get_id(),
'post_status' => 'draft',
)
);
}
array_push( $variation_objects, $variation_object->get_id() );
}
$main_product->set_children( $variation_objects );
return $main_product;
}
/**
* Invoke a protected method in an object.
*
* @param object $object Object whose method will be invoked.
* @param string $method Name of the method to invoke.
* @param array $args Arguments for the method.
*
* @return mixed Result from the method invocation.
* @throws ReflectionException Error when dealing with reflection.
*/
private function invoke_protected( $object, $method, $args ) {
$class = new ReflectionClass( $object );
$method = $class->getMethod( $method );
$method->setAccessible( true );
return $method->invokeArgs( $object, $args );
}
/**
* Invokes the get_filtered_term_product_counts method on an instance the widget,
* for a given filtering request, and returns the resulting counts.
*
* @param string $operator Operator in the filtering request.
* @param array $colors Slugs of the colors included in the filtering request.
*
* @return array An associative array where the keys are the color slugs and the values are the counts for each color.
* @throws ReflectionException Error when dealing with reflection to invoke the method.
*/
private function run_get_filtered_term_product_counts( $operator, $colors ) {
$sut = $this->get_widget( $operator, $colors );
$color_terms = get_terms( 'pa_color', array( 'hide_empty' => '1' ) );
$color_term_ids = wp_list_pluck( $color_terms, 'term_id' );
$color_term_names = wp_list_pluck( $color_terms, 'slug' );
$color_names_by_id = array_combine( $color_term_ids, $color_term_names );
$counts = $this->invoke_protected(
$sut,
'get_filtered_term_product_counts',
array(
$color_term_ids,
'pa_color',
$operator,
)
);
$counts_by_name = array();
foreach ( $counts as $id => $count ) {
$counts_by_name[ $color_names_by_id[ $id ] ] = $count;
}
return $counts_by_name;
}
/**
* Changes the status of a post to 'draft'.
*
* @param int $post_id Id of the post to change.
*/
private function set_post_as_draft( $post_id ) {
global $wpdb;
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'update ' . $wpdb->posts . " set post_status='draft' where ID=" . $post_id );
}
/**
* Data provider for test_product_count_per_attribute.
*
* @return array[]
*/
public function data_provider_for_test_product_count_per_attribute() {
return array(
// OR filtering, no attributes selected.
// Should count all the visible variations of all the products.
array(
'or',
array(),
false,
array(
'black' => 1,
'brown' => 2,
'blue' => 2,
'green' => 2,
'pink' => 1,
'yellow' => 1,
),
),
// OR filtering, some attributes selected
// (doesn't matter, the result is the same as in the previous case).
array(
'or',
array( 'black', 'green' ),
false,
array(
'black' => 1,
'brown' => 2,
'blue' => 2,
'green' => 2,
'pink' => 1,
'yellow' => 1,
),
),
// OR filtering, no attributes selected. Simple product is created too.
// Now it should include all the attributes of the simple product too.
array(
'or',
array(),
true,
array(
'black' => 2,
'brown' => 3,
'blue' => 3,
'green' => 3,
'pink' => 2,
'yellow' => 2,
),
),
// OR filtering, some attributes selected, Simple product is created too.
// Again, the attributes selected don't change the result.
array(
'or',
array( 'black', 'green' ),
true,
array(
'black' => 2,
'brown' => 3,
'blue' => 3,
'green' => 3,
'pink' => 2,
'yellow' => 2,
),
),
// AND filtering, no attributes selected.
// Should count all the visible variations of all the products as in the 'or' case.
array(
'and',
array(),
false,
array(
'black' => 1,
'brown' => 2,
'blue' => 2,
'green' => 2,
'pink' => 1,
'yellow' => 1,
),
),
// AND filtering, one attribute selected.
// Should count the visible variations for all products that have the variation for
// the selected attribute visible.
// E.g. 2 products have 'green' visible, and of those, one has also 'blue'
// and other has also 'pink' and 'yellow'.
array(
'and',
array( 'green' ),
false,
array(
'green' => 2,
'blue' => 1,
'pink' => 1,
'yellow' => 1,
),
),
// AND filtering, more than one attribute selected.
// Same as the previous one, but the products must have the variations for all selected attributes
// visible.
// E.g. only one product has both 'green' and 'pink' visible, and it has also 'yellow' visible.
array(
'and',
array( 'green', 'pink' ),
false,
array(
'green' => 1,
'pink' => 1,
'yellow' => 1,
),
),
// AND filtering, no attributes selected, include simple product too.
// Same case as 'or': it should include all the attributes of the simple product too.
array(
'and',
array(),
true,
array(
'black' => 2,
'brown' => 3,
'blue' => 3,
'green' => 3,
'pink' => 2,
'yellow' => 2,
),
),
// AND filtering, select one attribute, include simple product too.
// The simple product is now included in all counters, since it has the selected attribute.
array(
'and',
array( 'green' ),
true,
array(
'black' => 1,
'brown' => 1,
'blue' => 2,
'green' => 3,
'pink' => 2,
'yellow' => 2,
),
),
// AND filtering, select a couple of attributes, include simple product too.
// The simple product is included too in all counter, since it has all of the selected attributes.
array(
'and',
array( 'green', 'pink' ),
true,
array(
'black' => 1,
'brown' => 1,
'blue' => 1,
'green' => 2,
'pink' => 2,
'yellow' => 2,
),
),
);
}
/**
* @testdox Test that the counters are correct for different filtering combinations, see the data provider method for details.
*
* @dataProvider data_provider_for_test_product_count_per_attribute
*
* @param string $filter_operator Filtering operator to use, 'or' or 'and'.
* @param array $filter_terms Slugs of the colors selected for filtering.
* @param bool $create_simple_product_too If true, create one simple product too. If false, create only the variable products.
* @param array $expected_counts An associative array where the keys are the color slugs and the values are the counts for each color.
*/
public function test_product_count_per_attribute( $filter_operator, $filter_terms, $create_simple_product_too, $expected_counts ) {
if ( $create_simple_product_too ) {
$this->create_colored_product( 'Something with many colors', null );
}
$this->create_colored_product( 'Big shoes', array( 'black', 'brown' ) );
$this->create_colored_product( 'Medium shoes', array( 'blue', 'brown' ) );
$this->create_colored_product( 'Small shoes', array( 'blue', 'green' ) );
$this->create_colored_product( 'Kids shoes', array( 'green', 'pink', 'yellow' ) );
$counts = $this->run_get_filtered_term_product_counts( $filter_operator, $filter_terms );
$this->assertEquals( $expected_counts, $counts );
}
/**
* @testdox When a variable product is not published, none of its variations should be included in the counts.
*
* @throws ReflectionException Error when dealing with reflection to invoke the tested method.
*/
public function test_product_count_per_attribute_with_parent_not_published() {
$this->create_colored_product( 'Big shoes', array( 'black', 'brown' ) );
$medium = $this->create_colored_product( 'Medium shoes', array( 'blue', 'brown' ) );
$this->set_post_as_draft( $medium->get_id() );
$actual = $this->run_get_filtered_term_product_counts( 'or', array() );
$expected = array(
'black' => 1,
'brown' => 1,
);
$this->assertEquals( $expected, $actual );
}
/**
* @testdox When a variation is not published it should not be included in the counts (but other variations of the same product should).
*
* @throws ReflectionException Error when dealing with reflection to invoke the tested method.
*/
public function test_product_count_per_attribute_with_variation_not_published() {
$this->create_colored_product( 'Big shoes', array( 'black', 'brown' ) );
$this->create_colored_product( 'Medium shoes', array( 'blue', 'brown' ), array( 'brown' ) );
$actual = $this->run_get_filtered_term_product_counts( 'or', array() );
$expected = array(
'black' => 1,
'brown' => 1,
'blue' => 1,
);
$this->assertEquals( $expected, $actual );
}
}