Commit 00490010db for woocommerce

commit 00490010db2514d3bc3a2e12268155428545c652
Author: Michael Pretty <prettyboymp@users.noreply.github.com>
Date:   Tue Nov 25 18:14:21 2025 -0500

    Update WC_Data::__clone() to allow mode configuration (#61591)

    * Remove WC_Data::clone override and clear meta_ids when updating the entity ID of an object.

    * Remove __clone() from WC_Abstract_Order

    * updates for readability

    * fix lint issue

    * Restore __clone(), but only have it clone Meta_Data objects without replacing IDs.

    * Add tests

    * restore maybe_read_meta_data and the order class __clone() override for compatibility.

    * Revert temporary change to plugins/woocommerce/includes/class-wc-product-factory.php

    * Switch to using a clone mode setting to better preserve backward compatibility.

    * fix lint issues.

    * fixing @since versions

    * Address feedback

    * fix lint issue

diff --git a/plugins/woocommerce/changelog/update-remove-wc-data-clone-override b/plugins/woocommerce/changelog/update-remove-wc-data-clone-override
new file mode 100644
index 0000000000..5cb68dae5a
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-remove-wc-data-clone-override
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add configurable clone mode to WC_Data to support both duplication and caching workflows while maintaining backward compatibility.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
index f0025d46c6..c54719f749 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
@@ -25,6 +25,20 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 abstract class WC_Data {

+	/**
+	 * Clone mode constant: Duplicate mode clears meta IDs (default, for backward compatibility).
+	 *
+	 * @since 10.4.0
+	 */
+	const CLONE_MODE_DUPLICATE = 'duplicate';
+
+	/**
+	 * Clone mode constant: Cache mode preserves meta IDs.
+	 *
+	 * @since 10.4.0
+	 */
+	const CLONE_MODE_CACHE = 'cache';
+
 	/**
 	 * ID for this object.
 	 *
@@ -104,10 +118,18 @@ abstract class WC_Data {
 	 * Stores additional meta data.
 	 *
 	 * @since 3.0.0
-	 * @var array
+	 * @var WC_Meta_Data[]|null
 	 */
 	protected $meta_data = null;

+	/**
+	 * Clone mode for controlling meta ID handling during clone operations.
+	 *
+	 * @since 10.4.0
+	 * @var string Either CLONE_MODE_DUPLICATE (default, clears meta IDs) or CLONE_MODE_CACHE (preserves meta IDs).
+	 */
+	protected $clone_mode = self::CLONE_MODE_DUPLICATE;
+
 	/**
 	 * List of properties that were earlier managed by data store. However, since DataStore is a not a stored entity in itself, they used to store data in metadata of the data object.
 	 * With custom tables, some of these are moved from metadata to their own columns, but existing code will still try to add them to metadata. This array is used to keep track of such properties.
@@ -152,7 +174,11 @@ abstract class WC_Data {
 	}

 	/**
-	 * When the object is cloned, make sure meta is duplicated correctly.
+	 * When the object is cloned, make sure meta is cloned correctly.
+	 *
+	 * Meta ID handling depends on the clone mode:
+	 * - CLONE_MODE_DUPLICATE (default): Clears meta IDs for duplication (backward compatible).
+	 * - CLONE_MODE_CACHE: Preserves meta IDs for caching purposes.
 	 *
 	 * @since 3.0.2
 	 */
@@ -161,13 +187,43 @@ abstract class WC_Data {
 		if ( ! empty( $this->meta_data ) ) {
 			foreach ( $this->meta_data as $array_key => $meta ) {
 				$this->meta_data[ $array_key ] = clone $meta;
-				if ( ! empty( $meta->id ) ) {
+
+				// Only clear meta IDs in duplicate mode (maintains backward compatibility).
+				if ( self::CLONE_MODE_DUPLICATE === $this->clone_mode && ! empty( $meta->id ) ) {
 					$this->meta_data[ $array_key ]->id = null;
 				}
 			}
 		}
 	}

+	/**
+	 * Set the clone mode.
+	 *
+	 * This controls how meta IDs are handled when the object is cloned:
+	 * - CLONE_MODE_DUPLICATE (default): Clears meta IDs for duplication workflows
+	 * - CLONE_MODE_CACHE: Preserves meta IDs for caching workflows
+	 *
+	 * @since 10.4.0
+	 * @param string $mode One of the CLONE_MODE_* constants.
+	 * @throws InvalidArgumentException If an invalid mode is provided.
+	 */
+	public function set_clone_mode( $mode ) {
+		if ( ! in_array( $mode, array( self::CLONE_MODE_DUPLICATE, self::CLONE_MODE_CACHE ), true ) ) {
+			throw new InvalidArgumentException( 'Clone mode must be either WC_Data::CLONE_MODE_DUPLICATE or WC_Data::CLONE_MODE_CACHE' );
+		}
+		$this->clone_mode = $mode;
+	}
+
+	/**
+	 * Get the current clone mode.
+	 *
+	 * @since 10.4.0
+	 * @return string The current clone mode (one of the CLONE_MODE_* constants).
+	 */
+	public function get_clone_mode() {
+		return $this->clone_mode;
+	}
+
 	/**
 	 * Get the data store.
 	 *
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
index a6ad6a9fab..11c6951ff7 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-duplicate-product.php
@@ -190,7 +190,8 @@ class WC_Admin_Duplicate_Product {
 		 */
 		if ( ! apply_filters( 'woocommerce_duplicate_product_exclude_children', false, $product ) && $product->is_type( ProductType::VARIABLE ) ) {
 			foreach ( $product->get_children() as $child_id ) {
-				$child           = wc_get_product( $child_id );
+				$child = wc_get_product( $child_id );
+				$child->read_meta_data();
 				$child_duplicate = clone $child;
 				$child_duplicate->set_parent_id( $duplicate->get_id() );
 				$child_duplicate->set_id( 0 );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/admin/class-wc-tests-admin-duplicate-product.php b/plugins/woocommerce/tests/legacy/unit-tests/admin/class-wc-tests-admin-duplicate-product.php
index 57c7c576b6..2ca26fb5ac 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/admin/class-wc-tests-admin-duplicate-product.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/admin/class-wc-tests-admin-duplicate-product.php
@@ -19,13 +19,19 @@ class WC_Tests_Admin_Duplicate_Product extends WC_Unit_Test_Case {
 	 */
 	public function test_simple_product_duplication() {
 		$product = WC_Helper_Product::create_simple_product();
+		$product->add_meta_data( 'test_meta_key', 'test_meta_value' );
+		$product->save();

 		$duplicate = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $product );

 		$this->assertNotEquals( $product->get_id(), $duplicate->get_id() );
 		$this->assertEquals( $product->get_name() . ' (Copy)', $duplicate->get_name() );
 		$this->assertEquals( ProductStatus::DRAFT, $duplicate->get_status() );
+		$this->assertEquals( $product->get_meta( 'test_meta_key' ), $duplicate->get_meta( 'test_meta_key' ) );
 		$this->assertDuplicateWasReset( $duplicate );
+
+		$product->read_meta_data( true );
+		$this->assertEquals( $duplicate->get_meta( 'test_meta_key' ), $product->get_meta( 'test_meta_key' ), 'Duplicating a product should not remove meta from the original product.' );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/crud/data.php b/plugins/woocommerce/tests/legacy/unit-tests/crud/data.php
index 76a8e85c92..7e3b381c28 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/crud/data.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/crud/data.php
@@ -590,4 +590,175 @@ class WC_Tests_CRUD_Data extends WC_Unit_Test_Case {
 		$this->assertEquals( 2, $new_data['prop2']['subprop2'] );
 		$this->assertEquals( 3, $new_data['prop2']['subprop3'] );
 	}
+
+	/**
+	 * Test that __clone() properly clones meta_data objects in duplicate mode (default).
+	 */
+	public function test_clone_meta_data_objects_duplicate_mode() {
+		$object = $this->create_test_post();
+		$object->add_meta_data( 'test_meta_key', 'val1', true );
+		$object->add_meta_data( 'test_meta_key_2', 'val2', true );
+		$object->save_meta_data();
+
+		// Clone the object (default CLONE_MODE_DUPLICATE).
+		$cloned_object = clone $object;
+
+		// Get meta data from both objects.
+		$original_meta = $object->get_meta_data();
+		$cloned_meta   = $cloned_object->get_meta_data();
+
+		// Verify that the meta data arrays have the same count.
+		$this->assertCount( count( $original_meta ), $cloned_meta );
+
+		// Verify that the meta data objects are different instances (not shared references).
+		foreach ( $original_meta as $index => $meta ) {
+			$this->assertNotSame( $meta, $cloned_meta[ $index ], 'Meta data objects should not be the same instance after cloning' );
+			$this->assertNull( $cloned_meta[ $index ]->id, 'Meta IDs should be null in duplicate mode (backward compatible behavior)' );
+			$this->assertEquals( $meta->key, $cloned_meta[ $index ]->key, 'Meta keys should match' );
+			$this->assertEquals( $meta->value, $cloned_meta[ $index ]->value, 'Meta values should match' );
+		}
+
+		// Modify cloned meta and verify original is unchanged.
+		$cloned_meta[0]->value = 'modified_value';
+		$original_meta_after   = $object->get_meta_data();
+		$this->assertNotEquals( 'modified_value', $original_meta_after[0]->value, 'Modifying cloned meta should not affect original' );
+	}
+
+	/**
+	 * Test that __clone() preserves meta IDs in cache mode.
+	 */
+	public function test_clone_meta_data_objects_cache_mode() {
+		$object = $this->create_test_post();
+		$object->add_meta_data( 'test_meta_key', 'val1', true );
+		$object->add_meta_data( 'test_meta_key_2', 'val2', true );
+		$object->save_meta_data();
+
+		// Get original meta IDs.
+		$original_meta      = $object->get_meta_data();
+		$original_meta_id_1 = $original_meta[0]->id;
+		$original_meta_id_2 = $original_meta[1]->id;
+
+		// Set cache mode before cloning.
+		$object->set_clone_mode( WC_Data::CLONE_MODE_CACHE );
+
+		// Clone the object.
+		$cloned_object = clone $object;
+
+		// Get meta data from cloned object.
+		$cloned_meta = $cloned_object->get_meta_data();
+
+		// Verify that meta IDs are preserved in cache mode.
+		$this->assertEquals( $original_meta_id_1, $cloned_meta[0]->id, 'Meta IDs should be preserved in cache mode' );
+		$this->assertEquals( $original_meta_id_2, $cloned_meta[1]->id, 'Meta IDs should be preserved in cache mode' );
+
+		// Verify keys and values are preserved.
+		$this->assertEquals( 'test_meta_key', $cloned_meta[0]->key );
+		$this->assertEquals( 'val1', $cloned_meta[0]->value );
+		$this->assertEquals( 'test_meta_key_2', $cloned_meta[1]->key );
+		$this->assertEquals( 'val2', $cloned_meta[1]->value );
+
+		// Verify clone mode persists on cloned object.
+		$this->assertEquals( WC_Data::CLONE_MODE_CACHE, $cloned_object->get_clone_mode(), 'Clone mode should persist to cloned object' );
+
+		// Modify cloned meta and verify original is unchanged.
+		$cloned_meta[0]->value = 'modified_value';
+		$original_meta_after   = $object->get_meta_data();
+		$this->assertNotEquals( 'modified_value', $original_meta_after[0]->value, 'Modifying cloned meta should not affect original' );
+	}
+
+	/**
+	 * Test setting clone mode with invalid values throws exception.
+	 */
+	public function test_set_clone_mode_validation() {
+		$object = $this->create_test_post();
+
+		// Valid modes should work.
+		$object->set_clone_mode( WC_Data::CLONE_MODE_DUPLICATE );
+		$this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $object->get_clone_mode() );
+
+		$object->set_clone_mode( WC_Data::CLONE_MODE_CACHE );
+		$this->assertEquals( WC_Data::CLONE_MODE_CACHE, $object->get_clone_mode() );
+
+		// Invalid mode should throw exception.
+		$this->expectException( InvalidArgumentException::class );
+		$object->set_clone_mode( 'invalid_mode' );
+	}
+
+	/**
+	 * Test duplication workflow: clone (duplicate mode) + set_id(0) + save.
+	 */
+	public function test_duplication_workflow() {
+		$object    = $this->create_test_post();
+		$object_id = $object->get_id();
+		$object->add_meta_data( 'test_meta_key', 'val1', true );
+		$object->save_meta_data();
+
+		// Get original meta ID.
+		$original_meta    = $object->get_meta_data();
+		$original_meta_id = $original_meta[0]->id;
+
+		// Clone the object (default CLONE_MODE_DUPLICATE).
+		$duplicate = clone $object;
+
+		// Verify cloned object has null meta IDs (duplicate mode).
+		$duplicate_meta = $duplicate->get_meta_data();
+		$this->assertNull( $duplicate_meta[0]->id, 'Cloned object meta IDs should be null in duplicate mode' );
+
+		// Set a new ID and save to create a duplicate.
+		$duplicate->set_id( 0 );
+		$duplicate->save();
+
+		// Verify the duplicate now has a different object ID.
+		$this->assertNotEquals( $object_id, $duplicate->get_id(), 'Duplicate should have different object ID' );
+
+		// Save meta data for the duplicate.
+		$duplicate->save_meta_data();
+
+		// Verify the duplicate's meta has a new ID.
+		$duplicate_meta_after = $duplicate->get_meta_data();
+		$this->assertNotEmpty( $duplicate_meta_after[0]->id, 'Duplicate meta should have an ID after save' );
+		$this->assertNotEquals( $original_meta_id, $duplicate_meta_after[0]->id, 'Duplicate meta should have different ID than original' );
+
+		// Verify original object's meta is unchanged.
+		$object->read_meta_data( true );
+		$original_meta_after = $object->get_meta_data();
+		$this->assertEquals( $original_meta_id, $original_meta_after[0]->id, 'Original meta ID should be unchanged' );
+		$this->assertEquals( 'val1', $original_meta_after[0]->value, 'Original meta value should be unchanged' );
+	}
+
+	/**
+	 * Test cache workflow: WordPress object cache double-clone scenario.
+	 */
+	public function test_cache_double_clone_workflow() {
+		$object = $this->create_test_post();
+		$object->add_meta_data( 'test_meta_key', 'val1', true );
+		$object->save_meta_data();
+
+		// Get original meta ID.
+		$original_meta    = $object->get_meta_data();
+		$original_meta_id = $original_meta[0]->id;
+
+		// Set cache mode (what caching layer would do before wp_cache_set).
+		$object->set_clone_mode( WC_Data::CLONE_MODE_CACHE );
+
+		// First clone (simulates WordPress wp_cache_set cloning the object).
+		$cached_object = clone $object;
+
+		// Verify meta IDs preserved after first clone.
+		$cached_meta = $cached_object->get_meta_data();
+		$this->assertEquals( $original_meta_id, $cached_meta[0]->id, 'Meta ID should be preserved in first clone (cache set)' );
+		$this->assertEquals( WC_Data::CLONE_MODE_CACHE, $cached_object->get_clone_mode(), 'Clone mode should persist after first clone' );
+
+		// Second clone (simulates WordPress wp_cache_get cloning the cached object).
+		$retrieved_object = clone $cached_object;
+
+		// Verify meta IDs still preserved after second clone.
+		$retrieved_meta = $retrieved_object->get_meta_data();
+		$this->assertEquals( $original_meta_id, $retrieved_meta[0]->id, 'Meta ID should be preserved in second clone (cache get)' );
+		$this->assertEquals( WC_Data::CLONE_MODE_CACHE, $retrieved_object->get_clone_mode(), 'Clone mode should persist after second clone' );
+
+		// Cache layer would reset mode after retrieval.
+		$retrieved_object->set_clone_mode( WC_Data::CLONE_MODE_DUPLICATE );
+		$this->assertEquals( WC_Data::CLONE_MODE_DUPLICATE, $retrieved_object->get_clone_mode() );
+	}
 }