diff --git a/lib/domain/dtos/filters/RunFilterDto.js b/lib/domain/dtos/filters/RunFilterDto.js index 88d9e95b65..f1dbadc789 100644 --- a/lib/domain/dtos/filters/RunFilterDto.js +++ b/lib/domain/dtos/filters/RunFilterDto.js @@ -29,8 +29,39 @@ const EorReasonFilterDto = Joi.object({ description: Joi.string(), }); +/** + * Validates run numbers ranges to not exceed 100 runs + * + * @param {*} value The value to validate + * @param {*} helpers The helpers object + * @returns {Object} The value if validation passes + */ +const validateRange = (value, helpers) => { + const MAX_RANGE_SIZE = 100; + + const runNumbers = value.split(',').map((runNumber) => runNumber.trim()); + + for (const runNumber of runNumbers) { + if (runNumber.includes('-')) { + const [start, end] = runNumber.split('-').map((n) => parseInt(n, 10)); + if (Number.isNaN(start) || Number.isNaN(end) || start > end) { + return helpers.error('any.invalid', { message: `Invalid range: ${runNumber}` }); + } + const rangeSize = end - start + 1; + + if (rangeSize > MAX_RANGE_SIZE) { + return helpers.error('any.invalid', { message: `Given range exceeds max size of ${MAX_RANGE_SIZE} runs: ${runNumber}` }); + } + } + } + + return value; +}; + exports.RunFilterDto = Joi.object({ - runNumbers: Joi.string().trim(), + runNumbers: Joi.string().trim().custom(validateRange).messages({ + 'any.invalid': '{{#message}}', + }), calibrationStatuses: Joi.array().items(...RUN_CALIBRATION_STATUS), definitions: CustomJoi.stringArray().items(Joi.string().uppercase().trim().valid(...RUN_DEFINITIONS)), eorReason: EorReasonFilterDto, diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index fcf67c329c..d0d4877404 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -77,17 +77,37 @@ class GetAllRunsUseCase { } = filter; if (runNumbers) { - const runNumberList = runNumbers.split(SEARCH_ITEMS_SEPARATOR) - .map((runNumber) => parseInt(runNumber, 10)) - .filter((runNumber) => !Number.isNaN(runNumber)); - - if (runNumberList.length) { - if (runNumberList.length > 1) { - // Multiple run numbers. - filteringQueryBuilder.where('runNumber').oneOf(...runNumberList); + const runNumberCriteria = runNumbers.split(SEARCH_ITEMS_SEPARATOR) + .map((runNumbers) => runNumbers.trim()) + .filter(Boolean); + + const runNumberSet = new Set(); + + runNumberCriteria.forEach((runNumber) => { + if (runNumber.includes('-')) { + const [start, end] = runNumber.split('-').map((n) => parseInt(n, 10)); + if (!Number.isNaN(start) && !Number.isNaN(end)) { + for (let i = start; i <= end; i++) { + runNumberSet.add(i); + } + } } else { - // One run number. - const [runNumber] = runNumberList; + const parsedRunNumber = parseInt(runNumber, 10); + if (!Number.isNaN(parsedRunNumber)) { + runNumberSet.add(parsedRunNumber); + } + } + }); + + const finalRunNumberList = Array.from(runNumberSet); + + // Check that the final run numbers list contains at least one valid run number + if (finalRunNumberList.length > 0) { + // Check if user provided more than 1 run number initially, it might be twice the same to disable the `LIKE` filtering + if (finalRunNumberList.length > 1 || runNumberCriteria.length > 1) { + filteringQueryBuilder.where('runNumber').oneOf(...finalRunNumberList); + } else { + const [runNumber] = finalRunNumberList; filteringQueryBuilder.where('runNumber').substring(`${runNumber}`); } } @@ -160,7 +180,8 @@ class GetAllRunsUseCase { * @param {function} literal function to create an object representing a database literal expression * @return {Object} the object representing the column */ - const computedColumn = ({ literal }) => literal("ROUND(alice_l3_current * IF(`alice_l3_polarity` = 'NEGATIVE', -1, 1) / 1000)"); + const computedColumn = ({ literal }) => + literal('ROUND(alice_l3_current * IF(`alice_l3_polarity` = \'NEGATIVE\', -1, 1) / 1000)'); filteringQueryBuilder.where(computedColumn).is(aliceL3Current); } if (aliceDipoleCurrent !== undefined) { @@ -170,7 +191,7 @@ class GetAllRunsUseCase { * @return {Object} the object representing the column */ const computedColumn = ({ literal }) => - literal("ROUND(alice_dipole_current * IF(`alice_dipole_polarity` = 'NEGATIVE', -1, 1) / 1000)"); + literal('ROUND(alice_dipole_current * IF(`alice_dipole_polarity` = \'NEGATIVE\', -1, 1) / 1000)'); filteringQueryBuilder.where(computedColumn).is(aliceDipoleCurrent); } diff --git a/test/api/runs.test.js b/test/api/runs.test.js index 53e8868d11..4d639bb158 100644 --- a/test/api/runs.test.js +++ b/test/api/runs.test.js @@ -149,6 +149,25 @@ module.exports = () => { expect(runs).to.lengthOf(2); }); + it('should successfully filter on run number range', async () => { + const response = await request(server).get('/api/runs?filter[runNumbers]=1-5,8,12,20-30'); + + expect(response.status).to.equal(200); + const { data: runs } = response.body; + expect(runs).to.lengthOf(18); + }); + + it('should return 400 if range exceeds maximum of 100', async () => { + const runNumberRange = '1-108'; + const MAX_RANGE_SIZE = 100; + const response = await request(server).get(`/api/runs?filter[runNumbers]=${runNumberRange}`); + + expect(response.status).to.equal(400); + const { errors: [error] } = response.body; + expect(error.title).to.equal('Invalid Attribute'); + expect(error.detail).to.equal(`Given range exceeds max size of ${MAX_RANGE_SIZE} runs: ${runNumberRange}`); + }); + it('should return 400 if the calibration status filter is invalid', async () => { { const response = await request(server).get('/api/runs?filter[calibrationStatuses]=invalid'); diff --git a/test/lib/usecases/run/GetAllRunsUseCase.test.js b/test/lib/usecases/run/GetAllRunsUseCase.test.js index 652691499b..1d1799fecc 100644 --- a/test/lib/usecases/run/GetAllRunsUseCase.test.js +++ b/test/lib/usecases/run/GetAllRunsUseCase.test.js @@ -51,6 +51,14 @@ module.exports = () => { expect(runs[1].runNumber).to.equal(17); }); + it('should return an array, only containing runs with specified run number and ranges', async () => { + getAllRunsDto.query = { filter: { runNumbers: '1-5,8,12,20-30' } }; + const { runs } = await new GetAllRunsUseCase().execute(getAllRunsDto); + + expect(runs).to.be.an('array'); + expect(runs).to.have.lengthOf(18); + }); + it('should return runs sorted by runNumber', async () => { { const { runs } = await new GetAllRunsUseCase().execute({ query: { sort: { runNumber: 'ASC' } } });