Commit 00a614581d5 for woocommerce
commit 00a614581d58f708998cca192c8c99cd7df7030b
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Fri Aug 7 18:15:44 2026 +0300
Fix excluded IDs leaking into product search results (#67308)
* fix(product): stop excluded IDs leaking back into product search results
Product searches reintroduced excluded IDs after the SQL exclusion had already
removed them. A matching variation contributed its parent through the
parent_id column, and a numeric search term appended the searched ID and its
parent unconditionally. Already selected variable products therefore came back
in the enhanced search multi-selects, where clicking one silently removed it
from the selection.
Discard excluded parents in the SELECT, and apply the exclusion inside the
numeric-term branch, so nothing excluded reaches the result list in the first
place.
Refs #67293
* docs(changelog): scope the product search exclusion entry to what it fixes
The entry claimed already selected products stay hidden in enhanced product
search multi-selects. The order screen Grant access field is one of those and
is not covered, because its AJAX handler never forwards its exclude list to
search_products(). Drop the clause rather than overstate the scope.
Refs #67293
* test(product): assert variations survive their parent's exclusion
The parent exclusion case only asserted the parent was absent, so it would
also pass if the search dropped the matching variation along with it. Assert
the variation is still returned, which is the behaviour that makes excluding a
parent useful in the first place.
Refs #67293
* test(product): cover the parent guard in the numeric-term branch
The numeric branch checks the exclusion twice, once for the searched ID and
once for its parent, but the existing case searched a simple product. That has
no parent, so the second check was only ever evaluated against 0 and could be
deleted without any test noticing.
Search a variation by its numeric ID with the parent excluded, which is the
combination that actually exercises the guard.
Refs #67293
* fix(product): keep zero-valued exclude entries inert in product search
absint() maps any non-numeric exclude value to 0, and until now that was
harmless: no post has ID 0, so `posts.ID NOT IN(0)` matched nothing. Feeding
the same list to `posts.post_parent IN(0)` changed that, because 0 is the
post_parent every top-level product carries. A malformed entry such as
exclude[]=abc therefore nulled the parent of every top-level row, dropping the
0 the method has always returned, and emptying the result set entirely for a
numeric term that matched nothing.
Filter the zeros out during normalization so such input stays the no-op it was,
while a list that also holds real IDs keeps excluding them.
Refs #67293
* Update plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>
* Update plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>
---------
Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>
diff --git a/plugins/woocommerce/changelog/67293-exclude-ids-in-product-search b/plugins/woocommerce/changelog/67293-exclude-ids-in-product-search
new file mode 100644
index 00000000000..37ccc10ad46
--- /dev/null
+++ b/plugins/woocommerce/changelog/67293-exclude-ids-in-product-search
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Excluded product IDs no longer leak back into product search results through matching variations of a variable product or through numeric search terms.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index aa76f17798c..c77b7bee4b0 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -2074,8 +2074,10 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$search_where .= ' AND posts.ID IN(' . implode( ',', array_map( 'absint', $include ) ) . ') ';
}
- if ( ! empty( $exclude ) && is_array( $exclude ) ) {
- $search_where .= ' AND posts.ID NOT IN(' . implode( ',', array_map( 'absint', $exclude ) ) . ') ';
+ $exclude_ids = ! empty( $exclude ) && is_array( $exclude ) ? array_filter( array_map( 'absint', $exclude ) ) : array();
+
+ if ( $exclude_ids ) {
+ $search_where .= ' AND posts.ID NOT IN(' . implode( ',', $exclude_ids ) . ') ';
}
if ( 'virtual' === $type ) {
@@ -2092,10 +2094,18 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$limit_query = $wpdb->prepare( ' LIMIT %d ', $limit );
}
+ // A matching variation contributes its parent ID, so excluded parents are discarded here
+ // rather than reintroduced alongside the variation.
+ $parent_id_select = 'posts.post_parent as parent_id';
+
+ if ( $exclude_ids ) {
+ $parent_id_select = 'CASE WHEN posts.post_parent IN(' . implode( ',', $exclude_ids ) . ') THEN NULL ELSE posts.post_parent END as parent_id';
+ }
+
// phpcs:ignore WordPress.VIP.DirectDatabaseQuery.DirectQuery
$search_results = $wpdb->get_results(
// phpcs:disable
- "SELECT DISTINCT posts.ID as product_id, posts.post_parent as parent_id FROM {$wpdb->posts} posts
+ "SELECT DISTINCT posts.ID as product_id, {$parent_id_select} FROM {$wpdb->posts} posts
LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id
$join_query
WHERE posts.post_type IN ('" . implode( "','", $post_types ) . "')
@@ -2114,13 +2124,21 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$post_id = absint( $term );
$post_type = get_post_type( $post_id );
- if ( 'product_variation' === $post_type && $include_variations ) {
- $product_ids[] = $post_id;
- } elseif ( 'product' === $post_type ) {
- $product_ids[] = $post_id;
+ // A numeric term bypasses the query above, so the exclusion is applied to both the
+ // searched ID and its parent before either is appended.
+ if ( ! in_array( $post_id, $exclude_ids, true ) ) {
+ if ( 'product_variation' === $post_type && $include_variations ) {
+ $product_ids[] = $post_id;
+ } elseif ( 'product' === $post_type ) {
+ $product_ids[] = $post_id;
+ }
}
- $product_ids[] = wp_get_post_parent_id( $post_id );
+ $parent_id = absint( wp_get_post_parent_id( $post_id ) );
+
+ if ( ! in_array( $parent_id, $exclude_ids, true ) ) {
+ $product_ids[] = $parent_id;
+ }
}
return wp_parse_id_list( $product_ids );
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
index 68fcaf60290..039ed53ba35 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
@@ -225,6 +225,107 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertContains( $beta->get_id(), $results, 'Product matched by the second OR group should be returned' );
}
+ /**
+ * @testdox Excluded variable products should not be re-added to search results by their matching variations.
+ */
+ public function test_search_products_excludes_variable_products_with_matching_variations(): void {
+ $parent = new WC_Product_Variable();
+ $parent->set_name( 'Excludable variable product' );
+ $parent->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $parent->get_id() );
+ $variation->save();
+
+ $data_store = WC_Data_Store::load( 'product' );
+
+ $results = $data_store->search_products( 'Excludable variable', '', true, true );
+ $this->assertContains( $parent->get_id(), $results );
+ $this->assertContains( $variation->get_id(), $results );
+
+ $results = $data_store->search_products( 'Excludable variable', '', true, true, null, null, array( $parent->get_id() ) );
+ $this->assertNotContains( $parent->get_id(), $results, 'An excluded parent must not be re-added through its matching variations' );
+ $this->assertContains( $variation->get_id(), $results, 'Excluding a parent must not exclude its variations' );
+
+ $results = $data_store->search_products( 'Excludable variable', '', true, true, null, null, array( $variation->get_id() ) );
+ $this->assertNotContains( $variation->get_id(), $results, 'An excluded variation must not be returned' );
+ $this->assertContains( $parent->get_id(), $results, 'Excluding a variation must not exclude its parent' );
+ }
+
+ /**
+ * @testdox Excluded products should not be re-added to search results when the search term is their numeric ID.
+ */
+ public function test_search_products_excludes_numeric_term_matches() {
+ $product = new WC_Product_Simple();
+ $product->set_name( 'Numeric term widget' );
+ $product->save();
+
+ $data_store = WC_Data_Store::load( 'product' );
+
+ $results = $data_store->search_products( (string) $product->get_id(), '', false, true );
+ $this->assertContains( $product->get_id(), $results );
+
+ $results = $data_store->search_products( (string) $product->get_id(), '', false, true, null, null, array( $product->get_id() ) );
+ $this->assertNotContains( $product->get_id(), $results, 'An excluded product must not be re-added by the numeric term match' );
+ }
+
+ /**
+ * A numeric term appends both the searched ID and its parent, so the parent needs its own
+ * exclusion check. Searching a variation by ID is the case that exercises it, since a
+ * top-level product has no parent to re-add.
+ *
+ * @testdox Excluded parents should not be re-added when the search term is a variation's numeric ID.
+ */
+ public function test_search_products_excludes_parent_for_numeric_variation_term() {
+ $parent = new WC_Product_Variable();
+ $parent->set_name( 'Numeric parent widget' );
+ $parent->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $parent->get_id() );
+ $variation->save();
+
+ $data_store = WC_Data_Store::load( 'product' );
+
+ $results = $data_store->search_products( (string) $variation->get_id(), '', true, true );
+ $this->assertContains( $variation->get_id(), $results );
+ $this->assertContains( $parent->get_id(), $results, 'A numeric variation term should surface its parent' );
+
+ $results = $data_store->search_products( (string) $variation->get_id(), '', true, true, null, null, array( $parent->get_id() ) );
+ $this->assertNotContains( $parent->get_id(), $results, 'An excluded parent must not be re-added by a numeric variation term' );
+ $this->assertContains( $variation->get_id(), $results, 'Excluding the parent must not drop the searched variation' );
+ }
+
+ /**
+ * absint() maps any non-numeric exclude value to 0, and 0 is the post_parent every top-level
+ * product carries. Zeros are filtered out of the exclusion list so that such input stays the
+ * no-op it has always been, rather than matching every top-level row.
+ *
+ * @testdox Exclude values that normalise to zero should leave search results untouched.
+ */
+ public function test_search_products_ignores_zero_exclude_values() {
+ $product = new WC_Product_Simple();
+ $product->set_name( 'Zero exclude widget' );
+ $product->save();
+
+ $data_store = WC_Data_Store::load( 'product' );
+
+ $baseline = $data_store->search_products( 'Zero exclude', '', true, true );
+ $this->assertContains( $product->get_id(), $baseline );
+
+ $zero_like = array(
+ 'integer zero' => 0,
+ 'string zero' => '0',
+ 'non-numeric value' => 'abc',
+ 'empty string' => '',
+ );
+
+ foreach ( $zero_like as $label => $value ) {
+ $results = $data_store->search_products( 'Zero exclude', '', true, true, null, null, array( $value ) );
+ $this->assertEqualSets( $baseline, $results, "An exclude list holding a {$label} must not change the results" );
+ }
+ }
+
/**
* Ensure product rating counts are calculated correctly.
*