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