Commit 0bd3f35b16e for woocommerce

commit 0bd3f35b16e9a6f22e603402df1d5c37ebecd288
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Fri Sep 4 15:11:26 2026 -0400

    Order product widgets by the lookup table for sales and rating (#68300)

diff --git a/plugins/woocommerce/changelog/fix-wooplug-7559-product-widget-sorting b/plugins/woocommerce/changelog/fix-wooplug-7559-product-widget-sorting
new file mode 100644
index 00000000000..bded2163fc6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7559-product-widget-sorting
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Sort the products widget by sales and the top rated products widget by rating through the product lookup table instead of post meta.
diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-products.php b/plugins/woocommerce/includes/widgets/class-wc-widget-products.php
index d061b4db9fd..e61befe544f 100644
--- a/plugins/woocommerce/includes/widgets/class-wc-widget-products.php
+++ b/plugins/woocommerce/includes/widgets/class-wc-widget-products.php
@@ -160,21 +160,94 @@ class WC_Widget_Products extends WC_Widget {
 				$query_args['orderby'] = 'menu_order';
 				break;
 			case 'price':
-				$query_args['meta_key'] = '_price'; // WPCS: slow query ok.
+				// Kept on post meta: the join also drops products that have no price at all, and
+				// wc_product_meta_lookup stores 0 for those so it cannot reproduce that filter.
+				$query_args['meta_key'] = '_price'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Ordering by the _price meta is what keeps priceless products out of the list; adding the lookup table join on top of it measured slower than the meta join alone.
 				$query_args['orderby']  = 'meta_value_num';
 				break;
 			case 'rand':
 				$query_args['orderby'] = 'rand';
 				break;
 			case 'sales':
-				$query_args['meta_key'] = 'total_sales'; // WPCS: slow query ok.
+				// Left in the args so that the query args filter below keeps seeing the documented
+				// payload; the query itself is switched to wc_product_meta_lookup afterwards.
+				$query_args['meta_key'] = 'total_sales'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Part of the filter payload only; query_products() orders through wc_product_meta_lookup instead of joining post meta.
 				$query_args['orderby']  = 'meta_value_num';
 				break;
 			default:
 				$query_args['orderby'] = 'date';
 		}

-		return new WP_Query( apply_filters( 'woocommerce_products_widget_query_args', $query_args ) );
+		/**
+		 * Filters the query arguments the Products widget uses to fetch its products.
+		 *
+		 * @since 2.4.0
+		 * @param array $query_args Arguments passed to WP_Query.
+		 */
+		$query_args = apply_filters( 'woocommerce_products_widget_query_args', $query_args );
+
+		return $this->query_products( $query_args );
+	}
+
+	/**
+	 * Run the widget query, ordering through the product lookup table where that is equivalent to, and
+	 * cheaper than, ordering through post meta.
+	 *
+	 * Only the sales ordering is redirected. `total_sales` post meta is written for every product, so the
+	 * meta join it replaces filters nothing out, while `wc_product_meta_lookup` holds one narrow row per
+	 * product instead of one row per product in a table that grows with every extension's meta. Price
+	 * ordering deliberately stays on post meta; see the `price` case in get_products().
+	 *
+	 * The swap happens after `woocommerce_products_widget_query_args` has run, and only when nothing on
+	 * that filter changed the ordering arguments, so the filter payload keeps its documented shape and a
+	 * consumer that overrides the ordering still wins.
+	 *
+	 * @param array $query_args Query arguments, as returned by the widget query args filter.
+	 *
+	 * @return WP_Query
+	 */
+	private function query_products( $query_args ) {
+		// WP_Query skips posts_clauses when a filter asks for suppressed filters, which would leave the
+		// query with no ordering at all, so stay on the post meta ordering in that case.
+		$orders_by_total_sales = isset( $query_args['meta_key'], $query_args['orderby'] )
+			&& empty( $query_args['suppress_filters'] )
+			&& 'total_sales' === $query_args['meta_key']
+			&& 'meta_value_num' === $query_args['orderby'];
+
+		if ( ! $orders_by_total_sales ) {
+			return new WP_Query( $query_args );
+		}
+
+		$order = isset( $query_args['order'] ) && 'asc' === strtolower( $query_args['order'] ) ? 'ASC' : 'DESC';
+
+		unset( $query_args['meta_key'] );
+		$query_args['orderby'] = 'none';
+
+		$products = new WP_Query();
+
+		$order_by_total_sales = static function ( $clauses, $query ) use ( $products, $order ) {
+			global $wpdb;
+
+			if ( $query !== $products ) {
+				return $clauses;
+			}
+
+			if ( ! strstr( $clauses['join'], 'wc_product_meta_lookup' ) ) {
+				$clauses['join'] .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON {$wpdb->posts}.ID = wc_product_meta_lookup.product_id ";
+			}
+
+			// The IS NULL term sorts ascending in both directions, so products that have no lookup row
+			// (a partly regenerated table, say) always land last instead of leading an ascending list.
+			$clauses['orderby'] = " wc_product_meta_lookup.total_sales IS NULL, wc_product_meta_lookup.total_sales {$order}, wc_product_meta_lookup.product_id {$order} ";
+
+			return $clauses;
+		};
+
+		add_filter( 'posts_clauses', $order_by_total_sales, 10, 2 );
+		$products->query( $query_args );
+		remove_filter( 'posts_clauses', $order_by_total_sales, 10 );
+
+		return $products;
 	}

 	/**
diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-top-rated-products.php b/plugins/woocommerce/includes/widgets/class-wc-widget-top-rated-products.php
index cc7663f8fb9..2064e105fdf 100644
--- a/plugins/woocommerce/includes/widgets/class-wc-widget-top-rated-products.php
+++ b/plugins/woocommerce/includes/widgets/class-wc-widget-top-rated-products.php
@@ -65,15 +65,17 @@ class WC_Widget_Top_Rated_Products extends WC_Widget {
 				'no_found_rows'  => 1,
 				'post_status'    => 'publish',
 				'post_type'      => 'product',
-				'meta_key'       => '_wc_average_rating',
+				// Left in the args so that the filter above keeps seeing the documented payload; the
+				// query itself is switched to wc_product_meta_lookup in query_top_rated_products().
+				'meta_key'       => '_wc_average_rating', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Part of the filter payload only; query_top_rated_products() orders through wc_product_meta_lookup instead of joining post meta.
 				'orderby'        => 'meta_value_num',
 				'order'          => 'DESC',
-				'meta_query'     => WC()->query->get_meta_query(),
-				'tax_query'      => WC()->query->get_tax_query(),
+				'meta_query'     => WC()->query->get_meta_query(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Empty unless an extension adds clauses through woocommerce_product_query_meta_query; the same container the shop catalog query uses.
+				'tax_query'      => WC()->query->get_tax_query(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Product visibility exclusions are indexed by term_taxonomy_id and are the only way to hide catalog-excluded products.
 			)
-		); // WPCS: slow query ok.
+		);

-		$r = new WP_Query( $query_args );
+		$r = $this->query_top_rated_products( $query_args );

 		if ( $r->have_posts() ) {

@@ -104,4 +106,50 @@ class WC_Widget_Top_Rated_Products extends WC_Widget {

 		$this->cache_widget( $args, $content );
 	}
+
+	/**
+	 * Run the widget query, ordering through the product lookup table instead of the rating post meta.
+	 *
+	 * `_wc_average_rating` post meta is written for every product, so the meta join this replaces filters
+	 * nothing out, while `wc_product_meta_lookup` holds one narrow row per product instead of one row per
+	 * product in a table that grows with every extension's meta. Ordering is delegated to the same clause
+	 * callback the shop catalog uses for "sort by average rating", which also breaks ties on rating count
+	 * and product ID rather than leaving equally rated products in an undefined order.
+	 *
+	 * The swap happens after `woocommerce_top_rated_products_widget_args` has run, and only when nothing on
+	 * that filter changed the ordering arguments, so the filter payload keeps its documented shape and a
+	 * consumer that overrides the ordering still wins.
+	 *
+	 * @param array $query_args Query arguments, as returned by the widget args filter.
+	 *
+	 * @return WP_Query
+	 */
+	private function query_top_rated_products( $query_args ) {
+		// WP_Query skips posts_clauses when a filter asks for suppressed filters, which would leave the
+		// query with no ordering at all, so stay on the post meta ordering in that case.
+		$orders_by_average_rating = isset( $query_args['meta_key'], $query_args['orderby'], $query_args['order'] )
+			&& empty( $query_args['suppress_filters'] )
+			&& '_wc_average_rating' === $query_args['meta_key']
+			&& 'meta_value_num' === $query_args['orderby']
+			&& 'DESC' === strtoupper( $query_args['order'] );
+
+		if ( ! $orders_by_average_rating ) {
+			return new WP_Query( $query_args );
+		}
+
+		unset( $query_args['meta_key'] );
+		$query_args['orderby'] = 'none';
+
+		$products = new WP_Query();
+
+		$order_by_rating = static function ( $clauses, $query ) use ( $products ) {
+			return $query === $products ? WC()->query->order_by_rating_post_clauses( $clauses ) : $clauses;
+		};
+
+		add_filter( 'posts_clauses', $order_by_rating, 10, 2 );
+		$products->query( $query_args );
+		remove_filter( 'posts_clauses', $order_by_rating, 10 );
+
+		return $products;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/widgets/class-wc-widget-products-test.php b/plugins/woocommerce/tests/php/includes/widgets/class-wc-widget-products-test.php
new file mode 100644
index 00000000000..1a0cb446e86
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/widgets/class-wc-widget-products-test.php
@@ -0,0 +1,135 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Widget_Products.
+ *
+ * @package WooCommerce\Tests\Widgets
+ */
+
+/**
+ * WC_Widget_Products_Test class.
+ */
+class WC_Widget_Products_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * Query the widget for its products, ordered by sales.
+	 *
+	 * Every call flushes the object cache first: the lookup table is not part of the post cache, so
+	 * WP_Query would otherwise hand back the result ids it cached for an identical earlier query.
+	 *
+	 * @param string $order 'asc' or 'desc'.
+	 *
+	 * @return int[] Product ids, in the order the widget returned them.
+	 */
+	private function sales_ordered_ids( string $order ): array {
+		wp_cache_flush();
+
+		$query = ( new WC_Widget_Products() )->get_products(
+			array(),
+			array(
+				'number'  => 3,
+				'orderby' => 'sales',
+				'order'   => $order,
+			)
+		);
+
+		return wp_list_pluck( $query->posts, 'ID' );
+	}
+
+	/**
+	 * Sales ordering comes from wc_product_meta_lookup, breaks ties deterministically, keeps products
+	 * that have no lookup row last, and steps aside for a filter that changes the ordering.
+	 */
+	public function test_sales_ordering_reads_total_sales_from_the_lookup_table(): void {
+		global $wpdb;
+
+		$ids = array();
+		foreach ( array( 20, 30, 10 ) as $total_sales ) {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_total_sales( $total_sales );
+			$product->save();
+			$ids[] = $product->get_id();
+		}
+
+		// Rank the products differently in the lookup table from the post meta, so the two sources
+		// can be told apart. Post meta ranks them p1, p0, p2; the lookup table ranks them p2, p1, p0.
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'total_sales' => 10 ), array( 'product_id' => $ids[0] ) );
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'total_sales' => 20 ), array( 'product_id' => $ids[1] ) );
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'total_sales' => 30 ), array( 'product_id' => $ids[2] ) );
+
+		$this->assertSame(
+			array( $ids[2], $ids[1], $ids[0] ),
+			$this->sales_ordered_ids( 'desc' ),
+			'Sales ordering should follow the lookup table, not the post meta.'
+		);
+
+		// A filter that suppresses query filters would stop WP_Query running posts_clauses, so the
+		// widget has to fall back to the post meta ordering rather than emit an unordered query.
+		$suppress_filters = static function ( $args ) {
+			$args['suppress_filters'] = true;
+			return $args;
+		};
+
+		add_filter( 'woocommerce_products_widget_query_args', $suppress_filters );
+
+		try {
+			$this->assertSame(
+				array( $ids[1], $ids[0], $ids[2] ),
+				$this->sales_ordered_ids( 'desc' ),
+				'Suppressed filters should fall back to the post meta ordering.'
+			);
+		} finally {
+			remove_filter( 'woocommerce_products_widget_query_args', $suppress_filters );
+		}
+
+		// Equal sales are broken by product id, in whichever direction the widget is ordered.
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'total_sales' => 50 ), array( 'product_id' => $ids[0] ) );
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'total_sales' => 50 ), array( 'product_id' => $ids[1] ) );
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'total_sales' => 5 ), array( 'product_id' => $ids[2] ) );
+
+		$this->assertSame(
+			array( $ids[1], $ids[0], $ids[2] ),
+			$this->sales_ordered_ids( 'desc' ),
+			'Products on equal sales should be ordered by descending product id.'
+		);
+		$this->assertSame(
+			array( $ids[2], $ids[0], $ids[1] ),
+			$this->sales_ordered_ids( 'asc' ),
+			'Products on equal sales should be ordered by ascending product id.'
+		);
+
+		// A filter that changes the ordering wins; the lookup ordering is not applied on top of it.
+		$order_by_id = static function ( $args ) {
+			$args['orderby'] = 'ID';
+			return $args;
+		};
+
+		add_filter( 'woocommerce_products_widget_query_args', $order_by_id );
+
+		try {
+			$this->assertSame(
+				array( $ids[2], $ids[1], $ids[0] ),
+				$this->sales_ordered_ids( 'desc' ),
+				'A filter that changes orderby should decide the ordering.'
+			);
+		} finally {
+			remove_filter( 'woocommerce_products_widget_query_args', $order_by_id );
+		}
+
+		// A product with no lookup row at all, as happens while the lookup table is being regenerated,
+		// sorts last whichever way round the widget is ordered.
+		$wpdb->delete( $wpdb->wc_product_meta_lookup, array( 'product_id' => $ids[2] ) );
+
+		$this->assertSame(
+			array( $ids[1], $ids[0], $ids[2] ),
+			$this->sales_ordered_ids( 'desc' ),
+			'A product with no lookup row should sort last descending.'
+		);
+		$this->assertSame(
+			array( $ids[0], $ids[1], $ids[2] ),
+			$this->sales_ordered_ids( 'asc' ),
+			'A product with no lookup row should sort last ascending.'
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/widgets/class-wc-widget-top-rated-products-test.php b/plugins/woocommerce/tests/php/includes/widgets/class-wc-widget-top-rated-products-test.php
new file mode 100644
index 00000000000..4664c821b62
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/widgets/class-wc-widget-top-rated-products-test.php
@@ -0,0 +1,143 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Widget_Top_Rated_Products.
+ *
+ * @package WooCommerce\Tests\Widgets
+ */
+
+/**
+ * WC_Widget_Top_Rated_Products_Test class.
+ */
+class WC_Widget_Top_Rated_Products_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * Render the widget and return the ids of the products it listed, in order.
+	 *
+	 * The ordering is read from the `the_post` action rather than the `the_posts` filter, because a
+	 * query that asks for suppressed filters never runs the latter.
+	 *
+	 * Every call flushes the object cache first: the lookup table is not part of the post cache, so
+	 * WP_Query would otherwise hand back the result ids it cached for an identical earlier query.
+	 *
+	 * @return int[] Product ids, in the order the widget listed them.
+	 */
+	private function rating_ordered_ids(): array {
+		wp_cache_flush();
+
+		$ordered = array();
+		$capture = function ( $post ) use ( &$ordered ) {
+			$ordered[] = (int) $post->ID;
+		};
+
+		add_action( 'the_post', $capture );
+		ob_start();
+
+		try {
+			( new WC_Widget_Top_Rated_Products() )->widget(
+				array(
+					'before_widget' => '',
+					'after_widget'  => '',
+					'before_title'  => '',
+					'after_title'   => '',
+				),
+				array( 'number' => 3 )
+			);
+		} finally {
+			ob_end_clean();
+			remove_action( 'the_post', $capture );
+		}
+
+		return $ordered;
+	}
+
+	/**
+	 * Rating ordering comes from wc_product_meta_lookup, breaks ties on rating count then product id,
+	 * and steps aside for a filter that changes the ordering.
+	 */
+	public function test_rating_ordering_reads_average_rating_from_the_lookup_table(): void {
+		global $wpdb;
+
+		$ids = array();
+		foreach ( array( '3.00', '5.00', '1.00' ) as $average_rating ) {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_average_rating( $average_rating );
+			$product->save();
+			$ids[] = $product->get_id();
+		}
+
+		// Rank the products differently in the lookup table from the post meta, so the two sources
+		// can be told apart. Post meta ranks them p1, p0, p2; the lookup table ranks them p2, p1, p0.
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'average_rating' => '1.00' ), array( 'product_id' => $ids[0] ) );
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'average_rating' => '3.00' ), array( 'product_id' => $ids[1] ) );
+		$wpdb->update( $wpdb->wc_product_meta_lookup, array( 'average_rating' => '5.00' ), array( 'product_id' => $ids[2] ) );
+
+		$this->assertSame(
+			array( $ids[2], $ids[1], $ids[0] ),
+			$this->rating_ordered_ids(),
+			'Rating ordering should follow the lookup table, not the post meta.'
+		);
+
+		// A filter that suppresses query filters would stop WP_Query running posts_clauses, so the
+		// widget has to fall back to the post meta ordering rather than emit an unordered query.
+		$suppress_filters = static function ( $args ) {
+			$args['suppress_filters'] = true;
+			return $args;
+		};
+
+		add_filter( 'woocommerce_top_rated_products_widget_args', $suppress_filters );
+
+		try {
+			$this->assertSame(
+				array( $ids[1], $ids[0], $ids[2] ),
+				$this->rating_ordered_ids(),
+				'Suppressed filters should fall back to the post meta ordering.'
+			);
+		} finally {
+			remove_filter( 'woocommerce_top_rated_products_widget_args', $suppress_filters );
+		}
+
+		// Equally rated products fall back to the rating count, and then to the product id.
+		$rating_counts = array(
+			$ids[0] => 2,
+			$ids[1] => 9,
+			$ids[2] => 2,
+		);
+
+		foreach ( $rating_counts as $id => $rating_count ) {
+			$wpdb->update(
+				$wpdb->wc_product_meta_lookup,
+				array(
+					'average_rating' => '5.00',
+					'rating_count'   => $rating_count,
+				),
+				array( 'product_id' => $id )
+			);
+		}
+
+		$this->assertSame(
+			array( $ids[1], $ids[2], $ids[0] ),
+			$this->rating_ordered_ids(),
+			'Equally rated products should be ordered by rating count, then by descending product id.'
+		);
+
+		// A filter that changes the ordering wins; the lookup ordering is not applied on top of it.
+		$order_by_id = static function ( $args ) {
+			$args['orderby'] = 'ID';
+			return $args;
+		};
+
+		add_filter( 'woocommerce_top_rated_products_widget_args', $order_by_id );
+
+		try {
+			$this->assertSame(
+				array( $ids[2], $ids[1], $ids[0] ),
+				$this->rating_ordered_ids(),
+				'A filter that changes orderby should decide the ordering.'
+			);
+		} finally {
+			remove_filter( 'woocommerce_top_rated_products_widget_args', $order_by_id );
+		}
+	}
+}