Commit 3ef69407bed for woocommerce

commit 3ef69407beda276dd278088103752e16a0042948
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Oct 28 16:45:04 2026 +0200

    Speed up Store API stock status counts with a single query (#69320)

    * perf(store-api): Improve stock status collection counts

    Collection data runs a separate postmeta count query for each stock status, making the guest price and stock facet request expensive on large catalogs.

    Use one conditional distinct-count query over the same filtered product set when WordPress has no custom database query observer. Keep the existing per-status query sequence for extensions that observe queries or replace wpdb.

    * perf(store-api): Remove stock count observer fallback

    Core SQL observers and database subclasses may see optimized SQL.\nThe observer guard kept PHPUnit on one count query per stock status\nbecause its temporary-table callbacks also use the query filter.\n\nRun the existing conditional aggregate for every nonempty status set,\nwhile preserving the stock options filter and product query builder.\nCover observer execution, result parity, membership and empty options.

    * chore(changelog): Add stock count performance entry

    * test(store-api): Keep stock count tests to behavior checks

    The stock count tests mixed behavior checks with assertions about how
    the change was arrived at: a query-count test that duplicated the
    observer test with a weaker bound, SQL text matching on the statement
    shape, and a test that pinned the pre-existing double escaping of a
    quoted status key as expected output.

    Keep the tests that describe behavior a reviewer can rely on: one
    statement counts every status with a query observer attached, an empty
    status list issues no SQL, duplicate meta is counted once, and a
    filtered request keeps its product set. Assert that a quoted status key
    is handled without a database error instead of asserting its count, and
    add coverage for products on the three core statuses, which the fixture
    never exercised.

    * perf(store-api): Skip the product query when no stock statuses exist

    The empty-status return sat after the product selection had already
    been prepared and run, so a request with no configured stock statuses
    still did that work, and `related` requests still loaded products.

    Return before registering the query filters so an empty status list
    does no product or count SQL. The test now observes both the product
    selection and the count statement.

    * fix(store-api): Prepare stock status count values

    The combined stock count query interpolated escaped status keys into
    SQL, changing returned keys containing quotes or percent signs. A failed
    query also left the result mapping reading properties on null.

    Prepare the CASE expressions and IN values at their SQL boundaries,
    leaving the existing product-selection query intact. Return string zero
    counts when the query fails, matching the successful result type.

    Retain the empty-status guard because the public options filter can
    return no statuses. Remove the ineffective visibility unset on a numeric
    list; ProductQuery already applies the hide-out-of-stock setting.

    Cover matching custom statuses, literal percent searches, query failures
    and both visibility settings with regression tests.

    * chore(phpstan): Remove obsolete stock status baseline entry

    The stock count follow-up removed an ineffective out-of-stock unset,
    but left its PHPStan suppression behind. Full-project analysis rejects
    the unmatched baseline entry even though file-scoped analysis passes.

    Remove only that obsolete entry so the baseline reflects the fixed code.

    * test(store-api): Improve stock count assertions

    Whole-result comparisons make the stock count tests fail when unrelated
    statuses are added or their order changes.

    Assert each expected key and string count independently, retaining the
    single-query assertion. Use the existing stock-status constants in the
    fixtures and assertions to follow the test conventions.

    * test(store-api): Assert stock count key order

    The stock count query maps its numbered status columns back to keys
    by index, and the options filter decides the order of the returned
    statuses. The tests checked each key and count but never the order,
    and the core-status test gives four statuses the same count, so an
    index mix-up or a result built from the row instead of the options
    list would still pass.

    Compare the returned keys with the stock status options in order.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(store-api): Trim stock count tests to their behavior

    Several stock count tests checked more than their name promised. The
    one-statement test was named for a query observer that only mattered
    to a fallback removed earlier on this branch, and it repeated the core
    count assertions. The empty-list test asserted which queries ran, yet
    its empty result would also come back without the guard, since the
    loop over no statuses returns nothing either way. Two tests pinned the
    total number of statuses, so a new core status would break them.

    Keep only the statement count in the one-statement test. Assert that
    an empty status list causes no database error, which fails when the
    guard is removed. Compare result keys with the configured statuses
    instead of fixed totals. Drop the original custom count test, which
    the core status test now covers with strict types, the repeated
    custom-status sanity checks, and the hook cleanup the WordPress test
    case already performs after every test.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(store-api): Detach the failing query filter after its test

    The failed-count test attaches a query filter that rewrites any
    statement reading _stock_status into a broken one. The hook restore
    removes it, but only after WC_Unit_Test_Case clears the cart, notices,
    session and shipping state, so the filter stays live during that
    cleanup. Nothing there reads stock status today, but a future cleanup
    step that did would fail in a way unrelated to its own code.

    Remove the filter in the same finally block that restores the wpdb
    error suppression, so the test leaves no SQL rewriting behind.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(store-api): Explain the explicit query filter removal

    The other tests in this class rely on the hook restore, so a reader
    could take the explicit remove_filter() in the failed-count test for
    redundant cleanup and drop it. Say why it stays: the restore runs only
    after WC_Unit_Test_Case clears its singleton state, and this filter
    rewrites SQL that the cleanup could otherwise run through.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/perf-s-27-conditional-stock-counts b/plugins/woocommerce/changelog/perf-s-27-conditional-stock-counts
new file mode 100644
index 00000000000..f67281495b2
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-s-27-conditional-stock-counts
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Speed up product filtering by counting stock statuses together.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 434d6e46702..52309f92bdb 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -67548,12 +67548,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Utilities/ProductQuery.php

-		-
-			message: '#^Cannot unset offset ''outofstock'' on list\<string\>\.$#'
-			identifier: unset.offset
-			count: 1
-			path: src/StoreApi/Utilities/ProductQueryFilters.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\StoreApi\\Utilities\\ProductQueryFilters\:\:get_attribute_counts\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php
index 9cd6350fcb5..cc818baf72c 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQueryFilters.php
@@ -1,7 +1,6 @@
 <?php
 namespace Automattic\WooCommerce\StoreApi\Utilities;

-use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;

 /**
@@ -53,11 +52,10 @@ class ProductQueryFilters {
 	 */
 	public function get_stock_status_counts( $request ) {
 		global $wpdb;
-		$product_query         = new ProductQuery();
-		$stock_status_options  = array_map( 'esc_sql', array_keys( wc_get_product_stock_status_options() ) );
-		$hide_outofstock_items = get_option( 'woocommerce_hide_out_of_stock_items' );
-		if ( 'yes' === $hide_outofstock_items ) {
-			unset( $stock_status_options[ ProductStockStatus::OUT_OF_STOCK ] );
+		$product_query        = new ProductQuery();
+		$stock_status_options = array_keys( wc_get_product_stock_status_options() );
+		if ( empty( $stock_status_options ) ) {
+			return array();
 		}

 		add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
@@ -75,40 +73,31 @@ class ProductQueryFilters {
 		remove_filter( 'posts_pre_query', '__return_empty_array' );

 		$stock_status_counts = array();
-
-		foreach ( $stock_status_options as $status ) {
-			$stock_status_count_sql = $this->generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options );
-
-			$result = $wpdb->get_row( $stock_status_count_sql ); // phpcs:ignore
-			$stock_status_counts[ $status ] = $result->status_count;
+		$selects             = array();
+		$statuses            = array();
+		foreach ( $stock_status_options as $index => $status ) {
+			$selects[]  = $wpdb->prepare(
+				'COUNT( DISTINCT CASE WHEN postmeta.meta_value = %s THEN posts.ID END ) as status_%d',
+				$status,
+				$index
+			);
+			$statuses[] = $wpdb->prepare( '%s', $status );
 		}

-		return $stock_status_counts;
-	}
-
-	/**
-	 * Generate calculate query by stock status.
-	 *
-	 * @param string $status status to calculate.
-	 * @param string $product_query_sql product query for current filter state.
-	 * @param array  $stock_status_options available stock status options.
-	 *
-	 * @return false|string
-	 */
-	private function generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options ) {
-		if ( ! in_array( $status, $stock_status_options, true ) ) {
-			return false;
-		}
-		global $wpdb;
-		$status = esc_sql( $status );
-		return "
-			SELECT COUNT( DISTINCT posts.ID ) as status_count
+		$stock_status_count_sql = '
+			SELECT ' . implode( ', ', $selects ) . "
 			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 = '{$status}'
+				AND postmeta.meta_key = '_stock_status'
+				AND postmeta.meta_value IN ( " . implode( ', ', $statuses ) . " )
 			WHERE posts.ID IN ( {$product_query_sql} )
 		";
+		$result = $wpdb->get_row( $stock_status_count_sql ); // phpcs:ignore
+		foreach ( $stock_status_options as $index => $status ) {
+			$stock_status_counts[ $status ] = $result->{"status_{$index}"} ?? '0';
+		}
+
+		return $stock_status_counts;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryFiltersTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryFiltersTest.php
index e03a93fd8c5..c8c90aa89cf 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryFiltersTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/ProductQueryFiltersTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Utilities;

+use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\StoreApi\Utilities\ProductQueryFilters;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;

@@ -10,6 +11,12 @@ use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
  * Unit tests for the ProductQueryFilters class.
  */
 class ProductQueryFiltersTest extends \WC_Unit_Test_Case {
+	/**
+	 * Test products.
+	 *
+	 * @var \WC_Product[]
+	 */
+	private $products;
 	/**
 	 * Setup test product data. Called before every test.
 	 */
@@ -58,14 +65,188 @@ class ProductQueryFiltersTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Test that custom stock levels are returned properly with the correct counts.
+	 * @testdox Should count products on the core stock statuses.
 	 */
-	public function test_custom_stock_counts() {
-		$class  = new ProductQueryFilters();
-		$result = $class->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+	public function test_core_stock_status_counts(): void {
+		$fixtures = new FixtureData();
+		foreach ( array( ProductStockStatus::IN_STOCK, ProductStockStatus::OUT_OF_STOCK, ProductStockStatus::ON_BACKORDER ) as $status ) {
+			$fixtures->get_simple_product(
+				array(
+					'name'          => "Core {$status}",
+					'stock_status'  => $status,
+					'regular_price' => 10,
+				)
+			);
+		}
+
+		$result = ( new ProductQueryFilters() )->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+
+		$this->assertArrayHasKey( ProductStockStatus::IN_STOCK, $result );
+		$this->assertSame( '1', $result[ ProductStockStatus::IN_STOCK ] );
+		$this->assertArrayHasKey( ProductStockStatus::OUT_OF_STOCK, $result );
+		$this->assertSame( '1', $result[ ProductStockStatus::OUT_OF_STOCK ] );
+		$this->assertArrayHasKey( ProductStockStatus::ON_BACKORDER, $result );
+		$this->assertSame( '1', $result[ ProductStockStatus::ON_BACKORDER ] );
 		$this->assertArrayHasKey( 'custom1', $result );
+		$this->assertSame( '1', $result['custom1'] );
 		$this->assertArrayHasKey( 'custom2', $result );
-		$this->assertEquals( 1, $result['custom1'] );
-		$this->assertEquals( 2, $result['custom2'] );
+		$this->assertSame( '2', $result['custom2'] );
+		$this->assertSame( array_keys( wc_get_product_stock_status_options() ), array_keys( $result ), 'Counts should follow the stock status options order.' );
+	}
+
+	/**
+	 * @testdox Should count all stock statuses in one statement.
+	 */
+	public function test_stock_counts_in_one_statement(): void {
+		$queries = array();
+		add_filter(
+			'query',
+			static function ( $sql ) use ( &$queries ) {
+				if ( false !== strpos( $sql, "meta_key = '_stock_status'" ) ) {
+					$queries[] = $sql;
+				}
+				return $sql;
+			}
+		);
+
+		( new ProductQueryFilters() )->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+
+		$this->assertCount( 1, $queries, 'All stock statuses should be counted by one statement.' );
+	}
+
+	/**
+	 * @testdox Should return no counts without a database error when no stock statuses are configured.
+	 */
+	public function test_empty_stock_status_options(): void {
+		global $wpdb;
+		add_filter( 'woocommerce_product_stock_status_options', '__return_empty_array', 20 );
+
+		$result = ( new ProductQueryFilters() )->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+
+		$this->assertSame( array(), $result );
+		$this->assertSame( '', $wpdb->last_error, 'An empty status list must not run an invalid count statement.' );
+	}
+
+	/**
+	 * @testdox Should count a product once when stock metadata is duplicated.
+	 */
+	public function test_duplicate_stock_metadata(): void {
+		add_post_meta( $this->products[0]->get_id(), '_stock_status', 'custom1' );
+		$result = ( new ProductQueryFilters() )->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+
+		$this->assertSame( '1', $result['custom1'] );
+	}
+
+	/**
+	 * @testdox Should preserve product membership while removing the stock-status request constraint.
+	 */
+	public function test_filtered_stock_counts(): void {
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+		$request->set_param( 'include', array( $this->products[0]->get_id() ) );
+		$request->set_param( 'stock_status', array( 'custom2' ) );
+		$result = ( new ProductQueryFilters() )->get_stock_status_counts( $request );
+
+		$this->assertSame( '1', $result['custom1'] );
+		$this->assertSame( '0', $result['custom2'] );
+	}
+
+	/**
+	 * @testdox Should count products with custom status keys containing SQL-sensitive characters.
+	 * @testWith ["custom'quoted"]
+	 *           ["custom%s"]
+	 * @param string $status Custom stock status key.
+	 */
+	public function test_custom_stock_status_option( string $status ): void {
+		global $wpdb;
+
+		add_filter(
+			'woocommerce_product_stock_status_options',
+			static function ( $statuses ) use ( $status ) {
+				$statuses[ $status ] = 'Custom stock status';
+				return $statuses;
+			},
+			20
+		);
+		( new FixtureData() )->get_simple_product(
+			array(
+				'name'          => 'Custom stock product',
+				'stock_status'  => $status,
+				'regular_price' => 10,
+			)
+		);
+
+		$result = ( new ProductQueryFilters() )->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+
+		$this->assertSame( '', $wpdb->last_error, 'Custom status keys must not break the count statement.' );
+		$this->assertArrayHasKey( $status, $result );
+		$this->assertSame( '1', $result[ $status ] );
+		$this->assertSame( array_keys( wc_get_product_stock_status_options() ), array_keys( $result ), 'Every status from the filter should be present in the result.' );
+	}
+
+	/**
+	 * @testdox Should preserve literal percent signs in product searches when counting stock statuses.
+	 */
+	public function test_stock_counts_with_percent_in_search(): void {
+		$this->products[0]->set_name( 'Discount 50%s' );
+		$this->products[0]->save();
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+		$request->set_param( 'search', '50%s' );
+
+		$result = ( new ProductQueryFilters() )->get_stock_status_counts( $request );
+
+		$this->assertSame( '1', $result['custom1'] );
+		$this->assertSame( '0', $result['custom2'] );
+	}
+
+	/**
+	 * @testdox Should count only visible out-of-stock products while retaining their status key.
+	 * @testWith ["yes", "0"]
+	 *           ["no", "1"]
+	 * @param string $hide_out_of_stock Whether out-of-stock products are hidden.
+	 * @param string $expected_count Expected out-of-stock count.
+	 */
+	public function test_stock_counts_with_out_of_stock_visibility( string $hide_out_of_stock, string $expected_count ): void {
+		update_option( 'woocommerce_hide_out_of_stock_items', $hide_out_of_stock );
+		( new FixtureData() )->get_simple_product(
+			array(
+				'name'          => 'Out of stock product',
+				'stock_status'  => ProductStockStatus::OUT_OF_STOCK,
+				'regular_price' => 10,
+			)
+		);
+
+		$result = ( new ProductQueryFilters() )->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+
+		$this->assertArrayHasKey( ProductStockStatus::OUT_OF_STOCK, $result );
+		$this->assertSame( $expected_count, $result[ ProductStockStatus::OUT_OF_STOCK ] );
+	}
+
+	/**
+	 * @testdox Should return string zero counts without warnings when the count query fails.
+	 */
+	public function test_stock_counts_when_query_fails(): void {
+		global $wpdb;
+
+		$fail_count_query = static function ( $sql ) {
+			if ( false !== strpos( $sql, "meta_key = '_stock_status'" ) ) {
+				return 'SELECT missing_stock_status_column';
+			}
+			return $sql;
+		};
+		$suppress_errors  = $wpdb->suppress_errors( true );
+		add_filter( 'query', $fail_count_query );
+		try {
+			$result = ( new ProductQueryFilters() )->get_stock_status_counts( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+			$this->assertNotEmpty( $wpdb->last_error, 'The count query must fail to exercise the fallback.' );
+		} finally {
+			// The hook restore runs only after WC_Unit_Test_Case clears its singleton state, so detach this SQL rewrite now to keep it out of that cleanup.
+			remove_filter( 'query', $fail_count_query );
+			$wpdb->suppress_errors( $suppress_errors );
+		}
+
+		$this->assertCount( count( wc_get_product_stock_status_options() ), $result );
+		foreach ( $result as $count ) {
+			$this->assertSame( '0', $count );
+		}
 	}
 }