Commit bd54b72532e for woocommerce
commit bd54b72532ee9d4cf5a2bd1203c5937454ff7ba9
Author: Luigi Teschio <gigitux@gmail.com>
Date: Thu May 21 00:25:56 2026 +0200
Add v4 product stock quantity filters (#65117)
* Add v4 product stock quantity filters
* Add changelog entry for stock quantity filters
* Update v4 products README PR reference
* improve logic
* update implementation
* lint code
* improve logic
diff --git a/plugins/woocommerce/changelog/add-v4-product-list-filters b/plugins/woocommerce/changelog/add-v4-product-list-filters
new file mode 100644
index 00000000000..15f2c11d941
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-v4-product-list-filters
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add WC REST API v4 product filters for stock quantity ranges.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index 93d182ce5f7..4a97975ffac 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -106,6 +106,13 @@ class Controller extends WC_REST_Products_V2_Controller {
*/
private $exclude_status = array();
+ /**
+ * Stock quantity bounds for the current collection query.
+ *
+ * @var array
+ */
+ private $stock_quantity_filter = array();
+
/**
* Stores attachment IDs processed during the current request for potential cleanup.
*
@@ -530,6 +537,20 @@ class Controller extends WC_REST_Products_V2_Controller {
$args['meta_query'] = $this->add_meta_query( $args, wc_get_min_max_price_meta_query( $request ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
+ $min_stock_quantity = $request['min_stock_quantity'];
+ $max_stock_quantity = $request['max_stock_quantity'];
+
+ if ( null !== $min_stock_quantity || null !== $max_stock_quantity ) {
+ $this->stock_quantity_filter = array(
+ 'min' => $min_stock_quantity,
+ 'max' => $max_stock_quantity,
+ 'post_statuses' => (array) $args['post_status'],
+ 'excluded_statuses' => $this->exclude_status,
+ );
+ } else {
+ $this->stock_quantity_filter = array();
+ }
+
// Filter product by stock_status.
if ( ! empty( $request['stock_status'] ) ) {
$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
@@ -550,7 +571,15 @@ class Controller extends WC_REST_Products_V2_Controller {
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;
- $args[ $on_sale_key ] += $on_sale_ids;
+ if ( true === $request['on_sale'] && ! empty( $this->stock_quantity_filter ) && ! empty( $args['post__in'] ) ) {
+ $args['post__in'] = array_values( array_intersect( array_map( 'absint', $args['post__in'] ), array_map( 'absint', $on_sale_ids ) ) );
+
+ if ( empty( $args['post__in'] ) ) {
+ $args['post__in'] = array( 0 );
+ }
+ } else {
+ $args[ $on_sale_key ] += $on_sale_ids;
+ }
}
// Force the post_type argument, since it's not a user input variable.
@@ -584,6 +613,45 @@ class Controller extends WC_REST_Products_V2_Controller {
return $args;
}
+ /**
+ * Build the variation post status SQL fragment for stock quantity filtering.
+ *
+ * @param array $post_statuses Product statuses included in the collection query.
+ * @param array $excluded_statuses Product statuses excluded from the collection query.
+ * @param string $posts_alias Posts table alias.
+ * @return string
+ */
+ private function get_stock_quantity_variation_status_where( array $post_statuses, array $excluded_statuses, string $posts_alias = 'posts' ): string {
+ global $wpdb;
+
+ $clauses = array();
+ $post_statuses = array_filter( $post_statuses );
+
+ if ( in_array( 'any', $post_statuses, true ) ) {
+ $post_statuses = array( ProductStatus::PUBLISH );
+ }
+
+ if ( ! empty( $post_statuses ) ) {
+ $prepared_statuses = array();
+ foreach ( $post_statuses as $post_status ) {
+ $prepared_statuses[] = $wpdb->prepare( '%s', $post_status );
+ }
+
+ $clauses[] = "{$posts_alias}.post_status IN ( " . implode( ', ', $prepared_statuses ) . ' )';
+ }
+
+ if ( ! empty( $excluded_statuses ) ) {
+ $prepared_statuses = array();
+ foreach ( $excluded_statuses as $post_status ) {
+ $prepared_statuses[] = $wpdb->prepare( '%s', $post_status );
+ }
+
+ $clauses[] = "{$posts_alias}.post_status NOT IN ( " . implode( ', ', $prepared_statuses ) . ' )';
+ }
+
+ return $clauses ? 'AND ' . implode( ' AND ', $clauses ) : '';
+ }
+
/**
* Get objects.
*
@@ -591,7 +659,8 @@ class Controller extends WC_REST_Products_V2_Controller {
* @return array
*/
protected function get_objects( $query_args ) {
- $add_search_criteria = $this->search_sku_arg_value || $this->search_name_or_sku_tokens || $this->search_fields_tokens;
+ $add_search_criteria = $this->search_sku_arg_value || $this->search_name_or_sku_tokens || $this->search_fields_tokens;
+ $add_stock_quantity_filter = ! empty( $this->stock_quantity_filter );
// Add filters for search criteria in product postmeta via the lookup table.
if ( $add_search_criteria ) {
@@ -599,6 +668,11 @@ class Controller extends WC_REST_Products_V2_Controller {
add_filter( 'posts_where', array( $this, 'add_search_criteria_to_wp_query_where' ) );
}
+ // Add filters for stock quantity ranges.
+ if ( $add_stock_quantity_filter ) {
+ add_filter( 'posts_where', array( $this, 'add_stock_quantity_to_wp_query_where' ) );
+ }
+
// Add filters for excluding product statuses.
if ( ! empty( $this->exclude_status ) ) {
add_filter( 'posts_where', array( $this, 'exclude_product_statuses' ) );
@@ -616,6 +690,13 @@ class Controller extends WC_REST_Products_V2_Controller {
$this->search_fields_tokens = null;
}
+ // Remove filters for stock quantity ranges.
+ if ( $add_stock_quantity_filter ) {
+ remove_filter( 'posts_where', array( $this, 'add_stock_quantity_to_wp_query_where' ) );
+
+ $this->stock_quantity_filter = array();
+ }
+
// Remove filters for excluding product statuses.
if ( ! empty( $this->exclude_status ) ) {
remove_filter( 'posts_where', array( $this, 'exclude_product_statuses' ) );
@@ -626,6 +707,84 @@ class Controller extends WC_REST_Products_V2_Controller {
return $result;
}
+ /**
+ * Add stock quantity bounds to the product collection query.
+ *
+ * Product variation stock is stored against the variation, but the products
+ * collection returns variable parent products by default. Matching variation
+ * rows are also mapped back to their parent product IDs.
+ *
+ * @param string $where Where clause used to search posts.
+ * @return string
+ */
+ public function add_stock_quantity_to_wp_query_where( $where ) {
+ if ( empty( $this->stock_quantity_filter ) ) {
+ return $where;
+ }
+
+ global $wpdb;
+
+ $direct_stock_where = $this->get_stock_quantity_where_clause( 'direct_lookup' );
+ $variation_stock_where = $this->get_stock_quantity_where_clause( 'variation_lookup' );
+ $variation_status = $this->get_stock_quantity_variation_status_where(
+ $this->stock_quantity_filter['post_statuses'],
+ $this->stock_quantity_filter['excluded_statuses'],
+ 'variation_posts'
+ );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return $where . "
+ AND (
+ EXISTS (
+ SELECT 1
+ FROM {$wpdb->wc_product_meta_lookup} AS direct_lookup
+ WHERE direct_lookup.product_id = {$wpdb->posts}.ID
+ AND direct_lookup.stock_quantity IS NOT NULL
+ AND {$direct_stock_where}
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM {$wpdb->posts} AS variation_posts
+ INNER JOIN {$wpdb->wc_product_meta_lookup} AS variation_lookup
+ ON variation_posts.ID = variation_lookup.product_id
+ WHERE variation_posts.post_type = 'product_variation'
+ AND variation_posts.post_parent = {$wpdb->posts}.ID
+ AND variation_posts.post_parent > 0
+ {$variation_status}
+ AND variation_lookup.stock_quantity IS NOT NULL
+ AND {$variation_stock_where}
+ )
+ )";
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ /**
+ * Build the stock quantity SQL fragment for the provided lookup table alias.
+ *
+ * @param string $lookup_alias Product lookup table alias.
+ * @return string
+ */
+ private function get_stock_quantity_where_clause( string $lookup_alias ): string {
+ global $wpdb;
+
+ $min_stock_quantity = $this->stock_quantity_filter['min'];
+ $max_stock_quantity = $this->stock_quantity_filter['max'];
+
+ if ( null !== $min_stock_quantity && null !== $max_stock_quantity ) {
+ return $wpdb->prepare(
+ "{$lookup_alias}.stock_quantity BETWEEN %f AND %f", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ (float) $min_stock_quantity,
+ (float) $max_stock_quantity
+ );
+ }
+
+ if ( null !== $min_stock_quantity ) {
+ return $wpdb->prepare( "{$lookup_alias}.stock_quantity >= %f", (float) $min_stock_quantity ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ return $wpdb->prepare( "{$lookup_alias}.stock_quantity <= %f", (float) $max_stock_quantity ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
/**
* Join `wc_product_meta_lookup` table when SKU search query is present.
*
@@ -2180,6 +2339,20 @@ class Controller extends WC_REST_Products_V2_Controller {
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['min_stock_quantity'] = array(
+ 'description' => __( 'Limit result set to products with stock quantity greater than or equal to the specified amount.', 'woocommerce' ),
+ 'type' => wc_is_stock_amount_integer() ? 'integer' : 'number',
+ 'sanitize_callback' => 'wc_stock_amount',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
+ $params['max_stock_quantity'] = array(
+ 'description' => __( 'Limit result set to products with stock quantity less than or equal to the specified amount.', 'woocommerce' ),
+ 'type' => wc_is_stock_amount_integer() ? 'integer' : 'number',
+ 'sanitize_callback' => 'wc_stock_amount',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
$params['downloadable'] = array(
'description' => __( 'Limit result set to downloadable products.', 'woocommerce' ),
'type' => 'boolean',
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
index 1227a73ab4d..d3e5f6632ed 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/README.md
@@ -32,6 +32,14 @@ As discussed in the team conversation:
**Breaking Changes**: None
+### 2026-05-18 - Add stock quantity filters
+
+**Summary**: Added filters for stock quantity ranges. The endpoint now supports `min_stock_quantity` and `max_stock_quantity`.
+
+**PR**: [#65117](https://github.com/woocommerce/woocommerce/pull/65117)
+
+**Breaking Changes**: None
+
### 2026-05-05 - Add embedded variation links
**Summary**: Added embeddable `variations` links to variable product responses so child variations can be embedded when requesting products with `_embed=1`. The product schema now exposes the `embed` context for fields that are already available in `view` context, while sensitive fields such as downloads, metadata, purchase notes, and cost of goods sold remain excluded from embedded variation responses.
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index bd3a074b25f..1b8fd86ed73 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -1351,6 +1351,272 @@ class ProductsControllerTest extends WC_REST_Unit_Test_Case {
wp_delete_term( $included_category['term_id'], 'product_cat' );
}
+ /**
+ * @testdox The min_stock_quantity and max_stock_quantity parameters filter products by stock quantity.
+ */
+ public function test_products_filter_with_stock_quantity_range(): void {
+ $low_stock_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 2,
+ )
+ );
+ $in_range_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ )
+ );
+ $high_stock_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 8,
+ )
+ );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params(
+ array(
+ 'min_stock_quantity' => 4,
+ 'max_stock_quantity' => 6,
+ 'include' => array(
+ $low_stock_product->get_id(),
+ $in_range_product->get_id(),
+ $high_stock_product->get_id(),
+ ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( array( $in_range_product->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+ WC_Helper_Product::delete_product( $low_stock_product->get_id() );
+ WC_Helper_Product::delete_product( $in_range_product->get_id() );
+ WC_Helper_Product::delete_product( $high_stock_product->get_id() );
+ }
+
+ /**
+ * @testdox Stock quantity parameters include variable products when a variation matches.
+ */
+ public function test_products_filter_with_stock_quantity_range_matches_variable_product_variations(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable_product->get_children();
+ $matching_variation = wc_get_product( $variation_ids[0] );
+ $non_matching_variation = wc_get_product( $variation_ids[1] );
+ $non_matching_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 2,
+ )
+ );
+ $non_matching_variable = WC_Helper_Product::create_variation_product();
+ $non_matching_variation_id = $non_matching_variable->get_children()[0];
+ $non_matching_variation_2 = wc_get_product( $non_matching_variation_id );
+
+ $matching_variation->set_manage_stock( true );
+ $matching_variation->set_stock_quantity( 5 );
+ $matching_variation->save();
+
+ $non_matching_variation->set_manage_stock( true );
+ $non_matching_variation->set_stock_quantity( 8 );
+ $non_matching_variation->save();
+
+ $non_matching_variation_2->set_manage_stock( true );
+ $non_matching_variation_2->set_stock_quantity( 10 );
+ $non_matching_variation_2->save();
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params(
+ array(
+ 'min_stock_quantity' => 4,
+ 'max_stock_quantity' => 6,
+ 'include' => array(
+ $variable_product->get_id(),
+ $non_matching_product->get_id(),
+ $non_matching_variable->get_id(),
+ ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( array( $variable_product->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+ WC_Helper_Product::delete_product( $variable_product->get_id() );
+ WC_Helper_Product::delete_product( $non_matching_product->get_id() );
+ WC_Helper_Product::delete_product( $non_matching_variable->get_id() );
+ }
+
+ /**
+ * @testdox Stock quantity parameters ignore unpublished variations when matching variable products.
+ */
+ public function test_products_filter_with_stock_quantity_range_ignores_unpublished_variations(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable_product->get_children();
+ $draft_variation = wc_get_product( $variation_ids[0] );
+ $non_matching_variation = wc_get_product( $variation_ids[1] );
+
+ $draft_variation->set_status( ProductStatus::DRAFT );
+ $draft_variation->set_manage_stock( true );
+ $draft_variation->set_stock_quantity( 5 );
+ $draft_variation->save();
+
+ $non_matching_variation->set_manage_stock( true );
+ $non_matching_variation->set_stock_quantity( 8 );
+ $non_matching_variation->save();
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params(
+ array(
+ 'min_stock_quantity' => 4,
+ 'max_stock_quantity' => 6,
+ 'include' => array( $variable_product->get_id() ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( array(), wp_list_pluck( $response->get_data(), 'id' ) );
+
+ WC_Helper_Product::delete_product( $variable_product->get_id() );
+ }
+
+ /**
+ * @testdox The min_stock_quantity parameter includes products with the specified stock quantity.
+ */
+ public function test_products_filter_with_min_stock_quantity(): void {
+ $low_stock_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 2,
+ )
+ );
+ $matching_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ )
+ );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params(
+ array(
+ 'min_stock_quantity' => 5,
+ 'include' => array( $low_stock_product->get_id(), $matching_product->get_id() ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( array( $matching_product->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+ WC_Helper_Product::delete_product( $low_stock_product->get_id() );
+ WC_Helper_Product::delete_product( $matching_product->get_id() );
+ }
+
+ /**
+ * @testdox The max_stock_quantity parameter includes products with the specified stock quantity.
+ */
+ public function test_products_filter_with_max_stock_quantity(): void {
+ $matching_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ )
+ );
+ $high_stock_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 8,
+ )
+ );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params(
+ array(
+ 'max_stock_quantity' => 5,
+ 'include' => array( $matching_product->get_id(), $high_stock_product->get_id() ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( array( $matching_product->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+ WC_Helper_Product::delete_product( $matching_product->get_id() );
+ WC_Helper_Product::delete_product( $high_stock_product->get_id() );
+ }
+
+ /**
+ * @testdox The stock quantity and on_sale parameters only include products matching both filters.
+ */
+ public function test_products_filter_with_stock_quantity_and_on_sale(): void {
+ $matching_on_sale_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ 'sale_price' => 5,
+ )
+ );
+ $matching_stock_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 5,
+ )
+ );
+ $low_stock_on_sale_product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'manage_stock' => true,
+ 'stock_quantity' => 2,
+ 'sale_price' => 5,
+ )
+ );
+
+ delete_transient( 'wc_products_onsale' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/products' );
+ $request->set_query_params(
+ array(
+ 'min_stock_quantity' => 4,
+ 'on_sale' => true,
+ 'include' => array(
+ $matching_on_sale_product->get_id(),
+ $matching_stock_product->get_id(),
+ $low_stock_on_sale_product->get_id(),
+ ),
+ 'orderby' => 'id',
+ 'order' => 'asc',
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( array( $matching_on_sale_product->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+ WC_Helper_Product::delete_product( $matching_on_sale_product->get_id() );
+ WC_Helper_Product::delete_product( $matching_stock_product->get_id() );
+ WC_Helper_Product::delete_product( $low_stock_on_sale_product->get_id() );
+ delete_transient( 'wc_products_onsale' );
+ }
+
/**
* Test that `exclude_types` parameter correctly excludes a single type.
*/