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.
 	 */