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