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();