Commit 81dce004291 for woocommerce

commit 81dce004291826de892844167c872d300bcc2f6a
Author: MILLER/F <fab@millerf.com>
Date:   Tue Aug 11 09:42:12 2026 +0200

    fix: interpolate order table names instead of %i placeholder (#66374)

    * fix: interpolate order table names instead of %i placeholder

    Order data stores (HPOS + legacy CPT), the orders list table, the customer
    history meta box, and the V4 orders REST collection query used the %i
    identifier placeholder. A $wpdb drop-in running on a supported WordPress
    version may not implement %i (its has_cap( 'identifier_placeholders' )
    returns false), silently producing malformed queries. These table names are
    trusted developer-provided values, so interpolate them directly instead.

    Supersedes #66262.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * Prevent assigning unecessary variables

    * Fix phpcs alignment warning in get_batch_refund_totals()

    ---------

    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Co-authored-by: Tom Cafferkey <tjcafferkey@gmail.com>

diff --git a/plugins/woocommerce/changelog/fix-identifier-placeholders-orders b/plugins/woocommerce/changelog/fix-identifier-placeholders-orders
new file mode 100644
index 00000000000..2940662e3ac
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-identifier-placeholders-orders
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Interpolate trusted order table names directly instead of using the `%i` placeholder in the order data stores, list table, customer history meta box, and orders REST query, so these queries stay valid on database layers that run on a supported WordPress version but don't implement `%i`.
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 4d0770c2caa..10e30fda862 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
@@ -976,7 +976,8 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 	 */
 	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 );
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+		return $wpdb->prepare( "{$wpdb->posts} AS refunds ON ( refunds.post_type = %s AND refunds.post_parent = %d )", 'shop_order_refund', $order_id );
 	}

 	/**
@@ -992,8 +993,8 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 	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' );
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above; trusted table name.
+		return $wpdb->prepare( "{$wpdb->posts} AS refunds ON ( refunds.post_type = %s AND refunds.post_parent IN ( $id_list ) )", 'shop_order_refund' );
 	}

 	/**
@@ -1022,18 +1023,14 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme

 		$id_list = implode( ', ', array_map( 'absint', $order_ids ) );

-		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above.
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above; trusted table names.
 		$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 ) )
+			"SELECT posts.post_parent AS order_id, SUM( postmeta.meta_value ) AS total
+				FROM {$wpdb->postmeta} AS postmeta
+				INNER JOIN {$wpdb->posts} 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
-			)
+				GROUP BY posts.post_parent"
 		);
 		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

@@ -1059,19 +1056,19 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme

 		$refund_join      = $this->get_refund_orders_join_clause( $order->get_id() );
 		$meta_placeholder = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
+		$order_itemmeta   = $wpdb->prefix . 'woocommerce_order_itemmeta';
+		$order_items      = $wpdb->prefix . 'woocommerce_order_items';

 		$total = $wpdb->get_var(
-			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $refund_join is already prepared.
+			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $refund_join is already prepared; trusted table names.
 			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $meta_keys is splatted.
 			$wpdb->prepare(
 				"SELECT SUM( order_itemmeta.meta_value )
-				FROM %i AS order_itemmeta
+				FROM {$order_itemmeta} 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 )
+				INNER JOIN {$order_items} 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 IN ( $meta_placeholder )",
-				$wpdb->prefix . 'woocommerce_order_itemmeta',
-				$wpdb->prefix . 'woocommerce_order_items',
 				$item_type,
 				...$meta_keys,
 			)
@@ -1159,22 +1156,20 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 		}

 		// 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();
+		$refund_join    = $this->get_refund_orders_batch_join_clause( $non_cached_ids );
+		$parent_col     = $this->get_refund_parent_column();
+		$order_itemmeta = $wpdb->prefix . 'woocommerce_order_itemmeta';
+		$order_items    = $wpdb->prefix . 'woocommerce_order_items';

-		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $refund_join is already prepared, $parent_col is hardcoded.
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $refund_join is already prepared, $parent_col is hardcoded, trusted table names.
 		$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
+			"SELECT $parent_col AS order_id, SUM( order_itemmeta.meta_value ) AS total
+				FROM {$order_itemmeta} 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' )
+				INNER JOIN {$order_items} 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'
-			)
+				GROUP BY $parent_col"
 		);
 		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
index c8c6cb4370d..7218ce51511 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
@@ -876,21 +876,21 @@ class ListTable extends WP_List_Table {
 	protected function get_months_filter_options(): array {
 		global $wpdb;

-		$table_name     = OrdersTableDataStore::get_orders_table_name();
+		$table_name = OrdersTableDataStore::get_orders_table_name();
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
 		$min_max_months = $wpdb->get_row(
 			$wpdb->prepare(
 				"SELECT MIN(date_created_gmt) as min_date_gmt, MAX(date_created_gmt) as max_date_gmt
 				 FROM (
-					( SELECT date_created_gmt FROM %i WHERE type = %s AND status != 'trash' ORDER BY date_created_gmt DESC LIMIT 1 )
+					( SELECT date_created_gmt FROM {$table_name} WHERE type = %s AND status != 'trash' ORDER BY date_created_gmt DESC LIMIT 1 )
 					UNION ALL
-					( SELECT date_created_gmt FROM %i WHERE type = %s AND status != 'trash' ORDER BY date_created_gmt ASC LIMIT 1 )
+					( SELECT date_created_gmt FROM {$table_name} WHERE type = %s AND status != 'trash' ORDER BY date_created_gmt ASC LIMIT 1 )
 				 ) d",
-				$table_name,
 				$this->order_type,
-				$table_name,
 				$this->order_type
 			)
 		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

 		/**
 		 * Normalize "this month" to be the first day of the month in the current timezone of the site.
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
index d6020bb3ba0..a22a2651ee6 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
@@ -124,7 +124,7 @@ class CustomerHistory {

 		$sql = null;

-		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- status filters are built from hardcoded fragments; trusted table names.
 		if ( $customer_id > 0 ) {
 			$status_filter    = $excluded_statuses_sql ? "AND status NOT IN $excluded_statuses_sql" : '';
 			$co_status_filter = $excluded_statuses_sql ? "AND co.status NOT IN $excluded_statuses_sql" : '';
@@ -134,21 +134,18 @@ class CustomerHistory {
 					COALESCE( SUM( filtered.total_amount ), 0 ) + COALESCE( SUM( r.refund_total ), 0 ) AS total_spend
 				FROM (
 					SELECT id, total_amount
-					FROM %i
+					FROM {$orders_table}
 					WHERE customer_id = %d AND type = 'shop_order' $status_filter
 				) AS filtered
 				LEFT JOIN (
 					SELECT rp.parent_order_id, SUM( rp.total_amount ) AS refund_total
-					FROM %i AS rp
-					INNER JOIN %i AS co ON rp.parent_order_id = co.id
+					FROM {$orders_table} AS rp
+					INNER JOIN {$orders_table} AS co ON rp.parent_order_id = co.id
 					WHERE rp.type = 'shop_order_refund'
 						AND co.customer_id = %d AND co.type = 'shop_order' $co_status_filter
 					GROUP BY rp.parent_order_id
 				) AS r ON filtered.id = r.parent_order_id",
-				$orders_table,
 				$customer_id,
-				$orders_table,
-				$orders_table,
 				$customer_id
 			);
 		} elseif ( '' !== $billing_email ) {
@@ -161,25 +158,20 @@ class CustomerHistory {
 					COALESCE( SUM( filtered.total_amount ), 0 ) + COALESCE( SUM( r.refund_total ), 0 ) AS total_spend
 				FROM (
 					SELECT o.id, o.total_amount
-					FROM %i AS o
-					INNER JOIN %i AS a ON o.id = a.order_id AND a.address_type = 'billing'
+					FROM {$orders_table} AS o
+					INNER JOIN {$addresses_table} AS a ON o.id = a.order_id AND a.address_type = 'billing'
 					WHERE o.customer_id = 0 AND a.email = %s AND o.type = 'shop_order' $o_status_filter
 				) AS filtered
 				LEFT JOIN (
 					SELECT rp.parent_order_id, SUM( rp.total_amount ) AS refund_total
-					FROM %i AS rp
-					INNER JOIN %i AS co ON rp.parent_order_id = co.id
-					INNER JOIN %i AS ca ON co.id = ca.order_id AND ca.address_type = 'billing'
+					FROM {$orders_table} AS rp
+					INNER JOIN {$orders_table} AS co ON rp.parent_order_id = co.id
+					INNER JOIN {$addresses_table} AS ca ON co.id = ca.order_id AND ca.address_type = 'billing'
 					WHERE rp.type = 'shop_order_refund'
 						AND co.customer_id = 0 AND ca.email = %s AND co.type = 'shop_order' $co_status_filter
 					GROUP BY rp.parent_order_id
 				) AS r ON filtered.id = r.parent_order_id",
-				$orders_table,
-				$addresses_table,
 				$billing_email,
-				$orders_table,
-				$orders_table,
-				$addresses_table,
 				$billing_email
 			);
 		}
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index 5be6a1596c8..4b8c7c3778a 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -1121,7 +1121,9 @@ WHERE
 	 */
 	protected function get_refund_orders_join_clause( int $order_id ): string {
 		global $wpdb;
-		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 );
+		$orders_table = self::get_orders_table_name();
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+		return $wpdb->prepare( "{$orders_table} AS refunds ON ( refunds.type = %s AND refunds.parent_order_id = %d )", 'shop_order_refund', $order_id );
 	}

 	/**
@@ -3389,9 +3391,10 @@ FROM $order_meta_table
 	 */
 	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' );
+		$id_list      = implode( ', ', array_map( 'absint', $order_ids ) );
+		$orders_table = self::get_orders_table_name();
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above; trusted table name.
+		return $wpdb->prepare( "{$orders_table} AS refunds ON ( refunds.type = %s AND refunds.parent_order_id IN ( $id_list ) )", 'shop_order_refund' );
 	}

 	/**
@@ -3417,17 +3420,15 @@ FROM $order_meta_table
 	protected function get_batch_refund_totals( array $order_ids ): array {
 		global $wpdb;

-		$id_list = implode( ', ', array_map( 'absint', $order_ids ) );
+		$id_list      = implode( ', ', array_map( 'absint', $order_ids ) );
+		$orders_table = self::get_orders_table_name();

-		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above.
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $id_list is sanitized via absint above; trusted table name.
 		$refund_totals = $wpdb->get_results(
-			$wpdb->prepare(
-				"SELECT parent_order_id AS order_id, SUM( total_amount ) AS total
-				FROM %i
+			"SELECT parent_order_id AS order_id, SUM( total_amount ) AS total
+				FROM {$orders_table}
 				WHERE type = 'shop_order_refund' AND parent_order_id IN ( $id_list )
-				GROUP BY parent_order_id",
-				self::get_orders_table_name()
-			)
+				GROUP BY parent_order_id"
 		);
 		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/CollectionQuery.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/CollectionQuery.php
index 387b62b0279..246379af319 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/CollectionQuery.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/CollectionQuery.php
@@ -243,11 +243,12 @@ class CollectionQuery extends AbstractCollectionQuery {
 		if ( ! empty( $request['product'] ) ) {
 			global $wpdb;

-			$order_ids = $wpdb->get_col(
+			$order_items    = $wpdb->prefix . 'woocommerce_order_items';
+			$order_itemmeta = $wpdb->prefix . 'woocommerce_order_itemmeta';
+			$order_ids      = $wpdb->get_col(
 				$wpdb->prepare(
-					"SELECT order_id FROM %i WHERE order_item_id IN ( SELECT order_item_id FROM %i WHERE meta_key = '_product_id' AND meta_value = %d ) AND order_item_type = %s",
-					$wpdb->prefix . 'woocommerce_order_items',
-					$wpdb->prefix . 'woocommerce_order_itemmeta',
+					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table names.
+					"SELECT order_id FROM {$order_items} WHERE order_item_id IN ( SELECT order_item_id FROM {$order_itemmeta} WHERE meta_key = '_product_id' AND meta_value = %d ) AND order_item_type = %s",
 					$request['product'],
 					OrderItemType::LINE_ITEM
 				)
diff --git a/plugins/woocommerce/src/Utilities/OrderUtil.php b/plugins/woocommerce/src/Utilities/OrderUtil.php
index dbe798c24e8..9bd71b05feb 100644
--- a/plugins/woocommerce/src/Utilities/OrderUtil.php
+++ b/plugins/woocommerce/src/Utilities/OrderUtil.php
@@ -231,10 +231,11 @@ final class OrderUtil {

 		if ( null === $count_per_status ) {
 			if ( self::custom_orders_table_usage_is_enabled() ) {
-				$results          = $wpdb->get_results(
+				$orders_table = self::get_table_for_orders();
+				$results      = $wpdb->get_results(
 					$wpdb->prepare(
-						'SELECT status, COUNT(*) AS count FROM %i WHERE type = %s GROUP BY status',
-						self::get_table_for_orders(),
+						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted table name.
+						"SELECT status, COUNT(*) AS count FROM {$orders_table} WHERE type = %s GROUP BY status",
 						$order_type
 					),
 					ARRAY_A