Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
22 changes: 18 additions & 4 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchFilterKey>([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]);

function getGroupExportExactMatchFilterKeys(groupBy: SearchQueryJSON['groupBy']): ReadonlySet<SearchFilterKey> | undefined {
return groupBy === CONST.SEARCH.GROUP_BY.MERCHANT ? MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS : undefined;
}

type ShouldShowBulkDuplicateParams = {
selectedTransactionsKeys: string[];
selectedTransactions: Record<string, {reportID?: string; transaction?: Transaction}>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<SearchFilterKey>) => {
const columnsToExport = getColumnsToShow({
currentAccountID: accountID,
data: exportSearchData ?? {},
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 31 additions & 8 deletions src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,12 @@ function getQueryHashes(query: SearchQueryJSON) {
}

const filterSet = new Set<string>(orderedQuery);
const exactMatchFilterKeys = [...(query.exactMatchFilterKeys ?? [])].sort();
if (exactMatchFilterKeys.length > 0) {
const exactMatchIdentity = `exactMatch:${exactMatchFilterKeys.join(',')}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this new key 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just internal, to have different hashes (not user-facing)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These queries will have different hashes:

merchant:Amazon
merchant:Amazon exactMatch:merchant

orderedQuery += ` ${exactMatchIdentity}`;
filterSet.add(exactMatchIdentity);
}

// Certain filters shouldn't affect whether two searchers are similar or not, since they dont
// actually filter out results
Expand Down Expand Up @@ -609,6 +615,17 @@ function getQueryHashes(query: SearchQueryJSON) {
return {primaryHash, recentSearchHash, similarSearchHash};
}

function withExactMatchFilterKeys(queryJSON: Readonly<SearchQueryJSON>, 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)
*/
Expand Down Expand Up @@ -2468,22 +2485,26 @@ function getAdvancedFiltersToReset(searchAdvancedFiltersForm: Partial<SearchAdva
* should be treated as a substring/partial match (`contains`) when querying the backend.
* This allows searches like `merchant:coffee` to match "Coffee shop".
*/
const TEXT_SEARCH_FIELDS = new Set<string>([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<SearchFilterKey>): 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,
};
}

Expand All @@ -2505,13 +2526,14 @@ function getDateModifierTitle(modifier: ValueOf<typeof CONST.SEARCH.DATE_MODIFIE
* Serializes a query object to a JSON string for backend commands (Search, export, CSV).
* Applies text-field operator normalization (`eq` → `contains`) for `merchant` and `description`
* so all backend commands use consistent partial-match semantics — matching what the search view shows.
* Keys in `exactMatchFilterKeys` keep exact-match semantics for generated filters.
* Do NOT use for saving/persisting query definitions (e.g. saveSearch), where the original operators must be preserved.
*/
function serializeQueryJSONForBackend<T extends {filters?: ASTNode | null; rawFilterList?: RawQueryFilter[]}>(queryData: T): string {
const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters) : queryData.filters;
function serializeQueryJSONForBackend<T extends {filters?: ASTNode | null; rawFilterList?: RawQueryFilter[]}>(queryData: T, exactMatchFilterKeys?: ReadonlySet<SearchFilterKey>): 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;
Expand All @@ -2537,6 +2559,7 @@ export {
getRangeBoundariesFromFormValue,
getRangeQueryValue,
getQueryHashes,
withExactMatchFilterKeys,
isSearchDatePreset,
getDateRangeForPreset,
getDateFilterRange,
Expand Down
7 changes: 6 additions & 1 deletion src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ import {
isFilterSupported,
isSearchDatePreset,
sortOptionsWithEmptyValue,
withExactMatchFilterKeys,
} from './SearchQueryUtils';
import StringUtils from './StringUtils';
import {getIOUPayerAndReceiver} from './TransactionPreviewUtils';
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand Down
69 changes: 62 additions & 7 deletions tests/unit/Search/SearchQueryUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -39,6 +39,7 @@ import {
shouldResetSort,
shouldResetSortForViewChange,
sortOptionsWithEmptyValue,
withExactMatchFilterKeys,
} from '@src/libs/SearchQueryUtils';
import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -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');
}
Expand All @@ -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<SearchFilterKey>([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<SearchFilterKey>([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<SearchFilterKey>([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]);
});
});

Expand Down
1 change: 1 addition & 0 deletions tests/unit/Search/SearchUIUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"', () => {
Expand Down
Loading