Commit ec40b0bc174 for woocommerce
commit ec40b0bc1740c09625766bc409a8955559a0be32
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Thu Aug 27 15:39:57 2026 +0100
Fix deferred deletion removing replacement order items (#67975)
* Add targeted order item deletion by ID
* Preserve replacement items during deferred order deletion
* Add regression coverage for deferred item deletion
* Add changelog entry for deferred item deletion fix
* Refactor deferred deletion regression tests
* Remove resolved order deletion PHPStan baseline entry
* Preserve deletion of custom order item types
* Test legacy custom order data store fallback
* Exercise legacy data store snapshot fallback
* Remove version tags from private order helpers
* Rename order data store item ID getter
* Reject invalid persisted order item IDs
* Preserve re-added items during deferred deletion
* Test re-adding items queued for deferred deletion
* Restore specific order item ID getter name
* Surface deferred order item database failures
* Use consistent order item ID getter name
* Log order item database failures safely
* Test order item deletion safety guards
* Update order item helper since tags
diff --git a/plugins/woocommerce/changelog/fix-deferred-order-item-deletion b/plugins/woocommerce/changelog/fix-deferred-order-item-deletion
new file mode 100644
index 00000000000..2b80af41d6c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-deferred-order-item-deletion
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve replacement order items saved by extensions during deferred checkout item deletion.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 4b258335a8d..6fe5343db48 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -118,6 +118,14 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
protected $item_types_to_bulk_delete = array();
+ /**
+ * Item IDs captured for bulk delete by type.
+ *
+ * @since 11.1.0
+ * @var array<string, array<int>>
+ */
+ protected $item_ids_to_bulk_delete_by_type = array();
+
/**
* Whether every order item type should be deleted on the next save().
*
@@ -129,6 +137,14 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
protected $bulk_delete_all_items_pending = false;
+ /**
+ * IDs of items captured when a full deferred removal is requested.
+ *
+ * @since 11.1.0
+ * @var array<int>
+ */
+ protected $item_ids_to_bulk_delete = array();
+
/**
* Stores meta in cache for future reads.
*
@@ -312,6 +328,91 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
);
}
+ /**
+ * Deletes items in bulk if the data store supports it, otherwise deletes them one by one.
+ *
+ * @param array<int> $item_ids IDs of the items to delete.
+ * @return void
+ */
+ private function delete_items_by_ids( array $item_ids ): void {
+ /**
+ * Data store wrapper.
+ *
+ * @var WC_Data_Store $data_store
+ */
+ $data_store = $this->data_store;
+
+ if ( $data_store->has_callable( 'delete_items_by_ids' ) ) {
+ // @phpstan-ignore-next-line -- Optional data store method checked above.
+ $data_store->delete_items_by_ids( $this, $item_ids );
+ } else {
+ // Custom data stores may not support bulk deletion by IDs, so delete the snapshotted items individually
+ // so that items added *after* remove_order_items() are preserved.
+ foreach ( array_unique( $item_ids ) as $item_id ) {
+ $item = WC_Order_Factory::get_order_item( $item_id );
+
+ if ( $item && $this->get_id() === $item->get_order_id() ) {
+ $item->delete();
+ }
+ }
+ }
+ }
+
+ /**
+ * Get IDs of items currently persisted for this order.
+ *
+ * @param string|null $type Item type, or null for every registered item type.
+ * @return array<int>
+ */
+ private function get_persisted_item_ids( $type = null ): array {
+ /**
+ * Data store wrapper.
+ *
+ * @var WC_Data_Store $data_store
+ */
+ $data_store = $this->data_store;
+
+ if ( $data_store->has_callable( 'get_item_ids' ) ) {
+ // @phpstan-ignore-next-line -- Optional data store method checked above.
+ $item_ids = $data_store->get_item_ids( $this, $type );
+
+ return array_values(
+ array_unique(
+ array_filter(
+ array_map( 'intval', (array) $item_ids ),
+ static fn( $item_id ) => $item_id > 0
+ )
+ )
+ );
+ }
+
+ $types = null === $type
+ ? array_keys( $this->get_item_types_to_group() )
+ : array( $type );
+
+ $item_ids = array();
+ foreach ( $types as $item_type ) {
+ if ( ! is_string( $item_type ) || '' === $item_type ) {
+ continue;
+ }
+
+ // @phpstan-ignore-next-line -- Required order data store method forwarded by WC_Data_Store::__call().
+ $items = $this->data_store->read_items( $this, $item_type );
+ foreach ( (array) $items as $item ) {
+ if ( ! $item instanceof WC_Order_Item ) {
+ continue;
+ }
+ $item_id = absint( $item->get_id() );
+
+ if ( $item_id && $this->get_id() === (int) $item->get_order_id() ) {
+ $item_ids[] = $item_id;
+ }
+ }
+ }
+
+ return array_values( array_unique( $item_ids ) );
+ }
+
/**
* Save all order items which are part of this order.
*
@@ -321,10 +422,12 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$items_changed = false;
if ( $this->bulk_delete_all_items_pending ) {
- $this->data_store->delete_items( $this );
- $this->bulk_delete_all_items_pending = false;
- $this->item_types_to_bulk_delete = array();
- $items_changed = true;
+ $this->delete_items_by_ids( $this->item_ids_to_bulk_delete );
+ $this->bulk_delete_all_items_pending = false;
+ $this->item_ids_to_bulk_delete_by_type = array();
+ $this->item_ids_to_bulk_delete = array();
+ $this->item_types_to_bulk_delete = array();
+ $items_changed = true;
/**
* Trigger action after removing all order line items from the database.
@@ -336,11 +439,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
do_action( 'woocommerce_removed_order_items', $this, null );
} elseif ( ! empty( $this->item_types_to_bulk_delete ) ) {
- // Drain the queue one type at a time, dropping each entry only after delete_items() succeeds.
+ // Drain the queue one type at a time, dropping each entry only after item deletion succeeds.
// If a delete or hook callback throws, save()'s catch handles it and any types that have not
// yet been processed remain queued for the next save() rather than being silently lost.
foreach ( array_values( array_unique( $this->item_types_to_bulk_delete ) ) as $type ) {
- $this->data_store->delete_items( $this, $type );
+ $item_ids = $this->item_ids_to_bulk_delete_by_type[ $type ] ?? array();
+ $this->delete_items_by_ids( $item_ids );
+ unset( $this->item_ids_to_bulk_delete_by_type[ $type ] );
$items_changed = true;
$this->item_types_to_bulk_delete = array_values(
array_filter(
@@ -961,6 +1066,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* the post-hook fire at save() time.
*
* @param string|null $type Order item type. Default null (remove every type).
+ * @throws Exception If persisted item IDs cannot be read.
* @return void
*/
public function remove_order_items( $type = null ) {
@@ -993,7 +1099,25 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
if ( ! empty( $type ) ) {
if ( $has_persisted_items ) {
- $this->item_types_to_bulk_delete[] = $type;
+ $item_ids = $this->get_persisted_item_ids( $type );
+
+ if ( $this->bulk_delete_all_items_pending ) {
+ $this->item_ids_to_bulk_delete = array_values(
+ array_unique(
+ array_merge( $this->item_ids_to_bulk_delete, $item_ids )
+ )
+ );
+ } else {
+ $this->item_types_to_bulk_delete[] = $type;
+ $this->item_ids_to_bulk_delete_by_type[ $type ] = array_values(
+ array_unique(
+ array_merge(
+ $this->item_ids_to_bulk_delete_by_type[ $type ] ?? array(),
+ $item_ids
+ )
+ )
+ );
+ }
}
$group = $this->type_to_group( $type );
@@ -1006,8 +1130,20 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
} else {
if ( $has_persisted_items ) {
- $this->bulk_delete_all_items_pending = true;
- $this->item_types_to_bulk_delete = array();
+ $item_ids = $this->get_persisted_item_ids();
+
+ foreach ( $this->item_ids_to_bulk_delete_by_type as $typed_item_ids ) {
+ $item_ids = array_merge( $item_ids, $typed_item_ids );
+ }
+
+ $this->bulk_delete_all_items_pending = true;
+ $this->item_ids_to_bulk_delete = array_values(
+ array_unique(
+ array_merge( $this->item_ids_to_bulk_delete, $item_ids )
+ )
+ );
+ $this->item_types_to_bulk_delete = array();
+ $this->item_ids_to_bulk_delete_by_type = array();
}
$type_to_group = $this->get_item_types_to_group();
// Union with currently populated keys so any group already loaded into
@@ -1299,7 +1435,6 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
if ( ! $items_key ) {
return false;
}
-
// Make sure existing items are loaded so we can append this new one.
if ( ! isset( $this->items[ $items_key ] ) ) {
$this->items[ $items_key ] = $this->get_items( $item->get_type() );
@@ -1310,8 +1445,26 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
// Append new row with generated temporary ID.
$item_id = $item->get_id();
+
if ( $item_id ) {
$this->items[ $items_key ][ $item_id ] = $item;
+
+ // Scrub this item from deferred bulk deletion snapshots, in case it was removed and then re-added before save().
+ $this->item_ids_to_bulk_delete = array_values(
+ array_filter(
+ $this->item_ids_to_bulk_delete,
+ static fn( $deleted_item_id ) => $deleted_item_id !== $item_id
+ )
+ );
+
+ foreach ( $this->item_ids_to_bulk_delete_by_type as $type => $item_ids ) {
+ $this->item_ids_to_bulk_delete_by_type[ $type ] = array_values(
+ array_filter(
+ $item_ids,
+ static fn( $deleted_item_id ) => $deleted_item_id !== $item_id
+ )
+ );
+ }
} else {
$this->items[ $items_key ][ 'new:' . $items_key . $this->temp_item_id_counter++ ] = $item;
}
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 10e30fda862..f218e05e3e4 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
@@ -780,6 +780,130 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
protected function prime_needs_processing_transients( $order_ids, $query_vars ) {
}
+ /**
+ * Get persisted order item IDs, optionally limited to an item type.
+ *
+ * @since 11.1.0
+ *
+ * @param WC_Order $order Order object.
+ * @param string|null $type Order item type, or null for every type.
+ * @throws Exception If the database query fails.
+ * @return int[]
+ */
+ public function get_item_ids( $order, $type = null ) {
+ global $wpdb;
+
+ if ( ! $order->get_id() ) {
+ return array();
+ }
+
+ if ( null === $type ) {
+ $item_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d",
+ $order->get_id()
+ )
+ );
+ } else {
+ $item_ids = $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->get_id(),
+ $type
+ )
+ );
+ }
+
+ if ( '' !== $wpdb->last_error ) {
+ wc_get_logger()->error(
+ 'Failed to retrieve persisted order item IDs.',
+ array(
+ 'source' => 'order-data-store',
+ 'order_id' => $order->get_id(),
+ 'error' => $wpdb->last_error,
+ )
+ );
+ throw new Exception( esc_html__( 'Unable to retrieve persisted order item IDs.', 'woocommerce' ) );
+ }
+
+ return array_map( 'intval', $item_ids );
+ }
+
+ /**
+ * Delete selected order items by ID.
+ *
+ * @since 11.1.0
+ *
+ * @param WC_Order $order Order object.
+ * @param int[] $ids Order item IDs to delete.
+ * @throws Exception If the database query fails.
+ * @return void
+ */
+ public function delete_items_by_ids( $order, $ids ) {
+ global $wpdb;
+
+ if ( ! $order->get_id() || empty( $ids ) ) {
+ return;
+ }
+
+ $sanitized_ids = array_values(
+ array_unique(
+ array_filter(
+ array_map( 'intval', (array) $ids ),
+ static fn( $id ) => $id > 0
+ )
+ )
+ );
+ if ( empty( $sanitized_ids ) ) {
+ return;
+ }
+
+ $ids_placeholders = implode( ', ', array_fill( 0, count( $sanitized_ids ), '%d' ) );
+
+ $result = $wpdb->query(
+ $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)",
+ array_merge( array( $order->get_id() ), $sanitized_ids )
+ )
+ );
+ if ( false === $result ) {
+ wc_get_logger()->error(
+ 'Failed to delete order item metadata.',
+ 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' ) );
+ }
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $ids_placeholders is generated above.
+ "DELETE 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 ) {
+ wc_get_logger()->error(
+ 'Failed to delete order items.',
+ array(
+ 'source' => 'order-data-store',
+ 'order_id' => $order->get_id(),
+ 'error' => $wpdb->last_error,
+ )
+ );
+ throw new Exception( esc_html__( 'Unable to delete order items.', 'woocommerce' ) );
+ }
+
+ foreach ( $sanitized_ids as $item_id ) {
+ wp_cache_delete( 'item-' . $item_id, 'order-items' );
+ }
+
+ $this->clear_caches( $order );
+ }
+
/**
* Remove all line items (products, coupons, shipping, taxes) from the order.
*
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 6964211caa3..1fddb9acec8 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -528,12 +528,6 @@ parameters:
count: 1
path: includes/abstracts/abstract-wc-order.php
- -
- message: '#^Call to an undefined method object\:\:delete_items\(\)\.$#'
- identifier: method.notFound
- count: 2
- path: includes/abstracts/abstract-wc-order.php
-
-
message: '#^Call to an undefined method object\:\:get_order_item_type\(\)\.$#'
identifier: method.notFound
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 5cd2451f3ac..c13609b26c7 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
@@ -1102,6 +1102,112 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertCount( 0, $reloaded_after_save->get_items( 'shipping' ), 'Shipping items should be removed from the DB after save().' );
}
+ /**
+ * @testdox remove_order_items() should surface a failed item ID snapshot query.
+ */
+ public function test_remove_order_items_throws_when_snapshot_query_fails() {
+ global $wpdb;
+
+ $order = WC_Helper_Order::create_order();
+ $query_fragment = "SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items";
+ $query_filter = static function ( $query ) use ( $query_fragment ) {
+ return false !== strpos( $query, $query_fragment ) ? 'INVALID SQL' : $query;
+ };
+
+ $logger = $this->createMock( WC_Logger_Interface::class );
+ $logger->expects( $this->once() )
+ ->method( 'error' )
+ ->with(
+ 'Failed to retrieve persisted order item IDs.',
+ $this->callback(
+ static function ( $context ) use ( $order ) {
+ return 'order-data-store' === ( $context['source'] ?? null )
+ && $order->get_id() === ( $context['order_id'] ?? null )
+ && ! empty( $context['error'] );
+ }
+ )
+ );
+ $logger_filter = static function () use ( $logger ) {
+ return $logger;
+ };
+
+ $previous_suppress_errors = $wpdb->suppress_errors( true );
+ $caught_exception = null;
+
+ add_filter( 'query', $query_filter );
+ add_filter( 'woocommerce_logging_class', $logger_filter );
+ try {
+ $order->remove_order_items();
+ } catch ( Exception $exception ) {
+ $caught_exception = $exception;
+ } finally {
+ remove_filter( 'query', $query_filter );
+ remove_filter( 'woocommerce_logging_class', $logger_filter );
+ $wpdb->suppress_errors( $previous_suppress_errors );
+ }
+
+ $this->assertInstanceOf( Exception::class, $caught_exception );
+ $this->assertSame( 'Unable to retrieve persisted order item IDs.', $caught_exception->getMessage() );
+ $this->assertNotEmpty( wc_get_order( $order->get_id() )->get_items(), 'A failed snapshot must leave persisted items unchanged.' );
+ }
+
+ /**
+ * @testdox save() should retain the deferred deletion queue after a database delete fails.
+ * @dataProvider provide_failed_order_item_delete_queries
+ *
+ * @param string $query_fragment SQL fragment identifying the query to fail.
+ */
+ public function test_remove_order_items_retries_after_delete_query_fails( $query_fragment ) {
+ global $wpdb;
+
+ $order = WC_Helper_Order::create_order();
+ $query_fragment = str_replace( '{prefix}', $wpdb->prefix, $query_fragment );
+ $query_filter = static function ( $query ) use ( $query_fragment ) {
+ return false !== strpos( $query, $query_fragment ) ? 'INVALID SQL' : $query;
+ };
+
+ $order->remove_order_items( 'line_item' );
+
+ $previous_suppress_errors = $wpdb->suppress_errors( true );
+ add_filter( 'query', $query_filter );
+ try {
+ $order->save();
+ } finally {
+ remove_filter( 'query', $query_filter );
+ $wpdb->suppress_errors( $previous_suppress_errors );
+ }
+
+ $persisted_item_count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_type = 'line_item'",
+ $order->get_id()
+ )
+ );
+ $this->assertGreaterThan( 0, $persisted_item_count, 'A failed delete must leave the item row available for retry.' );
+
+ $order->save();
+
+ $persisted_item_count = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id = %d AND order_item_type = 'line_item'",
+ $order->get_id()
+ )
+ );
+ $this->assertSame( 0, $persisted_item_count, 'A subsequent save should retry and complete the queued deletion.' );
+ }
+
+ /**
+ * Provides metadata and item-row deletion queries to fail.
+ *
+ * @return array<string, array{string}>
+ */
+ public static function provide_failed_order_item_delete_queries(): array {
+ return array(
+ 'item metadata delete' => array( 'DELETE itemmeta FROM' ),
+ 'item row delete' => array( 'DELETE FROM {prefix}woocommerce_order_items' ),
+ );
+ }
+
/**
* @testdox add_item() must not overwrite an earlier unsaved item when items are removed and re-added before save().
*/
@@ -1144,6 +1250,356 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertCount( 2, $names );
}
+ /**
+ * @testdox add_item() should preserve a persisted item re-added after deferred removal.
+ * @dataProvider provide_deferred_removal_types
+ *
+ * @param string|null $type Item type to remove, or null to remove every type.
+ */
+ public function test_add_item_keeps_persisted_item_after_remove_and_readd( $type ) {
+ $order = WC_Helper_Order::create_order();
+ $item = current( $order->get_items() );
+ $item_id = $item->get_id();
+ $updated_quantity = 5;
+
+ $order->add_product( $item->get_product(), 1 );
+ $order->save();
+
+ $order->remove_order_items( $type );
+ $item->set_quantity( $updated_quantity );
+ $item->add_meta_data( '_readded_item', 'preserved', true );
+ $order->add_item( $item );
+ $order->save();
+
+ $persisted_items = wc_get_order( $order->get_id() )->get_items();
+ $this->assertCount( 1, $persisted_items, 'Only the explicitly re-added line item should remain.' );
+ $this->assertArrayHasKey( $item_id, $persisted_items, 'The re-added item should retain its ID.' );
+ $this->assertSame( $updated_quantity, $persisted_items[ $item_id ]->get_quantity(), 'The re-added item should retain its updated quantity.' );
+ $this->assertSame( 'preserved', $persisted_items[ $item_id ]->get_meta( '_readded_item' ), 'The re-added item should retain its metadata.' );
+ }
+
+ /**
+ * Provides full and typed deferred removal cases.
+ *
+ * @return array<string, array{string|null}>
+ */
+ public static function provide_deferred_removal_types(): array {
+ return array(
+ 'all item types' => array( null ),
+ 'line items' => array( 'line_item' ),
+ );
+ }
+
+ /**
+ * Create a product item for deferred deletion tests.
+ *
+ * @param WC_Product $product Product object.
+ * @param string $name Item name.
+ * @return WC_Order_Item_Product
+ */
+ private function create_deferred_deletion_test_item( WC_Product $product, string $name ): WC_Order_Item_Product {
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10,
+ 'total' => 10,
+ )
+ );
+ $item->set_name( $name );
+ return $item;
+ }
+
+ /**
+ * @testdox Should preserve replacement items saved while bulk deletion is pending.
+ */
+ public function test_remove_order_items_preserves_replacement_items_saved_before_order_save() {
+ $order = WC_Helper_Order::create_order();
+ $original_items = $order->get_items();
+ $product = current( $original_items )->get_product();
+
+ $order->remove_order_items();
+
+ $early_saved_item_ids = array();
+ $expected_item_names = array();
+ for ( $index = 1; $index <= 5; $index++ ) {
+ $item_name = 'Replacement ' . $index;
+ $expected_item_names[] = $item_name;
+ $item = $this->create_deferred_deletion_test_item( $product, $item_name );
+ $item->add_meta_data( '_replacement_index', (string) $index, true );
+ $order->add_item( $item );
+
+ if ( $index <= 4 ) {
+ $item->set_order_id( $order->get_id() );
+ $early_saved_item_ids[] = $item->save();
+ }
+ }
+
+ $order->save();
+
+ $persisted_items = wc_get_order( $order->get_id() )->get_items();
+ $this->assertEqualsCanonicalizing(
+ $expected_item_names,
+ array_map( static fn( $item ) => $item->get_name(), $persisted_items ),
+ 'Every replacement, and no original line item, should survive the deferred deletion.'
+ );
+ foreach ( $early_saved_item_ids as $item_id ) {
+ $this->assertArrayHasKey( $item_id, $persisted_items, 'An early-saved replacement should retain its original item ID.' );
+ $this->assertNotEmpty( $persisted_items[ $item_id ]->get_meta( '_replacement_index' ), 'An early-saved replacement should retain its metadata.' );
+ }
+ }
+
+ /**
+ * @testdox Item-read filters should not control deletion snapshots, and deleted item caches should be cleared.
+ */
+ public function test_remove_order_items_uses_unfiltered_snapshot_and_clears_item_cache() {
+ $order = WC_Helper_Order::create_order();
+ $original_line_item_ids = array_keys( $order->get_items() );
+ $original_shipping_item_ids = array_keys( $order->get_items( 'shipping' ) );
+ $hide_items = static function () {
+ return array();
+ };
+
+ add_filter( 'woocommerce_order_get_items', $hide_items );
+ try {
+ $order->remove_order_items();
+ } finally {
+ remove_filter( 'woocommerce_order_get_items', $hide_items );
+ }
+
+ $order->save();
+
+ $persisted_order = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty( $original_line_item_ids, 'The test order should initially contain line items.' );
+ $this->assertNotEmpty( $original_shipping_item_ids, 'The test order should initially contain shipping items.' );
+ $this->assertCount( 0, $persisted_order->get_items(), 'The read filter should not prevent persisted line items from being deleted.' );
+ $this->assertCount( 0, $persisted_order->get_items( 'shipping' ), 'The read filter should not prevent persisted shipping items from being deleted.' );
+
+ foreach ( array_merge( $original_line_item_ids, $original_shipping_item_ids ) as $item_id ) {
+ $this->assertFalse(
+ WC_Order_Factory::get_order_item( $item_id ),
+ 'Deleted items should not remain available from the item cache.'
+ );
+ }
+ }
+
+ /**
+ * @testdox Should delete persisted items whose types are not registered with the order object.
+ * @dataProvider provide_unregistered_item_removal_types
+ *
+ * @param string|null $type Item type to remove, or null for every type.
+ */
+ public function test_remove_order_items_deletes_unregistered_persisted_item_types( $type ) {
+ global $wpdb;
+
+ $order = WC_Helper_Order::create_order();
+ $wpdb->insert(
+ $wpdb->prefix . 'woocommerce_order_items',
+ array(
+ 'order_item_name' => 'Extension item',
+ 'order_item_type' => 'extension_item',
+ 'order_id' => $order->get_id(),
+ ),
+ array( '%s', '%s', '%d' )
+ );
+ $extension_item_id = (int) $wpdb->insert_id;
+ wc_add_order_item_meta( $extension_item_id, '_extension_item_test', 'present' );
+
+ $this->assertGreaterThan( 0, $extension_item_id, 'Precondition: an extension item should be persisted.' );
+ $this->assertSame( 'present', wc_get_order_item_meta( $extension_item_id, '_extension_item_test', true ), 'Precondition: extension item metadata should be persisted.' );
+
+ $order->remove_order_items( $type );
+ $order->save();
+
+ $this->assertNull(
+ $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_item_id = %d",
+ $extension_item_id
+ )
+ ),
+ 'An unregistered persisted item should be deleted.'
+ );
+ $this->assertNull(
+ $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT meta_id FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE order_item_id = %d",
+ $extension_item_id
+ )
+ ),
+ 'Metadata for an unregistered persisted item should be deleted.'
+ );
+ }
+
+ /**
+ * Provide full and typed removal requests for unregistered persisted items.
+ *
+ * @return array<string, array{string|null}>
+ */
+ public static function provide_unregistered_item_removal_types(): array {
+ return array(
+ 'full removal' => array( null ),
+ 'typed removal' => array( 'extension_item' ),
+ );
+ }
+
+ /**
+ * @testdox Should preserve replacement items saved while deletion by type is pending.
+ */
+ public function test_remove_order_items_by_type_preserves_replacement_items_saved_before_order_save() {
+ $order = WC_Helper_Order::create_order();
+ $original_items = $order->get_items();
+ $product = current( $original_items )->get_product();
+ $original_shipping_ids = array_keys( $order->get_items( 'shipping' ) );
+
+ $order->remove_order_items( 'line_item' );
+
+ $early_saved_item = $this->create_deferred_deletion_test_item( $product, 'Early-saved replacement' );
+ $early_saved_item->add_meta_data( '_typed_removal_test', 'preserved', true );
+ $order->add_item( $early_saved_item );
+ $early_saved_item->set_order_id( $order->get_id() );
+ $early_saved_item_id = $early_saved_item->save();
+
+ $unsaved_item = $this->create_deferred_deletion_test_item( $product, 'Unsaved replacement' );
+ $order->add_item( $unsaved_item );
+
+ $order->save();
+
+ $persisted_order = wc_get_order( $order->get_id() );
+ $persisted_items = $persisted_order->get_items();
+ $this->assertEqualsCanonicalizing(
+ array( 'Early-saved replacement', 'Unsaved replacement' ),
+ array_map( static fn( $item ) => $item->get_name(), $persisted_items ),
+ 'Both replacements, and no original line item, should survive the typed deferred deletion.'
+ );
+ $this->assertArrayHasKey( $early_saved_item_id, $persisted_items, 'The early-saved replacement should retain its item ID.' );
+ $this->assertSame( 'preserved', $persisted_items[ $early_saved_item_id ]->get_meta( '_typed_removal_test' ), 'The early-saved replacement should retain its metadata.' );
+ $this->assertSame( $original_shipping_ids, array_keys( $persisted_order->get_items( 'shipping' ) ), 'Items of other types should remain unchanged.' );
+ }
+
+ /**
+ * @testdox Repeated removal requests should combine their persisted item snapshots.
+ * @dataProvider provide_repeated_remove_order_items_sequences
+ *
+ * @param string|null $first_type First removal type.
+ * @param string|null $second_type Second removal type.
+ * @param bool $should_preserve_shipping Whether shipping should survive.
+ */
+ public function test_repeated_remove_order_items_combines_snapshots( $first_type, $second_type, $should_preserve_shipping ) {
+ $order = WC_Helper_Order::create_order();
+ $original_items = $order->get_items();
+ $original_item_ids = array_keys( $original_items );
+ $product = current( $original_items )->get_product();
+ $original_shipping_ids = array_keys( $order->get_items( 'shipping' ) );
+
+ $order->remove_order_items( $first_type );
+
+ $intermediate_item = $this->create_deferred_deletion_test_item( $product, 'Intermediate replacement' );
+ $order->add_item( $intermediate_item );
+ $intermediate_item->set_order_id( $order->get_id() );
+ $intermediate_item_id = $intermediate_item->save();
+
+ $order->remove_order_items( $second_type );
+
+ $final_item = $this->create_deferred_deletion_test_item( $product, 'Final replacement' );
+ $order->add_item( $final_item );
+ $order->save();
+
+ $persisted_order = wc_get_order( $order->get_id() );
+ $persisted_items = $persisted_order->get_items();
+ $this->assertSame(
+ array( 'Final replacement' ),
+ array_values( array_map( static fn( $item ) => $item->get_name(), $persisted_items ) ),
+ 'Only the item added after the final removal should survive.'
+ );
+ $this->assertArrayNotHasKey( $intermediate_item_id, $persisted_items, 'The intermediate item should be deleted by the second removal.' );
+ foreach ( $original_item_ids as $original_item_id ) {
+ $this->assertArrayNotHasKey( $original_item_id, $persisted_items, 'Items captured by the first removal should still be deleted.' );
+ }
+
+ if ( $should_preserve_shipping ) {
+ $this->assertSame( $original_shipping_ids, array_keys( $persisted_order->get_items( 'shipping' ) ), 'Typed removals should preserve other item types.' );
+ } else {
+ $this->assertCount( 0, $persisted_order->get_items( 'shipping' ), 'A full removal should remove other item types.' );
+ }
+ }
+
+ /**
+ * Provide repeated removal sequences.
+ *
+ * @return array<string, array{string|null, string|null, bool}>
+ */
+ public static function provide_repeated_remove_order_items_sequences(): array {
+ return array(
+ 'typed then typed' => array( 'line_item', 'line_item', true ),
+ 'full then typed' => array( null, 'line_item', false ),
+ 'typed then full' => array( 'line_item', null, false ),
+ );
+ }
+
+ /**
+ * @testdox Should preserve replacement items with a legacy custom data store lacking ID snapshot and deletion methods.
+ */
+ public function test_remove_order_items_preserves_replacements_with_custom_data_store_fallback() {
+ $order = WC_Helper_Order::create_order();
+ $original_items = $order->get_items();
+ $product = current( $original_items )->get_product();
+
+ $original_data_store = $order->get_data_store();
+ // phpcs:disable Squiz.Commenting -- Anonymous test double methods are self-explanatory.
+ $custom_data_store = new class( $original_data_store ) extends WC_Data_Store {
+ private $delegate;
+
+ public function __construct( $delegate ) {
+ parent::__construct( 'order' );
+ $this->delegate = $delegate;
+ }
+
+ public function has_callable( string $method ): bool {
+ return in_array( $method, array( 'get_item_ids', 'delete_items_by_ids' ), true ) ? false : $this->delegate->has_callable( $method );
+ }
+
+ public function update( &$data ) {
+ return $this->delegate->update( $data );
+ }
+
+ public function get_current_class_name() {
+ return $this->delegate->get_current_class_name();
+ }
+
+ public function __call( $method, $parameters ) {
+ return $this->delegate->{$method}( ...$parameters );
+ }
+ };
+ // phpcs:enable Squiz.Commenting
+
+ $reflection = new ReflectionProperty( WC_Data::class, 'data_store' );
+ $reflection->setAccessible( true );
+ $reflection->setValue( $order, $custom_data_store );
+
+ $order->remove_order_items();
+
+ $early_saved_item = $this->create_deferred_deletion_test_item( $product, 'Early-saved replacement' );
+ $early_saved_item->add_meta_data( '_fallback_test', 'preserved', true );
+ $order->add_item( $early_saved_item );
+ $early_saved_item->set_order_id( $order->get_id() );
+ $early_saved_item_id = $early_saved_item->save();
+
+ $unsaved_item = $this->create_deferred_deletion_test_item( $product, 'Unsaved replacement' );
+ $order->add_item( $unsaved_item );
+ $order->save();
+
+ $persisted_items = wc_get_order( $order->get_id() )->get_items();
+ $this->assertEqualsCanonicalizing(
+ array( 'Early-saved replacement', 'Unsaved replacement' ),
+ array_map( static fn( $item ) => $item->get_name(), $persisted_items ),
+ 'Both replacements, and no original item, should survive the custom data store fallback.'
+ );
+ $this->assertArrayHasKey( $early_saved_item_id, $persisted_items, 'The early-saved replacement should retain its item ID.' );
+ $this->assertSame( 'preserved', $persisted_items[ $early_saved_item_id ]->get_meta( '_fallback_test' ), 'The early-saved replacement should retain its metadata.' );
+ }
+
/**
* @testdox Should keep original items in the DB if save() never runs after remove_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 63e6902cfd2..2ccc0bbff5d 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
@@ -582,6 +582,26 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertFalse( $r_order->get_item( $shipping_item->get_id() ) );
}
+ /**
+ * @testdox delete_items_by_ids() should reject invalid IDs and preserve items belonging to other orders.
+ */
+ public function test_delete_items_by_ids_sanitizes_and_scopes_ids_to_order() {
+ $order_1 = WC_Helper_Order::create_order();
+ $order_2 = WC_Helper_Order::create_order();
+ $order_1_item_id = array_key_first( $order_1->get_items() );
+ $order_2_item_id = array_key_first( $order_2->get_items() );
+ $data_store = $order_1->get_data_store();
+
+ $data_store->delete_items_by_ids( $order_1, array( -$order_1_item_id, 0 ) );
+
+ $this->assertInstanceOf( WC_Order_Item::class, WC_Order_Factory::get_order_item( $order_1_item_id ), 'Negative and zero IDs should be rejected.' );
+
+ $data_store->delete_items_by_ids( $order_1, array( $order_1_item_id, $order_1_item_id, $order_2_item_id ) );
+
+ $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.' );
+ }
+
/**
* @testDox Deleting order item should delete items from only that order.
*/