diff --git a/api/v1/constants.js b/api/v1/constants.js index 82bc0d19cd..a362469122 100644 --- a/api/v1/constants.js +++ b/api/v1/constants.js @@ -39,6 +39,7 @@ module.exports = { SEQ_DESC: 'DESC', SEQ_LIKE: '$iLike', SEQ_CONTAINS: '$contains', + SEQ_IN: '$in', SEQ_OR: '$or', SEQ_WILDCARD: '%', SLASH: '/', diff --git a/api/v1/helpers/nouns/aspects.js b/api/v1/helpers/nouns/aspects.js index bb3b52822b..5491b1943e 100644 --- a/api/v1/helpers/nouns/aspects.js +++ b/api/v1/helpers/nouns/aspects.js @@ -17,6 +17,7 @@ const m = 'aspect'; const fieldsWithJsonArrayType = ['relatedLinks']; const fieldsWithArrayType = ['tags']; +const fieldsWithEnum = ['valueType']; module.exports = { apiLinks: { @@ -34,4 +35,5 @@ module.exports = { modelName: 'Aspect', fieldsWithArrayType, fieldsWithJsonArrayType, + fieldsWithEnum, }; // exports diff --git a/api/v1/helpers/nouns/samples.js b/api/v1/helpers/nouns/samples.js index 766768c44e..172595999d 100644 --- a/api/v1/helpers/nouns/samples.js +++ b/api/v1/helpers/nouns/samples.js @@ -17,6 +17,7 @@ const config = require('../../../../config'); const m = 'sample'; const fieldsWithJsonArrayType = ['relatedLinks']; +const fieldsWithEnum = ['status', 'previousStatus']; const loggingEnabled = ( config.auditSamples === 'API' || config.auditSamples === 'ALL' ) || false; @@ -34,6 +35,6 @@ module.exports = { model: Sample, modelName: 'Sample', fieldsWithJsonArrayType, + fieldsWithEnum, loggingEnabled, - }; // exports diff --git a/api/v1/helpers/verbs/findUtils.js b/api/v1/helpers/verbs/findUtils.js index f13fed704c..3b497dd78d 100644 --- a/api/v1/helpers/verbs/findUtils.js +++ b/api/v1/helpers/verbs/findUtils.js @@ -56,6 +56,13 @@ function toSequelizeWildcards(val) { * case-insensitive string matching */ function toWhereClause(val, props) { + // given array, return { $in: array } + if (Array.isArray(val) && props.isEnum) { + const inClause = {}; + inClause[constants.SEQ_IN] = val; + return inClause; + } + if (Array.isArray(val) && props.tagFilterName) { const containsClause = {}; containsClause[constants.SEQ_CONTAINS] = val; @@ -84,6 +91,7 @@ function toWhereClause(val, props) { function toSequelizeWhere(filter, props) { const where = {}; const keys = Object.keys(filter); + for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (filter[key] !== undefined) { @@ -93,12 +101,31 @@ function toSequelizeWhere(filter, props) { const values = []; + /* + * If enum filter is enabled and key is an enumerable field + * then create an "in" + * clause and add it to where clause, e.g. + * { + * where: { + valueType: { $in: ["PERCENT", "BOOLEAN"] }, + }, + * } + */ + if (Array.isArray(props.fieldsWithEnum) && + props.fieldsWithEnum.indexOf(key) > -1) { + const enumArr = filter[key]; + // to use $in instead of $contains in toWhereClause + props.isEnum = true; + values.push(toWhereClause(enumArr, props)); + where[key] = values[0]; + } + /* * If tag filter is enabled and key is "tags", then create a "contains" * clause and add it to where clause, e.g. * { where : { '$contains': ['tag1', 'tag2'] } } */ - if (props.tagFilterName && key === props.tagFilterName) { + else if (props.tagFilterName && key === props.tagFilterName) { const tagArr = filter[key]; values.push(toWhereClause(tagArr, props)); where[key] = values[0]; diff --git a/api/v1/swagger.yaml b/api/v1/swagger.yaml index ff8a12538c..4a28befb20 100644 --- a/api/v1/swagger.yaml +++ b/api/v1/swagger.yaml @@ -2447,6 +2447,41 @@ paths: $ref: "#/parameters/limitParam" - $ref: "#/parameters/offsetParam" + - + name: name + in: query + description: >- + Filter by sample name; asterisk (*) wildcards ok. + required: false + type: string + - + name: messageCode + in: query + description: >- + Filter by sample messageCode; asterisk (*) wildcards ok. + required: false + type: string + - + name: status + in: query + description: >- + Filter by sample status (Critical|Invalid|Timeout|Warning|Info|OK). + required: false + type: string + - + name: previousStatus + in: query + description: >- + Filter by sample previousStatus (Critical|Invalid|Timeout|Warning|Info|OK). + required: false + type: string + - + name: value + in: query + description: >- + Filter by sample value (BOOLEAN|NUMERIC|PERCENT). + required: false + type: string responses: 200: description: >- @@ -3624,11 +3659,11 @@ paths: For example, ?aspect=FOO,BAR will only return subjects in the hierarchy with samples for those two aspects (and all those subjects' ancestors up to the specified root of the requested - hierarchy). Prefix each of the aspect name with a negative sign to + hierarchy). Prefix each of the aspect name with a negative sign to indicate that a sample with that aspect should be excluded. For example, ?aspect=-BAZ,-FOO will return only the subjects (and its hierarchy) that have samples with aspect name not equal - to BAZ or FOO. Subjects without samples are not included in the + to BAZ or FOO. Subjects without samples are not included in the result set type: string - @@ -3639,7 +3674,7 @@ paths: For example, ?status=OK,CRITICAL will only return subjects in the hierarchy with samples that are in those statuses (and all those subjects' ancestors up to the specified root of the requested - hierarchy). Prefix each of the status with a negative sign to + hierarchy). Prefix each of the status with a negative sign to indicate that a sample with that status should be excluded. For example, ?status=-OK,-CRITICAL will return only the subjects (and its hierarchy) that have samples not in OK or CRITICAL status. @@ -3653,11 +3688,11 @@ paths: For example, ?aspectTags=TAG1,TAG2 will only return subjects in the hierarchy with samples having aspect with tags matching TAG1 and TAG2 (and all those subjects' ancestors up to the specified root of the - requested hierarchy). Prefix each of the tag names with a negative - sign to indicate that a sample having aspect with those tag names + requested hierarchy). Prefix each of the tag names with a negative + sign to indicate that a sample having aspect with those tag names will be excluded. For example, ?aspectTags=-TAG3,-TAG4 will - return the subject hierarchy without aspects having tags - - TAG3 and TAG4. Subjects without samples are not included in + return the subject hierarchy without aspects having tags - + TAG3 and TAG4. Subjects without samples are not included in the result set type: string - @@ -3668,9 +3703,9 @@ paths: For example, ?subjectTags=TAG1,TAG2 will only return subjects in the hierarchy with tags matching TAG1 and TAG2 (and all those subjects' ancestors up to the specified root of the - requested hierarchy). Prefix each of the tag names with a negative - sign to indicate that the subject having tags with those names will - be excluded. For example, ?subjectTags=-TAG3,-TAG4 will return the + requested hierarchy). Prefix each of the tag names with a negative + sign to indicate that the subject having tags with those names will + be excluded. For example, ?subjectTags=-TAG3,-TAG4 will return the subject hierarchy without subjects having tags - TAG3 and TAG4. type: string - diff --git a/package.json b/package.json index 7186e4c8c8..ebd6b87913 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "view": "view/**/*/app.js" }, "scripts": { + "cleanup": "npm run dropdb && npm run initdb && npm run checkdb", "build": "NODE_ENV=development && webpack --config ./webpack.config.js --progress --profile", "checkdb": "node db/createOrUpdateDb.js", "dropdb": "node db/createOrDropDb.js --drop", diff --git a/tests/api/v1/aspects/get.js b/tests/api/v1/aspects/get.js index 9802568f12..b52cef0bb4 100644 --- a/tests/api/v1/aspects/get.js +++ b/tests/api/v1/aspects/get.js @@ -18,6 +18,7 @@ const tu = require('../../../testUtils'); const u = require('./utils'); const Aspect = tu.db.Aspect; const path = '/v1/aspects'; +const expect = require('chai').expect; describe(`api: GET ${path}`, () => { let token; @@ -65,6 +66,17 @@ describe(`api: GET ${path}`, () => { describe('Single Values: ', () => { + it('filter by BOOLEAN returns expected values', (done) => { + api.get(path + '?valueType=PERCENT') // BOOLEAN is default + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.be.equal(1); + expect(res.body[0].valueType).to.be.equal('PERCENT'); + }) + .end((err /* , res */) => done(err)); + }); + it('key used twice in url', (done) => { api.get(`${path}?name=${tu.namePrefix}a0&description=foo&name=xyz`) .set('Authorization', token) @@ -102,17 +114,12 @@ describe(`api: GET ${path}`, () => { .set('Authorization', token) .expect(constants.httpStatus.OK) .expect((res) => { - if (!tu.gotArrayWithExpectedLength(res.body, 2)) { - throw new Error('expecting 2 aspects'); - } + expect(res.body.length).to.be.equal(2); + res.body.map((aspect) => { + expect(aspect.name.slice(0, 3)).to.equal(tu.namePrefix); + }); }) - .end((err /* , res */) => { - if (err) { - return done(err); - } - - done(); - }); + .end((err /* , res */) => done(err)); }); it('leading asterisk is treated as "ends with"', (done) => { @@ -231,17 +238,12 @@ describe(`api: GET ${path}`, () => { .set('Authorization', token) .expect(constants.httpStatus.OK) .expect((res) => { - if (!tu.gotArrayWithExpectedLength(res.body, 2)) { - throw new Error('expecting 2 aspects'); - } + expect(res.body.length).to.be.equal(2); + res.body.map((aspect) => { + expect(aspect.name).to.contain('a'); + }); }) - .end((err /* , res */) => { - if (err) { - return done(err); - } - - done(); - }); + .end((err /* , res */) => done(err)); }); }); // Lists }); diff --git a/tests/api/v1/samples/filter.js b/tests/api/v1/samples/filter.js new file mode 100644 index 0000000000..a7a2864752 --- /dev/null +++ b/tests/api/v1/samples/filter.js @@ -0,0 +1,215 @@ +/** + * Copyright (c) 2016, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or + * https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * tests/api/v1/samples/filter.js + */ + +'use strict'; + +const supertest = require('supertest'); +const api = supertest(require('../../../../index').app); +const constants = require('../../../../api/v1/constants'); +const tu = require('../../../testUtils'); +const u = require('./utils'); +const Sample = tu.db.Sample; +const path = '/v1/samples'; +const expect = require('chai').expect; + +describe('sample api: FILTER' + path, () => { + let sampleId; + let token; + let SPECIAL_SAMPLE_ID; + const THREE = '3'; + const ONE = '1'; + const MESSAGE_CODE_1 = '12345'; + + before((done) => { + tu.createToken() + .then((returnedToken) => { + token = returnedToken; + done(); + }) + .catch((err) => done(err)); + }); + + /** + * Sets up an object with aspect id, subject id + * + * @param {String} aspectName The name of the aspect + * @param {String} subjectName The name of the subject + * @returns {Object} contains aspect id, subject id + */ + function doSetup(aspectName, subjectName) { + const aspectToCreate = { + isPublished: true, + name: `${tu.namePrefix + aspectName}`, + timeout: '30s', + criticalRange: [3, 3], + valueType: 'NUMERIC', + }; + + const subjectToCreate = { + isPublished: true, + name: `${tu.namePrefix + subjectName}`, + }; + + return new tu.db.Sequelize.Promise((resolve, reject) => { + const samp = {}; + tu.db.Aspect.create(aspectToCreate) + .then((a) => { + samp.aspectId = a.id; + return tu.db.Subject.create(subjectToCreate); + }) + .then((s) => { + samp.subjectId = s.id; + resolve(samp); + }) + .catch((err) => reject(err)); + }); + } + + before((done) => { + doSetup('POTATO', 'COFFEE') + .then((obj) => { + obj.value = 111; + return Sample.create(obj); + }) + .then(() => doSetup('GELATO', 'COLUMBIA')) + .then((obj) => { + obj.value = THREE; + return Sample.create(obj); + }) + .then(() => doSetup('SPECIAL', 'UNIQUE')) + .then((obj) => { + obj.value = THREE; + obj.messageCode = MESSAGE_CODE_1; + return Sample.create(obj); + }) + .then((samp) => { // to test previousStatus + SPECIAL_SAMPLE_ID = samp.id; + return samp.update({ value: ONE }); + }) + .then(() => { // sample updated + done(); + }) + .catch((err) => done(err)); + }); + + after(u.forceDelete); + after(tu.forceDeleteUser); + + it('no asterisk is treated as "equals" for value', (done) => { + api.get(path + '?value=' + ONE) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + res.body.map((sample) => { + console.log('*** value is', sample.value) + }) + }) + .end((err /* , res */) => done(err)); + }); + + + it('no asterisk is treated as "equals" for name', (done) => { + const NAME = tu.namePrefix + 'COFFEE|' + tu.namePrefix + 'POTATO'; + api.get(path + '?name=' + NAME) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.equal(1); + expect(res.body[0].name).to.equal(NAME); + }) + .end((err /* , res */) => done(err)); + }); + + it('trailing asterisk is treated as "starts with"', (done) => { + api.get(path + '?name=' + tu.namePrefix + '*') + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.equal(3); + res.body.map((sample) => { + expect(sample.name.slice(0, 3)).to.equal(tu.namePrefix); + }) + }) + .end((err /* , res */) => done(err)); + }); + + it('leading asterisk is treated as "ends with"', (done) => { + api.get(path + '?name=*O') + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.equal(2); + res.body.map((sample) => { + expect(sample.name.slice(-2)).to.equal('TO'); + }); + }) + .end((err /* , res */) => done(err)); + }); + + it('leading and trailing asterisks are treated as "contains"', + (done) => { + api.get(path + '?name=*ATO*') + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + res.body.map((sample) => { + expect(sample.name).to.contain('ATO'); + }); + }) + .end((err /* , res */) => done(err)); + }); + + it('filter by value', (done) => { + api.get(path + '?value=' + ONE) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.equal(1); + expect(res.body[0].value).to.equal(ONE); + }) + .end((err /* , res */) => done(err)); + }); + + it('filter by messageCode.', (done) => { + api.get(path + '?messageCode=' + MESSAGE_CODE_1) + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.equal(1); + expect(res.body[0].messageCode).to.equal(MESSAGE_CODE_1); + }) + .end((err /* , res */) => done(err)); + }); + + it('filter by status', (done) => { + api.get(path + '?status=Critical') + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.equal(1); + expect(res.body[0].status).to.equal('Critical'); + }) + .end((err /* , res */) => done(err)); + }); + + it('filter by previousStatus', (done) => { + api.get(path + '?previousStatus=Critical') + .set('Authorization', token) + .expect(constants.httpStatus.OK) + .expect((res) => { + expect(res.body.length).to.equal(1); + expect(res.body[0].previousStatus).to.equal('Critical'); + expect(res.body[0].status).to.equal('Invalid'); + }) + .end((err /* , res */) => done(err)); + }); +});