Commit ce28320d131 for woocommerce
commit ce28320d131ced6ee5dcadb3332c9c38ccf90a67
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date: Wed May 20 13:00:30 2026 +0200
[Performance] Audit and speedup SQLs in product filters. (#65141)
- Aligns SQLs on not joining the posts table (valid product IDs are supplied in the query)
- Leverages querying the wc_product_meta_lookup table where applicable.
- Collapse per stock status queries into a single query.
diff --git a/plugins/woocommerce/changelog/performance-product-filter-data-faster-sqls b/plugins/woocommerce/changelog/performance-product-filter-data-faster-sqls
new file mode 100644
index 00000000000..f88eec9e7d5
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-product-filter-data-faster-sqls
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Review and optimize the SQL queries used in product filters to improve performance.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0ad147e39d9..0daca2f8e17 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66366,12 +66366,6 @@ parameters:
count: 1
path: src/Internal/ProductFilters/FilterData.php
- -
- message: '#^Binary operation "\." between non\-falsy\-string and array\|string results in an error\.$#'
- identifier: binaryOp.invalid
- count: 1
- path: src/Internal/ProductFilters/FilterData.php
-
-
message: '#^Method Automattic\\WooCommerce\\Internal\\ProductFilters\\FilterData\:\:get_cache\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
index 433b341dd14..33294faf3d7 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
@@ -145,25 +145,29 @@ class FilterData {
if ( $product_ids ) {
global $wpdb;
- foreach ( $statuses as $status ) {
- $stock_status_count_sql = "
- SELECT COUNT( DISTINCT posts.ID ) as status_count
- FROM {$wpdb->posts} as posts
- INNER JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
- AND postmeta.meta_key = '_stock_status'
- AND postmeta.meta_value = '" . esc_sql( $status ) . "'
- WHERE posts.ID IN ( {$product_ids} )
+ if ( get_option( 'woocommerce_product_lookup_table_is_generating' ) ) {
+ // Optimization note: this serves as a fallback while wc_product_meta_lookup is being populated and is bypassed most of the time.
+ $sql = "
+ SELECT meta_value AS stock_status, COUNT( DISTINCT post_id ) AS status_count
+ FROM {$wpdb->postmeta}
+ WHERE post_id IN ( {$product_ids} ) AND meta_key = '_stock_status'
+ GROUP BY meta_value
";
+ } else {
+ // Optimization note: this is the main performance driver as the database processes fewer rows than when scanning the posts meta table.
+ $sql = "
+ SELECT stock_status, COUNT( DISTINCT product_id ) as status_count
+ FROM {$wpdb->wc_product_meta_lookup}
+ WHERE product_id IN ( {$product_ids} )
+ GROUP BY stock_status
+ ";
+ }
- /**
- * We can't use $wpdb->prepare() here because using %s with
- * $wpdb->prepare() for a subquery won't work as it will escape the
- * SQL query.
- * We're using the query as is, same as Core does.
- */
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
- $result = $wpdb->get_row( $stock_status_count_sql );
- $results[ $status ] = $result->status_count;
+ $results = array_fill_keys( $statuses, 0 );
+ foreach ( $wpdb->get_results( $sql ) as $row ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ if ( isset( $results[ $row->stock_status ] ) ) {
+ $results[ $row->stock_status ] = (int) $row->status_count;
+ }
}
}
@@ -266,14 +270,15 @@ class FilterData {
if ( $product_ids ) {
global $wpdb;
+ // Optimization note: We evaluated using wc_product_attributes_lookup but decided against it, as removing
+ // the posts table join in the query below produced better benchmarking results and required minimal changes.
$taxonomy_escaped = esc_sql( wc_sanitize_taxonomy_name( $attribute_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
+ SELECT COUNT( DISTINCT term_relationships.object_id ) as term_count, terms.term_id as term_count_id
+ FROM {$wpdb->term_relationships} AS term_relationships
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_ids} )
+ WHERE term_relationships.object_id IN ( {$product_ids} )
AND term_taxonomy.taxonomy = '{$taxonomy_escaped}'
GROUP BY terms.term_id
";
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
index 17ff2dfe4ad..5f59fd3a0c9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
@@ -1,5 +1,4 @@
-<?php
-declare(strict_types=1);
+<?php declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Internal\ProductFilters;
@@ -81,12 +80,23 @@ class FilterDataTest extends AbstractProductFiltersTest {
}
/**
- * @testdox Test stock counts without filter.
+ * @testdox Test stock counts without filter: via wc_product_meta_lookup table.
*/
- public function test_get_stock_status_counts_with_default_query() {
- $wp_query = new \WP_Query( array( 'post_type' => 'product' ) );
+ public function test_get_stock_status_counts_with_default_query_using_lookup_table() {
+ $this->test_get_stock_status_counts_with( new \WP_Query( array( 'post_type' => 'product' ) ) );
+ }
- $this->test_get_stock_status_counts_with( $wp_query );
+ /**
+ * @testdox Test stock counts without filter: via postmeta table.
+ */
+ public function test_get_stock_status_counts_with_default_query_using_postmeta_table() {
+ global $wpdb;
+ // Truncate the lookup table to confirm that the underlying query is targeting the correct postmeta table.
+ $wpdb->query( "TRUNCATE TABLE {$wpdb->wc_product_meta_lookup}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ update_option( 'woocommerce_product_lookup_table_is_generating', '1' );
+ $this->test_get_stock_status_counts_with( new \WP_Query( array( 'post_type' => 'product' ) ) );
+ delete_option( 'woocommerce_product_lookup_table_is_generating' );
}
/**
@@ -467,7 +477,8 @@ class FilterDataTest extends AbstractProductFiltersTest {
* @param callable $filter_callback Callback passed to filter test products.
*/
private function test_get_stock_status_counts_with( $wp_query, $filter_callback = null ) {
- $query_vars = array_filter( $wp_query->query_vars );
+ $query_vars = array_filter( $wp_query->query_vars );
+ $query_vars['counts-cache-bypass'] = microtime( true );
$actual_stock_status_counts = $this->sut->get_stock_status_counts( $query_vars, array( 'instock', 'outofstock', 'onbackorder' ) );
@@ -500,7 +511,8 @@ class FilterDataTest extends AbstractProductFiltersTest {
* @param callable $filter_callback Callback passed to filter test products.
*/
private function test_get_filtered_price_with( $wp_query, $filter_callback = null ) {
- $query_vars = array_filter( $wp_query->query_vars );
+ $query_vars = array_filter( $wp_query->query_vars );
+ $query_vars['counts-cache-bypass'] = microtime( true );
$prices = array();