Commit 818d888145e for woocommerce

commit 818d888145e1f4d159f15a5393ea58a5e103ee4f
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date:   Fri Sep 4 15:29:20 2026 +0300

    Fix Analytics products search dropping matches past the first 100 (#67626)

diff --git a/packages/js/data/changelog/50786-add-server-side-search-opt-out b/packages/js/data/changelog/50786-add-server-side-search-opt-out
new file mode 100644
index 00000000000..30e7b194b03
--- /dev/null
+++ b/packages/js/data/changelog/50786-add-server-side-search-opt-out
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the `woocommerce_admin_report_server_side_search_item_types` filter, so an integration that overrides a report route with a handler that does not read `search` can opt out and keep receiving resolved item IDs.
diff --git a/packages/js/data/changelog/50786-fix-analytics-products-search-limit b/packages/js/data/changelog/50786-fix-analytics-products-search-limit
new file mode 100644
index 00000000000..dd780c14235
--- /dev/null
+++ b/packages/js/data/changelog/50786-fix-analytics-products-search-limit
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add usesServerSideSearch() and pass the search term straight to report endpoints that resolve it themselves, instead of limiting them to a hydrated list of matching IDs.
diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts
index 2e5fac1bd55..a6765b8535f 100644
--- a/packages/js/data/src/index.ts
+++ b/packages/js/data/src/index.ts
@@ -107,6 +107,7 @@ export {
 	getReportTableQuery,
 	getReportChartData,
 	getTooltipValueFormat,
+	usesServerSideSearch,
 } from './reports/utils';

 // Export constants
diff --git a/packages/js/data/src/reports/test/utils.ts b/packages/js/data/src/reports/test/utils.ts
index 95c268aa81a..c3c145154b0 100644
--- a/packages/js/data/src/reports/test/utils.ts
+++ b/packages/js/data/src/reports/test/utils.ts
@@ -1,7 +1,193 @@
+/**
+ * External dependencies
+ */
+import { addFilter, removeFilter } from '@wordpress/hooks';
+
 /**
  * Internal dependencies
  */
-import { getReportChartData } from '../utils';
+import {
+	getFilterQuery,
+	getReportChartData,
+	usesServerSideSearch,
+} from '../utils';
+
+type FilterQueryOptions = Parameters< typeof getFilterQuery >[ 0 ];
+
+/**
+ * Calls getFilterQuery with only the options a given test cares about.
+ *
+ * @param {Object} options Partial getFilterQuery options.
+ * @return {Object} The filter query.
+ */
+const filterQuery = ( options: Partial< FilterQueryOptions > ) =>
+	getFilterQuery( options as FilterQueryOptions );
+
+describe( 'usesServerSideSearch', () => {
+	it( 'should be true when the search resolves to products', () => {
+		expect( usesServerSideSearch( [ 'products' ] ) ).toBe( true );
+	} );
+
+	it( 'should be true when another filter limits the report as well', () => {
+		// The single category view searches products and limits the request to the
+		// category. The endpoint intersects the two.
+		expect( usesServerSideSearch( [ 'products', 'categories' ] ) ).toBe(
+			true
+		);
+	} );
+
+	it( 'should be false when the search resolves to something else', () => {
+		// The Categories report list view searches category names, not products.
+		expect( usesServerSideSearch( [ 'categories', 'products' ] ) ).toBe(
+			false
+		);
+		expect( usesServerSideSearch( [ 'categories' ] ) ).toBe( false );
+		expect( usesServerSideSearch( [ 'coupons' ] ) ).toBe( false );
+	} );
+
+	it( 'should be false when nothing limits the report', () => {
+		expect( usesServerSideSearch( [] ) ).toBe( false );
+	} );
+
+	it( 'should be false when the limit properties are not a list', () => {
+		const limitProperties = ( value: unknown ) => value as string[];
+
+		expect( usesServerSideSearch( limitProperties( undefined ) ) ).toBe(
+			false
+		);
+		expect( usesServerSideSearch( limitProperties( null ) ) ).toBe( false );
+		expect( usesServerSideSearch( limitProperties( 'products' ) ) ).toBe(
+			false
+		);
+	} );
+
+	describe( 'woocommerce_admin_report_server_side_search_item_types', () => {
+		const hook = 'woocommerce_admin_report_server_side_search_item_types';
+
+		afterEach( () => {
+			removeFilter( hook, 'test' );
+		} );
+
+		it( 'should let an integration opt an item type out', () => {
+			// An integration replacing the products report route with a handler that does
+			// not read `search` needs the client to resolve the term into IDs instead.
+			addFilter( hook, 'test', ( itemTypes: string[] ) =>
+				itemTypes.filter( ( itemType ) => itemType !== 'products' )
+			);
+
+			expect( usesServerSideSearch( [ 'products' ] ) ).toBe( false );
+		} );
+
+		it( 'should take a callback registered after the module loaded into account', () => {
+			expect( usesServerSideSearch( [ 'coupons' ] ) ).toBe( false );
+
+			addFilter( hook, 'test', ( itemTypes: string[] ) => [
+				...itemTypes,
+				'coupons',
+			] );
+
+			expect( usesServerSideSearch( [ 'coupons' ] ) ).toBe( true );
+		} );
+
+		it( 'should be false when a callback returns something other than a list', () => {
+			addFilter( hook, 'test', () => undefined );
+
+			expect( usesServerSideSearch( [ 'products' ] ) ).toBe( false );
+		} );
+	} );
+} );
+
+describe( 'getFilterQuery', () => {
+	it( 'should send the search term to the products endpoint instead of a list of IDs', () => {
+		expect(
+			filterQuery( {
+				endpoint: 'products',
+				query: { search: 'widget' },
+			} )
+		).toEqual( { search: [ 'widget' ] } );
+	} );
+
+	it( 'should keep an active product filter alongside the search term', () => {
+		// Picking a comparison or a single product does not clear the search, and the
+		// endpoint intersects the two, so dropping the IDs would widen the report.
+		expect(
+			filterQuery( {
+				endpoint: 'products',
+				query: { search: 'widget', products: '1,2,3' },
+			} )
+		).toEqual( { search: [ 'widget' ], products: '1,2,3' } );
+	} );
+
+	it( 'should not send an empty product filter alongside the search term', () => {
+		expect(
+			filterQuery( {
+				endpoint: 'products',
+				query: { search: 'widget', products: '' },
+			} )
+		).toEqual( { search: [ 'widget' ] } );
+	} );
+
+	it( 'should split a comma separated search into separate terms', () => {
+		expect(
+			filterQuery( {
+				endpoint: 'products',
+				query: { search: 'widget,gadget' },
+			} )
+		).toEqual( { search: [ 'widget', 'gadget' ] } );
+	} );
+
+	it( 'should unescape a comma inside a single search term', () => {
+		expect(
+			filterQuery( {
+				endpoint: 'products',
+				query: { search: 'widget%2C large' },
+			} )
+		).toEqual( { search: [ 'widget, large' ] } );
+	} );
+
+	it( 'should send the search term alongside the other limits of a product request', () => {
+		// The single category view. The endpoint intersects the term with the category,
+		// instead of the client capping the search at one page of resolved IDs.
+		expect(
+			filterQuery( {
+				endpoint: 'products',
+				query: { search: 'widget', products: '1,2', categories: '5' },
+				limitBy: [ 'products', 'categories' ],
+			} )
+		).toEqual( {
+			search: [ 'widget' ],
+			products: '1,2',
+			categories: '5',
+		} );
+	} );
+
+	it( 'should keep sending resolved IDs when the search resolves to something else', () => {
+		// The Categories report list view searches category names against the products
+		// endpoint, so the term is not the product search the endpoint would run.
+		expect(
+			filterQuery( {
+				endpoint: 'products',
+				query: { search: 'clothing', categories: '5,6' },
+				limitBy: [ 'categories' ],
+			} )
+		).toEqual( { categories: '5,6' } );
+	} );
+
+	it( 'should keep sending resolved IDs for endpoints that do not resolve the search', () => {
+		expect(
+			filterQuery( {
+				endpoint: 'coupons',
+				query: { search: 'half off', coupons: '7,8' },
+			} )
+		).toEqual( { coupons: '7,8' } );
+	} );
+
+	it( 'should fall through to the configured filters when there is no search', () => {
+		expect( filterQuery( { endpoint: 'products', query: {} } ) ).toEqual(
+			{}
+		);
+	} );
+} );

 describe( 'getReportChartData()', () => {
 	it( 'returns updated data when a report is refetched for the same query', () => {
diff --git a/packages/js/data/src/reports/utils.ts b/packages/js/data/src/reports/utils.ts
index dcf214cac62..a0490aaae2f 100644
--- a/packages/js/data/src/reports/utils.ts
+++ b/packages/js/data/src/reports/utils.ts
@@ -13,9 +13,11 @@ import {
 	flattenFilters,
 	getActiveFiltersFromQuery,
 	getQueryFromActiveFilters,
+	getSearchWords,
 } from '@woocommerce/navigation';
 import deprecated from '@wordpress/deprecated';
 import { select as WPSelect } from '@wordpress/data';
+import { applyFilters } from '@wordpress/hooks';

 /**
  * Internal dependencies
@@ -161,6 +163,55 @@ export function getQueryFromConfig(
 	};
 }

+const SERVER_SIDE_SEARCH_ITEM_TYPES_FILTER =
+	'woocommerce_admin_report_server_side_search_item_types';
+
+// Item types whose report endpoints resolve a `search` argument themselves. For every other
+// type the client has to turn the search into a list of matching item IDs first and pass
+// those as the limit-by parameter, which caps the report at one page of search results.
+const serverSideSearchItemTypes = [ 'products' ];
+
+/**
+ * Whether a report request can pass its search term straight to the API.
+ *
+ * The first limit property is what the search resolves to: the Products report searches
+ * products, the Categories report searches categories, and its single category view searches
+ * products again. Any further property is a filter the endpoint applies on top of the term.
+ *
+ * @param {Array} limitProperties Properties used to limit the results, search subject first.
+ * @return {boolean} True when the search can be resolved server-side.
+ */
+export function usesServerSideSearch( limitProperties: string[] ) {
+	if ( ! Array.isArray( limitProperties ) ) {
+		return false;
+	}
+
+	/**
+	 * Item types whose report endpoint resolves a `search` argument itself.
+	 *
+	 * An integration that replaces one of these report routes with a handler that does not
+	 * read `search` should remove that type, so the client goes back to resolving the term
+	 * into item IDs and sending those as the limit-by parameter instead.
+	 *
+	 * The filter is applied per call rather than once, so a callback registered after this
+	 * module loads is still taken into account.
+	 *
+	 * @filter woocommerce_admin_report_server_side_search_item_types
+	 * @param {Array.<string>} itemTypes Item types the report endpoints search themselves.
+	 */
+	const itemTypes = applyFilters(
+		SERVER_SIDE_SEARCH_ITEM_TYPES_FILTER,
+		serverSideSearchItemTypes
+	);
+
+	// A callback is free to return anything, and every report search runs through here.
+	if ( ! Array.isArray( itemTypes ) ) {
+		return false;
+	}
+
+	return includes( itemTypes, limitProperties[ 0 ] );
+}
+
 /**
  * Add filters and advanced filters values to a query object.
  *
@@ -186,6 +237,24 @@ export function getFilterQuery(
 	} = options;
 	if ( query.search ) {
 		const limitProperties = limitBy || [ endpoint ];
+
+		if ( usesServerSideSearch( limitProperties ) ) {
+			// A filter can still be active alongside the search, since picking one does not
+			// clear the term. Sending both keeps the comparison, the single item selection or
+			// the category, which the endpoint intersects with what the term matches.
+			return limitProperties.reduce<
+				Record< string, string | string[] >
+			>(
+				( result, limitProperty ) => {
+					if ( query[ limitProperty ] ) {
+						result[ limitProperty ] = query[ limitProperty ];
+					}
+					return result;
+				},
+				{ search: getSearchWords( query ) }
+			);
+		}
+
 		return limitProperties.reduce< Record< string, string > >(
 			( result, limitProperty ) => {
 				result[ limitProperty ] = query[ limitProperty ];
diff --git a/plugins/woocommerce/changelog/50786-add-server-side-search-opt-out b/plugins/woocommerce/changelog/50786-add-server-side-search-opt-out
new file mode 100644
index 00000000000..dd6608e389e
--- /dev/null
+++ b/plugins/woocommerce/changelog/50786-add-server-side-search-opt-out
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the `woocommerce_admin_report_server_side_search_item_types` JavaScript filter, so an integration that overrides an Analytics report route with a handler that does not read `search` can opt out of server-side search and keep receiving resolved item IDs.
diff --git a/plugins/woocommerce/changelog/50786-fix-analytics-products-search-limit b/plugins/woocommerce/changelog/50786-fix-analytics-products-search-limit
new file mode 100644
index 00000000000..2101c257c4f
--- /dev/null
+++ b/plugins/woocommerce/changelog/50786-fix-analytics-products-search-limit
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Show every matching product when a search matches more than 100 products, in the Analytics > Products report and in the single category view of the Analytics > Categories report. The products report endpoints now accept a `search` argument and resolve the search themselves.
diff --git a/plugins/woocommerce/changelog/fix-analytics-disjoint-category-product-filters b/plugins/woocommerce/changelog/fix-analytics-disjoint-category-product-filters
new file mode 100644
index 00000000000..233b829b55f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-analytics-disjoint-category-product-filters
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Report nothing instead of everything in Analytics when the category and product filters have no product in common. An empty intersection was indistinguishable from an absent product filter, so both filters were dropped. Affects the products, products/stats, variations, variations/stats and orders/stats report endpoints.
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/report-chart/index.js b/plugins/woocommerce/client/admin/client/analytics/components/report-chart/index.js
index 97f7b17d596..573cb5d4587 100644
--- a/plugins/woocommerce/client/admin/client/analytics/components/report-chart/index.js
+++ b/plugins/woocommerce/client/admin/client/analytics/components/report-chart/index.js
@@ -1,7 +1,6 @@
 /**
  * External dependencies
  */
-import { __ } from '@wordpress/i18n';
 import { Component } from '@wordpress/element';
 import { compose } from '@wordpress/compose';
 import { format as formatDate } from '@wordpress/date';
@@ -33,6 +32,7 @@ import {
 	createDateFormatter,
 	buildChartData,
 } from './utils';
+import { getEmptyMessage, hasEmptySearchResults } from '../utils';

 /**
  * Component that renders the chart in reports.
@@ -125,11 +125,12 @@ export class ReportChart extends Component {

 	renderChart( mode, isRequesting, chartData, legendTotals ) {
 		const {
-			emptySearchResults,
+			endpoint,
 			filterParam,
 			interactiveLegend,
 			itemsLabel,
 			legendPosition,
+			limitProperties,
 			path,
 			query,
 			selectedChart,
@@ -147,9 +148,10 @@ export class ReportChart extends Component {
 			primaryData.data.intervals.length,
 			{ type: 'php' }
 		);
-		const emptyMessage = emptySearchResults
-			? __( 'No data for the current search', 'woocommerce' )
-			: __( 'No data for the selected date range', 'woocommerce' );
+		const emptyMessage = getEmptyMessage(
+			query,
+			limitProperties || [ endpoint ]
+		);
 		const { formatAmount, getCurrencyConfig } = this.context;
 		return (
 			<Chart
@@ -358,15 +360,9 @@ export default compose(
 			return newProps;
 		}

-		const hasLimitByParam = limitBy.some(
-			( item ) => query[ item ] && query[ item ].length
-		);
-
-		if ( query.search && ! hasLimitByParam ) {
-			return {
-				...newProps,
-				emptySearchResults: true,
-			};
+		// Nothing matched, so skip the request and let the chart render its empty state.
+		if ( hasEmptySearchResults( query, limitBy ) ) {
+			return newProps;
 		}

 		const reportStoreSelector = select( reportsStore );
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/report-summary/index.js b/plugins/woocommerce/client/admin/client/analytics/components/report-summary/index.js
index 8321a09f0d0..0f1231c26a3 100644
--- a/plugins/woocommerce/client/admin/client/analytics/components/report-summary/index.js
+++ b/plugins/woocommerce/client/admin/client/analytics/components/report-summary/index.js
@@ -22,6 +22,7 @@ import { CurrencyContext } from '@woocommerce/currency';
 /**
  * Internal dependencies
  */
+import { hasEmptySearchResults } from '../utils';

 /**
  * Component to render summary numbers in reports.
@@ -209,11 +210,7 @@ export default compose(
 		} = props;
 		const limitBy = limitProperties || [ endpoint ];

-		const hasLimitByParam = limitBy.some(
-			( item ) => query[ item ] && query[ item ].length
-		);
-
-		if ( query.search && ! hasLimitByParam ) {
+		if ( hasEmptySearchResults( query, limitBy ) ) {
 			return {
 				emptySearchResults: true,
 			};
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js b/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js
index 2fb39ea9ca9..4d05aba4273 100644
--- a/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js
+++ b/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js
@@ -44,6 +44,7 @@ import { recordEvent } from '@woocommerce/tracks';
  */
 import DownloadIcon from './download-icon';
 import { extendTableData, getExportQuery } from './utils';
+import { hasEmptySearchResults } from '../utils';
 import './style.scss';

 const TABLE_FILTER = 'woocommerce_admin_report_table';
@@ -65,13 +66,13 @@ const ReportTable = ( props ) => {
 		endpoint,
 		// These props are not used in the render function, but are destructured
 		// so they are not included in the `tableProps` variable.
-		// eslint-disable-next-line no-unused-vars
 		itemIdField,
-		// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
+		// eslint-disable-next-line @typescript-eslint/no-unused-vars
 		tableQuery = {},
 		compareBy,
 		compareParam = 'filter',
 		searchBy,
+		limitProperties,
 		labels = {},
 		...tableProps
 	} = props;
@@ -539,6 +540,11 @@ ReportTable.propTypes = {
 		helpText: PropTypes.string,
 		placeholder: PropTypes.string,
 	} ),
+	/**
+	 * Properties used to limit the results. It will be used in the API call to send the IDs.
+	 * Defaults to the `endpoint`.
+	 */
+	limitProperties: PropTypes.array,
 	/**
 	 * Primary data of that report. If it's not provided, it will be automatically
 	 * loaded via the provided `endpoint`.
@@ -577,6 +583,7 @@ export default compose(
 			getSummary,
 			isRequesting,
 			itemIdField,
+			limitProperties,
 			query,
 			tableData,
 			tableQuery,
@@ -586,13 +593,13 @@ export default compose(
 			extendedItemsStoreName,
 		} = props;

+		const limitBy = limitProperties || [ endpoint ];
+
 		const extendedStoreSelector = extendedItemsStoreName
 			? select( extendedItemsStoreName )
 			: null;

-		const noSearchResultsFound =
-			query.search && ! ( query[ endpoint ] && query[ endpoint ].length );
-		if ( isRequesting || noSearchResultsFound ) {
+		if ( isRequesting || hasEmptySearchResults( query, limitBy ) ) {
 			return EMPTY_OBJECT;
 		}

@@ -609,6 +616,7 @@ export default compose(
 					selector: reportStoreSelector,
 					dataType: 'primary',
 					query,
+					limitBy,
 					filters,
 					advancedFilters,
 					defaultDateRange,
@@ -621,6 +629,7 @@ export default compose(
 				endpoint,
 				query,
 				selector: reportStoreSelector,
+				limitBy,
 				tableQuery,
 				filters,
 				advancedFilters,
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/test/utils.js b/plugins/woocommerce/client/admin/client/analytics/components/test/utils.js
new file mode 100644
index 00000000000..3bc2ecb921d
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/components/test/utils.js
@@ -0,0 +1,108 @@
+/**
+ * Internal dependencies
+ */
+import { getEmptyMessage, hasEmptySearchResults } from '../utils';
+
+describe( 'hasEmptySearchResults', () => {
+	it( 'returns false when there is no search', () => {
+		expect( hasEmptySearchResults( {}, [ 'coupons' ] ) ).toBe( false );
+	} );
+
+	it( 'returns false when the endpoint resolves the search itself', () => {
+		// The term is sent through as `search`, so the API decides what it matches
+		// and an empty ID list on the client means nothing.
+		expect(
+			hasEmptySearchResults( { search: 'kingston' }, [ 'products' ] )
+		).toBe( false );
+	} );
+
+	it( 'returns true when a client resolved search matched nothing', () => {
+		expect(
+			hasEmptySearchResults( { search: 'kingston' }, [ 'coupons' ] )
+		).toBe( true );
+	} );
+
+	it( 'returns false when a client resolved search produced IDs', () => {
+		expect(
+			hasEmptySearchResults( { search: 'kingston', coupons: '1,2,3' }, [
+				'coupons',
+			] )
+		).toBe( false );
+	} );
+
+	it( 'returns false when the endpoint resolves the search under another limit too', () => {
+		// The Categories report single category view sends the term and the category,
+		// so the API is still the one deciding what matched.
+		expect(
+			hasEmptySearchResults( { search: 'kingston' }, [
+				'products',
+				'categories',
+			] )
+		).toBe( false );
+	} );
+
+	it( 'ignores limit properties other than the search subject', () => {
+		// The resolved IDs land on the first property. A filter carried alongside says
+		// nothing about whether the term matched.
+		expect(
+			hasEmptySearchResults( { search: 'clothing', products: '1,2' }, [
+				'categories',
+				'products',
+			] )
+		).toBe( true );
+	} );
+
+	it( 'returns true when the limit property is present but empty', () => {
+		expect(
+			hasEmptySearchResults( { search: 'kingston', coupons: '' }, [
+				'coupons',
+			] )
+		).toBe( true );
+	} );
+} );
+
+describe( 'getEmptyMessage', () => {
+	const searchMessage = 'No data for the current search';
+	const dateRangeMessage = 'No data for the selected date range';
+	const bothMessage =
+		'No data for the current search in the selected date range';
+
+	it( 'blames the date range when there is no search', () => {
+		expect( getEmptyMessage( {}, [ 'products' ] ) ).toBe(
+			dateRangeMessage
+		);
+	} );
+
+	it( 'blames the search when a client resolved search matched nothing', () => {
+		expect( getEmptyMessage( { search: 'kingston' }, [ 'coupons' ] ) ).toBe(
+			searchMessage
+		);
+	} );
+
+	it( 'blames the date range when a client resolved search produced IDs', () => {
+		// The search matched, so an empty report is down to the date range.
+		expect(
+			getEmptyMessage( { search: 'kingston', coupons: '1,2,3' }, [
+				'coupons',
+			] )
+		).toBe( dateRangeMessage );
+	} );
+
+	it( 'names both when the endpoint resolves the search itself', () => {
+		// The API answers the same way whether the term matched no product or matched
+		// products without sales in the period, so neither one can be blamed on its own.
+		expect(
+			getEmptyMessage( { search: 'kingston' }, [ 'products' ] )
+		).toBe( bothMessage );
+	} );
+
+	it( 'names both for a product request that also carries a category', () => {
+		// The single category view resolves the search server side too.
+		expect(
+			getEmptyMessage(
+				{ search: 'kingston', products: '1,2', categories: '5' },
+				[ 'products', 'categories' ]
+			)
+		).toBe( bothMessage );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/utils.js b/plugins/woocommerce/client/admin/client/analytics/components/utils.js
new file mode 100644
index 00000000000..96bc976688f
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/components/utils.js
@@ -0,0 +1,55 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { usesServerSideSearch } from '@woocommerce/data';
+
+/**
+ * Whether a report should render its empty search state.
+ *
+ * Only reports that resolve the search client side can know this: their query carries the
+ * matching item IDs, so an active search without any means nothing matched.
+ *
+ * @param {Object} query   Current query object.
+ * @param {Array}  limitBy Properties used to limit the results, search subject first.
+ * @return {boolean} True when the search is known to have matched nothing.
+ */
+export function hasEmptySearchResults( query, limitBy ) {
+	if ( ! query.search || usesServerSideSearch( limitBy ) ) {
+		return false;
+	}
+
+	// The resolved IDs land on the search subject. Any further limit property is an
+	// independent filter and says nothing about whether the term matched.
+	const [ searchSubject ] = limitBy;
+	if ( ! searchSubject ) {
+		return false;
+	}
+
+	return ! ( query[ searchSubject ] && query[ searchSubject ].length );
+}
+
+/**
+ * Returns the message explaining why a report has nothing to show.
+ *
+ * @param {Object} query   Current query object.
+ * @param {Array}  limitBy Properties used to limit the results, search subject first.
+ * @return {string} Message to render in place of the report.
+ */
+export function getEmptyMessage( query, limitBy ) {
+	if ( hasEmptySearchResults( query, limitBy ) ) {
+		// The client resolved the search itself, so it knows the term is what matched nothing.
+		return __( 'No data for the current search', 'woocommerce' );
+	}
+
+	if ( query.search && usesServerSideSearch( limitBy ) ) {
+		// The endpoint answers the same way whether the term matched nothing or matched items
+		// without data in the period, so name both rather than blame the wrong one.
+		return __(
+			'No data for the current search in the selected date range',
+			'woocommerce'
+		);
+	}
+
+	return __( 'No data for the selected date range', 'woocommerce' );
+}
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/categories/index.js b/plugins/woocommerce/client/admin/client/analytics/report/categories/index.js
index 66fdfef2aae..52e0bf680cf 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/categories/index.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/categories/index.js
@@ -101,6 +101,7 @@ class CategoriesReport extends Component {
 					<ProductsReportTable
 						isRequesting={ isRequesting }
 						query={ chartQuery }
+						limitProperties={ [ 'products', 'categories' ] }
 						baseSearchQuery={ { filter: 'single_category' } }
 						hideCompare={ isSingleCategoryView }
 						filters={ filters }
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/index.js b/plugins/woocommerce/client/admin/client/analytics/report/index.js
index 08be5347515..aa1d613d751 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/index.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/index.js
@@ -7,7 +7,11 @@ import { withSelect } from '@wordpress/data';
 import PropTypes from 'prop-types';
 import { find } from 'lodash';
 import { getQuery, getSearchWords } from '@woocommerce/navigation';
-import { searchItemsByString, itemsStore } from '@woocommerce/data';
+import {
+	searchItemsByString,
+	itemsStore,
+	usesServerSideSearch,
+} from '@woocommerce/data';
 import { AnalyticsError } from '@woocommerce/components';
 import {
 	CurrencyContext,
@@ -126,19 +130,25 @@ export default compose(
 		}

 		const report = getReportParam( props );
-		const searchWords = getSearchWords( query );
+
 		// Single category view in Categories Report uses the products endpoint, so search must also.
 		const mappedReport =
 			report === 'categories' && query.filter === 'single_category'
 				? 'products'
 				: report;

+		// Nothing to hydrate when the report endpoint resolves the search itself. The rest still
+		// need the term turned into a list of matching IDs, which is what caps them at 100.
+		if ( usesServerSideSearch( [ mappedReport ] ) ) {
+			return {};
+		}
+
 		const itemsSelector = select( itemsStore );

 		const itemsResult = searchItemsByString(
 			itemsSelector,
 			mappedReport,
-			searchWords,
+			getSearchWords( query ),
 			{
 				per_page: 100,
 			}
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/products/table.js b/plugins/woocommerce/client/admin/client/analytics/report/products/table.js
index 29afa07e530..5368ca66137 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/products/table.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/products/table.js
@@ -321,6 +321,7 @@ class ProductsReportTable extends Component {
 			filters,
 			hideCompare,
 			isRequesting,
+			limitProperties,
 			query,
 		} = this.props;

@@ -350,6 +351,7 @@ class ProductsReportTable extends Component {
 				labels={ labels }
 				query={ query }
 				searchBy="products"
+				limitProperties={ limitProperties }
 				baseSearchQuery={ baseSearchQuery }
 				tableQuery={ {
 					orderby: query.orderby || 'items_sold',
@@ -370,12 +372,9 @@ ProductsReportTable.contextType = CurrencyContext;

 export default compose(
 	withSelect( ( select, props ) => {
-		const { query, isRequesting } = props;
+		const { isRequesting } = props;

-		if (
-			isRequesting ||
-			( query.search && ! ( query.products && query.products.length ) )
-		) {
+		if ( isRequesting ) {
 			return {};
 		}

diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 1741b076035..54a94f5bd80 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -356,6 +356,7 @@ class WC_Install {
 		),
 		'11.2.0-1' => array(
 			'wc_update_11201_migrate_tax_lookup_order_items',
+			'wc_update_11201_invalidate_analytics_reports_cache',
 		),
 	);

diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 0791bf0430f..a77e5d63d48 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -3703,3 +3703,20 @@ function wc_update_1120_migrate_stock_notifications_alpha_constant() {

 	update_option( StockNotifications::ENABLE_OPTION_NAME, 'yes', true );
 }
+
+/**
+ * Invalidate the Analytics report cache.
+ *
+ * Report responses are cached for a week and keyed on the query arguments alone, so a report
+ * run before the update keeps serving its pre-update answer. That hides the corrected result
+ * for category and product filters that have no product in common.
+ *
+ * @since 11.2.0
+ *
+ * @return void
+ */
+function wc_update_11201_invalidate_analytics_reports_cache() {
+	if ( class_exists( \Automattic\WooCommerce\Admin\API\Reports\Cache::class ) ) {
+		\Automattic\WooCommerce\Admin\API\Reports\Cache::invalidate();
+	}
+}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 231f6560797..836df98de44 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -40269,18 +40269,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/Reports/Categories/DataStore.php

-		-
-			message: '#^Parameter \#2 \$id_field of method Automattic\\WooCommerce\\Admin\\API\\Reports\\DataStore\:\:get_ids_table\(\) expects array, string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Admin/API/Reports/Categories/DataStore.php
-
-		-
-			message: '#^Part \$ids_table \(array\) of encapsed string cannot be cast to string\.$#'
-			identifier: encapsedStringPart.nonString
-			count: 1
-			path: src/Admin/API/Reports/Categories/DataStore.php
-
 		-
 			message: '#^Property Automattic\\WooCommerce\\Admin\\API\\Reports\\Categories\\DataStore\:\:\$order is never read, only written\.$#'
 			identifier: property.onlyWritten
@@ -40575,18 +40563,6 @@ parameters:
 			count: 2
 			path: src/Admin/API/Reports/Coupons/DataStore.php

-		-
-			message: '#^Parameter \#2 \$id_field of method Automattic\\WooCommerce\\Admin\\API\\Reports\\DataStore\:\:get_ids_table\(\) expects array, string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Admin/API/Reports/Coupons/DataStore.php
-
-		-
-			message: '#^Part \$ids_table \(array\) of encapsed string cannot be cast to string\.$#'
-			identifier: encapsedStringPart.nonString
-			count: 1
-			path: src/Admin/API/Reports/Coupons/DataStore.php
-
 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:get_data\(\)\.$#'
 			identifier: method.notFound
@@ -41199,12 +41175,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/Reports/DataStore.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\Reports\\DataStore\:\:get_ids_table\(\) should return array but returns string\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Admin/API/Reports/DataStore.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\Reports\\DataStore\:\:get_included_products_array\(\) should return array but returns array\|Automattic\\WooCommerce\\Admin\\API\\Reports\\stdClass\.$#'
 			identifier: return.type
@@ -41409,12 +41379,6 @@ parameters:
 			count: 2
 			path: src/Admin/API/Reports/DataStore.php

-		-
-			message: '#^Part \$id_field \(array\) of encapsed string cannot be cast to string\.$#'
-			identifier: encapsedStringPart.nonString
-			count: 1
-			path: src/Admin/API/Reports/DataStore.php
-
 		-
 			message: '#^Property Automattic\\WooCommerce\\Admin\\API\\Reports\\DataStore\:\:\$subquery \(Automattic\\WooCommerce\\Admin\\API\\Reports\\SqlQuery\) in isset\(\) is not nullable\.$#'
 			identifier: isset.property
@@ -43053,18 +43017,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/Reports/Products/DataStore.php

-		-
-			message: '#^Parameter \#2 \$id_field of method Automattic\\WooCommerce\\Admin\\API\\Reports\\DataStore\:\:get_ids_table\(\) expects array, string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Admin/API/Reports/Products/DataStore.php
-
-		-
-			message: '#^Part \$ids_table \(array\) of encapsed string cannot be cast to string\.$#'
-			identifier: encapsedStringPart.nonString
-			count: 1
-			path: src/Admin/API/Reports/Products/DataStore.php
-
 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:get_data\(\)\.$#'
 			identifier: method.notFound
@@ -44409,18 +44361,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/Reports/Variations/DataStore.php

-		-
-			message: '#^Parameter \#2 \$id_field of method Automattic\\WooCommerce\\Admin\\API\\Reports\\DataStore\:\:get_ids_table\(\) expects array, string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Admin/API/Reports/Variations/DataStore.php
-
-		-
-			message: '#^Part \$ids_table \(array\) of encapsed string cannot be cast to string\.$#'
-			identifier: encapsedStringPart.nonString
-			count: 1
-			path: src/Admin/API/Reports/Variations/DataStore.php
-
 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:get_data\(\)\.$#'
 			identifier: method.notFound
diff --git a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
index 2137191f632..491fb2863d1 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
@@ -1021,10 +1021,10 @@ class DataStore extends SqlQuery implements DataStoreInterface {
 	/**
 	 * Generates a virtual table given a list of IDs.
 	 *
-	 * @param array $ids          Array of IDs.
-	 * @param array $id_field     Name of the ID field.
-	 * @param array $other_values Other values that must be contained in the virtual table.
-	 * @return array
+	 * @param array  $ids          Array of IDs.
+	 * @param string $id_field     Name of the ID field.
+	 * @param array  $other_values Other values that must be contained in the virtual table.
+	 * @return string
 	 */
 	protected function get_ids_table( $ids, $id_field, $other_values = array() ) {
 		global $wpdb;
@@ -1229,6 +1229,12 @@ class DataStore extends SqlQuery implements DataStoreInterface {
 				if ( 'AND' === $operator ) {
 					// AND results in an intersection between products from selected categories and manually included products.
 					$included_products = array_intersect( $included_products, $query_args['product_includes'] );
+
+					// Force an empty set the same way an empty category does. Callers cannot tell an
+					// empty list apart from an absent product filter, and would drop both filters.
+					if ( empty( $included_products ) ) {
+						$included_products = array( '-1' );
+					}
 				} elseif ( 'OR' === $operator ) {
 					// OR results in a union of products from selected categories and manually included products.
 					$included_products = array_merge( $included_products, $query_args['product_includes'] );
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php
index 9f1728bbf59..8d2d6deb84d 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Controller.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface;
 use Automattic\WooCommerce\Admin\API\Reports\GenericController;
 use Automattic\WooCommerce\Admin\API\Reports\GenericQuery;
+use Automattic\WooCommerce\Internal\Admin\Reports\ProductSearchQuery;
 use WP_REST_Request;
 use WP_REST_Response;

@@ -271,6 +272,7 @@ class Controller extends GenericController implements ExportableInterface {
 			),

 		);
+		$params['search']        = ProductSearchQuery::get_collection_param();
 		$params['extended_info'] = array(
 			'description'       => __( 'Add additional piece of info about each product to the report.', 'woocommerce' ),
 			'type'              => 'boolean',
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php
index 6cac9773371..711eaa29f8e 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Products/DataStore.php
@@ -9,6 +9,7 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
 use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
+use Automattic\WooCommerce\Internal\Admin\Reports\ProductSearchQuery;
 use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
 use Automattic\WooCommerce\Admin\API\Reports\SqlQuery;
 use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -93,6 +94,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 */
 	protected $context = 'products';

+	/**
+	 * Whether the query currently being served carries a `search` argument.
+	 *
+	 * @var bool
+	 */
+	private $is_search = false;
+
+	/**
+	 * Last search statement built, with the arguments it was built from.
+	 *
+	 * @var array|null
+	 */
+	private $search_subquery = null;
+
 	/**
 	 * Assign report columns once full table name has been assigned.
 	 *
@@ -198,10 +213,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$this->get_limit_sql_params( $query_args );
 		$this->add_order_by_sql_params( $query_args );

-		$included_products = $this->get_included_products( $query_args );
-		if ( $included_products ) {
+		$included_products = $this->get_included_products_array( $query_args );
+		$product_id_filter = ProductSearchQuery::get_id_condition(
+			"{$order_product_lookup_table}.product_id",
+			$this->get_search_subquery( $query_args, $included_products ),
+			$included_products
+		);
+		if ( $product_id_filter ) {
 			$this->add_from_sql_params( $query_args, 'outer', 'default_results.product_id' );
-			$this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" );
+			$this->subquery->add_sql_clause( 'where', "AND {$product_id_filter}" );
 		} else {
 			$this->add_from_sql_params( $query_args, 'inner', "{$order_product_lookup_table}.product_id" );
 		}
@@ -218,6 +238,73 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		}
 	}

+	/**
+	 * Returns the statement the query's `search` argument resolves to.
+	 *
+	 * Serving one report needs it twice, once for the restriction and once for the row count, and
+	 * building it runs a WP_Query, so the last one is kept for as long as the arguments match.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param array $query_args        Query parameters.
+	 * @param array $included_products Product IDs the `categories` and `products` filters resolve to.
+	 * @return string SQL statement, or an empty string when the query carries no search.
+	 */
+	private function get_search_subquery( $query_args, array $included_products ): string {
+		$terms = $query_args['search'] ?? array();
+
+		if ( null === $this->search_subquery
+			|| $this->search_subquery['terms'] !== $terms
+			|| $this->search_subquery['included_products'] !== $included_products
+		) {
+			$this->search_subquery = array(
+				'terms'             => $terms,
+				'included_products' => $included_products,
+				'subquery'          => ProductSearchQuery::get_ids_subquery( $terms, $included_products ),
+			);
+		}
+
+		return $this->search_subquery['subquery'];
+	}
+
+	/**
+	 * Returns the cache key for a query, and records whether it carries a search.
+	 *
+	 * `should_use_cache()` decides whether the response is cached but only receives the key, so the
+	 * search has to be noted here, where the query arguments are still around.
+	 *
+	 * @override ReportsDataStore::get_cache_key()
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param array $params Query parameters.
+	 * @return string Cache key.
+	 */
+	protected function get_cache_key( $params ) {
+		$this->is_search = ! empty( $params['search'] );
+
+		return parent::get_cache_key( $params );
+	}
+
+	/**
+	 * Whether the report should be read from and written to the report cache.
+	 *
+	 * @override ReportsDataStore::should_use_cache()
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return bool
+	 */
+	protected function should_use_cache() {
+		// Run the parent first, since it applies the filter plugins opt out of the cache through.
+		$use_cache = parent::should_use_cache();
+
+		// A search is resolved against product titles and SKUs while the report runs, and nothing
+		// invalidates the report cache when one is renamed, so a cached response would keep
+		// answering with the old matches for up to a week.
+		return $this->is_search ? false : $use_cache;
+	}
+
 	/**
 	 * Maps ordering specified by the user to columns in the database/fields in the data.
 	 *
@@ -340,6 +427,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$defaults                      = parent::get_default_query_vars();
 		$defaults['category_includes'] = array();
 		$defaults['product_includes']  = array();
+		$defaults['search']            = array();
 		$defaults['extended_info']     = false;

 		return $defaults;
@@ -371,13 +459,23 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {

 		$selections        = $this->selected_columns( $query_args );
 		$included_products = $this->get_included_products_array( $query_args );
+		$search_subquery   = $this->get_search_subquery( $query_args, $included_products );
 		$params            = $this->get_limit_params( $query_args );
 		$this->add_sql_query_params( $query_args );

-		if ( count( $included_products ) > 0 ) {
-			$filtered_products = array_diff( $included_products, array( '-1' ) );
-			$total_results     = count( $filtered_products );
-			$total_pages       = (int) ceil( $total_results / $params['per_page'] );
+		if ( $search_subquery || count( $included_products ) > 0 ) {
+			if ( $search_subquery ) {
+				// The set of matching products is only known to the database, so count it there too.
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $search_subquery is built from prepared fragments.
+				$total_results = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( {$search_subquery} ) AS search_results" );
+				$ids_table     = $search_subquery;
+			} else {
+				$filtered_products = array_diff( $included_products, array( '-1' ) );
+				$total_results     = count( $filtered_products );
+				$ids_table         = $this->get_ids_table( $included_products, 'product_id' );
+			}
+
+			$total_pages = (int) ceil( $total_results / $params['per_page'] );

 			if ( 'date' === $query_args['orderby'] ) {
 				$selections .= ", {$table_name}.date_created";
@@ -385,7 +483,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {

 			$fields          = $this->get_fields( $query_args );
 			$join_selections = $this->format_join_selections( $fields, array( 'product_id' ) );
-			$ids_table       = $this->get_ids_table( $included_products, 'product_id' );

 			$this->subquery->clear_sql_clause( 'select' );
 			$this->subquery->add_sql_clause( 'select', $selections );
@@ -400,6 +497,14 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			);
 			$this->add_sql_clause( 'where', 'AND default_results.product_id != -1' );

+			// The database is free to resolve a tie differently for each page, so a product comes
+			// back on two of them while another is never reached. A product without sales ties on
+			// every column the report can order by, and a filtered report is mostly those.
+			// `get_ids_table()` types its column as text, so cast it or 100 would sort before 99.
+			$order_by = $this->get_sql_clause( 'order_by' );
+			$this->clear_sql_clause( 'order_by' );
+			$this->add_sql_clause( 'order_by', "{$order_by}, CAST( default_results.product_id AS SIGNED )" );
+
 			$products_query = $this->get_query_statement();
 		} else {
 			$count_query      = "SELECT COUNT(*) FROM (
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php
index 2cccdbf046e..4979c3c3fc3 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/Controller.php
@@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Admin\API\Reports\GenericQuery;
 use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController;
+use Automattic\WooCommerce\Internal\Admin\Reports\ProductSearchQuery;
 use WP_REST_Request;
 use WP_REST_Response;

@@ -237,6 +238,7 @@ class Controller extends GenericStatsController {
 				'type' => 'integer',
 			),
 		);
+		$params['search']          = ProductSearchQuery::get_collection_param();
 		$params['segmentby']       = array(
 			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
 			'type'              => 'string',
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php
index efb32aad752..93b27b8300b 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore;
 use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface;
 use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
 use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait;
+use Automattic\WooCommerce\Internal\Admin\Reports\ProductSearchQuery;

 /**
  * API\Reports\Products\Stats\DataStore.
@@ -83,9 +84,14 @@ class DataStore extends ProductsDataStore implements DataStoreInterface {
 		$products_from_clause       = '';
 		$order_product_lookup_table = self::get_db_table_name();

-		$included_products = $this->get_included_products( $query_args );
-		if ( $included_products ) {
-			$products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";
+		$included_products = $this->get_included_products_array( $query_args );
+		$product_id_filter = ProductSearchQuery::get_id_condition(
+			"{$order_product_lookup_table}.product_id",
+			ProductSearchQuery::get_ids_subquery( $query_args['search'] ?? array(), $included_products ),
+			$included_products
+		);
+		if ( $product_id_filter ) {
+			$products_where_clause .= " AND {$product_id_filter}";
 		}

 		$included_variations = $this->get_included_variations( $query_args );
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Segmenter.php b/plugins/woocommerce/src/Admin/API/Reports/Segmenter.php
index 778a2e076d3..c8315abe119 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Segmenter.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Segmenter.php
@@ -10,6 +10,7 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
 use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as TaxesStatsDataStore;
 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Admin\Reports\ProductSearchQuery;

 /**
  * Date & time interval and numeric range handling class for Reporting API.
@@ -354,6 +355,22 @@ class Segmenter {
 				}
 			}

+			// A search restricts the report, so it has to restrict this list too. Otherwise every
+			// product the term does not match comes back as a segment zeroed across every interval.
+			$search_ids = ProductSearchQuery::get_ids( $this->query_args['search'] ?? array(), $args['include'] ?? array() );
+			if ( null !== $search_ids ) {
+				if ( ! empty( $args['category'] ) ) {
+					// Narrow the term's matches by the category here rather than sending them all
+					// to the segment query, where a broad term would become a very long ID list.
+					$category_ids = (array) wc_get_products( array_merge( $args, array( 'return' => 'ids' ) ) );
+					$search_ids   = array_intersect( $search_ids, $category_ids );
+					unset( $args['category'] );
+				}
+
+				// An empty `include` reads as no restriction, so name an ID no product can have.
+				$args['include'] = empty( $search_ids ) ? array( 0 ) : array_values( $search_ids );
+			}
+
 			$segment_objects = wc_get_products( $args );
 			foreach ( $segment_objects as $segment ) {
 				$id                    = $segment->get_id();
diff --git a/plugins/woocommerce/src/Internal/Admin/Reports/ProductSearchQuery.php b/plugins/woocommerce/src/Internal/Admin/Reports/ProductSearchQuery.php
new file mode 100644
index 00000000000..821c672347b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/Reports/ProductSearchQuery.php
@@ -0,0 +1,286 @@
+<?php
+/**
+ * ProductSearchQuery class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Admin\Reports;
+
+use WP_Query;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Builds the SQL that resolves a free-text product search to product IDs, and the condition
+ * restricting a products report to the products its filters resolve to.
+ *
+ * @internal
+ *
+ * @since 11.2.0
+ */
+class ProductSearchQuery {
+
+	/**
+	 * Query variable carrying the search terms into the WP_Query filters below.
+	 *
+	 * @var string
+	 */
+	private const TERMS_QUERY_VAR = 'wc_analytics_product_search';
+
+	/**
+	 * Alias of the product meta lookup table joined for the SKU comparison.
+	 *
+	 * @var string
+	 */
+	private const LOOKUP_ALIAS = 'wc_analytics_product_search_lookup';
+
+	/**
+	 * Normalizes the `search` REST argument into a list of terms.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string|string[] $value Raw argument value, a list of terms or a comma separated string.
+	 * @return string[] Search terms.
+	 */
+	public static function parse_terms( $value ) {
+		// Not `wp_parse_list()`, which also splits on whitespace and would break multi-word terms.
+		$terms = is_array( $value ) ? $value : explode( ',', (string) $value );
+
+		return array_values(
+			array_filter(
+				array_map( 'sanitize_text_field', $terms ),
+				function ( $term ) {
+					return '' !== $term;
+				}
+			)
+		);
+	}
+
+	/**
+	 * Returns the REST collection parameter definition for the product search.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return array Parameter definition.
+	 */
+	public static function get_collection_param() {
+		return array(
+			'description'       => __( 'Limit result to products whose name or SKU matches any of the given terms.', 'woocommerce' ),
+			'type'              => 'array',
+			'sanitize_callback' => array( self::class, 'parse_terms' ),
+			'validate_callback' => 'rest_validate_request_arg',
+			'items'             => array(
+				'type' => 'string',
+			),
+		);
+	}
+
+	/**
+	 * Returns a SELECT statement resolving the given search terms to product IDs.
+	 *
+	 * The statement yields a single `product_id` column, for use as a derived table or in an `IN (...)` clause.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string|string[] $terms           Search terms. A product matches if it matches any term.
+	 * @param int[]           $restrict_to_ids Optional. Product IDs to intersect the results with. An ID
+	 *                                         that cannot belong to a product matches nothing.
+	 * @return string SQL statement, or an empty string when there is nothing to search for.
+	 */
+	public static function get_ids_subquery( $terms, $restrict_to_ids = array() ) {
+		$terms = self::parse_terms( $terms );
+
+		if ( empty( $terms ) ) {
+			return '';
+		}
+
+		$args = array(
+			'post_type'           => 'product',
+			// Matches the search box, and a drafted product can still have sales to report.
+			'post_status'         => 'any',
+			'posts_per_page'      => -1,
+			'fields'              => 'ids',
+			// The report orders and pages the result itself, so the subquery does not have to.
+			'orderby'             => 'none',
+			'no_found_rows'       => true,
+			// The query is never run, so its empty result is not worth caching.
+			'cache_results'       => false,
+			self::TERMS_QUERY_VAR => $terms,
+		);
+
+		$restrict_to_ids = (array) $restrict_to_ids;
+		if ( ! empty( $restrict_to_ids ) ) {
+			// WP_Query runs `post__in` through `absint()`, which would read the `-1` the report
+			// filters use for an empty set as product ID 1. Clamp to 0, which matches nothing.
+			$args['post__in'] = array_map( static fn( $id ) => max( 0, (int) $id ), $restrict_to_ids );
+		}
+
+		$statement = self::build_statement( $args );
+
+		// A plugin filtering `posts_fields` can add columns, so name the one this returns.
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP_Query prepares the statement it builds.
+		return "SELECT DISTINCT ID AS product_id FROM ( {$statement} ) AS wc_analytics_product_search_results";
+	}
+
+	/**
+	 * Returns the condition restricting a report to a set of products.
+	 *
+	 * A search resolves to a subquery, the `categories` and `products` filters to an ID list. The
+	 * subquery already covers those filters, since it is built restricted to the same IDs.
+	 *
+	 * Not a data store method: both products data stores need it and one extends the other, so it
+	 * would become part of what extensions inherit.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $column            Product ID column to compare, qualified with its table name.
+	 * @param string $search_subquery   Statement the `search` argument resolves to, from
+	 *                                  `get_ids_subquery()`. Empty when the report carries no search.
+	 * @param array  $included_products Product IDs the `categories` and `products` filters resolve to.
+	 * @return string SQL condition, or an empty string when the report is not restricted.
+	 */
+	public static function get_id_condition( string $column, string $search_subquery, array $included_products ): string {
+		if ( '' !== $search_subquery ) {
+			return "{$column} IN ( {$search_subquery} )";
+		}
+
+		$id_list = implode( ',', $included_products );
+
+		return $id_list ? "{$column} IN ( {$id_list} )" : '';
+	}
+
+	/**
+	 * Returns the product IDs the given search terms resolve to.
+	 *
+	 * For callers that need the matches themselves rather than a statement to compose with.
+	 * An empty list means the terms matched nothing, which is not the same as `null`, meaning
+	 * there was nothing to search for.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string|string[] $terms           Search terms. A product matches if it matches any term.
+	 * @param int[]           $restrict_to_ids Optional. Product IDs to intersect the results with.
+	 * @return int[]|null Matching product IDs, or null when there is nothing to search for.
+	 */
+	public static function get_ids( $terms, $restrict_to_ids = array() ) {
+		global $wpdb;
+
+		$subquery = self::get_ids_subquery( $terms, $restrict_to_ids );
+		if ( '' === $subquery ) {
+			return null;
+		}
+
+		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $subquery is built from prepared fragments; the containing report is what caches.
+		return array_map( 'intval', $wpdb->get_col( $subquery ) );
+	}
+
+	/**
+	 * Adds the SKU lookup table to a product search query.
+	 *
+	 * @internal Hooked on `posts_join` for the duration of the search query.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string   $join     Join clause.
+	 * @param WP_Query $wp_query Query being built.
+	 * @return string Join clause.
+	 */
+	public static function add_wp_query_join( $join, $wp_query ) {
+		global $wpdb;
+
+		if ( ! $wp_query->get( self::TERMS_QUERY_VAR ) || ! wc_product_sku_enabled() ) {
+			return $join;
+		}
+
+		$alias = self::LOOKUP_ALIAS;
+
+		return $join . " LEFT JOIN {$wpdb->wc_product_meta_lookup} AS {$alias} ON {$wpdb->posts}.ID = {$alias}.product_id ";
+	}
+
+	/**
+	 * Restricts a product search query to the products matching any of its terms.
+	 *
+	 * @internal Hooked on `posts_where` for the duration of the search query.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string   $where    Where clause.
+	 * @param WP_Query $wp_query Query being built.
+	 * @return string Where clause.
+	 */
+	public static function add_wp_query_filter( $where, $wp_query ) {
+		global $wpdb;
+
+		$terms = $wp_query->get( self::TERMS_QUERY_VAR );
+		if ( ! $terms ) {
+			return $where;
+		}
+
+		$sku_enabled  = wc_product_sku_enabled();
+		$alias        = self::LOOKUP_ALIAS;
+		$term_clauses = array();
+
+		foreach ( (array) $terms as $term ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts is a table name.
+			$clause = $wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s", '%' . $wpdb->esc_like( $term ) . '%' );
+			if ( $sku_enabled ) {
+				// Matches Admin\API\Products, which leaves the term unescaped, so a LIKE wildcard stays
+				// one. Escaping it would make the report disagree with the search box on what matches.
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $alias is a class constant.
+				$clause .= $wpdb->prepare( " OR {$alias}.sku LIKE %s", $term );
+			}
+
+			$term_clauses[] = "( {$clause} )";
+		}
+
+		return $where . ' AND ( ' . implode( ' OR ', $term_clauses ) . ' )';
+	}
+
+	/**
+	 * Skips running a product search query, which is built for its statement rather than its rows.
+	 *
+	 * Only the search query itself is skipped. A query another plugin runs from one of the filters
+	 * above, to work out how to filter this one, still has to return its own results.
+	 *
+	 * @internal Hooked on `posts_pre_query` for the duration of the search query.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param array|null $posts    Posts to return instead of running the query, or null to run it.
+	 * @param WP_Query   $wp_query Query being built.
+	 * @return array|null Posts to return instead of running the query, or null to run it.
+	 */
+	public static function skip_wp_query_results( $posts, $wp_query ) {
+		return $wp_query->get( self::TERMS_QUERY_VAR ) ? array() : $posts;
+	}
+
+	/**
+	 * Returns the statement WP_Query builds for the given arguments, without running it.
+	 *
+	 * The search composes into the report query, so the statement is what is needed rather than the
+	 * rows. Going through WP_Query keeps the query filters in play, which is how multilingual plugins
+	 * restrict products to the active language and how the search box itself resolves a term.
+	 *
+	 * @param array $args Query arguments.
+	 * @return string SQL statement.
+	 */
+	private static function build_statement( array $args ): string {
+		add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 );
+		add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 );
+		add_filter( 'posts_pre_query', array( __CLASS__, 'skip_wp_query_results' ), 10, 2 );
+
+		try {
+			$query = new WP_Query();
+			$query->query( $args );
+
+			return $query->request;
+		} finally {
+			// A filter left behind would follow every later query in the request, so drop them
+			// even when the query above threw.
+			remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 );
+			remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 );
+			remove_filter( 'posts_pre_query', array( __CLASS__, 'skip_wp_query_results' ), 10 );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products-stats.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products-stats.php
index 92a41ce69a9..910f3fc95fa 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products-stats.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products-stats.php
@@ -121,6 +121,279 @@ class WC_Admin_Tests_API_Reports_Products_Stats extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( $expected_reports, $reports );
 	}

+	/**
+	 * @testdox Should narrow the totals to the products matching the `search` param.
+	 */
+	public function test_get_reports_search_param() {
+		WC_Helper_Reports::reset_stats_dbs();
+		wp_set_current_user( $this->user );
+
+		$time = time();
+
+		foreach ( array( 'Kingston Widget', 'Unrelated Thing' ) as $name ) {
+			$product = new WC_Product_Simple();
+			$product->set_name( $name );
+			$product->set_regular_price( 25 );
+			$product->save();
+
+			$order = WC_Helper_Order::create_order( 1, $product );
+			$order->set_status( OrderStatus::COMPLETED );
+			// $25 x 4.
+			$order->set_total( 100 );
+			$order->save();
+		}
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'before'   => gmdate( 'Y-m-d 23:59:59', $time ),
+				'after'    => gmdate( 'Y-m-d 00:00:00', $time ),
+				'interval' => 'day',
+				'search'   => 'Kingston',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals(
+			array(
+				'items_sold'       => 4,
+				'net_revenue'      => 100.0,
+				'orders_count'     => 1,
+				'products_count'   => 1,
+				'variations_count' => 1,
+				'segments'         => array(),
+			),
+			$reports['totals'],
+			'Only the products matching the search should be aggregated'
+		);
+	}
+
+	/**
+	 * @testdox Should report no totals when the `search` param is combined with a filter no product satisfies.
+	 */
+	public function test_get_reports_search_param_with_a_filter_no_product_satisfies() {
+		WC_Helper_Reports::reset_stats_dbs();
+		wp_set_current_user( $this->user );
+
+		$time = time();
+
+		$match = $this->create_product_with_id_1( 'Kingston Widget' );
+
+		$order = WC_Helper_Order::create_order( 1, $match );
+		$order->set_status( OrderStatus::COMPLETED );
+		// $25 x 4.
+		$order->set_total( 100 );
+		$order->save();
+
+		$empty_category = wp_insert_term( 'Empty Category', 'product_cat' );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'before'     => gmdate( 'Y-m-d 23:59:59', $time ),
+				'after'      => gmdate( 'Y-m-d 00:00:00', $time ),
+				'interval'   => 'day',
+				'search'     => 'Kingston',
+				'categories' => (string) $empty_category['term_id'],
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals(
+			array(
+				'items_sold'       => 0,
+				'net_revenue'      => 0.0,
+				'orders_count'     => 0,
+				'products_count'   => 0,
+				'variations_count' => 0,
+				'segments'         => array(),
+			),
+			$reports['totals'],
+			'A category holding no product leaves the search nothing to match'
+		);
+	}
+
+	/**
+	 * @testdox Should segment by the products matching the `search` param, and no others.
+	 *
+	 * The segment list is filled in with a zeroed entry per product it covers, so leaving the
+	 * search out of it puts every product in the store in the response.
+	 */
+	public function test_get_reports_search_param_narrows_the_product_segments() {
+		WC_Helper_Reports::reset_stats_dbs();
+		wp_set_current_user( $this->user );
+
+		$time     = time();
+		$products = array();
+
+		foreach ( array( 'Kingston Widget', 'Kingston Gadget', 'Unrelated Thing' ) as $name ) {
+			$product = new WC_Product_Simple();
+			$product->set_name( $name );
+			$product->set_regular_price( 25 );
+			$product->save();
+
+			$products[ $name ] = $product->get_id();
+		}
+
+		$order = WC_Helper_Order::create_order( 1, wc_get_product( $products['Kingston Widget'] ) );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 100 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'before'    => gmdate( 'Y-m-d 23:59:59', $time ),
+				'after'     => gmdate( 'Y-m-d 00:00:00', $time ),
+				'interval'  => 'day',
+				'search'    => 'Kingston',
+				'segmentby' => 'product',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		$segment_ids = wp_list_pluck( $reports['totals']['segments'], 'segment_id' );
+		sort( $segment_ids );
+
+		$expected = array( $products['Kingston Gadget'], $products['Kingston Widget'] );
+		sort( $expected );
+
+		$this->assertEquals(
+			$expected,
+			$segment_ids,
+			'A product the search does not match should not come back as a segment'
+		);
+	}
+
+	/**
+	 * @testdox Should segment by the products the `search` param matches inside the category.
+	 *
+	 * This is the Categories report's single category view, which segments by product. The term and
+	 * the category narrow each other, so a product only one of them covers is not a segment.
+	 */
+	public function test_get_reports_search_param_narrows_the_product_segments_within_a_category() {
+		WC_Helper_Reports::reset_stats_dbs();
+		wp_set_current_user( $this->user );
+
+		$time     = time();
+		$category = wp_insert_term( 'Widgets', 'product_cat' );
+		$products = array();
+
+		foreach ( array( 'Kingston Widget', 'Kingston Gadget', 'Unrelated Widget' ) as $name ) {
+			$product = new WC_Product_Simple();
+			$product->set_name( $name );
+			$product->set_regular_price( 25 );
+			$product->save();
+
+			$products[ $name ] = $product->get_id();
+		}
+
+		// Everything but the gadget is in the category, so only the widget satisfies both.
+		wp_set_object_terms( $products['Kingston Widget'], array( $category['term_id'] ), 'product_cat' );
+		wp_set_object_terms( $products['Unrelated Widget'], array( $category['term_id'] ), 'product_cat' );
+
+		$order = WC_Helper_Order::create_order( 1, wc_get_product( $products['Kingston Widget'] ) );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 100 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'before'     => gmdate( 'Y-m-d 23:59:59', $time ),
+				'after'      => gmdate( 'Y-m-d 00:00:00', $time ),
+				'interval'   => 'day',
+				'search'     => 'Kingston',
+				'categories' => (string) $category['term_id'],
+				'segmentby'  => 'product',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals(
+			array( $products['Kingston Widget'] ),
+			wp_list_pluck( $reports['totals']['segments'], 'segment_id' ),
+			'Only a product both the search and the category cover should come back as a segment'
+		);
+	}
+
+	/**
+	 * @testdox Should report no product segments when the `search` param matches nothing.
+	 */
+	public function test_get_reports_search_param_with_no_match_has_no_product_segments() {
+		WC_Helper_Reports::reset_stats_dbs();
+		wp_set_current_user( $this->user );
+
+		$time = time();
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Kingston Widget' );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 100 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params(
+			array(
+				'before'    => gmdate( 'Y-m-d 23:59:59', $time ),
+				'after'     => gmdate( 'Y-m-d 00:00:00', $time ),
+				'interval'  => 'day',
+				'search'    => 'nothing matches this',
+				'segmentby' => 'product',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame(
+			array(),
+			$reports['totals']['segments'],
+			'An empty set of matches should not read as no restriction at all'
+		);
+	}
+
+	/**
+	 * @testdox Should register the `search` collection param.
+	 */
+	public function test_search_collection_param_is_registered() {
+		wp_set_current_user( $this->user );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'OPTIONS', $this->endpoint ) );
+		$args     = $response->get_data()['endpoints'][0]['args'];
+
+		$this->assertArrayHasKey( 'search', $args );
+		$this->assertEquals( 'array', $args['search']['type'] );
+	}
+
 	/**
 	 * Test getting reports without valid permissions.
 	 *
@@ -172,4 +445,35 @@ class WC_Admin_Tests_API_Reports_Products_Stats extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'orders_count', $subtotals );
 		$this->assertArrayHasKey( 'segments', $subtotals );
 	}
+
+	/**
+	 * Creates a simple product with product ID 1.
+	 *
+	 * The filters resolve to `-1` when no product satisfies them and `absint()` reads that as
+	 * product ID 1, so the wrong product is only aggregated when one has that ID.
+	 *
+	 * @param string $name Product name.
+	 * @return WC_Product_Simple
+	 */
+	private function create_product_with_id_1( $name ) {
+		wp_delete_post( 1, true );
+
+		$this->assertSame(
+			1,
+			wp_insert_post(
+				array(
+					'import_id'   => 1,
+					'post_title'  => $name,
+					'post_type'   => 'product',
+					'post_status' => 'publish',
+				)
+			)
+		);
+
+		$product = wc_get_product( 1 );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		return $product;
+	}
 }
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products.php
index 65a1096dc26..074a9218104 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-products.php
@@ -144,6 +144,388 @@ class WC_Admin_Tests_API_Reports_Products extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'product', $product_report['_links'] );
 	}

+	/**
+	 * @testdox Should only report the products matching the `search` param.
+	 */
+	public function test_get_reports_search_param() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$sold_match   = $this->create_product( 'Kingston Widget' );
+		$unsold_match = $this->create_product( 'Kingston Gadget' );
+		$sold_other   = $this->create_product( 'Unrelated Thing' );
+
+		$this->create_completed_order( $sold_match );
+		$this->create_completed_order( $sold_other );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$response = $this->dispatch_report( array( 'search' => 'Kingston' ) );
+		$reports  = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 2, count( $reports ) );
+
+		$reports_by_id = array_column( $reports, null, 'product_id' );
+
+		$this->assertArrayHasKey( $sold_match->get_id(), $reports_by_id, 'A matching product with sales should be reported' );
+		$this->assertArrayHasKey( $unsold_match->get_id(), $reports_by_id, 'A matching product without sales should be reported' );
+		$this->assertArrayNotHasKey( $sold_other->get_id(), $reports_by_id, 'A product that does not match the search should be left out' );
+
+		$this->assertEquals( 4, $reports_by_id[ $sold_match->get_id() ]['items_sold'] );
+		$this->assertEquals( 1, $reports_by_id[ $sold_match->get_id() ]['orders_count'] );
+		$this->assertSame( 0, $reports_by_id[ $unsold_match->get_id() ]['items_sold'] );
+	}
+
+	/**
+	 * @testdox Should not cap the `search` param at the first 100 matching products.
+	 *
+	 * The client used to resolve the search itself and pass back at most 100 product IDs, so any
+	 * match past that was missing from the report.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/50786
+	 */
+	public function test_get_reports_search_param_is_not_capped_at_100_products() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		// These only need to match the search, so the full product CRUD would be wasted work.
+		for ( $i = 0; $i < 104; $i++ ) {
+			wp_insert_post(
+				array(
+					'post_title'  => sprintf( 'Kingston Widget %03d', $i ),
+					'post_type'   => 'product',
+					'post_status' => 'publish',
+				)
+			);
+		}
+
+		$sold = $this->create_product( 'Kingston Widget 999' );
+		$this->create_completed_order( $sold );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$response = $this->dispatch_report(
+			array(
+				'search'   => 'Kingston',
+				'per_page' => 100,
+				'orderby'  => 'items_sold',
+				'order'    => 'desc',
+			)
+		);
+		$reports  = $response->get_data();
+		$headers  = $response->get_headers();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 105, $headers['X-WP-Total'], 'Every matching product should be counted' );
+		$this->assertEquals( 2, $headers['X-WP-TotalPages'] );
+		$this->assertCount( 100, $reports );
+
+		$top_seller = reset( $reports );
+		$this->assertEquals( $sold->get_id(), $top_seller['product_id'], 'The only product with sales should sort first' );
+		$this->assertEquals( 4, $top_seller['items_sold'] );
+
+		$second_page = $this->dispatch_report(
+			array(
+				'search'   => 'Kingston',
+				'per_page' => 100,
+				'page'     => 2,
+			)
+		);
+
+		$this->assertEquals( 200, $second_page->get_status() );
+		$this->assertCount( 5, $second_page->get_data() );
+	}
+
+	/**
+	 * @testdox Should order products tied on the sorting column by ID, so paging stays stable.
+	 *
+	 * A product without sales ties with every other one on every column the report can be ordered
+	 * by, and the database is free to resolve a tie differently for each page, so a product comes
+	 * back on two pages while another is never reached.
+	 */
+	public function test_get_reports_orders_products_tied_on_the_sorting_column_by_id() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		// These only need to match the search, so the full product CRUD would be wasted work.
+		$without_sales = array();
+		for ( $i = 0; $i < 12; $i++ ) {
+			$without_sales[] = wp_insert_post(
+				array(
+					'post_title'  => sprintf( 'Kingston Widget %03d', $i ),
+					'post_type'   => 'product',
+					'post_status' => 'publish',
+				)
+			);
+		}
+
+		$with_sales = $this->create_product( 'Kingston Widget 999' );
+		$this->create_completed_order( $with_sales );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		sort( $without_sales );
+
+		// The only product with sales leads, and the rest tie on every column being tested.
+		$expected = array_merge( array( $with_sales->get_id() ), $without_sales );
+
+		// Both filters resolve to the same set of products through the same code path.
+		$filters = array(
+			'search'   => array( 'search' => 'Kingston' ),
+			'products' => array( 'products' => implode( ',', $expected ) ),
+		);
+
+		foreach ( $filters as $filter_name => $filter ) {
+			foreach ( array( 'items_sold', 'net_revenue', 'date' ) as $orderby ) {
+				$paged_through = array();
+
+				for ( $page = 1; $page <= 3; $page++ ) {
+					$response = $this->dispatch_report(
+						array_merge(
+							$filter,
+							array(
+								'per_page' => 5,
+								'page'     => $page,
+								'orderby'  => $orderby,
+								'order'    => 'desc',
+							)
+						)
+					);
+
+					$this->assertEquals( 200, $response->get_status() );
+
+					$paged_through = array_merge( $paged_through, array_column( $response->get_data(), 'product_id' ) );
+				}
+
+				$this->assertEquals(
+					$expected,
+					$paged_through,
+					"Filtering by {$filter_name} and ordering by {$orderby} should page through every product once, ties in ID order"
+				);
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should break a tie on the sorting column by numeric ID, not by ID as text.
+	 *
+	 * The virtual table the report joins its filtered IDs through types that column as text, so
+	 * without a cast product 100 sorts before product 99 and a page boundary lands mid-run.
+	 */
+	public function test_get_reports_breaks_ties_by_numeric_product_id() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		// Rows come from the filtered ID list, so these do not have to be products that exist. They
+		// straddle a digit boundary, which is where a text sort and a numeric one disagree.
+		$response = $this->dispatch_report(
+			array(
+				'products' => '1000001,999998,1000000,999999,999997',
+				'per_page' => 10,
+				'orderby'  => 'items_sold',
+				'order'    => 'desc',
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals(
+			array( 999997, 999998, 999999, 1000000, 1000001 ),
+			array_column( $response->get_data(), 'product_id' )
+		);
+	}
+
+	/**
+	 * @testdox Should match products by SKU as well as by title.
+	 */
+	public function test_get_reports_search_param_matches_sku() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$match    = $this->create_product( 'Unrelated Thing', 'KINGSTON-1' );
+		$no_match = $this->create_product( 'Another Thing', 'OTHER-1' );
+
+		$this->create_completed_order( $match );
+		$this->create_completed_order( $no_match );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$reports = $this->dispatch_report( array( 'search' => 'KINGSTON-1' ) )->get_data();
+
+		$this->assertEquals( 1, count( $reports ) );
+		$this->assertEquals( $match->get_id(), $reports[0]['product_id'] );
+	}
+
+	/**
+	 * @testdox Should treat a multi word `search` term as a single term.
+	 *
+	 * WordPress splits a string argument on whitespace as well as commas, which would turn one
+	 * multi word search into several single word ones.
+	 */
+	public function test_get_reports_search_param_keeps_multi_word_terms_intact() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$match    = $this->create_product( 'Blue Widget' );
+		$no_match = $this->create_product( 'Blue Gadget' );
+
+		$this->create_completed_order( $match );
+		$this->create_completed_order( $no_match );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$reports = $this->dispatch_report( array( 'search' => 'Blue Widget' ) )->get_data();
+
+		$this->assertEquals( 1, count( $reports ) );
+		$this->assertEquals( $match->get_id(), $reports[0]['product_id'] );
+	}
+
+	/**
+	 * @testdox Should narrow the `products` param with the `search` param rather than replace it.
+	 */
+	public function test_get_reports_search_param_intersects_with_products_param() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$in_both       = $this->create_product( 'Kingston Widget' );
+		$search_only   = $this->create_product( 'Kingston Gadget' );
+		$products_only = $this->create_product( 'Unrelated Thing' );
+
+		$this->create_completed_order( $in_both );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$reports = $this->dispatch_report(
+			array(
+				'search'   => 'Kingston',
+				'products' => $in_both->get_id() . ',' . $products_only->get_id(),
+			)
+		)->get_data();
+
+		$reported_ids = array_column( $reports, 'product_id' );
+
+		$this->assertEquals( array( $in_both->get_id() ), $reported_ids );
+		$this->assertNotContains( $search_only->get_id(), $reported_ids );
+		$this->assertNotContains( $products_only->get_id(), $reported_ids );
+	}
+
+	/**
+	 * @testdox Should return an empty report when the `search` param matches nothing.
+	 */
+	public function test_get_reports_search_param_without_matches() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = $this->create_product( 'Kingston Widget' );
+		$this->create_completed_order( $product );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$response = $this->dispatch_report( array( 'search' => 'nothing matches this' ) );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( array(), $response->get_data() );
+		$this->assertEquals( 0, $response->get_headers()['X-WP-Total'] );
+	}
+
+	/**
+	 * @testdox Should report nothing when the `search` param is combined with a filter no product satisfies.
+	 */
+	public function test_get_reports_search_param_with_a_filter_no_product_satisfies() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$match = $this->create_product_with_id_1( 'Kingston Widget' );
+		$this->create_completed_order( $match );
+
+		$empty_category = wp_insert_term( 'Empty Category', 'product_cat' );
+		$other_category = wp_insert_term( 'Other Category', 'product_cat' );
+
+		$other_product = $this->create_product( 'Unrelated Thing' );
+		wp_set_object_terms( $other_product->get_id(), array( $other_category['term_id'] ), 'product_cat' );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$empty_category_report = $this->dispatch_report(
+			array(
+				'search'     => 'Kingston',
+				'categories' => (string) $empty_category['term_id'],
+			)
+		);
+
+		$this->assertEquals( 200, $empty_category_report->get_status() );
+		$this->assertSame( array(), $empty_category_report->get_data(), 'A category holding no product leaves the search nothing to match' );
+
+		$disjoint_filters_report = $this->dispatch_report(
+			array(
+				'search'     => 'Kingston',
+				'categories' => (string) $other_category['term_id'],
+				'products'   => (string) $match->get_id(),
+				'match'      => 'all',
+			)
+		);
+
+		$this->assertEquals( 200, $disjoint_filters_report->get_status() );
+		$this->assertSame( array(), $disjoint_filters_report->get_data(), 'A category and a product filter with no product in common leave the search nothing to match' );
+	}
+
+	/**
+	 * @testdox Should leave out a product a scope on the product query excludes.
+	 *
+	 * A searched report joins every match onto the sales data, so a product a plugin scopes out
+	 * would otherwise come back as a row of its own, name included.
+	 */
+	public function test_get_reports_search_param_honours_a_product_query_scope() {
+		wp_set_current_user( $this->user );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$vendor    = $this->factory->user->create( array( 'role' => 'editor' ) );
+		$in_scope  = $this->create_product( 'Kingston Widget' );
+		$out_scope = $this->create_product( 'Kingston Gadget' );
+
+		wp_update_post(
+			array(
+				'ID'          => $in_scope->get_id(),
+				'post_author' => $vendor,
+			)
+		);
+
+		$this->create_completed_order( $in_scope );
+		$this->create_completed_order( $out_scope );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$scope = function ( $query ) use ( $vendor ) {
+			if ( 'product' === $query->get( 'post_type' ) ) {
+				$query->set( 'author', $vendor );
+			}
+		};
+
+		add_action( 'pre_get_posts', $scope );
+		$response = $this->dispatch_report( array( 'search' => 'Kingston' ) );
+		remove_action( 'pre_get_posts', $scope );
+
+		$reports_by_id = array_column( $response->get_data(), null, 'product_id' );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertArrayHasKey( $in_scope->get_id(), $reports_by_id );
+		$this->assertArrayNotHasKey( $out_scope->get_id(), $reports_by_id, 'A product outside the scope should not be reported' );
+		$this->assertEquals( 1, $response->get_headers()['X-WP-Total'], 'The row count should leave out the products the scope excludes' );
+	}
+
+	/**
+	 * @testdox Should register the `search` collection param.
+	 */
+	public function test_search_collection_param_is_registered() {
+		wp_set_current_user( $this->user );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'OPTIONS', $this->endpoint ) );
+		$args     = $response->get_data()['endpoints'][0]['args'];
+
+		$this->assertArrayHasKey( 'search', $args );
+		$this->assertEquals( 'array', $args['search']['type'] );
+	}
+
 	/**
 	 * Test getting reports without valid permissions.
 	 *
@@ -175,4 +557,85 @@ class WC_Admin_Tests_API_Reports_Products extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'orders_count', $properties );
 		$this->assertArrayHasKey( 'extended_info', $properties );
 	}
+
+	/**
+	 * Creates a simple product.
+	 *
+	 * @param string $name Product name.
+	 * @param string $sku  Optional. Product SKU.
+	 * @return WC_Product_Simple
+	 */
+	private function create_product( $name, $sku = '' ) {
+		$product = new WC_Product_Simple();
+		$product->set_name( $name );
+		$product->set_regular_price( 25 );
+
+		if ( '' !== $sku ) {
+			$product->set_sku( $sku );
+		}
+
+		$product->save();
+
+		return $product;
+	}
+
+	/**
+	 * Creates a simple product with product ID 1.
+	 *
+	 * The filters resolve to `-1` when no product satisfies them and `absint()` reads that as
+	 * product ID 1, so the wrong product is only reported when one has that ID.
+	 *
+	 * @param string $name Product name.
+	 * @return WC_Product_Simple
+	 */
+	private function create_product_with_id_1( $name ) {
+		wp_delete_post( 1, true );
+
+		$this->assertSame(
+			1,
+			wp_insert_post(
+				array(
+					'import_id'   => 1,
+					'post_title'  => $name,
+					'post_type'   => 'product',
+					'post_status' => 'publish',
+				)
+			)
+		);
+
+		$product = wc_get_product( 1 );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		return $product;
+	}
+
+	/**
+	 * Creates a completed order containing four units of the given product.
+	 *
+	 * @param WC_Product $product Product to order.
+	 * @return WC_Order
+	 */
+	private function create_completed_order( $product ) {
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		// $25 x 4.
+		$order->set_total( 100 );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Dispatches a request to the reports endpoint.
+	 *
+	 * @param array $query_params Query parameters.
+	 * @return WP_REST_Response
+	 */
+	private function dispatch_report( $query_params ) {
+		$request = new WP_REST_Request( 'GET', $this->endpoint );
+		$request->set_query_params( $query_params );
+
+		return $this->server->dispatch( $request );
+	}
 }
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php
index 8b7c426635c..1e656d293b3 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php
@@ -8,6 +8,7 @@

 use Automattic\WooCommerce\Admin\API\Reports\GenericQuery;
 use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
+use Automattic\WooCommerce\Admin\API\Reports\Products\Stats\DataStore as ProductsStatsDataStore;
 use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
 use Automattic\WooCommerce\Admin\ReportCSVExporter;
 use Automattic\WooCommerce\Enums\OrderStatus;
@@ -778,6 +779,281 @@ class WC_Admin_Tests_Reports_Products extends WC_Unit_Test_Case {
 		$this->assertEquals( $expected_csv, $actual_csv );
 	}

+	/**
+	 * @testdox Should report nothing when the category and product filters have no product in common.
+	 *
+	 * An empty intersection was indistinguishable from an absent product filter, so both filters
+	 * were dropped and the report covered every product instead of none.
+	 */
+	public function test_disjoint_category_and_product_filters() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$in_category = new WC_Product_Simple();
+		$in_category->set_name( 'In Category' );
+		$in_category->set_regular_price( 25 );
+		$in_category->save();
+
+		$outside = new WC_Product_Simple();
+		$outside->set_name( 'Outside Category' );
+		$outside->set_regular_price( 25 );
+		$outside->save();
+
+		$term = wp_insert_term( 'Filtered Category', 'product_cat' );
+		wp_set_object_terms( $in_category->get_id(), array( $term['term_id'] ), 'product_cat' );
+
+		foreach ( array( $in_category, $outside ) as $product ) {
+			$order = WC_Helper_Order::create_order( 1, $product );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->set_total( 100 );
+			$order->save();
+		}
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$data_store = new ProductsDataStore();
+		$args       = array(
+			'after'             => '2000-01-01 00:00:00',
+			'before'            => '2100-01-01 00:00:00',
+			'category_includes' => array( $term['term_id'] ),
+			'product_includes'  => array( $outside->get_id() ),
+		);
+
+		$data = $data_store->get_data( $args );
+
+		$this->assertEquals( 0, $data->total, 'A product filter that excludes every product in the category should report nothing' );
+		$this->assertSame( array(), $data->data );
+
+		$args['product_includes'] = array( $in_category->get_id() );
+
+		$data = $data_store->get_data( $args );
+
+		$this->assertEquals( 1, $data->total );
+		$this->assertEquals( $in_category->get_id(), $data->data[0]['product_id'] );
+	}
+
+	/**
+	 * @testdox Should report nothing when a product filter is combined with an empty category.
+	 */
+	public function test_product_filter_with_an_empty_category() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Uncategorized Product' );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 100 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$term = wp_insert_term( 'Empty Category', 'product_cat' );
+
+		$data_store = new ProductsDataStore();
+		$data       = $data_store->get_data(
+			array(
+				'after'             => '2000-01-01 00:00:00',
+				'before'            => '2100-01-01 00:00:00',
+				'category_includes' => array( $term['term_id'] ),
+				'product_includes'  => array( $product->get_id() ),
+			)
+		);
+
+		$this->assertEquals( 0, $data->total, 'An empty category should keep forcing an empty set once a product filter is added' );
+		$this->assertSame( array(), $data->data );
+	}
+
+	/**
+	 * @testdox Should not answer a search from the report cache.
+	 *
+	 * Which products a term matches is resolved while the report runs, and nothing invalidates the
+	 * report cache when a product is renamed, so a cached response would keep the old matches.
+	 */
+	public function test_a_search_is_not_answered_from_the_report_cache() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$matching = new WC_Product_Simple();
+		$matching->set_name( 'Cached Widget' );
+		$matching->set_regular_price( 25 );
+		$matching->save();
+
+		$renamed = new WC_Product_Simple();
+		$renamed->set_name( 'Cached Gadget' );
+		$renamed->set_regular_price( 25 );
+		$renamed->save();
+
+		foreach ( array( $matching, $renamed ) as $product ) {
+			$order = WC_Helper_Order::create_order( 1, $product );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->set_total( 100 );
+			$order->save();
+		}
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$args = array(
+			'after'  => '2000-01-01 00:00:00',
+			'before' => '2100-01-01 00:00:00',
+			'search' => array( 'Widget' ),
+		);
+
+		$data = ( new ProductsDataStore() )->get_data( $args );
+
+		$this->assertEquals( 1, $data->total );
+		$this->assertEquals( $matching->get_id(), $data->data[0]['product_id'] );
+
+		$renamed->set_name( 'Renamed Widget' );
+		$renamed->save();
+
+		$data        = ( new ProductsDataStore() )->get_data( $args );
+		$product_ids = wp_list_pluck( $data->data, 'product_id' );
+
+		$this->assertEquals( 2, $data->total, 'A product renamed into the search term should show up right away' );
+		$this->assertContains( $renamed->get_id(), $product_ids );
+
+		$stats = ( new ProductsStatsDataStore() )->get_data( array_merge( $args, array( 'interval' => 'year' ) ) );
+
+		$this->assertEquals( 2, $stats->totals->products_count, 'The stats endpoint should see the same matches' );
+	}
+
+	/**
+	 * @testdox Should still run the cache opt out filter when the report carries a search.
+	 *
+	 * The parent implementation is what applies it, so returning before that runs would take the
+	 * report out of the cache without telling the plugins listening.
+	 */
+	public function test_the_cache_opt_out_filter_runs_for_a_searched_report() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Filtered Widget' );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 100 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$cache_keys = array();
+		$record     = function ( $use_cache, $cache_key ) use ( &$cache_keys ) {
+			$cache_keys[] = $cache_key;
+
+			return $use_cache;
+		};
+
+		add_filter( 'woocommerce_analytics_report_should_use_cache', $record, 10, 2 );
+
+		( new ProductsDataStore() )->get_data(
+			array(
+				'after'  => '2000-01-01 00:00:00',
+				'before' => '2100-01-01 00:00:00',
+				'search' => array( 'Widget' ),
+			)
+		);
+
+		remove_filter( 'woocommerce_analytics_report_should_use_cache', $record, 10 );
+
+		$this->assertSame( array( 'products' ), array_unique( $cache_keys ), 'A searched report should reach the filter under its own cache key' );
+	}
+
+	/**
+	 * @testdox Should keep answering a report without a search from the cache.
+	 */
+	public function test_a_report_without_a_search_is_still_cached() {
+		global $wpdb;
+
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Cached Product' );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 100 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$args = array(
+			'after'  => '2000-01-01 00:00:00',
+			'before' => '2100-01-01 00:00:00',
+		);
+
+		$data       = ( new ProductsDataStore() )->get_data( $args );
+		$items_sold = $data->data[0]['items_sold'];
+
+		// Change the report data behind the cache's back, so only a fresh query can see it.
+		$wpdb->query( "UPDATE {$wpdb->prefix}wc_order_product_lookup SET product_qty = product_qty + 10" );
+
+		$data = ( new ProductsDataStore() )->get_data( $args );
+
+		$this->assertEquals( $items_sold, $data->data[0]['items_sold'], 'A report without a search should still be answered from the cache' );
+	}
+
+	/**
+	 * @testdox Should not require a `search` argument from a caller that builds its own query arguments.
+	 *
+	 * `get_noncached_data()` is public, so an extension can call it with the arguments it put
+	 * together before the search argument existed. Reading a key that is not there would warn.
+	 */
+	public function test_get_noncached_data_without_a_search_argument() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Direct Caller Product' );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 100 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		// The defaults as they stood before `search` was added to them.
+		$args = array(
+			'per_page'          => 10,
+			'page'              => 1,
+			'order'             => 'DESC',
+			'orderby'           => 'date',
+			'before'            => new WC_DateTime( '2100-01-01 00:00:00' ),
+			'after'             => new WC_DateTime( '2000-01-01 00:00:00' ),
+			'fields'            => '*',
+			'category_includes' => array(),
+			'product_includes'  => array(),
+			'extended_info'     => false,
+		);
+
+		$data = ( new ProductsDataStore() )->get_noncached_data( $args );
+
+		$this->assertEquals( 1, $data->total );
+		$this->assertEquals( $product->get_id(), $data->data[0]['product_id'] );
+
+		$stats_data = (object) array(
+			'totals'    => null,
+			'intervals' => array(),
+		);
+		$stats      = ( new ProductsStatsDataStore() )->get_noncached_stats_data(
+			array_merge( $args, array( 'interval' => 'year' ) ),
+			array(
+				'per_page' => 10,
+				'offset'   => 0,
+			),
+			$stats_data,
+			1
+		);
+
+		$this->assertEquals( 1, $stats->totals->products_count, 'The stats data store should take the same arguments' );
+	}
+
 	/**
 	 * Tests the data stored in the wc_order_product_lookup table when a full refund is made.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index c7e0bb0c4cf..2744f289539 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -6,6 +6,7 @@
  */

 use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
 use Automattic\WooCommerce\Blocks\Options as BlockOptions;
 use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
 use Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator;
@@ -496,4 +497,29 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {

 		$batch_processor->remove_processor( OrderTaxLookupMigrator::class );
 	}
+
+	/**
+	 * @testdox Migration invalidates the Analytics report cache, so a response cached before the update stops being served.
+	 */
+	public function test_wc_update_11201_invalidate_analytics_reports_cache(): void {
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$db_updates = WC_Install::get_db_update_callbacks();
+
+		// Under its own key, so that a store already on 11.2.0 from the batch that shipped beside
+		// it still drops its stale responses.
+		$this->assertArrayHasKey( '11.2.0-1', $db_updates );
+		$this->assertContains( 'wc_update_11201_invalidate_analytics_reports_cache', $db_updates['11.2.0-1'] );
+
+		// The cache version is a timestamp, so pin an old one rather than race the clock.
+		set_transient( ReportsCache::VERSION_OPTION . '-transient-version', '1000000000' );
+
+		$key = 'wc_report_products_pre_update';
+		ReportsCache::set( $key, 'pre-update response' );
+		$this->assertSame( 'pre-update response', ReportsCache::get( $key ), 'The response should be served from cache before the update runs' );
+
+		wc_update_11201_invalidate_analytics_reports_cache();
+
+		$this->assertFalse( ReportsCache::get( $key ), 'A response cached before the update should no longer be served' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Reports/ProductSearchQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Reports/ProductSearchQueryTest.php
new file mode 100644
index 00000000000..9a4ffd9b11c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Reports/ProductSearchQueryTest.php
@@ -0,0 +1,586 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Reports;
+
+use Automattic\WooCommerce\Internal\Admin\Reports\ProductSearchQuery;
+use WC_Product_Simple;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductSearchQuery class.
+ */
+class ProductSearchQueryTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'wc_product_sku_enabled', '__return_false' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should split a comma separated string into terms.
+	 */
+	public function test_parse_terms_splits_a_comma_separated_string(): void {
+		$this->assertSame(
+			array( 'widget', 'gadget' ),
+			ProductSearchQuery::parse_terms( 'widget,gadget' )
+		);
+	}
+
+	/**
+	 * @testdox Should keep multi word terms intact instead of splitting them on whitespace.
+	 */
+	public function test_parse_terms_keeps_multi_word_terms_intact(): void {
+		$this->assertSame(
+			array( 'blue widget', 'red gadget' ),
+			ProductSearchQuery::parse_terms( 'blue widget, red gadget' ),
+			'Splitting on whitespace would turn one multi word search into several single word searches'
+		);
+	}
+
+	/**
+	 * @testdox Should accept a list of terms as well as a string.
+	 */
+	public function test_parse_terms_accepts_an_array(): void {
+		$this->assertSame(
+			array( 'blue widget', 'gadget' ),
+			ProductSearchQuery::parse_terms( array( 'blue widget', 'gadget' ) )
+		);
+	}
+
+	/**
+	 * @testdox Should drop terms that are empty or whitespace only.
+	 */
+	public function test_parse_terms_drops_empty_terms(): void {
+		$this->assertSame(
+			array( 'widget', 'gadget' ),
+			ProductSearchQuery::parse_terms( 'widget,,   ,gadget,' )
+		);
+	}
+
+	/**
+	 * @testdox Should sanitize each term.
+	 */
+	public function test_parse_terms_sanitizes_each_term(): void {
+		$this->assertSame(
+			array( 'widget' ),
+			ProductSearchQuery::parse_terms( '<b>widget</b>' )
+		);
+	}
+
+	/**
+	 * @testdox Should return an empty list when there is nothing to search for.
+	 *
+	 * @dataProvider empty_search_value_provider
+	 *
+	 * @param string|string[] $value Raw argument value.
+	 */
+	public function test_parse_terms_returns_an_empty_list_for_empty_values( $value ): void {
+		$this->assertSame( array(), ProductSearchQuery::parse_terms( $value ) );
+	}
+
+	/**
+	 * Data provider for the empty search value tests.
+	 *
+	 * @return array[]
+	 */
+	public function empty_search_value_provider(): array {
+		return array(
+			'empty string'    => array( '' ),
+			'whitespace only' => array( '   ' ),
+			'commas only'     => array( ',,,' ),
+			'empty array'     => array( array() ),
+			'array of blanks' => array( array( '', ' ' ) ),
+		);
+	}
+
+	/**
+	 * @testdox Should trim each term.
+	 */
+	public function test_parse_terms_trims_each_term(): void {
+		$this->assertSame(
+			array( 'widget', 'blue gadget' ),
+			ProductSearchQuery::parse_terms( '  widget  ,  blue   gadget  ' ),
+			'sanitize_text_field() trims and collapses internal whitespace runs'
+		);
+	}
+
+	/**
+	 * @testdox Should return an empty statement when there is nothing to search for.
+	 */
+	public function test_get_ids_subquery_returns_an_empty_statement_when_there_is_nothing_to_search_for(): void {
+		$this->assertSame( '', ProductSearchQuery::get_ids_subquery( array() ) );
+		$this->assertSame( '', ProductSearchQuery::get_ids_subquery( '' ) );
+		$this->assertSame( '', ProductSearchQuery::get_ids_subquery( '  ,  ' ) );
+	}
+
+	/**
+	 * @testdox Should match products whose title contains the term.
+	 */
+	public function test_get_ids_subquery_matches_products_by_partial_title(): void {
+		$match    = $this->create_product( 'Kingston Widget' );
+		$no_match = $this->create_product( 'Unrelated Gadget' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'kingston' ) ) );
+
+		$this->assertContains( $match, $found, 'A partial, case insensitive title match should be returned' );
+		$this->assertNotContains( $no_match, $found );
+	}
+
+	/**
+	 * @testdox Should match products by SKU.
+	 */
+	public function test_get_ids_subquery_matches_products_by_sku(): void {
+		$match    = $this->create_product( 'Unrelated Gadget', 'KINGSTON-1' );
+		$no_match = $this->create_product( 'Another Gadget', 'OTHER-1' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'KINGSTON-1' ) ) );
+
+		$this->assertContains( $match, $found );
+		$this->assertNotContains( $no_match, $found );
+	}
+
+	/**
+	 * @testdox Should not match products by SKU when SKUs are disabled.
+	 */
+	public function test_get_ids_subquery_ignores_skus_when_they_are_disabled(): void {
+		$product = $this->create_product( 'Unrelated Gadget', 'KINGSTON-1' );
+
+		add_filter( 'wc_product_sku_enabled', '__return_false' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'KINGSTON-1' ) ) );
+
+		$this->assertNotContains( $product, $found );
+	}
+
+	/**
+	 * @testdox Should match a product that matches any of the given terms.
+	 */
+	public function test_get_ids_subquery_matches_any_of_the_given_terms(): void {
+		$first    = $this->create_product( 'Kingston Widget' );
+		$second   = $this->create_product( 'Brighton Gadget' );
+		$no_match = $this->create_product( 'Unrelated Thing' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston', 'Brighton' ) ) );
+
+		$this->assertContains( $first, $found );
+		$this->assertContains( $second, $found );
+		$this->assertNotContains( $no_match, $found );
+	}
+
+	/**
+	 * @testdox Should accept a comma separated string of terms.
+	 */
+	public function test_get_ids_subquery_accepts_a_comma_separated_string(): void {
+		$first  = $this->create_product( 'Kingston Widget' );
+		$second = $this->create_product( 'Brighton Gadget' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( 'Kingston,Brighton' ) );
+
+		$this->assertContains( $first, $found );
+		$this->assertContains( $second, $found );
+	}
+
+	/**
+	 * @testdox Should intersect the matches with the given product IDs.
+	 */
+	public function test_get_ids_subquery_intersects_with_the_given_product_ids(): void {
+		$kept    = $this->create_product( 'Kingston Widget' );
+		$dropped = $this->create_product( 'Kingston Gadget' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ), array( $kept ) ) );
+
+		$this->assertSame( array( $kept ), $found );
+	}
+
+	/**
+	 * @testdox Should match nothing when the given product IDs exclude every match.
+	 */
+	public function test_get_ids_subquery_matches_nothing_when_restricted_to_unrelated_ids(): void {
+		$this->create_product( 'Kingston Widget' );
+		$unrelated = $this->create_product( 'Unrelated Thing' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ), array( $unrelated ) ) );
+
+		$this->assertSame( array(), $found );
+	}
+
+	/**
+	 * @testdox Should match nothing when every given product ID is unusable.
+	 */
+	public function test_get_ids_subquery_matches_nothing_when_restricted_to_unusable_ids(): void {
+		$this->create_product( 'Kingston Widget' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ), array( 0 ) ) );
+
+		$this->assertSame( array(), $found );
+	}
+
+	/**
+	 * @testdox Should not read a negative product ID as the product with the matching positive ID.
+	 *
+	 * WP_Query runs `post__in` through `absint()`, which turns the report filters' `-1` into 1.
+	 */
+	public function test_get_ids_subquery_does_not_flip_negative_product_ids(): void {
+		$product = $this->create_product( 'Kingston Widget' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ), array( -$product ) ) );
+
+		$this->assertSame( array(), $found );
+	}
+
+	/**
+	 * @testdox Should not match products in a status that is hidden from search.
+	 */
+	public function test_get_ids_subquery_excludes_products_hidden_from_search(): void {
+		$published = $this->create_product( 'Kingston Widget' );
+
+		$trashed = $this->create_product( 'Kingston Trashed' );
+		wp_trash_post( $trashed );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) ) );
+
+		$this->assertContains( $published, $found );
+		$this->assertNotContains( $trashed, $found );
+	}
+
+	/**
+	 * @testdox Should match the same unpublished products the search box returns.
+	 */
+	public function test_get_ids_subquery_matches_unpublished_products(): void {
+		$draft   = $this->create_product( 'Kingston Draft', '', 'draft' );
+		$private = $this->create_product( 'Kingston Private', '', 'private' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) ) );
+
+		// Matches the search box, and a drafted product can still have sales worth reporting.
+		$this->assertContains( $draft, $found );
+		$this->assertContains( $private, $found );
+	}
+
+	/**
+	 * @testdox Should not match other post types.
+	 */
+	public function test_get_ids_subquery_does_not_match_other_post_types(): void {
+		$product = $this->create_product( 'Kingston Widget' );
+		$post    = self::factory()->post->create( array( 'post_title' => 'Kingston Widget' ) );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) ) );
+
+		$this->assertContains( $product, $found );
+		$this->assertNotContains( $post, $found );
+	}
+
+	/**
+	 * @testdox Should treat LIKE wildcards in the search term as literal characters when matching titles.
+	 *
+	 * @dataProvider like_wildcard_provider
+	 *
+	 * @param string $term          Search term containing a LIKE wildcard.
+	 * @param string $matching_name Title of the product the term should match.
+	 * @param string $other_name    Title of the product the term should not match.
+	 */
+	public function test_get_ids_subquery_escapes_like_wildcards( string $term, string $matching_name, string $other_name ): void {
+		$match    = $this->create_product( $matching_name );
+		$no_match = $this->create_product( $other_name );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( $term ) ) );
+
+		$this->assertContains( $match, $found );
+		$this->assertNotContains( $no_match, $found, "\"{$term}\" should not be treated as a wildcard pattern" );
+	}
+
+	/**
+	 * Data provider for the LIKE wildcard escaping test.
+	 *
+	 * @return array[]
+	 */
+	public function like_wildcard_provider(): array {
+		return array(
+			'percent sign' => array( '100%', '100% Cotton Shirt', '1000 Cotton Shirts' ),
+			'underscore'   => array( 'a_b', 'Model a_b', 'Model axb' ),
+		);
+	}
+
+	/**
+	 * @testdox Should compare the SKU against the raw term, LIKE wildcards included.
+	 *
+	 * Admin\API\Products leaves the term unescaped, so a wildcard in it is a pattern rather than a
+	 * literal. Escaping it here would make the report disagree with the search box.
+	 */
+	public function test_get_ids_subquery_does_not_escape_wildcards_in_the_sku_clause(): void {
+		$match    = $this->create_product( 'Unrelated Gadget', 'A-100' );
+		$no_match = $this->create_product( 'Another Gadget', 'B-200' );
+
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'A-1%' ) ) );
+
+		$this->assertContains( $match, $found, 'The SKU clause should treat the term as a LIKE pattern, as the search box does' );
+		$this->assertNotContains( $no_match, $found );
+	}
+
+	/**
+	 * @testdox Should honour a posts_where filter, the way the search box does.
+	 *
+	 * Multilingual plugins restrict products to the active language through the WP_Query clause
+	 * filters, and so does the search box the report has to agree with.
+	 */
+	public function test_get_ids_subquery_honours_a_posts_where_filter(): void {
+		$kept    = $this->create_product( 'Kingston Widget' );
+		$hidden  = $this->create_product( 'Kingston Gadget' );
+		$exclude = function ( $where ) use ( $hidden ) {
+			global $wpdb;
+
+			return $where . " AND {$wpdb->posts}.ID != {$hidden} ";
+		};
+
+		add_filter( 'posts_where', $exclude );
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) ) );
+		remove_filter( 'posts_where', $exclude );
+
+		$this->assertContains( $kept, $found );
+		$this->assertNotContains( $hidden, $found );
+	}
+
+	/**
+	 * @testdox Should honour a posts_join filter, the way the search box does.
+	 */
+	public function test_get_ids_subquery_honours_a_posts_join_filter(): void {
+		$translated = $this->create_product( 'Kingston Widget' );
+		update_post_meta( $translated, '_test_language', 'fr' );
+
+		// Not translated, so the join drops it.
+		$this->create_product( 'Kingston Gadget' );
+
+		$join = function ( $clause ) {
+			global $wpdb;
+
+			return $clause . " INNER JOIN {$wpdb->postmeta} AS language_meta ON {$wpdb->posts}.ID = language_meta.post_id AND language_meta.meta_key = '_test_language' ";
+		};
+
+		add_filter( 'posts_join', $join );
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) ) );
+		remove_filter( 'posts_join', $join );
+
+		$this->assertSame( array( $translated ), $found );
+	}
+
+	/**
+	 * @testdox Should honour a pre_get_posts restriction on the products it searches.
+	 *
+	 * A multivendor plugin scopes products to the vendor that authored them. The search resolves
+	 * to a report restriction rather than a listing, so a product left out here is one the report
+	 * cannot reach either.
+	 */
+	public function test_get_ids_subquery_honours_a_pre_get_posts_author_restriction(): void {
+		$vendor = self::factory()->user->create( array( 'role' => 'editor' ) );
+		$own    = $this->create_product( 'Kingston Widget' );
+
+		// Authored by someone else, so the scope drops it.
+		$this->create_product( 'Kingston Gadget' );
+
+		wp_update_post(
+			array(
+				'ID'          => $own,
+				'post_author' => $vendor,
+			)
+		);
+
+		$scope = function ( $query ) use ( $vendor ) {
+			$query->set( 'author', $vendor );
+		};
+
+		add_action( 'pre_get_posts', $scope );
+		$found = $this->run_subquery( ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) ) );
+		remove_action( 'pre_get_posts', $scope );
+
+		$this->assertSame( array( $own ), $found );
+	}
+
+	/**
+	 * @testdox Should apply a pre_get_posts restriction on top of the report's own product filter.
+	 */
+	public function test_get_ids_subquery_keeps_the_report_filter_alongside_a_pre_get_posts_restriction(): void {
+		$vendor       = self::factory()->user->create( array( 'role' => 'editor' ) );
+		$in_both      = $this->create_product( 'Kingston Widget' );
+		$vendors_only = $this->create_product( 'Kingston Gadget' );
+		$report_only  = $this->create_product( 'Kingston Sprocket' );
+
+		foreach ( array( $in_both, $vendors_only ) as $product ) {
+			wp_update_post(
+				array(
+					'ID'          => $product,
+					'post_author' => $vendor,
+				)
+			);
+		}
+
+		$scope = function ( $query ) use ( $vendor ) {
+			$query->set( 'author', $vendor );
+		};
+
+		add_action( 'pre_get_posts', $scope );
+		$found = $this->run_subquery(
+			ProductSearchQuery::get_ids_subquery( array( 'Kingston' ), array( $in_both, $report_only ) )
+		);
+		remove_action( 'pre_get_posts', $scope );
+
+		$this->assertSame( array( $in_both ), $found, 'Only the product both the scope and the report filter allow should match' );
+	}
+
+	/**
+	 * @testdox Should leave queries other than its own alone.
+	 */
+	public function test_get_ids_subquery_does_not_affect_later_queries(): void {
+		$product = $this->create_product( 'Kingston Widget' );
+		$other   = $this->create_product( 'Unrelated Thing' );
+
+		ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) );
+
+		$query = new \WP_Query(
+			array(
+				'post_type'      => 'product',
+				'post_status'    => 'any',
+				'fields'         => 'ids',
+				'posts_per_page' => -1,
+				'no_found_rows'  => true,
+			)
+		);
+
+		$this->assertContains( $product, $query->posts );
+		$this->assertContains( $other, $query->posts, 'The search filters should no longer be attached' );
+	}
+
+	/**
+	 * @testdox Should let a query run from one of its own filters return its results.
+	 *
+	 * A plugin can run a query while working out how to filter this one, and short circuiting
+	 * every query for the duration would hand it an empty result to decide on.
+	 */
+	public function test_get_ids_subquery_does_not_short_circuit_a_query_run_from_a_filter(): void {
+		$product = $this->create_product( 'Kingston Widget' );
+		$nested  = null;
+		$running = false;
+
+		$run_nested_query = function ( $where ) use ( &$nested, &$running ) {
+			if ( $running ) {
+				return $where;
+			}
+
+			$running = true;
+			$nested  = ( new \WP_Query(
+				array(
+					'post_type'      => 'product',
+					'post_status'    => 'any',
+					'fields'         => 'ids',
+					'posts_per_page' => -1,
+					'no_found_rows'  => true,
+				)
+			) )->posts;
+			$running = false;
+
+			return $where;
+		};
+
+		add_filter( 'posts_where', $run_nested_query );
+		ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) );
+		remove_filter( 'posts_where', $run_nested_query );
+
+		$this->assertContains( $product, (array) $nested, 'A query run from a filter should still return its own rows' );
+	}
+
+	/**
+	 * @testdox Should detach its filters even when the query throws.
+	 */
+	public function test_get_ids_subquery_detaches_its_filters_when_the_query_throws(): void {
+		$product = $this->create_product( 'Kingston Widget' );
+		$boom    = function () {
+			throw new \RuntimeException( 'Thrown from pre_get_posts' );
+		};
+
+		add_action( 'pre_get_posts', $boom );
+		try {
+			ProductSearchQuery::get_ids_subquery( array( 'Kingston' ) );
+			$this->fail( 'The exception should have propagated' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertSame( 'Thrown from pre_get_posts', $e->getMessage() );
+		} finally {
+			remove_action( 'pre_get_posts', $boom );
+		}
+
+		$query = new \WP_Query(
+			array(
+				'post_type'      => 'product',
+				'post_status'    => 'any',
+				'fields'         => 'ids',
+				'posts_per_page' => -1,
+				'no_found_rows'  => true,
+			)
+		);
+
+		$this->assertContains( $product, $query->posts, 'A left behind filter would empty every later query' );
+	}
+
+	/**
+	 * @testdox Should return null when there is nothing to search for, and the matches otherwise.
+	 */
+	public function test_get_ids_tells_an_absent_search_apart_from_one_that_matched_nothing(): void {
+		$product = $this->create_product( 'Kingston Widget' );
+
+		$this->assertNull( ProductSearchQuery::get_ids( array() ), 'No search is not the same as a search that matched nothing' );
+		$this->assertSame( array(), ProductSearchQuery::get_ids( array( 'nothing matches this' ) ) );
+		$this->assertSame( array( $product ), ProductSearchQuery::get_ids( array( 'Kingston' ) ) );
+	}
+
+	/**
+	 * @testdox Should intersect the matches with the given product IDs.
+	 */
+	public function test_get_ids_intersects_with_the_given_product_ids(): void {
+		$kept = $this->create_product( 'Kingston Widget' );
+		$this->create_product( 'Kingston Gadget' );
+
+		$this->assertSame( array( $kept ), ProductSearchQuery::get_ids( array( 'Kingston' ), array( $kept ) ) );
+		$this->assertSame( array(), ProductSearchQuery::get_ids( array( 'Kingston' ), array( '-1' ) ) );
+	}
+
+	/**
+	 * Runs a statement built by the system under test and returns the product IDs it yields.
+	 *
+	 * @param string $sql SQL statement.
+	 * @return int[]
+	 */
+	private function run_subquery( string $sql ): array {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Running the statement produced by the system under test.
+		$ids = $wpdb->get_col( $sql );
+
+		$ids = array_map( 'intval', $ids );
+		sort( $ids );
+
+		return $ids;
+	}
+
+	/**
+	 * Creates a simple product.
+	 *
+	 * @param string $name   Product name.
+	 * @param string $sku    Optional. Product SKU.
+	 * @param string $status Optional. Product status.
+	 * @return int Product ID.
+	 */
+	private function create_product( string $name, string $sku = '', string $status = 'publish' ): int {
+		$product = new WC_Product_Simple();
+		$product->set_name( $name );
+		$product->set_regular_price( '10' );
+		$product->set_status( $status );
+
+		if ( '' !== $sku ) {
+			$product->set_sku( $sku );
+		}
+
+		return $product->save();
+	}
+}