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.
 	 *