Commit e8732426f5b for woocommerce
commit e8732426f5bf8ab7679437dc0a1162ec363d868e
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Fri Oct 23 18:25:18 2026 +0300
Filter the orders list by shipping provider with a subquery instead of a materialized ID list (#69938)
* [WOOPLUG-6444] Filter orders by shipping provider with a subquery instead of a materialized ID list
* Add changefile(s) from automation for the following project(s): woocommerce
* [WOOPLUG-6444] Fall back to an id list when the orders query skips the clause filter
* [WOOPLUG-6444] Update the new docblock version tags to 11.4.0
* [WOOPLUG-6444] Drop the redundant unset in the orders clause filter
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/69938-wooplug-6444-shipping-provider-filter b/plugins/woocommerce/changelog/69938-wooplug-6444-shipping-provider-filter
new file mode 100644
index 00000000000..1e1189c8600
--- /dev/null
+++ b/plugins/woocommerce/changelog/69938-wooplug-6444-shipping-provider-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Improve the orders list performance when filtering by shipping provider on stores with many orders.
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
index e014468bd71..c72ebd50b98 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
@@ -16,6 +17,13 @@ use WC_Order;
*/
class FulfillmentsRenderer {
+ /**
+ * Query argument that carries the selected shipping provider through to the clause filters.
+ *
+ * @var string
+ */
+ private const SHIPPING_PROVIDER_FILTER_ARG = 'fulfillment_shipping_provider';
+
/**
* Fulfillments cache, that holds the fulfillments for each order to eliminate
* fetching fulfillment records of an order on each column render.
@@ -67,6 +75,7 @@ class FulfillmentsRenderer {
add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'render_shipping_provider_filter' ) );
add_filter( 'woocommerce_order_query_args', array( $this, 'filter_orders_list_table_query' ), 10, 1 );
add_filter( 'woocommerce_order_list_table_prepare_items_query_args', array( $this, 'filter_orders_by_shipping_provider' ), 10, 1 );
+ add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'filter_orders_table_clauses_by_shipping_provider' ), 10, 3 );
} else {
// For legacy orders table, we need to add the bulk actions to the legacy orders table.
add_filter( 'bulk_actions-edit-shop_order', array( $this, 'define_fulfillment_bulk_actions' ) );
@@ -76,6 +85,7 @@ class FulfillmentsRenderer {
add_action( 'restrict_manage_posts', array( $this, 'render_shipping_provider_filter_legacy' ) );
add_action( 'pre_get_posts', array( $this, 'filter_legacy_orders_list_query' ) );
add_action( 'pre_get_posts', array( $this, 'filter_legacy_orders_by_shipping_provider' ) );
+ add_filter( 'posts_clauses', array( $this, 'filter_legacy_orders_clauses_by_shipping_provider' ), 10, 2 );
}
}
@@ -606,24 +616,58 @@ class FulfillmentsRenderer {
// phpcs:ignore WordPress.Security.NonceVerification
$shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
- $order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
-
- if ( empty( $order_ids ) ) {
- $args['post__in'] = array( 0 );
- } elseif ( isset( $args['post__in'] ) && is_array( $args['post__in'] ) ) {
- $args['post__in'] = array_intersect( $args['post__in'], $order_ids );
- if ( empty( $args['post__in'] ) ) {
- $args['post__in'] = array( 0 );
- }
- } else {
- $args['post__in'] = $order_ids;
+
+ // A `woocommerce_hpos_pre_query` callback may answer the query from its own source, and
+ // `suppress_filters` skips the clause filter, so fall back to an id list the query carries.
+ if ( has_filter( 'woocommerce_hpos_pre_query' ) || ! empty( $args['suppress_filters'] ) ) {
+ // `p` and `post__in` are both remapped onto `id`, so narrow every restriction the
+ // query already carries rather than overwriting any of them.
+ $args['post__in'] = $this->restrict_ids_to_shipping_provider(
+ array( $args['post__in'] ?? null, $args['id'] ?? null, $args['p'] ?? null ),
+ $shipping_provider
+ );
+
+ return $args;
}
+ // Carry the provider through as a marker; the subquery is injected later in the clause filter
+ // so high-volume stores never materialize every matching order id into PHP.
+ $args[ self::SHIPPING_PROVIDER_FILTER_ARG ] = $shipping_provider;
+
return $args;
}
/**
- * Filter legacy orders by shipping provider.
+ * Inject the shipping provider filter into the HPOS orders query clauses.
+ *
+ * Restricts the orders to those with a matching fulfillment via an `IN ( subquery )`
+ * condition, instead of a materialized `post__in` id list.
+ *
+ * @since 11.4.0
+ *
+ * @param array $clauses The query clauses (fields, join, where, etc).
+ * @param object $query The OrdersTableQuery instance.
+ * @param array $args The query args.
+ * @return array The modified clauses.
+ */
+ public function filter_orders_table_clauses_by_shipping_provider( $clauses, $query, $args ): array {
+ $shipping_provider = $args[ self::SHIPPING_PROVIDER_FILTER_ARG ] ?? '';
+ if ( ! is_string( $shipping_provider ) || '' === $shipping_provider ) {
+ return $clauses;
+ }
+
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+ $subquery = $this->get_shipping_provider_order_ids_subquery( $shipping_provider );
+
+ // The table name comes from the data store and the subquery is already prepared.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $clauses['where'] = ( $clauses['where'] ?? '' ) . " AND {$orders_table}.id IN ( {$subquery} )";
+
+ return $clauses;
+ }
+
+ /**
+ * Mark the legacy orders query with the selected shipping provider.
*
* @since 10.7.0
*
@@ -645,31 +689,88 @@ class FulfillmentsRenderer {
// phpcs:ignore WordPress.Security.NonceVerification
$shipping_provider = sanitize_text_field( wp_unslash( $_GET['shipping_provider'] ) );
- $order_ids = $this->get_order_ids_by_shipping_provider( $shipping_provider );
- if ( empty( $order_ids ) ) {
- $query->set( 'post__in', array( 0 ) );
- } else {
- $existing = $query->get( 'post__in' );
- if ( ! empty( $existing ) && is_array( $existing ) ) {
- $order_ids = array_intersect( $existing, $order_ids );
- if ( empty( $order_ids ) ) {
- $order_ids = array( 0 );
- }
+ // A `posts_pre_query` callback may answer the query from its own source, and `suppress_filters`
+ // skips the clause filter, so fall back to an id list the query carries.
+ if ( has_filter( 'posts_pre_query' ) || $query->get( 'suppress_filters' ) ) {
+ $query->set(
+ 'post__in',
+ $this->restrict_ids_to_shipping_provider( array( $query->get( 'post__in' ) ), $shipping_provider )
+ );
+
+ return;
+ }
+
+ $query->set( self::SHIPPING_PROVIDER_FILTER_ARG, $shipping_provider );
+ }
+
+ /**
+ * Inject the shipping provider filter into the legacy orders query clauses.
+ *
+ * @since 11.4.0
+ *
+ * @param array $clauses The query clauses (join, where, etc).
+ * @param \WP_Query $query The WP_Query being filtered.
+ * @return array The modified clauses.
+ */
+ public function filter_legacy_orders_clauses_by_shipping_provider( $clauses, $query ): array {
+ $shipping_provider = $query->get( self::SHIPPING_PROVIDER_FILTER_ARG );
+ if ( ! is_string( $shipping_provider ) || '' === $shipping_provider ) {
+ return $clauses;
+ }
+
+ global $wpdb;
+ $subquery = $this->get_shipping_provider_order_ids_subquery( $shipping_provider );
+
+ // The subquery is already prepared and the table name comes from $wpdb.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $clauses['where'] = ( $clauses['where'] ?? '' ) . " AND {$wpdb->posts}.ID IN ( {$subquery} )";
+
+ return $clauses;
+ }
+
+ /**
+ * Narrow a set of order ids to those whose fulfillments use a shipping provider.
+ *
+ * Only used when the orders query can be answered without the SQL the clause filters build.
+ *
+ * @since 11.4.0
+ *
+ * @param array $existing_restrictions The id restrictions the query already carries, if any.
+ * @param string $shipping_provider The shipping provider key, or the `__other__` sentinel.
+ * @return array<int> The ids to restrict the query to, or `array( 0 )` when nothing matches.
+ */
+ private function restrict_ids_to_shipping_provider( array $existing_restrictions, string $shipping_provider ): array {
+ global $wpdb;
+
+ // The subquery is prepared by get_shipping_provider_order_ids_subquery().
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $results = $wpdb->get_col( $this->get_shipping_provider_order_ids_subquery( $shipping_provider ) );
+ $order_ids = array_map( 'absint', $results );
+
+ foreach ( $existing_restrictions as $restriction ) {
+ if ( empty( $restriction ) ) {
+ continue;
}
- $query->set( 'post__in', $order_ids );
+
+ $order_ids = array_intersect( array_map( 'absint', (array) $restriction ), $order_ids );
}
+
+ // An empty list drops the restriction instead of applying it, so use an id that cannot match.
+ return empty( $order_ids ) ? array( 0 ) : array_values( $order_ids );
}
/**
- * Get order IDs that have fulfillments with a specific shipping provider.
+ * Build the prepared subquery selecting order ids whose fulfillments use a shipping provider.
*
- * @since 10.7.0
+ * The returned SQL is fully prepared and safe to embed as an `IN ( ... )` subquery.
+ *
+ * @since 11.4.0
*
- * @param string $shipping_provider The shipping provider key to filter by.
- * @return array Array of order IDs.
+ * @param string $shipping_provider The shipping provider key, or the `__other__` sentinel.
+ * @return string The prepared SELECT statement returning matching order ids.
*/
- private function get_order_ids_by_shipping_provider( string $shipping_provider ): array {
+ private function get_shipping_provider_order_ids_subquery( string $shipping_provider ): string {
global $wpdb;
$fulfillments_table = $wpdb->prefix . 'wc_order_fulfillments';
@@ -681,58 +782,51 @@ class FulfillmentsRenderer {
$known_keys = array_keys( $known_providers );
if ( empty( $known_keys ) ) {
- $results = $wpdb->get_col(
- $wpdb->prepare(
- "SELECT DISTINCT f.entity_id
- FROM {$fulfillments_table} f
- INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
- WHERE m.meta_key = %s
- AND m.meta_value IS NOT NULL
- AND m.meta_value NOT IN ( '', %s )
- AND f.date_deleted IS NULL
- AND m.date_deleted IS NULL",
- '_shipment_provider',
- wp_json_encode( '' )
- )
- );
- } else {
- // Exclude known providers and both empty string and the JSON-encoded one the data store writes.
- $excluded = array_merge(
- array( '', wp_json_encode( '' ) ),
- array_map( 'wp_json_encode', $known_keys )
- );
- $placeholders = implode( ',', array_fill( 0, count( $excluded ), '%s' ) );
- $results = $wpdb->get_col(
- $wpdb->prepare(
- "SELECT DISTINCT f.entity_id
- FROM {$fulfillments_table} f
- INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
- WHERE m.meta_key = '_shipment_provider'
- AND m.meta_value NOT IN ({$placeholders})
- AND m.meta_value IS NOT NULL
- AND f.date_deleted IS NULL
- AND m.date_deleted IS NULL",
- ...$excluded
- )
- );
- }
- } else {
- $results = $wpdb->get_col(
- $wpdb->prepare(
+ return $wpdb->prepare(
"SELECT DISTINCT f.entity_id
FROM {$fulfillments_table} f
INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
- WHERE m.meta_key = '_shipment_provider'
- AND m.meta_value = %s
+ WHERE m.meta_key = %s
+ AND m.meta_value IS NOT NULL
+ AND m.meta_value NOT IN ( '', %s )
AND f.date_deleted IS NULL
AND m.date_deleted IS NULL",
- wp_json_encode( $shipping_provider )
- )
+ '_shipment_provider',
+ wp_json_encode( '' )
+ );
+ }
+
+ // Exclude known providers and both empty string and the JSON-encoded one the data store writes.
+ $excluded = array_merge(
+ array( '', wp_json_encode( '' ) ),
+ array_map( 'wp_json_encode', $known_keys )
+ );
+ $placeholders = implode( ',', array_fill( 0, count( $excluded ), '%s' ) );
+
+ return $wpdb->prepare(
+ "SELECT DISTINCT f.entity_id
+ FROM {$fulfillments_table} f
+ INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
+ WHERE m.meta_key = '_shipment_provider'
+ AND m.meta_value NOT IN ({$placeholders})
+ AND m.meta_value IS NOT NULL
+ AND f.date_deleted IS NULL
+ AND m.date_deleted IS NULL",
+ ...$excluded
);
}
- // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
- return array_map( 'absint', $results );
+ return $wpdb->prepare(
+ "SELECT DISTINCT f.entity_id
+ FROM {$fulfillments_table} f
+ INNER JOIN {$meta_table} m ON f.fulfillment_id = m.fulfillment_id
+ WHERE m.meta_key = '_shipment_provider'
+ AND m.meta_value = %s
+ AND f.date_deleted IS NULL
+ AND m.date_deleted IS NULL",
+ wp_json_encode( $shipping_provider )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
index e1548e50699..ccde9391b21 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
@@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsRenderer;
use Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\AmazonLogisticsShippingProvider;
@@ -417,31 +418,29 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
$empty_provider_order_id = $this->create_order_with_shipment_provider( '' );
$known_provider_order_id = $this->create_order_with_shipment_provider( 'dhl' );
- $_GET['shipping_provider'] = '__other__';
- $args = $this->renderer->filter_orders_by_shipping_provider( array() );
- unset( $_GET['shipping_provider'] );
+ $order_ids = $this->get_order_ids( '__other__' );
$this->assertContains(
$unknown_provider_order_id,
- $args['post__in'],
+ $order_ids,
'A provider outside the known list should match the "Other" filter'
);
$this->assertNotContains(
$empty_provider_order_id,
- $args['post__in'],
+ $order_ids,
'A fulfillment saved without a shipping provider should not match the "Other" filter'
);
if ( $is_known_providers ) {
$this->assertNotContains(
$known_provider_order_id,
- $args['post__in'],
+ $order_ids,
'A known provider should not match the "Other" filter'
);
} else {
$this->assertContains(
$known_provider_order_id,
- $args['post__in'],
+ $order_ids,
'With no known providers, any non-empty provider should match the "Other" filter'
);
}
@@ -559,45 +558,29 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox The HPOS orders query is filtered to the matching order ids when no post__in exists yet.
+ * @testdox The HPOS filter carries the requested provider through as a query marker.
*/
- public function test_filter_orders_sets_post_in_when_absent(): void {
- $this->seed_fulfillment( 301, 'acme-couriers' );
-
+ public function test_filter_orders_sets_provider_marker(): void {
$this->with_provider_param(
'acme-couriers',
function () {
$args = $this->renderer->filter_orders_by_shipping_provider( array() );
- $this->assertSame( array( 301 ), $args['post__in'] );
+ $this->assertSame( 'acme-couriers', $args['fulfillment_shipping_provider'] );
+ $this->assertArrayNotHasKey( 'post__in', $args, 'The optimized filter must not materialize a post__in id list.' );
}
);
}
/**
- * @testdox The HPOS filter intersects an existing post__in with the provider matches.
+ * @testdox The HPOS filter leaves any existing query arguments in place while adding the marker.
*/
- public function test_filter_orders_intersects_existing_post_in(): void {
- $this->seed_fulfillment( 401, 'acme-couriers' );
- $this->seed_fulfillment( 402, 'acme-couriers' );
-
+ public function test_filter_orders_preserves_existing_args(): void {
$this->with_provider_param(
'acme-couriers',
function () {
$args = $this->renderer->filter_orders_by_shipping_provider( array( 'post__in' => array( 401, 999 ) ) );
- $this->assertSame( array( 401 ), array_values( $args['post__in'] ), 'Only the id present in both sets should remain.' );
- }
- );
- }
-
- /**
- * @testdox The HPOS filter returns a no-match sentinel when the provider has no orders.
- */
- public function test_filter_orders_returns_zero_when_no_match(): void {
- $this->with_provider_param(
- 'provider-with-no-orders',
- function () {
- $args = $this->renderer->filter_orders_by_shipping_provider( array() );
- $this->assertSame( array( 0 ), $args['post__in'], 'An unmatched provider should force an empty result set.' );
+ $this->assertSame( array( 401, 999 ), $args['post__in'], 'An existing post__in must be preserved, not replaced.' );
+ $this->assertSame( 'acme-couriers', $args['fulfillment_shipping_provider'] );
}
);
}
@@ -612,11 +595,35 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox The legacy orders query is filtered to the matching order ids.
+ * @testdox The HPOS clause filter restricts the orders table to the provider subquery.
+ */
+ public function test_filter_orders_table_clauses_injects_subquery(): void {
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+
+ $clauses = $this->renderer->filter_orders_table_clauses_by_shipping_provider(
+ array( 'where' => '1=1' ),
+ null,
+ array( 'fulfillment_shipping_provider' => 'acme-couriers' )
+ );
+
+ $this->assertStringContainsString( "{$orders_table}.id IN (", $clauses['where'] );
+ $this->assertStringContainsString( $GLOBALS['wpdb']->prefix . 'wc_order_fulfillments', $clauses['where'] );
+ $this->assertStringStartsWith( '1=1 AND ', $clauses['where'], 'The existing WHERE must be preserved.' );
+ }
+
+ /**
+ * @testdox The HPOS clause filter is a no-op when the provider marker is absent.
*/
- public function test_filter_legacy_orders_sets_post_in(): void {
- $this->seed_fulfillment( 501, 'acme-couriers' );
+ public function test_filter_orders_table_clauses_noop_without_marker(): void {
+ $clauses = array( 'where' => '1=1' );
+ $this->assertSame( $clauses, $this->renderer->filter_orders_table_clauses_by_shipping_provider( $clauses, null, array() ) );
+ }
+
+ /**
+ * @testdox The legacy filter marks the main orders query with the requested provider.
+ */
+ public function test_filter_legacy_orders_sets_provider_marker(): void {
$query = new WP_Query();
$query->set( 'post_type', 'shop_order' );
@@ -627,7 +634,8 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
'acme-couriers',
function () use ( $query ) {
$this->renderer->filter_legacy_orders_by_shipping_provider( $query );
- $this->assertSame( array( 501 ), $query->get( 'post__in' ) );
+ $this->assertSame( 'acme-couriers', $query->get( 'fulfillment_shipping_provider' ) );
+ $this->assertEmpty( $query->get( 'post__in' ), 'The optimized filter must not materialize a post__in id list.' );
}
);
}
@@ -638,8 +646,6 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
* @testdox The legacy filter does nothing when the query is not the admin main orders query.
*/
public function test_filter_legacy_orders_skips_non_main_query(): void {
- $this->seed_fulfillment( 601, 'acme-couriers' );
-
// A secondary query: not registered as the main query, so the guard must bail.
$query = new WP_Query();
$query->set( 'post_type', 'shop_order' );
@@ -653,7 +659,7 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
'acme-couriers',
function () use ( $query ) {
$this->renderer->filter_legacy_orders_by_shipping_provider( $query );
- $this->assertEmpty( $query->get( 'post__in' ), 'A non-main query should not be filtered.' );
+ $this->assertEmpty( $query->get( 'fulfillment_shipping_provider' ), 'A non-main query should not be marked.' );
}
);
} finally {
@@ -666,16 +672,327 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
}
/**
- * Invoke the private provider-to-order-ids query.
+ * @testdox The legacy clause filter restricts the posts table to the provider subquery.
+ */
+ public function test_filter_legacy_orders_clauses_injects_subquery(): void {
+ global $wpdb;
+
+ $query = new WP_Query();
+ $query->set( 'fulfillment_shipping_provider', 'acme-couriers' );
+
+ $clauses = $this->renderer->filter_legacy_orders_clauses_by_shipping_provider( array( 'where' => '' ), $query );
+
+ $this->assertStringContainsString( "{$wpdb->posts}.ID IN (", $clauses['where'] );
+ $this->assertStringContainsString( $wpdb->prefix . 'wc_order_fulfillments', $clauses['where'] );
+ }
+
+ /**
+ * @testdox The legacy clause filter is a no-op when the provider marker is absent.
+ */
+ public function test_filter_legacy_orders_clauses_noop_without_marker(): void {
+ $clauses = array( 'where' => " AND {$GLOBALS['wpdb']->posts}.post_status = 'wc-completed'" );
+
+ $query = new WP_Query();
+
+ $this->assertSame( $clauses, $this->renderer->filter_legacy_orders_clauses_by_shipping_provider( $clauses, $query ) );
+ }
+
+ /**
+ * @testdox Running a legacy orders query with the provider marker returns only the matching orders and count.
+ */
+ public function test_legacy_query_returns_only_matching_orders(): void {
+ $match_a = $this->create_shop_order_post();
+ $match_b = $this->create_shop_order_post();
+ $other = $this->create_shop_order_post();
+ $no_filled = $this->create_shop_order_post();
+
+ $this->seed_fulfillment( $match_a, 'acme-couriers' );
+ $this->seed_fulfillment( $match_b, 'acme-couriers' );
+ $this->seed_fulfillment( $other, 'other-co' );
+
+ add_filter( 'posts_clauses', array( $this->renderer, 'filter_legacy_orders_clauses_by_shipping_provider' ), 10, 2 );
+ try {
+ $query = new WP_Query(
+ array(
+ 'post_type' => 'shop_order',
+ 'post_status' => 'wc-processing',
+ 'fields' => 'ids',
+ 'posts_per_page' => -1,
+ 'fulfillment_shipping_provider' => 'acme-couriers',
+ )
+ );
+ } finally {
+ remove_filter( 'posts_clauses', array( $this->renderer, 'filter_legacy_orders_clauses_by_shipping_provider' ), 10 );
+ }
+
+ $ids = array_map( 'absint', $query->posts );
+ sort( $ids );
+ $expected = array( $match_a, $match_b );
+ sort( $expected );
+
+ $this->assertSame( $expected, $ids, 'Only orders whose fulfillment uses the provider should be returned.' );
+ $this->assertSame( 2, (int) $query->found_posts, 'The found-rows count must match the filtered result set.' );
+ $this->assertNotContains( $other, $ids );
+ $this->assertNotContains( $no_filled, $ids );
+ }
+
+ /**
+ * @testdox The HPOS id-list fallback narrows a single-order `p` restriction instead of widening it.
+ */
+ public function test_filter_orders_fallback_intersects_existing_p_arg(): void {
+ $this->seed_fulfillment( 971, 'acme-couriers' );
+ $this->seed_fulfillment( 972, 'acme-couriers' );
+
+ add_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ try {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () {
+ // The query remaps p onto id, so a single-order query must not widen to the whole set.
+ $args = $this->renderer->filter_orders_by_shipping_provider( array( 'p' => 972 ) );
+
+ $this->assertSame( array( 972 ), $args['post__in'] );
+ }
+ );
+ } finally {
+ remove_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ }
+ }
+
+ /**
+ * @testdox The legacy count query applies the provider filter when the result set is paginated.
+ */
+ public function test_legacy_paginated_query_counts_only_matching_orders(): void {
+ $match_a = $this->create_shop_order_post();
+ $match_b = $this->create_shop_order_post();
+ $other = $this->create_shop_order_post();
+
+ $this->seed_fulfillment( $match_a, 'acme-couriers' );
+ $this->seed_fulfillment( $match_b, 'acme-couriers' );
+ $this->seed_fulfillment( $other, 'other-co' );
+
+ add_filter( 'posts_clauses', array( $this->renderer, 'filter_legacy_orders_clauses_by_shipping_provider' ), 10, 2 );
+ try {
+ // A positive posts_per_page makes WP run the count query the pager reads, which an
+ // unlimited query skips entirely.
+ $query = new WP_Query(
+ array(
+ 'post_type' => 'shop_order',
+ 'post_status' => 'wc-processing',
+ 'fields' => 'ids',
+ 'posts_per_page' => 1,
+ 'fulfillment_shipping_provider' => 'acme-couriers',
+ )
+ );
+ } finally {
+ remove_filter( 'posts_clauses', array( $this->renderer, 'filter_legacy_orders_clauses_by_shipping_provider' ), 10 );
+ }
+
+ $this->assertCount( 1, $query->posts, 'The page should hold a single order.' );
+ $this->assertSame( 2, (int) $query->found_posts, 'The count query must apply the provider filter too.' );
+ $this->assertSame( 2, (int) $query->max_num_pages );
+ $this->assertNotContains( $other, array_map( 'absint', $query->posts ) );
+ }
+
+ /**
+ * @testdox The HPOS filter restricts by id list when a callback can answer the query without the built SQL.
+ */
+ public function test_filter_orders_falls_back_to_id_list_for_short_circuited_queries(): void {
+ $this->seed_fulfillment( 901, 'acme-couriers' );
+ $this->seed_fulfillment( 902, 'acme-couriers' );
+ $this->seed_fulfillment( 903, 'other-co' );
+
+ // Only the callback's presence matters here, not what it returns.
+ add_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ try {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () {
+ $args = $this->renderer->filter_orders_by_shipping_provider( array() );
+
+ $this->assertArrayHasKey( 'post__in', $args );
+ $this->assertEqualsCanonicalizing( array( 901, 902 ), $args['post__in'] );
+ $this->assertArrayNotHasKey(
+ 'fulfillment_shipping_provider',
+ $args,
+ 'The marker would be ignored by a callback that discards the built SQL.'
+ );
+ }
+ );
+ } finally {
+ remove_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ }
+ }
+
+ /**
+ * @testdox The HPOS id-list fallback narrows an existing post__in rather than replacing it.
+ */
+ public function test_filter_orders_fallback_intersects_existing_ids(): void {
+ $this->seed_fulfillment( 911, 'acme-couriers' );
+ $this->seed_fulfillment( 912, 'acme-couriers' );
+
+ add_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ try {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () {
+ $args = $this->renderer->filter_orders_by_shipping_provider( array( 'post__in' => array( 911, 999 ) ) );
+
+ $this->assertSame( array( 911 ), $args['post__in'] );
+ }
+ );
+ } finally {
+ remove_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ }
+ }
+
+ /**
+ * @testdox The HPOS id-list fallback narrows an existing id restriction instead of replacing it.
+ */
+ public function test_filter_orders_fallback_intersects_existing_id_arg(): void {
+ $this->seed_fulfillment( 961, 'acme-couriers' );
+ $this->seed_fulfillment( 962, 'acme-couriers' );
+
+ add_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ try {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () {
+ // The query remaps post__in onto id, so an existing id must survive the fallback.
+ $args = $this->renderer->filter_orders_by_shipping_provider( array( 'id' => array( 962, 999 ) ) );
+
+ $this->assertSame( array( 962 ), $args['post__in'] );
+ }
+ );
+ } finally {
+ remove_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ }
+ }
+
+ /**
+ * @testdox The HPOS id-list fallback matches no orders when the provider has none.
+ */
+ public function test_filter_orders_fallback_matches_nothing_for_unused_provider(): void {
+ $this->seed_fulfillment( 921, 'acme-couriers' );
+
+ add_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ try {
+ $this->with_provider_param(
+ 'nobody-ships-with-this',
+ function () {
+ $args = $this->renderer->filter_orders_by_shipping_provider( array() );
+
+ $this->assertSame( array( 0 ), $args['post__in'], 'An empty post__in would drop the filter instead of applying it.' );
+ }
+ );
+ } finally {
+ remove_filter( 'woocommerce_hpos_pre_query', '__return_null' );
+ }
+ }
+
+ /**
+ * @testdox The legacy filter restricts by id list when a callback can answer the query without the built SQL.
+ */
+ public function test_filter_legacy_orders_falls_back_to_id_list_for_short_circuited_queries(): void {
+ $this->seed_fulfillment( 931, 'acme-couriers' );
+ $this->seed_fulfillment( 932, 'other-co' );
+
+ $query = new WP_Query();
+ $query->set( 'post_type', 'shop_order' );
+
+ // Only the callback's presence matters here, not what it returns.
+ add_filter( 'posts_pre_query', '__return_null' );
+ try {
+ $this->with_main_query(
+ $query,
+ function () use ( $query ) {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () use ( $query ) {
+ $this->renderer->filter_legacy_orders_by_shipping_provider( $query );
+
+ $this->assertSame( array( 931 ), $query->get( 'post__in' ) );
+ $this->assertEmpty(
+ $query->get( 'fulfillment_shipping_provider' ),
+ 'The marker would be ignored by a callback that discards the built SQL.'
+ );
+ }
+ );
+ }
+ );
+ } finally {
+ remove_filter( 'posts_pre_query', '__return_null' );
+ }
+ }
+
+ /**
+ * @testdox The HPOS filter restricts by id list when the query suppresses the clause filters.
+ */
+ public function test_filter_orders_falls_back_to_id_list_when_filters_are_suppressed(): void {
+ $this->seed_fulfillment( 941, 'acme-couriers' );
+ $this->seed_fulfillment( 942, 'other-co' );
+
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () {
+ $args = $this->renderer->filter_orders_by_shipping_provider( array( 'suppress_filters' => true ) );
+
+ $this->assertSame( array( 941 ), $args['post__in'] );
+ $this->assertArrayNotHasKey(
+ 'fulfillment_shipping_provider',
+ $args,
+ 'A suppressed query never reaches the clause filter that reads the marker.'
+ );
+ }
+ );
+ }
+
+ /**
+ * @testdox The legacy filter restricts by id list when the query suppresses the clause filters.
+ */
+ public function test_filter_legacy_orders_falls_back_to_id_list_when_filters_are_suppressed(): void {
+ $this->seed_fulfillment( 951, 'acme-couriers' );
+ $this->seed_fulfillment( 952, 'other-co' );
+
+ $query = new WP_Query();
+ $query->set( 'post_type', 'shop_order' );
+ $query->set( 'suppress_filters', true );
+
+ $this->with_main_query(
+ $query,
+ function () use ( $query ) {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () use ( $query ) {
+ $this->renderer->filter_legacy_orders_by_shipping_provider( $query );
+
+ $this->assertSame( array( 951 ), $query->get( 'post__in' ) );
+ $this->assertEmpty(
+ $query->get( 'fulfillment_shipping_provider' ),
+ 'A suppressed query never reaches the clause filter that reads the marker.'
+ );
+ }
+ );
+ }
+ );
+ }
+
+ /**
+ * Build the provider subquery and run it, returning the matching order ids.
*
* @param string $provider The provider key or the __other__ sentinel.
* @return array<int> The matching order ids.
*/
private function get_order_ids( string $provider ): array {
- $method = new \ReflectionMethod( FulfillmentsRenderer::class, 'get_order_ids_by_shipping_provider' );
+ global $wpdb;
+
+ $method = new \ReflectionMethod( FulfillmentsRenderer::class, 'get_shipping_provider_order_ids_subquery' );
$method->setAccessible( true );
+ $subquery = $method->invoke( $this->renderer, $provider );
- return $method->invoke( $this->renderer, $provider );
+ // The subquery is already prepared inside the method under test.
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ return array_map( 'absint', $wpdb->get_col( $subquery ) );
}
/**
@@ -719,6 +1036,20 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
}
}
+ /**
+ * Insert a shop_order post and return its id.
+ *
+ * @return int The created order post id.
+ */
+ private function create_shop_order_post(): int {
+ return (int) wp_insert_post(
+ array(
+ 'post_type' => 'shop_order',
+ 'post_status' => 'wc-processing',
+ )
+ );
+ }
+
/**
* Insert a fulfillment referencing a provider slug, as the filter query expects.
*