Commit ab331b85986 for woocommerce
commit ab331b859860e8646078135a9614a29416540d73
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Oct 23 14:01:00 2026 +0300
Skip the HPOS order search count query when the first page holds every match (#69344)
* perf(orders): Skip the count query when a short first search page holds every match
An HPOS order search runs two statements: the list for the current
page and a COUNT(*) with the same WHERE for the total. The search WHERE
holds subqueries over the order items and address index tables that run
as full scans, so the count costs as much as the list. On a store with
1M orders it takes 4.4 to 4.8 s, roughly 38% of the request.
When page 1 comes back with fewer rows than the page size, the list has
already proven the total, so the count is skipped and the total is the
row count. The shortcut applies only to a search with no JOIN, when the
clauses, SQL and count SQL filters returned the query unchanged. Later
pages, offsets, full first pages, joined queries and any query altered
through the filters keep running the count exactly as before.
* Add changelog entry for the HPOS order search count change
* test(hpos): Cover search count behavior with full-text indexes
The first-page search optimization excludes joins, including those
introduced by full-text search. Its tests did not exercise that feature
state and inferred count execution from whichever SQL ran last.
Exercise both full-text search settings with real indexes and capture
executed SQL to assert the count directly. Preserve transaction isolation
by creating indexes before tests and removing only those created here.
Clarify that the JOIN exclusion is conservative; runtime eligibility
remains unchanged.
Refs #69344
* test(hpos): Simplify order count statement matching
Replace the SQL capture helper regex with case-insensitive string checks.
Keep the table boundary explicit so counts against related tables do not
match. This makes the assertion easier to read without changing the
production query scope.
* test(hpos): Set database version for full-text search fixtures
The search tests create the current address index, which includes phone.
When the database version option is absent or older than 9.4, search uses
an incompatible column list and fails before issuing the count query.
This caused the four nonempty full-text cases to fail across CI jobs.
Set the version to match the index fixture and assert that the query
succeeds, so an empty result cannot hide an invalid full-text query.
* fix(hpos): Preserve search totals after global SQL rewrites
A global query filter can shorten the list after eligibility checks,
causing a partial page to supply an incorrect total and page count.
Compare the executed SQL with the prepared query after removing WordPress
placeholder escapes. Retain counting for rewritten lists while preserving
inference for ordinary LIKE searches and unchanged query observers.
Cover reduced limits and shifted offsets with regression tests that
assert the returned IDs, total, pagination, and count execution.
* test(hpos): Pin the database version with a filter in the first-page total test
The address search adds the phone column to its MATCH() list only when
the stored woocommerce_db_version is 9.4.0 or later. The full-text index
that OrdersTableQueryTests creates always includes phone, so a lower
stored version builds a column list that matches no index.
Pin the version in memory with a pre_option filter instead of writing
the option, and explain in the comment why the pin is needed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/perf-m-25-first-page-search-total b/plugins/woocommerce/changelog/perf-m-25-first-page-search-total
new file mode 100644
index 00000000000..039fa28c77e
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-m-25-first-page-search-total
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Speed up order searches on stores using HPOS when every matching order fits on the first page of results.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
index 8ce3a1b86fb..5dc56fc04c3 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
@@ -178,6 +178,13 @@ class OrdersTableQuery {
*/
private $suppress_filters = false;
+ /**
+ * Whether a short first page of results can supply the total without running the count query.
+ *
+ * @var bool
+ */
+ private $can_infer_first_page_total = false;
+
/**
* Sets up and runs the query after processing arguments.
*
@@ -870,7 +877,8 @@ class OrdersTableQuery {
// GROUP BY.
$groupby = $this->groupby ? implode( ', ', (array) $this->groupby ) : '';
- $pieces = compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' );
+ $pieces = compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' );
+ $clauses = $pieces;
if ( ! $this->suppress_filters ) {
/**
@@ -943,6 +951,11 @@ class OrdersTableQuery {
$filtered_sql = $status_union_sql ?? $this->sql;
}
+ // A short first page of a plain search already holds every match, so the count query, which repeats the
+ // search's expensive WHERE, can be skipped. Conservatively keep the count for joined queries, including
+ // full-text searches, and for queries changed by filters.
+ $this->can_infer_first_page_total = ! empty( $this->args['s'] ) && '' === $join && $clauses === $pieces && $filtered_sql === $this->sql;
+
$this->sql = $filtered_sql;
$this->build_count_query( $fields, $join, $where, $groupby );
@@ -966,7 +979,8 @@ class OrdersTableQuery {
// DISTINCT adds performance overhead, exclude the DISTINCT function when confident it is not needed.
$count_fields = 'COUNT(*)';
}
- $this->count_sql = "SELECT $count_fields FROM $orders_table $join WHERE $where";
+ $this->count_sql = "SELECT $count_fields FROM $orders_table $join WHERE $where";
+ $unfiltered_count_sql = $this->count_sql;
if ( ! $this->suppress_filters ) {
/**
@@ -984,6 +998,8 @@ class OrdersTableQuery {
*/
$this->count_sql = apply_filters_ref_array( 'woocommerce_orders_table_query_count_sql', array( $this->count_sql, &$this, $this->args, $fields, $join, $where, $groupby ) );
}
+
+ $this->can_infer_first_page_total = $this->can_infer_first_page_total && $this->count_sql === $unfiltered_count_sql;
}
/**
@@ -1466,7 +1482,13 @@ class OrdersTableQuery {
$row_count = (int) ( $this->limits[1] ?? 0 );
if ( $row_count > 0 || $offset > 0 ) {
- $this->found_orders = absint( $wpdb->get_var( $this->count_sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ // Compare after WordPress's normal placeholder removal to detect SQL changed during execution.
+ $first_page_holds_all = $this->can_infer_first_page_total
+ && 0 === $offset
+ && count( $this->orders ) < $row_count
+ && $wpdb->last_query === $wpdb->remove_placeholder_escape( $this->sql );
+
+ $this->found_orders = $first_page_holds_all ? count( $this->orders ) : absint( $wpdb->get_var( $this->count_sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$this->max_num_pages = $row_count > 0
? (int) ceil( $this->found_orders / $row_count )
: 0;
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php
index e9bd85aad73..6bcb766eb8c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableQueryTests.php
@@ -5,7 +5,9 @@ namespace Automattic\WooCommerce\Tests\Internal\DataStores\Orders;
use Automattic\WooCommerce\Caches\OrderCountCache;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableQuery;
+use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -21,10 +23,48 @@ class OrdersTableQueryTests extends \WC_Unit_Test_Case {
use HPOSToggleTrait;
/**
- * Ensure permanent HPOS tables exist before per-test transactions start.
+ * Whether this class created the address full-text index.
+ *
+ * @var bool
+ */
+ private static $created_address_index = false;
+
+ /**
+ * Whether this class created the order item full-text index.
+ *
+ * @var bool
+ */
+ private static $created_item_index = false;
+
+ /**
+ * Create tables and full-text indexes before per-test transactions start.
*/
public static function wpSetUpBeforeClass(): void {
self::setup_cot_tables();
+
+ $db_util = wc_get_container()->get( DatabaseUtil::class );
+ self::$created_address_index = ! $db_util->fts_index_on_order_address_table_exists();
+ self::$created_item_index = ! $db_util->fts_index_on_order_item_table_exists();
+ if ( self::$created_address_index ) {
+ $db_util->create_fts_index_order_address_table();
+ }
+ if ( self::$created_item_index ) {
+ $db_util->create_fts_index_order_item_table();
+ }
+ }
+
+ /**
+ * Remove only the full-text indexes created by this class.
+ */
+ public static function wpTearDownAfterClass(): void {
+ global $wpdb;
+
+ if ( self::$created_address_index ) {
+ wc_get_container()->get( DatabaseUtil::class )->drop_fts_index_order_address_table();
+ }
+ if ( self::$created_item_index ) {
+ $wpdb->query( "ALTER TABLE {$wpdb->prefix}woocommerce_order_items DROP INDEX order_item_fts" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name uses the trusted WordPress prefix and a fixed suffix.
+ }
}
/**
@@ -479,6 +519,268 @@ class OrdersTableQueryTests extends \WC_Unit_Test_Case {
$this->assertCount( 0, $query->orders );
}
+ /**
+ * Run an order query and check how many order count statements it executes.
+ *
+ * @param array $args Query arguments.
+ * @param int $expected_count_statements Expected number of order count statements.
+ * @return OrdersTableQuery
+ */
+ private function query_and_assert_count_statements( array $args, int $expected_count_statements ): OrdersTableQuery {
+ global $wpdb;
+
+ $statements = array();
+ $capture = static function ( $sql ) use ( &$statements ) {
+ $statements[] = $sql;
+ return $sql;
+ };
+
+ add_filter( 'query', $capture );
+ try {
+ $query = new OrdersTableQuery( $args );
+ $this->assertSame( '', $wpdb->last_error, 'The order query should not produce a database error.' );
+ } finally {
+ remove_filter( 'query', $capture );
+ }
+
+ $count_statements = array_filter(
+ $statements,
+ static fn( $sql ) => 0 === stripos( $sql, 'SELECT COUNT(' ) && false !== stripos( $sql, " FROM {$wpdb->prefix}wc_orders " )
+ );
+ $this->assertCount( $expected_count_statements, $count_statements, 'Unexpected number of order count queries.' );
+
+ return $query;
+ }
+
+ /**
+ * @testdox First-page totals remain correct with full-text search enabled or disabled.
+ * @dataProvider provider_first_search_page_sizes
+ *
+ * @param int $matches Number of orders matching the search term.
+ * @param bool $count_expected Whether the count query must still run.
+ * @param bool $fts_enabled Whether full-text search is enabled.
+ */
+ public function test_first_search_page_supplies_total( int $matches, bool $count_expected, bool $fts_enabled ): void {
+ // The address search only adds the phone column to its MATCH() list when the stored database version is 9.4.0 or later.
+ // The full-text index this class creates always includes phone, so a lower stored version builds a column list that matches no index and fails.
+ // Pin the version in memory instead of writing the option.
+ add_filter( 'pre_option_woocommerce_db_version', static fn() => WC_VERSION );
+ add_filter( 'pre_option_' . CustomOrdersTableController::HPOS_FTS_INDEX_OPTION, static fn() => $fts_enabled ? 'yes' : 'no' );
+ add_filter( 'pre_option_' . CustomOrdersTableController::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION, static fn() => 'yes' );
+ add_filter( 'pre_option_' . CustomOrdersTableController::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION, static fn() => 'yes' );
+
+ $ids = array();
+ for ( $i = 0; $i < $matches; $i++ ) {
+ $order = new WC_Order();
+ $order->set_billing_email( 'first-page@woo.test' );
+ $order->save();
+ $ids[] = $order->get_id();
+ }
+
+ $query = $this->query_and_assert_count_statements(
+ array(
+ 's' => 'first-page@woo.test',
+ 'limit' => 5,
+ 'paginate' => true,
+ 'orderby' => 'ID',
+ 'order' => 'DESC',
+ 'return' => 'ids',
+ ),
+ $count_expected ? 1 : 0
+ );
+
+ $this->assertSame( array_slice( array_reverse( $ids ), 0, 5 ), $query->orders );
+ $this->assertSame( $matches, $query->found_orders );
+ $this->assertSame( (int) ceil( $matches / 5 ), $query->max_num_pages );
+ if ( $fts_enabled ) {
+ $this->assertStringContainsString( 'AS fts_addresses ON', $query->request );
+ $this->assertStringContainsString( 'AS fts_items ON', $query->request );
+ } else {
+ $this->assertStringNotContainsString( 'JOIN', $query->request );
+ }
+ }
+
+ /**
+ * Numbers of matching orders around the page size of 5, with both search modes.
+ *
+ * @return array<string, array{int, bool, bool}>
+ */
+ public static function provider_first_search_page_sizes(): array {
+ return array(
+ 'FTS off, no match' => array( 0, false, false ),
+ 'FTS off, one match' => array( 1, false, false ),
+ 'FTS off, almost full page' => array( 4, false, false ),
+ 'FTS off, full page' => array( 5, true, false ),
+ 'FTS off, more than a page' => array( 6, true, false ),
+ 'FTS on, no match' => array( 0, false, true ),
+ 'FTS on, one match' => array( 1, true, true ),
+ 'FTS on, almost full page' => array( 4, true, true ),
+ 'FTS on, full page' => array( 5, true, true ),
+ 'FTS on, more than a page' => array( 6, true, true ),
+ );
+ }
+
+ /**
+ * @testdox A global SQL filter that shortens the result page preserves the total and pagination.
+ * @testWith ["LIMIT 0, 2", [0, 1]]
+ * ["LIMIT 4, 5", [4, 5]]
+ *
+ * @param string $limits Replacement SQL limit and offset.
+ * @param int[] $expected_indexes Indexes of the orders expected on the page.
+ */
+ public function test_globally_rewritten_query_keeps_count( string $limits, array $expected_indexes ): void {
+ global $wpdb;
+
+ $ids = array();
+ for ( $i = 0; $i < 6; $i++ ) {
+ $order = new WC_Order();
+ $order->set_transaction_id( 'rewritten-search' );
+ $order->save();
+ $ids[] = $order->get_id();
+ }
+
+ $rewrite = static function ( $sql ) use ( $wpdb, $limits ) {
+ if ( 0 === strpos( $sql, "SELECT {$wpdb->prefix}wc_orders.id" ) && false !== strpos( $sql, 'rewritten-search' ) ) {
+ return str_replace( 'LIMIT 0, 5', $limits, $sql );
+ }
+ return $sql;
+ };
+ add_filter( 'query', $rewrite );
+ try {
+ $query = $this->query_and_assert_count_statements(
+ array(
+ 's' => 'rewritten-search',
+ 'search_filter' => 'transaction_id',
+ 'limit' => 5,
+ 'paginate' => true,
+ 'return' => 'ids',
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ ),
+ 1
+ );
+ } finally {
+ remove_filter( 'query', $rewrite );
+ }
+
+ $this->assertSame( array( $ids[ $expected_indexes[0] ], $ids[ $expected_indexes[1] ] ), $query->orders );
+ $this->assertSame( 6, $query->found_orders );
+ $this->assertSame( 2, $query->max_num_pages );
+ }
+
+ /**
+ * @testdox The count query still runs when the page cannot prove the total.
+ * @dataProvider provider_pages_that_cannot_prove_total
+ *
+ * @param array $args Query argument overrides.
+ * @param int $expected_orders Number of orders on the page.
+ */
+ public function test_count_query_runs_when_page_cannot_prove_total( array $args, int $expected_orders ): void {
+ for ( $i = 0; $i < 3; $i++ ) {
+ $order = new WC_Order();
+ $order->set_billing_email( 'keep-count@woo.test' );
+ $order->update_meta_data( '_keep_count', 'yes' );
+ $order->save();
+ }
+
+ $query = $this->query_and_assert_count_statements(
+ array_merge(
+ array(
+ 's' => 'keep-count@woo.test',
+ 'limit' => 2,
+ 'paginate' => true,
+ 'return' => 'ids',
+ ),
+ $args
+ ),
+ 1
+ );
+
+ $this->assertCount( $expected_orders, $query->orders );
+ $this->assertSame( 3, $query->found_orders );
+ }
+
+ /**
+ * Queries whose result page does not hold every match, or is not a plain search.
+ *
+ * @return array<string, array{array, int}>
+ */
+ public static function provider_pages_that_cannot_prove_total(): array {
+ return array(
+ 'second page' => array( array( 'page' => 2 ), 1 ),
+ 'offset' => array( array( 'offset' => 2 ), 1 ),
+ 'not a search' => array(
+ array(
+ 's' => '',
+ 'limit' => 5,
+ ),
+ 3,
+ ),
+ 'search that joins a table' => array(
+ array(
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ array(
+ 'key' => '_keep_count',
+ 'value' => 'yes',
+ ),
+ ),
+ 'limit' => 5,
+ ),
+ 3,
+ ),
+ );
+ }
+
+ /**
+ * @testdox A filter that changes the query keeps the count query.
+ * @dataProvider provider_query_changing_filters
+ *
+ * @param string $hook Filter name.
+ * @param callable $callback Callback that changes the filtered value.
+ */
+ public function test_changed_query_keeps_count( string $hook, callable $callback ): void {
+ $order = new WC_Order();
+ $order->set_billing_email( 'changed-query@woo.test' );
+ $order->save();
+
+ add_filter( $hook, $callback );
+ $query = $this->query_and_assert_count_statements(
+ array(
+ 's' => 'changed-query@woo.test',
+ 'limit' => 5,
+ 'paginate' => true,
+ 'return' => 'ids',
+ ),
+ 1
+ );
+ remove_filter( $hook, $callback );
+
+ $this->assertSame( array( $order->get_id() ), $query->orders );
+ $this->assertSame( 1, $query->found_orders );
+ }
+
+ /**
+ * WooCommerce query filters, each with a callback that makes a harmless change.
+ *
+ * @return array<string, array{string, callable}>
+ */
+ public static function provider_query_changing_filters(): array {
+ return array(
+ 'clauses' => array(
+ 'woocommerce_orders_table_query_clauses',
+ static fn( array $clauses ) => array_merge( $clauses, array( 'where' => $clauses['where'] . ' AND 1=1' ) ),
+ ),
+ 'list SQL' => array(
+ 'woocommerce_orders_table_query_sql',
+ static fn( string $sql ) => $sql . ' ',
+ ),
+ 'count SQL' => array(
+ 'woocommerce_orders_table_query_count_sql',
+ static fn( string $sql ) => $sql . ' ',
+ ),
+ );
+ }
+
/**
* Set up some dummy orders, to help test the search filter.
*