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 );
}
}