From 3c8618dd48b69ac9c46db71875a7b4fce00bc327 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Thu, 7 Apr 2016 22:26:51 +0200 Subject: [PATCH 1/2] feat(parser): refactor array operator parsing Refactor operator parsing logic into a separate function which is reused for arrays and normal key value parsing. This also allows multiple entries of operators in addition to `$in` and `$nin` operators inside arrays. The query `?count=>10&count<100` will evaluate to the following: ``` { "count": { "$gt": 10, "$lt": 100, } } ``` BREAKING CHANGE: the new parser will not discriminate agains having both `$in` and `$nin` values for the same key - which is redundant - but still a valid query. Close #20 Signed-off-by: Hans Kristian Flaatten --- examples/test.js | 12 +++ index.js | 151 ++++++++++++++++++++++++------------ test.js | 197 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 276 insertions(+), 84 deletions(-) diff --git a/examples/test.js b/examples/test.js index 97f7bf8..100bd3f 100644 --- a/examples/test.js +++ b/examples/test.js @@ -148,4 +148,16 @@ describe('Example App', function() { }) .end(done); }); + + it('returns places with visits > 40 and < 10,000', function(done) { + app.get(url + '?visits=>40&visits=<10000') + .expect(200) + .expect(function(res) { + assert.equal(res.body.length, 3); + assert.equal(res.body[0].name, 'Norddalshytten'); + assert.equal(res.body[1].name, 'Vatnane'); + assert.equal(res.body[2].name, 'Selhamar'); + }) + .end(done); + }); }); diff --git a/index.js b/index.js index e6f7d82..51b791e 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ module.exports = function MongoQS(opts) { this.string.toNumber = opts.string.toNumber || true; this.keyRegex = opts.keyRegex || /^[a-zæøå0-9-_.]+$/i; + this.valRegex = opts.valRegex || /[^a-zæøå0-9-_.* ]/i; this.arrRegex = opts.arrRegex || /^[a-zæøå0-9-_.]+(\[\])?$/i; for (var param in this.custom) { @@ -117,12 +118,78 @@ module.exports.prototype.customAfter = function(field) { }; }; -module.exports.prototype.parseString = function(string) { +module.exports.prototype.parseString = function(string, array) { + var op = string[0] || ''; + var eq = string[1] === '='; + var org = string.substr(eq ? 2 : 1) || ''; + var val = this.parseStringVal(org); + + var ret = {op: op, org: org, value: val}; + + switch (op) { + case '!': + if (array) { + ret.field = '$nin'; + } else if (org === '') { + ret.field = '$exists'; + ret.value = false; + } else { + ret.field = '$ne'; + } + break; + case '>': + ret.field = eq ? '$gte' : '$gt'; + break; + case '<': + ret.field = eq ? '$lte' : '$lt'; + break; + case '^': + case '$': + case '~': + ret.field = '$regex'; + ret.options = 'i'; + ret.value = org.replace(this.valReqex, ''); + + switch (op) { + case '^': + ret.value = '^' + val; + break; + case '$': + ret.value = val + '$'; + break; + } + break; + default: + ret.org = org = op + org; + ret.op = op = ''; + ret.value = this.parseStringVal(org); + + if (array) { + ret.field = '$in'; + } else if (org === '') { + ret.field = '$exists'; + ret.value = true; + } else { + ret.field = '$eq'; + } + } + + ret.parsed = {}; + ret.parsed[ret.field] = ret.value; + + if (ret.options) { + ret.parsed.$options = ret.options; + } + + return ret; +}; + +module.exports.prototype.parseStringVal = function(string) { if (this.string.toBoolean && string.toLowerCase() === 'true') { return true; } else if (this.string.toBoolean && string.toLowerCase() === 'false') { return false; - } else if (this.string.toNumber && !isNaN(string)) { + } else if (this.string.toNumber && !isNaN(parseFloat(string, 10))) { return parseFloat(string, 10); } else { return string; @@ -130,7 +197,7 @@ module.exports.prototype.parseString = function(string) { }; module.exports.prototype.parse = function(query) { - var op, val; + var val; var res = {}; for (var key in query) { @@ -161,71 +228,55 @@ module.exports.prototype.parse = function(query) { if (this.ops.indexOf('$in') >= 0 && val.length > 0) { // remove [] at end of key name (unless it has already been removed) key = key.replace(/\[\]$/, ''); - - // $in query - if (val[0][0] !== '!') { - res[key] = {$in: val.filter(function(element) { - return element[0] !== '!'; - }).map(function(element) { - return this.parseString(element); - }.bind(this))}; - - // $nin query - } else { - res[key] = {$nin: val.filter(function(element) { - return element[0] === '!'; - }).map(function(element) { - return this.parseString(element.substr(1)); - }.bind(this))}; + res[key] = {}; + + for (var i = 0; i < val.length; i++) { + if (this.ops.indexOf(val[i][0]) >= 0) { + var parsed = this.parseString(val[i], true); + + switch (parsed.field) { + case '$in': + case '$nin': + res[key][parsed.field] = res[key][parsed.field] || []; + res[key][parsed.field].push(parsed.value); + break; + case '$regex': + res[key].$regex = parsed.value; + res[key].$options = parsed.options; + break; + default: + res[key][parsed.field] = parsed.value; + } + } else { + res[key].$in = res[key].$in || []; + res[key].$in.push(this.parseStringVal(val[i])); + } } } continue; } + // value must be a string if (typeof val !== 'string') { continue; } + // custom functions if (typeof this.custom[key] === 'function') { this.custom[key](res, val); + // field exists query } else if (!val) { res[key] = { $exists: true }; + // query operators } else if (this.ops.indexOf(val[0]) >= 0) { - op = val.charAt(0); - val = val.substr(1); - - res[key] = (function() { - var hasEqual = (val.charAt(0) === '='); - var output = parseFloat((hasEqual ? val.substr(1) : val), 10); - switch (op) { - case '!': - if (val) { - return { $ne: this.parseString(val) }; - } else { - return { $exists: false }; - } - break; - case '>': - return output ? hasEqual ? { $gte: output } : { $gt: output } : {}; - case '<': - return output ? hasEqual ? { $lte: output } : { $lt: output } : {}; - default: - val = val.replace(/[^a-zæøå0-9-_.* ]/i, ''); - switch (op) { - case '^': - return { $regex: '^' + val, $options: 'i' }; - case '$': - return { $regex: val + '$', $options: 'i' }; - default: - return { $regex: val, $options: 'i' }; - } - } - }.bind(this))(); + res[key] = this.parseString(val).parsed; + + // equal operator (no operator) } else { - res[key] = this.parseString(val); + res[key] = this.parseStringVal(val); } } return res; diff --git a/test.js b/test.js index 6352bf1..f8e2cba 100644 --- a/test.js +++ b/test.js @@ -100,44 +100,183 @@ describe('customAfter()', function() { }); }); -describe('parseString()', function() { +describe('parseStringVal()', function() { it('returns boolean true for "true" string', function() { - assert.equal(qs.parseString('true'), true); + assert.equal(qs.parseStringVal('true'), true); }); it('returns string "true" when boolean parsing is disabled', function() { qs.string.toBoolean = false; - assert.equal(qs.parseString('true'), 'true'); + assert.equal(qs.parseStringVal('true'), 'true'); }); it('returns boolean false for "flase" string', function() { - assert.equal(qs.parseString('false'), false); + assert.equal(qs.parseStringVal('false'), false); }); it('returns string "false" when boolean parsing is disabled', function() { qs.string.toBoolean = false; - assert.equal(qs.parseString('false'), 'false'); + assert.equal(qs.parseStringVal('false'), 'false'); }); it('returns number for parseable integer', function() { - assert.equal(qs.parseString('100'), 100); + assert.equal(qs.parseStringVal('100'), 100); }); it('returns string number when number parsing is disabled', function() { qs.string.toNumber = false; - assert.equal(qs.parseString('100'), '100'); + assert.equal(qs.parseStringVal('100'), '100'); }); it('returns number for zero padded parseable integer', function() { - assert.equal(qs.parseString('000100'), 100); + assert.equal(qs.parseStringVal('000100'), 100); }); it('returns number for parseable float', function() { - assert.equal(qs.parseString('10.123'), 10.123); + assert.equal(qs.parseStringVal('10.123'), 10.123); }); it('returns number for zero padded parseable float', function() { - assert.equal(qs.parseString('00010.123'), 10.123); + assert.equal(qs.parseStringVal('00010.123'), 10.123); + }); + + it('returns string for empty string', function() { + assert.equal(qs.parseStringVal(''), ''); + }); +}); + +describe('parseString()', function() { + it('returns $nin for "!" operator when array is true', function() { + assert.deepEqual(qs.parseString('!10', true), { + field: '$nin', + op: '!', + org: '10', + parsed: {$nin: 10}, + value: 10 + }); + }); + + it('returns $in for "" operator when array is true', function() { + assert.deepEqual(qs.parseString('10', true), { + field: '$in', + op: '', + org: '10', + parsed: {$in: 10}, + value: 10 + }); + }); + + it('returns $exists false for "!" operator when value is ""', function() { + assert.deepEqual(qs.parseString('!'), { + field: '$exists', + op: '!', + org: '', + parsed: {$exists: false}, + value: false + }); + }); + + it('returns $exists true for "" operator when value is ""', function() { + assert.deepEqual(qs.parseString(''), { + field: '$exists', + op: '', + org: '', + parsed: {$exists: true}, + value: true + }); + }); + + it('returns $ne for "!" operator', function() { + assert.deepEqual(qs.parseString('!10'), { + field: '$ne', + op: '!', + org: '10', + parsed: {$ne: 10}, + value: 10 + }); + }); + + it('returns $eq for "" operator', function() { + assert.deepEqual(qs.parseString('10'), { + field: '$eq', + op: '', + org: '10', + parsed: {$eq: 10}, + value: 10 + }); + }); + + it('returns $gt for ">" operator', function() { + assert.deepEqual(qs.parseString('>10'), { + field: '$gt', + op: '>', + org: '10', + parsed: {$gt: 10}, + value: 10 + }); + }); + + it('returns $gte for ">=" operator', function() { + assert.deepEqual(qs.parseString('>=10'), { + field: '$gte', + op: '>', + org: '10', + parsed: {$gte: 10}, + value: 10 + }); + }); + + it('returns $lt for "<" operator', function() { + assert.deepEqual(qs.parseString('<10'), { + field: '$lt', + op: '<', + org: '10', + parsed: {$lt: 10}, + value: 10 + }); + }); + + it('returns $lte for "<=" operator', function() { + assert.deepEqual(qs.parseString('<=10'), { + field: '$lte', + op: '<', + org: '10', + parsed: {$lte: 10}, + value: 10 + }); + }); + + it('returns $regex for "^" operator', function() { + assert.deepEqual(qs.parseString('^10'), { + field: '$regex', + op: '^', + options: 'i', + org: '10', + parsed: {$options: 'i', $regex: '^10'}, + value: '^10' + }); + }); + + it('returns $regex for "$" operator', function() { + assert.deepEqual(qs.parseString('$10'), { + field: '$regex', + op: '$', + options: 'i', + org: '10', + parsed: {$options: 'i', $regex: '10$'}, + value: '10$' + }); + }); + + it('returns $regex for "~" operator', function() { + assert.deepEqual(qs.parseString('~10'), { + field: '$regex', + op: '~', + options: 'i', + org: '10', + parsed: {$options: 'i', $regex: '10'}, + value: '10' + }); }); }); @@ -311,20 +450,6 @@ describe('parse()', function() { }); }); - describe('>= operator', function() { - it('returns greater than or equal to query', function() { - query = qs.parse({ - navn: '>=10.110' - }); - assert.deepEqual(query, { - navn: { - $gte: 10.110 - } - }); - return assert.strictEqual(query.navn.$gte, 10.110); - }); - }); - describe('< operator', function() { it('returns less than query', function() { query = qs.parse({ @@ -353,17 +478,19 @@ describe('parse()', function() { }); }); - describe('<= operator', function() { - it('returns less than query or equal to', function() { + describe('multiple <, <=, >, >= operators', function() { + it('returns multiple comparison operators for same field', function() { query = qs.parse({ - navn: '<=10.110' + count: ['>0.123', '>=1.234', '<2.345', '<=3.456'] }); assert.deepEqual(query, { - navn: { - $lte: 10.110 + count: { + $gt: 0.123, + $gte: 1.234, + $lt: 2.345, + $lte: 3.456 } }); - assert.strictEqual(query.navn.$lte, 10.110); }); }); @@ -429,13 +556,14 @@ describe('parse()', function() { }); }); - it('returns in array without any not in array query', function() { + it('returns in array with any not in array query', function() { var string = 'foo[]=10&foo[]=!10.011&foo[]=!bar&foo[]=baz'; var params = require('querystring').parse(string); assert.deepEqual(qs.parse(params), { foo: { - $in: [10, 'baz'] + $in: [10, 'baz'], + $nin: [10.011, 'bar'] } }); }); @@ -463,13 +591,14 @@ describe('parse()', function() { }); - it('returns not in array without any in array query', function() { + it('returns not in array with any in array query', function() { var string = 'foo[]=!10&foo[]=10.011&foo[]=bar&foo[]=!baz'; var params = require('querystring').parse(string); assert.deepEqual(qs.parse(params), { foo: { - $nin: [10, 'baz'] + $nin: [10, 'baz'], + $in: [10.011, 'bar'] } }); }); From 4f9f5c81c82bb416714a1b27104d99c7cbaae07f Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Thu, 7 Apr 2016 23:05:54 +0200 Subject: [PATCH 2/2] chore(package): use correct link to project readme Signed-off-by: Hans Kristian Flaatten --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f5e8437..2c5406e 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "bugs": { "url": "https://github.com/Turistforeningen/node-mongo-querystring/issues" }, - "homepage": "https://github.com/Turistforeningen/node-mongo-querystring", + "homepage": "https://github.com/Turistforeningen/node-mongo-querystring#readme", "devDependencies": { "JSONStream": "^1.1.1", "express": "^4.13.4",