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.
 	 *