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 );
+ }
+ }
+}