Commit d39afa2d60e for woocommerce

commit d39afa2d60edc88a9fb86a9efbf5a80f5e845a5e
Author: Abdalsalaam Halawa <abdalsalaamnafez@gmail.com>
Date:   Wed Aug 12 14:19:17 2026 +0300

    Perf: gate unconditional aggregates in wc/v4 CustomerSchema response (#66072)

    * Perf: gate unconditional aggregates in wc/v4 CustomerSchema response

    CustomerSchema::get_item_response() computed orders_count, total_spent,
    avatar_url, and the wc_last_active meta for every customer before _fields
    filtering, so a sparse request such as GET /wc/v4/customers?_fields=id,email
    still paid the full per-customer aggregate cost (under HPOS each aggregate
    hits the orders table when the cached usermeta is stale) and discarded it.

    Gate each of those computations behind whether the field was requested via
    _fields. The default response (no _fields) is byte-identical.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Add regression tests for _fields gating of expensive customer aggregates

    Cover the default request keeping the full payload, sparse _fields
    omitting and skipping orders_count, total_spent, avatar_url and the
    last_active fields, and _fields requests for last_active running the
    wc_last_active meta normalization.

    * Keep the last_active meta read ungated in the v4 customer schema

    The wc_last_active meta is already in memory by the time the schema runs:
    WC_Customer_Data_Store::read() calls read_meta_data() unconditionally, so
    get_meta() is an array lookup rather than a query. Gating it saved no work.

    * Assert real aggregate values and the skipped COUNT/SUM in v4 customer tests

    The default-request test asserted an order count of 0 and a total spent of
    0.0 on a customer with no orders, which a broken gate returning null would
    also satisfy. It now places a completed order and asserts the real values.

    Adds a test that the per-customer COUNT/SUM queries behind orders_count and
    total_spent do not run for a sparse _fields request, with a cold aggregate
    cache and a positive control so the query matcher cannot silently match
    nothing. Drops the last_active _fields test, which no longer covers anything
    beyond the existing default-path coverage.

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/66072-perf-v4-customer-schema-gate-aggregates b/plugins/woocommerce/changelog/66072-perf-v4-customer-schema-gate-aggregates
new file mode 100644
index 00000000000..945c469401c
--- /dev/null
+++ b/plugins/woocommerce/changelog/66072-perf-v4-customer-schema-gate-aggregates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+REST API: in the wc/v4 customers endpoint, only compute the order count, total spent and avatar fields when they are requested via _fields, avoiding wasted per-customer aggregate queries on sparse requests.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Customers/CustomerSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Customers/CustomerSchema.php
index 426a1a671a9..781d6a2b766 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Customers/CustomerSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Customers/CustomerSchema.php
@@ -277,6 +277,15 @@ class CustomerSchema extends AbstractSchema {

 		$data = $item->get_data();

+		// Only compute fields that will actually be returned. When $include_fields is empty the full
+		// response is requested, so everything is computed (default behavior, byte-identical output).
+		// When a sparse _fields subset is requested, skip the per-customer aggregates and the avatar
+		// lookup the caller did not ask for, instead of computing them and discarding them after
+		// array_intersect_key.
+		$is_field_included = static function ( string $field ) use ( $include_fields ) {
+			return empty( $include_fields ) || in_array( $field, $include_fields, true );
+		};
+
 		// Normalize last active timestamp - treat empty string, '0', 0, or false as null.
 		$last_active = $item->get_meta( 'wc_last_active' );
 		$last_active = empty( $last_active ) ? null : $last_active;
@@ -295,9 +304,9 @@ class CustomerSchema extends AbstractSchema {
 			'billing'            => $data['billing'],
 			'shipping'           => $data['shipping'],
 			'is_paying_customer' => $data['is_paying_customer'],
-			'orders_count'       => $item->get_order_count(),
-			'total_spent'        => $item->get_total_spent(),
-			'avatar_url'         => $item->get_avatar_url(),
+			'orders_count'       => $is_field_included( 'orders_count' ) ? $item->get_order_count() : null,
+			'total_spent'        => $is_field_included( 'total_spent' ) ? $item->get_total_spent() : null,
+			'avatar_url'         => $is_field_included( 'avatar_url' ) ? $item->get_avatar_url() : null,
 			'last_active'        => $last_active ? wc_rest_prepare_date_response( $last_active, false ) : null,
 			'last_active_gmt'    => $last_active ? wc_rest_prepare_date_response( $last_active ) : null,
 		);
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Customers/class-wc-rest-customers-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Customers/class-wc-rest-customers-v4-controller-tests.php
index 9caa504f907..f064d1a87fe 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Customers/class-wc-rest-customers-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Customers/class-wc-rest-customers-v4-controller-tests.php
@@ -1,10 +1,12 @@
 <?php
 declare( strict_types=1 );

+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers\Controller as CustomersController;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers\CustomerSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers\CollectionQuery;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers\UpdateUtils;
+use Automattic\WooCommerce\Internal\Utilities\Users;
 use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;

 /**
@@ -189,6 +191,61 @@ class WC_REST_Customers_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Create a paid order totalling $50 for a customer, so the customer's order aggregates have
+	 * non-zero values. The status has to be a paid one for total_spent to count it.
+	 *
+	 * @param int $customer_id Customer ID.
+	 * @return WC_Order
+	 */
+	private function create_paid_order_for_customer( int $customer_id ): WC_Order {
+		$order = WC_Helper_Order::create_order( $customer_id );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Drop the cached order count and money spent user meta so the next read has to recompute them
+	 * from the orders table.
+	 *
+	 * @param int $customer_id Customer ID.
+	 */
+	private function clear_customer_aggregate_caches( int $customer_id ): void {
+		Users::delete_site_user_meta( $customer_id, 'wc_order_count' );
+		Users::delete_site_user_meta( $customer_id, 'wc_money_spent' );
+	}
+
+	/**
+	 * Count the per-customer COUNT/SUM order aggregate queries — the ones backing orders_count and
+	 * total_spent — executed while the given callback runs. Matches both the HPOS and the posts
+	 * table variants of those queries.
+	 *
+	 * @param callable $callback Code to run while counting.
+	 * @return int Number of aggregate queries executed.
+	 */
+	private function count_customer_aggregate_queries( callable $callback ): int {
+		$count = 0;
+		$spy   = function ( $query ) use ( &$count ) {
+			$is_aggregate = 1 === preg_match( '/^\s*SELECT\s+(COUNT|SUM)\s*\(/i', $query );
+			$is_customer  = false !== strpos( $query, 'customer_id' ) || false !== strpos( $query, '_customer_user' );
+			if ( $is_aggregate && $is_customer ) {
+				++$count;
+			}
+			return $query;
+		};
+
+		add_filter( 'query', $spy );
+		try {
+			$callback();
+		} finally {
+			remove_filter( 'query', $spy );
+		}
+
+		return $count;
+	}
+
 	/**
 	 * Helper method to validate response against schema.
 	 *
@@ -459,6 +516,102 @@ class WC_REST_Customers_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'billing', $response_data );
 	}

+	/**
+	 * Test a default request (no _fields) includes the aggregate fields with their real values.
+	 */
+	public function test_default_request_includes_aggregate_fields(): void {
+		$customer = $this->create_test_customer();
+		$this->create_paid_order_for_customer( $customer->get_id() );
+		$this->clear_customer_aggregate_caches( $customer->get_id() );
+
+		$last_active = time() - HOUR_IN_SECONDS;
+		update_user_meta( $customer->get_id(), 'wc_last_active', (string) $last_active );
+
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/customers/' . $customer->get_id() );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$response_data = $response->get_data();
+
+		$this->assertSame( 1, $response_data['orders_count'] );
+		$this->assertSame( '50.00', $response_data['total_spent'] );
+		$this->assertNotEmpty( $response_data['avatar_url'] );
+		$this->assertSame( gmdate( 'Y-m-d\TH:i:s', $last_active ), $response_data['last_active_gmt'] );
+		$this->assertNotNull( $response_data['last_active'] );
+	}
+
+	/**
+	 * Test a sparse _fields request that excludes the aggregate fields omits them from the
+	 * response and skips computing them.
+	 */
+	public function test_fields_parameter_excluding_aggregates_omits_and_skips_them(): void {
+		$customer = $this->create_test_customer();
+
+		$avatar_lookups       = 0;
+		$count_avatar_lookups = function ( $args ) use ( &$avatar_lookups ) {
+			++$avatar_lookups;
+			return $args;
+		};
+		add_filter( 'pre_get_avatar_data', $count_avatar_lookups );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v4/customers/' . $customer->get_id() );
+		$request->set_param( '_fields', 'id,email' );
+		$response = $this->server->dispatch( $request );
+
+		remove_filter( 'pre_get_avatar_data', $count_avatar_lookups );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$response_data = $response->get_data();
+
+		$this->assertArrayHasKey( 'id', $response_data );
+		$this->assertArrayHasKey( 'email', $response_data );
+		foreach ( array( 'orders_count', 'total_spent', 'avatar_url' ) as $field ) {
+			$this->assertArrayNotHasKey( $field, $response_data, "Response must not contain unrequested field: {$field}" );
+		}
+		$this->assertSame( 0, $avatar_lookups, 'avatar_url must not be computed when it is not requested via _fields' );
+	}
+
+	/**
+	 * Test a sparse _fields request that excludes orders_count and total_spent does not run the
+	 * per-customer COUNT/SUM queries backing them, while a request that asks for them still does.
+	 */
+	public function test_fields_parameter_excluding_aggregates_skips_aggregate_queries(): void {
+		$customer = $this->create_test_customer();
+		$this->create_paid_order_for_customer( $customer->get_id() );
+
+		// A cold aggregate cache is the case this optimization targets: with the cache warm the
+		// COUNT/SUM never runs anyway and the test could not tell the gate apart from a no-op.
+		$this->clear_customer_aggregate_caches( $customer->get_id() );
+
+		$sparse_request = new WP_REST_Request( 'GET', '/wc/v4/customers/' . $customer->get_id() );
+		$sparse_request->set_param( '_fields', 'id,email' );
+		$sparse_queries = $this->count_customer_aggregate_queries(
+			function () use ( $sparse_request ) {
+				$this->assertEquals( 200, $this->server->dispatch( $sparse_request )->get_status() );
+			}
+		);
+
+		$this->assertSame( 0, $sparse_queries, 'orders_count/total_spent must not run their COUNT/SUM queries when not requested via _fields' );
+
+		// The sparse request left the cache cold, so the same request asking for the fields must
+		// now run them. This guards the assertion above against silently matching nothing.
+		$full_request = new WP_REST_Request( 'GET', '/wc/v4/customers/' . $customer->get_id() );
+		$full_request->set_param( '_fields', 'id,orders_count,total_spent' );
+		$response     = null;
+		$full_queries = $this->count_customer_aggregate_queries(
+			function () use ( $full_request, &$response ) {
+				$response = $this->server->dispatch( $full_request );
+			}
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( 2, $full_queries, 'orders_count and total_spent must each run their aggregate query when requested via _fields' );
+
+		$response_data = $response->get_data();
+		$this->assertSame( 1, $response_data['orders_count'] );
+		$this->assertSame( '50.00', $response_data['total_spent'] );
+	}
+
 	/**
 	 * Test search functionality.
 	 */