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