diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 489594ab7e5a..4e07779f7cb5 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -367,6 +367,8 @@ type SearchQueryJSON = { similarSearchHash: number; flatFilters: QueryFilters; isViewExplicitlySet?: boolean; + /** Filters generated for a specific group that must keep exact-match semantics when sent to the backend. */ + exactMatchFilterKeys?: SearchFilterKey[]; } & SearchQueryAST; type SearchAutocompleteResult = { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 945f48f68a6a..4f8e706efaea 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -206,6 +206,12 @@ function addSelectedGroupsFilter(queryJSON: SearchQueryJSON, selectedTransaction return buildSearchQueryJSON(buildSearchQueryString({...queryJSON, flatFilters: newFlatFilters})) ?? queryJSON; } +const MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + +function getGroupExportExactMatchFilterKeys(groupBy: SearchQueryJSON['groupBy']): ReadonlySet | undefined { + return groupBy === CONST.SEARCH.GROUP_BY.MERCHANT ? MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS : undefined; +} + type ShouldShowBulkDuplicateParams = { selectedTransactionsKeys: string[]; selectedTransactions: Record; @@ -648,7 +654,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { { templateName, templateType, - jsonQuery: isGroupExport ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) : '{}', + jsonQuery: isGroupExport + ? serializeQueryJSONForBackend( + addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data), + getGroupExportExactMatchFilterKeys(queryJSON.groupBy), + ) + : '{}', reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, policyID, @@ -689,7 +700,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportSearchType = searchResults?.search.type ?? queryJSON?.type; const getCSVExportParameters = useCallback( - (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined) => { + (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined, exactMatchFilterKeys?: ReadonlySet) => { const columnsToExport = getColumnsToShow({ currentAccountID: accountID, data: exportSearchData ?? {}, @@ -705,7 +716,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { exportColumnLabels[column] = translate(getSearchColumnTranslationKey(column)); } - const jsonQuery = queryJSONToExport ? serializeQueryJSONForBackend({...queryJSONToExport, columns: columnsToExport}) : (JSON.stringify(queryJSONToExport) ?? ''); + const jsonQuery = queryJSONToExport + ? serializeQueryJSONForBackend({...queryJSONToExport, columns: columnsToExport}, exactMatchFilterKeys) + : (JSON.stringify(queryJSONToExport) ?? ''); return { jsonQuery, @@ -747,7 +760,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { let didFail = false; const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; const queryJSONToExport = isGroupExport && queryJSON ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; - const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport); + const exactMatchFilterKeys = isGroupExport ? getGroupExportExactMatchFilterKeys(queryJSON?.groupBy) : undefined; + const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport, exactMatchFilterKeys); await exportSearchItemsToCSV( { jsonQuery: exportParameters.jsonQuery, diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 2a20f4ceadb8..255398c6a480 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -555,6 +555,12 @@ function getQueryHashes(query: SearchQueryJSON) { } const filterSet = new Set(orderedQuery); + const exactMatchFilterKeys = [...(query.exactMatchFilterKeys ?? [])].sort(); + if (exactMatchFilterKeys.length > 0) { + const exactMatchIdentity = `exactMatch:${exactMatchFilterKeys.join(',')}`; + orderedQuery += ` ${exactMatchIdentity}`; + filterSet.add(exactMatchIdentity); + } // Certain filters shouldn't affect whether two searchers are similar or not, since they dont // actually filter out results @@ -609,6 +615,17 @@ function getQueryHashes(query: SearchQueryJSON) { return {primaryHash, recentSearchHash, similarSearchHash}; } +function withExactMatchFilterKeys(queryJSON: Readonly, exactMatchFilterKeys: SearchFilterKey[]): SearchQueryJSON { + const queryWithExactMatches = {...queryJSON, exactMatchFilterKeys}; + const {primaryHash, recentSearchHash, similarSearchHash} = getQueryHashes(queryWithExactMatches); + return { + ...queryWithExactMatches, + hash: primaryHash, + recentSearchHash, + similarSearchHash, + }; +} + /** * Returns whether a given string is a date preset (e.g. Last month) */ @@ -2468,22 +2485,26 @@ function getAdvancedFiltersToReset(searchAdvancedFiltersForm: Partial([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION]); +function isTextSearchField(key: string): key is SearchFilterKey { + return key === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION; +} /** * Recursively traverses a search AST and replaces the `eq` operator with `contains` * for free-text filter fields (merchant, description). This enables partial/substring * matching on the backend for text searches while preserving the user-facing `:` syntax. + * Keys in `exactMatchFilterKeys` keep their original `eq` operator. */ -function applyContainsOperatorToTextFields(node: ASTNode): ASTNode { - if (typeof node.left === 'string' && TEXT_SEARCH_FIELDS.has(node.left) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { +function applyContainsOperatorToTextFields(node: ASTNode, exactMatchFilterKeys?: ReadonlySet): ASTNode { + const filterKey = typeof node.left === 'string' && isTextSearchField(node.left) ? node.left : undefined; + if (filterKey && !exactMatchFilterKeys?.has(filterKey) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...node, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return { ...node, - left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left) : node.left, - right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right) : node.right, + left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left, exactMatchFilterKeys) : node.left, + right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right, exactMatchFilterKeys) : node.right, }; } @@ -2505,13 +2526,14 @@ function getDateModifierTitle(modifier: ValueOf(queryData: T): string { - const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters) : queryData.filters; +function serializeQueryJSONForBackend(queryData: T, exactMatchFilterKeys?: ReadonlySet): string { + const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters, exactMatchFilterKeys) : queryData.filters; const normalizedRawFilterList = queryData.rawFilterList ? queryData.rawFilterList.map((filter) => { - if (TEXT_SEARCH_FIELDS.has(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { + if (isTextSearchField(filter.key) && !exactMatchFilterKeys?.has(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...filter, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return filter; @@ -2537,6 +2559,7 @@ export { getRangeBoundariesFromFormValue, getRangeQueryValue, getQueryHashes, + withExactMatchFilterKeys, isSearchDatePreset, getDateRangeForPreset, getDateFilterRange, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index aeeb5417bb6f..d67dedd723b3 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -190,6 +190,7 @@ import { isFilterSupported, isSearchDatePreset, sortOptionsWithEmptyValue, + withExactMatchFilterKeys, } from './SearchQueryUtils'; import StringUtils from './StringUtils'; import {getIOUPayerAndReceiver} from './TransactionPreviewUtils'; @@ -3072,7 +3073,11 @@ function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFi const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== filterKey); newFlatFilters.push({key: filterKey, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: filterValue}]}); const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, flatFilters: newFlatFilters}; - return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); + const specificGroupQueryJSON = buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); + if (!specificGroupQueryJSON || filterKey !== CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + return specificGroupQueryJSON; + } + return withExactMatchFilterKeys(specificGroupQueryJSON, [filterKey]); } function buildEmptyTagGroupQuery(queryJSON: SearchQueryJSON): SearchQueryJSON | undefined { diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 6cc3c3f1960a..8ffb51bfc2ae 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1023,7 +1023,7 @@ function search({ inFlightSearchRequests.add(dedupeKey); const {optimisticData, successData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); - const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; + const {exactMatchFilterKeys, flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) ? { ...queryJSONWithoutFlatFilters, @@ -1046,7 +1046,7 @@ function search({ // Backend expects 'maximumResults' instead of 'limit' ...(limit !== undefined && {maximumResults: limit}), }; - const jsonQuery = serializeQueryJSONForBackend(query); + const jsonQuery = serializeQueryJSONForBackend(query, exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined); if (shouldUpdateLastSearchParams) { saveLastSearchParams({ diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index aec27175eb74..79b59f0ececd 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -1,4 +1,4 @@ -import type {ASTNode, QueryFilter, SearchQueryJSON} from '@components/Search/types'; +import type {ASTNode, QueryFilter, SearchFilterKey, SearchQueryJSON} from '@components/Search/types'; import {generatePolicyID} from '@libs/actions/Policy/Policy'; import type * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -39,6 +39,7 @@ import { shouldResetSort, shouldResetSortForViewChange, sortOptionsWithEmptyValue, + withExactMatchFilterKeys, } from '@src/libs/SearchQueryUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -3418,8 +3419,9 @@ describe('SearchQueryUtils', () => { if (!queryJSON) { throw new Error('Expected queryJSON to be defined'); } - const serialized = JSON.parse(serializeQueryJSONForBackend(queryJSON)) as {filters: ASTNode}; - const merchantNode = findNode(serialized.filters, 'merchant'); + const normalizedFilters = applyContainsOperatorToTextFields(queryJSON.filters); + expect(serializeQueryJSONForBackend(queryJSON)).toBe(JSON.stringify({...queryJSON, filters: normalizedFilters, status: ''})); + const merchantNode = findNode(normalizedFilters, 'merchant'); if (!merchantNode) { throw new Error('Expected merchant node to be found in AST'); } @@ -3428,14 +3430,67 @@ describe('SearchQueryUtils', () => { it('should apply contains to merchant in rawFilterList', () => { const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; - const serialized = JSON.parse(serializeQueryJSONForBackend({filters: undefined, rawFilterList})) as {rawFilterList: typeof rawFilterList}; - expect(serialized.rawFilterList.at(0)?.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + const normalizedRawFilterList = rawFilterList.map((filter) => ({...filter, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS})); + expect(serializeQueryJSONForBackend({filters: undefined, rawFilterList})).toBe(JSON.stringify({filters: undefined, rawFilterList: normalizedRawFilterList, status: ''})); + }); + + it('should preserve exact merchant matches in AST filters', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); + if (!queryJSON) { + throw new Error('Expected queryJSON to be defined'); + } + const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + expect(serializeQueryJSONForBackend(queryJSON, exactMatchFilterKeys)).toBe(JSON.stringify({...queryJSON, status: ''})); + const merchantNode = findNode(queryJSON.filters, 'merchant'); + if (!merchantNode) { + throw new Error('Expected merchant node to be found in AST'); + } + expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + }); + + it('should preserve exact merchant matches in rawFilterList', () => { + const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; + const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + expect(serializeQueryJSONForBackend({filters: undefined, rawFilterList}, exactMatchFilterKeys)).toBe(JSON.stringify({filters: undefined, rawFilterList, status: ''})); + }); + + it('should preserve multiple exact merchant matches while keeping description as contains', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant:Amazon,"Amazon Marketplace" description:order'); + if (!queryJSON) { + throw new Error('Expected queryJSON to be defined'); + } + const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + const normalizedFilters = applyContainsOperatorToTextFields(queryJSON.filters, exactMatchFilterKeys); + expect(serializeQueryJSONForBackend(queryJSON, exactMatchFilterKeys)).toBe(JSON.stringify({...queryJSON, filters: normalizedFilters, status: ''})); + const merchantNode = findNode(normalizedFilters, 'merchant'); + const descriptionNode = findNode(normalizedFilters, 'description'); + if (!merchantNode || !descriptionNode) { + throw new Error('Expected merchant and description nodes to be found in AST'); + } + expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + expect(merchantNode.right).toEqual(['Amazon', 'Amazon Marketplace']); + expect(descriptionNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); }); it('should not affect non-text fields in rawFilterList', () => { const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'food'}]; - const serialized = JSON.parse(serializeQueryJSONForBackend({filters: undefined, rawFilterList})) as {rawFilterList: typeof rawFilterList}; - expect(serialized.rawFilterList.at(0)?.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + expect(serializeQueryJSONForBackend({filters: undefined, rawFilterList})).toBe(JSON.stringify({filters: undefined, rawFilterList, status: ''})); + }); + }); + + describe('withExactMatchFilterKeys', () => { + it('should give exact queries distinct snapshot hashes', () => { + const partialQuery = buildSearchQueryJSON('type:expense merchant:Amazon'); + if (!partialQuery) { + throw new Error('Expected partial query to be defined'); + } + + const exactQuery = withExactMatchFilterKeys(partialQuery, [CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + + expect(exactQuery.hash).not.toBe(partialQuery.hash); + expect(exactQuery.recentSearchHash).not.toBe(partialQuery.recentSearchHash); + expect(exactQuery.similarSearchHash).not.toBe(partialQuery.similarSearchHash); + expect(exactQuery.exactMatchFilterKeys).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); }); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index ddbb4bba571d..733dbf617f48 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -4619,6 +4619,7 @@ describe('SearchUIUtils', () => { expect(emptyMerchantItem?.transactionsQueryJSON).toBeDefined(); // The query should use 'none' (MERCHANT_EMPTY_VALUE) instead of empty string expect(emptyMerchantItem?.transactionsQueryJSON?.inputQuery).toContain(CONST.SEARCH.MERCHANT_EMPTY_VALUE); + expect(emptyMerchantItem?.transactionsQueryJSON?.exactMatchFilterKeys).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); }); it('should treat DEFAULT_MERCHANT "Expense" as empty merchant and display "No merchant"', () => {