Commit 150a0020953 for woocommerce

commit 150a00209534523ea0697e9629abee28a78dd30d
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Fri Nov 6 15:01:08 2026 +0100

    Preserve product-type price ranges during lookup refreshes (#70676)

    * fix: preserve product lookup price ranges

    * chore: add price-range changelog

    * fix: keep custom generic product store lookup data

    Only hand the lookup refresh to the product type's store when it extends
    the current store. A store that replaces the generic product store now
    keeps its own lookup data for variations and other Core types, and a
    store that only proxies through __call no longer skips the write.

    Also share the re-entry guard across subclasses on PHP 7.4 and 8.0, and
    route set_product_stock() through the same refresh.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXvk1aFWgz6vnqD5rqx

    * Update plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-lookup-refresh-test.php

    Co-authored-by: Justin P <228780+layoutd@users.noreply.github.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
    Co-authored-by: Justin P <228780+layoutd@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/wooplug-8038-lookup-table-refresh-in-update_product_sales b/plugins/woocommerce/changelog/wooplug-8038-lookup-table-refresh-in-update_product_sales
new file mode 100644
index 00000000000..900bd6f4eb4
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-8038-lookup-table-refresh-in-update_product_sales
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve bundle and composite price ranges after sales, stock changes, and scheduled sales.
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 51f387bc907..509e0e34ceb 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
@@ -109,6 +109,13 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 	 */
 	protected $updated_props = array();

+	/**
+	 * Lookup refreshes currently handed off to a product-type store, keyed by blog and product ID.
+	 *
+	 * @var array<string, bool>
+	 */
+	private static $refreshing_lookup_table = array();
+

 	/**
 	 * Method to obtain DB lock on SKU to make sure we only
@@ -1005,11 +1012,36 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 	/**
 	 * Refresh the product meta lookup table row for a given product.
 	 *
+	 * When the product type's data store extends this one, the refresh runs there so its lookup data is used.
+	 *
 	 * @since 10.8.0
 	 * @param int $product_id Product ID.
 	 * @return void
 	 */
 	public function refresh_product_lookup_table( int $product_id ): void {
+		$product_id = absint( $product_id );
+		if ( ! $product_id ) {
+			return;
+		}
+		$refresh_key = get_current_blog_id() . ':' . $product_id;
+
+		// Custom stores can delegate back here through a different store instance.
+		if ( empty( self::$refreshing_lookup_table[ $refresh_key ] ) ) {
+			$product_type = WC_Product_Factory::get_product_type( $product_id );
+			$data_store   = WC_Data_Store::load( is_string( $product_type ) ? 'product-' . $product_type : 'product' );
+
+			// Only hand off to a more specific store, so a customized generic store keeps its own lookup data.
+			if ( is_subclass_of( $data_store->get_current_class_name(), get_class( $this ) ) ) {
+				self::$refreshing_lookup_table[ $refresh_key ] = true;
+				try {
+					$data_store->__call( 'refresh_product_lookup_table', array( $product_id ) );
+				} finally {
+					unset( self::$refreshing_lookup_table[ $refresh_key ] );
+				}
+				return;
+			}
+		}
+
 		$this->update_lookup_table( $product_id, 'wc_product_meta_lookup' );
 	}

@@ -1867,7 +1899,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		// Sometimes I wonder if it shouldn't be part of update_lookup_table.
 		wp_cache_delete( $product_id_with_stock, 'post_meta' );

-		$this->update_lookup_table( $product_id_with_stock, 'wc_product_meta_lookup' );
+		$this->refresh_product_lookup_table( absint( $product_id_with_stock ) );
 	}

 	/**
@@ -1936,7 +1968,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		// Sometimes I wonder if it shouldn't be part of update_lookup_table.
 		wp_cache_delete( $product_id_with_stock, 'post_meta' );

-		$this->update_lookup_table( $product_id_with_stock, 'wc_product_meta_lookup' );
+		$this->refresh_product_lookup_table( absint( $product_id_with_stock ) );

 		/**
 		 * Fire an action for this direct update so it can be detected by other code.
@@ -2000,7 +2032,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da

 		wp_cache_delete( $product_id, 'post_meta' );

-		$this->update_lookup_table( $product_id, 'wc_product_meta_lookup' );
+		$this->refresh_product_lookup_table( absint( $product_id ) );

 		/**
 		 * Fire an action for this direct update so it can be detected by other code.
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-lookup-refresh-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-lookup-refresh-test.php
new file mode 100644
index 00000000000..8ec0a926e7f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-lookup-refresh-test.php
@@ -0,0 +1,268 @@
+<?php
+/**
+ * Product-type data store coverage for direct lookup refreshes.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Tests for lookup refreshes after sales, stock and scheduled price updates.
+ */
+class WC_Product_Lookup_Refresh_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should preserve a product data store's custom lookup prices after sales, stock and scheduled sale updates.
+	 * @testWith ["sales", 10]
+	 *           ["stock", 10]
+	 *           ["sale-start", 5]
+	 *           ["sale-end", 10]
+	 *           ["direct-sales", 10]
+	 *           ["direct-stock", 10]
+	 *           ["direct-sales", 10, "fqcn"]
+	 *           ["direct-sales", 10, "alias"]
+	 * @param string $operation Update to perform.
+	 * @param int    $min_price Expected minimum price.
+	 * @param string $registration Data-store registration shape.
+	 */
+	public function test_refresh_uses_product_data_store( string $operation, int $min_price, string $registration = 'object' ): void {
+		$store = new class() extends WC_Product_Data_Store_CPT {
+			/**
+			 * Supply the configuration's highest price, as extension data stores do.
+			 *
+			 * @param int    $id Product ID.
+			 * @param string $table Lookup table name.
+			 * @return array
+			 */
+			protected function get_data_for_lookup_table( $id, $table ) {
+				$data = parent::get_data_for_lookup_table( $id, $table );
+				if ( 'wc_product_meta_lookup' === $table ) {
+					$data['max_price'] = get_post_meta( $id, '_test_max_price', true );
+				}
+				return $data;
+			}
+		};
+		if ( 'fqcn' === $registration ) {
+			$store = '\\' . get_class( $store );
+		} elseif ( 'alias' === $registration ) {
+			if ( ! class_exists( 'WC_Test_Lookup_Store_Alias', false ) ) {
+				class_alias( get_class( $store ), 'WC_Test_Lookup_Store_Alias' );
+			}
+			$store = 'WC_Test_Lookup_Store_Alias';
+		}
+		add_filter(
+			'woocommerce_data_stores',
+			function ( $stores ) use ( $store ) {
+				$stores['product-simple'] = $store;
+				return $stores;
+			}
+		);
+		$product = new WC_Product_Simple();
+		$product->set_regular_price( '10' );
+		$product->set_sale_price( '5' );
+		$product->set_date_on_sale_from( time() + DAY_IN_SECONDS );
+		$product->set_manage_stock( true );
+		$product->set_stock_quantity( 10 );
+		$product->update_meta_data( '_test_max_price', '30' );
+		$product->save();
+		$product->get_data_store()->refresh_product_lookup_table( $product->get_id() );
+
+		global $wpdb;
+		$lookup = static function () use ( $wpdb, $product ) {
+			return $wpdb->get_row(
+				$wpdb->prepare( "SELECT min_price, max_price, total_sales, stock_quantity FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $product->get_id() ),
+				ARRAY_A
+			);
+		};
+		$this->assertSame( 30.0, (float) $lookup()['max_price'], 'The fixture must start with the custom lookup range.' );
+
+		switch ( $operation ) {
+			case 'direct-sales':
+				WC_Data_Store::load( 'product' )->update_product_sales( $product->get_id(), 2, 'increase' );
+				$this->assertSame( 2, (int) $lookup()['total_sales'] );
+				break;
+			case 'direct-stock':
+				WC_Data_Store::load( 'product' )->update_product_stock( $product->get_id(), 4 );
+				$this->assertSame( 4, (int) $lookup()['stock_quantity'] );
+				break;
+			case 'sales':
+				$order = wc_create_order();
+				$order->add_product( $product, 2 );
+				$order->save();
+				add_filter( 'woocommerce_payment_complete_reduce_order_stock', '__return_false' );
+				$order->set_status( OrderStatus::PROCESSING );
+				$order->save();
+				$this->assertSame( 2, (int) $lookup()['total_sales'] );
+				break;
+			case 'stock':
+				$this->assertSame( 4, wc_update_product_stock( $product, 4, 'set', true ) );
+				$this->assertSame( 4, (int) $lookup()['stock_quantity'] );
+				break;
+			case 'sale-start':
+				wc_apply_sale_state_for_product( $product, 'start' );
+				break;
+			case 'sale-end':
+				$product->set_price( '5' );
+				$product->save();
+				wc_apply_sale_state_for_product( $product, 'end' );
+				break;
+		}
+
+		$row = $lookup();
+		$this->assertSame( (float) $min_price, (float) $row['min_price'] );
+		$this->assertSame( 30.0, (float) $row['max_price'], 'The type-specific maximum price must survive the refresh.' );
+	}
+
+	/**
+	 * @testdox Should keep a customized generic product store's lookup data for types with a Core store.
+	 */
+	public function test_custom_generic_store_keeps_lookup_data(): void {
+		$store = new class() extends WC_Product_Data_Store_CPT {
+			/**
+			 * Supply a custom highest price, as an extension replacing the generic store might.
+			 *
+			 * @param int    $id Product ID.
+			 * @param string $table Lookup table name.
+			 * @return array
+			 */
+			protected function get_data_for_lookup_table( $id, $table ) {
+				$data = parent::get_data_for_lookup_table( $id, $table );
+				if ( 'wc_product_meta_lookup' === $table ) {
+					$data['max_price'] = get_post_meta( $id, '_test_max_price', true );
+				}
+				return $data;
+			}
+		};
+		add_filter(
+			'woocommerce_data_stores',
+			function ( $stores ) use ( $store ) {
+				$stores['product'] = $store;
+				return $stores;
+			}
+		);
+		$variation_id = WC_Helper_Product::create_variation_product()->get_children()[0];
+		update_post_meta( $variation_id, '_test_max_price', '30' );
+
+		WC_Data_Store::load( 'product' )->update_product_stock( $variation_id, 4 );
+
+		global $wpdb;
+		$this->assertSame( 30.0, (float) $wpdb->get_var( $wpdb->prepare( "SELECT max_price FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $variation_id ) ) );
+	}
+
+	/**
+	 * @testdox Should not recurse when a product-type store hands its refresh back to the generic store.
+	 */
+	public function test_type_store_calling_generic_refresh(): void {
+		$store = new class() extends WC_Product_Data_Store_CPT {
+			/**
+			 * Number of refreshes received.
+			 *
+			 * @var int
+			 */
+			public static $calls = 0;
+
+			/**
+			 * Hand the refresh back to the generic product store.
+			 *
+			 * @param int $product_id Product ID.
+			 */
+			public function refresh_product_lookup_table( int $product_id ): void {
+				++self::$calls;
+				WC_Data_Store::load( 'product' )->refresh_product_lookup_table( $product_id );
+			}
+		};
+		add_filter(
+			'woocommerce_data_stores',
+			function ( $stores ) use ( $store ) {
+				$stores['product-simple'] = $store;
+				return $stores;
+			}
+		);
+		$product = WC_Helper_Product::create_simple_product();
+
+		WC_Data_Store::load( 'product' )->update_product_sales( $product->get_id(), 2, 'increase' );
+
+		global $wpdb;
+		$this->assertSame( 1, $store::$calls );
+		$this->assertSame( 2, (int) $wpdb->get_var( $wpdb->prepare( "SELECT total_sales FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $product->get_id() ) ) );
+	}
+
+	/**
+	 * @testdox Should still refresh the lookup table when a product store wraps the generic CPT store.
+	 */
+	public function test_delegating_store_refresh(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$store   = new class( 'product' ) extends WC_Data_Store implements WC_Object_Data_Store_Interface {
+			/**
+			 * Read metadata through the wrapped store.
+			 *
+			 * @param WC_Data $data Data object.
+			 * @return array
+			 */
+			public function read_meta( &$data ) {
+				return $this->__call( 'read_meta', array( &$data ) );
+			}
+
+			/**
+			 * Delete metadata through the wrapped store.
+			 *
+			 * @param WC_Data $data Data object.
+			 * @param object  $meta Metadata.
+			 * @return mixed
+			 */
+			public function delete_meta( &$data, $meta ) {
+				return $this->__call( 'delete_meta', array( &$data, $meta ) );
+			}
+
+			/**
+			 * Add metadata through the wrapped store.
+			 *
+			 * @param WC_Data $data Data object.
+			 * @param object  $meta Metadata.
+			 * @return mixed
+			 */
+			public function add_meta( &$data, $meta ) {
+				return $this->__call( 'add_meta', array( &$data, $meta ) );
+			}
+
+			/**
+			 * Update metadata through the wrapped store.
+			 *
+			 * @param WC_Data $data Data object.
+			 * @param object  $meta Metadata.
+			 * @return mixed
+			 */
+			public function update_meta( &$data, $meta ) {
+				return $this->__call( 'update_meta', array( &$data, $meta ) );
+			}
+		};
+		add_filter(
+			'woocommerce_data_stores',
+			function ( $stores ) use ( $store ) {
+				$stores['product-simple'] = $store;
+				return $stores;
+			}
+		);
+
+		WC_Data_Store::load( 'product' )->update_product_sales( $product->get_id(), 2, 'increase' );
+		$this->assertSame( '2', get_post_meta( $product->get_id(), 'total_sales', true ) );
+		global $wpdb;
+		$this->assertSame( 2, (int) $wpdb->get_var( $wpdb->prepare( "SELECT total_sales FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $product->get_id() ) ) );
+	}
+
+	/**
+	 * @testdox Should retain harmless handling of empty IDs in direct stock and sales updates.
+	 * @testWith [null]
+	 *           [""]
+	 * @param mixed $product_id Empty legacy ID.
+	 */
+	public function test_empty_product_id( $product_id ): void {
+		$this->setExpectedIncorrectUsage( 'WP_Object_Cache::delete' );
+		$store = WC_Data_Store::load( 'product' );
+		$this->assertNull( $store->update_product_sales( $product_id, 1, 'increase' ) );
+		$this->assertSame( 4, $store->update_product_stock( $product_id, 4 ) );
+	}
+}