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' );
+ }
}