Fix: broken contract in StoreAPI ProductCollectionData endpoint (#45247)

* revert: use original implementation

* fix: price query clause

* chore: changelog

* fix: passing full query state to get the correct count and avoid cache issue

* Replace references to woo-gutenberg-products-block

* fix php cs error

* fix php cs error

* Fix e2e tests for attributes count

* Separated out and/or query type tests for attrs count

---------

Co-authored-by: Alexandre Lara <allexandrelara@gmail.com>
Co-authored-by: roykho <roykho77@gmail.com>
This commit is contained in:
Tung Du 2024-04-06 01:50:19 +07:00 committed by GitHub
parent 6fb00c4297
commit 11ec7c6255
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 130 additions and 336 deletions

View File

@ -139,9 +139,7 @@ const AttributeFilterBlock = ( {
taxonomy: attributeObject?.taxonomy || '',
queryType: blockAttributes.queryType,
},
queryState: {
...queryState,
},
queryState,
isEditor,
} );

View File

@ -34,7 +34,7 @@ test.describe( 'Filter by Attributes Block - with All products Block', () => {
// Check if the page has loaded successfully.
await expect( page.getByText( 'Active Filters block' ) ).toBeVisible();
const expectedValues = [ '4', '0', '2', '2', '0' ];
const expectedValues = [ '4', '2', '3', '4', '1' ];
await expect(
page
@ -57,7 +57,7 @@ test.describe( 'Filter by Attributes Block - with All products Block', () => {
// Check if the page has loaded successfully.
await expect( page.getByText( 'Active Filters block' ) ).toBeVisible();
const expectedValues = [ '4', '3', '2', '2', '0' ];
const expectedValues = [ '4', '2', '3', '4', '1' ];
await expect(
page
@ -80,7 +80,7 @@ test.describe( 'Filter by Attributes Block - with All products Block', () => {
// Check if the page has loaded successfully.
await expect( page.getByText( 'Active Filters block' ) ).toBeVisible();
const expectedValues = [ '2', '0', '1', '1', '0' ];
const expectedValues = [ '2', '2', '2', '3', '1' ];
await expect(
page

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix the broken contract in the StoreAPI and the bug in the price clauses causing the incorrect filter counts.

View File

@ -100,12 +100,49 @@ class ProductCollectionData extends AbstractRoute {
}
if ( ! empty( $request['calculate_attribute_counts'] ) ) {
foreach ( $request['calculate_attribute_counts'] as $attributes_to_count ) {
if ( ! isset( $attributes_to_count['taxonomy'] ) ) {
continue;
}
$taxonomy__or_queries = [];
$taxonomy__and_queries = [];
$counts = $filters->get_attribute_counts( $request, $attributes_to_count['taxonomy'] );
foreach ( $request['calculate_attribute_counts'] as $attributes_to_count ) {
if ( ! empty( $attributes_to_count['taxonomy'] ) ) {
if ( empty( $attributes_to_count['query_type'] ) || 'or' === $attributes_to_count['query_type'] ) {
$taxonomy__or_queries[] = $attributes_to_count['taxonomy'];
} else {
$taxonomy__and_queries[] = $attributes_to_count['taxonomy'];
}
}
}
$data['attribute_counts'] = [];
// Or type queries need special handling because the attribute, if set, needs removing from the query first otherwise counts would not be correct.
if ( $taxonomy__or_queries ) {
foreach ( $taxonomy__or_queries as $taxonomy ) {
$filter_request = clone $request;
$filter_attributes = $filter_request->get_param( 'attributes' );
if ( ! empty( $filter_attributes ) ) {
$filter_attributes = array_filter(
$filter_attributes,
function ( $query ) use ( $taxonomy ) {
return $query['attribute'] !== $taxonomy;
}
);
}
$filter_request->set_param( 'attributes', $filter_attributes );
$counts = $filters->get_attribute_counts( $filter_request, [ $taxonomy ] );
foreach ( $counts as $key => $value ) {
$data['attribute_counts'][] = (object) [
'term' => $key,
'count' => $value,
];
}
}
}
if ( $taxonomy__and_queries ) {
$counts = $filters->get_attribute_counts( $request, $taxonomy__and_queries );
foreach ( $counts as $key => $value ) {
$data['attribute_counts'][] = (object) [

View File

@ -405,9 +405,9 @@ class ProductQuery {
$min_price_filter = $this->prepare_price_filter( $wp_query->get( 'min_price' ) );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'min_price', '>=' );
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'max_price', '>=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price >= %f ', $min_price_filter );
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price >= %f ', $min_price_filter );
}
}
@ -415,9 +415,9 @@ class ProductQuery {
$max_price_filter = $this->prepare_price_filter( $wp_query->get( 'max_price' ) );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'max_price', '<=' );
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'min_price', '<=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price <= %f ', $max_price_filter );
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price <= %f ', $max_price_filter );
}
}

View File

@ -2,9 +2,6 @@
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
use Exception;
use WP_REST_Request;
/**
* Product Query filters class.
@ -114,196 +111,63 @@ class ProductQueryFilters {
}
/**
* Get terms list for a given taxonomy.
* Get attribute counts for the current products.
*
* @param string $taxonomy Taxonomy name.
*
* @return array
* @param \WP_REST_Request $request The request object.
* @param array $attributes Attributes to count, either names or ids.
* @return array termId=>count pairs.
*/
public function get_terms_list( string $taxonomy ) {
public function get_attribute_counts( $request, $attributes = [] ) {
global $wpdb;
return $wpdb->get_results(
$wpdb->prepare(
"SELECT term_id as term_count_id,
count(DISTINCT product_or_parent_id) as term_count
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s
GROUP BY term_id",
$taxonomy
)
// Remove paging and sorting params from the request.
$request->set_param( 'page', null );
$request->set_param( 'per_page', null );
$request->set_param( 'order', null );
$request->set_param( 'orderby', null );
// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
$product_query = new ProductQuery();
add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
add_filter( 'posts_pre_query', '__return_empty_array' );
$query_args = $product_query->prepare_objects_query( $request );
$query_args['no_found_rows'] = true;
$query_args['posts_per_page'] = -1;
$query = new \WP_Query();
$result = $query->query( $query_args );
$product_query_sql = $query->request;
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
if ( count( $attributes ) === count( array_filter( $attributes, 'is_numeric' ) ) ) {
$attributes = array_map( 'wc_attribute_taxonomy_name_by_id', wp_parse_id_list( $attributes ) );
}
$attributes_to_count = array_map(
function ( $attribute ) {
$attribute = wc_sanitize_taxonomy_name( $attribute );
return esc_sql( $attribute );
},
$attributes
);
}
$attributes_to_count_sql = 'AND term_taxonomy.taxonomy IN ("' . implode( '","', $attributes_to_count ) . '")';
$attribute_count_sql = "
SELECT COUNT( DISTINCT posts.ID ) as term_count, terms.term_id as term_count_id
FROM {$wpdb->posts} AS posts
INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = 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 )
WHERE posts.ID IN ( {$product_query_sql} )
{$attributes_to_count_sql}
GROUP BY terms.term_id
";
/**
* Get the empty terms list for a given taxonomy.
*
* @param string $taxonomy Taxonomy name.
*
* @return array
*/
public function get_empty_terms_list( string $taxonomy ) {
global $wpdb;
$results = $wpdb->get_results( $attribute_count_sql ); // phpcs:ignore
return $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT term_id as term_count_id,
0 as term_count
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s",
$taxonomy
)
);
}
/**
* Get attribute and meta counts.
*
* @param WP_REST_Request $request Request data.
* @param string $filtered_attribute The attribute to count.
*
* @return array
*/
public function get_attribute_counts( $request, $filtered_attribute ) {
if ( is_array( $filtered_attribute ) ) {
wc_deprecated_argument( 'attributes', 'TBD', 'get_attribute_counts does not require an array of attributes as the second parameter anymore. Provide the filtered attribute as a string instead.' );
$filtered_attribute = ! empty( $filtered_attribute[0] ) ? $filtered_attribute[0] : '';
if ( empty( $filtered_attribute ) ) {
return array();
}
}
$attributes_data = $request->get_param( 'attributes' );
$calculate_attribute_counts = $request->get_param( 'calculate_attribute_counts' );
$min_price = $request->get_param( 'min_price' );
$max_price = $request->get_param( 'max_price' );
$rating = $request->get_param( 'rating' );
$stock_status = $request->get_param( 'stock_status' );
$transient_key = 'wc_get_attribute_and_meta_counts_' . md5(
wp_json_encode(
array(
'attributes_data' => $attributes_data,
'calculate_attribute_counts' => $calculate_attribute_counts,
'min_price' => $min_price,
'max_price' => $max_price,
'rating' => $rating,
'stock_status' => $stock_status,
'filtered_attribute' => $filtered_attribute,
)
)
);
$cached_results = get_transient( $transient_key );
if ( ! empty( $cached_results ) && defined( 'WP_DEBUG' ) && ! WP_DEBUG ) {
return $cached_results;
}
if ( empty( $attributes_data ) && empty( $min_price ) && empty( $max_price ) && empty( $rating ) && empty( $stock_status ) ) {
$counts = $this->get_terms_list( $filtered_attribute );
return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
}
$where_clause = '';
if ( ! empty( $min_price ) || ! empty( $max_price ) || ! empty( $rating ) || ! empty( $stock_status ) ) {
$product_metas = [
'min_price' => $min_price,
'max_price' => $max_price,
'average_rating' => $rating,
'stock_status' => $stock_status,
];
$filtered_products_by_metas = $this->get_product_by_metas( $product_metas );
$formatted_filtered_products_by_metas = implode( ',', array_map( 'intval', $filtered_products_by_metas ) );
if ( ! empty( $formatted_filtered_products_by_metas ) ) {
if ( ! empty( $rating ) ) {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_metas );
} else {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_id IN (%1s)', $formatted_filtered_products_by_metas );
}
} else {
$counts = $this->get_empty_terms_list( $filtered_attribute );
return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
}
}
$join_type = 'LEFT';
foreach ( $attributes_data as $attribute ) {
$filtered_terms = $attribute['slug'] ?? '';
if ( empty( $filtered_terms ) ) {
continue;
}
$taxonomy = $attribute['attribute'] ?? '';
$term_ids = [];
if ( in_array( $taxonomy, wc_get_attribute_taxonomy_names(), true ) ) {
foreach ( $filtered_terms as $filtered_term ) {
$term = get_term_by( 'slug', $filtered_term, $taxonomy );
if ( is_object( $term ) ) {
$term_ids[] = $term->term_id;
}
}
}
if ( empty( $term_ids ) ) {
continue;
}
foreach ( $calculate_attribute_counts as $calculate_attribute_count ) {
if ( ! isset( $calculate_attribute_count['taxonomy'] ) && ! isset( $calculate_attribute_count['query_type'] ) ) {
continue;
}
$query_type = $calculate_attribute_count['query_type'];
$filtered_products_by_terms = $this->get_product_by_filtered_terms( $calculate_attribute_count['taxonomy'], $term_ids, $query_type );
$formatted_filtered_products_by_terms = implode( ',', array_map( 'intval', $filtered_products_by_terms ) );
if ( ! empty( $formatted_filtered_products_by_terms ) ) {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_terms );
}
if ( $calculate_attribute_count['taxonomy'] === $filtered_attribute ) {
$join_type = 'or' === $query_type ? 'LEFT' : 'INNER';
}
}
}
global $wpdb;
$counts = $wpdb->get_results(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT attributes.term_id as term_count_id, coalesce(term_count, 0) as term_count
FROM (SELECT DISTINCT term_id
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s) as attributes %1s JOIN (
SELECT COUNT(DISTINCT product_attribute_lookup.product_or_parent_id) as term_count, product_attribute_lookup.term_id
FROM {$wpdb->prefix}wc_product_attributes_lookup product_attribute_lookup
INNER JOIN {$wpdb->posts} posts
ON posts.ID = product_attribute_lookup.product_id
WHERE posts.post_type IN ('product', 'product_variation') AND posts.post_status = 'publish'%1s
GROUP BY product_attribute_lookup.term_id
) summarize
ON attributes.term_id = summarize.term_id
",
$filtered_attribute,
$join_type,
$where_clause
)
);
// phpcs:enable
$results = array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
set_transient( $transient_key, $results, 24 * HOUR_IN_SECONDS );
return $results;
return array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
}
/**
@ -347,139 +211,4 @@ class ProductQueryFilters {
return array_map( 'absint', wp_list_pluck( $results, 'product_count', 'rounded_average_rating' ) );
}
/**
* Gets product by metas.
*
* @since TBD
* @param array $metas Array of metas to query.
* @return array $results
*/
public function get_product_by_metas( $metas = array() ) {
global $wpdb;
if ( empty( $metas ) ) {
return array();
}
$where = array();
$results = array();
$params = array();
foreach ( $metas as $column => $value ) {
if ( empty( $value ) ) {
continue;
}
if ( 'stock_status' === $column ) {
$stock_product_ids = array();
foreach ( $value as $stock_status ) {
$stock_product_ids[] = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE stock_status = %s",
$stock_status
)
);
}
$where[] = 'product_id IN (' . implode( ',', array_merge( ...$stock_product_ids ) ) . ')';
continue;
}
if ( 'min_price' === $column ) {
$where[] = "{$column} >= %d";
$params[] = intval( $value ) / 100;
continue;
}
if ( 'max_price' === $column ) {
$where[] = "{$column} <= %d";
$params[] = intval( $value ) / 100;
continue;
}
if ( 'average_rating' === $column ) {
$where_rating = array();
foreach ( $value as $rating ) {
$where_rating[] = sprintf( '(average_rating >= %f - 0.5 AND average_rating < %f + 0.5)', $rating, $rating );
}
$where[] = '(' . implode( ' OR ', $where_rating ) . ')';
continue;
}
$where[] = sprintf( "%1s = '%s'", $column, $value );
$params[] = $value;
}
if ( ! empty( $where ) ) {
$where_clause = implode( ' AND ', $where );
$where_clause = sprintf( $where_clause, ...$params );
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE %1s",
$where_clause
)
);
}
// phpcs:enable
return $results;
}
/**
* Gets product by filtered terms.
*
* @since TBD
* @param string $taxonomy Taxonomy name.
* @param array $term_ids Term IDs.
* @param string $query_type or | and.
* @return array Product IDs.
*/
public function get_product_by_filtered_terms( $taxonomy = '', $term_ids = array(), $query_type = 'or' ) {
global $wpdb;
$term_count = count( $term_ids );
$results = array();
$term_ids = implode( ',', array_map( 'intval', $term_ids ) );
if ( 'or' === $query_type ) {
$results = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN (%1s)
",
$taxonomy,
$term_ids
)
// phpcs:enable
);
}
if ( 'and' === $query_type ) {
$results = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN (%1s)
GROUP BY `product_or_parent_id`
HAVING COUNT( DISTINCT `term_id` ) >= %d
",
$taxonomy,
$term_ids,
$term_count
)
// phpcs:enable
);
}
return $results;
}
}

View File

@ -84,6 +84,7 @@ class ProductCollectionData extends ControllerTestCase {
);
$fixtures->get_taxonomy_and_term( $product, 'pa_size', 'large', 'large' );
// AND query type.
$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
$request->set_param(
'calculate_attribute_counts',
@ -95,6 +96,30 @@ class ProductCollectionData extends ControllerTestCase {
),
);
$response = rest_get_server()->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( null, $data['price_range'] );
$this->assertEquals( null, $data['rating_counts'] );
$this->assertIsArray( $data );
$this->assertTrue( property_exists( $data['attribute_counts'][0], 'term' ) );
$this->assertTrue( property_exists( $data['attribute_counts'][0], 'count' ) );
// OR query type.
$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/collection-data' );
$request->set_param(
'calculate_attribute_counts',
array(
array(
'taxonomy' => 'pa_size',
'query_type' => 'or',
),
),
);
$request->set_param(
'attributes',
array(
@ -114,6 +139,7 @@ class ProductCollectionData extends ControllerTestCase {
$this->assertEquals( null, $data['rating_counts'] );
$this->assertIsArray( $data );
$this->assertTrue( property_exists( $data['attribute_counts'][0], 'term' ) );
$this->assertTrue( property_exists( $data['attribute_counts'][0], 'count' ) );
}