Commit 31276699e8b for woocommerce
commit 31276699e8bc1f38303f480d6e0b0c8516ca78f1
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Jul 29 18:43:10 2026 +0300
Fix variation stock filtering in the Products list (#66942)
* fix: include variation stock in product filters
Context: Variable parents intentionally keep aggregate in-stock status while any child variation remains in stock.
Problem: The Products admin out-of-stock filter only checked parent lookup rows, so merchants could not discover variable products with manually out-of-stock variations when stock tracking was disabled.
Solution: Include parent IDs from published out-of-stock variation lookup rows through an indexed semijoin while preserving parent stock aggregation, and add regression coverage for the product-list query path.
Refs #35276
* chore: add variation stock filter changelog
Context: WooCommerce package changes require release-note metadata for release assembly.
Problem: The Products stock-status filter fix for variable products with out-of-stock variations did not yet have a package changelog entry.
Solution: Add a patch fix changelog entry for including variable products with out-of-stock variations in the Products stock status filter.
Refs #35276
* perf(admin): Materialize variation stock matches
Context: The Products out-of-stock filter must include variable parents with published out-of-stock children while preserving aggregate parent stock status.
Problem: The initial IN/UNION query is evaluated as a dependent subquery by MariaDB, making work scale with candidate parents. Its regression test also leaked list-table hooks and did not distinguish disabled children, duplicate parents, unchanged statuses, or callback order.
Solution: Materialize one distinct mapping from out-of-stock lookup rows to direct product or published variation parent IDs inside the predicate. Strengthen the query test with exact fixture results and isolated reverse-order filter coverage.
Refs #35276
* docs: Clarify published variation stock filtering
The out-of-stock query deliberately uses only published variations when deciding whether a variable parent is discoverable in this filtered view. Without that context, the status restriction can look inconsistent with aggregate child handling.\n\nDocument the distinction so future changes preserve the intended filtering semantics.
* test: Move products list table test out of the legacy suite
The variation stock filtering test was added under
tests/legacy/unit-tests/admin/, which runs in the wc-phpunit-legacy
suite.
tests/README.md is explicit that tests/legacy/unit-tests must never
receive new tests, and that new coverage for includes/ code belongs in
tests/php/includes. Landing it there would have entrenched a directory
the project is actively trying to freeze.
Move the file to tests/php/includes/admin/list-tables/ so its path
mirrors the source it covers,
includes/admin/list-tables/class-wc-admin-list-table-products.php, and
so it sits beside the existing orders list table test. Rename the class
to WC_Admin_List_Table_Products_Test to match the *_Test convention used
throughout tests/php/includes/admin/.
The test is unchanged otherwise and now runs under wc-phpunit-main.
* test: Select the private variation by stock status, not position
The private-variation-exclusion case picked the variation to hide via
get_children()[0], assuming index 0 is the first-created (out-of-stock)
child. That holds today only because variations save with menu_order 0
and the data store tie-breaks on ID ASC, so read order equals insertion
order.
That coupling is invisible at the call site: a reader can't tell index 0
is meant to be the out-of-stock variation, and a future change to
variation ordering or the fixture would silently mark the wrong child
private, quietly gutting the regression this test guards.
Select the child by the property under test instead, and assert one was
found so a broken fixture fails loudly here rather than fataling on a
null variation.
* perf: Improve variation stock filter query
Out-of-stock filtering maps variation lookup rows to parent product IDs before applying the Products list query.
The previous LEFT JOIN fallback retained unpublished variation IDs in the materialized set and mixed signed and unsigned identifiers into a decimal expression. On large stores with many disabled variations, that could spill temporary tables to disk.
Use an INNER JOIN and CASE mapping to discard irrelevant variation rows before DISTINCT, then cast the normalized ID to UNSIGNED so generated membership keys match wp_posts.ID.
* refactor(tests): Name the products list table SUT variable per convention
The stock-status filter test helper named the system under test
$list_table, while the project's unit-test conventions reserve the
$sut name for that role. Rename the variable; no behavior change.
Refs #35276.
diff --git a/plugins/woocommerce/changelog/fix-35276-variation-stock-filter b/plugins/woocommerce/changelog/fix-35276-variation-stock-filter
new file mode 100644
index 00000000000..814dd3752c1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-35276-variation-stock-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Include variable products with out-of-stock variations in the Products stock status filter.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index 53a296e5d40..88c7fb61a31 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -7,6 +7,7 @@
*/
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
@@ -806,8 +807,40 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
public function filter_stock_status_post_clauses( $args ) {
global $wpdb;
if ( ! empty( $_GET['stock_status'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $args['join'] = $this->append_product_sorting_table_join( $args['join'] );
- $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.stock_status=%s ', wc_clean( wp_unslash( $_GET['stock_status'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $stock_status = wc_clean( wp_unslash( $_GET['stock_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ if ( ProductStockStatus::OUT_OF_STOCK === $stock_status ) {
+ // Only published variations qualify their parent for this discoverability filter.
+ // Other statuses retain normal aggregate-parent behavior.
+ $args['where'] .= $wpdb->prepare(
+ " AND {$wpdb->posts}.ID IN (
+ SELECT stock_status_products.product_id
+ FROM (
+ SELECT DISTINCT CAST(
+ CASE
+ WHEN stock_status_posts.post_type = 'product_variation' THEN stock_status_posts.post_parent
+ ELSE stock_status_lookup.product_id
+ END AS UNSIGNED
+ ) AS product_id
+ FROM {$wpdb->wc_product_meta_lookup} stock_status_lookup
+ INNER JOIN {$wpdb->posts} stock_status_posts
+ ON stock_status_posts.ID = stock_status_lookup.product_id
+ WHERE stock_status_lookup.stock_status = %s
+ AND (
+ stock_status_posts.post_type = 'product'
+ OR (
+ stock_status_posts.post_type = 'product_variation'
+ AND stock_status_posts.post_status = 'publish'
+ )
+ )
+ ) stock_status_products
+ ) ",
+ $stock_status
+ );
+ } else {
+ $args['join'] = $this->append_product_sorting_table_join( $args['join'] );
+ $args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.stock_status=%s ', $stock_status );
+ }
}
return $args;
}
diff --git a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
new file mode 100644
index 00000000000..a5a5b801bdd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
@@ -0,0 +1,186 @@
+<?php
+/**
+ * Unit tests for the products admin list table.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\ProductStockStatus;
+
+require_once WC_ABSPATH . 'includes/admin/list-tables/class-wc-admin-list-table-products.php';
+
+/**
+ * WC_Admin_List_Table_Products tests.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Original global stock management option.
+ *
+ * @var string|false
+ */
+ private $original_manage_stock;
+
+ /**
+ * Set up.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->original_manage_stock = get_option( 'woocommerce_manage_stock' );
+ }
+
+ /**
+ * Tear down.
+ */
+ public function tearDown(): void {
+ if ( false === $this->original_manage_stock ) {
+ delete_option( 'woocommerce_manage_stock' );
+ } else {
+ update_option( 'woocommerce_manage_stock', $this->original_manage_stock );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Products list out-of-stock filter includes variable products with out-of-stock variations.
+ */
+ public function test_out_of_stock_filter_includes_variable_products_with_out_of_stock_variations() {
+ update_option( 'woocommerce_manage_stock', 'no' );
+
+ $simple_out_of_stock = WC_Helper_Product::create_simple_product();
+ $simple_out_of_stock->set_manage_stock( false );
+ $simple_out_of_stock->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
+ $simple_out_of_stock->set_virtual( true );
+ $simple_out_of_stock->save();
+
+ $variable_with_out_of_stock_child = $this->create_variable_product_with_child_stock_statuses(
+ array(
+ ProductStockStatus::OUT_OF_STOCK,
+ ProductStockStatus::OUT_OF_STOCK,
+ ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ $variable_in_stock = $this->create_variable_product_with_child_stock_statuses(
+ array(
+ ProductStockStatus::IN_STOCK,
+ ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ $variable_with_private_out_of_stock_child = $this->create_variable_product_with_child_stock_statuses(
+ array(
+ ProductStockStatus::OUT_OF_STOCK,
+ ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ $private_variation = null;
+ foreach ( $variable_with_private_out_of_stock_child->get_children() as $child_id ) {
+ $child = wc_get_product( $child_id );
+ if ( ProductStockStatus::OUT_OF_STOCK === $child->get_stock_status() ) {
+ $private_variation = $child;
+ break;
+ }
+ }
+ $this->assertInstanceOf( WC_Product_Variation::class, $private_variation );
+ $private_variation->set_status( 'private' );
+ $private_variation->save();
+ WC_Product_Variable::sync( $variable_with_private_out_of_stock_child );
+ $variable_with_private_out_of_stock_child = wc_get_product( $variable_with_private_out_of_stock_child->get_id() );
+
+ $this->assertSame( ProductStockStatus::IN_STOCK, $variable_with_out_of_stock_child->get_stock_status() );
+ $this->assertSame( ProductStockStatus::IN_STOCK, $variable_with_private_out_of_stock_child->get_stock_status() );
+
+ $fixture_ids = array(
+ $simple_out_of_stock->get_id(),
+ $variable_with_out_of_stock_child->get_id(),
+ $variable_in_stock->get_id(),
+ $variable_with_private_out_of_stock_child->get_id(),
+ );
+
+ $this->assertSame(
+ array( $simple_out_of_stock->get_id(), $variable_with_out_of_stock_child->get_id() ),
+ array_values( array_intersect( $this->query_product_ids_for_stock_status( ProductStockStatus::OUT_OF_STOCK ), $fixture_ids ) )
+ );
+ $this->assertSame(
+ array( $variable_with_out_of_stock_child->get_id(), $variable_in_stock->get_id(), $variable_with_private_out_of_stock_child->get_id() ),
+ array_values( array_intersect( $this->query_product_ids_for_stock_status( ProductStockStatus::IN_STOCK ), $fixture_ids ) )
+ );
+ $this->assertSame(
+ array( $simple_out_of_stock->get_id() ),
+ array_values( array_intersect( $this->query_product_ids_for_stock_status( ProductStockStatus::OUT_OF_STOCK, 'filter_virtual_post_clauses' ), $fixture_ids ) )
+ );
+ }
+
+ /**
+ * Create a variable product with children that use the supplied stock statuses.
+ *
+ * @param array $stock_statuses Child variation stock statuses.
+ * @return WC_Product_Variable
+ */
+ private function create_variable_product_with_child_stock_statuses( array $stock_statuses ) {
+ $product = new WC_Product_Variable();
+ $product->set_manage_stock( false );
+ $product->save();
+
+ foreach ( $stock_statuses as $index => $stock_status ) {
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->set_status( 'publish' );
+ $variation->set_regular_price( 10 + $index );
+ $variation->set_manage_stock( false );
+ $variation->set_stock_status( $stock_status );
+ $variation->save();
+ }
+
+ $product = wc_get_product( $product->get_id() );
+ WC_Product_Variable::sync( $product );
+
+ return wc_get_product( $product->get_id() );
+ }
+
+ /**
+ * Query product IDs through the products list table stock-status filter.
+ *
+ * @param string $stock_status Stock status to query.
+ * @param string|null $additional_filter Optional clause filter to register after the stock filter.
+ * @return array
+ */
+ private function query_product_ids_for_stock_status( $stock_status, $additional_filter = null ) {
+ $sut = ( new ReflectionClass( WC_Admin_List_Table_Products::class ) )->newInstanceWithoutConstructor();
+ $original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test cleanup restores the raw original request data.
+
+ $_GET['stock_status'] = $stock_status;
+ add_filter( 'posts_clauses', array( $sut, 'filter_stock_status_post_clauses' ) );
+ if ( $additional_filter ) {
+ add_filter( 'posts_clauses', array( $sut, $additional_filter ) );
+ }
+
+ try {
+ $query = new WP_Query(
+ array(
+ 'fields' => 'ids',
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ 'post_status' => 'publish',
+ 'post_type' => 'product',
+ 'posts_per_page' => -1,
+ )
+ );
+
+ return array_map( 'intval', $query->posts );
+ } finally {
+ remove_filter( 'posts_clauses', array( $sut, 'filter_stock_status_post_clauses' ) );
+ if ( $additional_filter ) {
+ remove_filter( 'posts_clauses', array( $sut, $additional_filter ) );
+ }
+ $_GET = $original_get;
+ }
+ }
+}