Commit 4c410eaa3d2 for woocommerce

commit 4c410eaa3d28c6c6768b80503a1a69fcd0bfb326
Author: Christopher Hertel <mail@christopher-hertel.de>
Date:   Fri Nov 6 18:14:23 2026 +0100

    Add an include list of order IDs to the orders-query ability (#70700)

    * Add an include list of order IDs to the orders-query ability

    wc_get_orders() has no include argument, so the ability passes the IDs
    as post__in, which both order stores support. It intersects them with
    the IDs an extension filter returns before merging the extension args,
    because the recursive merge would otherwise take the union. An id
    together with include is refused, like an id together with filters.

    * Create the HPOS tables outside the test transaction in OrdersQueryTest

    setup_cot() drops and recreates the tables, which the suite refuses
    inside a test because DDL commits implicitly. The tables are now
    created once per class and each test only switches the storage.

    The include description also tells agents that results are still paged,
    so a list longer than per_page needs a larger per_page.

    * Refuse an empty include list in the orders-query ability

    An agent that sends an empty list of IDs means no orders, but an empty
    post__in applies no filter and returned every order. minItems: 1 refuses
    it, as product-terms-query already does for its include.

    * Return every included order in one page unless per_page is sent

    include now allows up to 100 IDs, the per_page maximum, and per_page
    defaults to the number of IDs when an agent does not send it, so a list
    of 15 IDs no longer comes back as page 1 of 2. An explicit per_page still
    applies. The extension filter test also covers include together with an
    extension that excludes IDs.

    * Describe the per_page default of the orders-query ability

    The schema advertised per_page with a default of 10, which contradicted
    the include description, and a client that fills in schema defaults
    would send 10 and page the included orders. The default is now in the
    description only; the callback already falls back to 10 itself.

    * Count only remaining order IDs toward the orders-query per_page default

    Duplicate and excluded IDs no longer inflate the reported per_page, and the
    include description says that IDs outside type or status are left out.

diff --git a/plugins/woocommerce/changelog/wooai-1456-orders-query-include b/plugins/woocommerce/changelog/wooai-1456-orders-query-include
new file mode 100644
index 00000000000..05f76d8a348
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1456-orders-query-include
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Let AI agents fetch several orders by ID in one request with the include input of the woocommerce/orders-query ability.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index e6da404add0..b5285052100 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -86,6 +86,14 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

+		if ( ! empty( $input['id'] ) && ! empty( $input['include'] ) ) {
+			return new \WP_Error(
+				'woocommerce_ability_order_include_with_id',
+				__( 'Send either an id or include, not both.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
 		if ( ! empty( $input['id'] ) ) {
 			$order = self::get_order_from_input( $input );

@@ -106,9 +114,10 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 			);
 		}

-		$page     = $input['page'] ?? 1;
-		$per_page = $input['per_page'] ?? 10;
-		$args     = array(
+		$page             = $input['page'] ?? 1;
+		$default_per_page = empty( $input['include'] ) ? 10 : max( 1, count( array_diff( array_unique( $input['include'] ), $input['exclude'] ?? array() ) ) );
+		$per_page         = $input['per_page'] ?? $default_per_page;
+		$args             = array(
 			'limit'    => $per_page,
 			'page'     => $page,
 			'paginate' => true,
@@ -163,9 +172,15 @@ 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'] );
 		}
-		if ( isset( $extension_args['post__in'] ) && ! empty( $args['exclude'] ) ) {
-			$include                    = array_values( array_diff( $extension_args['post__in'], (array) $args['exclude'] ) );
-			$extension_args['post__in'] = empty( $include ) ? array( 0 ) : $include;
+		// 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 !== $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() ) {
@@ -270,6 +285,16 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 					'description' => __( 'Filter by parent order ID.', 'woocommerce' ),
 					'minimum'     => 1,
 				),
+				'include'            => array(
+					'type'        => 'array',
+					'description' => __( 'Return only these order IDs, up to 100. IDs that don\'t match type or status are left out. Without per_page, they come back in one page.', 'woocommerce' ),
+					'minItems'    => 1,
+					'maxItems'    => 100,
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+				),
 				'exclude'            => array(
 					'type'        => 'array',
 					'description' => __( 'Order IDs to exclude from the results.', 'woocommerce' ),
@@ -320,10 +345,10 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
 					'minimum' => 1,
 				),
 				'per_page'           => array(
-					'type'    => 'integer',
-					'default' => 10,
-					'minimum' => 1,
-					'maximum' => 100,
+					'type'        => 'integer',
+					'description' => __( 'Orders per page. Defaults to 10, or to the number of IDs in include.', 'woocommerce' ),
+					'minimum'     => 1,
+					'maximum'     => 100,
 				),
 			),
 			'additionalProperties' => false,
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
new file mode 100644
index 00000000000..70dd0fd5fab
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/OrdersQueryTest.php
@@ -0,0 +1,188 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities\Domain;
+
+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_Unit_Test_Case;
+
+/**
+ * Tests for the orders-query ability.
+ */
+class OrdersQueryTest extends WC_Unit_Test_Case {
+	use HPOSToggleTrait;
+
+	/**
+	 * The order storage that was active before the test.
+	 *
+	 * @var bool|null
+	 */
+	private ?bool $original_hpos = null;
+
+	/**
+	 * Create the HPOS tables outside the test transaction, since DDL commits implicitly.
+	 *
+	 * @param \WP_UnitTest_Factory $factory WordPress unit test factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ): void {
+		unset( $factory );
+		$previous_hpos = OrderUtil::custom_orders_table_usage_is_enabled();
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		try {
+			self::setup_cot_tables();
+			if ( OrderUtil::custom_orders_table_usage_is_enabled() !== $previous_hpos ) {
+				OrderHelper::toggle_cot_feature_and_usage( $previous_hpos );
+			}
+		} finally {
+			remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		}
+	}
+
+	/**
+	 * Restore the order storage a test switched.
+	 */
+	public function tearDown(): void {
+		try {
+			if ( null !== $this->original_hpos ) {
+				$this->toggle_cot_feature_and_usage( $this->original_hpos );
+				$this->original_hpos = null;
+			}
+		} finally {
+			remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Storage modes.
+	 *
+	 * @return array<string, array{0: bool}>
+	 */
+	public function storage_modes(): array {
+		return array(
+			'posts storage' => array( false ),
+			'HPOS storage'  => array( true ),
+		);
+	}
+
+	/**
+	 * @testdox Should return exactly the included orders of the requested type and drop excluded IDs.
+	 *
+	 * @dataProvider storage_modes
+	 * @param bool $hpos Whether to use the custom orders table.
+	 */
+	public function test_include_returns_only_listed_orders( bool $hpos ): void {
+		$this->use_storage( $hpos );
+		$first  = OrderHelper::create_order();
+		$second = OrderHelper::create_order();
+		$third  = OrderHelper::create_order();
+		OrderHelper::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $first->get_id(),
+				'amount'   => 1,
+			)
+		);
+
+		$included = OrdersQuery::execute( array( 'include' => array( $first->get_id(), $second->get_id(), $third->get_id(), $refund->get_id() ) ) );
+		$excluded = OrdersQuery::execute(
+			array(
+				'include' => array( $first->get_id(), $second->get_id() ),
+				'exclude' => array( $second->get_id() ),
+			)
+		);
+		$all_out  = OrdersQuery::execute(
+			array(
+				'include' => array( $first->get_id() ),
+				'exclude' => array( $first->get_id() ),
+			)
+		);
+
+		$this->assertEqualsCanonicalizing( array( $first->get_id(), $second->get_id(), $third->get_id() ), array_column( $included['orders'], 'id' ), 'A refund ID in include should not return the refund.' );
+		$this->assertSame( array( $first->get_id() ), array_column( $excluded['orders'], 'id' ) );
+		$this->assertSame( array(), $all_out['orders'], 'Excluding every included ID should return no orders, not all of them.' );
+	}
+
+	/**
+	 * @testdox Should refuse an include list that is empty or longer than 100 IDs.
+	 */
+	public function test_refuses_include_outside_size_limits(): void {
+		$schema = OrdersQuery::get_registration_args()['input_schema'];
+
+		$this->assertWPError( rest_validate_value_from_schema( array( 'include' => array() ), $schema, 'input' ), 'An empty list should be refused instead of returning every order.' );
+		$this->assertWPError( rest_validate_value_from_schema( array( 'include' => range( 1, 101 ) ), $schema, 'input' ) );
+		$this->assertTrue( rest_validate_value_from_schema( array( 'include' => range( 1, 100 ) ), $schema, 'input' ) );
+	}
+
+	/**
+	 * @testdox Should return every included order in one page when per_page is not sent, counting each remaining ID once.
+	 */
+	public function test_include_without_per_page_returns_one_page(): void {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$order_ids = array();
+		foreach ( range( 1, 15 ) as $unused ) {
+			$order_ids[] = OrderHelper::create_order()->get_id();
+		}
+
+		$all_ids = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'include' => $order_ids ) );
+		$paged   = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'include'  => $order_ids,
+				'per_page' => 10,
+			)
+		);
+
+		$deduped = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'include' => array( $order_ids[0], $order_ids[0], $order_ids[1] ),
+				'exclude' => array( $order_ids[1] ),
+			)
+		);
+		$all_out = wp_get_ability( 'woocommerce/orders-query' )->execute(
+			array(
+				'include' => array( $order_ids[0] ),
+				'exclude' => array( $order_ids[0] ),
+			)
+		);
+
+		$per_page = OrdersQuery::get_registration_args()['input_schema']['properties']['per_page'];
+		$this->assertArrayNotHasKey( 'default', $per_page, 'A client that fills in schema defaults would send 10 and page the included orders.' );
+		$this->assertEqualsCanonicalizing( $order_ids, array_column( $all_ids['orders'], 'id' ) );
+		$this->assertSame( 1, $all_ids['total_pages'] );
+		$this->assertCount( 10, $paged['orders'], 'An explicit per_page should still apply.' );
+		$this->assertSame( 1, $deduped['per_page'], 'Duplicate and excluded IDs should not count toward the default per_page.' );
+		$this->assertSame( 1, $all_out['per_page'], 'The default per_page should stay at least 1 when every ID is excluded.' );
+	}
+
+	/**
+	 * @testdox Should refuse an id together with include.
+	 */
+	public function test_refuses_id_with_include(): void {
+		$order = OrderHelper::create_order();
+
+		$result = OrdersQuery::execute(
+			array(
+				'id'      => $order->get_id(),
+				'include' => array( $order->get_id() ),
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_ability_order_include_with_id', $result->get_error_code() );
+		$this->assertSame( 400, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * Switch the order storage for the current test.
+	 *
+	 * @param bool $hpos Whether to use the custom orders table.
+	 */
+	private function use_storage( bool $hpos ): void {
+		$this->original_hpos = 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 );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 85974bbd840..6dece73966a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -513,7 +513,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should keep the orders whose IDs every filter returns, from data that is not meta, and still apply Core's exclude, with HPOS and with posts storage.
+	 * @testdox Should keep the orders whose IDs every filter returns, from data that is not meta, still apply Core's exclude, and narrow Core's include both ways, with HPOS and with posts storage.
 	 * @testWith [true, false]
 	 *           [true, true]
 	 *           [false, false]
@@ -560,6 +560,9 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			$without  = $this->run_ability( 'woocommerce/orders-query', $filters( array( 'without' => array( $orders['plain'] ) ) ) );
 			$excluded = $this->run_ability( 'woocommerce/orders-query', array( 'exclude' => array( $orders['gift'] ) ) + $filters( array( 'contains_product' => $coffee->get_id() ) ) );
 			$both_out = $this->run_ability( 'woocommerce/orders-query', array( 'exclude' => array( $other->get_id() ) ) + $filters( array( 'without' => array( $orders['plain'] ) ) ) );
+			$narrowed = $this->run_ability( 'woocommerce/orders-query', array( 'include' => array( $orders['gift'], $other->get_id() ) ) + $filters( array( 'contains_product' => $coffee->get_id() ) ) );
+			$disjoint = $this->run_ability( 'woocommerce/orders-query', array( 'include' => array( $other->get_id() ) ) + $filters( array( 'contains_product' => $coffee->get_id() ) ) );
+			$minus    = $this->run_ability( 'woocommerce/orders-query', array( 'include' => array( $orders['gift'], $orders['plain'] ) ) + $filters( array( 'without' => array( $orders['plain'] ) ) ) );

 			$this->assertEqualsCanonicalizing( array_values( $orders ), array_column( $contains['orders'], 'id' ) );
 			$this->assertSame( array( $orders['gift'] ), array_column( $both['orders'], 'id' ) );
@@ -567,6 +570,9 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
 			$this->assertEqualsCanonicalizing( array( $orders['gift'], $other->get_id() ), array_column( $without['orders'], 'id' ) );
 			$this->assertSame( array( $orders['plain'] ), array_column( $excluded['orders'], 'id' ) );
 			$this->assertSame( array( $orders['gift'] ), array_column( $both_out['orders'], 'id' ) );
+			$this->assertSame( array( $orders['gift'] ), array_column( $narrowed['orders'], 'id' ), 'An extension filter should narrow include, not widen it.' );
+			$this->assertSame( array(), $disjoint['orders'], 'An extension filter that shares no ID with include should return no orders.' );
+			$this->assertSame( array( $orders['gift'] ), array_column( $minus['orders'], 'id' ), 'IDs an extension filter excludes should leave include.' );
 		} finally {
 			remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_contains_product_filter' ) );
 			remove_all_filters( 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );