├── .circleci └── config.yml ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ └── dependabot.yml ├── .gitignore ├── .snyk ├── LICENSE ├── README.md ├── index.js ├── lib ├── index.js ├── logger.js ├── parseQuery.js └── tools.js ├── package-lock.json ├── package.json └── test ├── .eslintrc.json └── tests.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | workflows: 4 | version: 2 5 | test-publish: 6 | jobs: 7 | - test-node12: 8 | filters: # required since `deploy` has tag filters AND requires `build` 9 | tags: 10 | only: /.*/ 11 | - test-node14: 12 | filters: # required since `deploy` has tag filters AND requires `build` 13 | tags: 14 | only: /.*/ 15 | - test-node16: 16 | filters: # required since `deploy` has tag filters AND requires `build` 17 | tags: 18 | only: /.*/ 19 | - test-node-latest: 20 | filters: # required since `deploy` has tag filters AND requires `build` 21 | tags: 22 | only: /.*/ 23 | - publish: 24 | requires: 25 | - test-node12 26 | - test-node14 27 | - test-node16 28 | - test-node-latest 29 | filters: 30 | tags: 31 | only: /^v.*/ 32 | branches: 33 | ignore: /.*/ 34 | 35 | 36 | defaults: &defaults 37 | working_directory: ~/repo 38 | docker: 39 | - image: circleci/node:12 40 | steps: 41 | - checkout 42 | - run: node --version > _tmp_file 43 | - restore_cache: 44 | key: dependency-cache-{{ checksum "_tmp_file" }}-{{ checksum "package.json" }} 45 | - run: 46 | name: npm-install 47 | command: npm install 48 | 49 | - save_cache: 50 | key: dependency-cache-{{ checksum "_tmp_file" }}-{{ checksum "package.json" }} 51 | paths: 52 | - ./node_modules 53 | - run: 54 | name: test 55 | command: npm test 56 | environment: 57 | REPORTER: mocha-circleci-reporter 58 | MOCHA_FILE: junit/test-results.xml 59 | #- run: 60 | # name: coveralls 61 | # command: cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 62 | - run: 63 | name: lint 64 | command: npm run lint 65 | when: always 66 | - store_test_results: 67 | path: junit 68 | - store_artifacts: 69 | path: junit 70 | #- store_artifacts: 71 | # path: coverage 72 | # prefix: coverage 73 | #- store_test_results: 74 | # path: coverage/coverage.json 75 | jobs: 76 | test-node12: 77 | <<: *defaults 78 | docker: 79 | - image: circleci/node:12-browsers 80 | environment: 81 | CHROME_BIN: "/usr/bin/google-chrome" 82 | - image: mongo:4.1.2 83 | test-node14: 84 | <<: *defaults 85 | docker: 86 | - image: circleci/node:14-browsers 87 | environment: 88 | CHROME_BIN: "/usr/bin/google-chrome" 89 | - image: mongo:4.1.2 90 | test-node16: 91 | <<: *defaults 92 | docker: 93 | - image: circleci/node:16-browsers 94 | environment: 95 | CHROME_BIN: "/usr/bin/google-chrome" 96 | - image: mongo:4.1.2 97 | test-node-latest: 98 | <<: *defaults 99 | docker: 100 | - image: circleci/node:latest-browsers 101 | environment: 102 | CHROME_BIN: "/usr/bin/google-chrome" 103 | - image: mongo:4.1.2 104 | publish: 105 | <<: *defaults 106 | steps: 107 | - checkout 108 | - run: node --version > _tmp_file 109 | - restore_cache: 110 | key: dependency-cache-{{ checksum "_tmp_file" }}-{{ checksum "package.json" }} 111 | - run: 112 | name: install dependencies 113 | command: npm ci 114 | - run: 115 | name: Authenticate with registry 116 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 117 | - run: 118 | name: Publish package 119 | command: npm publish 120 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "es6": true, 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "plugins": [ 9 | "import", 10 | "node", 11 | "promise" 12 | ], 13 | "rules": { 14 | "comma-dangle": [ 15 | "error", 16 | "never" 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: auto-merge 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | auto-merge: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 12 | with: 13 | target: minor 14 | github-token: ${{ secrets.PUSH_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.14.1 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-LODASH-567746: 7 | - lodash: 8 | patched: '2020-05-01T02:53:17.461Z' 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mongoose-query 2 | [![Build Status](https://travis-ci.org/jupe/mongoose-query.png?branch=master)](https://travis-ci.org/jupe/mongoose-query) 3 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 4 | [![License badge](https://img.shields.io/badge/license-MIT-blue.svg)](https://img.shields.io) 5 | [![release](http://github-release-version.herokuapp.com/github/jupe/mongoose-query/release.svg?style=flat)](https://github.com/jupe/mongoose-query/releases/latest) 6 | [![npm version](https://badge.fury.io/js/mongoose-query.svg)](https://badge.fury.io/js/mongoose-query) 7 | [![Stars badge](https://img.shields.io/github/stars/jupe/mongoose-query.svg)](https://img.shields.io) 8 | 9 | This library is usefull example with `expressjs` + `mongoose` applications to help construct mongoose query model directly from url parameters. Example: 10 | 11 | ``` 12 | http://localhost/model?q={"group":"users"}&f=name&sk=1&l=5&p=name 13 | ``` 14 | Converted to: 15 | ``` 16 | model.find({group: "users"}).select("name").skip(1).limit(5).populate('name') 17 | ``` 18 | 19 | ### Tested with node.js versions 20 | 12.x, 14.x, 16.x,latest release 21 | 22 | ## Changes log 23 | 24 | See [releases page](https://github.com/jupe/mongoose-query/releases). 25 | 26 | ## Installation 27 | 28 | Use [npm](https://www.npmjs.org/package/mongoose-query): 29 | ``` 30 | npm install mongoose-query 31 | ``` 32 | 33 | ## Test 34 | ``` 35 | npm test && npm run lint 36 | ``` 37 | 38 | ## Usage Example 39 | 40 | ``` 41 | var QueryPlugin = require(mongoose-query); 42 | var TestSchema = new mongoose.Schema({}); 43 | TestSchema.plugin(QueryPlugin); 44 | var testmodel = mongoose.model('test', TestSchema); 45 | 46 | //express route 47 | module.exports = function query(req, res) { 48 | testmodel.query(req.query, function(error, data){ 49 | res.json(error?{error: error}:data); 50 | }); 51 | } 52 | ``` 53 | 54 | ## doc 55 | 56 | ``` 57 | var QueryPlugin = require(mongoose-query); 58 | schema.plugin( QueryPlugin(, ) ) 59 | ``` 60 | optional `options`: 61 | * `logger`: custom logger, e.g. winston logger, default: "dummy logger" 62 | * `includeAllParams`: Parse also other values. e.g. `?name=me`. default: true 63 | * `ignoreKeys` : keys to be ignored. Default: [] 64 | 65 | Model static methods: 66 | 67 | `model.query( (, ) )` 68 | 69 | `model.leanQuery((, ) )`: gives plain objects ([lean](http://mongoosejs.com/docs/api.html#query_Query-lean)) 70 | 71 | **Note:** without `` you get Promise. 72 | 73 | **URL API:** 74 | ``` 75 | http://www.myserver.com/query?[q=][&t=][&f=][&s=][&sk=] 76 | [&l=][&p=][&fl=][&map=][&reduce=] 77 | 78 | q= restrict results by the specified JSON query 79 | regex e.g. q='{"field":{"$regex":"/mygrep/", "$options":"i"}}' 80 | t= find|findOne|count|estimateCount|aggregate|distinct|aggregate|mapReduce 81 | f= specify the set of fields to include or exclude in each document 82 | (1 - include; 0 - exclude) 83 | s= specify the order in which to sort each specified field 84 | (1- ascending; -1 - descending), JSON 85 | sk= specify the number of results to skip in the result set; 86 | useful for paging 87 | l= specify the limit for the number of results (default is 1000) 88 | p= specify the fields for populate, also more complex json object is supported. 89 | x= allowed values: 'queryPlanner', 'executionStats', 'allPlansExecution' 90 | to= timeout for query 91 | d= allowDiskUse (for heavy queries) 92 | map= mongodb map function as string 93 | http://docs.mongodb.org/manual/reference/command/mapReduce/#mapreduce-map-cmd' 94 | e.g. "function(){if (this.status == 'A') emit(this.cust_id, 1);)}" 95 | reduce= mongodb reduce function as string 96 | http://docs.mongodb.org/manual/reference/command/mapReduce/#mapreduce-reduce-cmd 97 | e.g. "function(key, values) {return result;}" 98 | fl= Flat results or not 99 | 100 | Special values: 101 | "oid:" string is converted to ObjectId 102 | "/regex/(options)" converted to regex 103 | { $regex: //, regex match with optional regex options 104 | ($options: "") } 105 | 106 | Alternative search conditions: 107 | "key={i}a" case insensitive 108 | "key={e}a" ends with a 109 | "key={ei}a" ends with a, case insensitive 110 | "key={b}a" begins with a 111 | "key={bi}a" begins with a, case insensitive 112 | "key={in}a,b" At least one of these is in array 113 | "key={nin}a,b" Any of these values is not in array 114 | "key={all}a,b" All of these contains in array 115 | "key={empty}" Field is empty or not exists 116 | "key={!empty}" Field exists and is not empty 117 | "key={mod}a,b" Docs where key mod a is b 118 | "key={gt}a" value is greater than a 119 | "key={gte}a" value is greater or equal than a 120 | "key={lt}a" value is lower than a 121 | "key={lte}a" value is lower or equal 122 | "key={ne}a" value is not equal 123 | "key={size}a" value is array, and array size is a 124 | "key={sort_by}" sort by asc 125 | "key={sort_by}-1" sort by desc 126 | 127 | **References to mongo:** 128 | - [elemMatch](https://docs.mongodb.com/manual/reference/operator/query/elemMatch/) 129 | - [size](https://docs.mongodb.com/manual/reference/operator/query/size/) 130 | ``` 131 | Results with `fl=false`: 132 | ``` 133 | [ 134 | { 135 | nest: { 136 | ed: { 137 | data: 'value', 138 | data2':'value' 139 | } 140 | } 141 | } 142 | ] 143 | ``` 144 | 145 | Results with `fl=true`: 146 | ``` 147 | [ 148 | {'nest.ed.data': 'value', 149 | 'nest.ed.data2':'value'}, 150 | ] 151 | ``` 152 | 153 | 154 | #### Date 155 | 156 | Allowed date formats: 157 | - `2010/10/1` (y/m/d) 158 | - `31/2/2010` (d/m/y) 159 | - `2011-10-10T14:48:00` (ISO 8601) 160 | 161 | **Note:** 162 | Only valid format is ISO when using date inside `q` -parameter. 163 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | MONGOOSE QUERY GENERATOR FROM HTTP URL 3 | e.g. 4 | let QueryPlugin = require(mongoose-query); 5 | schema.plugin(QueryPlugin); 6 | mymodel.query(req.query, function(error, data){ 7 | }); 8 | 9 | */ 10 | const { flatten } = require('flat'); 11 | const _ = require('lodash'); 12 | const parseQuery = require('./parseQuery'); 13 | const logger = require('./logger'); 14 | 15 | function QueryPlugin(schema, options = {}) { 16 | _.defaults(options, {}); 17 | if (_.has(options, 'logger')) { 18 | logger.setLogger(logger); 19 | } 20 | // eslint-disable-next-line no-param-reassign 21 | options = _.omit(options, ['logger', 'lean']); 22 | const leanOptions = _.defaults({ lean: true }, options); 23 | 24 | const tmp = {}; 25 | function doQuery(data, opt, callback) { 26 | logger.debug('doQuery:', data, opt, callback); 27 | const q = parseQuery(data, opt); 28 | logger.debug('q:', JSON.stringify(q)); 29 | let query; 30 | switch (q.t) { 31 | case ('find'): 32 | case ('findOne'): 33 | query = this.find(q.q); 34 | logger.debug('find:', q.q); 35 | break; 36 | case ('count'): 37 | if (!callback) { 38 | return this.countDocuments(q.q).exec() 39 | .then(count => ({ count })); 40 | } 41 | return this.countDocuments(q.q, (error, count) => { 42 | if (error) callback(error); 43 | else callback(error, { count }); 44 | }); 45 | case ('estimateCount'): 46 | if (!callback) { 47 | return this.estimatedDocumentCount().exec() 48 | .then(count => ({ count })); 49 | } 50 | return this.estimatedDocumentCount((error, count) => { 51 | if (error) callback(error); 52 | else callback(error, { count }); 53 | }); 54 | case ('distinct'): 55 | query = this.distinct(q.f, q.q, callback); 56 | if (q.options.maxTimeMS) query = query.maxTimeMS(q.options.maxTimeMS); 57 | return query; 58 | case ('aggregate'): 59 | return this.aggregate(q.q).option(q.options).exec(callback); 60 | case ('mapReduce'): 61 | try { 62 | tmp.map = q.map; 63 | tmp.reduce = q.reduce; 64 | tmp.limit = q.l; 65 | tmp.query = q.q; 66 | if (q.scope) tmp.scope = JSON.parse(decodeURIComponent(q.scope)); 67 | if (q.finalize) tmp.finalize = decodeURIComponent(q.finalize); 68 | logger.debug('mapReduce:', tmp); 69 | return this.mapReduce(tmp, callback); 70 | } catch (e) { 71 | return callback ? callback(e) : Promise.reject(e); 72 | } 73 | 74 | default: 75 | logger.error('not supported query type'); 76 | return {}; 77 | } 78 | 79 | if (['find', 'findOne'].indexOf(q.t) >= 0) { 80 | if (q.s) query = query.sort(q.s); 81 | if (q.sk) query = query.skip(q.sk); 82 | if (q.l) query = query.limit(q.l); 83 | if (q.f) query = query.select(q.f); 84 | if (q.p) query = query.populate(q.p); 85 | if (opt.lean) query = query.lean(); 86 | if (q.options.maxTimeMS) query = query.maxTimeMS(q.options.maxTimeMS); 87 | if (q.options.explain) query = query.explain(q.options.explain); 88 | if (q.t === 'findOne') { 89 | if (q.fl) { 90 | if (callback) { 91 | query.findOne((error, doc) => { 92 | if (error) callback(error); 93 | else callback(error, flatten(doc)); 94 | }); 95 | } else { 96 | return new Promise((resolve, reject) => { 97 | query.findOne((error, doc) => { 98 | if (error) reject(error); 99 | else resolve(flatten(doc)); 100 | }); 101 | }); 102 | } 103 | } else { 104 | return query.findOne(callback); 105 | } 106 | } else if (q.fl) { 107 | if (callback) { 108 | query.find((error, docs) => { 109 | if (error) { 110 | return callback(error); 111 | } 112 | 113 | const arr = []; 114 | docs.forEach((doc) => { 115 | const json = opt.lean ? doc : doc.toJSON({ virtuals: true }); 116 | arr.push(flatten(json)); 117 | }); 118 | return callback(error, arr); 119 | }); 120 | } else { 121 | return new Promise((resolve, reject) => { 122 | query.find((error, docs) => { 123 | if (error) { 124 | return reject(error); 125 | } 126 | const arr = []; 127 | docs.forEach((doc) => { 128 | const json = opt.lean ? doc : doc.toJSON({ virtuals: true }); 129 | arr.push(flatten(json)); 130 | }); 131 | return resolve(arr); 132 | }); 133 | }); 134 | } 135 | } else { 136 | logger.debug('find..', callback); 137 | return query.exec(callback); 138 | } 139 | } 140 | return undefined; 141 | } 142 | function defaultQuery(data, callback) { 143 | return doQuery.bind(this)(data, options, callback); 144 | } 145 | // eslint-disable-next-line no-param-reassign 146 | schema.query.query = defaultQuery; 147 | schema.static('query', defaultQuery); 148 | 149 | function leanQuery(data, callback) { 150 | return doQuery.bind(this)(data, leanOptions, callback); 151 | } 152 | // eslint-disable-next-line no-param-reassign 153 | schema.query.leanQuery = leanQuery; 154 | schema.static('leanQuery', leanQuery); 155 | } 156 | 157 | QueryPlugin.parseQuery = parseQuery; 158 | 159 | module.exports = QueryPlugin; 160 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | const dummy = () => {}; 2 | class Logger { 3 | constructor() { 4 | this.logger = this.getDummyLogger(); 5 | } 6 | 7 | // eslint-disable-next-line class-methods-use-this 8 | getDummyLogger() { 9 | return { 10 | silly: dummy, 11 | warn: dummy, 12 | error: dummy, 13 | info: dummy, 14 | debug: dummy 15 | }; 16 | } 17 | 18 | get silly() { return this.logger.silly; } 19 | 20 | get warn() { return this.logger.warn; } 21 | 22 | get error() { return this.logger.error; } 23 | 24 | get info() { return this.logger.info; } 25 | 26 | get debug() { return this.logger.debug; } 27 | 28 | setLogger(logger) { 29 | this.logger = logger; 30 | } 31 | } 32 | module.exports = new Logger(); 33 | -------------------------------------------------------------------------------- /lib/parseQuery.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const { Types } = require('mongoose'); 3 | // module tools 4 | const tools = require('./tools'); 5 | 6 | const { 7 | toBool, toNumber, escapeRegExp, isObjectID, parseDateCustom 8 | } = tools; 9 | 10 | function parseQuery(query, options = {}) { 11 | /** 12 | 13 | reserved keys: q,t,f,s,sk,l,p,to,x,d 14 | 15 | [q=][&t=][&f=][&s=][&sk=][&l=][&p=] 16 | q= - restrict results by the specified JSON query 17 | t= - find|findOne|count|aggregate|distinct.. 18 | f= - specify the set of fields to include or exclude in 19 | each document (1 - include; 0 - exclude) 20 | s= - specify the order in which to sort each specified 21 | field (1- ascending; -1 - descending) 22 | sk= - specify the number of results to skip in 23 | the result set; useful for paging 24 | l= - specify the limit for the number of results (default is 1000) 25 | p= - specify the fields for populate 26 | to= - timeout for query 27 | x= - explain query 28 | d= - allow disk usage for query 29 | 30 | alternative search conditions: 31 | "key={in}a,b" 32 | "At least one of these is in array" 33 | 34 | "key={nin}a,b" 35 | "Any of these values is not in array" 36 | 37 | "key={all}a,b" 38 | "All of these contains in array" 39 | 40 | "key={empty}-" 41 | "Field is empty or not exists" 42 | 43 | "key={!empty}-" 44 | "Field exists and is not empty" 45 | 46 | "key={mod}a,b" 47 | "Docs where key mod a is b" 48 | 49 | "key={gt}a" 50 | "Docs key is greater than a" 51 | 52 | "key={lt}a" 53 | "Docs key is lower than a" 54 | 55 | "key=a|b|c" 56 | "Docs where key type is Array, contains at least one of given value 57 | */ 58 | _.defaults(options, { includeAllParams: true }); 59 | 60 | const qy = { 61 | q: {}, // query 62 | map: '', 63 | reduce: '', 64 | t: 'find', // query type 65 | f: false, // fields 66 | s: false, // sort 67 | sk: false, // skip 68 | l: 1000, // limit 69 | p: false, // populate 70 | fl: false, // flat 71 | options: { 72 | // maxTimeMS, .. 73 | } 74 | }; 75 | 76 | const arrayOperators = ['in', 'nin', 'all', 'mod']; 77 | 78 | function addCondition(key, val) { 79 | if (['$or', '$nor', '$and'].indexOf(key) !== -1) { 80 | if (!_.has(qy.q, key)) { 81 | qy.q[key] = []; 82 | } 83 | qy.q[key].push(val); 84 | } else { 85 | qy.q[key] = val; 86 | } 87 | } 88 | 89 | function parseParam(key, val) { 90 | const lcKey = key; 91 | let operator = false; 92 | if (typeof val === 'string') { 93 | operator = val.match(/\{(.*)\}/); 94 | if (operator) { 95 | // eslint-disable-next-line no-param-reassign 96 | val = val.replace(/\{(.*)\}/, ''); 97 | [, operator] = operator; 98 | } 99 | 100 | if (/^\/.*\/\S*?$/.test(val)) { 101 | const matches = val.match(/\/(.*)\/(\S*)?$/); 102 | // eslint-disable-next-line no-param-reassign 103 | val = new RegExp(matches[1], matches[2]); 104 | } 105 | 106 | // there is problem when/if value is valid objectid but it 107 | // should be handled as a number, e.g. "123456789012345678901234" 108 | // anyway - collision propability is quite low. 109 | if (!isObjectID(val)) { 110 | const num = toNumber(val); 111 | if (!Number.isNaN(num)) { 112 | // eslint-disable-next-line no-param-reassign 113 | val = num; 114 | } else { 115 | const date = parseDateCustom(val); 116 | if (!Number.isNaN(date) 117 | && operator !== 'size' 118 | && arrayOperators.indexOf(operator) === -1 119 | && ['i', 'e', 'b', 'm'].indexOf(operator) === -1) { 120 | // eslint-disable-next-line no-param-reassign 121 | val = date; 122 | } 123 | } 124 | } 125 | } 126 | if (key.startsWith('$')) { 127 | // bypass $ characters for security reasons 128 | throw new Error(`$ is not allowed to be beginning of the key (${key})`); 129 | } 130 | if (val === '' && operator !== 'empty' && operator !== '!empty') { 131 | // do nothing 132 | } else if (lcKey === 'sort_by') { 133 | if (!_.isString(val) && val.length > 0) { 134 | throw new Error('sort_by value should contains string'); 135 | } 136 | const parts = val.match('(.*),([-1]{1})?'); 137 | if (parts) { 138 | qy.s = {}; 139 | qy.s[parts[1]] = parts.length > 2 ? parseInt(parts[2], 10) : 1; 140 | } else { 141 | throw new Error('invalid sort_by value'); 142 | } 143 | } else if (toBool(val) !== -1) { 144 | const b = toBool(val); 145 | if (b === false) { 146 | const orCond = {}; 147 | orCond[lcKey] = { $exists: false }; 148 | qy.q.$or = []; 149 | qy.q.$or.push(orCond); 150 | orCond[lcKey] = b; 151 | qy.q.$or.push(orCond); 152 | } else addCondition(lcKey, b); 153 | } else if (['gt', 'gte', 154 | 'lt', 'lte', 155 | 'in', 'nin', 'ne', 156 | 'size'].indexOf(operator) !== -1) { 157 | const tmp = {}; 158 | if (arrayOperators.indexOf(operator) !== -1) { 159 | // eslint-disable-next-line no-param-reassign 160 | val = val.split(','); 161 | } 162 | tmp[`$${operator}`] = val; 163 | addCondition(lcKey, tmp); 164 | } else if (operator === 'i') { 165 | // key={i} 166 | addCondition(lcKey, new RegExp(`^${escapeRegExp(val)}$`, 'i')); // http://scriptular.com/ 167 | } else if (operator === 'e') { 168 | // key={e} 169 | addCondition(lcKey, new RegExp(`${escapeRegExp(val)}$`)); 170 | } else if (operator === 'b') { 171 | addCondition(lcKey, new RegExp(`^${escapeRegExp(val)}`)); 172 | } else if (operator === 'ei') { 173 | addCondition(lcKey, new RegExp(`${escapeRegExp(val)}$`, 'i')); 174 | } else if (operator === 'bi') { 175 | addCondition(lcKey, new RegExp(`^${escapeRegExp(val)}`, 'i')); 176 | } else if (operator === 'm') { 177 | // key={m}, 178 | if (_.isString(val)) { 179 | const match = val.match(/(.*),(.*)/); 180 | if (match) { 181 | qy.q[key] = {}; 182 | qy.q[key].$elemMatch = {}; 183 | // eslint-disable-next-line prefer-destructuring 184 | qy.q[key].$elemMatch[match[1]] = match[2]; 185 | } 186 | } 187 | } else if (operator === 'empty') { 188 | const empty = {}; 189 | empty[lcKey] = ''; 190 | const unexists = {}; 191 | unexists[lcKey] = { $exists: false }; 192 | addCondition('$or', empty); 193 | addCondition('$or', unexists); 194 | } else if (operator === '!empty') { 195 | const empty = {}; 196 | empty[lcKey] = ''; 197 | const unexists = {}; 198 | unexists[lcKey] = { $exists: false }; 199 | addCondition('$nor', empty); 200 | addCondition('$nor', unexists); 201 | } else if (operator === 'c') { 202 | const tmp = val.match(/(.*)\/(.*)/); 203 | if (tmp) { 204 | const regexp = new RegExp(escapeRegExp(tmp[1]), escapeRegExp(tmp[2])); 205 | addCondition(lcKey, regexp); 206 | } else { 207 | throw new Error('Invalid options for operator "c"'); 208 | } 209 | } else { 210 | if ((options.ignoreKeys === true) 211 | || (Array.isArray(options.ignoreKeys) 212 | && (options.ignoreKeys.indexOf(key) !== -1))) { 213 | return; 214 | } 215 | if (_.isString(val) 216 | && /.*\|.*/.test(val)) { 217 | const parts = val.split('|'); 218 | for (let i = 0; i < parts.length; i += 1) { 219 | const tmp = {}; 220 | tmp[lcKey] = parts[i]; 221 | addCondition('$or', tmp); 222 | } 223 | } else { 224 | addCondition(lcKey, val); 225 | } 226 | } 227 | } 228 | 229 | /** 230 | * Internal function to convert plain json 231 | * values which cannot represent in json api like: 232 | * RexExp, date and 233 | * @param value 234 | * @param key 235 | * @param obj 236 | */ 237 | function jsonConverter(value, key, obj) { 238 | if (value !== null && typeof value === 'object') { 239 | // Recurse into children 240 | _.each(value, jsonConverter); 241 | } else if (typeof value === 'string') { 242 | // handle special cases (regex/number/date) 243 | if (key === '$regex') { 244 | const m = value.match(/\/(.*)\//); 245 | if (m) { 246 | if (obj.$options) { 247 | m[2] = obj.$options; 248 | // eslint-disable-next-line no-param-reassign 249 | delete obj.$options; 250 | } 251 | // eslint-disable-next-line no-param-reassign 252 | obj[key] = new RegExp(m[1], m[2]); 253 | } 254 | } else if (key === '$search') { // handle special case text search 255 | // eslint-disable-next-line no-param-reassign 256 | obj[key] = value; 257 | } else if (/^oid:[a-fA-F0-9]{24}$/.test(value)) { 258 | // eslint-disable-next-line prefer-destructuring 259 | const objId = value.split(':')[1]; 260 | // eslint-disable-next-line no-param-reassign 261 | obj[key] = new Types.ObjectId(objId); 262 | } else { 263 | // check if date to convert it 264 | const date = parseDateCustom(value); 265 | if (!Number.isNaN(date)) { 266 | // eslint-disable-next-line no-param-reassign 267 | obj[key] = date; 268 | } else { 269 | // eslint-disable-next-line no-param-reassign 270 | obj[key] = value; 271 | } 272 | } 273 | } 274 | } 275 | 276 | // eslint-disable-next-line guard-for-in, no-restricted-syntax 277 | for (const key in query) { 278 | const value = decodeURIComponent(query[key]); 279 | const someOf = function someOf(...args) { 280 | return args.indexOf(key) !== -1; 281 | }; 282 | if (key === 'q') { 283 | qy.q = _.isString(value) ? JSON.parse(value) : value; 284 | _.each(qy.q, jsonConverter); 285 | } else if (key === 't') { 286 | qy.t = value; 287 | } else if (someOf('f', 'select')) { 288 | qy.f = query[key]; 289 | } else if (someOf('s', 'sort')) { 290 | qy.s = JSON.parse(value); 291 | } else if (someOf('sk', 'skip', 'skips')) { 292 | qy.sk = parseInt(value, 10); 293 | } else if (someOf('l', 'limit')) { 294 | qy.l = parseInt(value, 10); 295 | } else if (someOf('to', 'timeout')) { 296 | qy.options.maxTimeMS = parseInt(value, 10); 297 | } else if (someOf('x', 'explain')) { 298 | const allowedExplains = _.reduce(['queryPlanner', 'executionStats', 'allPlansExecution'], 299 | (acc, explain) => { 300 | acc[explain] = explain; 301 | return acc; 302 | }, {}); 303 | const explain = _.get(allowedExplains, value, 'queryPlanner'); 304 | qy.options.explain = explain; 305 | } else if (someOf('d', 'allowDiskUse')) { 306 | qy.options.allowDiskUse = toBool(value); 307 | } else if (someOf('p')) { 308 | if (typeof value === 'string') { 309 | if (/^{.*}$/.test(value) 310 | || /^\[.*\]$/.test(value)) { 311 | // eslint-disable-next-line no-param-reassign 312 | query[key] = JSON.parse(value); 313 | } else if (value.indexOf(',') !== -1) { 314 | // eslint-disable-next-line no-param-reassign 315 | query[key] = value.split(','); 316 | } 317 | } 318 | qy.p = query[key]; 319 | } else if (key === 'map') { 320 | qy.map = value; 321 | } else if (key === 'reduce') { 322 | qy.reduce = value; 323 | } else if (key === 'fl') { 324 | qy.fl = toBool(value); 325 | } else if (options.includeAllParams) { 326 | const obj = {}; 327 | jsonConverter(value, key, obj); 328 | parseParam(key, obj[key]); 329 | } 330 | } 331 | return qy; 332 | } 333 | 334 | module.exports = parseQuery; 335 | -------------------------------------------------------------------------------- /lib/tools.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const moment = require('moment'); 3 | 4 | module.exports.toBool = function toBool(str) { 5 | if (_.isUndefined(str)) { 6 | return true; 7 | } 8 | if (!_.isString(str)) { 9 | return -1; 10 | } 11 | if (str.toLowerCase() === 'true' 12 | || str.toLowerCase() === 'yes') { 13 | return true; 14 | } if (str.toLowerCase() === 'false' 15 | || str.toLowerCase() === 'no') { 16 | return false; 17 | } 18 | return -1; 19 | }; 20 | 21 | module.exports.escapeRegExp = function escapeRegExp(str) { 22 | return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); 23 | }; 24 | 25 | 26 | module.exports.isObjectID = function isObjectID(str) { 27 | return /^[a-f\d]{24}$/i.test(str); 28 | }; 29 | /** 30 | * Convert string to numbers 31 | * @param {String}srt string to be converted 32 | * @return {Number|NaN} number of NaN if not valid number 33 | */ 34 | module.exports.toNumber = function toNumber(str) { 35 | if (str === '') return NaN; 36 | const num = Number(str); 37 | if (Number.isNaN(num)) { 38 | return NaN; 39 | } 40 | return num; 41 | }; 42 | 43 | function parseDateFormat1(str) { 44 | // 31/2/2010 45 | const m = str.match(/^(\d{1,2})[/\s.\-,](\d{1,2})[/\s.\-,](\d{4})$/); 46 | return (m) ? new Date(m[3], m[2] - 1, m[1]) : NaN; 47 | } 48 | 49 | function parseDateFormat2(str) { 50 | // 2010/31/2 51 | const m = str.match(/^(\d{4})[/\s.\-,](\d{1,2})[/\s.\-,](\d{1,2})$/); 52 | return (m) ? new Date(m[1], m[2] - 1, m[3]) : NaN; 53 | } 54 | 55 | 56 | const TOSTRING_FORMAT = 'ddd MMM D YYYY HH:mm:ss [GMT]ZZ'; 57 | 58 | function getMomentAllowedFormats() { 59 | const formats = []; 60 | const defaultFormats = [ 61 | 'DD|MM|YYYY HH:mm', 'MM|DD|YYYY HH:mm', 'MM|DD|YYYY', 'DD|MM|YYYY', 'YYYY|MM|DD', 'YYYY|DD|MM', 62 | 'DD|M|YYYY HH:mm', 'M|DD|YYYY HH:mm', 'M|DD|YYYY', 'DD|M|YYYY', 'YYYY|M|DD', 'YYYY|DD|M', 63 | 'D|MM|YYYY HH:mm', 'MM|D|YYYY HH:mm', 'MM|D|YYYY', 'D|MM|YYYY', 'YYYY|MM|D', 'YYYY|D|MM', 64 | 'D|M|YYYY HH:mm', 'M|D|YYYY HH:mm', 'M|D|YYYY', 'D|M|YYYY', 'YYYY|M|D', 'YYYY|D|M', 65 | 'YYYY|MM|DDTHH:mm:ss', 'YYYY|DD|MMTHH:mm:ss' 66 | ]; 67 | 68 | const delimiters = ['.', '/', '-']; 69 | 70 | for (let i = 0; i < delimiters.length; i += 1) { 71 | for (let j = 0; j < defaultFormats.length; j += 1) { 72 | formats.push(defaultFormats[j].replace(/\|/g, delimiters[i])); 73 | } 74 | } 75 | 76 | return formats; 77 | } 78 | 79 | function containsInvalidDateStr(str) { 80 | if (!_.isString(str)) return true; 81 | if (str.match(/[\d]/) === null) return true; // no any numbers 82 | if (str.match(/.*[-:T/].*/) === null) return true; // no any date parts related characters 83 | const rfc2822Moment = moment(str, moment.RFC_2822, true); 84 | const isoMoment = moment(str, moment.ISO_8601, true); 85 | const momentAllowedFormats = getMomentAllowedFormats(); 86 | const strFormat = moment(str, TOSTRING_FORMAT); 87 | const allOthersMoment = moment(str, momentAllowedFormats, true); 88 | const rfc2822 = rfc2822Moment.isValid(); 89 | const iso = isoMoment.isValid(); 90 | const strValid = strFormat.isValid(); 91 | const allOthers = allOthersMoment.isValid(); 92 | 93 | if (!rfc2822 && !iso && !allOthers && !strValid) return true; 94 | return false; 95 | } 96 | 97 | function newDate(str) { 98 | return new Date(str); 99 | } 100 | 101 | function isDate(val) { 102 | return _.isDate(val) && !Number.isNaN(val.getTime()); 103 | } 104 | 105 | /** 106 | * Custom date parser - because there is no indicate when string 107 | * is used as a int and when as a date. 108 | * e.g. 2010 -> assuming it's number 109 | * 2010/10/1 -> its'a date (y/m/d) but Date.parse doesn't detect it 110 | * //31/2/2010 -> it's another representation about date (m/d/y) 111 | * @param {String}str 112 | * @return {Date|NaN} 113 | */ 114 | module.exports.parseDateCustom = function parseDateCustom(str) { 115 | if (containsInvalidDateStr(str)) return NaN; 116 | const converters = [newDate, parseDateFormat1, parseDateFormat2]; 117 | // eslint-disable-next-line no-restricted-syntax, guard-for-in 118 | for (const i in converters) { 119 | const date = converters[i](str); 120 | if (isDate(date)) return date; 121 | } 122 | return NaN; 123 | }; 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongoose-query", 3 | "description": "mongoose Query lib", 4 | "keywords": [ 5 | "mongoose", 6 | "query", 7 | "mongodb" 8 | ], 9 | "version": "0.8.0", 10 | "homepage": "https://github.com/jupe/mongoose-query", 11 | "author": "Jussi Vatjus-Anttila )", 12 | "main": "lib/index", 13 | "bugs": { 14 | "url": "https://github.com/jupe/mongoose-query/issues" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/jupe/mongoose-query" 19 | }, 20 | "license": "MIT", 21 | "dependencies": { 22 | "flat": "^5.0.0", 23 | "lodash": "^4.17.21", 24 | "moment": "^2.30.1", 25 | "mongoose": "^5.13.14", 26 | "snyk": "^1.1285.0" 27 | }, 28 | "devDependencies": { 29 | "chai": "^4.2.0", 30 | "eslint": "^5.16.0", 31 | "eslint-config-airbnb": "^17.1.0", 32 | "eslint-plugin-chai-expect": "^3.0.0", 33 | "eslint-plugin-import": "^2.17.3", 34 | "eslint-plugin-mocha": "^6.3.0", 35 | "eslint-plugin-node": "^11.1.0", 36 | "eslint-plugin-promise": "^4.1.1", 37 | "mocha": "^10.2.0" 38 | }, 39 | "contributors": [ 40 | "Jussi Vatjus-Anttila " 41 | ], 42 | "scripts": { 43 | "test": "mocha -R list", 44 | "lint": "./node_modules/eslint/bin/eslint.js .", 45 | "snyk-protect": "snyk protect", 46 | "prepublish": "npm run snyk-protect" 47 | }, 48 | "snyk": true 49 | } 50 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "plugins": [ 6 | "mocha", 7 | "chai-expect" 8 | ], 9 | "rules": { 10 | "mocha/no-exclusive-tests": "error", 11 | "chai-expect/missing-assertion": "error", 12 | "chai-expect/terminating-properties": "error", 13 | "no-unused-expressions": "off", 14 | "prefer-arrow-callback": "off", 15 | "func-names": "off", 16 | "no-underscore-dangle": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | /* Node.js official modules */ 2 | 3 | /* 3rd party modules */ 4 | const _ = require('lodash'); 5 | const mongoose = require('mongoose'); 6 | const chai = require('chai'); 7 | 8 | const { Schema, Types } = mongoose; 9 | const { assert, expect } = chai; 10 | 11 | /* QueryPlugin itself */ 12 | const Query = require('../'); 13 | const parseQuery = require('../lib/parseQuery'); 14 | const { parseDateCustom } = require('../lib/tools'); 15 | 16 | mongoose.Promise = Promise; 17 | 18 | const isPromise = function (obj) { 19 | return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function'; 20 | }; 21 | const assertPromise = function (obj) { 22 | expect(isPromise(obj)).to.be.true; 23 | }; 24 | 25 | const { ObjectId } = Schema; 26 | const OrigSchema = new mongoose.Schema({ 27 | value: { type: String, default: 'original' } 28 | }); 29 | const TestSchema = new mongoose.Schema({ 30 | title: { type: String, index: true }, 31 | msg: { type: String, lowercase: true, trim: true }, 32 | date: { type: Date, default: Date.now }, 33 | empty: { type: String }, 34 | orig: { type: ObjectId, ref: 'originals' }, 35 | i: { type: Number, index: true }, 36 | arr: [ 37 | { ay: { type: String } } 38 | ], 39 | nest: { 40 | ed: { type: String, default: 'value' } 41 | } 42 | }); 43 | TestSchema.plugin(Query); 44 | 45 | const OrigTestModel = mongoose.model('originals', OrigSchema); 46 | const TestModel = mongoose.model('test', TestSchema); 47 | 48 | describe('unittests', function () { 49 | describe('parseDateCustom', function () { 50 | it('is not valid date', function () { 51 | assert.isNaN(parseDateCustom('2000')); 52 | assert.isNaN(parseDateCustom('1000128123')); 53 | assert.isNaN(parseDateCustom('2011A1020')); 54 | assert.isNaN(parseDateCustom('')); 55 | assert.isNaN(parseDateCustom()); 56 | assert.isNaN(parseDateCustom('test-yannick-2')); 57 | assert.isNaN(parseDateCustom('Thue May 15 2018 23:15:16 GMT+0300 (EEST)')); 58 | }); 59 | it('is valid', function () { 60 | assert.isTrue(_.isDate(parseDateCustom('2017/09/10'))); 61 | assert.isTrue(_.isDate(parseDateCustom('31/3/2010'))); 62 | assert.isTrue(_.isDate(parseDateCustom('2011-10-10T14:48:00'))); // ISO 8601 with time 63 | assert.isTrue(_.isDate(parseDateCustom('2011-10-10'))); // ISO 8601 64 | assert.isTrue(_.isDate(parseDateCustom('Tue May 15 2018 23:15:16 GMT+0300 (EEST)'))); // Date.toString() -fmt 65 | }); 66 | }); 67 | describe('parseQuery', function () { 68 | const defaultResp = { 69 | q: {}, 70 | map: '', 71 | reduce: '', 72 | t: 'find', 73 | f: false, 74 | s: false, 75 | sk: false, 76 | l: 1000, 77 | p: false, 78 | fl: false, 79 | options: {} 80 | }; 81 | const mergeResult = obj => _.merge({}, defaultResp, obj); 82 | 83 | it('text search is parsed correctly from q', function () { 84 | assert.deepEqual( 85 | parseQuery({ q: JSON.stringify({ $text: { $search: '100-10-7' } }) }), 86 | mergeResult({ q: { $text: { $search: '100-10-7' } } }) 87 | ); 88 | }); 89 | 90 | it('objectid is parsed correctly from q', function () { 91 | assert.deepEqual( 92 | parseQuery({ q: JSON.stringify({ fieldName: 'oid:000000000000000000000000' }) }), 93 | mergeResult({ q: { fieldName: new Types.ObjectId('000000000000000000000000') } }) 94 | ); 95 | }); 96 | it('objectid is parsed correctly from $match', function () { 97 | assert.deepEqual( 98 | parseQuery({ q: JSON.stringify({ $match: { fieldName: 'oid:000000000000000000000000' } }) }), 99 | mergeResult({ 100 | q: { 101 | $match: { 102 | fieldName: new Types.ObjectId('000000000000000000000000') 103 | } 104 | } 105 | }) 106 | ); 107 | }); 108 | 109 | it('objectid is parsed correctly from parameter', function () { 110 | assert.deepEqual( 111 | parseQuery({ fieldName: 'oid:000000000000000000000000' }), 112 | mergeResult({ q: { fieldName: new Types.ObjectId('000000000000000000000000') } }) 113 | ); 114 | }); 115 | it('option q(query as a json) is parsed correctly', function () { 116 | const date = new Date(); 117 | assert.deepEqual( 118 | parseQuery({ q: `{"a": "b", "b": 1, "c": "${date.toISOString()}", "d": "oid:000000000000000000000000"}` }), 119 | mergeResult({ 120 | q: { 121 | a: 'b', b: 1, c: date, d: new Types.ObjectId('000000000000000000000000') 122 | } 123 | }) 124 | ); 125 | 126 | const aggregate = [ 127 | { 128 | $match: { 129 | _id: '000000000000000000000000' 130 | } 131 | }, 132 | { 133 | $group: { 134 | _id: '$_id', 135 | balance: { $sum: '$records.amount' } 136 | } 137 | } 138 | ]; 139 | const q = JSON.stringify(aggregate); 140 | assert.deepEqual({ q, type: 'aggregate' }, { q, type: 'aggregate' }); 141 | assert.throws(parseQuery.bind(this, { q: '{a: "a"' }), Error); 142 | }); 143 | it('option t (type) is parsed correctly', function () { 144 | assert.deepEqual( 145 | parseQuery({ t: 'count' }), 146 | mergeResult({ t: 'count' }) 147 | ); 148 | }); 149 | it('option p(populate) is parsed correctly', function () { 150 | assert.deepEqual( 151 | parseQuery({ p: 'a' }), 152 | mergeResult({ p: 'a' }) 153 | ); 154 | assert.deepEqual( 155 | parseQuery({ p: '["a","b"]' }), 156 | mergeResult({ p: ['a', 'b'] }) 157 | ); 158 | assert.deepEqual( 159 | parseQuery({ p: '{"a":"b"}' }), 160 | mergeResult({ p: { a: 'b' } }) 161 | ); 162 | assert.deepEqual( 163 | parseQuery({ p: 'a,b' }), 164 | mergeResult({ p: ['a', 'b'] }) 165 | ); 166 | }); 167 | 168 | 169 | it('values are parsed correctly without option', function () { 170 | assert.deepEqual( 171 | parseQuery({ id: '000000000000000000000000' }), 172 | mergeResult({ q: { id: '000000000000000000000000' } }) 173 | ); 174 | assert.deepEqual( 175 | parseQuery({ id: '00000000000000000000000' }), 176 | mergeResult({ q: { id: 0 } }) 177 | ); 178 | assert.deepEqual( 179 | parseQuery({ q: '{"id":"000000000000000000000000"}' }), 180 | mergeResult({ q: { id: '000000000000000000000000' } }) 181 | ); 182 | 183 | const date = new Date(); 184 | assert.deepEqual( 185 | parseQuery({ time: `${date.toISOString()}` }), 186 | mergeResult({ q: { time: date } }) 187 | ); 188 | }); 189 | it('option l(limit) is parsed correctly', function () { 190 | assert.deepEqual( 191 | parseQuery({ l: '101' }), 192 | mergeResult({ l: 101 }) 193 | ); 194 | assert.deepEqual( 195 | parseQuery({ limit: '101' }), 196 | mergeResult({ l: 101 }) 197 | ); 198 | assert.deepEqual( 199 | parseQuery({ skips: '101' }), 200 | mergeResult({ sk: 101 }) 201 | ); 202 | }); 203 | it('option d(allowDiskUse)', function () { 204 | assert.deepEqual( 205 | parseQuery({ d: 'true' }), 206 | mergeResult({ options: { allowDiskUse: true } }) 207 | ); 208 | assert.deepEqual( 209 | parseQuery({ allowDiskUse: 'true' }), 210 | mergeResult({ options: { allowDiskUse: true } }) 211 | ); 212 | }); 213 | it('option to(timeout)', function () { 214 | assert.deepEqual( 215 | parseQuery({ to: '1' }), 216 | mergeResult({ options: { maxTimeMS: 1 } }) 217 | ); 218 | assert.deepEqual( 219 | parseQuery({ timeout: '2' }), 220 | mergeResult({ options: { maxTimeMS: 2 } }) 221 | ); 222 | }); 223 | it('option x(explain)', function () { 224 | assert.deepEqual( 225 | parseQuery({ x: 'asd' }), 226 | mergeResult({ options: { explain: 'queryPlanner' } }) 227 | ); 228 | assert.deepEqual( 229 | parseQuery({ explain: 'executionStats' }), 230 | mergeResult({ options: { explain: 'executionStats' } }) 231 | ); 232 | }); 233 | it('invalid keys thrown an error', function () { 234 | assert.throws(parseQuery.bind(this, { $1: 'a' }), Error); 235 | assert.throws(parseQuery.bind(this, { sort_by: undefined }), Error); 236 | }); 237 | it('value operators is parsed properly', function () { 238 | assert.deepEqual( 239 | parseQuery({ a: '{in}a,b' }), 240 | mergeResult({ q: { a: { $in: ['a', 'b'] } } }) 241 | ); 242 | assert.deepEqual( 243 | parseQuery({ a: '{m}k,v' }), 244 | mergeResult({ q: { a: { $elemMatch: { k: 'v' } } } }) 245 | ); 246 | assert.deepEqual( 247 | parseQuery({ a: '{empty}' }), 248 | mergeResult({ q: { $or: [{ a: '' }, { a: { $exists: false } }] } }) 249 | ); 250 | assert.deepEqual( 251 | parseQuery({ a: '{!empty}' }), 252 | mergeResult({ q: { $nor: [{ a: '' }, { a: { $exists: false } }] } }) 253 | ); 254 | assert.deepEqual( 255 | parseQuery({ a: 'b|c|d' }), 256 | mergeResult({ q: { $or: [{ a: 'b' }, { a: 'c' }, { a: 'd' }] } }) 257 | ); 258 | assert.deepEqual( 259 | parseQuery({ a: '/a/' }), 260 | mergeResult({ q: { a: /a/ } }) 261 | ); 262 | assert.deepEqual( 263 | parseQuery({ a: '/a/i' }), 264 | mergeResult({ q: { a: /a/i } }) 265 | ); 266 | }); 267 | }); 268 | }); 269 | describe('Query:apitests', function () { 270 | let origTestDocId; 271 | const _id = '57ae125aaf1b792c1768982b'; 272 | let firstDate; 273 | let lastDate; 274 | 275 | const docCount = 4000; 276 | const defaultLimit = 1000; 277 | 278 | const create = (i, max, callback) => { 279 | if (i < max - 1) { 280 | const obj = new TestModel({ 281 | title: (i % 2 === 0 ? 'testa' : 'testb'), msg: `i#${i}`, orig: origTestDocId, i, arr: [{ ay: `i#${i}` }] 282 | }); 283 | obj.save(() => { 284 | if (!firstDate) firstDate = obj.date; 285 | create(i + 1, max, callback); 286 | }); 287 | } else { 288 | const obj = new TestModel({ 289 | _id, title: (i % 2 === 0 ? 'testa' : 'testb'), msg: `i#${i}`, orig: origTestDocId, i 290 | }); 291 | lastDate = obj.date; 292 | obj.save(callback); 293 | } 294 | }; 295 | 296 | before(function () { 297 | return mongoose.connect('mongodb://localhost/mongoose-query-tests', { useNewUrlParser: true }); 298 | }); 299 | before(function (done) { 300 | this.timeout(10000); 301 | const obj = new OrigTestModel(); 302 | obj.save((error, doc) => { 303 | assert.equal(error, undefined); 304 | origTestDocId = doc._id; 305 | TestModel.deleteMany({}, () => { 306 | create(0, docCount, done); 307 | }); 308 | }); 309 | }); 310 | after(function () { 311 | return OrigTestModel.deleteMany({}); 312 | }); 313 | after(function () { 314 | return TestModel.deleteMany({}); 315 | }); 316 | after(function () { 317 | return mongoose.disconnect(); 318 | }); 319 | 320 | it('find', function (done) { 321 | const req = { q: '{}' }; 322 | TestModel.query(req, function (error, data) { 323 | assert.equal(error, undefined); 324 | 325 | const validateData = (obj) => { 326 | assert.equal(obj.length, defaultLimit); 327 | assert.isTrue((`${obj[0].orig}`).match(/([0-9a-z]{24})/) != null); 328 | _.each(obj, (doc) => { 329 | assert.isTrue(!_.isPlainObject(doc)); 330 | }); 331 | }; 332 | validateData(data); 333 | // alternative: 334 | const promise = TestModel.query(req); 335 | assertPromise(promise); 336 | promise.then(validateData).then(done); 337 | }); 338 | }); 339 | it('find with timeout', function (done) { 340 | const req = { to: 100 }; 341 | TestModel.query(req, function (error, data) { 342 | assert.equal(error, undefined); 343 | 344 | const validateData = (obj) => { 345 | assert.equal(obj.length, defaultLimit); 346 | assert.isTrue((`${obj[0].orig}`).match(/([0-9a-z]{24})/) != null); 347 | _.each(obj, (doc) => { 348 | assert.isTrue(!_.isPlainObject(doc)); 349 | }); 350 | }; 351 | validateData(data); 352 | // alternative: 353 | const promise = TestModel.query(req); 354 | assertPromise(promise); 355 | promise.then(validateData).then(done); 356 | }); 357 | }); 358 | it('find with explain', function () { 359 | const req = { x: 'default' }; 360 | return TestModel.query(req) 361 | .then((doc) => { 362 | assert.isTrue(_.isPlainObject(doc[0])); 363 | }); 364 | }); 365 | it('findOne using objectid', function (done) { 366 | const req = { _id, t: 'findOne' }; 367 | TestModel.query(req, function (error, data) { 368 | assert.equal(error, undefined); 369 | assert.equal(data._id, _id); 370 | done(); 371 | }); 372 | }); 373 | it('regex', function (done) { 374 | const req = { q: '{"title": {"$regex": "/^testa/"}, "i": { "$lt": 20}}' }; 375 | TestModel.query(req, function (error, data) { 376 | assert.equal(error, undefined); 377 | const validateData = (obj) => { 378 | assert.equal(obj.length, 10); 379 | assert.isTrue((`${obj[0].orig}`).match(/([0-9a-z]{24})/) != null); 380 | }; 381 | validateData(data); 382 | // alternative 383 | const promise = TestModel.query(req); 384 | assertPromise(promise); 385 | promise.then(validateData).then(done); 386 | }); 387 | }); 388 | it('findOne & sort', function (done) { 389 | const req = { q: '{}', t: 'findOne', s: '{"msg": 1}' }; 390 | TestModel.query(req, function (error, data) { 391 | assert.equal(error, undefined); 392 | const validateData = (obj) => { 393 | assert.typeOf(obj, 'Object'); 394 | assert.equal(obj.title, 'testa'); 395 | assert.equal(obj.msg, 'i#0'); 396 | }; 397 | validateData(data); 398 | // alternative 399 | const promise = TestModel.query(req); 400 | assertPromise(promise); 401 | promise.then(validateData).then(done); 402 | }); 403 | }); 404 | it('exact', function (done) { 405 | const req = { q: '{"msg":"i#3"}' }; 406 | TestModel.query(req, function (error, data) { 407 | assert.equal(error, undefined); 408 | const validateData = (obj) => { 409 | assert.equal(obj.length, 1); 410 | assert.equal(obj[0].msg, 'i#3'); 411 | }; 412 | validateData(data); 413 | // alternative 414 | const promise = TestModel.query(req); 415 | assertPromise(promise); 416 | promise.then(validateData).then(done); 417 | }); 418 | }); 419 | it('populate', function (done) { 420 | const req = { q: '{"msg":"i#3"}', p: 'orig' }; 421 | TestModel.query(req, function (error, data) { 422 | assert.equal(error, undefined); 423 | const validateData = (obj) => { 424 | assert.equal(obj.length, 1); 425 | assert.equal(obj[0].msg, 'i#3'); 426 | assert.equal(obj[0].orig.value, 'original'); 427 | }; 428 | validateData(data); 429 | // alternative 430 | const promise = TestModel.query(req); 431 | assertPromise(promise); 432 | promise.then(validateData).then(done); 433 | }); 434 | }); 435 | it('limit & select', function (done) { 436 | const req = { 437 | q: '{}', f: 'title', l: '3', s: '{"title": -1}' 438 | }; 439 | TestModel.query(req, function (error, data) { 440 | assert.equal(error, undefined); 441 | const validateData = (obj) => { 442 | assert.equal(obj.length, 3); 443 | assert.equal(obj[0].msg, undefined); 444 | assert.equal(obj[0].title, 'testb'); 445 | assert.equal(obj[1].msg, undefined); 446 | assert.equal(obj[1].title, 'testb'); 447 | assert.equal(obj[2].msg, undefined); 448 | assert.equal(obj[2].title, 'testb'); 449 | }; 450 | validateData(data); 451 | // alternative 452 | const promise = TestModel.query(req); 453 | assertPromise(promise); 454 | promise.then(validateData).then(done); 455 | }); 456 | }); 457 | 458 | it('skip', function (done) { 459 | const req = { q: '{}', sk: '3' }; 460 | TestModel.query(req, function (error, data) { 461 | assert.equal(error, undefined); 462 | const validateData = (obj) => { 463 | assert.equal(obj.length, defaultLimit); 464 | }; 465 | validateData(data); 466 | // alternative 467 | const promise = TestModel.query(req); 468 | assertPromise(promise); 469 | promise.then(validateData).then(done); 470 | }); 471 | }); 472 | 473 | it('count', function (done) { 474 | const req = { q: '{"$or": [ {"msg":"i#1"}, {"msg":"i#2"}]}', t: 'count' }; 475 | TestModel.query(req, function (error, data) { 476 | assert.equal(error, undefined); 477 | const validateData = (obj) => { 478 | assert.typeOf(obj, 'object'); 479 | assert.equal(obj.count, 2); 480 | }; 481 | validateData(data); 482 | // alternative 483 | const promise = TestModel.query(req); 484 | assertPromise(promise); 485 | promise.then(validateData).then(done); 486 | }); 487 | }); 488 | it('estimateCount', function (done) { 489 | const req = { t: 'estimateCount' }; 490 | TestModel.query(req, function (error, { count }) { 491 | assert.equal(count, 4000); 492 | done(); 493 | }); 494 | }); 495 | 496 | it('distinct', function (done) { 497 | const req = { f: 'title', t: 'distinct' }; 498 | TestModel.query(req, function (error, data) { 499 | assert.equal(error, undefined); 500 | assert.equal(data.length, 2); 501 | // alternative 502 | assertPromise(TestModel.query(req)); 503 | done(); 504 | }); 505 | }); 506 | it('distinct with timeout', function (done) { 507 | const req = { f: 'title', t: 'distinct', to: 10000 }; 508 | TestModel.query(req, function (error, data) { 509 | assert.equal(error, undefined); 510 | assert.equal(data.length, 2); 511 | // alternative 512 | assertPromise(TestModel.query(req)); 513 | done(); 514 | }); 515 | }); 516 | it('flatten', function (done) { 517 | const req = { q: '{}', fl: 'true', l: '1' }; 518 | TestModel.query(req, function (error, data) { 519 | assert.equal(error, undefined); 520 | const validateData = (obj) => { 521 | assert.typeOf(obj, 'array'); 522 | obj.forEach(function (item) { 523 | assert.typeOf(item, 'object'); 524 | assert.equal(item['nest.ed'], 'value'); 525 | }); 526 | }; 527 | validateData(data); 528 | // this is not supported when no callback is used 529 | const promise = TestModel.query(req); 530 | assertPromise(promise); 531 | promise.then(validateData).then(done); 532 | }); 533 | }); 534 | it('!empty', function (done) { 535 | // Field exists and is not empty 536 | const req = { 'nest.ed': '{!empty}-' }; 537 | TestModel.query(req, function (error, data) { 538 | assert.equal(error, undefined); 539 | const validateData = (obj) => { 540 | assert.equal(obj[0].nest.ed, 'value'); 541 | }; 542 | validateData(data); 543 | // this is not supported when no callback is used 544 | const promise = TestModel.query(req); 545 | assertPromise(promise); 546 | promise.then(validateData).then(done); 547 | }); 548 | }); 549 | it('!empty', function (done) { 550 | // Field exists and is not empty 551 | const req = { empty: '{!empty}-' }; 552 | TestModel.query(req, function (error, data) { 553 | assert.equal(error, undefined); 554 | const validateData = (obj) => { 555 | assert.equal(obj.length, 0); 556 | }; 557 | validateData(data); 558 | // this is not supported when no callback is used 559 | const promise = TestModel.query(req); 560 | assertPromise(promise); 561 | promise.then(validateData).then(done); 562 | }); 563 | }); 564 | it('empty', function (done) { 565 | // Field is empty or not exists 566 | const req = { empty: '{empty}-' }; 567 | TestModel.query(req, function (error, data) { 568 | assert.equal(error, undefined); 569 | const validateData = (obj) => { 570 | assert.equal(obj.length, defaultLimit); 571 | }; 572 | validateData(data); 573 | // this is not supported when no callback is used 574 | const promise = TestModel.query(req); 575 | assertPromise(promise); 576 | promise.then(validateData).then(done); 577 | }); 578 | }); 579 | it('limit more than default', function (done) { 580 | // Field is empty or not exists 581 | const req = { l: '2000' }; 582 | TestModel.query(req, function (error, data) { 583 | assert.equal(error, undefined); 584 | const validateData = (obj) => { 585 | assert.equal(obj.length, 2000); 586 | }; 587 | validateData(data); 588 | // this is not supported when no callback is used 589 | const promise = TestModel.query(req); 590 | assertPromise(promise); 591 | promise.then(validateData).then(done); 592 | }); 593 | }); 594 | it('limit with skip', function (done) { 595 | // Field is empty or not exists 596 | const req = { l: '2000', sk: '2500' }; 597 | TestModel.query(req, function (error, data) { 598 | assert.equal(error, undefined); 599 | const validateData = (obj) => { 600 | assert.equal(obj.length, 1500); 601 | }; 602 | validateData(data); 603 | // this is not supported when no callback is used 604 | const promise = TestModel.query(req); 605 | assertPromise(promise); 606 | promise.then(validateData).then(done); 607 | }); 608 | }); 609 | it('limit with filter', function (done) { 610 | // Field is empty or not exists 611 | const req = { l: '2000', q: '{ "title": "testa"}' }; 612 | TestModel.query(req, function (error, data) { 613 | assert.equal(error, undefined); 614 | const validateData = (obj) => { 615 | assert.equal(obj.length, 2000); 616 | }; 617 | validateData(data); 618 | // this is not supported when no callback is used 619 | const promise = TestModel.query(req); 620 | assertPromise(promise); 621 | promise.then(validateData).then(done); 622 | }); 623 | }); 624 | it('limit with sort', function (done) { 625 | // Field is empty or not exists 626 | const req = { l: '2000', s: '{ "i": -1 }' }; 627 | TestModel.query(req, function (error, data) { 628 | assert.equal(error, undefined); 629 | const validateData = (obj) => { 630 | assert.equal(obj.length, 2000); 631 | }; 632 | validateData(data); 633 | // this is not supported when no callback is used 634 | const promise = TestModel.query(req); 635 | assertPromise(promise); 636 | promise.then(validateData).then(done); 637 | }); 638 | }); 639 | it('oid wildcard', function (done) { 640 | const req = { q: `{"_id": "${_id}"}` }; 641 | TestModel.query(req, function (error, data) { 642 | assert.equal(error, undefined); 643 | const validateData = (obj) => { 644 | assert.equal(obj.length, 1); 645 | assert.equal(obj[0]._id, `${_id}`); 646 | }; 647 | validateData(data); 648 | // this is not supported when no callback is used 649 | const promise = TestModel.query(req); 650 | assertPromise(promise); 651 | promise.then(validateData).then(done); 652 | }); 653 | }); 654 | it('leanQuery', function (done) { 655 | const req = {}; 656 | TestModel.leanQuery(req, function (error, data) { 657 | assert.equal(error, undefined); 658 | const validateData = (obj) => { 659 | assert.equal(obj.length, defaultLimit); 660 | _.each(obj, (json) => { 661 | assert.isTrue(_.isPlainObject(json)); 662 | }); 663 | }; 664 | validateData(data); 665 | // this is not supported when no callback is used 666 | const promise = TestModel.leanQuery(req); 667 | assertPromise(promise); 668 | promise.then(validateData).then(done); 669 | }); 670 | }); 671 | it('leanQuery with flatten', function (done) { 672 | const req = { fl: 1 }; 673 | TestModel.leanQuery(req, function (error, data) { 674 | assert.equal(error, undefined); 675 | const validateData = (obj) => { 676 | assert.equal(obj.length, defaultLimit); 677 | _.each(obj, (json) => { 678 | assert.isTrue(_.isPlainObject(json)); 679 | }); 680 | }; 681 | validateData(data); 682 | // this is not supported when no callback is used 683 | const promise = TestModel.leanQuery(req); 684 | assertPromise(promise); 685 | promise.then(validateData).then(done); 686 | }); 687 | }); 688 | it('number search', function (done) { 689 | const req = { i: '1' }; 690 | TestModel.leanQuery(req, function (error, data) { 691 | assert.equal(error, undefined); 692 | assert.equal(data.length, 1); 693 | assert.equal(data[0].i, 1); 694 | done(); 695 | }); 696 | }); 697 | it('match', function (done) { 698 | const req = { arr: '{m}ay,i#1' }; 699 | TestModel.leanQuery(req, function (error, data) { 700 | assert.equal(error, undefined); 701 | assert.equal(data.length, 1); 702 | assert.equal(data[0].arr[0].ay, 'i#1'); 703 | done(); 704 | }); 705 | }); 706 | it('aggregate', function () { 707 | const req = { 708 | q: JSON.stringify([ 709 | { 710 | $match: { 711 | date: { 712 | $gt: firstDate.toString(), 713 | $lte: lastDate.toString() 714 | } 715 | } 716 | }, 717 | { 718 | $group: { 719 | _id: '$title' 720 | } 721 | } 722 | ]), 723 | t: 'aggregate' 724 | }; 725 | return TestModel.query(req).then((data) => { 726 | assert.deepEqual(data, [{ _id: 'testb' }, { _id: 'testa' }]); 727 | }); 728 | }); 729 | 730 | it('mapReduce', function () { 731 | const req = { 732 | map: 'function () { emit(this.title, 1) }', 733 | reduce: 'function (k, vals) { return 11 }', 734 | t: 'mapReduce' 735 | }; 736 | return TestModel.query(req).then((data) => { 737 | assert.deepEqual(data.results, [{ _id: 'testa', value: 11 }, { _id: 'testb', value: 11 }]); 738 | }); 739 | }); 740 | }); 741 | --------------------------------------------------------------------------------