Commit 3683411989f for woocommerce

commit 3683411989fd9007263951ec3cc75e57ababd3bf
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Oct 30 15:43:55 2026 +0000

    Fix replaced order items not saving on SQLite sites (#69986)

    * Fix order item meta deletes failing on the SQLite database integration

    * Add changelog entry for SQLite order item meta delete fix

diff --git a/plugins/woocommerce/changelog/fix-sqlite-order-item-meta-delete b/plugins/woocommerce/changelog/fix-sqlite-order-item-meta-delete
new file mode 100644
index 00000000000..10ad4234da1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-sqlite-order-item-meta-delete
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix replaced order items not being saved on sites using the SQLite database integration, such as WordPress Studio and Playground.
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 fe7296e2b50..8dde869ba80 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
@@ -874,14 +874,27 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme

 		$ids_placeholders = implode( ', ', array_fill( 0, count( $sanitized_ids ), '%d' ) );

-		$result = $wpdb->query(
+		// Only delete meta of items that belong to this order.
+		$owned_ids = $wpdb->get_col(
 			$wpdb->prepare(
 				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $ids_placeholders is generated above.
-				"DELETE itemmeta FROM {$wpdb->prefix}woocommerce_order_itemmeta as itemmeta INNER JOIN {$wpdb->prefix}woocommerce_order_items as items WHERE itemmeta.order_item_id = items.order_item_id AND items.order_id = %d AND items.order_item_id IN ($ids_placeholders)",
+				"SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_id IN ($ids_placeholders)",
 				array_merge( array( $order->get_id() ), $sanitized_ids )
 			)
 		);
-		if ( false === $result ) {
+		if ( '' !== $wpdb->last_error ) {
+			wc_get_logger()->error(
+				'Failed to read order item IDs for deletion.',
+				array(
+					'source'   => 'order-data-store',
+					'order_id' => $order->get_id(),
+					'error'    => $wpdb->last_error,
+				)
+			);
+			throw new Exception( esc_html__( 'Unable to delete order item metadata.', 'woocommerce' ) );
+		}
+
+		if ( false === $this->delete_order_item_meta( $owned_ids ) ) {
 			wc_get_logger()->error(
 				'Failed to delete order item metadata.',
 				array(
@@ -934,16 +947,43 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 		}

 		if ( ! empty( $type ) ) {
-			$wpdb->query( $wpdb->prepare( "DELETE itemmeta FROM {$wpdb->prefix}woocommerce_order_itemmeta as itemmeta INNER JOIN {$wpdb->prefix}woocommerce_order_items as items WHERE itemmeta.order_item_id = items.order_item_id AND items.order_id = %d AND items.order_item_type = %s", $order_id, $type ) );
+			$this->delete_order_item_meta( $wpdb->get_col( $wpdb->prepare( "SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_type = %s", $order_id, $type ) ) );
 			$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_type = %s", $order_id, $type ) );
 		} else {
-			$wpdb->query( $wpdb->prepare( "DELETE itemmeta FROM {$wpdb->prefix}woocommerce_order_itemmeta as itemmeta INNER JOIN {$wpdb->prefix}woocommerce_order_items as items WHERE itemmeta.order_item_id = items.order_item_id and items.order_id = %d", $order_id ) );
+			$this->delete_order_item_meta( $wpdb->get_col( $wpdb->prepare( "SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d", $order_id ) ) );
 			$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d", $order_id ) );
 		}

 		$this->clear_caches( $order );
 	}

+	/**
+	 * Delete the metadata of the given order items.
+	 *
+	 * Uses a single-table DELETE because the SQLite database integration used by Studio and Playground can't run a multi-table one.
+	 *
+	 * @param int[] $item_ids Order item IDs.
+	 * @return int|bool Number of deleted rows, true when there was nothing to delete, or false on failure.
+	 */
+	private function delete_order_item_meta( array $item_ids ) {
+		global $wpdb;
+
+		$item_ids = array_map( 'intval', $item_ids );
+		if ( empty( $item_ids ) ) {
+			return true;
+		}
+
+		$placeholders = implode( ', ', array_fill( 0, count( $item_ids ), '%d' ) );
+
+		return $wpdb->query(
+			$wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is generated above.
+				"DELETE FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE order_item_id IN ($placeholders)",
+				$item_ids
+			)
+		);
+	}
+
 	/**
 	 * Get token ids for an order.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index 9d6991f68b6..338e9061481 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -1805,7 +1805,8 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 	 */
 	public static function provide_failed_order_item_delete_queries(): array {
 		return array(
-			'item metadata delete' => array( 'DELETE itemmeta FROM' ),
+			'item ID lookup'       => array( 'SELECT order_item_id FROM {prefix}woocommerce_order_items WHERE order_id' ),
+			'item metadata delete' => array( 'DELETE FROM {prefix}woocommerce_order_itemmeta' ),
 			'item row delete'      => array( 'DELETE FROM {prefix}woocommerce_order_items' ),
 		);
 	}
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 c9fe0c02e2b..c1140d06c25 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
@@ -576,6 +576,9 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		$this->assertFalse( $r_order->get_item( $fee_item_2->get_id() ) );
 		$this->assertTrue( $r_order->get_item( $product_item->get_id() )->get_id() === $product_item->get_id() );
 		$this->assertTrue( $r_order->get_item( $shipping_item->get_id() )->get_id() === $shipping_item->get_id() );
+		$this->assertSame( 0, $this->get_item_meta_count( $fee_item_1->get_id() ), 'Deleted items should not leave metadata behind.' );
+		$this->assertSame( 0, $this->get_item_meta_count( $fee_item_2->get_id() ), 'Deleted items should not leave metadata behind.' );
+		$this->assertGreaterThan( 0, $this->get_item_meta_count( $product_item->get_id() ), 'Metadata of items of other types should remain.' );

 		// Deleting all items should all items.
 		$r_order->get_data_store()->delete_items( $r_order );
@@ -583,6 +586,8 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		$this->assertFalse( $r_order->get_item( $fee_item_2->get_id() ) );
 		$this->assertFalse( $r_order->get_item( $product_item->get_id() ) );
 		$this->assertFalse( $r_order->get_item( $shipping_item->get_id() ) );
+		$this->assertSame( 0, $this->get_item_meta_count( $product_item->get_id() ), 'Deleted items should not leave metadata behind.' );
+		$this->assertSame( 0, $this->get_item_meta_count( $shipping_item->get_id() ), 'Deleted items should not leave metadata behind.' );
 	}

 	/**
@@ -603,6 +608,20 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {

 		$this->assertFalse( WC_Order_Factory::get_order_item( $order_1_item_id ), 'A valid item belonging to the target order should be deleted.' );
 		$this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $order_2_item_id ), 'An item belonging to another order should remain.' );
+
+		$this->assertSame( 0, $this->get_item_meta_count( $order_1_item_id ), 'The deleted item should not leave metadata behind.' );
+		$this->assertGreaterThan( 0, $this->get_item_meta_count( $order_2_item_id ), 'Metadata of an item belonging to another order should remain.' );
+	}
+
+	/**
+	 * Count the metadata rows stored for an order item.
+	 *
+	 * @param int $item_id Order item ID.
+	 * @return int
+	 */
+	private function get_item_meta_count( int $item_id ): int {
+		global $wpdb;
+		return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE order_item_id = %d", $item_id ) );
 	}

 	/**
@@ -634,6 +653,7 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {

 		$this->assertFalse( $order_1->get_item( $product_item_1->get_id() ) );
 		$this->assertTrue( $order_2->get_item( $product_item_2->get_id() )->get_id() === $product_item_2->get_id() );
+		$this->assertGreaterThan( 0, $this->get_item_meta_count( $product_item_2->get_id() ), 'Metadata of items belonging to another order should remain.' );
 	}

 	/**