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 );
+ }
}
}