Commit b0763261f44 for woocommerce

commit b0763261f44ff7de5eccfb33f1315571e1711db9
Author: Marcin Dudek <109857699+MarcinDudekDev@users.noreply.github.com>
Date:   Wed Jul 15 07:57:37 2026 +0200

    Performance: expand hierarchical product-filter taxonomies with get_term_children() (#66359)

    * Product Filters: batch hierarchical taxonomy descendant expansion

    add_taxonomy_clauses() expanded a hierarchical taxonomy filter by running a
    get_terms( child_of ) query for every chosen term. On stores without a
    persistent object cache the per-term cache is request-local, so the work scaled
    with the number of selected categories on every request.

    Resolve descendants from TaxonomyHierarchyData::get_descendants(), which builds
    the whole hierarchy with one get_terms per taxonomy (already used by FilterData),
    and drop the bespoke per-term child cache. Expanded term set is unchanged.

    * Product Filters: lazy-resolve hierarchy data, merge descendants once

    Address review feedback on the hierarchical taxonomy expansion in
    QueryClauses:

    - Resolve TaxonomyHierarchyData lazily via wc_get_container() inside the
      is_taxonomy_hierarchical() branch instead of injecting it through
      init(), so the hierarchy map is only built when a hierarchical filter
      is actually in play.
    - Collect the per-term descendant sets and array_merge() them once
      rather than calling array_merge() inside the loop.

    * Inject TaxonomyHierarchyData into QueryClauses::init()

    Resolve the dependency through init() method-injection instead of the
    wc_get_container()->get() locate, matching CacheController and
    FilterDataProvider. The map is still built lazily in get_hierarchy_map(),
    reached only from the hierarchical branch.

    * Product Filters: expand hierarchy with get_term_children()

    Use core's get_term_children() instead of TaxonomyHierarchyData for the
    hierarchical descendant expansion. It resolves from the precomputed
    {$taxonomy}_children option, which core keeps warm and autoloads, so it is
    faster on a cold request than lazily building the hierarchy map and needs no
    extra query on a warm one. Still no child_of query per chosen term.

    Drops the init() injection added earlier on this branch, restoring the
    original signature.

    * docs: udpate comment

    ---------

    Co-authored-by: Vladimir Reznichenko <kalessil@gmail.com>
    Co-authored-by: Tung Du <dinhtungdu@gmail.com>

diff --git a/plugins/woocommerce/changelog/perf-product-filters-batch-taxonomy-descendants b/plugins/woocommerce/changelog/perf-product-filters-batch-taxonomy-descendants
new file mode 100644
index 00000000000..3f3624dc9b8
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-product-filters-batch-taxonomy-descendants
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Product Filters: expand hierarchical taxonomy filters with get_term_children() instead of a child_of query per chosen term.
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php b/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
index 8ae467f36de..b2c5a93e090 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/QueryClauses.php
@@ -11,7 +11,6 @@ use Automattic\WooCommerce\Enums\TaxDisplayMode;
 use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
 use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
 use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\MainQueryClausesGenerator;
-use Automattic\WooCommerce\Internal\ProductFilters\CacheController;
 use WC_Tax;
 use WC_Cache_Helper;

@@ -359,33 +358,19 @@ class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
 			}

 			if ( is_taxonomy_hierarchical( $taxonomy ) ) {
-				$expanded_term_ids = $term_ids;
+				// Expand chosen terms to include descendants. get_term_children()
+				// resolves from the precomputed {$taxonomy}_children option, so it
+				// adds no per-term query.
+				$descendant_sets = array( $term_ids );

 				foreach ( $term_ids as $term_id ) {
-					$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . 'child_terms_' . $taxonomy . '_' . $term_id;
-					$children  = wp_cache_get( $cache_key );
-
-					if ( false === $children ) {
-						$children = get_terms(
-							array(
-								'taxonomy'   => $taxonomy,
-								'child_of'   => $term_id,
-								'fields'     => 'ids',
-								'hide_empty' => false,
-							)
-						);
-
-						if ( ! is_wp_error( $children ) ) {
-							wp_cache_set( $cache_key, $children, '', HOUR_IN_SECONDS );
-						} else {
-							$children = array();
-						}
+					$children = get_term_children( (int) $term_id, $taxonomy );
+					if ( ! is_wp_error( $children ) ) {
+						$descendant_sets[] = $children;
 					}
-
-					$expanded_term_ids = array_merge( $expanded_term_ids, $children );
 				}

-				$term_ids = array_unique( $expanded_term_ids );
+				$term_ids = array_unique( array_merge( ...$descendant_sets ) );
 			}

 			$term_ids_list = '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
index 0e6e988de71..ffd865323c5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
@@ -239,4 +239,69 @@ class QueryClausesTest extends AbstractProductFiltersTest {

 		$this->assertEqualsCanonicalizing( $expected_products_name, $received_products_name );
 	}
+
+	/**
+	 * @testdox A hierarchical category filter must match products in descendant categories without a child_of query per chosen term.
+	 *
+	 * @testWith [["hcat-parent"], ["In Parent", "In Child", "In Grandchild"]]
+	 *           [["hcat-child"], ["In Child", "In Grandchild"]]
+	 *           [["hcat-grandchild"], ["In Grandchild"]]
+	 *           [["hcat-parent", "hcat-sibling"], ["In Parent", "In Child", "In Grandchild", "In Sibling"]]
+	 *
+	 * @param string[] $terms             Chosen category slugs.
+	 * @param string[] $expected_products Product names expected to match.
+	 */
+	public function test_taxonomy_clauses_expand_descendants_without_per_term_queries( array $terms, array $expected_products ): void {
+		$parent     = $this->fixture_data->get_product_category( array( 'name' => 'HCat Parent' ) );
+		$child      = $this->fixture_data->get_product_category( array( 'name' => 'HCat Child' ) );
+		$grandchild = $this->fixture_data->get_product_category( array( 'name' => 'HCat Grandchild' ) );
+		$sibling    = $this->fixture_data->get_product_category( array( 'name' => 'HCat Sibling' ) );
+		// parent > child > grandchild; sibling stays top-level (own branch).
+		wp_update_term( $child['term_id'], 'product_cat', array( 'parent' => $parent['term_id'] ) );
+		wp_update_term( $grandchild['term_id'], 'product_cat', array( 'parent' => $child['term_id'] ) );
+
+		$make_product = function ( $name, $term ) {
+			return $this->fixture_data->get_simple_product(
+				array(
+					'name'         => $name,
+					'category_ids' => array( $term['term_id'] ),
+				)
+			);
+		};
+		$products     = array(
+			$make_product( 'In Parent', $parent ),
+			$make_product( 'In Child', $child ),
+			$make_product( 'In Grandchild', $grandchild ),
+			$make_product( 'In Sibling', $sibling ),
+		);
+
+		// Fail if anything still resolves children one term at a time.
+		$child_of_queries = 0;
+		$counter          = function ( $args ) use ( &$child_of_queries ) {
+			if ( ! empty( $args['child_of'] ) ) {
+				++$child_of_queries;
+			}
+			return $args;
+		};
+		add_filter( 'get_terms_args', $counter );
+
+		$chosen          = array( 'product_cat' => $terms );
+		$filter_callback = function ( $args ) use ( $chosen ) {
+			return $this->sut->add_taxonomy_clauses( $args, $chosen );
+		};
+		add_filter( 'posts_clauses', $filter_callback );
+		$received = $this->get_data_from_products_array( wc_get_products( array() ) );
+		remove_filter( 'posts_clauses', $filter_callback );
+		remove_filter( 'get_terms_args', $counter );
+
+		$this->assertEqualsCanonicalizing( $expected_products, $received );
+		$this->assertSame( 0, $child_of_queries, 'Hierarchy must be resolved in batch, not via a child_of query per chosen term.' );
+
+		foreach ( $products as $product ) {
+			$product->delete( true );
+		}
+		foreach ( array( $grandchild, $sibling, $child, $parent ) as $term ) {
+			wp_delete_term( $term['term_id'], 'product_cat' );
+		}
+	}
 }