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