Commit 8db37848b72 for woocommerce

commit 8db37848b7214053b32dbc8d070caf8cd4472589
Author: Sulaiman Dauda <abiodauda@gmail.com>
Date:   Fri Oct 16 08:12:12 2026 +0100

    Make Clear transients delete per-product and report transients (#69862)

    * Fix Clear transients tool leaving per-product and report transients

    * Add changelog entry for Clear transients tool fix

diff --git a/plugins/woocommerce/changelog/fix-clear-transients-tool b/plugins/woocommerce/changelog/fix-clear-transients-tool
new file mode 100644
index 00000000000..f5564192e0b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-clear-transients-tool
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Make Status > Tools > Clear transients delete every product's cached data and cached reports, not just a few fixed entries.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php
index 8dd6cbc9993..86ecfc9d983 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller.php
@@ -11,6 +11,8 @@
 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
 use Automattic\WooCommerce\Internal\ProductFilters\CacheController;
+use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;

 defined( 'ABSPATH' ) || exit;

@@ -557,6 +559,11 @@ class WC_REST_System_Status_Tools_V2_Controller extends WC_REST_Controller {

 				wc_get_container()->get( CacheController::class )->delete_filter_data_transients();

+				// These names end in a product ID or a hash, so they can only be found by prefix.
+				wc_get_container()->get( DatabaseUtil::class )->delete_transients_by_prefix(
+					array_merge( ProductUtil::PRODUCT_SPECIFIC_TRANSIENT_PREFIXES, array( 'wc_product_loop_', 'wc_report_' ) )
+				);
+
 				$message = __( 'Product transients cleared', 'woocommerce' );
 				break;

diff --git a/plugins/woocommerce/src/Internal/Utilities/DatabaseUtil.php b/plugins/woocommerce/src/Internal/Utilities/DatabaseUtil.php
index a94ae3b4bcd..6fbade9c168 100644
--- a/plugins/woocommerce/src/Internal/Utilities/DatabaseUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/DatabaseUtil.php
@@ -437,4 +437,28 @@ $on_duplicate_clause
 		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_item_table is hardcoded.
 		return ! empty( $wpdb->get_results( "SHOW INDEX FROM $order_item_table WHERE Key_name = 'order_item_fts'" ) );
 	}
+
+	/**
+	 * Delete the transients whose name starts with any of the given prefixes.
+	 *
+	 * Rows are deleted from the options table directly, so transients held in a persistent object cache are not affected.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string[] $prefixes Transient name prefixes, without the "_transient_" part. Empty prefixes are ignored.
+	 * @return void
+	 */
+	public function delete_transients_by_prefix( array $prefixes ): void {
+		global $wpdb;
+
+		foreach ( array_filter( $prefixes ) as $prefix ) {
+			$wpdb->query(
+				$wpdb->prepare(
+					"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+					$wpdb->esc_like( '_transient_' . $prefix ) . '%',
+					$wpdb->esc_like( '_transient_timeout_' . $prefix ) . '%'
+				)
+			);
+		}
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index ed1877b7163..8b44c63b24f 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -23,6 +23,24 @@ class ProductUtil {
 	 */
 	public const OUTOFSTOCK_COUNT_TRANSIENT = 'wc_outofstock_count';

+	/**
+	 * Name prefixes of the transients kept for each product, followed by the product ID.
+	 *
+	 * Exposed as a constant so the "Clear transients" tool, which deletes these transients for
+	 * every product, stays in sync with the per-product invalidation below.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @var string[]
+	 */
+	public const PRODUCT_SPECIFIC_TRANSIENT_PREFIXES = array(
+		'wc_product_children_',
+		'wc_var_prices_',
+		'wc_related_',
+		'wc_child_has_weight_',
+		'wc_child_has_dimensions_',
+	);
+
 	/**
 	 * Get a product ID from a product, post, or numeric value.
 	 *
@@ -131,16 +149,8 @@ class ProductUtil {

 		$product_ids = array_unique( array_filter( array_map( 'absint', $product_ids ) ) );

-		$product_specific_transient_names = array(
-			'wc_product_children_',
-			'wc_var_prices_',
-			'wc_related_',
-			'wc_child_has_weight_',
-			'wc_child_has_dimensions_',
-		);
-
 		foreach ( $product_ids as $product_id ) {
-			foreach ( $product_specific_transient_names as $transient ) {
+			foreach ( self::PRODUCT_SPECIFIC_TRANSIENT_PREFIXES as $transient ) {
 				delete_transient( $transient . $product_id );
 			}
 		}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php
index dfeea681116..bd2e1fa5f80 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-tools-v2-controller-test.php
@@ -1,5 +1,7 @@
 <?php

+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query as OrdersStatsQuery;
+
 /**
  * Class WC_REST_System_Status_Tools_V2_Controller.
  */
@@ -146,6 +148,62 @@ class WC_REST_System_Status_Tools_V2_Controller_Test extends WC_REST_Unit_Test_C
 		$this->assertNotEquals( $prefix_before, $prefix_after, 'Cache prefix should have changed after invalidation' );
 	}

+	/**
+	 * @testdox Clear transients tool deletes the per-product, product shortcode and report transients, and keeps other transients.
+	 */
+	public function test_execute_tool_clear_transients_deletes_transients_of_every_product() {
+		wp_set_current_user( $this->user );
+
+		$product    = WC_Helper_Product::create_variation_product();
+		$product_id = $product->get_id();
+		$product->get_variation_prices();
+		$product->child_has_weight();
+		$product->child_has_dimensions();
+		wc_get_related_products( $product_id );
+		do_shortcode( '[products]' );
+		( new OrdersStatsQuery( array() ) )->get_data();
+		set_transient( 'wc_facebook_test', 'kept', DAY_IN_SECONDS );
+		set_transient( 'other_plugin_cache', 'kept', DAY_IN_SECONDS );
+
+		$core_transients = array(
+			'wc_var_prices_' . $product_id,
+			'wc_product_children_' . $product_id,
+			'wc_child_has_weight_' . $product_id,
+			'wc_child_has_dimensions_' . $product_id,
+			'wc_related_' . $product_id,
+			'wc_product_loop_',
+			'wc_report_orders_stats_',
+		);
+		foreach ( $core_transients as $name ) {
+			$this->assertNotEmpty( $this->get_option_names_starting_with( '_transient_' . $name ), "Arrange should create the $name transient" );
+			$this->assertNotEmpty( $this->get_option_names_starting_with( '_transient_timeout_' . $name ), "Arrange should create the $name transient timeout" );
+		}
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'PUT', '/wc/v2/system_status/tools/clear_transients' ) );
+
+		$this->assertTrue( $response->get_data()['success'], 'The tool should report success' );
+		foreach ( $core_transients as $name ) {
+			$this->assertSame( array(), $this->get_option_names_starting_with( '_transient_' . $name ), "The $name transient should be deleted" );
+			$this->assertSame( array(), $this->get_option_names_starting_with( '_transient_timeout_' . $name ), "The $name transient timeout should be deleted" );
+		}
+		foreach ( array( 'wc_facebook_test', 'other_plugin_cache' ) as $name ) {
+			$this->assertSame( array( "_transient_$name" ), $this->get_option_names_starting_with( "_transient_$name" ), "Another plugin's $name transient should be kept" );
+			$this->assertSame( array( "_transient_timeout_$name" ), $this->get_option_names_starting_with( "_transient_timeout_$name" ), "Another plugin's $name transient timeout should be kept" );
+		}
+	}
+
+	/**
+	 * Get the names of the rows in the options table whose name starts with a given string.
+	 *
+	 * @param string $prefix Start of the option name.
+	 * @return string[] Matching option names.
+	 */
+	private function get_option_names_starting_with( string $prefix ): array {
+		global $wpdb;
+
+		return $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( $prefix ) . '%' ) );
+	}
+
 	/**
 	 * @testdox Product lookup table regeneration status is shown alongside the matching tool.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php
index 9780f361b8b..f380d49859a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/DatabaseUtilTest.php
@@ -159,4 +159,29 @@ class DatabaseUtilTest extends \WC_Unit_Test_Case {
 			$this->assertEquals( $expected, $this->sut->sanitise_boolean_fts_search_term( $term ) );
 		}
 	}
+
+	/**
+	 * @testdox delete_transients_by_prefix() deletes the value and timeout rows of transients whose name starts with a prefix, and ignores empty prefixes.
+	 */
+	public function test_delete_transients_by_prefix(): void {
+		global $wpdb;
+
+		set_transient( 'wc_test_one_1', 'deleted', DAY_IN_SECONDS );
+		set_transient( 'wc_test_two_abc', 'deleted', DAY_IN_SECONDS );
+		set_transient( 'wc_testXone_1', 'kept, as the underscores in a prefix are not wildcards', DAY_IN_SECONDS );
+		set_transient( 'wc_test_three_1', 'kept', DAY_IN_SECONDS );
+
+		$this->sut->delete_transients_by_prefix( array( 'wc_test_one_', '', 'wc_test_two_' ) );
+
+		$this->assertEqualsCanonicalizing(
+			array(
+				'_transient_wc_testXone_1',
+				'_transient_timeout_wc_testXone_1',
+				'_transient_wc_test_three_1',
+				'_transient_timeout_wc_test_three_1',
+			),
+			$wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", '%' . $wpdb->esc_like( 'wc_test' ) . '%' ) ),
+			'Only the transients starting with a given prefix should be deleted'
+		);
+	}
 }