Commit fe30f760f94 for woocommerce

commit fe30f760f94d7e12550ed183734d543c0328e480
Author: Chris Huber <chubes@extrachill.com>
Date:   Mon Jun 29 12:42:25 2026 -0400

    Performance: skip redundant meta existence checks on product create (#65595)

diff --git a/plugins/woocommerce/changelog/65595-fix-rest-batch-postmeta-create-fast-path b/plugins/woocommerce/changelog/65595-fix-rest-batch-postmeta-create-fast-path
new file mode 100644
index 00000000000..aedf64b4ba5
--- /dev/null
+++ b/plugins/woocommerce/changelog/65595-fix-rest-batch-postmeta-create-fast-path
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Improve product and variation creation performance by avoiding redundant internal meta existence checks.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-data-store-wp.php b/plugins/woocommerce/includes/data-stores/class-wc-data-store-wp.php
index 657d7c2c1d6..cf941854c27 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-data-store-wp.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-data-store-wp.php
@@ -247,24 +247,78 @@ class WC_Data_Store_WP {
 	 *
 	 * Note: WordPress `get_metadata` function returns an empty string when meta data does not exist.
 	 *
-	 * @param WC_Data $object The WP_Data object (WC_Coupon for coupons, etc).
+	 * @param WC_Data $data_object The WP_Data object (WC_Coupon for coupons, etc).
 	 * @param string  $meta_key Meta key to update.
 	 * @param mixed   $meta_value Value to save.
-	 *
+	 * @param bool    $creating Whether the object is being created.
+	 * @param array   $existing_meta_keys Existing meta keys map, maintained across calls during creation. Passed by reference.
 	 * @since 3.6.0 Added to prevent empty meta being stored unless required.
 	 *
-	 * @return bool True if updated/deleted.
+	 * @return bool True if added/updated/deleted.
 	 */
-	protected function update_or_delete_post_meta( $object, $meta_key, $meta_value ) {
-		if ( in_array( $meta_value, array( array(), '' ), true ) && ! in_array( $meta_key, $this->must_exist_meta_keys, true ) ) {
-			$updated = delete_post_meta( $object->get_id(), $meta_key );
+	protected function update_or_delete_post_meta( $data_object, $meta_key, $meta_value, $creating = false, &$existing_meta_keys = null ) {
+		$is_empty_meta_value = in_array( $meta_value, array( array(), '' ), true );
+		$must_exist          = in_array( $meta_key, $this->must_exist_meta_keys, true );
+		$object_id           = $data_object->get_id();
+
+		if ( $creating ) {
+			if ( ! is_array( $existing_meta_keys ) ) {
+				$existing_meta_keys = $this->get_existing_meta_keys( $object_id );
+			}
+
+			$meta_exists = isset( $existing_meta_keys[ $meta_key ] );
+
+			if ( $is_empty_meta_value && ! $must_exist ) {
+				if ( ! $meta_exists ) {
+					return false;
+				}
+
+				$updated = delete_post_meta( $object_id, $meta_key );
+				unset( $existing_meta_keys[ $meta_key ] );
+
+				return (bool) $updated;
+			}
+
+			if ( $meta_exists ) {
+				$updated = update_post_meta( $object_id, $meta_key, $meta_value );
+			} else {
+				$updated = add_post_meta( $object_id, $meta_key, $meta_value );
+				if ( $updated ) {
+					$existing_meta_keys[ $meta_key ] = true;
+				}
+			}
+
+			return (bool) $updated;
+		}
+
+		if ( $is_empty_meta_value && ! $must_exist ) {
+			$updated = delete_post_meta( $object_id, $meta_key );
 		} else {
-			$updated = update_post_meta( $object->get_id(), $meta_key, $meta_value );
+			$updated = update_post_meta( $object_id, $meta_key, $meta_value );
 		}

 		return (bool) $updated;
 	}

+	/**
+	 * Get the set of meta keys an object already has, to avoid per-key existence queries during creation.
+	 *
+	 * @param int $object_id Object ID.
+	 * @return array Map of existing meta key => true.
+	 */
+	protected function get_existing_meta_keys( $object_id ) {
+		global $wpdb;
+
+		$meta_keys = $wpdb->get_col(
+			$wpdb->prepare(
+				"SELECT meta_key FROM {$wpdb->postmeta} WHERE post_id = %d",
+				$object_id
+			)
+		);
+
+		return array_fill_keys( $meta_keys, true );
+	}
+
 	/**
 	 * Get valid WP_Query args from a WC_Object_Query's query variables.
 	 *
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 de9b861600d..59798c645e5 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
@@ -248,7 +248,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 			$post_object = get_post( $product->get_id() );
 			$product->set_status( $post_object->post_status );

-			$this->update_post_meta( $product, true );
+			$this->update_post_meta_internal( $product, true, true );
 			$this->update_terms( $product, true );
 			$this->update_visibility( $product, true );
 			$this->update_attributes( $product, true );
@@ -697,12 +697,31 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 	/**
 	 * Helper method that updates all the post meta for a product based on it's settings in the WC_Product class.
 	 *
+	 * Subclasses that override this method to write custom product-type meta should also override
+	 * update_post_meta_internal() so those writes run during object creation.
+	 *
 	 * @param WC_Product $product Product object.
 	 * @param bool       $force Force update. Used during create.
 	 * @since 3.0.0
 	 * @return void
 	 */
 	protected function update_post_meta( &$product, $force = false ) {
+		$this->update_post_meta_internal( $product, $force, false );
+	}
+
+	/**
+	 * Internal implementation of update_post_meta() that also knows whether the product is being created.
+	 *
+	 * Subclasses that override update_post_meta() to write custom product-type meta should also override
+	 * this method so those writes run during object creation.
+	 *
+	 * @param WC_Product $product Product object.
+	 * @param bool       $force Force update. Used during create.
+	 * @param bool       $creating Whether the product is being created.
+	 * @param array      $existing_meta_keys Existing meta keys map, maintained across calls during creation. Passed by reference.
+	 * @return void
+	 */
+	protected function update_post_meta_internal( &$product, $force, $creating, &$existing_meta_keys = null ) {
 		$meta_key_to_props = array(
 			'_sku'                   => 'sku',
 			'_global_unique_id'      => 'global_unique_id',
@@ -788,7 +807,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 					break;
 			}

-			$updated = $this->update_or_delete_post_meta( $product, $meta_key, $value );
+			$updated = $this->update_or_delete_post_meta( $product, $meta_key, $value, $creating, $existing_meta_keys );

 			if ( $updated ) {
 				$this->updated_props[] = $prop;
@@ -810,7 +829,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 			$cogs_value = apply_filters( 'woocommerce_save_product_cogs_value', $cogs_value, $product );

 			if ( false !== $cogs_value ) {
-				$updated = $this->update_or_delete_post_meta( $product, '_cogs_total_value', is_null( $cogs_value ) ? '' : $cogs_value );
+				$updated = $this->update_or_delete_post_meta( $product, '_cogs_total_value', is_null( $cogs_value ) ? '' : $cogs_value, $creating, $existing_meta_keys );
 				if ( $updated ) {
 					$this->updated_props[] = 'cogs_value';
 				}
@@ -828,7 +847,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 				if ( is_callable( array( $product, $function ) ) ) {
 					$value   = $product->{$function}( 'edit' );
 					$value   = is_string( $value ) ? wp_slash( $value ) : $value;
-					$updated = $this->update_or_delete_post_meta( $product, $meta_key, $value );
+					$updated = $this->update_or_delete_post_meta( $product, $meta_key, $value, $creating, $existing_meta_keys );

 					if ( $updated ) {
 						$this->updated_props[] = $key;
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-grouped-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-grouped-data-store-cpt.php
index f21b9e6fa74..53188472c12 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-grouped-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-grouped-data-store-cpt.php
@@ -24,6 +24,19 @@ class WC_Product_Grouped_Data_Store_CPT extends WC_Product_Data_Store_CPT implem
 	 * @since 3.0.0
 	 */
 	protected function update_post_meta( &$product, $force = false ) {
+		$this->update_post_meta_internal( $product, $force, false );
+	}
+
+	/**
+	 * Internal implementation of update_post_meta() that also knows whether the product is being created.
+	 *
+	 * @param WC_Product $product Product object.
+	 * @param bool       $force Force update. Used during create.
+	 * @param bool       $creating Whether the product is being created.
+	 * @param array      $existing_meta_keys Existing meta keys map, maintained across calls during creation. Passed by reference.
+	 * @return void
+	 */
+	protected function update_post_meta_internal( &$product, $force, $creating, &$existing_meta_keys = null ) {
 		$meta_key_to_props = array(
 			'_children' => 'children',
 		);
@@ -38,7 +51,7 @@ class WC_Product_Grouped_Data_Store_CPT extends WC_Product_Data_Store_CPT implem
 			}
 		}

-		parent::update_post_meta( $product, $force );
+		parent::update_post_meta_internal( $product, $force, $creating, $existing_meta_keys );
 	}

 	/**
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
index 731f2d0a242..6df569ac85a 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
@@ -20,6 +20,18 @@ if ( ! defined( 'ABSPATH' ) ) {
  * @version  3.0.0
  */
 class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT implements WC_Object_Data_Store_Interface {
+	/**
+	 * Meta data which should exist in the DB, even if empty.
+	 *
+	 * @since 3.6.0
+	 *
+	 * @var array
+	 */
+	protected $must_exist_meta_keys = array(
+		'_tax_class',
+		'_variation_description',
+	);
+
 	/**
 	 * Callback to remove unwanted meta data.
 	 *
@@ -157,7 +169,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
 		if ( $id && ! is_wp_error( $id ) ) {
 			$product->set_id( $id );

-			$this->update_post_meta( $product, true );
+			$this->update_post_meta_internal( $product, true, true );
 			$this->update_terms( $product, true );
 			$this->update_visibility( $product, true );
 			$this->update_attributes( $product, true );
@@ -595,6 +607,19 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
 	 * @param bool       $force Force update. Used during create.
 	 */
 	public function update_post_meta( &$product, $force = false ) {
+		$this->update_post_meta_internal( $product, $force, false );
+	}
+
+	/**
+	 * Internal implementation of update_post_meta() that also knows whether the variation is being created.
+	 *
+	 * @since 3.0.0
+	 * @param WC_Product $product Product object.
+	 * @param bool       $force Force update. Used during create.
+	 * @param bool       $creating Whether the variation is being created.
+	 * @param array      $existing_meta_keys Existing meta keys map, maintained across calls during creation. Passed by reference.
+	 */
+	protected function update_post_meta_internal( &$product, $force, $creating, &$existing_meta_keys = null ) {
 		$meta_key_to_props = array(
 			'_variation_description' => 'description',
 		);
@@ -603,7 +628,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl

 		foreach ( $props_to_update as $meta_key => $prop ) {
 			$value   = $product->{"get_$prop"}( 'edit' );
-			$updated = update_post_meta( $product->get_id(), $meta_key, $value );
+			$updated = $this->update_or_delete_post_meta( $product, $meta_key, $value, $creating, $existing_meta_keys );
 			if ( $updated ) {
 				$this->updated_props[] = $prop;
 			}
@@ -625,14 +650,14 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
 			$cogs_value_is_additive = apply_filters( 'woocommerce_save_product_cogs_is_additive_flag', $cogs_value_is_additive, $product );

 			if ( ! is_null( $cogs_value_is_additive ) ) {
-				$updated = $this->update_or_delete_post_meta( $product, '_cogs_value_is_additive', $cogs_value_is_additive ? 'yes' : '' );
+				$updated = $this->update_or_delete_post_meta( $product, '_cogs_value_is_additive', $cogs_value_is_additive ? 'yes' : '', $creating, $existing_meta_keys );
 				if ( $updated ) {
 					$this->updated_props[] = 'cogs_value_is_additive';
 				}
 			}
 		}

-		parent::update_post_meta( $product, $force );
+		parent::update_post_meta_internal( $product, $force, $creating, $existing_meta_keys );
 	}

 	/**
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 27c688253fa..33a2a9e800d 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
@@ -362,6 +362,57 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		$this->assertSame( '30.000000', get_post_meta( $product_id, '_stock', true ) );
 	}

+	/**
+	 * @testdox Creating a product updates internal meta pre-created by save_post hooks instead of duplicating it.
+	 */
+	public function test_create_updates_internal_meta_precreated_by_save_post_hooks(): void {
+		$precreate_stock_meta = function ( $post_id, $post, $update ) {
+			if ( $update || metadata_exists( 'post', $post_id, '_stock' ) ) {
+				return;
+			}
+
+			add_post_meta( $post_id, '_manage_stock', 'no' );
+			add_post_meta( $post_id, '_stock', '0' );
+		};
+
+		add_action( 'save_post_product', $precreate_stock_meta, 10, 3 );
+
+		try {
+			$product = new WC_Product();
+			$product->set_name( 'Pre-created meta product' );
+			$product->set_manage_stock( true );
+			$product->set_stock_quantity( 5 );
+			$product->save();
+		} finally {
+			remove_action( 'save_post_product', $precreate_stock_meta, 10 );
+		}
+
+		$product_id = $product->get_id();
+
+		$this->assertSame( array( 'yes' ), get_post_meta( $product_id, '_manage_stock', false ) );
+		$this->assertSame( array( '5' ), get_post_meta( $product_id, '_stock', false ) );
+		$this->assertTrue( wc_get_product( $product_id )->get_manage_stock() );
+		$this->assertSame( 5, wc_get_product( $product_id )->get_stock_quantity() );
+	}
+
+	/**
+	 * @testdox Creating a grouped product persists children on the initial save.
+	 */
+	public function test_grouped_product_create_persists_children_on_initial_save(): void {
+		$child_1 = WC_Helper_Product::create_simple_product();
+		$child_2 = WC_Helper_Product::create_simple_product();
+
+		$product = new WC_Product_Grouped();
+		$product->set_name( 'Grouped product' );
+		$product->set_children( array( $child_1->get_id(), $child_2->get_id() ) );
+		$product->save();
+
+		$product_id = $product->get_id();
+
+		$this->assertSame( array( $child_1->get_id(), $child_2->get_id() ), get_post_meta( $product_id, '_children', true ) );
+		$this->assertSame( array( $child_1->get_id(), $child_2->get_id() ), wc_get_product( $product_id )->get_children() );
+	}
+
 	/**
 	 * Test update_product_sales updates on the meta-entry.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php
index d1fdb94a2dc..b61bca2786a 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variations-data-store-cpt-test.php
@@ -295,6 +295,73 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		$this->assertEquals( $flag_value ? 'yes' : '', get_post_meta( $product->get_id(), '_cogs_value_is_additive', true ) );
 	}

+	/**
+	 * @testdox Creating a variation inserts known-new internal meta without update/delete hooks.
+	 */
+	public function test_create_inserts_known_new_internal_meta_without_update_or_delete_hooks() {
+		$parent = new WC_Product_Variable();
+		$parent->set_name( 'Variable product' );
+		$parent_id = $parent->save();
+
+		$added_meta_events   = array();
+		$updated_meta_events = array();
+		$deleted_meta_events = array();
+
+		$added_meta_observer = function ( $meta_id, $object_id, $meta_key ) use ( &$added_meta_events ) {
+			$added_meta_events[] = array( $object_id, $meta_key );
+		};
+
+		$updated_meta_observer = function ( $meta_id, $object_id, $meta_key ) use ( &$updated_meta_events ) {
+			$updated_meta_events[] = array( $object_id, $meta_key );
+		};
+
+		$deleted_meta_observer = function ( $meta_ids, $object_id, $meta_key ) use ( &$deleted_meta_events ) {
+			$deleted_meta_events[] = array( $object_id, $meta_key );
+		};
+
+		add_action( 'added_post_meta', $added_meta_observer, 10, 3 );
+		add_action( 'updated_post_meta', $updated_meta_observer, 10, 3 );
+		add_action( 'deleted_post_meta', $deleted_meta_observer, 10, 3 );
+
+		try {
+			$variation = new WC_Product_Variation();
+			$variation->set_parent_id( $parent_id );
+			$variation->set_sku( 'test-variation-fast-path' );
+			$variation->set_regular_price( '12.50' );
+			$variation->set_manage_stock( true );
+			$variation->set_stock_quantity( 4 );
+			$variation->save();
+		} finally {
+			remove_action( 'added_post_meta', $added_meta_observer, 10 );
+			remove_action( 'updated_post_meta', $updated_meta_observer, 10 );
+			remove_action( 'deleted_post_meta', $deleted_meta_observer, 10 );
+		}
+
+		$variation_id           = $variation->get_id();
+		$filter_to_variation_id = static function ( array $events ) use ( $variation_id ) {
+			return array_values(
+				array_map(
+					static fn( $event ) => $event[1],
+					array_filter( $events, static fn( $event ) => (int) $event[0] === $variation_id )
+				)
+			);
+		};
+
+		$added_meta_keys   = $filter_to_variation_id( $added_meta_events );
+		$updated_meta_keys = $filter_to_variation_id( $updated_meta_events );
+		$deleted_meta_keys = $filter_to_variation_id( $deleted_meta_events );
+
+		$this->assertContains( '_sku', $added_meta_keys );
+		$this->assertContains( '_regular_price', $added_meta_keys );
+		$this->assertContains( '_price', $added_meta_keys );
+		$this->assertContains( '_manage_stock', $added_meta_keys );
+		$this->assertContains( '_stock', $added_meta_keys );
+		$this->assertContains( '_variation_description', $added_meta_keys );
+		$this->assertEmpty( $updated_meta_keys );
+		$this->assertEmpty( $deleted_meta_keys );
+		$this->assertSame( array( '' ), get_post_meta( $variation_id, '_variation_description', false ) );
+	}
+
 	/**
 	 * Create a variable product and return one of its variations.
 	 *