Commit 95c0f572f8a for woocommerce

commit 95c0f572f8a8a95a736001de4e194f03e1aee2d0
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Thu Oct 1 14:59:09 2026 +0300

    Fix product category queries discarding a caller's term meta filter (#68544)

    * fix: Preserve caller's meta filter in product category queries

    get_terms() defaults to menu_order sorting for product_cat, and the code
    that implements it claims the primary meta clause for the 'order' term
    meta. A caller who passed their own meta_key/meta_value had it overwritten
    and the meta query rebuilt from the clobbered args, so the query filtered
    on 'order' and returned nothing — no error, no warning.

    Move the caller's meta args into meta_query before claiming meta_key. The
    filter then applies as an additional clause on its own join, category
    sorting is unaffected, and the top-level form behaves like the nested
    meta_query form that already worked.

    Refs #34042

    * chore: Add changelog entry for the category meta filter fix

    * test: Cover a caller passing both top-level and nested meta filters

    The two forms combined take the AND-merge path, which no case reached. The
    query returns nothing on trunk, so this also pins a second broken shape.

    Refs #34042

    * docs: Note the preserved meta filter in wc_change_pre_get_terms

    Refs #34042

    * fix: Keep terms without order meta when a term query runs again

    Sorting by menu order leaves meta_key set to 'order' on the query. When
    the same WP_Term_Query object ran again, the preserved-filter code
    treated that key as a caller filter and moved it into meta_query as an
    INNER JOIN, so categories without 'order' meta disappeared from the
    second result. Plugins that re-run a category query lost categories this
    way, for example from a saved exclusion list.

    Move top-level meta args into meta_query only when meta_key is set and
    is not 'order'. A filter on 'order' shares the sort clause, the key left
    by an earlier run is always 'order', and keyless meta_compare, meta_type
    and meta_value keep the trunk behavior.

    Refs #34042

    * fix: Keep categories with other term meta in NOT EXISTS term queries

    WordPress joins termmeta with a LEFT JOIN instead of an INNER JOIN when
    any meta clause uses NOT EXISTS. The menu order sort only rewrote the
    INNER JOIN form to limit the join to the 'order' key, so with NOT EXISTS
    a category's unrelated term meta rows took part in the sort join and the
    category was dropped. This also fixes an older trunk bug: a nested
    NOT EXISTS meta_query dropped categories that had unrelated term meta.

    Rewrite both join forms in one str_replace() so the sort join always
    matches only the 'order' key.

    Refs #34042

    * test: Cover key-only term meta filters on product categories

    A meta_key with no value or compare narrows the result to categories that
    have that key, the same as an EXISTS clause. No case covered that form,
    so a change to how the caller's filter is moved could silently widen it
    back to every category.

    Give two of the three categories a marker key and assert that a key-only
    filter returns just those two, in order meta sequence.

    Refs #34042

    * docs: Correct the @since version for the preserved meta filter

    Trunk is 11.3.0-dev, so the change ships in 11.3.0, not 11.2.0.

    Refs #34042

    * test: Make term meta filter cases fail when the filter is dropped

    The top-level and nested NOT EXISTS cases expected every category, which
    an unfiltered query also returns. Give Category C the missing key so the
    expected result excludes it.

    In "both forms combined", the nested order <= 2 clause removed nothing
    the top-level filter kept. Give Category C order 0 and tighten the
    nested clause to order <= 1, so the combined query returns only B and
    dropping either clause changes the result. Expected names now come from
    the provider.

    Refs #34042

    * chore: Describe both category meta filter fixes in the changelog

    The entry named only the first fix and led with get_terms(). Describe
    what merchants notice for both fixes instead.

    Refs #34042

    * test: Cover legacy menu_order and meta_compare in term meta filters

    The legacy menu_order argument turns on menu order sorting even with an
    explicit orderby and on taxonomies WooCommerce doesn't sort, and nothing
    covered a caller's meta filter on that path. Add a product_tag case with
    orderby name that expects only the matching tags, in menu order.

    Also add a top-level meta_compare case, so the compare and value moving
    into meta_query together is covered.

    Refs #34042

diff --git a/plugins/woocommerce/changelog/34042-fix-get-terms-meta-key-clobber b/plugins/woocommerce/changelog/34042-fix-get-terms-meta-key-clobber
new file mode 100644
index 00000000000..7efec7a0e9b
--- /dev/null
+++ b/plugins/woocommerce/changelog/34042-fix-get-terms-meta-key-clobber
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix product category lists filtered by custom data showing no categories or leaving some out.
diff --git a/plugins/woocommerce/includes/wc-term-functions.php b/plugins/woocommerce/includes/wc-term-functions.php
index 4c70a4c2bc9..f4f45c311d7 100644
--- a/plugins/woocommerce/includes/wc-term-functions.php
+++ b/plugins/woocommerce/includes/wc-term-functions.php
@@ -55,6 +55,7 @@ add_filter( 'get_terms_defaults', 'wc_change_get_terms_defaults', 10, 2 );
  * Adds support to get_terms for menu_order argument.
  *
  * @since 3.6.0
+ * @since 11.3.0 A term meta filter supplied by the caller is kept instead of being overwritten by the 'order' sorting key.
  * @param WP_Term_Query $terms_query Instance of WP_Term_Query.
  */
 function wc_change_pre_get_terms( $terms_query ) {
@@ -83,6 +84,42 @@ function wc_change_pre_get_terms( $terms_query ) {
 	}

 	if ( ! empty( $args['force_menu_order_sort'] ) ) {
+		// Sorting by menu order claims the primary meta clause for the 'order' meta key, so move a meta
+		// filter on any other key into meta_query, where it still applies as an additional clause.
+		// A filter on 'order' itself shares the sort clause, and so does the key left here by an earlier run.
+		$caller_meta_clause = array();
+
+		if ( ! empty( $args['meta_key'] ) && 'order' !== $args['meta_key'] ) {
+			foreach ( array(
+				'key'         => 'meta_key',
+				'compare'     => 'meta_compare',
+				'type'        => 'meta_type',
+				'compare_key' => 'meta_compare_key',
+				'type_key'    => 'meta_type_key',
+			) as $clause_key => $query_var ) {
+				if ( ! empty( $args[ $query_var ] ) ) {
+					$caller_meta_clause[ $clause_key ] = $args[ $query_var ];
+					$args[ $query_var ]                = '';
+				}
+			}
+
+			// Matches how WP_Meta_Query::parse_query_vars() decides that a meta value was supplied.
+			if ( isset( $args['meta_value'] ) && '' !== $args['meta_value'] && ( ! is_array( $args['meta_value'] ) || $args['meta_value'] ) ) {
+				$caller_meta_clause['value'] = $args['meta_value'];
+				$args['meta_value']          = ''; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			}
+		}
+
+		if ( ! empty( $caller_meta_clause ) ) {
+			$args['meta_query'] = empty( $args['meta_query'] ) // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+				? array( $caller_meta_clause )
+				: array(
+					'relation' => 'AND',
+					$caller_meta_clause,
+					$args['meta_query'],
+				);
+		}
+
 		$args['orderby']  = 'meta_value_num';
 		$args['meta_key'] = 'order'; // phpcs:ignore
 		$terms_query->meta_query->parse_query_vars( $args );
@@ -111,9 +148,17 @@ function wc_terms_clauses( $clauses, $taxonomies, $args ) {
 		$clauses['orderby'] = str_replace( 'ORDER BY t.name', 'ORDER BY t.name+0', $clauses['orderby'] );
 	}

-	// For sorting, force left join in case order meta is missing.
+	// For sorting, force left join in case order meta is missing. WordPress already uses a LEFT JOIN
+	// when any meta clause is NOT EXISTS, so that form also needs the order key in its join condition.
 	if ( ! empty( $args['force_menu_order_sort'] ) ) {
-		$clauses['join']    = str_replace( "INNER JOIN {$wpdb->termmeta} ON ( t.term_id = {$wpdb->termmeta}.term_id )", "LEFT JOIN {$wpdb->termmeta} ON ( t.term_id = {$wpdb->termmeta}.term_id AND {$wpdb->termmeta}.meta_key='order')", $clauses['join'] );
+		$clauses['join']    = str_replace(
+			array(
+				"INNER JOIN {$wpdb->termmeta} ON ( t.term_id = {$wpdb->termmeta}.term_id )",
+				"LEFT JOIN {$wpdb->termmeta} ON ( t.term_id = {$wpdb->termmeta}.term_id )",
+			),
+			"LEFT JOIN {$wpdb->termmeta} ON ( t.term_id = {$wpdb->termmeta}.term_id AND {$wpdb->termmeta}.meta_key='order')",
+			$clauses['join']
+		);
 		$clauses['where']   = str_replace( "{$wpdb->termmeta}.meta_key = 'order'", "( {$wpdb->termmeta}.meta_key = 'order' OR {$wpdb->termmeta}.meta_key IS NULL )", $clauses['where'] );
 		$clauses['orderby'] = 'DESC' === $args['order'] ? str_replace( 'meta_value+0', 'meta_value+0 DESC, t.name', $clauses['orderby'] ) : str_replace( 'meta_value+0', 'meta_value+0 ASC, t.name', $clauses['orderby'] );
 	}
diff --git a/plugins/woocommerce/tests/php/includes/wc-term-functions-tests.php b/plugins/woocommerce/tests/php/includes/wc-term-functions-tests.php
index abe374c4436..f892171630c 100644
--- a/plugins/woocommerce/tests/php/includes/wc-term-functions-tests.php
+++ b/plugins/woocommerce/tests/php/includes/wc-term-functions-tests.php
@@ -285,4 +285,267 @@ class WC_Term_Functions_Tests extends \WC_Unit_Test_Case {
 		$featured_product->delete();
 		$regular_product->delete();
 	}
+
+	/**
+	 * @testdox Product categories are sorted by their order term meta when no orderby is requested.
+	 */
+	public function test_product_cat_default_sort_uses_order_term_meta(): void {
+		$unordered = wp_insert_term( 'Category A', 'product_cat' );
+		$third     = wp_insert_term( 'Category B', 'product_cat' );
+		$first     = wp_insert_term( 'Category C', 'product_cat' );
+		$second    = wp_insert_term( 'Category D', 'product_cat' );
+
+		delete_term_meta( $unordered['term_id'], 'order' );
+		update_term_meta( $first['term_id'], 'order', 1 );
+		update_term_meta( $second['term_id'], 'order', 2 );
+		update_term_meta( $third['term_id'], 'order', 3 );
+
+		$terms = get_terms(
+			array(
+				'taxonomy'   => 'product_cat',
+				'hide_empty' => false,
+				'fields'     => 'ids',
+				'include'    => array( $unordered['term_id'], $third['term_id'], $first['term_id'], $second['term_id'] ),
+			)
+		);
+
+		$this->assertSame(
+			array( (int) $unordered['term_id'], (int) $first['term_id'], (int) $second['term_id'], (int) $third['term_id'] ),
+			array_map( 'intval', $terms ),
+			'Categories should come back in order meta sequence, and categories without order meta should still be returned.'
+		);
+	}
+
+	/**
+	 * @testdox A term meta filter passed to get_terms is honored on product categories, without losing the default sorting.
+	 * @dataProvider provider_term_meta_filter_args
+	 *
+	 * @param array $filter_args The meta filter arguments to pass to get_terms().
+	 * @param array $expected    Names of the expected terms, in order.
+	 */
+	public function test_product_cat_honors_caller_term_meta_filter( array $filter_args, array $expected ): void {
+		$second    = wp_insert_term( 'Category A', 'product_cat' );
+		$first     = wp_insert_term( 'Category B', 'product_cat' );
+		$unmatched = wp_insert_term( 'Category C', 'product_cat' );
+
+		add_term_meta( $first['term_id'], 'wc_test_flag', 'yes' );
+		add_term_meta( $second['term_id'], 'wc_test_flag', 'yes' );
+		add_term_meta( $unmatched['term_id'], 'wc_test_flag', 'no' );
+		add_term_meta( $first['term_id'], 'wc_test_marker', 'yes' );
+		add_term_meta( $second['term_id'], 'wc_test_marker', 'yes' );
+		update_term_meta( $unmatched['term_id'], 'order', 0 );
+		update_term_meta( $first['term_id'], 'order', 1 );
+		update_term_meta( $second['term_id'], 'order', 2 );
+
+		$terms = get_terms(
+			array_merge(
+				array(
+					'taxonomy'   => 'product_cat',
+					'hide_empty' => false,
+					'fields'     => 'names',
+					'include'    => array( $second['term_id'], $first['term_id'], $unmatched['term_id'] ),
+				),
+				$filter_args
+			)
+		);
+
+		$this->assertSame(
+			$expected,
+			$terms,
+			'Only the categories matching the meta filter should be returned, in order meta sequence.'
+		);
+	}
+
+	/**
+	 * Both forms WordPress accepts for a term meta filter.
+	 *
+	 * @return array
+	 */
+	public function provider_term_meta_filter_args(): array {
+		return array(
+			'meta_key and meta_value' => array(
+				array(
+					'meta_key'   => 'wc_test_flag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+				),
+				array( 'Category B', 'Category A' ),
+			),
+			'meta_key only'           => array(
+				array(
+					'meta_key' => 'wc_test_marker', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				),
+				array( 'Category B', 'Category A' ),
+			),
+			'meta_compare !='         => array(
+				array(
+					'meta_key'     => 'wc_test_flag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_value'   => 'no', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+					'meta_compare' => '!=', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_compare
+				),
+				array( 'Category B', 'Category A' ),
+			),
+			'nested meta_query'       => array(
+				array(
+					'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+						array(
+							'key'   => 'wc_test_flag',
+							'value' => 'yes',
+						),
+					),
+				),
+				array( 'Category B', 'Category A' ),
+			),
+			'both forms combined'     => array(
+				array(
+					'meta_key'   => 'wc_test_flag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+					'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+						array(
+							'key'     => 'order',
+							'value'   => 1,
+							'compare' => '<=',
+						),
+					),
+				),
+				array( 'Category B' ),
+			),
+		);
+	}
+
+	/**
+	 * @testdox The legacy menu_order argument keeps a caller's term meta filter, even with an explicit orderby on a taxonomy WooCommerce doesn't sort.
+	 */
+	public function test_legacy_menu_order_arg_honors_caller_term_meta_filter(): void {
+		$second    = wp_insert_term( 'Tag A', 'product_tag' );
+		$first     = wp_insert_term( 'Tag B', 'product_tag' );
+		$unmatched = wp_insert_term( 'Tag C', 'product_tag' );
+
+		add_term_meta( $first['term_id'], 'wc_test_flag', 'yes' );
+		add_term_meta( $second['term_id'], 'wc_test_flag', 'yes' );
+		add_term_meta( $unmatched['term_id'], 'wc_test_flag', 'no' );
+		update_term_meta( $unmatched['term_id'], 'order', 0 );
+		update_term_meta( $first['term_id'], 'order', 1 );
+		update_term_meta( $second['term_id'], 'order', 2 );
+
+		$terms = get_terms(
+			array(
+				'taxonomy'   => 'product_tag',
+				'hide_empty' => false,
+				'fields'     => 'names',
+				'include'    => array( $second['term_id'], $first['term_id'], $unmatched['term_id'] ),
+				'orderby'    => 'name',
+				'menu_order' => 'ASC',
+				'meta_key'   => 'wc_test_flag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+			)
+		);
+
+		$this->assertSame(
+			array( 'Tag B', 'Tag A' ),
+			$terms,
+			'Only the tags matching the meta filter should be returned, in order meta sequence rather than by name.'
+		);
+	}
+
+	/**
+	 * @testdox Running the same product category query object again returns the same terms, including those without order meta.
+	 * @dataProvider provider_repeat_run_args
+	 *
+	 * @param array $extra_args Extra arguments for the term query.
+	 * @param array $expected   Names of the expected terms, in order.
+	 */
+	public function test_product_cat_repeat_query_returns_same_terms( array $extra_args, array $expected ): void {
+		$unordered = wp_insert_term( 'Category A', 'product_cat' );
+		$first     = wp_insert_term( 'Category B', 'product_cat' );
+		$second    = wp_insert_term( 'Category C', 'product_cat' );
+
+		delete_term_meta( $unordered['term_id'], 'order' );
+		update_term_meta( $first['term_id'], 'order', 1 );
+		update_term_meta( $second['term_id'], 'order', 2 );
+		add_term_meta( $unordered['term_id'], 'wc_test_flag', 'yes' );
+		add_term_meta( $first['term_id'], 'wc_test_flag', 'yes' );
+		add_term_meta( $second['term_id'], 'wc_test_flag', 'no' );
+		add_term_meta( $second['term_id'], 'wc_test_missing', 'yes' );
+
+		$query = new WP_Term_Query(
+			array_merge(
+				array(
+					'taxonomy'   => 'product_cat',
+					'hide_empty' => false,
+					'fields'     => 'id=>name',
+					'include'    => array( $unordered['term_id'], $first['term_id'], $second['term_id'] ),
+				),
+				$extra_args
+			)
+		);
+
+		$this->assertSame( $expected, array_values( $query->terms ), 'The first run should return the expected terms.' );
+		$this->assertSame( $expected, array_values( $query->get_terms() ), 'A second run of the same query object should return the same terms.' );
+	}
+
+	/**
+	 * Query arguments whose results must not change when the query object runs again.
+	 *
+	 * @return array
+	 */
+	public function provider_repeat_run_args(): array {
+		return array(
+			'default sort'          => array( array(), array( 'Category A', 'Category B', 'Category C' ) ),
+			'explicit order key'    => array(
+				array( 'meta_key' => 'order' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+				array( 'Category A', 'Category B', 'Category C' ),
+			),
+			'top-level meta filter' => array(
+				array(
+					'meta_key'   => 'wc_test_flag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_value' => 'yes', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+				),
+				array( 'Category A', 'Category B' ),
+			),
+			'nested meta_query'     => array(
+				array(
+					'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+						array(
+							'key'   => 'wc_test_flag',
+							'value' => 'yes',
+						),
+					),
+				),
+				array( 'Category A', 'Category B' ),
+			),
+			'order key NOT EXISTS'  => array(
+				array(
+					'meta_key'     => 'order', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_compare' => 'NOT EXISTS',
+				),
+				array( 'Category A' ),
+			),
+			'top-level NOT EXISTS'  => array(
+				array(
+					'meta_key'     => 'wc_test_missing', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+					'meta_compare' => 'NOT EXISTS', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_compare
+				),
+				array( 'Category A', 'Category B' ),
+			),
+			'compare only, no key'  => array(
+				array( 'meta_compare' => 'EXISTS' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_compare
+				array( 'Category A', 'Category B', 'Category C' ),
+			),
+			'type only, no key'     => array(
+				array( 'meta_type' => 'NUMERIC' ),
+				array( 'Category A', 'Category B', 'Category C' ),
+			),
+			'nested NOT EXISTS'     => array(
+				array(
+					'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+						array(
+							'key'     => 'wc_test_missing',
+							'compare' => 'NOT EXISTS',
+						),
+					),
+				),
+				array( 'Category A', 'Category B' ),
+			),
+		);
+	}
 }