Commit 65f09231b29 for woocommerce
commit 65f09231b296fe4583e02f00c4508a1f867d9c5e
Author: Darren Ethier <darren@roughsmootheng.in>
Date: Thu Aug 27 15:17:17 2026 -0400
Fix product attribute lookup updates for deleted products (#68100)
* Cover deleted variations and document missing product handling in LookupDataStore
- Add a test for `create_data_for_product` with a deleted variation id in
both the optimized and non-optimized paths. This is the case where the
`delete_data_for` call in the optimized path does non-redundant work,
since variation rows are keyed by the parent id in `product_or_parent_id`.
- Make `test_on_product_changed_skips_deleted_product` assert that the
method itself schedules nothing, instead of re-asserting the deletion
scheduled by the `delete_post` hook chain.
- Explain in comments why `wc_get_product` can return false in
`on_product_changed`, and why the optimized path guard calls
`delete_data_for` after the DELETE that already ran.
- Document the missing product contract in the `create_data_for_product`
docblock and fix its `WC_Product` type reference. Resolving the type let
PHPStan see the `is_a` call, so it is replaced with `instanceof` and the
four now-resolved baseline entries are removed.
* Clarify that missing product cleanup in create_data_for_product applies to ids only
diff --git a/plugins/woocommerce/changelog/fix-68028-deleted-product-lookup b/plugins/woocommerce/changelog/fix-68028-deleted-product-lookup
new file mode 100644
index 00000000000..fcba0fcd39b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-68028-deleted-product-lookup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent fatal errors when product attribute lookup updates run after a product is deleted.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 10b6b9ad775..aa19fa63b43 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -62670,12 +62670,6 @@ parameters:
count: 1
path: src/Internal/ProductAttributesLookup/LookupDataStore.php
- -
- message: '#^Call to method get_id\(\) on an unknown class Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\WC_Product\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/ProductAttributesLookup/LookupDataStore.php
-
-
message: '#^Method Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\LookupDataStore\:\:create_data_for\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -62802,12 +62796,6 @@ parameters:
count: 2
path: src/Internal/ProductAttributesLookup/LookupDataStore.php
- -
- message: '#^Parameter \#1 \$object_or_string of function is_a expects object, Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\WC_Product\|int given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/ProductAttributesLookup/LookupDataStore.php
-
-
message: '#^Parameter \#1 \$object_or_string of function is_a expects object, int\|WC_Product given\.$#'
identifier: argument.type
@@ -62820,24 +62808,12 @@ parameters:
count: 1
path: src/Internal/ProductAttributesLookup/LookupDataStore.php
- -
- message: '#^Parameter \#1 \$var of function intval expects array\|bool\|float\|int\|resource\|string\|null, Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\WC_Product\|int given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/ProductAttributesLookup/LookupDataStore.php
-
-
message: '#^Parameter \#1 \$variation of method Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\LookupDataStore\:\:create_data_for_variation\(\) expects WC_Product_Variation, WC_Product given\.$#'
identifier: argument.type
count: 1
path: src/Internal/ProductAttributesLookup/LookupDataStore.php
- -
- message: '#^Parameter \$product of method Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\LookupDataStore\:\:create_data_for_product\(\) has invalid type Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\WC_Product\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/ProductAttributesLookup/LookupDataStore.php
-
-
message: '#^Part \$attribute_name \(array\|string\) of encapsed string cannot be cast to string\.$#'
identifier: encapsedStringPart.nonString
diff --git a/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php b/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php
index 4d0160a30ba..e62bdcd0829 100644
--- a/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php
+++ b/plugins/woocommerce/src/Internal/ProductAttributesLookup/LookupDataStore.php
@@ -134,6 +134,12 @@ class LookupDataStore {
$product = WC()->call_function( 'wc_get_product', $product );
}
+ // A deferred caller can pass the id of a product that has been deleted in the meantime.
+ // The deletion has already scheduled the removal of the lookup data, so there's nothing to do.
+ if ( ! $product ) {
+ return;
+ }
+
$action = $this->get_update_action( $changeset );
if ( self::ACTION_NONE !== $action ) {
$this->maybe_schedule_update( $product->get_id(), $action );
@@ -301,18 +307,30 @@ class LookupDataStore {
* the information is created for all of its variations.
* This method is intended to be called from the data regenerator.
*
- * @param int|WC_Product $product Product object or id.
- * @param bool $use_optimized_db_access Use direct database access for data retrieval if possible.
+ * If a product id is passed and it no longer resolves to a product, any stale lookup
+ * data for that id is removed and the operation is not considered failed
+ * (see 'get_last_create_operation_failed'). Product objects are used as-is.
+ *
+ * @param int|\WC_Product $product Product object or id.
+ * @param bool $use_optimized_db_access Use direct database access for data retrieval if possible.
*/
public function create_data_for_product( $product, $use_optimized_db_access = false ) {
+ $product_id = intval( ( $product instanceof \WC_Product ) ? $product->get_id() : $product );
+
if ( $use_optimized_db_access ) {
- $product_id = intval( ( $product instanceof \WC_Product ) ? $product->get_id() : $product );
$this->create_data_for_product_cpt( $product_id );
} else {
- if ( ! is_a( $product, \WC_Product::class ) ) {
+ if ( ! $product instanceof \WC_Product ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
+ // A product can be deleted after its lookup table update has been scheduled.
+ if ( ! $product ) {
+ $this->delete_data_for( $product_id );
+ $this->last_create_operation_failed = false;
+ return;
+ }
+
$this->delete_data_for( $product->get_id() );
$this->create_data_for( $product );
}
@@ -888,6 +906,12 @@ class LookupDataStore {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$product_ids_with_stock_status = $wpdb->get_results( $sql, ARRAY_A );
+ if ( empty( $product_ids_with_stock_status ) ) {
+ // The product has been deleted. The DELETE above only covers rows keyed by parent id,
+ // delete_data_for also removes the rows of a deleted variation (keyed by product_id).
+ $this->delete_data_for( $product_id );
+ return;
+ }
$main_product_row = array_filter( $product_ids_with_stock_status, fn( $item ) => ProductType::VARIATION !== $item['product_type'] );
$is_variation = empty( $main_product_row );
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php
index e84c5ea4c06..59ca751b7fa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributesLookup/LookupDataStoreTest.php
@@ -187,6 +187,61 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$this->assertEquals( $expected, $actual );
}
+ /**
+ * @testdox `create_data_for_product` removes stale data for products that no longer exist.
+ *
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $use_optimized_db_access 'true' to use optimized db access for the table update.
+ */
+ public function test_create_data_for_product_removes_data_for_deleted_product( bool $use_optimized_db_access ): void {
+ $product = new \WC_Product_Simple();
+ $this->save( $product );
+ $product_id = $product->get_id();
+ $this->insert_lookup_table_data( $product_id, $product_id, self::$attributes[0]['name'], self::$attributes[0]['term_ids'][0], false, true );
+ $product->delete( true );
+
+ $this->assertFalse( wc_get_product( $product_id ) );
+ $this->assertCount( 1, $this->get_lookup_table_data() );
+
+ $this->sut->create_data_for_product( $product_id, $use_optimized_db_access );
+
+ $this->assertFalse( $this->sut->get_last_create_operation_failed() );
+ $this->assertEmpty( $this->get_lookup_table_data() );
+ }
+
+ /**
+ * @testdox `create_data_for_product` removes stale data for variations that no longer exist.
+ *
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $use_optimized_db_access 'true' to use optimized db access for the table update.
+ */
+ public function test_create_data_for_product_removes_data_for_deleted_variation( bool $use_optimized_db_access ): void {
+ $parent = new \WC_Product_Variable();
+ $this->save( $parent );
+ $parent_id = $parent->get_id();
+
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $parent_id );
+ $this->save( $variation );
+ $variation_id = $variation->get_id();
+
+ // Variation rows are keyed by the parent id in 'product_or_parent_id'.
+ $this->insert_lookup_table_data( $variation_id, $parent_id, self::$attributes[0]['name'], self::$attributes[0]['term_ids'][0], true, true );
+ $variation->delete( true );
+
+ $this->assertFalse( wc_get_product( $variation_id ) );
+ $this->assertCount( 1, $this->get_lookup_table_data() );
+
+ $this->sut->create_data_for_product( $variation_id, $use_optimized_db_access );
+
+ $this->assertFalse( $this->sut->get_last_create_operation_failed() );
+ $this->assertEmpty( $this->get_lookup_table_data() );
+ }
+
/**
* @testdox `create_data_for_product` creates the appropriate entries for variable products.
*
@@ -844,6 +899,28 @@ class LookupDataStoreTest extends \WC_Unit_Test_Case {
$this->assertSame( 'no', get_option( 'woocommerce_attribute_lookup_direct_updates' ) );
}
+ /**
+ * @testdox `on_product_changed` schedules nothing for products that no longer exist.
+ */
+ public function test_on_product_changed_skips_deleted_product(): void {
+ $this->set_direct_update_option( false );
+
+ $product = new \WC_Product_Simple();
+ $this->save( $product );
+ $product_id = $product->get_id();
+ $product->delete( true );
+
+ $this->assertFalse( wc_get_product( $product_id ) );
+
+ // Deleting the product schedules a lookup data deletion, that's not what's being tested here.
+ $queue = WC()->get_instance_of( \WC_Queue::class );
+ $queue->clear_methods_called();
+
+ $this->sut->on_product_changed( $product_id );
+
+ $this->assertEmpty( $queue->get_methods_called(), 'No lookup table update should be scheduled for a deleted product.' );
+ }
+
/**
* Data provider for on_product_changed tests with direct update option set.
*