Commit 66f4043dd74 for woocommerce

commit 66f4043dd743bf6a2d72e2dedf313c1522e98d62
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Wed Apr 8 07:57:32 2026 +0200

    [Performance] Optimize SQL performance in the wc_customer_bought_product function. (#63995)

    Using Customer ID-driven SQL queries improves performance for both HPOS and CPT storage.

diff --git a/plugins/woocommerce/changelog/performance-slow-sql-customer-bought-product b/plugins/woocommerce/changelog/performance-slow-sql-customer-bought-product
new file mode 100644
index 00000000000..57646355f6e
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-slow-sql-customer-bought-product
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Optimize SQL performance in the wc_customer_bought_product function.
diff --git a/plugins/woocommerce/includes/wc-user-functions.php b/plugins/woocommerce/includes/wc-user-functions.php
index ee38d176600..55b8871fb04 100644
--- a/plugins/woocommerce/includes/wc-user-functions.php
+++ b/plugins/woocommerce/includes/wc-user-functions.php
@@ -439,106 +439,129 @@ function wc_customer_bought_product( $customer_email, $user_id, $product_id ) {
 		$cache_version = WC_Cache_Helper::get_transient_version( 'orders' );
 	}

-	$cache_group = 'orders';
-	$cache_key   = 'wc_customer_bought_product_' . md5( $customer_email . '-' . $user_id . '-' . $use_lookup_tables );
-	$cache_value = wp_cache_get( $cache_key, $cache_group );
+	$aggregation_version = 'v2'; // Update the version when modifying the aggregation implementation to ensure the cache is repopulated.
+	$cache_group         = 'orders';
+	$cache_key           = 'wc_customer_bought_product_' . md5( $customer_email . '-' . $user_id . '-' . $use_lookup_tables . '-' . $aggregation_version );
+	$cache_value         = wp_cache_get( $cache_key, $cache_group );

 	if ( isset( $cache_value['value'], $cache_value['version'] ) && $cache_value['version'] === $cache_version ) {
 		$result = $cache_value['value'];
 	} else {
-		$customer_data = array( $user_id );
-
-		if ( $user_id ) {
-			$user = get_user_by( 'id', $user_id );
-
-			if ( isset( $user->user_email ) ) {
-				$customer_data[] = $user->user_email;
-			}
+		// Identify the customer using the provided data. Use Customer ID to optimize performance in the following SQL
+		// queries. If an account has been deleted, use the supplied ID to ensure graceful handling.
+		$user             = null;
+		$original_user_id = $user_id;
+		if ( ! $user_id && $customer_email && is_email( $customer_email ) ) {
+			$user    = get_user_by( 'email', $customer_email );
+			$user_id = $user->ID ?? $user_id;
+		}
+		if ( $user_id && ! $user ) {
+			$user    = get_user_by( 'id', $user_id );
+			$user_id = $user->ID ?? $user_id;
 		}

-		if ( is_email( $customer_email ) ) {
-			$customer_data[] = $customer_email;
+		// Deduplicate emails to ensure the Customer ID remains the primary performance driver in subsequent SQL queries.
+		$emails = array( $customer_email );
+		if ( $original_user_id ) {
+			$user_email = $user->user_email ?? '';
+			if ( $user_email && is_email( $user_email ) && strtolower( $user_email ) === strtolower( $customer_email ) ) {
+				$emails = array();
+			}
 		}
+		$emails = array_unique( array_filter( $emails, static fn( $email ) => $email && is_email( $email ) ) );

-		$customer_data = array_map( 'esc_sql', array_filter( array_unique( $customer_data ) ) );
-		$statuses      = array_map( 'esc_sql', wc_get_is_paid_statuses() );
+		if ( empty( $emails ) && ! $user_id ) {
+			wp_cache_set(
+				$cache_key,
+				array(
+					'version' => $cache_version,
+					'value'   => array(),
+				),
+				$cache_group,
+				MONTH_IN_SECONDS
+			);

-		if ( count( $customer_data ) === 0 ) {
 			return false;
 		}

+		$emails   = array_map( 'esc_sql', $emails );
+		$statuses = array_map( 'esc_sql', wc_get_is_paid_statuses() );
+
 		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
-			$statuses       = array_map(
-				function ( $status ) {
-					return "wc-$status";
-				},
-				$statuses
-			);
-			$order_table    = OrdersTableDataStore::get_orders_table_name();
-			$user_id_clause = '';
+			$statuses    = array_map( static fn( $status ) => "wc-$status", $statuses );
+			$order_table = OrdersTableDataStore::get_orders_table_name();
+
+			$identity_clause = array();
 			if ( $user_id ) {
-				$user_id_clause = 'OR o.customer_id = ' . absint( $user_id );
+				$identity_clause[] = 'orders.customer_id = ' . absint( $user_id );
 			}
+			if ( ! empty( $emails ) ) {
+				$identity_clause[] = "orders.billing_email IN ( '" . implode( "','", $emails ) . "' )";
+			}
+			$identity_clause = implode( ' OR ', $identity_clause );
+
 			if ( $use_lookup_tables ) {
 				// HPOS: yes, Lookup table: yes.
-				$sql = "
-SELECT DISTINCT product_or_variation_id FROM (
-SELECT CASE WHEN product_id != 0 THEN product_id ELSE variation_id END AS product_or_variation_id
-FROM {$wpdb->prefix}wc_order_product_lookup lookup
-INNER JOIN $order_table AS o ON lookup.order_id = o.ID
-WHERE o.status IN ('" . implode( "','", $statuses ) . "')
-AND ( o.billing_email IN ('" . implode( "','", $customer_data ) . "') $user_id_clause )
-) AS subquery
-WHERE product_or_variation_id != 0
-";
+				$sql = "SELECT DISTINCT product_or_variation_id
+				FROM (
+					SELECT CASE WHEN product_id != 0 THEN product_id ELSE variation_id END AS product_or_variation_id
+					FROM {$wpdb->prefix}wc_order_product_lookup lookup
+						INNER JOIN $order_table AS orders ON lookup.order_id = orders.ID
+					WHERE orders.status IN ( '" . implode( "','", $statuses ) . "' )
+						AND ( $identity_clause )
+				) AS subquery
+				WHERE product_or_variation_id != 0";
 			} else {
 				// HPOS: yes, Lookup table: no.
-				$sql = "
-SELECT DISTINCT im.meta_value FROM $order_table AS o
-INNER JOIN {$wpdb->prefix}woocommerce_order_items AS i ON o.id = i.order_id
-INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS im ON i.order_item_id = im.order_item_id
-WHERE o.status IN ('" . implode( "','", $statuses ) . "')
-AND im.meta_key IN ('_product_id', '_variation_id' )
-AND im.meta_value != 0
-AND ( o.billing_email IN ('" . implode( "','", $customer_data ) . "') $user_id_clause )
-";
+				$sql = "SELECT DISTINCT itemmeta.meta_value
+				FROM $order_table AS orders
+					INNER JOIN {$wpdb->prefix}woocommerce_order_items AS items ON orders.id = items.order_id
+					INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS itemmeta ON items.order_item_id = itemmeta.order_item_id
+				WHERE orders.status IN ( '" . implode( "','", $statuses ) . "' )
+					AND itemmeta.meta_key   IN ( '_product_id', '_variation_id' )
+					AND itemmeta.meta_value != '0'
+					AND ( $identity_clause )";
 			}
-			$result = $wpdb->get_col( $sql );
-		} elseif ( $use_lookup_tables ) {
-			// HPOS: no, Lookup table: yes.
-			$result = $wpdb->get_col(
-				"
-SELECT DISTINCT product_or_variation_id FROM (
-SELECT CASE WHEN lookup.product_id != 0 THEN lookup.product_id ELSE lookup.variation_id END AS product_or_variation_id
-FROM {$wpdb->prefix}wc_order_product_lookup AS lookup
-INNER JOIN {$wpdb->posts} AS p ON p.ID = lookup.order_id
-INNER JOIN {$wpdb->postmeta} AS pm ON p.ID = pm.post_id
-WHERE p.post_status IN ( 'wc-" . implode( "','wc-", $statuses ) . "' )
-AND pm.meta_key IN ( '_billing_email', '_customer_user' )
-AND pm.meta_value IN ( '" . implode( "','", $customer_data ) . "' )
-) AS subquery
-WHERE product_or_variation_id != 0
-		"
-			); // WPCS: unprepared SQL ok.
 		} else {
-			// HPOS: no, Lookup table: no.
-			// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
-			$result = $wpdb->get_col(
-				"
-SELECT DISTINCT im.meta_value FROM {$wpdb->posts} AS p
-INNER JOIN {$wpdb->postmeta} AS pm ON p.ID = pm.post_id
-INNER JOIN {$wpdb->prefix}woocommerce_order_items AS i ON p.ID = i.order_id
-INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS im ON i.order_item_id = im.order_item_id
-WHERE p.post_status IN ( 'wc-" . implode( "','wc-", $statuses ) . "' ) AND p.post_type = 'shop_order'
-AND pm.meta_key IN ( '_billing_email', '_customer_user' )
-AND im.meta_key IN ( '_product_id', '_variation_id' )
-AND im.meta_value != 0
-AND pm.meta_value IN ( '" . implode( "','", $customer_data ) . "' )
-		"
-			);
-			// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
+			$identity_clause = array();
+			if ( $user_id ) {
+				$identity_clause[] = "( postmeta.meta_key = '_customer_user' AND postmeta.meta_value = '" . absint( $user_id ) . "' )";
+			}
+			if ( ! empty( $emails ) ) {
+				$identity_clause[] = "( postmeta.meta_key = '_billing_email' AND postmeta.meta_value IN ( '" . implode( "','", $emails ) . "' ) )";
+			}
+			$identity_clause = implode( ' OR ', $identity_clause );
+
+			if ( $use_lookup_tables ) {
+				// HPOS: no, Lookup table: yes.
+				$sql = "SELECT DISTINCT product_or_variation_id
+				FROM (
+					SELECT CASE WHEN lookup.product_id != 0 THEN lookup.product_id ELSE lookup.variation_id END AS product_or_variation_id
+					FROM {$wpdb->prefix}wc_order_product_lookup AS lookup
+						INNER JOIN {$wpdb->posts} AS posts ON posts.ID = lookup.order_id
+						INNER JOIN {$wpdb->postmeta} AS postmeta ON posts.ID = postmeta.post_id
+					WHERE posts.post_status IN ( 'wc-" . implode( "','wc-", $statuses ) . "' )
+						AND ( $identity_clause )
+				) AS subquery
+				WHERE product_or_variation_id != 0";
+			} else {
+				// HPOS: no, Lookup table: no.
+				$sql = "SELECT DISTINCT itemmeta.meta_value
+				FROM {$wpdb->prefix}woocommerce_order_items AS items
+					INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS itemmeta ON items.order_item_id = itemmeta.order_item_id
+				WHERE items.order_id IN (
+						SELECT posts.ID as order_id
+						FROM {$wpdb->posts} AS posts
+							INNER JOIN {$wpdb->postmeta} AS postmeta ON posts.ID = postmeta.post_id
+						WHERE posts.post_type   = 'shop_order'
+						  AND posts.post_status IN ( 'wc-" . implode( "','wc-", $statuses ) . "' )
+						  AND ( $identity_clause )
+				)
+				AND itemmeta.meta_key   IN ( '_product_id', '_variation_id' )
+				AND itemmeta.meta_value != '0'";
+			}
 		}
-		$result = array_map( 'absint', $result );
+		$result = array_map( 'absint', $wpdb->get_col( $sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

 		wp_cache_set(
 			$cache_key,
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c60b25eaf38..1ab25cfc4d6 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -36897,12 +36897,6 @@ parameters:
 			count: 1
 			path: includes/wc-user-functions.php

-		-
-			message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(int\<min, \-1\>\|int\<1, max\>\|non\-falsy\-string\)\: mixed\)\|null, ''esc_sql'' given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/wc-user-functions.php
-
 		-
 			message: '#^Parameter \#1 \$object_or_class of function property_exists expects object\|string, WP_User\|false given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php
index 53e174546d6..bacc447aa90 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php
@@ -263,12 +263,22 @@ class WC_Tests_Customer_Functions extends WC_Unit_Test_Case {
 	public function test_wc_customer_bought_product() {
 		$customer_id_1 = wc_create_new_customer( 'test@example.com', 'testuser', 'testpassword' );
 		$customer_id_2 = wc_create_new_customer( 'test2@example.com', 'testuser2', 'testpassword2' );
+		$customer_id_3 = wc_create_new_customer( 'account@example.com', 'testuser3', 'testpassword3' );
 		$product_1     = new WC_Product_Simple();
 		$product_1->save();
 		$product_id_1 = $product_1->get_id();
 		$product_2    = new WC_Product_Simple();
 		$product_2->save();
 		$product_id_2 = $product_2->get_id();
+		$product_3    = new WC_Product_Simple();
+		$product_3->save();
+		$product_id_3 = $product_3->get_id();
+		$product_4    = new WC_Product_Simple();
+		$product_4->save();
+		$product_id_4 = $product_4->get_id();
+		$product_5    = new WC_Product_Simple();
+		$product_5->save();
+		$product_id_5 = $product_5->get_id();

 		$order_1 = WC_Helper_Order::create_order( $customer_id_1, $product_1 );
 		$order_1->set_billing_email( 'test@example.com' );
@@ -283,16 +293,44 @@ class WC_Tests_Customer_Functions extends WC_Unit_Test_Case {
 		$order_3->set_status( OrderStatus::PENDING );
 		$order_3->save();

+		$guest_order = wc_create_order();
+		$guest_order->add_product( $product_3 );
+		$guest_order->set_billing_email( 'guest@example.com' );
+		$guest_order->set_status( OrderStatus::COMPLETED );
+		$guest_order->save();
+
+		$different_billing_email_order = WC_Helper_Order::create_order( $customer_id_3, $product_4 );
+		$different_billing_email_order->set_billing_email( 'billing@example.com' );
+		$different_billing_email_order->set_status( OrderStatus::COMPLETED );
+		$different_billing_email_order->save();
+
+		$unlinked_guest_order = wc_create_order();
+		$unlinked_guest_order->add_product( $product_5 );
+		$unlinked_guest_order->set_billing_email( 'test@example.com' );
+		$unlinked_guest_order->set_status( OrderStatus::COMPLETED );
+		$unlinked_guest_order->save();
+
 		// Manually trigger the product lookup tables update, since it may take a few moments for it to happen automatically.
 		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );

 		foreach ( array( '__return_true', '__return_false' ) as $lookup_tables ) {
 			add_filter( 'woocommerce_customer_bought_product_use_lookup_tables', $lookup_tables );
+
 			$this->assertTrue( wc_customer_bought_product( 'test@example.com', $customer_id_1, $product_id_1 ) );
 			$this->assertTrue( wc_customer_bought_product( '', $customer_id_1, $product_id_1 ) );
 			$this->assertTrue( wc_customer_bought_product( 'test@example.com', 0, $product_id_1 ) );
 			$this->assertFalse( wc_customer_bought_product( 'test@example.com', $customer_id_1, $product_id_2 ) );
 			$this->assertFalse( wc_customer_bought_product( 'test2@example.com', $customer_id_2, $product_id_1 ) );
+
+			$this->assertTrue( wc_customer_bought_product( 'guest@example.com', 0, $product_id_3 ) );
+			$this->assertFalse( wc_customer_bought_product( 'other@example.com', 0, $product_id_3 ) );
+
+			$this->assertTrue( wc_customer_bought_product( 'billing@example.com', $customer_id_3, $product_id_4 ) );
+			$this->assertTrue( wc_customer_bought_product( '', $customer_id_3, $product_id_4 ) );
+			$this->assertTrue( wc_customer_bought_product( 'billing@example.com', 0, $product_id_4 ) );
+
+			$this->assertTrue( wc_customer_bought_product( 'test@example.com', 0, $product_id_5 ) );
+
 			remove_filter( 'woocommerce_customer_bought_product_use_lookup_tables', $lookup_tables );
 		}
 	}
diff --git a/plugins/woocommerce/tests/php/includes/wc-user-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-user-functions-test.php
index 8303b5dc615..ef0f701ddb0 100644
--- a/plugins/woocommerce/tests/php/includes/wc-user-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-user-functions-test.php
@@ -37,12 +37,22 @@ class WC_User_Functions_Tests extends WC_Unit_Test_Case {
 	public function test_hpos_wc_customer_bought_product() {
 		$customer_id_1 = wc_create_new_customer( 'test@example.com', 'testuser', 'testpassword' );
 		$customer_id_2 = wc_create_new_customer( 'test2@example.com', 'testuser2', 'testpassword2' );
+		$customer_id_3 = wc_create_new_customer( 'account@example.com', 'testuser3', 'testpassword3' );
 		$product_1     = new WC_Product_Simple();
 		$product_1->save();
 		$product_id_1 = $product_1->get_id();
 		$product_2    = new WC_Product_Simple();
 		$product_2->save();
 		$product_id_2 = $product_2->get_id();
+		$product_3    = new WC_Product_Simple();
+		$product_3->save();
+		$product_id_3 = $product_3->get_id();
+		$product_4    = new WC_Product_Simple();
+		$product_4->save();
+		$product_id_4 = $product_4->get_id();
+		$product_5    = new WC_Product_Simple();
+		$product_5->save();
+		$product_id_5 = $product_5->get_id();

 		$order_1 = WC_Helper_Order::create_order( $customer_id_1, $product_1 );
 		$order_1->set_billing_email( 'test@example.com' );
@@ -61,16 +71,44 @@ class WC_User_Functions_Tests extends WC_Unit_Test_Case {
 		$order_4->set_status( OrderStatus::COMPLETED );
 		$order_4->save();

+		$guest_order = wc_create_order();
+		$guest_order->add_product( $product_3 );
+		$guest_order->set_billing_email( 'guest@example.com' );
+		$guest_order->set_status( OrderStatus::COMPLETED );
+		$guest_order->save();
+
+		$different_billing_email_order = WC_Helper_Order::create_order( $customer_id_3, $product_4 );
+		$different_billing_email_order->set_billing_email( 'billing@example.com' );
+		$different_billing_email_order->set_status( OrderStatus::COMPLETED );
+		$different_billing_email_order->save();
+
+		$unlinked_guest_order = wc_create_order();
+		$unlinked_guest_order->add_product( $product_5 );
+		$unlinked_guest_order->set_billing_email( 'test@example.com' );
+		$unlinked_guest_order->set_status( OrderStatus::COMPLETED );
+		$unlinked_guest_order->save();
+
 		// Manually trigger the product lookup tables update, since it may take a few moments for it to happen automatically.
 		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );

 		foreach ( array( '__return_true', '__return_false' ) as $lookup_tables ) {
 			add_filter( 'woocommerce_customer_bought_product_use_lookup_tables', $lookup_tables );
+
 			$this->assertTrue( wc_customer_bought_product( 'test@example.com', $customer_id_1, $product_id_1 ) );
 			$this->assertTrue( wc_customer_bought_product( '', $customer_id_1, $product_id_1 ) );
 			$this->assertTrue( wc_customer_bought_product( 'test@example.com', 0, $product_id_1 ) );
 			$this->assertFalse( wc_customer_bought_product( 'test@example.com', $customer_id_1, $product_id_2 ) );
 			$this->assertFalse( wc_customer_bought_product( 'test2@example.com', $customer_id_2, $product_id_1 ) );
+
+			$this->assertTrue( wc_customer_bought_product( 'guest@example.com', 0, $product_id_3 ) );
+			$this->assertFalse( wc_customer_bought_product( 'other@example.com', 0, $product_id_3 ) );
+
+			$this->assertTrue( wc_customer_bought_product( 'billing@example.com', $customer_id_3, $product_id_4 ) );
+			$this->assertTrue( wc_customer_bought_product( '', $customer_id_3, $product_id_4 ) );
+			$this->assertTrue( wc_customer_bought_product( 'billing@example.com', 0, $product_id_4 ) );
+
+			$this->assertTrue( wc_customer_bought_product( 'test@example.com', 0, $product_id_5 ) );
+
 			remove_filter( 'woocommerce_customer_bought_product_use_lookup_tables', $lookup_tables );
 		}
 	}