diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index cd0cd4fcb..517a870f0 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -783,7 +783,7 @@ export class ExperimentController { * properties: * key: * type: string - * enum: [all, name, status, tag] + * enum: [all, name, status, tag, context, id, decisionPoint] * string: * type: string * sortParams: diff --git a/packages/backend/src/api/services/ExperimentService.ts b/packages/backend/src/api/services/ExperimentService.ts index 597d9aed4..5a022075c 100644 --- a/packages/backend/src/api/services/ExperimentService.ts +++ b/packages/backend/src/api/services/ExperimentService.ts @@ -1771,14 +1771,23 @@ export class ExperimentService { } private paginatedSearchString(params: IExperimentSearchParams): string { const type = params.key; - // escape % and ' characters - const searchString = params.string.replace(/%/g, '\\$&').replace(/'/g, "''"); + const searchString = params.string.replace(/'/g, "''"); + const likeSearchString = searchString.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); if (type === EXPERIMENT_SEARCH_KEY.ID && !isUUID(searchString)) { return ''; } - const likeString = `ILIKE '%${searchString}%'`; + const likeString = `ILIKE '%${likeSearchString}%' ESCAPE '\\'`; const searchArray: string[] = []; + const decisionPointDisplaySearch = + `(CASE WHEN COALESCE(partitions.target, '') = '' THEN partitions.site ` + + `ELSE CONCAT(partitions.site, ' (', partitions.target, ')') END) ${likeString}`; + const addDecisionPointSearch = () => { + searchArray.push(`partitions.site ${likeString}`); + searchArray.push(`partitions.target ${likeString}`); + searchArray.push(decisionPointDisplaySearch); + }; + switch (type) { case EXPERIMENT_SEARCH_KEY.NAME: searchArray.push(`${type} ${likeString}`); @@ -1796,16 +1805,14 @@ export class ExperimentService { searchArray.push(`experiment.id = '${searchString}'`); break; case EXPERIMENT_SEARCH_KEY.DECISION_POINT: - searchArray.push(`partitions.site ${likeString}`); - searchArray.push(`partitions.target ${likeString}`); + addDecisionPointSearch(); break; default: searchArray.push(`name ${likeString}`); searchArray.push(`state::TEXT = '${this.mapStatusStrings(searchString)}'`); searchArray.push(`ARRAY_TO_STRING(context, ',') ${likeString}`); searchArray.push(`ARRAY_TO_STRING(tags, ',') ${likeString}`); - searchArray.push(`partitions.site ${likeString}`); - searchArray.push(`partitions.target ${likeString}`); + addDecisionPointSearch(); if (isUUID(searchString)) { searchArray.push(`experiment.id = '${searchString}'`); } diff --git a/packages/backend/test/unit/services/ExperimentService.test.ts b/packages/backend/test/unit/services/ExperimentService.test.ts index 7e8dd6050..3aa226b41 100644 --- a/packages/backend/test/unit/services/ExperimentService.test.ts +++ b/packages/backend/test/unit/services/ExperimentService.test.ts @@ -48,6 +48,7 @@ import { LOG_TYPE, PAYLOAD_TYPE, IMetricMetaData, + EXPERIMENT_SEARCH_KEY, } from 'upgrade_types'; import { StateTimeLog } from '../../../src/api/models/StateTimeLogs'; import { Query } from '../../../src/api/models/Query'; @@ -983,4 +984,51 @@ describe('ExperimentService Testing', () => { expect(decisionPointRepo.setAllPendingActivationFalse).not.toHaveBeenCalled(); }); }); + + describe('paginatedSearchString()', () => { + const getSearchClause = (key: EXPERIMENT_SEARCH_KEY, searchString: string): string => + service['paginatedSearchString']({ key, string: searchString }); + + it('should search decision points by site, target, and displayed site-target pair', () => { + const searchString = 'SelectSection (absolute_value_plot_equality)'; + const escapedSearchString = 'SelectSection (absolute\\_value\\_plot\\_equality)'; + const likeClause = `ILIKE '%${escapedSearchString}%' ESCAPE '\\'`; + const result = getSearchClause(EXPERIMENT_SEARCH_KEY.DECISION_POINT, searchString); + + expect(result).toContain(`partitions.site ${likeClause}`); + expect(result).toContain(`partitions.target ${likeClause}`); + expect(result).toContain( + `(CASE WHEN COALESCE(partitions.target, '') = '' THEN partitions.site ` + + `ELSE CONCAT(partitions.site, ' (', partitions.target, ')') END) ${likeClause}` + ); + }); + + it('should use site as the decision point display value when target is empty', () => { + const likeClause = `ILIKE '%SelectSection%' ESCAPE '\\'`; + const result = getSearchClause(EXPERIMENT_SEARCH_KEY.DECISION_POINT, 'SelectSection'); + + expect(result).toContain(`COALESCE(partitions.target, '') = '' THEN partitions.site`); + expect(result).toContain(`partitions.site ${likeClause}`); + }); + + it('should include displayed decision point search in all-search results', () => { + const searchString = 'SelectSection (absolute_value_plot_equality)'; + const escapedSearchString = 'SelectSection (absolute\\_value\\_plot\\_equality)'; + const likeClause = `ILIKE '%${escapedSearchString}%' ESCAPE '\\'`; + const result = getSearchClause(EXPERIMENT_SEARCH_KEY.ALL, searchString); + + expect(result).toContain(`partitions.site ${likeClause}`); + expect(result).toContain(`partitions.target ${likeClause}`); + expect(result).toContain( + `(CASE WHEN COALESCE(partitions.target, '') = '' THEN partitions.site ` + + `ELSE CONCAT(partitions.site, ' (', partitions.target, ')') END) ${likeClause}` + ); + }); + + it('should escape LIKE wildcards and the escape character in search input', () => { + const result = getSearchClause(EXPERIMENT_SEARCH_KEY.NAME, '100%_path\\name'); + + expect(result).toContain(`name ILIKE '%100\\%\\_path\\\\name%' ESCAPE '\\'`); + }); + }); });