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
23 changes: 22 additions & 1 deletion src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,20 @@ function getRawFilterListFromQuery(rawQuery: SearchQueryString) {
return undefined;
}

// Cache for buildSearchQueryJSON to avoid re-running the PEG parser for identical queries.
// This is a pure function called from 64+ sites — many fire during the same render cycle
// with identical query strings, each running the full parser from scratch.

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.

Suggested change
// with identical query strings, each running the full parser from scratch.
// with identical query strings and without this cache we would run the full parser from scratch.

const buildSearchQueryJSONCache = new Map<string, SearchQueryJSON | undefined>();
const BUILD_SEARCH_QUERY_JSON_CACHE_MAX_SIZE = 50;
const BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR = '\x00'; // Null byte prevents collisions if query/rawQuery contain arbitrary strings

function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString) {
const cacheKey = rawQuery ? `${query}${BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR}${rawQuery}` : query;
if (buildSearchQueryJSONCache.has(cacheKey)) {
const cached = buildSearchQueryJSONCache.get(cacheKey);
return cached ? {...cached} : cached;
}

try {
const result = parseSearchQuery(query) as SearchQueryJSON;
const flatFilters = getFilters(result);
Expand Down Expand Up @@ -487,7 +500,15 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt
result.rawFilterList = getRawFilterListFromQuery(rawQuery);
}

return result;
if (buildSearchQueryJSONCache.size >= BUILD_SEARCH_QUERY_JSON_CACHE_MAX_SIZE) {
const firstKey = buildSearchQueryJSONCache.keys().next().value;
if (firstKey !== undefined) {
buildSearchQueryJSONCache.delete(firstKey);
}
}
buildSearchQueryJSONCache.set(cacheKey, result);

return {...result};
} catch (e) {
console.error(`Error when parsing SearchQuery: "${query}"`, e);
}
Expand Down
84 changes: 84 additions & 0 deletions tests/unit/Search/SearchQueryUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ const personalDetailsFakeData = {
},
} as Record<string, {accountID: number}>;

jest.mock('@libs/SearchParser/searchParser', () => {
const actual = jest.requireActual<{parse: (...args: unknown[]) => unknown}>('@libs/SearchParser/searchParser');
return {
...actual,
parse: jest.fn(actual.parse),
};
});

jest.mock('@libs/PersonalDetailsUtils', () => {
const actual = jest.requireActual<typeof PersonalDetailsUtils>('@libs/PersonalDetailsUtils');
return {
Expand Down Expand Up @@ -1287,4 +1295,80 @@ describe('SearchQueryUtils', () => {
expect(result).toContain('merchant:Amazon');
});
});

describe('buildSearchQueryJSON cache', () => {
test('mutating the returned object does not affect subsequent calls for the same query', () => {
const query = `type:expense groupBy:category view:bar date:last-month merchant:test${Date.now()}`;

const first = buildSearchQueryJSON(query);
if (first) {
first.groupBy = undefined;
first.view = CONST.SEARCH.VIEW.TABLE;
}

const second = buildSearchQueryJSON(query);

expect(second?.groupBy).toBe('category');
expect(second?.view).toBe('bar');
});

test('returns equal result on repeated calls with the same query', () => {
const query = 'type:expense status:outstanding';

const first = buildSearchQueryJSON(query);
const second = buildSearchQueryJSON(query);

expect(first).toEqual(second);
});

test('returns independent results for the same query with different rawQuery values', () => {
const query = 'type:expense';
const rawQueryA = 'type:expense status:drafts';
const rawQueryB = 'type:expense status:paid';

const resultA = buildSearchQueryJSON(query, rawQueryA);
const resultB = buildSearchQueryJSON(query, rawQueryB);

expect(resultA?.rawFilterList).not.toEqual(resultB?.rawFilterList);
});

test('does not cache a failed parse result so subsequent calls retry the parser', () => {
// Force the parser to throw on the first call only, then succeed on the second.
// Verifies that a failed parse is not stored in the cache.
const searchParserModule: {parse: jest.Mock} = jest.requireMock('@libs/SearchParser/searchParser');
const originalImpl = jest.requireActual<{parse: (...args: unknown[]) => unknown}>('@libs/SearchParser/searchParser').parse;

const query = `type:expense merchant:cache-err-test${Date.now()}`;
let callCount = 0;
searchParserModule.parse.mockImplementation((...args: unknown[]) => {
callCount++;
if (callCount === 1) {
throw new Error('Simulated parser failure');
}
return originalImpl(...args);
});

const firstResult = buildSearchQueryJSON(query);
const secondResult = buildSearchQueryJSON(query);

expect(firstResult).toBeUndefined();
expect(secondResult?.type).toBe('expense');

searchParserModule.parse.mockImplementation(originalImpl);
});

test('evicts the oldest entry when the cache exceeds max size', () => {
// Insert 51 entries (max is 50) to trigger eviction, then verify
// the evicted entry can still be re-parsed correctly.
const uniquePrefix = `type:expense merchant:evict${Date.now()}x`;
for (let i = 0; i < 51; i++) {
buildSearchQueryJSON(`${uniquePrefix}${i}`);
}

const afterEviction = buildSearchQueryJSON(`${uniquePrefix}0`);

expect(afterEviction).toBeDefined();
expect(afterEviction?.type).toBe('expense');
});
});
});
Loading