Commit ba962b1aa44 for woocommerce
commit ba962b1aa442e7be835b440904df05902888d9eb
Author: Mike Jolley <mike.jolley@me.com>
Date: Fri Oct 23 11:16:53 2026 +0100
Rank SKU and title matches first in admin product search (#69838)
* Rank SKU, GTIN and title matches first in product search
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Add changelog entry for product search ranking
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Rank exact SKU matches first for quoted product searches
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Prepare rank values separately to avoid phpcs ignores
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Reword product search changelog entry
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/30915-rank-product-search-results b/plugins/woocommerce/changelog/30915-rank-product-search-results
new file mode 100644
index 00000000000..0b8d57d7b13
--- /dev/null
+++ b/plugins/woocommerce/changelog/30915-rank-product-search-results
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep SKU and title matches in admin product search results on large catalogs, instead of cutting them off.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index cf114809c03..b9124804597 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -2113,6 +2113,19 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$search_where = '';
$search_queries = array();
+ // A variation without its own SKU or GTIN is searched by its parent's, so rank it by that too.
+ $identifier_columns = array();
+ foreach ( array( 'sku', 'global_unique_id' ) as $column ) {
+ $identifier_columns[] = $include_variations ? "COALESCE( NULLIF( wc_product_meta_lookup.{$column}, '' ), parent_wc_product_meta_lookup.{$column} )" : "wc_product_meta_lookup.{$column}";
+ }
+
+ $rank_tiers = array(
+ 'identifier_exact' => array(),
+ 'identifier_prefix' => array(),
+ 'title_prefix' => array(),
+ 'title_match' => array(),
+ );
+
foreach ( $term_groups as $term_group ) {
// Parse search terms.
if ( preg_match_all( '/".*?("|$)|((?<=[\t ",+])|^)[^\t ",+]+/', $term_group, $matches ) ) {
@@ -2127,10 +2140,35 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$search_terms = array( $term_group );
}
- $term_group_query = array();
+ $term_group_query = array();
+ $title_match_query = array();
+
+ // Rank on the raw term as well as the parsed one: parsing strips quotes, but also splits SKUs like "AB+12".
+ $rank_phrases = array_unique(
+ array_filter(
+ array( trim( $term_group ), implode( ' ', $search_terms ) ),
+ static function ( $phrase ) {
+ return '' !== $phrase;
+ }
+ )
+ );
+
+ foreach ( $rank_phrases as $rank_phrase ) {
+ $phrase_like = $wpdb->esc_like( $rank_phrase );
+ $exact_value = $wpdb->prepare( '%s', $phrase_like );
+ $prefix_value = $wpdb->prepare( '%s', $phrase_like . '%' );
+
+ foreach ( $identifier_columns as $column ) {
+ $rank_tiers['identifier_exact'][] = "{$column} LIKE {$exact_value}";
+ $rank_tiers['identifier_prefix'][] = "{$column} LIKE {$prefix_value}";
+ }
+
+ $rank_tiers['title_prefix'][] = "posts.post_title LIKE {$prefix_value}";
+ }
foreach ( $search_terms as $search_term ) {
- $like = '%' . $wpdb->esc_like( $search_term ) . '%';
+ $like = '%' . $wpdb->esc_like( $search_term ) . '%';
+ $title_match_query[] = $wpdb->prepare( 'posts.post_title LIKE %s', $like );
// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- an array of placeholders is a valid arg.
$term_query = $wpdb->prepare(
@@ -2148,10 +2186,24 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
}
if ( $term_group_query ) {
- $search_queries[] = implode( ' AND ', $term_group_query );
+ $search_queries[] = implode( ' AND ', $term_group_query );
+ $rank_tiers['title_match'][] = '( ' . implode( ' AND ', $title_match_query ) . ' )';
}
}
+ // Rank SKU, GTIN and title matches first so a limit doesn't drop them in favour of description-only matches.
+ // The rank is only used in ORDER BY: selecting it makes DISTINCT slower on MariaDB. Both joins are on primary
+ // keys, so each product has a single rank and DISTINCT can't merge rows with different ranks.
+ $rank_cases = '';
+
+ foreach ( array_values( $rank_tiers ) as $rank => $tier_queries ) {
+ if ( $tier_queries ) {
+ $rank_cases .= ' WHEN ( ' . implode( ' OR ', $tier_queries ) . " ) THEN {$rank}";
+ }
+ }
+
+ $rank_cases .= ' ELSE ' . count( $rank_tiers );
+
if ( ! empty( $search_queries ) ) {
$search_where = ' AND ((' . implode( ') OR (', $search_queries ) . ')) ';
}
@@ -2198,7 +2250,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$search_where
$status_where
$type_where
- ORDER BY posts.post_parent ASC, posts.post_title ASC
+ ORDER BY CASE{$rank_cases} END ASC, posts.post_parent ASC, posts.post_title ASC
$limit_query
"
// phpcs:enable
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
index 62d239c6d61..e88164ad623 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-data-store-cpt-test.php
@@ -80,13 +80,15 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
*
* @param string $name Product name.
* @param string $status Post status.
+ * @param array $props Extra product props, such as sku or description.
* @return WC_Product_Simple The saved product.
*/
- private function create_search_test_product( string $name, string $status = 'publish' ): WC_Product_Simple {
+ private function create_search_test_product( string $name, string $status = 'publish', array $props = array() ): WC_Product_Simple {
$product = new WC_Product_Simple();
$product->set_name( $name );
$product->set_status( $status );
$product->set_regular_price( '10' );
+ $product->set_props( $props );
$product->save();
return $product;
@@ -346,6 +348,107 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->assertContains( $product->get_id(), $data_store->search_products( 'Searchable private product', '', false, false ), 'Private product should be returned to users who can read private products but not edit them' );
}
+ /**
+ * @testdox Limited search should keep SKU matches when more description-only matches sort ahead of them by title.
+ */
+ public function test_search_products_limit_keeps_sku_matches_over_description_matches(): void {
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $this->create_search_test_product(
+ "Aaa Filler Card {$i}",
+ 'publish',
+ array(
+ 'sku' => "FILLER-{$i}",
+ 'description' => 'A greeting card featuring a rose motif.',
+ )
+ );
+ }
+
+ $parent = WC_Helper_Product::create_variation_product();
+ $parent->set_name( 'Rose Oil' );
+ $parent->set_sku( 'ROSO' );
+ $parent->save();
+
+ list( $exact_sku_variation_id, $prefix_sku_variation_id ) = $parent->get_children();
+
+ $exact_sku_variation = wc_get_product( $exact_sku_variation_id );
+ $exact_sku_variation->set_sku( 'ROS' );
+ $exact_sku_variation->save();
+ $prefix_sku_variation = wc_get_product( $prefix_sku_variation_id );
+ $prefix_sku_variation->set_sku( 'ROS4' );
+ $prefix_sku_variation->save();
+
+ $results = WC_Data_Store::load( 'product' )->search_products( 'ROS', '', true, false, 5 );
+
+ $this->assertContains( $parent->get_id(), $results, 'Variable product with a matching SKU should be returned' );
+ $this->assertContains( $exact_sku_variation_id, $results, 'Variation with an exact SKU match should be returned' );
+ $this->assertContains( $prefix_sku_variation_id, $results, 'Variation with a matching SKU prefix should be returned' );
+ }
+
+ /**
+ * @testdox Search should rank SKU matches, then title matches, ahead of description-only matches.
+ */
+ public function test_search_products_ranks_results_by_relevance(): void {
+ $products = array();
+ foreach (
+ array(
+ 'description' => array( 'Aaa bulb', 'BULB-1', 'Fits any lamp.' ),
+ 'contains' => array( 'Floor lamp', 'FLOOR-1', '' ),
+ 'title_start' => array( 'Lamp shade', 'SHADE-1', '' ),
+ 'sku_start' => array( 'Table light', 'LAMP-2', '' ),
+ 'sku_exact' => array( 'Desk lamp', 'LAMP', '' ),
+ ) as $key => list( $name, $sku, $description )
+ ) {
+ $products[ $key ] = $this->create_search_test_product(
+ $name,
+ 'publish',
+ array(
+ 'sku' => $sku,
+ 'description' => $description,
+ )
+ )->get_id();
+ }
+
+ // Simple products report a parent ID of 0, which search_products() appends after the matches.
+ $results = array_values( array_filter( WC_Data_Store::load( 'product' )->search_products( 'lamp', '', false, false ) ) );
+
+ $this->assertSame(
+ array( $products['sku_exact'], $products['sku_start'], $products['title_start'], $products['contains'], $products['description'] ),
+ $results,
+ 'Results should be ordered by exact SKU, SKU prefix, title prefix, title match, then other matches'
+ );
+ }
+
+ /**
+ * @testdox Search for $term should rank the exact SKU match ahead of title matches.
+ *
+ * @testWith ["\"lamp\"", "LAMP", "Lamp shade"]
+ * ["AB+12", "AB+12", "AB 12 holder"]
+ *
+ * @param string $term Search term, as typed.
+ * @param string $sku SKU that exactly matches the term.
+ * @param string $title_match_name Name of a product that only matches by title.
+ */
+ public function test_search_products_ranks_exact_sku_first_for_quoted_and_split_terms( string $term, string $sku, string $title_match_name ): void {
+ $title_match = $this->create_search_test_product( $title_match_name );
+ $exact_sku = $this->create_search_test_product( 'Zzz item', 'publish', array( 'sku' => $sku ) );
+
+ $results = WC_Data_Store::load( 'product' )->search_products( $term, '', false, false );
+
+ $this->assertSame( array( $exact_sku->get_id(), $title_match->get_id() ), array_values( array_filter( $results ) ), 'The exact SKU match should rank first' );
+ }
+
+ /**
+ * @testdox Limited search should keep a GTIN match when a description-only match sorts ahead of it by title.
+ */
+ public function test_search_products_limit_keeps_gtin_matches_over_description_matches(): void {
+ $this->create_search_test_product( 'Aaa barcode list', 'publish', array( 'description' => 'Replaces 4006381333931.' ) );
+ $product = $this->create_search_test_product( 'Pencil', 'publish', array( 'global_unique_id' => '4006381333931' ) );
+
+ $results = WC_Data_Store::load( 'product' )->search_products( '4006381333931', '', false, false, 1 );
+
+ $this->assertContains( $product->get_id(), $results, 'Product with an exact GTIN match should be returned' );
+ }
+
/**
* Ensure product rating counts are calculated correctly.
*