Commit 7f5c9498464 for woocommerce
commit 7f5c949846498475fbf46f30136baf8f8d2b42dc
Author: Christopher Hertel <mail@christopher-hertel.de>
Date: Mon Nov 9 18:56:21 2026 +0100
Add a search input to the orders query ability (#70747)
* Add a search input to the orders query ability
* Add changelog entry for searching orders in the orders query ability
* Refuse a search with no text and match the same term on both order storages
* Refuse an id with search and match an Order # prefix on both order storages
diff --git a/plugins/woocommerce/changelog/wooai-1458-search-orders b/plugins/woocommerce/changelog/wooai-1458-search-orders
new file mode 100644
index 00000000000..b6683872b24
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1458-search-orders
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let AI agents find orders by customer name, email or product name through the orders query ability.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index b5285052100..67d17ebb73e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -94,6 +94,25 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
);
}
+ if ( ! empty( $input['id'] ) && isset( $input['search'] ) ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_search_with_id',
+ __( 'Send either an id or search, not both.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Cleaned as wc_order_search() does, which also drops %-encoded octets such as "%ab", so both storages search the same term.
+ // The order queries skip an empty() `s`, which includes "0".
+ $search = isset( $input['search'] ) ? trim( str_replace( 'Order #', '', sanitize_text_field( $input['search'] ) ) ) : null;
+ if ( '' === $search || '0' === $search ) {
+ return new \WP_Error(
+ 'woocommerce_ability_order_search_empty',
+ __( 'Send search with text to match, or leave it out to list all orders. A search of only "0" is not supported.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
if ( ! empty( $input['id'] ) ) {
$order = self::get_order_from_input( $input );
@@ -172,18 +191,30 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
$args['exclude'] = array_merge( (array) ( $args['exclude'] ?? array() ), $extension_args['post__not_in'] );
unset( $extension_args['post__not_in'] );
}
+
+ $hpos = OrderUtil::custom_orders_table_usage_is_enabled();
+
// Both stores merge extension args recursively, which would add the extension IDs to include instead of intersecting them.
$include = empty( $input['include'] ) ? null : $input['include'];
if ( isset( $extension_args['post__in'] ) ) {
$include = null === $include ? $extension_args['post__in'] : array_intersect( $include, $extension_args['post__in'] );
unset( $extension_args['post__in'] );
}
+ if ( null !== $search ) {
+ if ( $hpos ) {
+ $args['s'] = $search;
+ } else {
+ // Posts storage matches `s` against post title and content only, which orders leave empty.
+ $matches = wc_order_search( $search );
+ $include = null === $include ? $matches : array_intersect( $include, $matches );
+ }
+ }
if ( null !== $include ) {
$include = array_values( array_diff( $include, (array) ( $args['exclude'] ?? array() ) ) );
$args['post__in'] = empty( $include ) ? array( 0 ) : $include;
}
- if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ if ( $hpos ) {
$args = array_merge_recursive( $args, $extension_args );
} else {
$args[ AbilitiesLoader::EXTENSION_QUERY_VAR ] = $extension_args;
@@ -280,6 +311,10 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
'type' => 'string',
'format' => 'email',
),
+ 'search' => array(
+ 'type' => 'string',
+ 'description' => __( 'Find orders by order number, or whose billing or shipping details (name, email, phone, address) or item names (products, shipping methods, fees, coupons) contain this text. With HPOS it also matches the transaction ID, and with the HPOS full-text index on it matches only the start of a word. A short term such as "Free" can match many orders, so add customer_id or other filters to narrow the results.', 'woocommerce' ),
+ ),
'parent' => array(
'type' => 'integer',
'description' => __( 'Filter by parent order ID.', 'woocommerce' ),
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
index 70dd0fd5fab..49e8920e2e1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
@@ -7,6 +7,8 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use Automattic\WooCommerce\Utilities\OrderUtil;
+use WC_Helper_Order;
+use WC_Helper_Product;
use WC_Unit_Test_Case;
/**
@@ -175,6 +177,162 @@ class OrdersQueryTest extends WC_Unit_Test_Case {
$this->assertSame( 400, $result->get_error_data()['status'] );
}
+ /**
+ * Search terms that each match one detail of the order that create_detailed_order() builds, on both storages.
+ *
+ * @return array<string, array{0: bool, 1: string}>
+ */
+ public function search_terms(): array {
+ $terms = array(
+ 'first name' => 'Jane',
+ 'last name' => 'doe',
+ 'start of the email' => 'jane.doe@',
+ 'product name' => 'Enamel',
+ 'billing address' => 'Elm Street',
+ 'phone' => '0199-44',
+ 'shipping name' => 'Robin',
+ 'shipping line' => 'Courier',
+ 'fee name' => 'Gift wrap',
+ 'coupon code' => 'spring10',
+ 'padded term' => ' Enamel ',
+ );
+
+ $cases = array();
+ foreach ( $this->storage_modes() as $storage => list( $hpos ) ) {
+ foreach ( $terms as $detail => $term ) {
+ $cases[ "$detail, $storage" ] = array( $hpos, $term );
+ }
+ }
+ return $cases;
+ }
+
+ /**
+ * @testdox Should find an order by each customer and item detail the search input describes.
+ *
+ * @dataProvider search_terms
+ * @param bool $hpos Whether to use the custom orders table.
+ * @param string $search Search text.
+ */
+ public function test_search_matches_order_details( bool $hpos, string $search ): void {
+ $this->use_storage( $hpos );
+ $order = $this->create_detailed_order();
+ $this->create_customer_order( 'John', 'Smith', 'john@example.com' );
+
+ $result = OrdersQuery::execute( array( 'search' => $search ) );
+
+ $this->assertSame( array( $order ), array_column( $result['orders'], 'id' ) );
+ }
+
+ /**
+ * @testdox Should find an order by its order number, with or without an "Order #" prefix.
+ *
+ * @dataProvider storage_modes
+ * @param bool $hpos Whether to use the custom orders table.
+ */
+ public function test_search_matches_order_number( bool $hpos ): void {
+ $this->use_storage( $hpos );
+ $this->create_customer_order( 'John', 'Smith', 'john@example.com' );
+ $order = $this->create_customer_order( 'Jane', 'Doe', 'jane.doe@example.com' );
+
+ $number = OrdersQuery::execute( array( 'search' => (string) $order ) );
+ $prefixed = OrdersQuery::execute( array( 'search' => 'Order #' . $order ) );
+
+ $this->assertContains( $order, array_column( $number['orders'], 'id' ) );
+ $this->assertContains( $order, array_column( $prefixed['orders'], 'id' ), 'An "Order #" prefix should match on both storages.' );
+ }
+
+ /**
+ * @testdox Should refuse an id together with search.
+ */
+ public function test_refuses_id_with_search(): void {
+ $order = OrderHelper::create_order();
+
+ $result = OrdersQuery::execute(
+ array(
+ 'id' => $order->get_id(),
+ 'search' => 'Jane',
+ )
+ );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_ability_order_search_with_id', $result->get_error_code() );
+ $this->assertSame( 400, $result->get_error_data()['status'] );
+ }
+
+ /**
+ * @testdox Should return no orders when nothing matches the search.
+ *
+ * @dataProvider storage_modes
+ * @param bool $hpos Whether to use the custom orders table.
+ */
+ public function test_search_without_matches_returns_nothing( bool $hpos ): void {
+ $this->use_storage( $hpos );
+ $this->create_customer_order( 'Jane', 'Doe', 'jane.doe@example.com' );
+
+ $result = OrdersQuery::execute( array( 'search' => 'Nobody' ) );
+
+ $this->assertSame( array(), $result['orders'] );
+ $this->assertSame( 0, $result['total_pages'] );
+ }
+
+ /**
+ * @testdox Should apply include, customer_id and exclude to the orders a search matches.
+ *
+ * @dataProvider storage_modes
+ * @param bool $hpos Whether to use the custom orders table.
+ */
+ public function test_search_combines_with_other_filters( bool $hpos ): void {
+ $this->use_storage( $hpos );
+ $customer_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ $guest = $this->create_customer_order( 'Jane', 'Doe', 'jane.doe@example.com' );
+ $first = $this->create_customer_order( 'Jane', 'Doe', 'jane.doe@example.com', null, $customer_id );
+ $second = $this->create_customer_order( 'Jane', 'Doe', 'jane.doe@example.com', null, $customer_id );
+ $john = $this->create_customer_order( 'John', 'Smith', 'john@example.com' );
+
+ $included = OrdersQuery::execute(
+ array(
+ 'search' => 'Jane',
+ 'include' => array( $guest, $john ),
+ )
+ );
+ $by_customer = OrdersQuery::execute(
+ array(
+ 'search' => 'Jane',
+ 'customer_id' => $customer_id,
+ )
+ );
+ $excluded = OrdersQuery::execute(
+ array(
+ 'search' => 'Jane',
+ 'exclude' => array( $first ),
+ )
+ );
+
+ $this->assertSame( array( $guest ), array_column( $included['orders'], 'id' ), 'A search should narrow an include list, not add to it.' );
+ $this->assertEqualsCanonicalizing( array( $first, $second ), array_column( $by_customer['orders'], 'id' ) );
+ $this->assertEqualsCanonicalizing( array( $guest, $second ), array_column( $excluded['orders'], 'id' ) );
+ }
+
+ /**
+ * @testdox Should refuse a search with no text to match, including "0", which the order queries treat as no search.
+ *
+ * @testWith [""]
+ * [" "]
+ * ["0"]
+ * [" 0 "]
+ * ["<b></b>"]
+ * ["Order #"]
+ *
+ * @param string $search Search text.
+ */
+ public function test_refuses_search_without_text( string $search ): void {
+ $result = OrdersQuery::execute( array( 'search' => $search ) );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_ability_order_search_empty', $result->get_error_code() );
+ $this->assertSame( 400, $result->get_error_data()['status'] );
+ }
+
/**
* Switch the order storage for the current test.
*
@@ -185,4 +343,60 @@ class OrdersQueryTest extends WC_Unit_Test_Case {
add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
$this->toggle_cot_feature_and_usage( $hpos );
}
+
+ /**
+ * Create Jane Doe's order, whose address, phone, shipping name and item names appear in no other test order.
+ *
+ * @return int Order ID.
+ */
+ private function create_detailed_order(): int {
+ $order = wc_get_order( $this->create_customer_order( 'Jane', 'Doe', 'jane.doe@example.com', 'Enamel mug' ) );
+ $order->set_billing_address_1( '42 Elm Street' );
+ $order->set_billing_phone( '0199-4477' );
+ $order->set_shipping_first_name( 'Robin' );
+
+ $shipping = new \WC_Order_Item_Shipping();
+ $shipping->set_method_title( 'Courier express' );
+ $order->add_item( $shipping );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_name( 'Gift wrap' );
+ $order->add_item( $fee );
+
+ $coupon = new \WC_Order_Item_Coupon();
+ $coupon->set_code( 'spring10' );
+ $order->add_item( $coupon );
+
+ $order->save();
+
+ return $order->get_id();
+ }
+
+ /**
+ * Create an order with one product and the given billing and shipping names.
+ *
+ * @param string $first_name Billing first name.
+ * @param string $last_name Billing last name.
+ * @param string $email Billing email.
+ * @param string|null $product_name Name of the ordered product, or null for the helper's default.
+ * @param int $customer_id Customer ID, 0 for a guest.
+ * @return int Order ID.
+ */
+ private function create_customer_order( string $first_name, string $last_name, string $email, ?string $product_name = null, int $customer_id = 0 ): int {
+ $product = WC_Helper_Product::create_simple_product();
+ if ( null !== $product_name ) {
+ $product->set_name( $product_name );
+ $product->save();
+ }
+
+ $order = WC_Helper_Order::create_order( $customer_id, $product );
+ $order->set_billing_first_name( $first_name );
+ $order->set_billing_last_name( $last_name );
+ $order->set_billing_email( $email );
+ $order->set_shipping_first_name( $first_name );
+ $order->set_shipping_last_name( $last_name );
+ $order->save();
+
+ return $order->get_id();
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 02d0a420bbd..46f913164f7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -581,6 +581,49 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should return only the orders that both a search and an extension's ID filter match, with HPOS and with posts storage.
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $hpos Whether HPOS is on.
+ */
+ public function test_search_intersects_with_order_ids_filter( bool $hpos ): void {
+ $original = OrderUtil::custom_orders_table_usage_is_enabled();
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ $this->toggle_cot_feature_and_usage( $hpos );
+ add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_contains_product_filter' ) );
+
+ try {
+ $coffee = \WC_Helper_Product::create_simple_product();
+ $orders = array();
+ foreach ( array(
+ 'jane_coffee' => array( 'Jane', $coffee ),
+ 'jane_other' => array( 'Jane', null ),
+ 'john_coffee' => array( 'John', $coffee ),
+ ) as $key => list( $first_name, $product ) ) {
+ $order = \WC_Helper_Order::create_order( 0, $product );
+ $order->set_billing_first_name( $first_name );
+ $order->save();
+ $orders[ $key ] = $order->get_id();
+ }
+
+ $result = $this->run_ability(
+ 'woocommerce/orders-query',
+ array(
+ 'search' => 'Jane',
+ 'filters' => array( 'extensions' => array( 'test-ext' => array( 'contains_product' => $coffee->get_id() ) ) ),
+ )
+ );
+
+ $this->assertSame( array( $orders['jane_coffee'] ), array_column( $result['orders'], 'id' ) );
+ } finally {
+ remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_contains_product_filter' ) );
+ $this->toggle_cot_feature_and_usage( $original );
+ remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ }
+ }
+
/**
* Register an order filter that reads order items, which are not meta, and returns order IDs,
* and one that excludes the order IDs it receives.