Commit 79c1eba3fef for woocommerce
commit 79c1eba3fefe1df2a1e985c47189f5f6fe61acce
Author: Anand Rajaram <anandrajaram21@gmail.com>
Date: Thu Jun 18 15:10:25 2026 +0530
Remove stale variation attribute meta on updates (#65766)
diff --git a/plugins/woocommerce/changelog/65766-56335-stale-attribute-meta b/plugins/woocommerce/changelog/65766-56335-stale-attribute-meta
new file mode 100644
index 00000000000..2ab1bd42626
--- /dev/null
+++ b/plugins/woocommerce/changelog/65766-56335-stale-attribute-meta
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Remove stale variation attribute meta on updates
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index 5359505289b..6a856dac094 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
use Automattic\WooCommerce\Proxies\LegacyProxy;
@@ -79,7 +80,7 @@ class WC_Post_Data {
add_action( 'edited_term', array( __CLASS__, 'handle_attribute_term_updated' ), 10, 3 );
add_action( 'delete_term', array( __CLASS__, 'handle_attribute_term_deleted' ), 10, 4 );
// Product Variations - Parent Product Updates Attributes.
- add_action( 'woocommerce_product_attributes_updated', array( __CLASS__, 'on_product_attributes_updated' ), 10, 1 );
+ add_action( 'woocommerce_product_attributes_updated', array( __CLASS__, 'on_product_attributes_updated' ), 10, 2 );
// Product Variations - Action Scheduler.
add_action( 'wc_regenerate_product_variation_summaries', array( __CLASS__, 'regenerate_product_variation_summaries' ), 10, 1 );
add_action( 'wc_regenerate_attribute_variation_summaries', array( __CLASS__, 'regenerate_attribute_variation_summaries' ), 10, 1 );
@@ -963,11 +964,19 @@ class WC_Post_Data {
*
* @since 10.2.0
* @param WC_Product $product The variable product whose attributes were updated.
+ * @param bool $force Whether the update was forced. Forced updates originate from the read-time
+ * backwards-compatibility migration in the data store, where the product's
+ * in-memory attributes can be an incomplete view of what's stored, so stale
+ * attribute meta cleanup must be skipped to avoid deleting valid meta.
*
* @return void
*/
- public static function on_product_attributes_updated( $product ) {
+ public static function on_product_attributes_updated( $product, $force = false ) {
if ( $product->is_type( 'variable' ) ) {
+ if ( ! $force ) {
+ self::delete_stale_variation_attribute_meta( $product );
+ }
+
global $wpdb;
$threshold = self::get_variation_summaries_sync_threshold();
$variation_ids = $wpdb->get_col(
@@ -1002,6 +1011,71 @@ class WC_Post_Data {
}
}
+ /**
+ * Deletes child variation attribute meta that no longer maps to a parent variation attribute.
+ *
+ * @param WC_Product $product The variable product whose attributes were updated.
+ *
+ * @return void
+ */
+ private static function delete_stale_variation_attribute_meta( $product ) {
+ global $wpdb;
+
+ $valid_attribute_meta_keys = array();
+ foreach ( $product->get_attributes() as $attribute ) {
+ if ( ! $attribute instanceof WC_Product_Attribute || ! $attribute->get_variation() ) {
+ continue;
+ }
+
+ $valid_attribute_meta_keys[] = wc_variation_attribute_name( $attribute->get_name() );
+ }
+ $valid_attribute_meta_keys = array_values( array_unique( $valid_attribute_meta_keys ) );
+
+ $query_args = array(
+ $product->get_id(),
+ 'product_variation',
+ $wpdb->esc_like( 'attribute_' ) . '%',
+ );
+ $not_in_sql = '';
+ if ( ! empty( $valid_attribute_meta_keys ) ) {
+ $not_in_sql = ' AND pm.meta_key NOT IN ( ' . implode( ', ', array_fill( 0, count( $valid_attribute_meta_keys ), '%s' ) ) . ' )';
+ $query_args = array_merge( $query_args, $valid_attribute_meta_keys );
+ }
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ $stale_rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT pm.post_id, pm.meta_key
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
+ WHERE p.post_parent = %d
+ AND p.post_type = %s
+ AND pm.meta_key LIKE %s
+ {$not_in_sql}",
+ ...$query_args
+ )
+ );
+
+ if ( empty( $stale_rows ) ) {
+ return;
+ }
+
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ $variation_ids = array();
+ foreach ( $stale_rows as $row ) {
+ $variation_id = (int) $row->post_id;
+ $variation_ids[ $variation_id ] = $variation_id;
+ delete_post_meta( $variation_id, $row->meta_key );
+ }
+
+ $invalidator = wc_get_container()->get( ProductVersionStringInvalidator::class );
+ foreach ( $variation_ids as $variation_id ) {
+ clean_post_cache( $variation_id );
+ $invalidator->invalidate( $variation_id );
+ }
+ $invalidator->invalidate( $product->get_id() );
+ }
+
/**
* Regenerates variation summaries for all variations of a variable product.
*
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 1c9ba3227b7..82c1c5c8476 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
@@ -252,4 +252,45 @@ 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 Should delete variation attribute meta when the parent variation attribute is removed.
+ */
+ public function test_product_attributes_updated_deletes_stale_variation_attribute_meta(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $product->get_children();
+
+ foreach ( $variation_ids as $variation_id ) {
+ update_post_meta( $variation_id, 'attribute_pa_colour', 'red' );
+ $this->assertSame( 'red', get_post_meta( $variation_id, 'attribute_pa_colour', true ), 'Variation should start with colour attribute meta' );
+ }
+
+ $attributes = $product->get_attributes();
+ unset( $attributes['pa_colour'] );
+ $product->set_attributes( $attributes );
+ $product->save();
+
+ foreach ( $variation_ids as $variation_id ) {
+ $this->assertFalse( metadata_exists( 'post', $variation_id, 'attribute_pa_colour' ), 'Removed parent variation attribute meta should be deleted from each child variation' );
+ }
+ $this->assertSame( 'huge', get_post_meta( $variation_ids[2], 'attribute_pa_size', true ), 'Remaining parent variation attribute meta should be preserved' );
+
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $product->get_children();
+
+ foreach ( $variation_ids as $variation_id ) {
+ update_post_meta( $variation_id, 'attribute_pa_size', 'huge' );
+ update_post_meta( $variation_id, 'attribute_pa_colour', 'red' );
+ update_post_meta( $variation_id, 'attribute_pa_number', '2' );
+ }
+
+ $product->set_attributes( array() );
+ $product->save();
+
+ foreach ( $variation_ids as $variation_id ) {
+ $this->assertFalse( metadata_exists( 'post', $variation_id, 'attribute_pa_size' ), 'Removed size attribute meta should be deleted from each child variation' );
+ $this->assertFalse( metadata_exists( 'post', $variation_id, 'attribute_pa_colour' ), 'Removed colour attribute meta should be deleted from each child variation' );
+ $this->assertFalse( metadata_exists( 'post', $variation_id, 'attribute_pa_number' ), 'Removed number attribute meta should be deleted from each child variation' );
+ }
+ }
}