Commit f4cfdcb51f3 for woocommerce

commit f4cfdcb51f3382015e6d2a08cbaa08fb8604f4bd
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Nov 4 13:44:34 2026 +0100

    Keep custom default attribute values intact when a term is renamed (#70627)

    Renaming a global attribute term now decodes each _default_attributes row and updates only the matching taxonomy entry, instead of a text replace on the serialized value. A database update rewrites rows that hold anything other than strings.

    Co-authored-by: Darren Ethier <1429108+nerrad@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-default-attributes-term-rename b/plugins/woocommerce/changelog/fix-default-attributes-term-rename
new file mode 100644
index 00000000000..2ecb7ea7509
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-default-attributes-term-rename
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep custom default attribute values intact when a global attribute term slug is renamed.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 0d2d83c3094..71471c545c4 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -365,6 +365,9 @@ class WC_Install {
 			// Run again to cancel all pattern fetch jobs now that scheduling is disabled.
 			'wc_update_1040_cleanup_legacy_ptk_patterns_fetching',
 		),
+		'11.2.2'   => array(
+			'wc_update_1122_remove_objects_from_default_attributes',
+		),
 		'11.3.0'   => array(
 			'wc_update_1130_set_legacy_variation_price_hash_option',
 			'wc_update_1130_delete_unpublished_variation_lookup_rows',
@@ -375,6 +378,8 @@ class WC_Install {
 		'11.4.0'   => array(
 			'wc_update_1140_re_enable_autoload_options',
 			'wc_update_1140_add_mpn_to_product_lookup_table',
+			// Run again for stores whose database was already at 11.3.0 or later, as they skip the 11.2.2 key.
+			'wc_update_1122_remove_objects_from_default_attributes',
 		),
 	);

diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index 6533bf9b52f..22eece017de 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -282,19 +282,87 @@ class WC_Post_Data {

 				$wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} SET meta_value = %s WHERE meta_key = %s AND meta_value = %s;", $edited_term->slug, 'attribute_' . sanitize_title( $taxonomy ), self::$editing_term->slug ) );

-				$wpdb->query(
+				// Edit the decoded array, not the serialized text: a text replace also hits matching text inside other values and breaks their lengths.
+				$old_slug = self::$editing_term->slug;
+				$meta_ids = $wpdb->get_col(
 					$wpdb->prepare(
-						"UPDATE {$wpdb->postmeta} SET meta_value = REPLACE( meta_value, %s, %s ) WHERE meta_key = '_default_attributes'",
-						serialize( self::$editing_term->taxonomy ) . serialize( self::$editing_term->slug ),
-						serialize( $edited_term->taxonomy ) . serialize( $edited_term->slug )
+						"SELECT meta_id FROM {$wpdb->postmeta} WHERE meta_key = '_default_attributes' AND meta_value LIKE %s",
+						'%' . $wpdb->esc_like( serialize( $taxonomy ) . serialize( $old_slug ) ) . '%' // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Matches the stored serialized form.
 					)
 				);
+
+				foreach ( array_chunk( array_map( 'absint', $meta_ids ), 500 ) as $chunk ) {
+					$rows     = $wpdb->get_results( "SELECT meta_id, post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_id IN (" . implode( ',', $chunk ) . ')' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- IDs are absint'd.
+					$updates  = array();
+					$post_ids = array();
+
+					foreach ( $rows as $row ) {
+						$default_attributes = @unserialize( trim( $row->meta_value ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Same as maybe_unserialize(), without objects.
+
+						// WooCommerce only stores strings here; leave rows holding anything else alone.
+						if ( ! is_array( $default_attributes ) || array_filter( $default_attributes, 'is_scalar' ) !== $default_attributes || ( $default_attributes[ $taxonomy ] ?? null ) !== $old_slug ) {
+							continue;
+						}
+
+						$default_attributes[ $taxonomy ] = $edited_term->slug;
+
+						$updates[ (int) $row->meta_id ] = array(
+							'old_value' => $row->meta_value,
+							'new_value' => serialize( $default_attributes ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Stored in the same format WordPress uses.
+						);
+						$post_ids[]                     = (int) $row->post_id;
+					}
+
+					if ( ! $updates ) {
+						continue;
+					}
+
+					$updated = self::update_post_meta_batch( $updates );
+
+					if ( false === $updated ) {
+						wc_get_logger()->error(
+							sprintf( 'Could not update the default attributes of products %1$s after a %2$s term was renamed: %3$s', implode( ', ', array_unique( $post_ids ) ), $taxonomy, $wpdb->last_error ),
+							array( 'source' => 'product-attributes' )
+						);
+					}
+
+					wp_cache_delete_multiple( array_unique( $post_ids ), 'post_meta' );
+				}
 			}
 		} else {
 			self::$editing_term = null;
 		}
 	}

+	/**
+	 * Update post metadata in one query, preserving concurrent edits.
+	 *
+	 * Each CASE branch assigns a replacement only when the row still holds its original value, compared under the column's collation; ELSE keeps newer values.
+	 * Writes bypass metadata hooks. The caller must invalidate the affected post meta caches.
+	 *
+	 * @param array<int, array{old_value: string, new_value: string}> $updates Original and replacement stored values, keyed by metadata ID.
+	 * @return bool Whether the query succeeded.
+	 */
+	private static function update_post_meta_batch( array $updates ): bool {
+		global $wpdb;
+
+		if ( ! $updates ) {
+			return true;
+		}
+
+		$cases = array();
+		foreach ( $updates as $meta_id => $values ) {
+			$cases[ (int) $meta_id ] = $wpdb->prepare(
+				'WHEN meta_id = %d AND meta_value = %s THEN %s',
+				$meta_id,
+				$values['old_value'],
+				$values['new_value']
+			);
+		}
+
+		return false !== $wpdb->query( "UPDATE {$wpdb->postmeta} SET meta_value = CASE " . implode( ' ', $cases ) . ' ELSE meta_value END WHERE meta_id IN (' . implode( ',', array_keys( $cases ) ) . ')' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Built from prepared fragments and integer IDs.
+	}
+
 	/**
 	 * Ensure floats are correctly converted to strings based on PHP locale.
 	 *
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index 2b0ccceaa34..51f387bc907 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -494,7 +494,16 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		$set_props = array();

 		foreach ( $meta_key_to_props as $meta_key => $prop ) {
-			$meta_value         = $post_meta_values[ $meta_key ][0] ?? null;
+			$meta_value = $post_meta_values[ $meta_key ][0] ?? null;
+
+			if ( '_default_attributes' === $meta_key && is_serialized( $meta_value ) ) {
+				// Default attributes only hold strings, so don't let a stored value create objects.
+				$default_attributes = @unserialize( trim( $meta_value ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Same as maybe_unserialize(), without objects.
+				// A whole-row object would otherwise be cast to an array of its class name and properties.
+				$set_props[ $prop ] = is_object( $default_attributes ) ? array() : $default_attributes;
+				continue;
+			}
+
 			$set_props[ $prop ] = maybe_unserialize( $meta_value ); // get_post_meta only unserializes single values.
 		}

diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index cc134a141ff..45c0d553b53 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -4261,3 +4261,81 @@ function wc_update_1140_add_mpn_to_product_lookup_table(): void {
 		$wpdb->query( "ALTER TABLE {$wpdb->wc_product_meta_lookup} ADD INDEX mpn (mpn(50))" );
 	}
 }
+
+/**
+ * Remove PHP objects from product default attributes, where WooCommerce only stores strings.
+ *
+ * Rows are decoded without allowing classes and written directly, so a stored object is never created. A row holding an object that
+ * can't be decoded is deleted; that product's default form values need to be set again.
+ *
+ * @since 11.2.2
+ */
+function wc_update_1122_remove_objects_from_default_attributes(): void {
+	global $wpdb;
+
+	// Objects serialize as O: or C:, so only matching rows are decoded. Each one is checked again below. LIKE rather than REGEXP, because
+	// MySQL 5.7's REGEXP stops reading at a NUL byte, which a custom default value can contain.
+	$meta_ids = $wpdb->get_col( "SELECT meta_id FROM {$wpdb->postmeta} WHERE meta_key = '_default_attributes' AND ( meta_value LIKE '%O:%' OR meta_value LIKE '%C:%' )" );
+
+	if ( '' !== $wpdb->last_error ) {
+		wc_get_logger()->error(
+			sprintf( 'Could not check product default attributes for PHP objects: %s', $wpdb->last_error ),
+			array( 'source' => 'wc-updater' )
+		);
+		return;
+	}
+
+	$product_ids = array();
+
+	foreach ( array_chunk( array_map( 'intval', $meta_ids ), 500 ) as $chunk ) {
+		$rows = $wpdb->get_results( "SELECT meta_id, post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_id IN (" . implode( ',', $chunk ) . ')' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Integer IDs.
+
+		foreach ( $rows as $row ) {
+			$raw   = trim( $row->meta_value );
+			$value = @unserialize( $raw, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Same as maybe_unserialize(), without objects.
+
+			// WooCommerce stores a flat array of strings here. An object, an array holding non-scalars, or a row that can't be decoded is cleaned
+			// when it contains object notation: the LIKE above also matches text such as "Info:" on case-insensitive collations, and the decoded
+			// value isn't walked because it can reference itself. Object text inside the strings of a flat array is plain text, and stays.
+			$may_hold_object = is_object( $value ) || false === $value || ( is_array( $value ) && array_filter( $value, 'is_scalar' ) !== $value );
+			if ( ! $may_hold_object || ! preg_match( '/[OC]:\d+:"/', $raw ) ) {
+				continue;
+			}
+
+			// Matched on the value read as well, so a row saved again in the meantime is left to that save.
+			$where = array(
+				'meta_id'    => $row->meta_id,
+				'meta_value' => $row->meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Matches the row by its primary key.
+			);
+			$done  = is_array( $value )
+				? $wpdb->update( $wpdb->postmeta, array( 'meta_value' => serialize( array_filter( $value, 'is_scalar' ) ) ), $where ) // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value, WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Update data, stored in the same format WordPress uses.
+				: $wpdb->delete( $wpdb->postmeta, $where );
+
+			if ( false === $done ) {
+				wc_get_logger()->error(
+					sprintf( 'Could not remove PHP objects from the default attributes of product %d: %s', $row->post_id, $wpdb->last_error ),
+					array( 'source' => 'wc-updater' )
+				);
+				continue;
+			}
+
+			// Nothing matched: the row was saved again after it was read.
+			if ( ! $done ) {
+				continue;
+			}
+
+			wp_cache_delete( (int) $row->post_id, 'post_meta' );
+			$product_ids[] = (int) $row->post_id;
+		}
+	}
+
+	if ( $product_ids ) {
+		wc_get_logger()->warning(
+			sprintf(
+				'Removed PHP objects or other non-string values from the default attributes of these products. WooCommerce only stores strings there, so check these products\' default form values: %s',
+				implode( ', ', array_unique( $product_ids ) )
+			),
+			array( 'source' => 'wc-updater' )
+		);
+	}
+}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 786dd41dd7a..2190e984573 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -12948,12 +12948,6 @@ parameters:
 			count: 3
 			path: includes/class-wc-post-data.php

-		-
-			message: '#^Access to an undefined property object\:\:\$taxonomy\.$#'
-			identifier: property.notFound
-			count: 1
-			path: includes/class-wc-post-data.php
-
 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:delete_by_order_id\(\)\.$#'
 			identifier: method.notFound
@@ -12990,12 +12984,6 @@ parameters:
 			count: 3
 			path: includes/class-wc-post-data.php

-		-
-			message: '#^Cannot access property \$taxonomy on WP_Term\|false\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/class-wc-post-data.php
-
 		-
 			message: '#^Expected 4 @param tags, found 3\.$#'
 			identifier: paramTag.count
diff --git a/plugins/woocommerce/tests/php/helpers/ObjectInstantiationProbe.php b/plugins/woocommerce/tests/php/helpers/ObjectInstantiationProbe.php
new file mode 100644
index 00000000000..9aba830d12c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/helpers/ObjectInstantiationProbe.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Object instantiation probe helper.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Records whether a stored serialized value was decoded into an object of this class.
+ */
+final class ObjectInstantiationProbe {
+
+	/**
+	 * Whether an instance was created by unserialize().
+	 *
+	 * @var bool
+	 */
+	public static bool $was_unserialized = false;
+
+	/**
+	 * Record instantiation from a serialized value.
+	 */
+	public function __wakeup(): void {
+		self::$was_unserialized = true;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
index 528b605a716..00651c81def 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
@@ -515,4 +515,124 @@ class WC_Post_Data_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( array( $product_1->get_id(), $product_2->get_id() ), $synced_ids, 'Each product should be synced at most once per request' );
 		$this->assertEmpty( $wc_deferred_product_sync, 'The queue should be empty after the sync' );
 	}
+
+	/**
+	 * @testdox Renaming an attribute term should rename the defaults of several products together and keep a default saved while the rename runs.
+	 */
+	public function test_renaming_attribute_term_keeps_defaults_saved_during_the_rename(): void {
+		global $wpdb;
+		$attribute = WC_Helper_Product::create_attribute( 'color', array( 'blue' ) );
+		$taxonomy  = $attribute['attribute_taxonomy'];
+		$products  = array();
+		foreach ( array( 'renamed', 'saved' ) as $label ) {
+			$products[ $label ] = new WC_Product_Variable();
+			$products[ $label ]->set_default_attributes(
+				array(
+					$taxonomy => 'blue',
+					'note'    => $label,
+				)
+			);
+			$products[ $label ]->save();
+		}
+		$saved_meanwhile = array(
+			$taxonomy => 'blue',
+			'note'    => 'saved meanwhile',
+		);
+		// Another request saves the second product between the rename reading the rows and writing them.
+		$save = function ( $query ) use ( $wpdb, $products, $saved_meanwhile, &$save ) {
+			if ( 0 === strpos( $query, "UPDATE {$wpdb->postmeta} SET meta_value = CASE" ) ) {
+				remove_filter( 'query', $save );
+				$wpdb->update(
+					$wpdb->postmeta,
+					array( 'meta_value' => serialize( $saved_meanwhile ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value, WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+					array(
+						'post_id'  => $products['saved']->get_id(),
+						'meta_key' => '_default_attributes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					)
+				);
+			}
+			return $query;
+		};
+		add_filter( 'query', $save );
+
+		wp_update_term( $attribute['term_ids'][0], $taxonomy, array( 'slug' => 'navy-blue' ) );
+		remove_filter( 'query', $save );
+
+		$this->assertSame(
+			array(
+				$taxonomy => 'navy-blue',
+				'note'    => 'renamed',
+			),
+			get_post_meta( $products['renamed']->get_id(), '_default_attributes', true ),
+			'The default for the renamed term should change'
+		);
+		$this->assertSame( $saved_meanwhile, get_post_meta( $products['saved']->get_id(), '_default_attributes', true ), 'A default saved during the rename should be kept' );
+	}
+
+	/**
+	 * @testdox Renaming an attribute term should update the default attributes that use it and leave other default values unchanged.
+	 */
+	public function test_renaming_attribute_term_updates_only_matching_default_attributes(): void {
+		$attribute = WC_Helper_Product::create_attribute( 'color', array( 'blue' ) );
+		$taxonomy  = $attribute['attribute_taxonomy'];
+		// A custom attribute value can hold the same text as the stored taxonomy/slug pair.
+		$custom_value = serialize( $taxonomy ) . serialize( 'blue' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+		$product      = new WC_Product_Variable();
+		$product->set_default_attributes(
+			array(
+				$taxonomy => 'blue',
+				'note'    => $custom_value,
+			)
+		);
+		$product->save();
+
+		wp_update_term( $attribute['term_ids'][0], $taxonomy, array( 'slug' => 'navy-blue' ) );
+
+		$this->assertSame(
+			array(
+				$taxonomy => 'navy-blue',
+				'note'    => $custom_value,
+			),
+			get_post_meta( $product->get_id(), '_default_attributes', true ),
+			'Only the default for the renamed term should change'
+		);
+	}
+
+	/**
+	 * @testdox Renaming an attribute term should leave default attributes that hold objects unchanged, without creating the objects.
+	 */
+	public function test_renaming_attribute_term_leaves_default_attributes_with_objects_alone(): void {
+		global $wpdb;
+
+		ObjectInstantiationProbe::$was_unserialized = false;
+		$attribute                                  = WC_Helper_Product::create_attribute( 'color', array( 'blue' ) );
+		$taxonomy                                   = $attribute['attribute_taxonomy'];
+		$product                                    = new WC_Product_Variable();
+		$product->set_default_attributes( array( $taxonomy => 'blue' ) );
+		$product->save();
+		// Store the row directly, as only a corrupted row can hold an object.
+		$stored = serialize( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+			array(
+				$taxonomy => 'blue',
+				'probe'   => new ObjectInstantiationProbe(),
+			)
+		);
+		$wpdb->update(
+			$wpdb->postmeta,
+			array( 'meta_value' => $stored ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			array(
+				'post_id'  => $product->get_id(),
+				'meta_key' => '_default_attributes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+			)
+		);
+
+		wp_update_term( $attribute['term_ids'][0], $taxonomy, array( 'slug' => 'navy-blue' ) );
+
+		$this->assertFalse( ObjectInstantiationProbe::$was_unserialized, 'The rename must not create objects stored in default attributes' );
+		$this->assertSame(
+			$stored,
+			$wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = '_default_attributes'", $product->get_id() ) ),
+			'A row holding an object should not be rewritten'
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
index e88164ad623..49385908f8d 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
@@ -858,4 +858,39 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		$store->update_product_sales( $product_id, 30.5, 'set' );
 		$this->assertSame( '30.500000', get_post_meta( $product_id, 'total_sales', true ) );
 	}
+
+	/**
+	 * @testdox Default attributes stored as a single object should load as empty, not as the object's class name.
+	 */
+	public function test_read_product_data_ignores_default_attributes_stored_as_an_object(): void {
+		$product = new WC_Product_Variable();
+		$product->save();
+		update_post_meta( $product->get_id(), '_default_attributes', new ObjectInstantiationProbe() );
+
+		$product = new WC_Product_Variable( $product->get_id() );
+
+		$this->assertSame( array(), $product->get_default_attributes() );
+	}
+
+	/**
+	 * @testdox Reading a product should not create objects from serialized product meta.
+	 */
+	public function test_read_product_data_does_not_unserialize_objects(): void {
+		ObjectInstantiationProbe::$was_unserialized = false;
+		$product                                    = new WC_Product_Variable();
+		$product->save();
+		update_post_meta(
+			$product->get_id(),
+			'_default_attributes',
+			array(
+				'probe' => new ObjectInstantiationProbe(),
+				'size'  => 'small',
+			)
+		);
+
+		$product = new WC_Product_Variable( $product->get_id() );
+
+		$this->assertFalse( ObjectInstantiationProbe::$was_unserialized, 'Stored product meta must not instantiate objects' );
+		$this->assertSame( array( 'size' => 'small' ), $product->get_default_attributes(), 'Scalar default attributes should still load' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 01dc783f8fd..acd6653482a 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -1394,4 +1394,65 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		$row = $wpdb->get_row( $wpdb->prepare( "SELECT payment_method FROM {$stats_table} WHERE order_id = %d", $order->get_id() ), ARRAY_A );
 		$this->assertSame( array( 'payment_method' => null ), $row, 'The row keeps a NULL payment method after a failed write.' );
 	}
+
+	/**
+	 * @testdox wc_update_1122_remove_objects_from_default_attributes should remove stored objects without creating them and keep plain text that only looks like one.
+	 */
+	public function test_wc_update_1122_remove_objects_from_default_attributes(): void {
+		global $wpdb;
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		ObjectInstantiationProbe::$was_unserialized = false;
+		// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+		$stored = array(
+			'object'     => serialize(
+				array(
+					'pa_color' => 'blue',
+					'probe'    => new ObjectInstantiationProbe(),
+				)
+			),
+			'unreadable' => 'a:1:{s:5:"probe";O:24:"ObjectInstantiationProbe":0:{}',
+			'only'       => serialize( new ObjectInstantiationProbe() ),
+			'broken'     => 'a:1:{s:4:"note";s:5:"TO: Größe";}',
+			'text'       => serialize( array( 'note' => 'O:8:"stdClass":0:{}' ) ),
+			'nested'     => serialize(
+				array(
+					'pa_color' => 'blue',
+					'list'     => array( new ObjectInstantiationProbe() ),
+				)
+			),
+			'recursive'  => 'a:2:{i:0;R:1;i:1;s:2:"O:";}',
+		);
+		// phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+		$product_ids = array();
+		foreach ( $stored as $label => $meta_value ) {
+			$product_ids[ $label ] = wp_insert_post(
+				array(
+					'post_type'  => 'product',
+					'post_title' => $label,
+				)
+			);
+			// Stored directly, as only a corrupted row can hold an object.
+			$wpdb->insert(
+				$wpdb->postmeta,
+				array(
+					'post_id'    => $product_ids[ $label ],
+					'meta_key'   => '_default_attributes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+				)
+			);
+		}
+
+		wc_update_1122_remove_objects_from_default_attributes();
+
+		$read = fn( string $label ) => $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = '_default_attributes'", $product_ids[ $label ] ) );
+		$this->assertFalse( ObjectInstantiationProbe::$was_unserialized, 'The migration must not create the stored objects' );
+		$this->assertSame( serialize( array( 'pa_color' => 'blue' ) ), $read( 'object' ), 'Objects are removed and string defaults kept' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+		$this->assertNull( $read( 'unreadable' ), 'A row holding an object that cannot be decoded is deleted' );
+		$this->assertNull( $read( 'only' ), 'A row that is only an object is deleted' );
+		$this->assertSame( $stored['broken'], $read( 'broken' ), 'A row that cannot be decoded but holds no object is kept' );
+		$this->assertSame( $stored['text'], $read( 'text' ), 'Text that only looks like an object is kept' );
+		$this->assertSame( serialize( array( 'pa_color' => 'blue' ) ), $read( 'nested' ), 'Objects nested in arrays are removed' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+		$this->assertSame( $stored['recursive'], $read( 'recursive' ), 'A self-referencing row without objects is kept, and does not stop the migration' );
+	}
 }