Commit 14c7c55c65e for woocommerce
commit 14c7c55c65e2c71dff9889eff0b854709599eb9a
Author: Mike Jolley <mike.jolley@me.com>
Date: Thu Mar 19 11:27:35 2026 +0000
Performance: Cache priming for HPOS orders (#63440)
* Order Table Data Store - prime item caches
* API v4 - Prime product caches
* Prime order item meta data
* changelog
* Add targetHints for self to prevent extra permissions checks
* Simplify target hint checks
* Prime the needs_processing transient
* return type
* Add missing return type
* Move shared priming methods to abstract
* Fix type guard for post_type key
* Simplify type guards
* Update baseline
* Guard against non-refund objects in prime_refund_caches_for_orders
The woocommerce_hpos_pre_query filter can intercept nested
wc_get_orders calls, returning non-refund objects even when
type is shop_order_refund. Replace the type hint with an
instanceof guard to handle this gracefully.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Reviewed code, small improvements
* Simplify ID placeholders in queries
* Fix phpstan warnings
* Remove V4 controller changes split into PR #63654
Removes targetHints on self links and product cache priming from the
V4 Orders Controller — these are now in a separate PR for smaller
review scope.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update changelog to remove product cache reference
Product cache priming was split into PR #63654.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Abstract refund total queries into shared base class
Move get_total_tax_refunded, get_total_shipping_tax_refunded, and
get_total_shipping_refunded from CPT and HPOS data stores into
Abstract_WC_Order_Data_Store_CPT. The only difference between the
two implementations was the refund orders JOIN clause, now provided
by an overridable get_refund_orders_join_clause() method.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Abstract prime_refund_total_caches_for_orders into shared base class
Move the refund total cache priming method from CPT and HPOS data
stores into Abstract_WC_Order_Data_Store_CPT. The tax query uses
overridable get_refund_orders_batch_join_clause() and
get_refund_parent_column() methods. The fundamentally different
refund totals query is handled by overridable get_batch_refund_totals().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add tests for HPOS order cache priming
Tests that prime_caches_for_orders correctly populates refund total,
tax refunded, and order item meta caches.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Unify refund total methods into shared get_refunded_item_meta_total
Collapse get_total_tax_refunded, get_total_shipping_tax_refunded, and
get_total_shipping_refunded into thin wrappers around a single
get_refunded_item_meta_total() method parameterized by item type and
meta keys.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address PR feedback: simplify array grouping and use prepared placeholders
Replace array_reduce with plain foreach loops for meta data grouping in
both abstract and CPT data stores. Use $wpdb->prepare with spread
operator for meta key IN clauses instead of esc_sql interpolation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Harden cache priming SQL and add CPT-path test coverage (#63705)
- Replace esc_sql() with $wpdb->prepare() and %d placeholders for
order item ID queries in prime_order_item_caches_for_orders
- Change get_refund_orders_batch_join_clause() and get_batch_refund_totals()
to accept array $order_ids instead of string $sanitized_ids, performing
absint sanitization internally rather than trusting callers
- Add CPT data store tests for prime_caches_for_orders covering both
refund total cache priming and order item meta priming
* Fix merge conflict
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Brandon Kraft <public@brandonkraft.com>
diff --git a/plugins/woocommerce/changelog/fix-hpos-order-item-cache-priming b/plugins/woocommerce/changelog/fix-hpos-order-item-cache-priming
new file mode 100644
index 00000000000..109694a7082
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hpos-order-item-cache-priming
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Prime order item meta caches during HPOS order queries to eliminate N+1 queries in REST API serialization.
diff --git a/plugins/woocommerce/includes/class-wc-order.php b/plugins/woocommerce/includes/class-wc-order.php
index 8ff22528977..f93fd6244b8 100644
--- a/plugins/woocommerce/includes/class-wc-order.php
+++ b/plugins/woocommerce/includes/class-wc-order.php
@@ -2192,7 +2192,7 @@ class WC_Order extends WC_Abstract_Order {
*
* Utilizes object cache to store refunds to avoid extra DB calls.
*
- * @see WC_Order_Data_Store_CPT::prime_refund_caches_for_order()
+ * @see Abstract_WC_Order_Data_Store_CPT::prime_refund_caches_for_orders()
* @since 2.2
*
* @return WC_Order_Refund[]
diff --git a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
index 24fb26e2f22..5260753b308 100644
--- a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
@@ -638,6 +638,117 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
}
$order_item_ids = wp_list_pluck( $order_items, 'order_item_id' );
update_meta_cache( 'order_item', $order_item_ids );
+
+ // Prime WC_Data meta cache (includes meta_id required by read_meta_data).
+ $id_placeholders = implode( ', ', array_fill( 0, count( $order_item_ids ), '%d' ) );
+ $raw_meta_data_array = $wpdb->get_results(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $id_placeholders is generated above.
+ "SELECT order_item_id as object_id, meta_id, meta_key, meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE order_item_id IN ({$id_placeholders}) ORDER BY meta_id",
+ ...$order_item_ids
+ )
+ );
+
+ if ( ! empty( $raw_meta_data_array ) ) {
+ $raw_meta_data_collection = array();
+ foreach ( $raw_meta_data_array as $raw_meta_data ) {
+ if ( ! isset( $raw_meta_data_collection[ $raw_meta_data->object_id ] ) ) {
+ $raw_meta_data_collection[ $raw_meta_data->object_id ] = array();
+ }
+ $raw_meta_data_collection[ $raw_meta_data->object_id ][] = $raw_meta_data;
+ }
+ \WC_Order_Item::prime_raw_meta_data_cache( $raw_meta_data_collection, 'order-items' );
+ }
+ }
+
+ /**
+ * Prime refund cache for a batch of orders.
+ *
+ * WC_Order::get_refunds() checks wp_cache before querying. By fetching
+ * all refunds for the batch in a single query and populating the cache,
+ * we eliminate one query per order.
+ *
+ * @param array $order_ids Order IDs to prime cache for.
+ * @param array $query_vars Query vars for the query.
+ * @return void
+ * @since 10.7.0
+ */
+ protected function prime_refund_caches_for_orders( $order_ids, $query_vars ) {
+ if ( isset( $query_vars['fields'] ) && 'all' !== $query_vars['fields'] ) {
+ if ( is_array( $query_vars['fields'] ) && ! in_array( 'refunds', $query_vars['fields'], true ) ) {
+ return;
+ }
+ }
+
+ $cache_keys_mapping = array();
+ foreach ( $order_ids as $order_id ) {
+ $cache_keys_mapping[ $order_id ] = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refunds' . $order_id;
+ }
+
+ $non_cached_ids = array();
+ $cache_values = wc_cache_get_multiple( array_values( $cache_keys_mapping ), 'orders' );
+
+ if ( ! is_array( $cache_values ) ) {
+ $non_cached_ids = $order_ids;
+ } else {
+ foreach ( $order_ids as $order_id ) {
+ if ( false === $cache_values[ $cache_keys_mapping[ $order_id ] ] ) {
+ $non_cached_ids[] = $order_id;
+ }
+ }
+ }
+
+ if ( empty( $non_cached_ids ) ) {
+ return;
+ }
+
+ /**
+ * Fetch all refunds for the given order IDs.
+ *
+ * @var WC_Order_Refund[] $refunds
+ */
+ $refunds = wc_get_orders(
+ array(
+ 'type' => 'shop_order_refund',
+ 'post_parent__in' => $non_cached_ids,
+ 'limit' => -1,
+ )
+ );
+
+ $order_refunds = array();
+ foreach ( $refunds as $refund ) {
+ if ( $refund instanceof \WC_Order_Refund ) {
+ $order_refunds[ $refund->get_parent_id() ][] = $refund;
+ }
+ }
+
+ foreach ( $non_cached_ids as $order_id ) {
+ $cached_refunds = isset( $order_refunds[ $order_id ] ) ? $order_refunds[ $order_id ] : array();
+ wp_cache_set( $cache_keys_mapping[ $order_id ], $cached_refunds, 'orders' );
+ }
+ }
+
+ /**
+ * Prime the needs_processing transient cache for a batch of orders.
+ *
+ * WC_Order::needs_processing() calls get_transient() per order, which
+ * triggers an individual wp_options query each time. By priming the
+ * object cache for all transient option names in a single query, we
+ * eliminate the N+1.
+ *
+ * @param array $order_ids Order IDs to prime cache for.
+ * @param array $query_vars Query vars for the query.
+ * @return void
+ * @since 10.7.0
+ */
+ protected function prime_needs_processing_transients( $order_ids, $query_vars ) {
+ $option_names = array();
+ foreach ( $order_ids as $order_id ) {
+ $option_names[] = '_transient_wc_order_' . $order_id . '_needs_processing';
+ $option_names[] = '_transient_timeout_wc_order_' . $order_id . '_needs_processing';
+ }
+
+ wp_prime_option_caches( $option_names );
}
/**
@@ -819,28 +930,225 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
}
/**
- * Get the total shipping tax refunded.
+ * Returns a prepared SQL JOIN clause for finding refund orders belonging to a given parent order.
*
- * @param WC_Order $order Order object.
+ * The clause aliases the refund table as `refunds`. Subclasses should override this
+ * to use a different table (e.g. the HPOS orders table).
*
- * @since 10.2.0
- * @return float
+ * @since 10.7.0
+ * @param int $order_id Parent order ID.
+ * @return string Prepared SQL JOIN fragment.
*/
- public function get_total_shipping_tax_refunded( $order ) {
+ protected function get_refund_orders_join_clause( int $order_id ): string {
global $wpdb;
+ return $wpdb->prepare( '%i AS refunds ON ( refunds.post_type = %s AND refunds.post_parent = %d )', $wpdb->posts, 'shop_order_refund', $order_id );
+ }
+
+ /**
+ * Returns a prepared SQL JOIN clause for finding refund orders belonging to multiple parent orders.
+ *
+ * The clause aliases the refund table as `refunds`. Subclasses should override this
+ * to use a different table (e.g. the HPOS orders table).
+ *
+ * @since 10.7.0
+ * @param array $order_ids List of order IDs.
+ * @return string Prepared SQL JOIN fragment.
+ */
+ protected function get_refund_orders_batch_join_clause( array $order_ids ): string {
+ global $wpdb;
+ $id_list = implode( ', ', array_map( 'absint', $order_ids ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above.
+ return $wpdb->prepare( "%i AS refunds ON ( refunds.post_type = %s AND refunds.post_parent IN ( $id_list ) )", $wpdb->posts, 'shop_order_refund' );
+ }
+
+ /**
+ * Returns the column name on the refund table alias (`refunds`) that holds the parent order ID.
+ *
+ * @since 10.7.0
+ * @return string Column reference, e.g. 'refunds.post_parent'.
+ */
+ protected function get_refund_parent_column(): string {
+ return 'refunds.post_parent';
+ }
+
+ /**
+ * Query total refunded amounts per order in a batch. Returns an associative array
+ * of order_id => total (positive float).
+ *
+ * Subclasses should override this when the refund total is stored differently
+ * (e.g. HPOS stores it directly in the orders table rather than postmeta).
+ *
+ * @since 10.7.0
+ * @param array $order_ids List of order IDs.
+ * @return array<int, float> Map of order_id => refund total.
+ */
+ protected function get_batch_refund_totals( array $order_ids ): array {
+ global $wpdb;
+
+ $id_list = implode( ', ', array_map( 'absint', $order_ids ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above.
+ $refund_totals = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT posts.post_parent AS order_id, SUM( postmeta.meta_value ) AS total
+ FROM %i AS postmeta
+ INNER JOIN %i AS posts ON ( posts.post_type = 'shop_order_refund' AND posts.post_parent IN ( $id_list ) )
+ WHERE postmeta.meta_key = '_refund_amount'
+ AND postmeta.post_id = posts.ID
+ GROUP BY posts.post_parent",
+ $wpdb->postmeta,
+ $wpdb->posts
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $totals_by_order = array();
+ foreach ( $refund_totals as $row ) {
+ $totals_by_order[ $row->order_id ] = floatval( $row->total );
+ }
+
+ return $totals_by_order;
+ }
+
+ /**
+ * Get the summed refund item meta value for a given order, item type, and meta keys.
+ *
+ * @since 10.7.0
+ * @param WC_Order $order Order object.
+ * @param string $item_type Order item type (e.g. 'tax', 'shipping').
+ * @param array $meta_keys Meta keys to sum.
+ * @return float Absolute total.
+ */
+ protected function get_refunded_item_meta_total( $order, string $item_type, array $meta_keys ): float {
+ global $wpdb;
+
+ $refund_join = $this->get_refund_orders_join_clause( $order->get_id() );
+ $meta_placeholder = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
$total = $wpdb->get_var(
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $refund_join is already prepared.
+ // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $meta_keys is splatted.
$wpdb->prepare(
"SELECT SUM( order_itemmeta.meta_value )
- FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
- INNER JOIN $wpdb->posts AS posts ON ( posts.post_type = 'shop_order_refund' AND posts.post_parent = %d )
- INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = posts.ID AND order_items.order_item_type = 'tax' )
+ FROM %i AS order_itemmeta
+ INNER JOIN $refund_join
+ INNER JOIN %i AS order_items ON ( order_items.order_id = refunds.id AND order_items.order_item_type = %s )
WHERE order_itemmeta.order_item_id = order_items.order_item_id
- AND order_itemmeta.meta_key = 'shipping_tax_amount'",
- $order->get_id()
+ AND order_itemmeta.meta_key IN ( $meta_placeholder )",
+ $wpdb->prefix . 'woocommerce_order_itemmeta',
+ $wpdb->prefix . 'woocommerce_order_items',
+ $item_type,
+ ...$meta_keys,
)
+ // phpcs:enable
) ?? 0;
return abs( $total );
}
+
+ /**
+ * Get the total tax refunded.
+ *
+ * @param WC_Order $order Order object.
+ * @return float
+ */
+ public function get_total_tax_refunded( $order ) {
+ return $this->get_refunded_item_meta_total( $order, 'tax', array( 'tax_amount', 'shipping_tax_amount' ) );
+ }
+
+ /**
+ * Get the total shipping tax refunded.
+ *
+ * @param WC_Order $order Order object.
+ *
+ * @since 10.2.0
+ * @return float
+ */
+ public function get_total_shipping_tax_refunded( $order ) {
+ return $this->get_refunded_item_meta_total( $order, 'tax', array( 'shipping_tax_amount' ) );
+ }
+
+ /**
+ * Get the total shipping refunded.
+ *
+ * @param WC_Order $order Order object.
+ * @return float
+ */
+ public function get_total_shipping_refunded( $order ) {
+ return $this->get_refunded_item_meta_total( $order, 'shipping', array( 'cost' ) );
+ }
+
+ /**
+ * Prime the refund total and refund tax total caches for a batch of orders.
+ *
+ * @since 10.7.0
+ * @param array $order_ids Order IDs to prime cache for.
+ * @param array $query_vars Query vars for the query.
+ * @return void
+ */
+ protected function prime_refund_total_caches_for_orders( $order_ids, $query_vars ): void {
+ global $wpdb;
+
+ $cache_prefix = \WC_Cache_Helper::get_cache_prefix( 'orders' );
+
+ // Find which orders need priming (check both total_refunded and total_tax_refunded).
+ $total_keys = array();
+ $tax_keys = array();
+ $non_cached_ids = array();
+ foreach ( $order_ids as $order_id ) {
+ $total_keys[ $order_id ] = $cache_prefix . 'total_refunded' . $order_id;
+ $tax_keys[ $order_id ] = $cache_prefix . 'total_tax_refunded' . $order_id;
+ }
+
+ $all_keys = array_merge( array_values( $total_keys ), array_values( $tax_keys ) );
+ $cache_values = wc_cache_get_multiple( $all_keys, 'orders' );
+
+ if ( ! is_array( $cache_values ) ) {
+ $non_cached_ids = $order_ids;
+ } else {
+ foreach ( $order_ids as $order_id ) {
+ if ( false === $cache_values[ $total_keys[ $order_id ] ] || false === $cache_values[ $tax_keys[ $order_id ] ] ) {
+ $non_cached_ids[] = $order_id;
+ }
+ }
+ }
+
+ if ( empty( $non_cached_ids ) ) {
+ return;
+ }
+
+ // Batch query: total refunded per order.
+ $totals_by_order = $this->get_batch_refund_totals( $non_cached_ids );
+ foreach ( $non_cached_ids as $order_id ) {
+ wp_cache_set( $total_keys[ $order_id ], $totals_by_order[ $order_id ] ?? 0.0, 'orders' );
+ }
+
+ // Batch query: total tax refunded per order.
+ $refund_join = $this->get_refund_orders_batch_join_clause( $non_cached_ids );
+ $parent_col = $this->get_refund_parent_column();
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $refund_join is already prepared, $parent_col is hardcoded.
+ $tax_totals = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT $parent_col AS order_id, SUM( order_itemmeta.meta_value ) AS total
+ FROM %i AS order_itemmeta
+ INNER JOIN $refund_join
+ INNER JOIN %i AS order_items ON ( order_items.order_id = refunds.id AND order_items.order_item_type = 'tax' )
+ WHERE order_itemmeta.order_item_id = order_items.order_item_id
+ AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')
+ GROUP BY $parent_col",
+ $wpdb->prefix . 'woocommerce_order_itemmeta',
+ $wpdb->prefix . 'woocommerce_order_items'
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $tax_by_order = array();
+ foreach ( $tax_totals as $row ) {
+ $tax_by_order[ $row->order_id ] = abs( floatval( $row->total ) );
+ }
+ foreach ( $non_cached_ids as $order_id ) {
+ wp_cache_set( $tax_keys[ $order_id ], $tax_by_order[ $order_id ] ?? 0.0, 'orders' );
+ }
+ }
}
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
index dcec945fb2e..3060c096854 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-order-data-store-cpt.php
@@ -453,54 +453,6 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement
return floatval( $total );
}
- /**
- * Get the total tax refunded.
- *
- * @param WC_Order $order Order object.
- * @return float
- */
- public function get_total_tax_refunded( $order ) {
- global $wpdb;
-
- $total = $wpdb->get_var(
- $wpdb->prepare(
- "SELECT SUM( order_itemmeta.meta_value )
- FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
- INNER JOIN $wpdb->posts AS posts ON ( posts.post_type = 'shop_order_refund' AND posts.post_parent = %d )
- INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = posts.ID AND order_items.order_item_type = 'tax' )
- WHERE order_itemmeta.order_item_id = order_items.order_item_id
- AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')",
- $order->get_id()
- )
- ) ?? 0;
-
- return abs( $total );
- }
-
- /**
- * Get the total shipping refunded.
- *
- * @param WC_Order $order Order object.
- * @return float
- */
- public function get_total_shipping_refunded( $order ) {
- global $wpdb;
-
- $total = $wpdb->get_var(
- $wpdb->prepare(
- "SELECT SUM( order_itemmeta.meta_value )
- FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
- INNER JOIN $wpdb->posts AS posts ON ( posts.post_type = 'shop_order_refund' AND posts.post_parent = %d )
- INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = posts.ID AND order_items.order_item_type = 'shipping' )
- WHERE order_itemmeta.order_item_id = order_items.order_item_id
- AND order_itemmeta.meta_key IN ('cost')",
- $order->get_id()
- )
- ) ?? 0;
-
- return abs( $total );
- }
-
/**
* Finds an Order ID based on an order key.
*
@@ -1214,69 +1166,22 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement
*
* @param array $order_ids List of order IDS to prime caches for.
* @param array $query_vars Original query arguments.
+ * @return void
*/
public function prime_caches_for_orders( $order_ids, $query_vars ) {
- // Lets do some cache hydrations so that we don't have to fetch data from DB for every order.
$this->prime_raw_meta_cache_for_orders( $order_ids, $query_vars );
- $this->prime_refund_caches_for_order( $order_ids, $query_vars );
$this->prime_order_item_caches_for_orders( $order_ids, $query_vars );
- }
- /**
- * Prime refund cache for orders.
- *
- * @param array $order_ids Order Ids to prime cache for.
- * @param array $query_vars Query vars for the query.
- */
- private function prime_refund_caches_for_order( $order_ids, $query_vars ) {
- if ( ! isset( $query_vars['type'] ) || ! ( 'shop_order' === $query_vars['type'] ) ) {
- return;
- }
- if ( isset( $query_vars['fields'] ) && 'all' !== $query_vars['fields'] ) {
- if ( is_array( $query_vars['fields'] ) && ! in_array( 'refunds', $query_vars['fields'], true ) ) {
- return;
- }
- }
- $cache_keys_mapping = array();
- foreach ( $order_ids as $order_id ) {
- $cache_keys_mapping[ $order_id ] = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refunds' . $order_id;
- }
- $non_cached_ids = array();
- $cache_values = wc_cache_get_multiple( array_values( $cache_keys_mapping ), 'orders' );
- foreach ( $order_ids as $order_id ) {
- if ( false === $cache_values[ $cache_keys_mapping[ $order_id ] ] ) {
- $non_cached_ids[] = $order_id;
- }
- }
- if ( empty( $non_cached_ids ) ) {
+ // The following priming methods only apply to shop_order queries.
+ $order_type = $query_vars['type'] ?? $query_vars['post_type'] ?? '';
+ $order_type = is_array( $order_type ) ? $order_type : array( $order_type );
+ if ( ! in_array( 'shop_order', $order_type, true ) ) {
return;
}
- $refunds = wc_get_orders(
- array(
- 'type' => 'shop_order_refund',
- 'post_parent__in' => $non_cached_ids,
- 'limit' => - 1,
- )
- );
- $order_refunds = array_reduce(
- $refunds,
- function ( $order_refunds_array, WC_Order_Refund $refund ) {
- if ( ! isset( $order_refunds_array[ $refund->get_parent_id() ] ) ) {
- $order_refunds_array[ $refund->get_parent_id() ] = array();
- }
- $order_refunds_array[ $refund->get_parent_id() ][] = $refund;
- return $order_refunds_array;
- },
- array()
- );
- foreach ( $non_cached_ids as $order_id ) {
- $refunds = array();
- if ( isset( $order_refunds[ $order_id ] ) ) {
- $refunds = $order_refunds[ $order_id ];
- }
- wp_cache_set( $cache_keys_mapping[ $order_id ], $refunds, 'orders' );
- }
+ $this->prime_refund_caches_for_orders( $order_ids, $query_vars );
+ $this->prime_refund_total_caches_for_orders( $order_ids, $query_vars );
+ $this->prime_needs_processing_transients( $order_ids, $query_vars );
}
/**
@@ -1318,17 +1223,13 @@ class WC_Order_Data_Store_CPT extends Abstract_WC_Order_Data_Store_CPT implement
ORDER BY post_id"
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
- $raw_meta_data_collection = array_reduce(
- $raw_meta_data_array,
- function ( $collection, $raw_meta_data ) {
- if ( ! isset( $collection[ $raw_meta_data->object_id ] ) ) {
- $collection[ $raw_meta_data->object_id ] = array();
- }
- $collection[ $raw_meta_data->object_id ][] = $raw_meta_data;
- return $collection;
- },
- array()
- );
+ $raw_meta_data_collection = array();
+ foreach ( $raw_meta_data_array as $raw_meta_data ) {
+ if ( ! isset( $raw_meta_data_collection[ $raw_meta_data->object_id ] ) ) {
+ $raw_meta_data_collection[ $raw_meta_data->object_id ] = array();
+ }
+ $raw_meta_data_collection[ $raw_meta_data->object_id ][] = $raw_meta_data;
+ }
WC_Order::prime_raw_meta_data_cache( $raw_meta_data_collection, 'orders' );
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index eaa61ed32f1..047532137dc 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -17706,12 +17706,6 @@ parameters:
count: 1
path: includes/data-stores/class-wc-order-data-store-cpt.php
- -
- message: '#^Cannot access offset non\-falsy\-string on array\|bool\.$#'
- identifier: offsetAccess.nonOffsetAccessible
- count: 1
- path: includes/data-stores/class-wc-order-data-store-cpt.php
-
-
message: '#^Cannot access offset string on array\|bool\.$#'
identifier: offsetAccess.nonOffsetAccessible
@@ -17760,24 +17754,12 @@ parameters:
count: 1
path: includes/data-stores/class-wc-order-data-store-cpt.php
- -
- message: '#^Method WC_Order_Data_Store_CPT\:\:prime_caches_for_orders\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/data-stores/class-wc-order-data-store-cpt.php
-
-
message: '#^Method WC_Order_Data_Store_CPT\:\:prime_raw_meta_cache_for_orders\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: includes/data-stores/class-wc-order-data-store-cpt.php
- -
- message: '#^Method WC_Order_Data_Store_CPT\:\:prime_refund_caches_for_order\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/data-stores/class-wc-order-data-store-cpt.php
-
-
message: '#^Method WC_Order_Data_Store_CPT\:\:read_cogs_data\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -17856,12 +17838,6 @@ parameters:
count: 1
path: includes/data-stores/class-wc-order-data-store-cpt.php
- -
- message: '#^Parameter \#1 \$input of function array_reduce expects array\<WC_Order\>, array\<WC_Order\>\|stdClass given\.$#'
- identifier: argument.type
- count: 1
- path: includes/data-stores/class-wc-order-data-store-cpt.php
-
-
message: '#^Parameter \#1 \$post_id of function get_post_meta expects int, bool\|int given\.$#'
identifier: argument.type
@@ -17892,12 +17868,6 @@ parameters:
count: 1
path: includes/data-stores/class-wc-order-data-store-cpt.php
- -
- message: '#^Parameter \#2 \$callback of function array_reduce expects callable\(mixed, WC_Order\)\: mixed, Closure\(mixed, WC_Order_Refund\)\: mixed given\.$#'
- identifier: argument.type
- count: 1
- path: includes/data-stores/class-wc-order-data-store-cpt.php
-
-
message: '#^Parameter \#3 \$index_key of function wp_list_pluck expects int\|string\|null, true given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index f8bf781377f..5da09df805d 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -1039,90 +1039,17 @@ WHERE
}
/**
- * Get the total tax refunded.
+ * Returns a prepared SQL JOIN clause for finding refund orders belonging to a given parent order.
*
- * @param WC_Order $order Order object.
- *
- * @return float
- */
- public function get_total_tax_refunded( $order ) {
- global $wpdb;
-
- $order_table = self::get_orders_table_name();
-
- $total = $wpdb->get_var(
- // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
- $wpdb->prepare(
- "SELECT SUM( order_itemmeta.meta_value )
- FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
- INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
- INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
- WHERE order_itemmeta.order_item_id = order_items.order_item_id
- AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')",
- $order->get_id(),
- )
- ) ?? 0;
- // phpcs:enable
-
- return abs( $total );
- }
-
- /**
- * Get the total shipping tax refunded.
+ * Overrides the CPT version to use the HPOS orders table.
*
- * @param WC_Order $order Order object.
- *
- * @since 10.2.0
- * @return float
+ * @since 10.7.0
+ * @param int $order_id Parent order ID.
+ * @return string Prepared SQL JOIN fragment.
*/
- public function get_total_shipping_tax_refunded( $order ) {
+ protected function get_refund_orders_join_clause( int $order_id ): string {
global $wpdb;
-
- $order_table = self::get_orders_table_name();
-
- $total = $wpdb->get_var(
- // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
- $wpdb->prepare(
- "SELECT SUM( order_itemmeta.meta_value )
- FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
- INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
- INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
- WHERE order_itemmeta.order_item_id = order_items.order_item_id
- AND order_itemmeta.meta_key = 'shipping_tax_amount'",
- $order->get_id()
- )
- ) ?? 0;
- // phpcs:enable
-
- return abs( $total );
- }
-
- /**
- * Get the total shipping refunded.
- *
- * @param WC_Order $order Order object.
- * @return float
- */
- public function get_total_shipping_refunded( $order ) {
- global $wpdb;
-
- $order_table = self::get_orders_table_name();
-
- $total = $wpdb->get_var(
- // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
- $wpdb->prepare(
- "SELECT SUM( order_itemmeta.meta_value )
- FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
- INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
- INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'shipping' )
- WHERE order_itemmeta.order_item_id = order_items.order_item_id
- AND order_itemmeta.meta_key IN ('cost')",
- $order->get_id()
- )
- ) ?? 0;
- // phpcs:enable
-
- return abs( $total );
+ return $wpdb->prepare( '%i AS refunds ON ( refunds.type = %s AND refunds.parent_order_id = %d )', self::get_orders_table_name(), 'shop_order_refund', $order_id );
}
/**
@@ -3188,6 +3115,7 @@ FROM $order_meta_table
$orders = $query->orders;
} else {
$orders = WC()->order_factory->get_orders( $query->orders );
+ $this->prime_caches_for_orders( $query->orders, $query_vars );
}
if ( isset( $query_vars['paginate'] ) && $query_vars['paginate'] ) {
@@ -3203,6 +3131,89 @@ FROM $order_meta_table
//phpcs:enable Squiz.Commenting, Generic.Commenting
+ /**
+ * Prime caches for a collection of orders. Reduces N+1 queries when iterating over order results.
+ *
+ * @param array $order_ids List of order IDs to prime caches for.
+ * @param array $query_vars Original query arguments.
+ * @return void
+ */
+ public function prime_caches_for_orders( array $order_ids, array $query_vars ): void {
+ $this->prime_order_item_caches_for_orders( $order_ids, $query_vars );
+
+ // The following priming methods only apply to shop_order queries.
+ $order_type = $query_vars['type'] ?? $query_vars['post_type'] ?? '';
+ $order_type = is_array( $order_type ) ? $order_type : array( $order_type );
+ if ( ! in_array( 'shop_order', $order_type, true ) ) {
+ return;
+ }
+
+ $this->prime_refund_caches_for_orders( $order_ids, $query_vars );
+ $this->prime_refund_total_caches_for_orders( $order_ids, $query_vars );
+ $this->prime_needs_processing_transients( $order_ids, $query_vars );
+ }
+
+ /**
+ * Returns a prepared SQL JOIN clause for finding refund orders belonging to multiple parent orders.
+ *
+ * Overrides the CPT version to use the HPOS orders table.
+ *
+ * @since 10.7.0
+ * @param array $order_ids List of order IDs.
+ * @return string Prepared SQL JOIN fragment.
+ */
+ protected function get_refund_orders_batch_join_clause( array $order_ids ): string {
+ global $wpdb;
+ $id_list = implode( ', ', array_map( 'absint', $order_ids ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above.
+ return $wpdb->prepare( "%i AS refunds ON ( refunds.type = %s AND refunds.parent_order_id IN ( $id_list ) )", self::get_orders_table_name(), 'shop_order_refund' );
+ }
+
+ /**
+ * Returns the column name on the refund table alias (`refunds`) that holds the parent order ID.
+ *
+ * @since 10.7.0
+ * @return string Column reference.
+ */
+ protected function get_refund_parent_column(): string {
+ return 'refunds.parent_order_id';
+ }
+
+ /**
+ * Query total refunded amounts per order in a batch.
+ *
+ * Overrides the CPT version to read directly from the HPOS orders table
+ * rather than joining postmeta.
+ *
+ * @since 10.7.0
+ * @param array $order_ids List of order IDs.
+ * @return array<int, float> Map of order_id => refund total.
+ */
+ protected function get_batch_refund_totals( array $order_ids ): array {
+ global $wpdb;
+
+ $id_list = implode( ', ', array_map( 'absint', $order_ids ) );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above.
+ $refund_totals = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT parent_order_id AS order_id, SUM( total_amount ) AS total
+ FROM %i
+ WHERE type = 'shop_order_refund' AND parent_order_id IN ( $id_list )
+ GROUP BY parent_order_id",
+ self::get_orders_table_name()
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $totals_by_order = array();
+ foreach ( $refund_totals as $row ) {
+ $totals_by_order[ $row->order_id ] = -1 * floatval( $row->total );
+ }
+
+ return $totals_by_order;
+ }
+
/**
* Get the SQL needed to create all the tables needed for the custom orders table feature.
*
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Controller.php
index 127fa72ab91..e05b1503d89 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Controller.php
@@ -319,7 +319,6 @@ class Controller extends AbstractController {
}
$product_ids = array_unique( array_filter( $product_ids ) );
if ( ! empty( $product_ids ) ) {
- // Prime caches to reduce future queries.
_prime_post_caches( $product_ids, true, true );
}
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
index ac1b01c693c..cbbea52799a 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
@@ -1257,4 +1257,112 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$expected_final_cogs = $expected_order_cogs + $expected_refund_cogs;
$this->assertEquals( $expected_final_cogs, $order->get_cogs_total_value(), 'Order COGS should be reduced by refund amount' );
}
+
+ /**
+ * @testdox CPT cache priming populates refund total and tax caches with correct values.
+ */
+ public function test_prime_caches_for_orders_primes_refund_totals(): void {
+ update_option( 'woocommerce_prices_include_tax', 'yes' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate' => '20',
+ 'tax_rate_name' => 'tax',
+ 'tax_rate_order' => '1',
+ 'tax_rate_shipping' => '1',
+ )
+ );
+
+ $rate = new WC_Shipping_Rate( 'flat_rate_shipping', 'Flat rate shipping', '10', array(), 'flat_rate' );
+ $item = new WC_Order_Item_Shipping();
+ $item->set_props(
+ array(
+ 'method_title' => $rate->label,
+ 'method_id' => $rate->id,
+ 'total' => wc_format_decimal( $rate->cost ),
+ 'taxes' => $rate->taxes,
+ )
+ );
+
+ $order = WC_Helper_Order::create_order();
+ $order->add_item( $item );
+ $order->calculate_totals();
+ $order->save();
+
+ $product_item_id = current( $order->get_items() )->get_id();
+ $shipping_item_id = current( $order->get_items( 'shipping' ) )->get_id();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ $product_item_id => array(
+ 'id' => $product_item_id,
+ 'qty' => 1,
+ 'refund_total' => 10,
+ 'refund_tax' => array( 1 => 2 ),
+ ),
+ $shipping_item_id => array(
+ 'id' => $shipping_item_id,
+ 'qty' => 1,
+ 'refund_total' => 10,
+ 'refund_tax' => array( 1 => 3 ),
+ ),
+ ),
+ )
+ );
+
+ wp_cache_flush();
+ WC_Cache_Helper::invalidate_cache_group( 'orders' );
+
+ $data_store = WC_Data_Store::load( 'order' );
+ $data_store->prime_caches_for_orders(
+ array( $order->get_id() ),
+ array(
+ 'fields' => 'all',
+ 'post_type' => 'shop_order',
+ )
+ );
+
+ $cache_prefix = WC_Cache_Helper::get_cache_prefix( 'orders' );
+ $order_id = $order->get_id();
+
+ $cached_total_refunded = wp_cache_get( $cache_prefix . 'total_refunded' . $order_id, 'orders' );
+ $cached_tax_refunded = wp_cache_get( $cache_prefix . 'total_tax_refunded' . $order_id, 'orders' );
+
+ $this->assertNotFalse( $cached_total_refunded, 'Total refunded should be cached after priming' );
+ $this->assertNotFalse( $cached_tax_refunded, 'Total tax refunded should be cached after priming' );
+ $this->assertIsFloat( $cached_total_refunded, 'Cached total refunded should be a float' );
+ $this->assertEquals( 5.0, $cached_tax_refunded, 'Cached tax refunded should equal sum of product tax (2) + shipping tax (3)' );
+ }
+
+ /**
+ * @testdox CPT cache priming populates order item meta caches so item access does not trigger additional queries.
+ */
+ public function test_prime_caches_for_orders_primes_item_meta(): void {
+ $order = WC_Helper_Order::create_order();
+
+ wp_cache_flush();
+ WC_Cache_Helper::invalidate_cache_group( 'orders' );
+
+ $data_store = WC_Data_Store::load( 'order' );
+ $data_store->prime_caches_for_orders(
+ array( $order->get_id() ),
+ array(
+ 'fields' => 'all',
+ 'post_type' => 'shop_order',
+ )
+ );
+
+ $reloaded_order = wc_get_order( $order->get_id() );
+ $items = $reloaded_order->get_items();
+
+ $this->assertNotEmpty( $items, 'Order should have line items' );
+
+ foreach ( $items as $item ) {
+ $this->assertGreaterThan( 0, $item->get_product_id(), 'Item should have a product ID from cached meta' );
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCachePrimingTest.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCachePrimingTest.php
new file mode 100644
index 00000000000..660502f2eed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreCachePrimingTest.php
@@ -0,0 +1,178 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\DataStores\Orders;
+
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use WC_Helper_Product;
+use WC_Order;
+use WC_Order_Item_Shipping;
+use WC_Shipping_Rate;
+use WC_Tax;
+
+/**
+ * Tests for cache priming in OrdersTableDataStore.
+ */
+class OrdersTableDataStoreCachePrimingTest extends \HposTestCase {
+ use HPOSToggleTrait;
+
+ /**
+ * The System Under Test.
+ *
+ * @var OrdersTableDataStore
+ */
+ private $sut;
+
+ /**
+ * Whether COT was enabled before the test.
+ *
+ * @var bool
+ */
+ private $cot_state;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+
+ $this->setup_cot();
+ $this->cot_state = OrderUtil::custom_orders_table_usage_is_enabled();
+ $this->toggle_cot_feature_and_usage( true );
+
+ $container = wc_get_container();
+ $container->reset_all_resolved();
+ $this->sut = $container->get( OrdersTableDataStore::class );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $this->toggle_cot_feature_and_usage( $this->cot_state );
+ $this->clean_up_cot_setup();
+
+ remove_all_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending' );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Cache priming populates refund total and tax caches with correct values.
+ */
+ public function test_prime_caches_for_orders_primes_refund_totals(): void {
+ update_option( 'woocommerce_prices_include_tax', 'yes' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate' => '20',
+ 'tax_rate_name' => 'tax',
+ 'tax_rate_order' => '1',
+ 'tax_rate_shipping' => '1',
+ )
+ );
+
+ $rate = new WC_Shipping_Rate( 'flat_rate_shipping', 'Flat rate shipping', '10', array(), 'flat_rate' );
+ $item = new WC_Order_Item_Shipping();
+ $item->set_props(
+ array(
+ 'method_title' => $rate->label,
+ 'method_id' => $rate->id,
+ 'total' => wc_format_decimal( $rate->cost ),
+ 'taxes' => $rate->taxes,
+ )
+ );
+
+ $order = new WC_Order();
+ $order->save();
+ $order->add_product( WC_Helper_Product::create_simple_product(), 10 );
+ $order->add_item( $item );
+ $order->calculate_totals();
+ $order->save();
+
+ $product_item_id = current( $order->get_items() )->get_id();
+ $shipping_item_id = current( $order->get_items( 'shipping' ) )->get_id();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ $product_item_id => array(
+ 'id' => $product_item_id,
+ 'qty' => 1,
+ 'refund_total' => 10,
+ 'refund_tax' => array( 1 => 2 ),
+ ),
+ $shipping_item_id => array(
+ 'id' => $shipping_item_id,
+ 'qty' => 1,
+ 'refund_total' => 10,
+ 'refund_tax' => array( 1 => 3 ),
+ ),
+ ),
+ )
+ );
+
+ // Clear all caches so prime_caches_for_orders has to do real work.
+ wp_cache_flush();
+ \WC_Cache_Helper::invalidate_cache_group( 'orders' );
+
+ $this->sut->prime_caches_for_orders(
+ array( $order->get_id() ),
+ array(
+ 'fields' => 'all',
+ 'type' => 'shop_order',
+ )
+ );
+
+ // Verify refund total caches were primed with correct values.
+ $cache_prefix = \WC_Cache_Helper::get_cache_prefix( 'orders' );
+ $order_id = $order->get_id();
+
+ $cached_total_refunded = wp_cache_get( $cache_prefix . 'total_refunded' . $order_id, 'orders' );
+ $cached_tax_refunded = wp_cache_get( $cache_prefix . 'total_tax_refunded' . $order_id, 'orders' );
+
+ $this->assertNotFalse( $cached_total_refunded, 'Total refunded should be cached after priming' );
+ $this->assertNotFalse( $cached_tax_refunded, 'Total tax refunded should be cached after priming' );
+ $this->assertIsFloat( $cached_total_refunded, 'Cached total refunded should be a float' );
+ $this->assertEquals( 5.0, $cached_tax_refunded, 'Cached tax refunded should equal sum of product tax (2) + shipping tax (3)' );
+ }
+
+ /**
+ * @testdox Cache priming populates order item meta caches so item access does not trigger additional queries.
+ */
+ public function test_prime_caches_for_orders_primes_item_meta(): void {
+ $order = new WC_Order();
+ $order->save();
+ $order->add_product( WC_Helper_Product::create_simple_product(), 2 );
+ $order->calculate_totals();
+ $order->save();
+
+ // Clear all caches.
+ wp_cache_flush();
+ \WC_Cache_Helper::invalidate_cache_group( 'orders' );
+
+ $this->sut->prime_caches_for_orders(
+ array( $order->get_id() ),
+ array(
+ 'fields' => 'all',
+ 'type' => 'shop_order',
+ )
+ );
+
+ // Reload order and access items — meta should already be cached.
+ $reloaded_order = wc_get_order( $order->get_id() );
+ $items = $reloaded_order->get_items();
+
+ $this->assertNotEmpty( $items, 'Order should have line items' );
+
+ foreach ( $items as $item ) {
+ $this->assertGreaterThan( 0, $item->get_product_id(), 'Item should have a product ID from cached meta' );
+ }
+ }
+}