├── .babelrc ├── .eslintrc ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── dist ├── index.js ├── index.test.js ├── options.js ├── options.test.js ├── pipelines.js ├── pipelines.test.js ├── utils.js └── utils.test.js ├── index.js ├── options.js ├── package-lock.json ├── package.json ├── src ├── index.js ├── index.test.js ├── options.js ├── options.test.js ├── pipelines.js ├── pipelines.test.js ├── utils.js └── utils.test.js └── wallaby.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "comments": false, 4 | "plugins": ["transform-object-rest-spread"] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "plugins": ["promise"], 9 | "extends": ["eslint:recommended", "plugin:promise/recommended"], 10 | "rules": { 11 | "new-cap": [0], 12 | "no-console": [0], 13 | "no-unused-vars": [1], 14 | "no-await-in-loop": [1], 15 | "no-prototype-builtins": [1], 16 | "no-template-curly-in-string": [1], 17 | "array-callback-return": [1], 18 | "block-scoped-var": [1], 19 | "complexity": [1], 20 | "consistent-return": [1], 21 | "default-case": [1], 22 | "eqeqeq": [1], 23 | "no-param-reassign": [1], 24 | "no-return-await": [1], 25 | "no-sequences": [1], 26 | "no-throw-literal": [1], 27 | "no-unused-expressions": [ 28 | 1, 29 | { 30 | "allowShortCircuit": true, 31 | "allowTernary": true 32 | } 33 | ], 34 | "no-useless-return": [1], 35 | "no-warning-comments": [1], 36 | "require-await": [1], 37 | "strict": [1, "global"] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | bower_components 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | *~ 41 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach", 11 | "port": 9229 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Launch Program", 17 | "program": "${workspaceFolder}/index.js" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#9ff356", 4 | "activityBar.background": "#9ff356", 5 | "activityBar.foreground": "#15202b", 6 | "activityBar.inactiveForeground": "#15202b99", 7 | "activityBarBadge.background": "#4c9af2", 8 | "activityBarBadge.foreground": "#15202b", 9 | "commandCenter.border": "#15202b99", 10 | "panel.border": "#9ff356", 11 | "sash.hoverBorder": "#9ff356", 12 | "sideBar.border": "#9ff356", 13 | "statusBar.background": "#84f026", 14 | "statusBar.foreground": "#15202b", 15 | "statusBarItem.hoverBackground": "#6bd40f", 16 | "statusBarItem.remoteBackground": "#84f026", 17 | "statusBarItem.remoteForeground": "#15202b", 18 | "tab.activeBorder": "#9ff356", 19 | "titleBar.activeBackground": "#84f026", 20 | "titleBar.activeForeground": "#15202b", 21 | "titleBar.inactiveBackground": "#84f02699", 22 | "titleBar.inactiveForeground": "#15202b99" 23 | }, 24 | "peacock.color": "#84f026" 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export SHELL=/bin/bash 2 | export PATH := ./node_modules/.bin:$(PATH) 3 | 4 | SRC = index.js 5 | 6 | TESTS = $(wildcard src/*.test.js) 7 | DISTTESTS = $(wildcard dist/*.test.js) 8 | 9 | .PHONY: test publish 10 | 11 | test: 12 | @echo "There are arbitrary issues with mongodb in docker, which lead" 13 | @echo "to timeouts in the tests. Trying multiple times can help," 14 | @echo "or run the docker command manually without -d and then execute" 15 | @echo "the tests using npx mocha -u tdd src/index.test.js" 16 | @echo "Hopefully this will be fixed with a new docker image." 17 | @echo 18 | ID=`docker run -p=27017:27017 --rm -d mongo:latest`; \ 19 | sleep 2; \ 20 | mocha -u tdd $(TESTS) ; \ 21 | docker stop $$ID 22 | 23 | testonly: 24 | mocha -u tdd $(TESTS) 25 | 26 | disttest: 27 | npm run prepare 28 | ID=`docker run -p=27017:27017 --rm -d mongo:latest`; \ 29 | sleep 2; \ 30 | mocha -r babel-polyfill -u tdd $(DISTTESTS) ; \ 31 | docker stop $$ID 32 | 33 | disttestonly: 34 | mocha -r babel-polyfill -u tdd $(DISTTESTS) 35 | 36 | test-coverage: 37 | npm run prepare 38 | ID=`docker run -p=27017:27017 --rm -d mongo:latest`; \ 39 | sleep 2; \ 40 | nyc --reporter=text mocha -u tdd $(TESTS) ; \ 41 | nyc report --reporter=lcov ; \ 42 | docker stop $$ID 43 | 44 | publish: 45 | @echo "MAKE SURE CHANGES HAVE BEEN PUSHED TO GITHUB." 46 | @echo "Current version from package.json:" 47 | grep --color "version" package.json 48 | @while [ -z "$$NEWVERSION" ]; do \ 49 | read -p "Enter the new version: " NEWVERSION; \ 50 | done ; \ 51 | npm version $$NEWVERSION; \ 52 | git push --tags && \ 53 | npm publish 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Querying a MongoDB collection using DevExtreme data store load parameters 3 | 4 | The JavaScript library [DevExtreme](https://js.devexpress.com/) by [DevExpress](https://www.devexpress.com) includes a highly advanced [data layer](https://js.devexpress.com/Documentation/Guide/Data_Layer/Data_Layer/). Many of the complex data-bound UI widgets in the library utilize the data layer infrastructure to load server-provided data efficiently. 5 | 6 | When data is loaded by a data source attached to a UI widget (or by code interaction with the data source), the underlying data store receives a call to its `load` function, and a parameter object is passed that I will refer to as `loadOptions`. If you implement a custom store to load data from your own server through a service interface, the server will (or should!) receive the `loadOptions` and query data accordingly. 7 | 8 | The library **devextreme-query-mongodb** implements the required logic to query data from a MongoDB collection, parametrized by a DevExtreme `loadOptions` object. 9 | 10 | ### Requirements 11 | 12 | #### For v2.x 13 | 14 | In v2, the library is published with babel-compiled files (in the dist) folder, which are used by default. This provides broader compatibility, but it introduces a requirement for `babel-polyfill`. To satisfy this, you should add a dependency to `babel-polyfill` to your project (`npm install --save babel-polyfill`) and initialize the polyfill before you load **devextreme-query-mongodb**: 15 | 16 | ```js 17 | require('babel-polyfill'); 18 | const query = require('devextreme-query-mongodb'); 19 | ``` 20 | 21 | #### For v1.x 22 | 23 | **devextreme-query-mongodb** requires at least version 7.3 of Node.js, and you need to pass the `--harmony` flag when running `node` (unless you're using the latest 8.x nightly builds, where `--harmony` is not required anymore). The reason for this requirement is that **devextreme-query-mongodb** uses `async` and `await`. 24 | 25 | ### Installing **devextreme-query-mongodb** 26 | 27 | The library is available through npm: 28 | 29 | `npm install devextreme-query-mongodb` 30 | 31 | ### Documentation 32 | 33 | Please see [the Wiki](https://github.com/oliversturm/devextreme-query-mongodb/wiki). 34 | 35 | ### Status 36 | 37 | The implementation is believed to be feature-complete at this time, but it comes without warranty. Please report any issues if you find them! 38 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 4 | 5 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 6 | 7 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 8 | 9 | var _require = require('./pipelines'), 10 | createGroupFieldName = _require.createGroupFieldName, 11 | createGroupKeyPipeline = _require.createGroupKeyPipeline, 12 | createGroupingPipeline = _require.createGroupingPipeline, 13 | createSkipTakePipeline = _require.createSkipTakePipeline, 14 | createCountPipeline = _require.createCountPipeline, 15 | createMatchPipeline = _require.createMatchPipeline, 16 | createSortPipeline = _require.createSortPipeline, 17 | createSummaryPipeline = _require.createSummaryPipeline, 18 | createSelectProjectExpression = _require.createSelectProjectExpression, 19 | createSelectPipeline = _require.createSelectPipeline, 20 | createCompleteFilterPipeline = _require.createCompleteFilterPipeline, 21 | createRemoveNestedFieldsPipeline = _require.createRemoveNestedFieldsPipeline; 22 | 23 | var _require2 = require('./utils'), 24 | replaceId = _require2.replaceId, 25 | createSummaryQueryExecutor = _require2.createSummaryQueryExecutor, 26 | merge = _require2.merge, 27 | debug = _require2.debug; 28 | 29 | function createContext(contextOptions, loadOptions) { 30 | var aggregateCall = function aggregateCall(collection, pipeline, identifier) { 31 | return function (aggregateOptions) { 32 | return collection.aggregate(pipeline, aggregateOptions); 33 | }(contextOptions.dynamicAggregateOptions ? filterAggregateOptions(contextOptions.dynamicAggregateOptions(identifier, pipeline, collection)) : contextOptions.aggregateOptions); 34 | }; 35 | 36 | var getCount = function getCount(collection, pipeline) { 37 | return aggregateCall(collection, pipeline, 'getCount').toArray().then(function (r) { 38 | return r.length > 0 ? r[0].count : 0; 39 | }); 40 | }; 41 | 42 | var populateSummaryResults = function populateSummaryResults(target, summary, summaryResults) { 43 | if (summary) { 44 | target.summary = []; 45 | 46 | var _iteratorNormalCompletion = true; 47 | var _didIteratorError = false; 48 | var _iteratorError = undefined; 49 | 50 | try { 51 | for (var _iterator = summary[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { 52 | var s = _step.value; 53 | 54 | switch (s.summaryType) { 55 | case 'sum': 56 | target.summary.push(summaryResults['___sum' + s.selector]); 57 | break; 58 | case 'avg': 59 | target.summary.push(summaryResults['___avg' + s.selector]); 60 | break; 61 | case 'min': 62 | target.summary.push(summaryResults['___min' + s.selector]); 63 | break; 64 | case 'max': 65 | target.summary.push(summaryResults['___max' + s.selector]); 66 | break; 67 | case 'count': 68 | target.summary.push(summaryResults.___count); 69 | break; 70 | default: 71 | console.error('Invalid summaryType ' + s.summaryType + ', ignoring'); 72 | } 73 | } 74 | } catch (err) { 75 | _didIteratorError = true; 76 | _iteratorError = err; 77 | } finally { 78 | try { 79 | if (!_iteratorNormalCompletion && _iterator.return) { 80 | _iterator.return(); 81 | } 82 | } finally { 83 | if (_didIteratorError) { 84 | throw _iteratorError; 85 | } 86 | } 87 | } 88 | } 89 | return target; 90 | }; 91 | 92 | var queryGroupData = function queryGroupData(collection, desc, includeDataItems, countSeparately, itemProjection, groupKeyPipeline, sortPipeline, filterPipelineDetails, skipTakePipeline, matchPipeline) { 93 | return aggregateCall(collection, [].concat(_toConsumableArray(contextOptions.preProcessingPipeline), _toConsumableArray(sortPipeline), _toConsumableArray(filterPipelineDetails.pipeline), _toConsumableArray(matchPipeline), _toConsumableArray(createRemoveNestedFieldsPipeline(filterPipelineDetails.nestedFields, contextOptions)), _toConsumableArray(createGroupingPipeline(desc, includeDataItems, countSeparately, groupKeyPipeline, itemProjection, contextOptions)), _toConsumableArray(skipTakePipeline)), 'queryGroupData').toArray().then(function (r) { 94 | return includeDataItems ? r.map(function (i) { 95 | return _extends({}, i, { 96 | items: contextOptions.replaceIds ? i.items.map(replaceId) : i.items 97 | }); 98 | }) : r; 99 | }); 100 | }; 101 | 102 | var queryGroup = function queryGroup(collection, groupIndex, runSummaryQuery, filterPipelineDetails) { 103 | var skipTakePipeline = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : []; 104 | var summaryPipeline = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : []; 105 | var matchPipeline = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : []; 106 | 107 | var group = loadOptions.group[groupIndex]; 108 | var lastGroup = groupIndex === loadOptions.group.length - 1; 109 | var itemDataRequired = lastGroup && group.isExpanded; 110 | var separateCountRequired = !lastGroup; 111 | 112 | var subGroupsRequired = !lastGroup; 113 | var summariesRequired = loadOptions.groupSummary && loadOptions.groupSummary.length > 0; 114 | 115 | var groupKeyPipeline = createGroupKeyPipeline(group.selector, group.groupInterval, groupIndex, contextOptions); 116 | 117 | var augmentWithSubGroups = function augmentWithSubGroups(groupData) { 118 | return subGroupsRequired ? groupData.map(function (item) { 119 | return queryGroup(collection, groupIndex + 1, runSummaryQuery, filterPipelineDetails, [], summaryPipeline, [].concat(_toConsumableArray(matchPipeline), _toConsumableArray(groupKeyPipeline), _toConsumableArray(createMatchPipeline(createGroupFieldName(groupIndex), item.key, contextOptions)))).then(function (r) { 120 | item.items = r; 121 | item.count = r.length; 122 | return r; 123 | }); 124 | }) : []; 125 | }; 126 | 127 | var augmentWithSeparateCount = function augmentWithSeparateCount(groupData) { 128 | if (separateCountRequired) { 129 | 130 | var nextGroup = loadOptions.group[groupIndex + 1]; 131 | var nextGroupKeyPipeline = createGroupKeyPipeline(nextGroup.selector, nextGroup.groupInterval, groupIndex + 1, contextOptions); 132 | return groupData.map(function (item) { 133 | return getCount(collection, [].concat(_toConsumableArray(contextOptions.preProcessingPipeline), _toConsumableArray(filterPipelineDetails.pipeline), _toConsumableArray(groupKeyPipeline), _toConsumableArray(matchPipeline), _toConsumableArray(createMatchPipeline(createGroupFieldName(groupIndex), item.key, contextOptions)), _toConsumableArray(createGroupingPipeline(nextGroup.desc, false, true, nextGroupKeyPipeline, contextOptions)), _toConsumableArray(createCountPipeline(contextOptions)))).then(function (r) { 134 | item.count = r; 135 | return r; 136 | }); 137 | }); 138 | } else return []; 139 | }; 140 | 141 | var augmentWithSummaries = function augmentWithSummaries(groupData) { 142 | return summariesRequired ? groupData.map(function (item) { 143 | return runSummaryQuery(function () { 144 | return aggregateCall(collection, [].concat(_toConsumableArray(contextOptions.preProcessingPipeline), _toConsumableArray(filterPipelineDetails.pipeline), _toConsumableArray(groupKeyPipeline), _toConsumableArray(matchPipeline), _toConsumableArray(createMatchPipeline(createGroupFieldName(groupIndex), item.key, contextOptions)), _toConsumableArray(summaryPipeline)), 'augmentWithSummaries').toArray(); 145 | }).then(function (r) { 146 | return populateSummaryResults(item, loadOptions.groupSummary, r[0]); 147 | }); 148 | }) : []; 149 | }; 150 | 151 | return queryGroupData(collection, group.desc, itemDataRequired, separateCountRequired, createSelectProjectExpression(loadOptions.select, true), groupKeyPipeline, itemDataRequired ? createSortPipeline(loadOptions.sort, contextOptions) : [], filterPipelineDetails, skipTakePipeline, matchPipeline).then(function (groupData) { 152 | return Promise.all([].concat(_toConsumableArray(augmentWithSubGroups(groupData)), _toConsumableArray(augmentWithSeparateCount(groupData)), _toConsumableArray(augmentWithSummaries(groupData)))).then(function () { 153 | return groupData; 154 | }); 155 | }); 156 | }; 157 | 158 | var totalCount = function totalCount(collection, completeFilterPipelineDetails) { 159 | return loadOptions.requireTotalCount || loadOptions.totalSummary ? contextOptions.preferMetadataCount && contextOptions.preProcessingPipeline.length === 0 && completeFilterPipelineDetails.pipeline.length <= 1 ? [collection.count(completeFilterPipelineDetails.pipeline.length === 1 ? completeFilterPipelineDetails.pipeline[0]['$match'] : undefined).then(function (r) { 160 | return { totalCount: r }; 161 | })] : [getCount(collection, [].concat(_toConsumableArray(contextOptions.preProcessingPipeline), _toConsumableArray(completeFilterPipelineDetails.pipeline), _toConsumableArray(createCountPipeline(contextOptions)))).then(function (r) { 162 | return { totalCount: r }; 163 | })] : []; 164 | }; 165 | 166 | var summary = function summary(collection, completeFilterPipelineDetails) { 167 | return function (resultObject) { 168 | return resultObject.totalCount > 0 && loadOptions.totalSummary ? aggregateCall(collection, [].concat(_toConsumableArray(contextOptions.preProcessingPipeline), _toConsumableArray(completeFilterPipelineDetails.pipeline), _toConsumableArray(createSummaryPipeline(loadOptions.totalSummary, contextOptions))), 'summary').toArray().then(function (r) { 169 | return populateSummaryResults(resultObject, loadOptions.totalSummary, r[0]); 170 | }) : Promise.resolve(resultObject); 171 | }; 172 | }; 173 | 174 | var queryGroups = function queryGroups(collection) { 175 | var completeFilterPipelineDetails = createCompleteFilterPipeline(loadOptions.searchExpr, loadOptions.searchOperation, loadOptions.searchValue, loadOptions.filter, contextOptions); 176 | var summaryPipeline = createSummaryPipeline(loadOptions.groupSummary, contextOptions); 177 | var skipTakePipeline = createSkipTakePipeline(loadOptions.skip, loadOptions.take, contextOptions); 178 | 179 | var mainQueryResult = function mainQueryResult() { 180 | return queryGroup(collection, 0, createSummaryQueryExecutor(undefined), completeFilterPipelineDetails, skipTakePipeline, summaryPipeline).then(function (r) { 181 | return { data: r }; 182 | }); 183 | }; 184 | 185 | var groupCount = function groupCount() { 186 | if (loadOptions.requireGroupCount) { 187 | var group = loadOptions.group[0]; 188 | 189 | return [getCount(collection, [].concat(_toConsumableArray(contextOptions.preProcessingPipeline), _toConsumableArray(completeFilterPipelineDetails.pipeline), _toConsumableArray(createGroupingPipeline(group.desc, false, true, createGroupKeyPipeline(group.selector, group.groupInterval, 0, contextOptions), contextOptions)), _toConsumableArray(createCountPipeline(contextOptions)))).then(function (r) { 190 | return { groupCount: r }; 191 | })]; 192 | } else return []; 193 | }; 194 | 195 | return Promise.all([mainQueryResult()].concat(_toConsumableArray(groupCount()), _toConsumableArray(totalCount(collection, completeFilterPipelineDetails)))).then(merge).then(summary(collection, completeFilterPipelineDetails)); 196 | }; 197 | 198 | var querySimple = function querySimple(collection) { 199 | var completeFilterPipelineDetails = createCompleteFilterPipeline(loadOptions.searchExpr, loadOptions.searchOperation, loadOptions.searchValue, loadOptions.filter, contextOptions); 200 | var sortPipeline = createSortPipeline(loadOptions.sort, contextOptions); 201 | var skipTakePipeline = createSkipTakePipeline(loadOptions.skip, loadOptions.take, contextOptions); 202 | var selectPipeline = createSelectPipeline(loadOptions.select, contextOptions); 203 | var removeNestedFieldsPipeline = createRemoveNestedFieldsPipeline(completeFilterPipelineDetails.nestedFields, contextOptions); 204 | 205 | var mainQueryResult = function mainQueryResult() { 206 | return aggregateCall(collection, [].concat(_toConsumableArray(contextOptions.preProcessingPipeline), _toConsumableArray(completeFilterPipelineDetails.pipeline), _toConsumableArray(sortPipeline), _toConsumableArray(skipTakePipeline), _toConsumableArray(selectPipeline), _toConsumableArray(removeNestedFieldsPipeline)), 'mainQueryResult').toArray().then(function (r) { 207 | return contextOptions.replaceIds ? r.map(replaceId) : r; 208 | }).then(function (r) { 209 | return { data: r }; 210 | }); 211 | }; 212 | 213 | return Promise.all([mainQueryResult()].concat(_toConsumableArray(totalCount(collection, completeFilterPipelineDetails)))).then(merge).then(summary(collection, completeFilterPipelineDetails)); 214 | }; 215 | 216 | return { queryGroups: queryGroups, querySimple: querySimple }; 217 | } 218 | 219 | function filterAggregateOptions(proposedOptions) { 220 | var acceptableAggregateOptionNames = ['allowDiskUse', 'maxTimeMS', 'readConcern', 'collation', 'hint', 'comment']; 221 | return Object.keys(proposedOptions).reduce(function (r, v) { 222 | return acceptableAggregateOptionNames.includes(v) ? _extends({}, r, _defineProperty({}, v, proposedOptions[v])) : r; 223 | }, {}); 224 | } 225 | 226 | function query(collection) { 227 | var loadOptions = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 228 | var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; 229 | 230 | var proposedAggregateOptions = options.aggregateOptions; 231 | delete options.aggregateOptions; 232 | 233 | var standardContextOptions = { 234 | replaceIds: true, 235 | summaryQueryLimit: 100, 236 | 237 | timezoneOffset: 0, 238 | preProcessingPipeline: [], 239 | caseInsensitiveRegex: true 240 | }; 241 | var contextOptions = Object.assign(standardContextOptions, options); 242 | 243 | if (!options.dynamicAggregateOptions && proposedAggregateOptions) contextOptions.aggregateOptions = filterAggregateOptions(proposedAggregateOptions); 244 | 245 | var context = createContext(contextOptions, loadOptions); 246 | 247 | return loadOptions.group && loadOptions.group.length > 0 ? context.queryGroups(collection) : context.querySimple(collection); 248 | } 249 | 250 | module.exports = query; -------------------------------------------------------------------------------- /dist/options.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 4 | 5 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 6 | 7 | var yup = require('yup'); 8 | 9 | var regexBool = /(true|false)/i; 10 | 11 | function OptionError() { 12 | var message = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 13 | 14 | this.name = 'OptionError'; 15 | this.message = message; 16 | } 17 | OptionError.prototype = Error.prototype; 18 | 19 | var asBool = function asBool(v) { 20 | var match = void 0; 21 | if (typeof v === 'string' && (match = v.match(regexBool))) { 22 | return { 23 | true: true, 24 | false: false 25 | }[match[0].toLowerCase()]; 26 | } else return !!v; 27 | }; 28 | 29 | function fixFilterAndSearch(schema) { 30 | 31 | var operators = ['=', '<>', '>', '>=', '<', '<=']; 32 | 33 | function fixValue(type, value) { 34 | return { 35 | int: parseInt, 36 | float: parseFloat, 37 | datetime: function datetime(v) { 38 | return new Date(v); 39 | }, 40 | bool: asBool 41 | }[type](value); 42 | } 43 | 44 | function fixFilter(f) { 45 | if (!f || !Array.isArray(f)) return f; 46 | if (f.length === 3 && typeof f[2] === 'string' && schema[f[0]] && operators.includes(f[1])) return [f[0], f[1], fixValue(schema[f[0]], f[2])];else return f.map(function (e) { 47 | return fixFilter(e); 48 | }); 49 | } 50 | 51 | function fixSearch(se, so, sv) { 52 | if (!se || !so || !sv || typeof sv !== 'string') return sv; 53 | var fieldName = typeof se === 'string' ? schema[se] : Array.isArray(se) ? se.find(function (e) { 54 | return schema[e] ? e : null; 55 | }) : null; 56 | return fieldName ? fixValue(schema[fieldName], sv) : sv; 57 | } 58 | 59 | return function (qry) { 60 | if (!qry) return qry; 61 | var fixedFilter = fixFilter(parse(qry.filter)); 62 | var fixedSearchValue = fixSearch(qry.searchExpr, qry.searchOperation, qry.searchValue); 63 | 64 | return Object.assign({}, qry, fixedFilter ? { 65 | filter: fixedFilter 66 | } : {}, fixedSearchValue ? { 67 | searchValue: fixedSearchValue 68 | } : {}); 69 | }; 70 | } 71 | 72 | var wrapYupChecker = function wrapYupChecker(yupChecker) { 73 | return { 74 | validate: function validate(o) { 75 | try { 76 | yupChecker.validateSync(o, { strict: true }); 77 | return null; 78 | } catch (e) { 79 | return e; 80 | } 81 | } 82 | }; 83 | }; 84 | 85 | var sortOptionsCheckerYup = yup.object().shape({ 86 | desc: yup.bool().required(), 87 | selector: yup.string().required(), 88 | 89 | isExpanded: yup.mixed() 90 | }).noUnknown(); 91 | 92 | var sortOptionsChecker = wrapYupChecker(sortOptionsCheckerYup); 93 | 94 | yup.addMethod(yup.mixed, 'or', function (schemas, msg) { 95 | return this.test({ 96 | name: 'or', 97 | message: "Can't find valid schema" || msg, 98 | test: function test(value) { 99 | if (!Array.isArray(schemas)) throw new OptionError('"or" requires schema array'); 100 | 101 | var results = schemas.map(function (schema) { 102 | return schema.isValidSync(value, { strict: true }); 103 | }); 104 | return results.some(function (res) { 105 | return !!res; 106 | }); 107 | }, 108 | exclusive: false 109 | }); 110 | }); 111 | 112 | var groupOptionsCheckerYup = yup.object().shape({ 113 | selector: yup.string().required(), 114 | desc: yup.bool(), 115 | isExpanded: yup.bool(), 116 | groupInterval: yup.mixed().or([yup.number().integer(), yup.mixed().oneOf(['year', 'quarter', 'month', 'day', 'dayOfWeek', 'hour', 'minute', 'second'])]) 117 | }).noUnknown(); 118 | 119 | var groupOptionsChecker = wrapYupChecker(groupOptionsCheckerYup); 120 | 121 | var summaryOptionsCheckerYup = yup.object().shape({ 122 | summaryType: yup.mixed().oneOf(['sum', 'avg', 'min', 'max', 'count']).required(), 123 | selector: yup.string() 124 | }).noUnknown(); 125 | 126 | var summaryOptionsChecker = wrapYupChecker(summaryOptionsCheckerYup); 127 | 128 | function validateAll(list, checker) { 129 | var short = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; 130 | 131 | return list.reduce(function (r, v) { 132 | if (short && !r.valid) return r; 133 | var newr = checker.validate(v); 134 | if (newr) { 135 | r.errors.push(newr); 136 | r.valid = false; 137 | } 138 | return r; 139 | }, { valid: true, errors: [] }); 140 | } 141 | 142 | function parse(arg) { 143 | var canBeString = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; 144 | 145 | var ob = arg; 146 | if (typeof arg === 'string') { 147 | try { 148 | ob = JSON.parse(arg); 149 | } catch (e) { 150 | if (!canBeString) throw new OptionError(e.message); 151 | return arg; 152 | } 153 | } 154 | return ob; 155 | } 156 | 157 | function representsTrue(val) { 158 | return val === true || val === 'true'; 159 | } 160 | 161 | function wrapLoadOptions(lo) { 162 | return { 163 | loadOptions: lo 164 | }; 165 | } 166 | 167 | function wrapProcessingOptions(po) { 168 | return { 169 | processingOptions: po 170 | }; 171 | } 172 | 173 | function check(qry, onames, checker) { 174 | var converter = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : function (v) { 175 | return v; 176 | }; 177 | var defaultValue = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : {}; 178 | var wrapper = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : wrapLoadOptions; 179 | 180 | var options = typeof onames === 'string' ? [onames] : onames; 181 | var allFound = qry && options.reduce(function (r, v) { 182 | return r && !!qry[v]; 183 | }, true); 184 | 185 | if (!allFound) return defaultValue; 186 | try { 187 | var vals = options.map(function (o) { 188 | return converter(qry[o], o); 189 | }); 190 | 191 | var checkResult = checker.apply(undefined, _toConsumableArray(vals)); 192 | 193 | return checkResult ? wrapper(checkResult) : { 194 | errors: options.map(function (o) { 195 | return 'Invalid \'' + o + '\': ' + qry[o]; 196 | }) 197 | }; 198 | } catch (err) { 199 | return { 200 | errors: [err] 201 | }; 202 | } 203 | } 204 | 205 | function takeOptions(qry) { 206 | return check(qry, 'take', function (take) { 207 | return take >= 0 ? { 208 | take: take 209 | } : null; 210 | }, function (take) { 211 | return parseInt(take); 212 | }); 213 | } 214 | 215 | function skipOptions(qry) { 216 | return check(qry, 'skip', function (skip) { 217 | return skip >= 0 ? { 218 | skip: skip 219 | } : null; 220 | }, function (skip) { 221 | return parseInt(skip); 222 | }); 223 | } 224 | 225 | function totalCountOptions(qry) { 226 | return check(qry, 'requireTotalCount', function (requireTotalCount) { 227 | return { 228 | requireTotalCount: requireTotalCount 229 | }; 230 | }, function (requireTotalCount) { 231 | return representsTrue(requireTotalCount); 232 | }); 233 | } 234 | 235 | function sortOptions(qry) { 236 | return check(qry, 'sort', function (sort) { 237 | var sortOptions = parse(sort); 238 | if (Array.isArray(sortOptions) && sortOptions.length > 0) { 239 | var vr = validateAll(sortOptions, sortOptionsChecker); 240 | if (vr.valid) return { 241 | sort: sortOptions 242 | };else { 243 | throw new OptionError('Sort parameter validation errors: ' + JSON.stringify(vr.errors)); 244 | } 245 | } else return null; 246 | }, function (sort) { 247 | var sortOptions = parse(sort); 248 | if (Array.isArray(sortOptions)) { 249 | return sortOptions.map(function (s) { 250 | return _extends({}, s, { desc: representsTrue(s.desc) }); 251 | }); 252 | } else return sort; 253 | }); 254 | } 255 | 256 | function groupOptions(qry) { 257 | return check(qry, 'group', function (group) { 258 | var groupOptions = parse(group); 259 | if (Array.isArray(groupOptions)) { 260 | if (groupOptions.length > 0) { 261 | var vr = validateAll(groupOptions, groupOptionsChecker); 262 | if (vr.valid) return mergeResults([wrapLoadOptions({ 263 | group: groupOptions 264 | }), check(qry, 'requireGroupCount', function (requireGroupCount) { 265 | return { 266 | requireGroupCount: requireGroupCount 267 | }; 268 | }, function (requireGroupCount) { 269 | return representsTrue(requireGroupCount); 270 | }), check(qry, 'groupSummary', function (groupSummary) { 271 | var gsOptions = parse(groupSummary); 272 | if (Array.isArray(gsOptions)) { 273 | if (gsOptions.length > 0) { 274 | var _vr = validateAll(gsOptions, summaryOptionsChecker); 275 | if (_vr.valid) return { 276 | groupSummary: gsOptions 277 | };else throw new OptionError('Group summary parameter validation errors: ' + JSON.stringify(_vr.errors)); 278 | } else return {}; 279 | } else return null; 280 | })]);else throw new OptionError('Group parameter validation errors: ' + JSON.stringify(vr.errors)); 281 | } else return {}; 282 | } else return null; 283 | }, function (group) { 284 | var groupOptions = parse(group); 285 | if (Array.isArray(groupOptions)) { 286 | return groupOptions.map(function (g) { 287 | return _extends({}, g, { 288 | isExpanded: representsTrue(g.isExpanded) 289 | }); 290 | }); 291 | } else return group; 292 | }, undefined, function (o) { 293 | return o; 294 | }); 295 | } 296 | 297 | function totalSummaryOptions(qry) { 298 | return check(qry, 'totalSummary', function (totalSummary) { 299 | var tsOptions = parse(totalSummary); 300 | if (Array.isArray(tsOptions)) { 301 | if (tsOptions.length > 0) { 302 | var vr = validateAll(tsOptions, summaryOptionsChecker); 303 | if (vr.valid) return { 304 | totalSummary: tsOptions 305 | };else throw new OptionError('Total summary parameter validation errors: ' + JSON.stringify(vr.errors)); 306 | } else return {}; 307 | } else return null; 308 | }); 309 | } 310 | 311 | function filterOptions(qry) { 312 | return check(qry, 'filter', function (filter) { 313 | var filterOptions = parse(filter, true); 314 | if (typeof filterOptions === 'string' || Array.isArray(filterOptions)) return { 315 | filter: filterOptions 316 | };else return null; 317 | }); 318 | } 319 | 320 | function searchOptions(qry) { 321 | return check(qry, ['searchExpr', 'searchOperation', 'searchValue'], function (se, so, sv) { 322 | if (typeof se === 'string' || Array.isArray(se)) return { 323 | searchExpr: se, 324 | searchOperation: so, 325 | searchValue: sv 326 | };else return null; 327 | }); 328 | } 329 | 330 | function selectOptions(qry) { 331 | return check(qry, 'select', function (select) { 332 | var selectOptions = parse(select, true); 333 | if (typeof selectOptions === 'string') return { 334 | select: [selectOptions] 335 | };else if (Array.isArray(selectOptions)) { 336 | if (selectOptions.length > 0) { 337 | if (selectOptions.reduce(function (r, v) { 338 | return r && typeof v === 'string'; 339 | })) return { 340 | select: selectOptions 341 | };else throw new OptionError('Select array parameter has invalid content: ' + JSON.stringify(selectOptions)); 342 | } else return {}; 343 | } else return null; 344 | }); 345 | } 346 | 347 | function timezoneOptions(qry) { 348 | return check(qry, 'tzOffset', function (tzOffset) { 349 | return { 350 | timezoneOffset: parseInt(tzOffset) || 0 351 | }; 352 | }, function (v) { 353 | return v; 354 | }, { 355 | timezoneOffset: 0 356 | }, wrapProcessingOptions); 357 | } 358 | 359 | function caseInsensitiveRegexOptions(qry) { 360 | return check(qry, 'caseInsensitiveRegex', function (caseInsensitiveRegex) { 361 | return { 362 | caseInsensitiveRegex: caseInsensitiveRegex 363 | }; 364 | }, function (caseInsensitiveRegex) { 365 | return representsTrue(caseInsensitiveRegex); 366 | }, { caseInsensitiveRegex: true }, wrapProcessingOptions); 367 | } 368 | 369 | function summaryQueryLimitOptions(qry) { 370 | return check(qry, 'summaryQueryLimit', function (sql) { 371 | return sql >= 0 ? { 372 | summaryQueryLimit: sql 373 | } : {}; 374 | }, function (sql) { 375 | return parseInt(sql); 376 | }, {}, wrapProcessingOptions); 377 | } 378 | 379 | function mergeResults(results) { 380 | return results.reduce(function (r, v) { 381 | return { 382 | loadOptions: _extends({}, r.loadOptions || {}, v.loadOptions || {}), 383 | processingOptions: _extends({}, r.processingOptions || {}, v.processingOptions || {}), 384 | errors: [].concat(_toConsumableArray(r.errors || []), _toConsumableArray(v.errors || [])) 385 | }; 386 | }, {}); 387 | } 388 | 389 | function getOptions(qry, schema) { 390 | if (!qry) return undefined; 391 | 392 | var fixedQry = schema ? fixFilterAndSearch(schema)(qry) : qry; 393 | 394 | return mergeResults([takeOptions, skipOptions, totalCountOptions, sortOptions, groupOptions, totalSummaryOptions, filterOptions, searchOptions, selectOptions, timezoneOptions, summaryQueryLimitOptions, caseInsensitiveRegexOptions].map(function (f) { 395 | return f(fixedQry); 396 | })); 397 | } 398 | 399 | module.exports = { 400 | getOptions: getOptions, 401 | private: { 402 | fixFilterAndSearch: fixFilterAndSearch, 403 | validateAll: validateAll, 404 | check: check, 405 | takeOptions: takeOptions, 406 | skipOptions: skipOptions, 407 | totalCountOptions: totalCountOptions, 408 | sortOptions: sortOptions, 409 | groupOptions: groupOptions, 410 | totalSummaryOptions: totalSummaryOptions, 411 | filterOptions: filterOptions, 412 | searchOptions: searchOptions, 413 | selectOptions: selectOptions, 414 | sortOptionsChecker: sortOptionsChecker, 415 | groupOptionsChecker: groupOptionsChecker, 416 | summaryOptionsChecker: summaryOptionsChecker, 417 | asBool: asBool, 418 | parse: parse 419 | } 420 | }; -------------------------------------------------------------------------------- /dist/options.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'); 4 | var expect = chai.expect; 5 | var qs = require('qs'); 6 | 7 | var _require = require('./options'), 8 | getOptions = _require.getOptions, 9 | _require$private = _require.private, 10 | fixFilterAndSearch = _require$private.fixFilterAndSearch, 11 | validateAll = _require$private.validateAll, 12 | check = _require$private.check, 13 | takeOptions = _require$private.takeOptions, 14 | skipOptions = _require$private.skipOptions, 15 | totalCountOptions = _require$private.totalCountOptions, 16 | sortOptions = _require$private.sortOptions, 17 | groupOptions = _require$private.groupOptions, 18 | totalSummaryOptions = _require$private.totalSummaryOptions, 19 | filterOptions = _require$private.filterOptions, 20 | searchOptions = _require$private.searchOptions, 21 | selectOptions = _require$private.selectOptions, 22 | sortOptionsChecker = _require$private.sortOptionsChecker, 23 | groupOptionsChecker = _require$private.groupOptionsChecker, 24 | summaryOptionsChecker = _require$private.summaryOptionsChecker, 25 | asBool = _require$private.asBool, 26 | parse = _require$private.parse; 27 | 28 | function testOptions(queryString, expectedResult, schema) { 29 | var result = getOptions(qs.parse(queryString), schema); 30 | 31 | 32 | expect(result).to.eql(expectedResult); 33 | } 34 | 35 | suite('parse', function () { 36 | test('parse correctly', function () { 37 | var source = { test: 'text', test2: 42 }; 38 | expect(parse(JSON.stringify(source))).to.eql(source); 39 | }); 40 | 41 | test('parse error', function () { 42 | var sourceText = '{"field1":"thing",error:true}'; 43 | try { 44 | parse(sourceText); 45 | throw new Error('parse did not throw!'); 46 | } catch (e) { 47 | expect(JSON.stringify(e)).to.not.eql('{}'); 48 | } 49 | }); 50 | }); 51 | 52 | suite('asBool', function () { 53 | test('true', function () { 54 | expect(asBool(true)).to.be.true; 55 | }); 56 | 57 | test('false', function () { 58 | expect(asBool(false)).to.be.false; 59 | }); 60 | 61 | test('true string', function () { 62 | expect(asBool('TrUe')).to.be.true; 63 | }); 64 | 65 | test('false string', function () { 66 | expect(asBool('FaLsE')).to.be.false; 67 | }); 68 | 69 | test('thruthy number', function () { 70 | expect(asBool(7)).to.be.true; 71 | }); 72 | 73 | test('falsy number', function () { 74 | expect(asBool(0)).to.be.false; 75 | }); 76 | 77 | test('thruthy string', function () { 78 | expect(asBool('something')).to.be.true; 79 | }); 80 | 81 | test('falsy string', function () { 82 | expect(asBool('')).to.be.false; 83 | }); 84 | }); 85 | 86 | suite('summaryOptionsChecker', function () { 87 | test('valid', function () { 88 | var result = summaryOptionsChecker.validate({ 89 | summaryType: 'sum', 90 | selector: 'thing' 91 | }); 92 | expect(result).to.eql(null); 93 | }); 94 | 95 | test('extra prop', function () { 96 | var result = summaryOptionsChecker.validate({ 97 | summaryType: 'sum', 98 | selector: 'thing', 99 | extra: 'thing' 100 | }); 101 | expect(result).to.include({ name: 'ValidationError', type: 'noUnknown' }); 102 | }); 103 | 104 | test('invalid summaryType', function () { 105 | var result = summaryOptionsChecker.validate({ 106 | summaryType: 'unknown', 107 | selector: 'thing' 108 | }); 109 | expect(result).to.include({ name: 'ValidationError', type: 'oneOf' }); 110 | }); 111 | 112 | test('invalid selector', function () { 113 | var result = summaryOptionsChecker.validate({ 114 | summaryType: 'sum', 115 | selector: true 116 | }); 117 | expect(result).to.include({ name: 'ValidationError', type: 'typeError' }); 118 | }); 119 | }); 120 | 121 | suite('groupOptionsChecker', function () { 122 | test('valid', function () { 123 | var result = groupOptionsChecker.validate({ 124 | selector: 'thing', 125 | desc: true, 126 | isExpanded: true, 127 | groupInterval: 'year' 128 | }); 129 | expect(result).to.eql(null); 130 | }); 131 | 132 | test('valid with groupInterval integer', function () { 133 | var result = groupOptionsChecker.validate({ 134 | selector: 'thing', 135 | desc: true, 136 | isExpanded: true, 137 | groupInterval: 11 138 | }); 139 | expect(result).to.eql(null); 140 | }); 141 | 142 | test('extra prop', function () { 143 | var result = groupOptionsChecker.validate({ 144 | selector: 'thing', 145 | desc: true, 146 | isExpanded: true, 147 | groupInterval: 'year', 148 | extra: 'thing' 149 | }); 150 | expect(result).to.include({ name: 'ValidationError', type: 'noUnknown' }); 151 | }); 152 | 153 | test('missing selector', function () { 154 | var result = groupOptionsChecker.validate({ 155 | desc: true, 156 | isExpanded: true, 157 | groupInterval: 'year' 158 | }); 159 | expect(result).to.include({ name: 'ValidationError', type: 'optionality' }); 160 | }); 161 | 162 | test('invalid desc', function () { 163 | var result = groupOptionsChecker.validate({ 164 | selector: 'thing', 165 | desc: 42, 166 | isExpanded: true, 167 | groupInterval: 'year' 168 | }); 169 | expect(result).to.include({ name: 'ValidationError', type: 'typeError' }); 170 | }); 171 | 172 | test('invalid selector', function () { 173 | var result = groupOptionsChecker.validate({ 174 | selector: 42, 175 | desc: true, 176 | isExpanded: true, 177 | groupInterval: 'year' 178 | }); 179 | expect(result).to.include({ name: 'ValidationError', type: 'typeError' }); 180 | }); 181 | 182 | test('invalid isExpanded', function () { 183 | var result = groupOptionsChecker.validate({ 184 | selector: 'thing', 185 | desc: true, 186 | isExpanded: 42, 187 | groupInterval: 'year' 188 | }); 189 | expect(result).to.include({ name: 'ValidationError', type: 'typeError' }); 190 | }); 191 | 192 | test('invalid groupInterval - not string or number', function () { 193 | var result = groupOptionsChecker.validate({ 194 | selector: 'thing', 195 | desc: true, 196 | isExpanded: true, 197 | groupInterval: true 198 | }); 199 | expect(result).to.include({ name: 'ValidationError', type: 'or' }); 200 | }); 201 | 202 | test('invalid groupInterval - not string or integer', function () { 203 | var result = groupOptionsChecker.validate({ 204 | selector: 'thing', 205 | desc: true, 206 | isExpanded: true, 207 | groupInterval: 10.3 208 | }); 209 | expect(result).to.include({ name: 'ValidationError', type: 'or' }); 210 | }); 211 | 212 | test('invalid groupInterval - invalid string', function () { 213 | var result = groupOptionsChecker.validate({ 214 | selector: 'thing', 215 | desc: true, 216 | isExpanded: true, 217 | groupInterval: 'wrong string' 218 | }); 219 | expect(result).to.include({ name: 'ValidationError', type: 'or' }); 220 | }); 221 | }); 222 | 223 | suite('sortOptionsChecker', function () { 224 | test('missing desc', function () { 225 | var result = sortOptionsChecker.validate({ selector: 'thing' }); 226 | expect(result).to.include({ name: 'ValidationError', type: 'optionality' }); 227 | }); 228 | 229 | test('missing selector', function () { 230 | var result = sortOptionsChecker.validate({ desc: true }); 231 | expect(result).to.include({ name: 'ValidationError', type: 'optionality' }); 232 | }); 233 | 234 | test('valid', function () { 235 | var result = sortOptionsChecker.validate({ 236 | selector: 'thing', 237 | desc: true 238 | }); 239 | expect(result).to.eql(null); 240 | }); 241 | 242 | test('valid with isExpanded', function () { 243 | var result = sortOptionsChecker.validate({ 244 | selector: 'thing', 245 | desc: true, 246 | isExpanded: 'random thing' 247 | }); 248 | expect(result).to.eql(null); 249 | }); 250 | 251 | test('extra prop', function () { 252 | var result = sortOptionsChecker.validate({ 253 | selector: 'thing', 254 | desc: true, 255 | extra: 'thing' 256 | }); 257 | expect(result).to.include({ name: 'ValidationError', type: 'noUnknown' }); 258 | }); 259 | 260 | test('incorrect desc type', function () { 261 | var result = sortOptionsChecker.validate({ 262 | selector: 'thing', 263 | desc: 42 264 | }); 265 | 266 | expect(result).to.include({ name: 'ValidationError', type: 'typeError' }); 267 | }); 268 | 269 | test('incorrect selector type', function () { 270 | var result = sortOptionsChecker.validate({ 271 | selector: 42, 272 | desc: true 273 | }); 274 | expect(result).to.include({ name: 'ValidationError', type: 'typeError' }); 275 | }); 276 | }); 277 | 278 | suite('takeOptions', function () { 279 | test('valid', function () { 280 | expect(takeOptions({ take: 10 })).to.eql({ loadOptions: { take: 10 } }); 281 | }); 282 | 283 | test('valid string value', function () { 284 | expect(takeOptions({ take: '10' })).to.eql({ loadOptions: { take: 10 } }); 285 | }); 286 | 287 | test('missing parameter', function () { 288 | expect(takeOptions({ tk: 10 })).to.eql({}); 289 | }); 290 | 291 | test('invalid value', function () { 292 | expect(takeOptions({ take: -10 })).to.eql({ 293 | errors: ['Invalid \'take\': -10'] 294 | }); 295 | }); 296 | }); 297 | 298 | suite('skipOptions', function () { 299 | test('valid', function () { 300 | expect(skipOptions({ skip: 10 })).to.eql({ loadOptions: { skip: 10 } }); 301 | }); 302 | 303 | test('valid string value', function () { 304 | expect(skipOptions({ skip: '10' })).to.eql({ loadOptions: { skip: 10 } }); 305 | }); 306 | 307 | test('missing parameter', function () { 308 | expect(skipOptions({ skp: 10 })).to.eql({}); 309 | }); 310 | 311 | test('invalid value', function () { 312 | expect(skipOptions({ skip: -10 })).to.eql({ 313 | errors: ['Invalid \'skip\': -10'] 314 | }); 315 | }); 316 | }); 317 | 318 | suite('totalCountOptions', function () { 319 | test('valid', function () { 320 | expect(totalCountOptions({ requireTotalCount: true })).to.eql({ 321 | loadOptions: { requireTotalCount: true } 322 | }); 323 | }); 324 | 325 | test('valid string value', function () { 326 | expect(totalCountOptions({ requireTotalCount: 'true' })).to.eql({ 327 | loadOptions: { requireTotalCount: true } 328 | }); 329 | }); 330 | 331 | test('missing parameter', function () { 332 | expect(totalCountOptions({ rqtc: true })).to.eql({}); 333 | }); 334 | }); 335 | 336 | suite('sortOptions', function () { 337 | test('invalid', function () { 338 | var result = sortOptions({ sort: ['thing'] }); 339 | expect(result.errors[0].message).to.match(/^Sort parameter validation errors/); 340 | }); 341 | 342 | test('empty', function () { 343 | var result = sortOptions({ sort: [] }); 344 | expect(result).to.eql({ errors: ['Invalid \'sort\': '] }); 345 | }); 346 | }); 347 | 348 | suite('totalSummaryOptions', function () { 349 | test('invalid', function () { 350 | var result = totalSummaryOptions({ totalSummary: ['thing'] }); 351 | expect(result.errors[0].message).to.match(/^Total summary parameter validation errors/); 352 | }); 353 | 354 | test('empty', function () { 355 | var result = totalSummaryOptions({ totalSummary: [] }); 356 | expect(result).to.eql({ loadOptions: {} }); 357 | }); 358 | 359 | test('non-array', function () { 360 | var result = totalSummaryOptions({ totalSummary: {} }); 361 | expect(result).to.eql({ 362 | errors: ['Invalid \'totalSummary\': [object Object]'] 363 | }); 364 | }); 365 | }); 366 | 367 | suite('filterOptions', function () { 368 | test('valid array', function () { 369 | var result = filterOptions({ filter: ['thing'] }); 370 | expect(result).to.eql({ loadOptions: { filter: ['thing'] } }); 371 | }); 372 | 373 | test('valid string', function () { 374 | var result = filterOptions({ filter: 'thing' }); 375 | expect(result).to.eql({ loadOptions: { filter: 'thing' } }); 376 | }); 377 | 378 | test('valid string with an array inside', function () { 379 | var result = filterOptions({ filter: '["thing"]' }); 380 | expect(result).to.eql({ loadOptions: { filter: ['thing'] } }); 381 | }); 382 | 383 | test('not string or array', function () { 384 | var result = filterOptions({ filter: {} }); 385 | expect(result).to.eql({ errors: ['Invalid \'filter\': [object Object]'] }); 386 | }); 387 | }); 388 | 389 | suite('searchOptions', function () { 390 | test('valid with string', function () { 391 | var result = searchOptions({ 392 | searchExpr: 'expr', 393 | searchOperation: '=', 394 | searchValue: 'val' 395 | }); 396 | expect(result).to.eql({ 397 | loadOptions: { 398 | searchExpr: 'expr', 399 | searchOperation: '=', 400 | searchValue: 'val' 401 | } 402 | }); 403 | }); 404 | 405 | test('valid with array', function () { 406 | var result = searchOptions({ 407 | searchExpr: ['expr1', 'expr2'], 408 | searchOperation: '=', 409 | searchValue: 'val' 410 | }); 411 | expect(result).to.eql({ 412 | loadOptions: { 413 | searchExpr: ['expr1', 'expr2'], 414 | searchOperation: '=', 415 | searchValue: 'val' 416 | } 417 | }); 418 | }); 419 | 420 | test('invalid searchExpr', function () { 421 | var result = searchOptions({ 422 | searchExpr: 42, 423 | searchOperation: '=', 424 | searchValue: 'val' 425 | }); 426 | expect(result).to.eql({ 427 | errors: ['Invalid \'searchExpr\': 42', 'Invalid \'searchOperation\': =', 'Invalid \'searchValue\': val'] 428 | }); 429 | }); 430 | }); 431 | 432 | suite('selectOptions', function () { 433 | test('valid string', function () { 434 | var result = selectOptions({ select: 'something' }); 435 | expect(result).to.eql({ loadOptions: { select: ['something'] } }); 436 | }); 437 | 438 | test('valid array', function () { 439 | var result = selectOptions({ select: ['something', 'other'] }); 440 | expect(result).to.eql({ loadOptions: { select: ['something', 'other'] } }); 441 | }); 442 | 443 | test('array with invalid content', function () { 444 | var result = selectOptions({ select: ['something', 'other', 42] }); 445 | expect(result.errors[0].message).to.match(/Select array parameter has invalid content/); 446 | }); 447 | 448 | test('empty array', function () { 449 | var result = selectOptions({ select: [] }); 450 | expect(result).to.eql({ loadOptions: {} }); 451 | }); 452 | 453 | test('type other than string and array', function () { 454 | var result = selectOptions({ select: 42 }); 455 | expect(result).to.eql({ errors: ['Invalid \'select\': 42'] }); 456 | }); 457 | }); 458 | 459 | suite('groupOptions', function () { 460 | test('invalid top level options', function () { 461 | var result = groupOptions({ group: ['thing'] }); 462 | expect(result.errors[0].message).to.match(/^Group parameter validation errors/); 463 | }); 464 | 465 | test('empty top level options', function () { 466 | var result = groupOptions({ group: [] }); 467 | expect(result).to.eql({}); 468 | }); 469 | 470 | test('non-array top level options', function () { 471 | var result = groupOptions({ group: {} }); 472 | expect(result).to.eql({ errors: ['Invalid \'group\': [object Object]'] }); 473 | }); 474 | 475 | test('invalid group summary options', function () { 476 | var result = groupOptions({ 477 | group: [{ selector: 'x' }], 478 | groupSummary: ['thing'] 479 | }); 480 | expect(result.errors[0].message).to.match(/^Group summary parameter validation errors/); 481 | }); 482 | 483 | test('empty group summary options', function () { 484 | var result = groupOptions({ 485 | group: [{ selector: 'x' }], 486 | groupSummary: [] 487 | }); 488 | expect(result).to.eql({ 489 | errors: [], 490 | loadOptions: { group: [{ isExpanded: false, selector: 'x' }] }, 491 | processingOptions: {} 492 | }); 493 | }); 494 | 495 | test('non-array group summary options', function () { 496 | var result = groupOptions({ 497 | group: [{ selector: 'x' }], 498 | groupSummary: {} 499 | }); 500 | expect(result).to.eql({ 501 | errors: ['Invalid \'groupSummary\': [object Object]'], 502 | loadOptions: { group: [{ isExpanded: false, selector: 'x' }] }, 503 | processingOptions: {} 504 | }); 505 | }); 506 | }); 507 | 508 | suite('check', function () { 509 | test('default value for one option', function () { 510 | expect(check({ one: 1, other: 2 }, 'nonexistent', undefined, undefined, 'default value')).to.eql('default value'); 511 | }); 512 | 513 | test('default value for multiple options', function () { 514 | expect(check({ one: 1, other: 2 }, ['one', 'nonexistent'], undefined, undefined, 'default value')).to.eql('default value'); 515 | }); 516 | 517 | test('simple converter', function () { 518 | expect(check({ one: 1, other: 2 }, ['one', 'other'], function (one, other) { 519 | return { 520 | one: one, 521 | other: other 522 | }; 523 | }, function (x) { 524 | return x * 2; 525 | })).to.eql({ loadOptions: { one: 2, other: 4 } }); 526 | }); 527 | 528 | test('checker is unhappy', function () { 529 | expect(check({ one: 1, other: 2 }, ['one', 'other'], function () { 530 | return undefined; 531 | })).to.eql({ errors: ['Invalid \'one\': 1', 'Invalid \'other\': 2'] }); 532 | }); 533 | 534 | test('checker is really unhappy', function () { 535 | expect(check({ one: 1, other: 2 }, ['one', 'other'], function () { 536 | throw 'argh!'; 537 | })).to.eql({ errors: ['argh!'] }); 538 | }); 539 | }); 540 | 541 | suite('validateAll', function () { 542 | test('simple short circuit test', function () { 543 | var checker = { validate: function validate(x) { 544 | return x > 10; 545 | } }; 546 | expect(validateAll([5, 7, 15, 25], checker, true)).to.eql({ 547 | valid: false, 548 | errors: [true] 549 | }); 550 | }); 551 | 552 | test('test without short circuit', function () { 553 | var checker = { validate: function validate(x) { 554 | return x > 10; 555 | } }; 556 | expect(validateAll([5, 7, 15, 25], checker, false)).to.eql({ 557 | valid: false, 558 | errors: [true, true] 559 | }); 560 | }); 561 | }); 562 | 563 | suite('fixFilterAndSearch', function () { 564 | test('fixes filter int', function () { 565 | expect(fixFilterAndSearch({ 566 | int: 'int' 567 | })({ 568 | filter: [['int', '=', '34']] 569 | })).to.eql({ 570 | filter: [['int', '=', 34]] 571 | }); 572 | }); 573 | 574 | test('accepts undefined query input', function () { 575 | expect(fixFilterAndSearch('schema')(undefined)).to.eql(undefined); 576 | }); 577 | 578 | test('fixes search int', function () { 579 | expect(fixFilterAndSearch({ 580 | int: 'int' 581 | })({ 582 | searchExpr: 'int', 583 | searchOperation: '=', 584 | searchValue: '34' 585 | })).to.eql({ 586 | searchExpr: 'int', 587 | searchOperation: '=', 588 | searchValue: 34 589 | }); 590 | }); 591 | 592 | test('no fix for field not defined in schema', function () { 593 | expect(fixFilterAndSearch({ 594 | int: 'int' 595 | })({ 596 | searchExpr: 'other', 597 | searchOperation: '=', 598 | searchValue: '34' 599 | })).to.eql({ 600 | searchExpr: 'other', 601 | searchOperation: '=', 602 | searchValue: '34' 603 | }); 604 | }); 605 | 606 | test('fixes search expr list', function () { 607 | expect(fixFilterAndSearch({ 608 | int: 'int' 609 | })({ 610 | searchExpr: ['int', 'other'], 611 | searchOperation: '=', 612 | searchValue: '34' 613 | })).to.eql({ 614 | searchExpr: ['int', 'other'], 615 | searchOperation: '=', 616 | searchValue: 34 617 | }); 618 | }); 619 | 620 | test('no fix for expr list without schema entries', function () { 621 | expect(fixFilterAndSearch({ 622 | int: 'int' 623 | })({ 624 | searchExpr: ['one', 'other'], 625 | searchOperation: '=', 626 | searchValue: '34' 627 | })).to.eql({ 628 | searchExpr: ['one', 'other'], 629 | searchOperation: '=', 630 | searchValue: '34' 631 | }); 632 | }); 633 | 634 | test('no fix for expr thats not string or array', function () { 635 | expect(fixFilterAndSearch({ 636 | int: 'int' 637 | })({ 638 | searchExpr: 42, 639 | searchOperation: '=', 640 | searchValue: '34' 641 | })).to.eql({ 642 | searchExpr: 42, 643 | searchOperation: '=', 644 | searchValue: '34' 645 | }); 646 | }); 647 | }); 648 | 649 | suite('getOptions', function () { 650 | test('take and total count', function () { 651 | testOptions('take=10&requireTotalCount=true', { 652 | errors: [], 653 | loadOptions: { 654 | take: 10, 655 | requireTotalCount: true 656 | }, 657 | processingOptions: {} 658 | }); 659 | }); 660 | 661 | test('take and total count with tzOffset', function () { 662 | testOptions('take=10&requireTotalCount=true&tzOffset=-60', { 663 | errors: [], 664 | loadOptions: { 665 | take: 10, 666 | requireTotalCount: true 667 | }, 668 | processingOptions: { 669 | timezoneOffset: -60 670 | } 671 | }); 672 | }); 673 | 674 | test('take and total count with caseInsensitiveRegex', function () { 675 | testOptions('take=10&requireTotalCount=true&caseInsensitiveRegex=false', { 676 | errors: [], 677 | loadOptions: { 678 | take: 10, 679 | requireTotalCount: true 680 | }, 681 | processingOptions: { 682 | caseInsensitiveRegex: false 683 | } 684 | }); 685 | }); 686 | 687 | test('take, skip, total count', function () { 688 | testOptions('take=10&requireTotalCount=true&skip=30', { 689 | errors: [], 690 | loadOptions: { 691 | take: 10, 692 | skip: 30, 693 | requireTotalCount: true 694 | }, 695 | processingOptions: {} 696 | }); 697 | }); 698 | 699 | test('contains 3 digits', function () { 700 | testOptions('filter%5B0%5D%5B0%5D=field&filter%5B0%5D%5B1%5D=contains&filter%5B0%5D%5B2%5D=234', { 701 | errors: [], 702 | loadOptions: { 703 | filter: [['field', 'contains', '234']] 704 | }, 705 | processingOptions: {} 706 | }); 707 | }); 708 | 709 | test('contains 4 digits', function () { 710 | testOptions('filter%5B0%5D%5B0%5D=field&filter%5B0%5D%5B1%5D=contains&filter%5B0%5D%5B2%5D=2345', { 711 | errors: [], 712 | loadOptions: { 713 | filter: [['field', 'contains', '2345']] 714 | }, 715 | processingOptions: {} 716 | }); 717 | }); 718 | 719 | test('contains 4 digits with dashes', function () { 720 | testOptions('filter%5B0%5D%5B0%5D=field&filter%5B0%5D%5B1%5D=contains&filter%5B0%5D%5B2%5D=23-45', { 721 | errors: [], 722 | loadOptions: { 723 | filter: [['field', 'contains', '23-45']] 724 | }, 725 | processingOptions: {} 726 | }); 727 | }); 728 | 729 | test('contains 5 digits', function () { 730 | testOptions('filter%5B0%5D%5B0%5D=field&filter%5B0%5D%5B1%5D=contains&filter%5B0%5D%5B2%5D=23456', { 731 | errors: [], 732 | loadOptions: { 733 | filter: [['field', 'contains', '23456']] 734 | }, 735 | processingOptions: {} 736 | }); 737 | }); 738 | 739 | test('contains 3 chars', function () { 740 | testOptions('filter%5B0%5D%5B0%5D=field&filter%5B0%5D%5B1%5D=contains&filter%5B0%5D%5B2%5D=abc', { 741 | errors: [], 742 | loadOptions: { 743 | filter: [['field', 'contains', 'abc']] 744 | }, 745 | processingOptions: {} 746 | }); 747 | }); 748 | 749 | test('contains 4 chars', function () { 750 | testOptions('filter%5B0%5D%5B0%5D=field&filter%5B0%5D%5B1%5D=contains&filter%5B0%5D%5B2%5D=abcd', { 751 | errors: [], 752 | loadOptions: { 753 | filter: [['field', 'contains', 'abcd']] 754 | }, 755 | processingOptions: {} 756 | }); 757 | }); 758 | 759 | test('sort, take and total count', function () { 760 | testOptions('sort%5B0%5D%5Bselector%5D=date2&sort%5B0%5D%5Bdesc%5D=false&take=10&requireTotalCount=true', { 761 | errors: [], 762 | loadOptions: { 763 | sort: [{ 764 | selector: 'date2', 765 | desc: false 766 | }], 767 | take: 10, 768 | requireTotalCount: true 769 | }, 770 | processingOptions: {} 771 | }); 772 | }); 773 | 774 | test('sort with correct parameter', function () { 775 | testOptions('sort=[{%22selector%22:%22population%22,%22desc%22:true}]', { 776 | errors: [], 777 | loadOptions: { 778 | sort: [{ 779 | selector: 'population', 780 | desc: true 781 | }] 782 | }, 783 | processingOptions: {} 784 | }); 785 | }); 786 | 787 | test('sort with incorrect parameter', function () { 788 | var queryString = 'sort=[{%22selectorX%22:%22population%22,%22desc%22:true}]'; 789 | var result = getOptions(qs.parse(queryString)); 790 | 791 | expect(result.errors.length).to.eql(1); 792 | 793 | expect(result.errors[0].message).to.be.a('string').and.satisfy(function (s) { 794 | return s.startsWith('Sort parameter validation errors:'); 795 | }); 796 | }); 797 | 798 | test('issue #10 - filter works when given as array', function () { 799 | expect(getOptions({ 800 | filter: [['dtFinished', '>=', '2018-08-01T16:20:30.000Z'], 'and', ['dtFinished', '<', '2018-08-01T16:20:30.000Z']] 801 | }, { dtFinished: 'datetime' })).to.eql({ 802 | errors: [], 803 | loadOptions: { 804 | filter: [['dtFinished', '>=', new Date('2018-08-01T16:20:30.000Z')], 'and', ['dtFinished', '<', new Date('2018-08-01T16:20:30.000Z')]] 805 | }, 806 | processingOptions: {} 807 | }); 808 | }); 809 | 810 | test('filter works with a bool value', function () { 811 | expect(getOptions({ 812 | filter: [['done', '=', true]] 813 | })).to.eql({ 814 | errors: [], 815 | loadOptions: { 816 | filter: [['done', '=', true]] 817 | }, 818 | processingOptions: {} 819 | }); 820 | }); 821 | 822 | test('filter works with a bool value given as a string', function () { 823 | expect(getOptions({ 824 | filter: [['done', '=', 'true']] 825 | }, { done: 'bool' })).to.eql({ 826 | errors: [], 827 | loadOptions: { 828 | filter: [['done', '=', true]] 829 | }, 830 | processingOptions: {} 831 | }); 832 | }); 833 | 834 | test('issue #10 - filter works when given as string', function () { 835 | expect(getOptions({ 836 | filter: '[["dtFinished",">=","2018-08-01T16:20:30.000Z"],"and",["dtFinished","<","2018-08-01T16:20:30.000Z"]]' 837 | }, { dtFinished: 'datetime' })).to.eql({ 838 | errors: [], 839 | loadOptions: { 840 | filter: [['dtFinished', '>=', new Date('2018-08-01T16:20:30.000Z')], 'and', ['dtFinished', '<', new Date('2018-08-01T16:20:30.000Z')]] 841 | }, 842 | processingOptions: {} 843 | }); 844 | }); 845 | 846 | test('total count, group, group count', function () { 847 | testOptions('sort%5B0%5D%5Bselector%5D=date2&sort%5B0%5D%5Bdesc%5D=false&requireTotalCount=true&group%5B0%5D%5Bselector%5D=date2&group%5B0%5D%5BisExpanded%5D=false&requireGroupCount=true', { 848 | errors: [], 849 | loadOptions: { 850 | sort: [{ 851 | selector: 'date2', 852 | desc: false 853 | }], 854 | requireTotalCount: true, 855 | group: [{ 856 | selector: 'date2', 857 | isExpanded: false 858 | }], 859 | requireGroupCount: true 860 | }, 861 | processingOptions: {} 862 | }); 863 | }); 864 | 865 | test('sort, filter with datetime in schema', function () { 866 | testOptions('sort%5B0%5D%5Bselector%5D=date2&sort%5B0%5D%5Bdesc%5D=false&filter%5B0%5D%5B0%5D=date2&filter%5B0%5D%5B1%5D=%3D&filter%5B0%5D%5B2%5D=2017-07-13T00%3A00%3A00.000Z', { 867 | errors: [], 868 | loadOptions: { 869 | sort: [{ 870 | selector: 'date2', 871 | desc: false 872 | }], 873 | filter: [['date2', '=', new Date(Date.parse('2017-07-13'))]] 874 | }, 875 | processingOptions: {} 876 | }, { date2: 'datetime' }); 877 | }); 878 | 879 | test('take, total count, filter with int', function () { 880 | testOptions('take=10&requireTotalCount=true&filter%5B0%5D%5B0%5D=int1&filter%5B0%5D%5B1%5D=%3D&filter%5B0%5D%5B2%5D=4', { 881 | errors: [], 882 | loadOptions: { 883 | take: 10, 884 | requireTotalCount: true, 885 | filter: [['int1', '=', 4]] 886 | }, 887 | processingOptions: {} 888 | }, { 889 | int1: 'int' 890 | }); 891 | }); 892 | 893 | test('summaryQueryLimit, skip, take, requireTotalCount, totalSummary, tzOffset', function () { 894 | testOptions('summaryQueryLimit=500&skip=0&take=20&requireTotalCount=true&totalSummary=%5B%7B%22selector%22%3A%22date1%22%2C%22summaryType%22%3A%22max%22%7D%2C%7B%22selector%22%3A%22int1%22%2C%22summaryType%22%3A%22avg%22%7D%2C%7B%22selector%22%3A%22int1%22%2C%22summaryType%22%3A%22sum%22%7D%5D&tzOffset=-60', { 895 | errors: [], 896 | loadOptions: { 897 | skip: 0, 898 | take: 20, 899 | requireTotalCount: true, 900 | totalSummary: [{ 901 | selector: 'date1', 902 | summaryType: 'max' 903 | }, { 904 | selector: 'int1', 905 | summaryType: 'avg' 906 | }, { 907 | selector: 'int1', 908 | summaryType: 'sum' 909 | }] 910 | }, 911 | processingOptions: { 912 | timezoneOffset: -60, 913 | summaryQueryLimit: 500 914 | } 915 | }); 916 | }); 917 | 918 | test('summaryQueryLimit, skip, take, requireTotalCount, totalSummary, group, requireGroupCount, groupSummary, tzOffset', function () { 919 | testOptions('summaryQueryLimit=500&skip=0&take=20&requireTotalCount=true&totalSummary=%5B%7B%22selector%22%3A%22date1%22%2C%22summaryType%22%3A%22max%22%7D%2C%7B%22selector%22%3A%22int1%22%2C%22summaryType%22%3A%22avg%22%7D%2C%7B%22selector%22%3A%22int1%22%2C%22summaryType%22%3A%22sum%22%7D%5D&group=%5B%7B%22selector%22%3A%22int1%22%2C%22desc%22%3Afalse%2C%22isExpanded%22%3Afalse%7D%5D&requireGroupCount=true&groupSummary=%5B%7B%22selector%22%3A%22date1%22%2C%22summaryType%22%3A%22min%22%7D%2C%7B%22selector%22%3A%22int1%22%2C%22summaryType%22%3A%22avg%22%7D%2C%7B%22selector%22%3A%22int1%22%2C%22summaryType%22%3A%22sum%22%7D%2C%7B%22summaryType%22%3A%22count%22%7D%5D&tzOffset=-60', { 920 | errors: [], 921 | loadOptions: { 922 | skip: 0, 923 | take: 20, 924 | requireTotalCount: true, 925 | totalSummary: [{ 926 | selector: 'date1', 927 | summaryType: 'max' 928 | }, { 929 | selector: 'int1', 930 | summaryType: 'avg' 931 | }, { 932 | selector: 'int1', 933 | summaryType: 'sum' 934 | }], 935 | requireGroupCount: true, 936 | group: [{ 937 | selector: 'int1', 938 | desc: false, 939 | isExpanded: false 940 | }], 941 | groupSummary: [{ 942 | selector: 'date1', 943 | summaryType: 'min' 944 | }, { 945 | selector: 'int1', 946 | summaryType: 'avg' 947 | }, { 948 | selector: 'int1', 949 | summaryType: 'sum' 950 | }, { 951 | summaryType: 'count' 952 | }] 953 | }, 954 | processingOptions: { 955 | timezoneOffset: -60, 956 | summaryQueryLimit: 500 957 | } 958 | }); 959 | }); 960 | }); -------------------------------------------------------------------------------- /dist/pipelines.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 4 | 5 | var _marked = regeneratorRuntime.mark(_fixAndChainWithIncompleteAnds); 6 | 7 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 8 | 9 | var _require = require('mongodb'), 10 | ObjectId = _require.ObjectId; 11 | 12 | var createGroupFieldName = function createGroupFieldName(groupIndex) { 13 | return '___group_key_' + groupIndex; 14 | }; 15 | 16 | var divInt = function divInt(dividend, divisor) { 17 | return { 18 | $divide: [subtractMod(dividend, divisor), divisor] 19 | }; 20 | }; 21 | 22 | var subtractMod = function subtractMod(a, b) { 23 | return { 24 | $subtract: [a, { 25 | $mod: [a, b] 26 | }] 27 | }; 28 | }; 29 | 30 | var createGroupKeyPipeline = function createGroupKeyPipeline(selector, groupInterval, groupIndex, contextOptions) { 31 | var timezoneOffset = contextOptions.timezoneOffset; 32 | 33 | 34 | var wrapGroupKey = function wrapGroupKey(keyExpr) { 35 | return { 36 | $addFields: _defineProperty({}, createGroupFieldName(groupIndex), keyExpr) 37 | }; 38 | }; 39 | 40 | var prefix = function prefix(s) { 41 | return '$' + s; 42 | }; 43 | 44 | var pipe = function pipe() { 45 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 46 | args[_key] = arguments[_key]; 47 | } 48 | 49 | var result = Array.from(args); 50 | result.groupIndex = groupIndex; 51 | return result; 52 | }; 53 | 54 | if (groupInterval) { 55 | var numericInterval = parseInt(Number(groupInterval)); 56 | if (numericInterval) { 57 | return pipe(wrapGroupKey(subtractMod(prefix(selector), numericInterval))); 58 | } else { 59 | var tafield = { 60 | $subtract: [prefix(selector), timezoneOffset * 60 * 1000] 61 | }; 62 | 63 | switch (groupInterval) { 64 | case 'year': 65 | return pipe(wrapGroupKey({ 66 | $year: tafield 67 | })); 68 | case 'quarter': 69 | return pipe({ 70 | $addFields: { 71 | ___mp2: { 72 | $add: [{ 73 | $month: tafield 74 | }, 2] 75 | } 76 | } 77 | }, wrapGroupKey(divInt('$___mp2', 3))); 78 | case 'month': 79 | return pipe(wrapGroupKey({ 80 | $month: tafield 81 | })); 82 | case 'day': 83 | return pipe(wrapGroupKey({ 84 | $dayOfMonth: tafield 85 | })); 86 | case 'dayOfWeek': 87 | return pipe(wrapGroupKey({ 88 | $subtract: [{ 89 | $dayOfWeek: tafield }, 1] 90 | })); 91 | case 'hour': 92 | return pipe(wrapGroupKey({ 93 | $hour: tafield 94 | })); 95 | case 'minute': 96 | return pipe(wrapGroupKey({ 97 | $minute: tafield 98 | })); 99 | case 'second': 100 | return pipe(wrapGroupKey({ 101 | $second: tafield 102 | })); 103 | default: 104 | return pipe(wrapGroupKey(prefix(selector))); 105 | } 106 | } 107 | } else { 108 | return pipe(wrapGroupKey(prefix(selector))); 109 | } 110 | }; 111 | 112 | var createGroupStagePipeline = function createGroupStagePipeline(includeDataItems, countingSeparately, itemProjection, groupKeyPipeline) { 113 | var result = { 114 | $group: { 115 | _id: '$' + createGroupFieldName(groupKeyPipeline.groupIndex) 116 | } 117 | }; 118 | if (!countingSeparately) { 119 | result.$group.count = { 120 | $sum: 1 121 | }; 122 | } 123 | if (includeDataItems) { 124 | result.$group.items = { 125 | $push: itemProjection 126 | }; 127 | } 128 | 129 | return groupKeyPipeline.concat([result]); 130 | }; 131 | 132 | var createGroupingPipeline = function createGroupingPipeline(desc, includeDataItems, countingSeparately, groupKeyPipeline) { 133 | var itemProjection = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : '$$CURRENT'; 134 | 135 | var projectStage = { 136 | $project: { 137 | _id: 0, 138 | key: '$_id' 139 | } 140 | }; 141 | var sortStage = { 142 | $sort: { 143 | key: desc ? -1 : 1 144 | } 145 | }; 146 | 147 | var pipeline = createGroupStagePipeline(includeDataItems, countingSeparately, itemProjection, groupKeyPipeline).concat([projectStage, sortStage]); 148 | 149 | if (!countingSeparately) { 150 | projectStage.$project.count = 1; 151 | } 152 | 153 | if (includeDataItems) { 154 | projectStage.$project.items = 1; 155 | } else { 156 | pipeline.push({ 157 | $addFields: { 158 | items: null } 159 | }); 160 | } 161 | 162 | return pipeline; 163 | }; 164 | 165 | var createSkipTakePipeline = function createSkipTakePipeline(skip, take) { 166 | var pipeline = []; 167 | 168 | if (skip) pipeline.push({ 169 | $skip: skip 170 | }); 171 | if (take) pipeline.push({ 172 | $limit: take 173 | }); 174 | 175 | return pipeline; 176 | }; 177 | 178 | var createCountPipeline = function createCountPipeline() { 179 | return [{ 180 | $count: 'count' 181 | }]; 182 | }; 183 | 184 | var createMatchPipeline = function createMatchPipeline(selector, value) { 185 | return [{ $match: _defineProperty({}, selector, value) }]; 186 | }; 187 | 188 | var construct = function construct(fieldName, operator, compValue) { 189 | return _defineProperty({}, fieldName, _defineProperty({}, operator, compValue)); 190 | }; 191 | 192 | var constructRegex = function constructRegex(fieldName, regex, caseInsensitive) { 193 | return _defineProperty({}, fieldName, { $regex: regex, $options: caseInsensitive ? 'i' : '' }); 194 | }; 195 | 196 | var isCorrectFilterOperatorStructure = function isCorrectFilterOperatorStructure(element, operator) { 197 | return element.reduce(function (r, v) { 198 | if (r.previous) return { ok: r.ok, previous: false };else return { 199 | ok: r.ok && typeof v === 'string' && v.toLowerCase() === operator, 200 | previous: true 201 | }; 202 | }, { ok: true, previous: true }).ok; 203 | }; 204 | 205 | var isAndChainWithIncompleteAnds = function isAndChainWithIncompleteAnds(element) { 206 | if (!Array.isArray(element)) return false; 207 | if (element.length < 2) return false; 208 | if (!Array.isArray(element[0])) return false; 209 | 210 | if (isCorrectFilterOperatorStructure(element, 'and')) return false; 211 | return element.reduce(function (r, v) { 212 | return r && (typeof v === 'string' && v.toLowerCase() === 'and' || Array.isArray(v)); 213 | }, true); 214 | }; 215 | 216 | function _fixAndChainWithIncompleteAnds(chain) { 217 | var firstDone, expectAnd, _iteratorNormalCompletion, _didIteratorError, _iteratorError, _iterator, _step, item; 218 | 219 | return regeneratorRuntime.wrap(function _fixAndChainWithIncompleteAnds$(_context) { 220 | while (1) { 221 | switch (_context.prev = _context.next) { 222 | case 0: 223 | firstDone = false; 224 | expectAnd = true; 225 | _iteratorNormalCompletion = true; 226 | _didIteratorError = false; 227 | _iteratorError = undefined; 228 | _context.prev = 5; 229 | _iterator = chain[Symbol.iterator](); 230 | 231 | case 7: 232 | if (_iteratorNormalCompletion = (_step = _iterator.next()).done) { 233 | _context.next = 35; 234 | break; 235 | } 236 | 237 | item = _step.value; 238 | 239 | if (firstDone) { 240 | _context.next = 15; 241 | break; 242 | } 243 | 244 | _context.next = 12; 245 | return item; 246 | 247 | case 12: 248 | firstDone = true; 249 | _context.next = 32; 250 | break; 251 | 252 | case 15: 253 | if (!expectAnd) { 254 | _context.next = 28; 255 | break; 256 | } 257 | 258 | if (!(typeof item === 'string')) { 259 | _context.next = 22; 260 | break; 261 | } 262 | 263 | _context.next = 19; 264 | return 'and'; 265 | 266 | case 19: 267 | expectAnd = false; 268 | _context.next = 26; 269 | break; 270 | 271 | case 22: 272 | _context.next = 24; 273 | return 'and'; 274 | 275 | case 24: 276 | _context.next = 26; 277 | return item; 278 | 279 | case 26: 280 | _context.next = 32; 281 | break; 282 | 283 | case 28: 284 | if (!(typeof item !== 'string')) { 285 | _context.next = 32; 286 | break; 287 | } 288 | 289 | _context.next = 31; 290 | return item; 291 | 292 | case 31: 293 | expectAnd = true; 294 | 295 | case 32: 296 | _iteratorNormalCompletion = true; 297 | _context.next = 7; 298 | break; 299 | 300 | case 35: 301 | _context.next = 41; 302 | break; 303 | 304 | case 37: 305 | _context.prev = 37; 306 | _context.t0 = _context['catch'](5); 307 | _didIteratorError = true; 308 | _iteratorError = _context.t0; 309 | 310 | case 41: 311 | _context.prev = 41; 312 | _context.prev = 42; 313 | 314 | if (!_iteratorNormalCompletion && _iterator.return) { 315 | _iterator.return(); 316 | } 317 | 318 | case 44: 319 | _context.prev = 44; 320 | 321 | if (!_didIteratorError) { 322 | _context.next = 47; 323 | break; 324 | } 325 | 326 | throw _iteratorError; 327 | 328 | case 47: 329 | return _context.finish(44); 330 | 331 | case 48: 332 | return _context.finish(41); 333 | 334 | case 49: 335 | case 'end': 336 | return _context.stop(); 337 | } 338 | } 339 | }, _marked, this, [[5, 37, 41, 49], [42,, 44, 48]]); 340 | } 341 | 342 | var fixAndChainWithIncompleteAnds = function fixAndChainWithIncompleteAnds(element) { 343 | return Array.from(_fixAndChainWithIncompleteAnds(element)); 344 | }; 345 | 346 | var parseFilter = function parseFilter(element) { 347 | var contextOptions = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 348 | var caseInsensitiveRegex = contextOptions.caseInsensitiveRegex; 349 | 350 | 351 | var rval = function rval(match, fieldList) { 352 | return { match: match, fieldList: fieldList }; 353 | }; 354 | 355 | if (typeof element === 'string') { 356 | var nf = checkNestedField(element); 357 | var fieldName = nf ? nf.filterFieldName : element; 358 | return rval(construct(fieldName, '$eq', true), [element]); 359 | } else if (Array.isArray(element)) { 360 | if (element.length === 1 && Array.isArray(element[0])) { 361 | return parseFilter(element[0], contextOptions); 362 | } else if (element.length === 2) { 363 | if (element[0] === '!') { 364 | var _parseFilter = parseFilter(element[1], contextOptions), 365 | match = _parseFilter.match, 366 | fieldList = _parseFilter.fieldList; 367 | 368 | if (match) return rval({ 369 | $nor: [match] 370 | }, fieldList);else return null; 371 | } else if (isAndChainWithIncompleteAnds(element)) return parseFilter(fixAndChainWithIncompleteAnds(element), contextOptions);else return null; 372 | } else { 373 | if (isAndChainWithIncompleteAnds(element)) return parseFilter(fixAndChainWithIncompleteAnds(element), contextOptions);else if (element.length % 2 === 1) { 374 | var operator = String(element[1]).toLowerCase(); 375 | 376 | if (['and', 'or'].includes(operator)) { 377 | if (isCorrectFilterOperatorStructure(element, operator)) { 378 | var result = element.reduce(function (r, v) { 379 | if (r.previous) return _extends({}, r, { previous: false });else { 380 | var nestedResult = parseFilter(v, contextOptions); 381 | var nestedFilter = nestedResult && nestedResult.match; 382 | var _fieldList = nestedResult ? nestedResult.fieldList : []; 383 | if (nestedFilter) r.list.push(nestedFilter); 384 | return { 385 | list: r.list, 386 | fieldList: r.fieldList.concat(_fieldList), 387 | previous: true 388 | }; 389 | } 390 | }, { list: [], fieldList: [], previous: false }); 391 | 392 | return rval(_defineProperty({}, '$' + operator, result.list), result.fieldList); 393 | } else return null; 394 | } else { 395 | if (element.length === 3) { 396 | var _nf = checkNestedField(element[0]); 397 | var _fieldName2 = _nf ? _nf.filterFieldName : element[0]; 398 | 399 | switch (operator) { 400 | case '=': 401 | return rval(construct(_fieldName2, '$eq', element[2]), [element[0]]); 402 | case '<>': 403 | return rval(construct(_fieldName2, '$ne', element[2]), [element[0]]); 404 | case '>': 405 | return rval(construct(_fieldName2, '$gt', element[2]), [element[0]]); 406 | case '>=': 407 | return rval(construct(_fieldName2, '$gte', element[2]), [element[0]]); 408 | case '<': 409 | return rval(construct(_fieldName2, '$lt', element[2]), [element[0]]); 410 | case '<=': 411 | return rval(construct(_fieldName2, '$lte', element[2]), [element[0]]); 412 | case 'startswith': 413 | return rval(constructRegex(_fieldName2, '^' + element[2], caseInsensitiveRegex), [element[0]]); 414 | case 'endswith': 415 | return rval(constructRegex(_fieldName2, element[2] + '$', caseInsensitiveRegex), [element[0]]); 416 | case 'contains': 417 | return rval(constructRegex(_fieldName2, element[2], caseInsensitiveRegex), [element[0]]); 418 | case 'notcontains': 419 | return rval(constructRegex(_fieldName2, '^((?!' + element[2] + ').)*$', caseInsensitiveRegex), [element[0]]); 420 | case 'equalsobjectid': 421 | return rval(construct(_fieldName2, '$eq', new ObjectId(element[2])), [element[0]]); 422 | default: 423 | return null; 424 | } 425 | } else return null; 426 | } 427 | } else return null; 428 | } 429 | } else return null; 430 | }; 431 | 432 | var createFilterPipeline = function createFilterPipeline(filter, contextOptions) { 433 | var dummy = { 434 | pipeline: [], 435 | fieldList: [] 436 | }; 437 | 438 | if (filter) { 439 | var result = parseFilter(filter, contextOptions); 440 | var match = result && result.match; 441 | var fieldList = result ? result.fieldList : []; 442 | if (match) return { 443 | pipeline: [{ 444 | $match: match 445 | }], 446 | fieldList: fieldList 447 | };else return dummy; 448 | } else return dummy; 449 | }; 450 | 451 | var createSortPipeline = function createSortPipeline(sort) { 452 | return sort ? [{ 453 | $sort: sort.reduce(function (r, v) { 454 | return _extends({}, r, _defineProperty({}, v.selector, v.desc ? -1 : 1)); 455 | }, {}) 456 | }] : []; 457 | }; 458 | 459 | var createSummaryPipeline = function createSummaryPipeline(summary) { 460 | if (summary) { 461 | var gc = { _id: null }; 462 | var _iteratorNormalCompletion2 = true; 463 | var _didIteratorError2 = false; 464 | var _iteratorError2 = undefined; 465 | 466 | try { 467 | for (var _iterator2 = summary[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { 468 | var s = _step2.value; 469 | 470 | switch (s.summaryType) { 471 | case 'sum': 472 | case 'avg': 473 | case 'min': 474 | case 'max': 475 | gc['___' + s.summaryType + s.selector] = _defineProperty({}, '$' + s.summaryType, '$' + s.selector); 476 | break; 477 | case 'count': 478 | gc.___count = { $sum: 1 }; 479 | break; 480 | default: 481 | console.error('Invalid summary type \'' + s.summaryType + '\', ignoring'); 482 | break; 483 | } 484 | } 485 | } catch (err) { 486 | _didIteratorError2 = true; 487 | _iteratorError2 = err; 488 | } finally { 489 | try { 490 | if (!_iteratorNormalCompletion2 && _iterator2.return) { 491 | _iterator2.return(); 492 | } 493 | } finally { 494 | if (_didIteratorError2) { 495 | throw _iteratorError2; 496 | } 497 | } 498 | } 499 | 500 | return [{ 501 | $group: gc 502 | }]; 503 | } else return []; 504 | }; 505 | 506 | var createSearchPipeline = function createSearchPipeline(expr, op, val, contextOptions) { 507 | var dummy = { 508 | pipeline: [], 509 | fieldList: [] 510 | }; 511 | 512 | if (!expr || !op || !val) return dummy; 513 | 514 | var criteria = void 0; 515 | if (typeof expr === 'string') criteria = [expr, op, val];else if (expr.length > 0) { 516 | criteria = []; 517 | var _iteratorNormalCompletion3 = true; 518 | var _didIteratorError3 = false; 519 | var _iteratorError3 = undefined; 520 | 521 | try { 522 | for (var _iterator3 = expr[Symbol.iterator](), _step3; !(_iteratorNormalCompletion3 = (_step3 = _iterator3.next()).done); _iteratorNormalCompletion3 = true) { 523 | var exprItem = _step3.value; 524 | 525 | if (criteria.length) criteria.push('or'); 526 | criteria.push([exprItem, op, val]); 527 | } 528 | } catch (err) { 529 | _didIteratorError3 = true; 530 | _iteratorError3 = err; 531 | } finally { 532 | try { 533 | if (!_iteratorNormalCompletion3 && _iterator3.return) { 534 | _iterator3.return(); 535 | } 536 | } finally { 537 | if (_didIteratorError3) { 538 | throw _iteratorError3; 539 | } 540 | } 541 | } 542 | } else return dummy; 543 | 544 | return createFilterPipeline(criteria, contextOptions); 545 | }; 546 | 547 | var createSelectProjectExpression = function createSelectProjectExpression(fields) { 548 | var explicitId = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; 549 | 550 | if (fields && fields.length > 0) { 551 | var project = {}; 552 | if (explicitId) project._id = '$_id'; 553 | var _iteratorNormalCompletion4 = true; 554 | var _didIteratorError4 = false; 555 | var _iteratorError4 = undefined; 556 | 557 | try { 558 | for (var _iterator4 = fields[Symbol.iterator](), _step4; !(_iteratorNormalCompletion4 = (_step4 = _iterator4.next()).done); _iteratorNormalCompletion4 = true) { 559 | var field = _step4.value; 560 | project[field] = '$' + field; 561 | } 562 | } catch (err) { 563 | _didIteratorError4 = true; 564 | _iteratorError4 = err; 565 | } finally { 566 | try { 567 | if (!_iteratorNormalCompletion4 && _iterator4.return) { 568 | _iterator4.return(); 569 | } 570 | } finally { 571 | if (_didIteratorError4) { 572 | throw _iteratorError4; 573 | } 574 | } 575 | } 576 | 577 | return project; 578 | } else return undefined; 579 | }; 580 | 581 | var createSelectPipeline = function createSelectPipeline(fields, contextOptions) { 582 | if (fields && fields.length > 0) { 583 | return [{ 584 | $project: createSelectProjectExpression(fields, contextOptions) 585 | }]; 586 | } else return []; 587 | }; 588 | 589 | var nestedFieldRegex = /^([^.]+)\.(year|quarter|month|dayofweek|day)$/i; 590 | 591 | var checkNestedField = function checkNestedField(fieldName) { 592 | var match = nestedFieldRegex.exec(fieldName); 593 | if (!match) return undefined; 594 | return { 595 | base: match[1], 596 | nested: match[2], 597 | filterFieldName: '___' + match[1] + '_' + match[2] 598 | }; 599 | }; 600 | 601 | var createAddNestedFieldsPipeline = function createAddNestedFieldsPipeline(fieldNames, contextOptions) { 602 | var timezoneOffset = contextOptions.timezoneOffset; 603 | 604 | var pr = fieldNames.reduce(function (r, v) { 605 | var nf = checkNestedField(v); 606 | 607 | if (nf) { 608 | var nestedFunction = nf.nested.toLowerCase(); 609 | if (['year', 'quarter', 'month', 'day', 'dayofweek'].includes(nestedFunction)) { 610 | var tafield = { 611 | $subtract: ['$' + nf.base, timezoneOffset * 60 * 1000] 612 | }; 613 | 614 | switch (nestedFunction) { 615 | case 'year': 616 | r.pipeline[1][nf.filterFieldName] = { 617 | $year: tafield 618 | }; 619 | r.nestedFields.push(nf.filterFieldName); 620 | break; 621 | case 'quarter': 622 | { 623 | var tempField = '___' + nf.base + '_mp2'; 624 | r.pipeline[0][tempField] = { 625 | $add: [{ 626 | $month: tafield 627 | }, 2] 628 | }; 629 | r.nestedFields.push(tempField); 630 | r.pipeline[1][nf.filterFieldName] = divInt('$' + tempField, 3); 631 | r.nestedFields.push(nf.filterFieldName); 632 | break; 633 | } 634 | case 'month': 635 | r.pipeline[1][nf.filterFieldName] = { 636 | $month: tafield 637 | }; 638 | r.nestedFields.push(nf.filterFieldName); 639 | break; 640 | case 'day': 641 | r.pipeline[1][nf.filterFieldName] = { 642 | $dayOfMonth: tafield 643 | }; 644 | r.nestedFields.push(nf.filterFieldName); 645 | break; 646 | case 'dayofweek': 647 | r.pipeline[1][nf.filterFieldName] = { 648 | $subtract: [{ 649 | $dayOfWeek: tafield 650 | }, 1] 651 | }; 652 | r.nestedFields.push(nf.filterFieldName); 653 | break; 654 | default: 655 | console.error('Hit a completely impossible default case'); 656 | } 657 | } 658 | } 659 | return r; 660 | }, { 661 | pipeline: [{}, {}], 662 | nestedFields: [] 663 | }); 664 | [1, 0].forEach(function (i) { 665 | if (Object.getOwnPropertyNames(pr.pipeline[i]).length === 0) { 666 | pr.pipeline.splice(i, 1); 667 | } else { 668 | pr.pipeline[i] = { 669 | $addFields: pr.pipeline[i] 670 | }; 671 | } 672 | }); 673 | 674 | return pr; 675 | }; 676 | 677 | var createCompleteFilterPipeline = function createCompleteFilterPipeline(searchExpr, searchOperation, searchValue, filter, contextOptions) { 678 | var searchPipeline = createSearchPipeline(searchExpr, searchOperation, searchValue, contextOptions); 679 | 680 | var filterPipeline = createFilterPipeline(filter, contextOptions); 681 | 682 | var addNestedFieldsPipelineDetails = createAddNestedFieldsPipeline(searchPipeline.fieldList.concat(filterPipeline.fieldList), contextOptions); 683 | 684 | return { 685 | pipeline: addNestedFieldsPipelineDetails.pipeline.concat(searchPipeline.pipeline, filterPipeline.pipeline), 686 | nestedFields: addNestedFieldsPipelineDetails.nestedFields 687 | }; 688 | }; 689 | 690 | var createRemoveNestedFieldsPipeline = function createRemoveNestedFieldsPipeline(nestedFields) { 691 | if (nestedFields.length === 0) return []; 692 | 693 | var pd = {}; 694 | var _iteratorNormalCompletion5 = true; 695 | var _didIteratorError5 = false; 696 | var _iteratorError5 = undefined; 697 | 698 | try { 699 | for (var _iterator5 = nestedFields[Symbol.iterator](), _step5; !(_iteratorNormalCompletion5 = (_step5 = _iterator5.next()).done); _iteratorNormalCompletion5 = true) { 700 | var f = _step5.value; 701 | pd[f] = 0; 702 | } 703 | } catch (err) { 704 | _didIteratorError5 = true; 705 | _iteratorError5 = err; 706 | } finally { 707 | try { 708 | if (!_iteratorNormalCompletion5 && _iterator5.return) { 709 | _iterator5.return(); 710 | } 711 | } finally { 712 | if (_didIteratorError5) { 713 | throw _iteratorError5; 714 | } 715 | } 716 | } 717 | 718 | return [{ 719 | $project: pd 720 | }]; 721 | }; 722 | 723 | module.exports = { 724 | createGroupFieldName: createGroupFieldName, 725 | createGroupKeyPipeline: createGroupKeyPipeline, 726 | createGroupingPipeline: createGroupingPipeline, 727 | createSkipTakePipeline: createSkipTakePipeline, 728 | createCountPipeline: createCountPipeline, 729 | createMatchPipeline: createMatchPipeline, 730 | createSortPipeline: createSortPipeline, 731 | createSummaryPipeline: createSummaryPipeline, 732 | createSelectProjectExpression: createSelectProjectExpression, 733 | createSelectPipeline: createSelectPipeline, 734 | createCompleteFilterPipeline: createCompleteFilterPipeline, 735 | createRemoveNestedFieldsPipeline: createRemoveNestedFieldsPipeline, 736 | testing: { 737 | divInt: divInt, 738 | subtractMod: subtractMod, 739 | createGroupStagePipeline: createGroupStagePipeline, 740 | construct: construct, 741 | constructRegex: constructRegex, 742 | parseFilter: parseFilter, 743 | createFilterPipeline: createFilterPipeline, 744 | createSearchPipeline: createSearchPipeline, 745 | checkNestedField: checkNestedField, 746 | createAddNestedFieldsPipeline: createAddNestedFieldsPipeline, 747 | isAndChainWithIncompleteAnds: isAndChainWithIncompleteAnds, 748 | fixAndChainWithIncompleteAnds: fixAndChainWithIncompleteAnds, 749 | isCorrectFilterOperatorStructure: isCorrectFilterOperatorStructure 750 | } 751 | }; -------------------------------------------------------------------------------- /dist/pipelines.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 4 | 5 | var chai = require('chai'); 6 | var assert = chai.assert; 7 | 8 | var _require = require('mongodb'), 9 | ObjectId = _require.ObjectId; 10 | 11 | var pipelines = require('./pipelines'); 12 | var createGroupKeyPipeline = pipelines.createGroupKeyPipeline, 13 | createGroupingPipeline = pipelines.createGroupingPipeline, 14 | createSkipTakePipeline = pipelines.createSkipTakePipeline, 15 | createCountPipeline = pipelines.createCountPipeline, 16 | createMatchPipeline = pipelines.createMatchPipeline, 17 | createSortPipeline = pipelines.createSortPipeline, 18 | createSummaryPipeline = pipelines.createSummaryPipeline, 19 | createSelectProjectExpression = pipelines.createSelectProjectExpression, 20 | createSelectPipeline = pipelines.createSelectPipeline, 21 | createCompleteFilterPipeline = pipelines.createCompleteFilterPipeline, 22 | createRemoveNestedFieldsPipeline = pipelines.createRemoveNestedFieldsPipeline; 23 | var _pipelines$testing = pipelines.testing, 24 | createGroupStagePipeline = _pipelines$testing.createGroupStagePipeline, 25 | construct = _pipelines$testing.construct, 26 | constructRegex = _pipelines$testing.constructRegex, 27 | parseFilter = _pipelines$testing.parseFilter, 28 | createFilterPipeline = _pipelines$testing.createFilterPipeline, 29 | createSearchPipeline = _pipelines$testing.createSearchPipeline, 30 | checkNestedField = _pipelines$testing.checkNestedField, 31 | createAddNestedFieldsPipeline = _pipelines$testing.createAddNestedFieldsPipeline, 32 | divInt = _pipelines$testing.divInt, 33 | subtractMod = _pipelines$testing.subtractMod, 34 | isAndChainWithIncompleteAnds = _pipelines$testing.isAndChainWithIncompleteAnds, 35 | fixAndChainWithIncompleteAnds = _pipelines$testing.fixAndChainWithIncompleteAnds, 36 | isCorrectFilterOperatorStructure = _pipelines$testing.isCorrectFilterOperatorStructure; 37 | 38 | 39 | suite('pipelines', function () { 40 | suite('divInt', function () { 41 | test('works', function () { 42 | assert.deepEqual(divInt(14, 3), { 43 | $divide: [{ 44 | $subtract: [14, { $mod: [14, 3] }] 45 | }, 3] 46 | }); 47 | }); 48 | }); 49 | 50 | suite('subtractMod', function () { 51 | test('works', function () { 52 | assert.deepEqual(subtractMod(14, 3), { 53 | $subtract: [14, { $mod: [14, 3] }] 54 | }); 55 | }); 56 | }); 57 | 58 | suite('createGroupKeyPipeline', function () { 59 | test('no groupInterval', function () { 60 | var result = createGroupKeyPipeline('sel', null, 0, 0); 61 | var wanted = [{ $addFields: { ___group_key_0: '$sel' } }]; 62 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 63 | assert.equal(result.groupIndex, 0); 64 | }); 65 | 66 | test('numeric groupInterval', function () { 67 | var result = createGroupKeyPipeline('sel', 15, 0, 0); 68 | var wanted = [{ 69 | $addFields: { 70 | ___group_key_0: { $subtract: ['$sel', { $mod: ['$sel', 15] }] } 71 | } 72 | }]; 73 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 74 | assert.equal(result.groupIndex, 0); 75 | }); 76 | 77 | var basicNamedGroupIntervalTest = function basicNamedGroupIntervalTest(name, tzo, mongoModName) { 78 | var result = createGroupKeyPipeline('sel', name, 0, { 79 | timezoneOffset: tzo 80 | }); 81 | var wanted = [{ 82 | $addFields: { 83 | ___group_key_0: _defineProperty({}, '$' + (mongoModName || name), { 84 | $subtract: ['$sel', tzo * 60 * 1000] 85 | }) 86 | } 87 | }]; 88 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 89 | assert.equal(result.groupIndex, 0); 90 | }; 91 | 92 | test('groupInterval year, timezoneOffset 0', function () { 93 | basicNamedGroupIntervalTest('year', 0); 94 | }); 95 | 96 | test('groupInterval year, timezoneOffset 60', function () { 97 | basicNamedGroupIntervalTest('year', 60); 98 | }); 99 | 100 | test('groupInterval quarter, timezoneOffset 60', function () { 101 | var result = createGroupKeyPipeline('sel', 'quarter', 0, { 102 | timezoneOffset: 60 103 | }); 104 | var wanted = [{ 105 | $addFields: { 106 | ___mp2: { $add: [{ $month: { $subtract: ['$sel', 3600000] } }, 2] } 107 | } 108 | }, { 109 | $addFields: { 110 | ___group_key_0: { 111 | $divide: [{ $subtract: ['$___mp2', { $mod: ['$___mp2', 3] }] }, 3] 112 | } 113 | } 114 | }]; 115 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 116 | assert.equal(result.groupIndex, 0); 117 | }); 118 | 119 | test('groupInterval month, timezoneOffset 60', function () { 120 | basicNamedGroupIntervalTest('month', 60); 121 | }); 122 | 123 | test('groupInterval day, timezoneOffset 60', function () { 124 | basicNamedGroupIntervalTest('day', 60, 'dayOfMonth'); 125 | }); 126 | 127 | test('groupInterval dayOfWeek, timezoneOffset 60', function () { 128 | var result = createGroupKeyPipeline('sel', 'dayOfWeek', 0, { 129 | timezoneOffset: 60 130 | }); 131 | var wanted = [{ 132 | $addFields: { 133 | ___group_key_0: { 134 | $subtract: [{ $dayOfWeek: { $subtract: ['$sel', 3600000] } }, 1] 135 | } 136 | } 137 | }]; 138 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 139 | assert.equal(result.groupIndex, 0); 140 | }); 141 | 142 | test('groupInterval hour, timezoneOffset 60', function () { 143 | basicNamedGroupIntervalTest('hour', 60); 144 | }); 145 | 146 | test('groupInterval minute, timezoneOffset 60', function () { 147 | basicNamedGroupIntervalTest('minute', 60); 148 | }); 149 | 150 | test('groupInterval second, timezoneOffset 60', function () { 151 | basicNamedGroupIntervalTest('second', 60); 152 | }); 153 | 154 | test('unknown groupInterval', function () { 155 | var result = createGroupKeyPipeline('sel', 'non-existent name', 0, 0); 156 | var wanted = [{ $addFields: { ___group_key_0: '$sel' } }]; 157 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 158 | assert.equal(result.groupIndex, 0); 159 | }); 160 | }); 161 | 162 | suite('createGroupStagePipeline', function () { 163 | test('basics', function () { 164 | var groupKeyPipeline = ['test']; 165 | groupKeyPipeline.groupIndex = 99; 166 | var result = createGroupStagePipeline(false, true, null, groupKeyPipeline); 167 | var wanted = ['test', { $group: { _id: '$___group_key_99' } }]; 168 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 169 | assert.isUndefined(result.groupIndex); 170 | }); 171 | 172 | test('not countingSeparately', function () { 173 | var groupKeyPipeline = ['test']; 174 | groupKeyPipeline.groupIndex = 99; 175 | var result = createGroupStagePipeline(false, false, null, groupKeyPipeline); 176 | var wanted = ['test', { $group: { _id: '$___group_key_99', count: { $sum: 1 } } }]; 177 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 178 | assert.isUndefined(result.groupIndex); 179 | }); 180 | 181 | test('not countingSeparately, includeDataItems', function () { 182 | var groupKeyPipeline = ['test']; 183 | groupKeyPipeline.groupIndex = 99; 184 | var result = createGroupStagePipeline(true, false, 'itemProjection', groupKeyPipeline); 185 | var wanted = ['test', { 186 | $group: { 187 | _id: '$___group_key_99', 188 | count: { $sum: 1 }, 189 | items: { $push: 'itemProjection' } 190 | } 191 | }]; 192 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 193 | assert.isUndefined(result.groupIndex); 194 | }); 195 | }); 196 | 197 | suite('createGroupingPipeline', function () { 198 | test('basics', function () { 199 | var groupKeyPipeline = ['test']; 200 | groupKeyPipeline.groupIndex = 99; 201 | var result = createGroupingPipeline(true, false, true, groupKeyPipeline); 202 | var wanted = ['test', { $group: { _id: '$___group_key_99' } }, { $project: { _id: 0, key: '$_id' } }, { $sort: { key: -1 } }, { $addFields: { items: null } }]; 203 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 204 | assert.isUndefined(result.groupIndex); 205 | }); 206 | 207 | test('not countingSeparately', function () { 208 | var groupKeyPipeline = ['test']; 209 | groupKeyPipeline.groupIndex = 99; 210 | var result = createGroupingPipeline(true, false, false, groupKeyPipeline); 211 | var wanted = ['test', { $group: { _id: '$___group_key_99', count: { $sum: 1 } } }, { $project: { _id: 0, key: '$_id', count: 1 } }, { $sort: { key: -1 } }, { $addFields: { items: null } }]; 212 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 213 | assert.isUndefined(result.groupIndex); 214 | }); 215 | 216 | test('not countingSeparately, includeDataItems', function () { 217 | var groupKeyPipeline = ['test']; 218 | groupKeyPipeline.groupIndex = 99; 219 | var result = createGroupingPipeline(true, true, false, groupKeyPipeline); 220 | var wanted = ['test', { 221 | $group: { 222 | _id: '$___group_key_99', 223 | count: { $sum: 1 }, 224 | items: { $push: '$$CURRENT' } 225 | } 226 | }, { $project: { _id: 0, key: '$_id', count: 1, items: 1 } }, { $sort: { key: -1 } }]; 227 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 228 | assert.isUndefined(result.groupIndex); 229 | }); 230 | 231 | test('not countingSeparately, includeDataItems, custom itemProjection', function () { 232 | var groupKeyPipeline = ['test']; 233 | groupKeyPipeline.groupIndex = 99; 234 | var result = createGroupingPipeline(true, true, false, groupKeyPipeline, '$$customProjection$$'); 235 | var wanted = ['test', { 236 | $group: { 237 | _id: '$___group_key_99', 238 | count: { $sum: 1 }, 239 | items: { $push: '$$customProjection$$' } 240 | } 241 | }, { $project: { _id: 0, key: '$_id', count: 1, items: 1 } }, { $sort: { key: -1 } }]; 242 | assert.equal(JSON.stringify(result), JSON.stringify(wanted)); 243 | assert.isUndefined(result.groupIndex); 244 | }); 245 | }); 246 | 247 | suite('createSkipTakePipeline', function () { 248 | test('no skip or take', function () { 249 | assert.deepEqual(createSkipTakePipeline(), []); 250 | }); 251 | 252 | test('skip, no take', function () { 253 | assert.deepEqual(createSkipTakePipeline(33), [{ $skip: 33 }]); 254 | }); 255 | 256 | test('no skip, take', function () { 257 | assert.deepEqual(createSkipTakePipeline(null, 33), [{ $limit: 33 }]); 258 | }); 259 | 260 | test('skip and take', function () { 261 | assert.deepEqual(createSkipTakePipeline(33, 44), [{ $skip: 33 }, { $limit: 44 }]); 262 | }); 263 | }); 264 | 265 | suite('createCountPipeline', function () { 266 | test('works', function () { 267 | assert.deepEqual(createCountPipeline(), [{ $count: 'count' }]); 268 | }); 269 | }); 270 | 271 | suite('createMatchPipeline', function () { 272 | test('works', function () { 273 | assert.deepEqual(createMatchPipeline('sel', 'val'), [{ $match: { sel: 'val' } }]); 274 | }); 275 | }); 276 | 277 | suite('construct', function () { 278 | test('works', function () { 279 | assert.deepEqual(construct('field', 'plus', 'val'), { 280 | field: { plus: 'val' } 281 | }); 282 | }); 283 | }); 284 | 285 | suite('constructRegex', function () { 286 | test('works', function () { 287 | assert.deepEqual(constructRegex('field', 'regex', true), { 288 | field: { $regex: 'regex', $options: 'i' } 289 | }); 290 | }); 291 | }); 292 | 293 | suite('parseFilter', function () { 294 | var testParseFilter = function testParseFilter(input, expectedMatch, expectedFieldList) { 295 | var result = parseFilter(input, { caseInsensitiveRegex: true }); 296 | var match = result && result.match; 297 | var fieldList = result ? result.fieldList : []; 298 | assert.deepEqual(match, expectedMatch); 299 | assert.deepEqual(fieldList, expectedFieldList); 300 | }; 301 | 302 | test('string element', function () { 303 | testParseFilter('thing', { 304 | thing: { $eq: true } 305 | }, ['thing']); 306 | }); 307 | 308 | test('nested array', function () { 309 | testParseFilter([[['!', 'thing']]], { 310 | $nor: [{ 311 | thing: { $eq: true } 312 | }] 313 | }, ['thing']); 314 | }); 315 | 316 | test('!', function () { 317 | testParseFilter(['!', 'thing'], { 318 | $nor: [{ 319 | thing: { $eq: true } 320 | }] 321 | }, ['thing']); 322 | }); 323 | 324 | test('unknown unary', function () { 325 | testParseFilter(['&', 'thing'], null, []); 326 | }); 327 | 328 | test('equal', function () { 329 | testParseFilter(['thing', '=', 'val'], { 330 | thing: { $eq: 'val' } 331 | }, ['thing']); 332 | }); 333 | 334 | test('equalsObjectId', function () { 335 | testParseFilter(['thing', 'equalsObjectId', '0123456789abcdef01234567'], { 336 | thing: { 337 | $eq: new ObjectId('0123456789abcdef01234567') 338 | } 339 | }, ['thing']); 340 | }); 341 | 342 | test('not equal', function () { 343 | testParseFilter(['thing', '<>', 'val'], { 344 | thing: { $ne: 'val' } 345 | }, ['thing']); 346 | }); 347 | 348 | test('greater than', function () { 349 | testParseFilter(['thing', '>', 'val'], { 350 | thing: { $gt: 'val' } 351 | }, ['thing']); 352 | }); 353 | 354 | test('greater than or equal', function () { 355 | testParseFilter(['thing', '>=', 'val'], { 356 | thing: { $gte: 'val' } 357 | }, ['thing']); 358 | }); 359 | 360 | test('lower than', function () { 361 | testParseFilter(['thing', '<', 'val'], { 362 | thing: { $lt: 'val' } 363 | }, ['thing']); 364 | }); 365 | 366 | test('lower than or equal', function () { 367 | testParseFilter(['thing', '<=', 'val'], { 368 | thing: { $lte: 'val' } 369 | }, ['thing']); 370 | }); 371 | 372 | test('startswith', function () { 373 | testParseFilter(['thing', 'startswith', 'val'], { 374 | thing: { $regex: '^val', $options: 'i' } 375 | }, ['thing']); 376 | }); 377 | 378 | test('endswith', function () { 379 | testParseFilter(['thing', 'endswith', 'val'], { 380 | thing: { $regex: 'val$', $options: 'i' } 381 | }, ['thing']); 382 | }); 383 | 384 | test('contains', function () { 385 | testParseFilter(['thing', 'contains', 'val'], { 386 | thing: { $regex: 'val', $options: 'i' } 387 | }, ['thing']); 388 | }); 389 | 390 | test('notcontains', function () { 391 | testParseFilter(['thing', 'notcontains', 'val'], { 392 | thing: { $regex: '^((?!val).)*$', $options: 'i' } 393 | }, ['thing']); 394 | }); 395 | 396 | test('unknown operator', function () { 397 | testParseFilter(['thing', '&%&%&%&', 'val'], null, []); 398 | }); 399 | 400 | test('even number of elements > 2', function () { 401 | testParseFilter([1, 3, 4, 6], null, []); 402 | }); 403 | 404 | test('not an array or a string', function () { 405 | testParseFilter({ barg: 42 }, null, []); 406 | }); 407 | 408 | test('odd number of elements > 3 without operator in pos 1', function () { 409 | testParseFilter([1, 'unknown item', 3, 4, 5], null, []); 410 | }); 411 | 412 | test('odd number of elements > 3 with non-string in pos 1', function () { 413 | testParseFilter([1, { barg: 42 }, 3, 4, 5], null, []); 414 | }); 415 | 416 | test('nested field', function () { 417 | testParseFilter(['thing.year', '=', 'val'], { 418 | ___thing_year: { $eq: 'val' } 419 | }, ['thing.year']); 420 | }); 421 | 422 | test('unrecognized nested field', function () { 423 | testParseFilter(['thing.unknown', '=', 'val'], { 424 | 'thing.unknown': { $eq: 'val' } 425 | }, ['thing.unknown']); 426 | }); 427 | 428 | test('correct "and" chain', function () { 429 | testParseFilter([['field1', '=', 42], 'and', ['field2', '>', 10], 'and', ['field3', '<>', 'this thing']], { 430 | $and: [{ 431 | field1: { $eq: 42 } 432 | }, { 433 | field2: { $gt: 10 } 434 | }, { 435 | field3: { $ne: 'this thing' } 436 | }] 437 | }, ['field1', 'field2', 'field3']); 438 | }); 439 | 440 | test('short "and" chain with no "ands"', function () { 441 | testParseFilter([['field1', '=', 42], ['field2', '>', 10]], { 442 | $and: [{ 443 | field1: { $eq: 42 } 444 | }, { 445 | field2: { $gt: 10 } 446 | }] 447 | }, ['field1', 'field2']); 448 | }); 449 | 450 | test('long "and" chain with no "ands"', function () { 451 | testParseFilter([['field1', '=', 42], ['field2', '>', 10], ['field3', '<>', 'this thing'], ['field4', '=', 11]], { 452 | $and: [{ 453 | field1: { $eq: 42 } 454 | }, { 455 | field2: { $gt: 10 } 456 | }, { 457 | field3: { $ne: 'this thing' } 458 | }, { 459 | field4: { $eq: 11 } 460 | }] 461 | }, ['field1', 'field2', 'field3', 'field4']); 462 | }); 463 | 464 | test('"and" chain with incomplete "ands"', function () { 465 | testParseFilter([['field1', '=', 42], 'and', ['field2', '>', 10], ['field3', '<>', 'this thing']], { 466 | $and: [{ 467 | field1: { $eq: 42 } 468 | }, { 469 | field2: { $gt: 10 } 470 | }, { 471 | field3: { $ne: 'this thing' } 472 | }] 473 | }, ['field1', 'field2', 'field3']); 474 | }); 475 | 476 | test('correct "or" chain', function () { 477 | testParseFilter([['field1', '=', 42], 'or', ['field2', '>', 10], 'or', ['field3', '<>', 'this thing']], { 478 | $or: [{ 479 | field1: { $eq: 42 } 480 | }, { 481 | field2: { $gt: 10 } 482 | }, { 483 | field3: { $ne: 'this thing' } 484 | }] 485 | }, ['field1', 'field2', 'field3']); 486 | }); 487 | 488 | test('incorrect operator chain', function () { 489 | testParseFilter([['field1', '=', 42], 'and', ['field2', '>', 10], 'or', ['field3', '<>', 'this thing']], null, []); 490 | }); 491 | 492 | test('correct combined operator chain', function () { 493 | testParseFilter([['field1', '=', 42], 'and', [['field2', '>', 10], 'or', ['field3', '<>', 'this thing']]], { 494 | $and: [{ 495 | field1: { $eq: 42 } 496 | }, { 497 | $or: [{ 498 | field2: { $gt: 10 } 499 | }, { 500 | field3: { $ne: 'this thing' } 501 | }] 502 | }] 503 | }, ['field1', 'field2', 'field3']); 504 | }); 505 | }); 506 | 507 | suite('createFilterPipeline', function () { 508 | test('works', function () { 509 | assert.deepEqual(createFilterPipeline(['thing', '=', 42]), { 510 | pipeline: [{ $match: { thing: { $eq: 42 } } }], 511 | fieldList: ['thing'] 512 | }); 513 | }); 514 | 515 | test('no filter', function () { 516 | assert.deepEqual(createFilterPipeline(), { 517 | pipeline: [], 518 | fieldList: [] 519 | }); 520 | }); 521 | 522 | test('invalid filter', function () { 523 | assert.deepEqual(createFilterPipeline(['thing', '=']), { 524 | pipeline: [], 525 | fieldList: [] 526 | }); 527 | }); 528 | }); 529 | 530 | suite('createSortPipeline', function () { 531 | test('works', function () { 532 | assert.deepEqual(createSortPipeline([{ selector: 'field1', desc: true }, { selector: 'field2' }]), [{ $sort: { field1: -1, field2: 1 } }]); 533 | }); 534 | }); 535 | 536 | suite('createSummaryPipeline', function () { 537 | test('works', function () { 538 | assert.deepEqual(createSummaryPipeline([{ summaryType: 'min', selector: 'thing' }, { summaryType: 'max', selector: 'other' }, { summaryType: 'invalid', selector: 'dontknow' }, { summaryType: 'count' }]), [{ 539 | $group: { 540 | ___minthing: { $min: '$thing' }, 541 | ___maxother: { $max: '$other' }, 542 | ___count: { $sum: 1 }, 543 | _id: null 544 | } 545 | }]); 546 | }); 547 | }); 548 | 549 | suite('createSearchPipeline', function () { 550 | test('simple values', function () { 551 | assert.deepEqual(createSearchPipeline('thing', '=', 42), { 552 | pipeline: [{ $match: { thing: { $eq: 42 } } }], 553 | fieldList: ['thing'] 554 | }); 555 | }); 556 | 557 | test('list of expr', function () { 558 | assert.deepEqual(createSearchPipeline(['thing', 'other', 'outlandish'], '=', 42), { 559 | pipeline: [{ 560 | $match: { 561 | $or: [{ thing: { $eq: 42 } }, { other: { $eq: 42 } }, { outlandish: { $eq: 42 } }] 562 | } 563 | }], 564 | fieldList: ['thing', 'other', 'outlandish'] 565 | }); 566 | }); 567 | }); 568 | 569 | suite('createSelectProjectExpression', function () { 570 | test('basics', function () { 571 | assert.deepEqual(createSelectProjectExpression(['field1', 'field2']), { 572 | field1: '$field1', 573 | field2: '$field2' 574 | }); 575 | }); 576 | 577 | test('explicitId', function () { 578 | assert.deepEqual(createSelectProjectExpression(['field1', 'field2'], true), { 579 | field1: '$field1', 580 | field2: '$field2', 581 | _id: '$_id' 582 | }); 583 | }); 584 | }); 585 | 586 | suite('createSelectPipeline', function () { 587 | test('works', function () { 588 | assert.deepEqual(createSelectPipeline(['field1', 'field2']), [{ 589 | $project: { 590 | field1: '$field1', 591 | field2: '$field2' 592 | } 593 | }]); 594 | }); 595 | }); 596 | 597 | suite('checkNestedField', function () { 598 | test('Quarter', function () { 599 | assert.deepEqual(checkNestedField('field.Quarter'), { 600 | base: 'field', 601 | nested: 'Quarter', 602 | filterFieldName: '___field_Quarter' 603 | }); 604 | }); 605 | 606 | test('year', function () { 607 | assert.deepEqual(checkNestedField('field.year'), { 608 | base: 'field', 609 | nested: 'year', 610 | filterFieldName: '___field_year' 611 | }); 612 | }); 613 | 614 | test('no match', function () { 615 | assert.isUndefined(checkNestedField('field.other')); 616 | }); 617 | }); 618 | 619 | suite('createAddNestedFieldsPipeline', function () { 620 | test('no recognized nested fields', function () { 621 | assert.deepEqual(createAddNestedFieldsPipeline(['field1', 'field2', 'field3.other'], 0), { pipeline: [], nestedFields: [] }); 622 | }); 623 | 624 | test('nested fields, tzo 60', function () { 625 | assert.deepEqual(createAddNestedFieldsPipeline(['field1', 'field2.year', 'field3.quarter', 'field4.month', 'field3.day', 'field3.dayofweek'], { timezoneOffset: 60 }), { 626 | pipeline: [{ 627 | $addFields: { 628 | ___field3_mp2: { 629 | $add: [{ 630 | $month: { 631 | $subtract: ['$field3', 3600000] 632 | } 633 | }, 2] 634 | } 635 | } 636 | }, { 637 | $addFields: { 638 | ___field2_year: { 639 | $year: { 640 | $subtract: ['$field2', 3600000] 641 | } 642 | }, 643 | ___field3_day: { 644 | $dayOfMonth: { 645 | $subtract: ['$field3', 3600000] 646 | } 647 | }, 648 | ___field3_dayofweek: { 649 | $subtract: [{ 650 | $dayOfWeek: { 651 | $subtract: ['$field3', 3600000] 652 | } 653 | }, 1] 654 | }, 655 | ___field3_quarter: { 656 | $divide: [{ 657 | $subtract: ['$___field3_mp2', { 658 | $mod: ['$___field3_mp2', 3] 659 | }] 660 | }, 3] 661 | }, 662 | ___field4_month: { 663 | $month: { 664 | $subtract: ['$field4', 3600000] 665 | } 666 | } 667 | } 668 | }], 669 | nestedFields: ['___field2_year', '___field3_mp2', '___field3_quarter', '___field4_month', '___field3_day', '___field3_dayofweek'] 670 | }); 671 | }); 672 | }); 673 | 674 | suite('createCompleteFilterPipeline', function () { 675 | test('works', function () { 676 | assert.deepEqual(createCompleteFilterPipeline('thing', '=', 42, [['thing2', '>', 13], 'and', ['date.month', '<', 5]], { timezoneOffset: 60 }), { 677 | pipeline: [{ 678 | $addFields: { 679 | ___date_month: { 680 | $month: { 681 | $subtract: ['$date', 3600000] 682 | } 683 | } 684 | } 685 | }, { 686 | $match: { 687 | thing: { 688 | $eq: 42 689 | } 690 | } 691 | }, { 692 | $match: { 693 | $and: [{ 694 | thing2: { 695 | $gt: 13 696 | } 697 | }, { 698 | ___date_month: { 699 | $lt: 5 700 | } 701 | }] 702 | } 703 | }], 704 | nestedFields: ['___date_month'] 705 | }); 706 | }); 707 | }); 708 | 709 | suite('createRemoveNestedFieldsPipeline', function () { 710 | test('works', function () { 711 | assert.deepEqual(createRemoveNestedFieldsPipeline(['field1', 'field2']), [{ $project: { field1: 0, field2: 0 } }]); 712 | }); 713 | }); 714 | 715 | suite('correctFilterOperatorStructure', function () { 716 | test('detect correct structure', function () { 717 | assert.isTrue(isCorrectFilterOperatorStructure([['field', '=', 42], 'and', ['field2', '>', 10], 'and', ['field3', '=', 15], 'and', ['field4', '=', 11], 'and', ['field8', '>', 100]], 'and')); 718 | }); 719 | 720 | test('reject missing operators', function () { 721 | assert.isFalse(isCorrectFilterOperatorStructure([['field', '=', 42], 'and', ['field2', '>', 10], ['field3', '=', 15], 'and', ['field4', '=', 11], 'and', ['field8', '>', 100]], 'and')); 722 | }); 723 | 724 | test('reject incorrect operators', function () { 725 | assert.isFalse(isCorrectFilterOperatorStructure([['field', '=', 42], 'and', ['field2', '>', 10], 'or', ['field3', '=', 15], 'and', ['field4', '=', 11], 'and', ['field8', '>', 100]], 'and')); 726 | }); 727 | }); 728 | 729 | suite('andChainWithIncompleteAnds', function () { 730 | test('detect short "and" chain with no "ands"', function () { 731 | assert.isTrue(isAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10]])); 732 | }); 733 | test('detect three element "and" chain with no "ands"', function () { 734 | assert.isTrue(isAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10], ['field3', '=', 15]])); 735 | }); 736 | test('detect long "and" chain with no "ands"', function () { 737 | assert.isTrue(isAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10], ['field3', '=', 15], ['field4', '=', 11], ['field8', '>', 100]])); 738 | }); 739 | test('detect long "and" chain with one "and"', function () { 740 | assert.isTrue(isAndChainWithIncompleteAnds([['field', '=', 42], 'and', ['field2', '>', 10], ['field3', '=', 15], ['field4', '=', 11], ['field8', '>', 100]])); 741 | }); 742 | test('detect long "and" chain with some "ands"', function () { 743 | assert.isTrue(isAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10], ['field3', '=', 15], 'and', ['field4', '=', 11], ['field8', '>', 100], 'and', ['field5', '=', 13]])); 744 | }); 745 | 746 | test('reject unary operator chain', function () { 747 | assert.isFalse(isAndChainWithIncompleteAnds(['!', ['field', '=', 10]])); 748 | }); 749 | 750 | test('reject simple criterion', function () { 751 | assert.isFalse(isAndChainWithIncompleteAnds(['field', '=', 10])); 752 | }); 753 | 754 | test('reject chain with invalid operators', function () { 755 | assert.isFalse(isAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10], ['field3', '=', 15], 'or', ['field4', '=', 11], ['field8', '>', 100]])); 756 | }); 757 | 758 | test('reject chain with complete set of operators', function () { 759 | assert.isFalse(isAndChainWithIncompleteAnds([['field', '=', 42], 'and', ['field2', '>', 10], 'and', ['field3', '=', 15], 'and', ['field4', '=', 11], 'and', ['field8', '>', 100]])); 760 | }); 761 | 762 | test('fix incomplete very short "and" chain with no "ands"', function () { 763 | assert.deepEqual(fixAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10]]), [['field', '=', 42], 'and', ['field2', '>', 10]]); 764 | }); 765 | 766 | test('fix incomplete short "and" chain with no "ands"', function () { 767 | assert.deepEqual(fixAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10], ['field3', '=', 15]]), [['field', '=', 42], 'and', ['field2', '>', 10], 'and', ['field3', '=', 15]]); 768 | }); 769 | 770 | test('fix incomplete long "and" chain with no "ands"', function () { 771 | assert.deepEqual(fixAndChainWithIncompleteAnds([['field', '=', 42], ['field2', '>', 10], ['field3', '=', 15], ['field4', '>', 42], ['field5', '=', 'something']]), [['field', '=', 42], 'and', ['field2', '>', 10], 'and', ['field3', '=', 15], 'and', ['field4', '>', 42], 'and', ['field5', '=', 'something']]); 772 | }); 773 | 774 | test('fix incomplete long "and" chain with one "and"', function () { 775 | assert.deepEqual(fixAndChainWithIncompleteAnds([['field', '=', 42], 'and', ['field2', '>', 10], ['field3', '=', 15], ['field4', '>', 42], ['field5', '=', 'something']]), [['field', '=', 42], 'and', ['field2', '>', 10], 'and', ['field3', '=', 15], 'and', ['field4', '>', 42], 'and', ['field5', '=', 'something']]); 776 | }); 777 | 778 | test('fix incomplete long "and" chain with some "ands"', function () { 779 | assert.deepEqual(fixAndChainWithIncompleteAnds([['field', '=', 42], 'and', ['field2', '>', 10], ['field3', '=', 15], 'and', ['field4', '>', 42], ['field5', '=', 'something']]), [['field', '=', 42], 'and', ['field2', '>', 10], 'and', ['field3', '=', 15], 'and', ['field4', '>', 42], 'and', ['field5', '=', 'something']]); 780 | }); 781 | }); 782 | }); -------------------------------------------------------------------------------- /dist/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 4 | 5 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 6 | 7 | var replaceId = function replaceId(item) { 8 | return item._id ? _extends({}, item, { _id: item._id.toHexString() }) : item; 9 | }; 10 | 11 | var createSummaryQueryExecutor = function createSummaryQueryExecutor(limit) { 12 | var queriesExecuted = 0; 13 | 14 | return function (fn) { 15 | return !limit || ++queriesExecuted <= limit ? fn() : Promise.resolve(); 16 | }; 17 | }; 18 | 19 | var merge = function merge(os) { 20 | return Object.assign.apply(Object, [{}].concat(_toConsumableArray(os))); 21 | }; 22 | 23 | var debug = function debug(id, f) { 24 | var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; 25 | 26 | var output = options.output || console.log; 27 | var processResult = options.processResult || function (result) { 28 | return result; 29 | }; 30 | var processArgs = options.processArgs || function (args) { 31 | return args; 32 | }; 33 | return function () { 34 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 35 | args[_key] = arguments[_key]; 36 | } 37 | 38 | output("DEBUG(" + id + "): ", processArgs(args)); 39 | var result = f.apply(undefined, args); 40 | output("DEBUG(" + id + "/result): ", processResult(result)); 41 | return result; 42 | }; 43 | }; 44 | 45 | module.exports = { replaceId: replaceId, createSummaryQueryExecutor: createSummaryQueryExecutor, merge: merge, debug: debug }; -------------------------------------------------------------------------------- /dist/utils.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var chai = require('chai'); 4 | var assert = chai.assert; 5 | var sinon = require('sinon'); 6 | 7 | var _require = require('./utils'), 8 | replaceId = _require.replaceId, 9 | createSummaryQueryExecutor = _require.createSummaryQueryExecutor; 10 | 11 | suite('utils', function () { 12 | suite('replaceId', function () { 13 | test('works', function () { 14 | assert.deepEqual(replaceId({ _id: { toHexString: function toHexString() { 15 | return '42'; 16 | } } }), { 17 | _id: '42' 18 | }); 19 | }); 20 | }); 21 | 22 | suite('createSummaryQueryExecutor', function () { 23 | test('works', function (done) { 24 | var exec = createSummaryQueryExecutor(3); 25 | var func = sinon.stub().resolves(); 26 | var promises = [exec(func), exec(func), exec(func), exec(func), exec(func)]; 27 | 28 | Promise.all(promises).then(function (rs) { 29 | assert.equal(rs.length, 5); 30 | 31 | assert.equal(func.callCount, 3); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | }); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/index'); -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/options'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devextreme-query-mongodb", 3 | "version": "2.0.16", 4 | "description": "Querying a MongoDB collection using DevExtreme data store load parameters", 5 | "main": "index.js", 6 | "files": [ 7 | "index.js", 8 | "options.js", 9 | "dist", 10 | "src", 11 | "LICENSE", 12 | "README.md" 13 | ], 14 | "scripts": { 15 | "prepare": "babel src --out-dir ./dist" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/oliversturm/devextreme-query-mongodb.git" 20 | }, 21 | "keywords": [ 22 | "DevExtreme", 23 | "MongoDB" 24 | ], 25 | "author": "Oliver Sturm ", 26 | "license": "SEE LICENSE IN LICENSE", 27 | "bugs": { 28 | "url": "https://github.com/oliversturm/devextreme-query-mongodb/issues" 29 | }, 30 | "homepage": "https://github.com/oliversturm/devextreme-query-mongodb#readme", 31 | "engines": { 32 | "node": ">=7.3" 33 | }, 34 | "dependencies": { 35 | "yup": "^1.1.0" 36 | }, 37 | "devDependencies": { 38 | "babel-cli": "^6.26.0", 39 | "babel-core": "^6.26.3", 40 | "babel-eslint": "^10.1.0", 41 | "babel-plugin-remove-comments": "^2.0.0", 42 | "babel-plugin-transform-es2015-modules-umd": "^6.24.1", 43 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 44 | "babel-polyfill": "^6.26.0", 45 | "babel-preset-env": "^1.7.0", 46 | "chai": "^4.3.7", 47 | "eslint": "^8.38.0", 48 | "eslint-plugin-promise": "^6.1.1", 49 | "mocha": "^10.2.0", 50 | "mongodb": "^5.2.0", 51 | "nyc": "^15.1.0", 52 | "qs": "^6.11.1", 53 | "sinon": "^15.0.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | createGroupFieldName, 3 | createGroupKeyPipeline, 4 | createGroupingPipeline, 5 | createSkipTakePipeline, 6 | createCountPipeline, 7 | createMatchPipeline, 8 | createSortPipeline, 9 | createSummaryPipeline, 10 | createSelectProjectExpression, 11 | createSelectPipeline, 12 | createCompleteFilterPipeline, 13 | createRemoveNestedFieldsPipeline, 14 | } = require('./pipelines'); 15 | const { 16 | replaceId, 17 | createSummaryQueryExecutor, 18 | merge, 19 | debug, 20 | } = require('./utils'); 21 | 22 | function createContext(contextOptions, loadOptions) { 23 | const aggregateCall = (collection, pipeline, identifier) => 24 | ((aggregateOptions) => collection.aggregate(pipeline, aggregateOptions))( 25 | contextOptions.dynamicAggregateOptions 26 | ? filterAggregateOptions( 27 | contextOptions.dynamicAggregateOptions( 28 | identifier, 29 | pipeline, 30 | collection 31 | ) 32 | ) 33 | : contextOptions.aggregateOptions 34 | ); 35 | 36 | const getCount = (collection, pipeline) => 37 | aggregateCall(collection, pipeline, 'getCount') 38 | .toArray() 39 | // Strangely, the pipeline returns an empty array when the "match" part 40 | // filters out all rows - I would expect to still see the "count" stage 41 | // working, but it doesn't. Ask mongo. 42 | .then((r) => (r.length > 0 ? r[0].count : 0)); 43 | 44 | const populateSummaryResults = (target, summary, summaryResults) => { 45 | if (summary) { 46 | target.summary = []; 47 | 48 | for (const s of summary) { 49 | switch (s.summaryType) { 50 | case 'sum': 51 | target.summary.push(summaryResults['___sum' + s.selector]); 52 | break; 53 | case 'avg': 54 | target.summary.push(summaryResults['___avg' + s.selector]); 55 | break; 56 | case 'min': 57 | target.summary.push(summaryResults['___min' + s.selector]); 58 | break; 59 | case 'max': 60 | target.summary.push(summaryResults['___max' + s.selector]); 61 | break; 62 | case 'count': 63 | target.summary.push(summaryResults.___count); 64 | break; 65 | default: 66 | console.error(`Invalid summaryType ${s.summaryType}, ignoring`); 67 | } 68 | } 69 | } 70 | return target; 71 | }; 72 | 73 | const queryGroupData = ( 74 | collection, 75 | desc, 76 | includeDataItems, 77 | countSeparately, 78 | itemProjection, 79 | groupKeyPipeline, 80 | sortPipeline, 81 | filterPipelineDetails, 82 | skipTakePipeline, 83 | matchPipeline 84 | ) => 85 | aggregateCall( 86 | collection, 87 | [ 88 | ...contextOptions.preProcessingPipeline, 89 | // sort pipeline first, apparently that enables it to use indexes 90 | ...sortPipeline, 91 | ...filterPipelineDetails.pipeline, 92 | ...matchPipeline, 93 | ...createRemoveNestedFieldsPipeline( 94 | filterPipelineDetails.nestedFields, 95 | contextOptions 96 | ), 97 | ...createGroupingPipeline( 98 | desc, 99 | includeDataItems, 100 | countSeparately, 101 | groupKeyPipeline, 102 | itemProjection, 103 | contextOptions 104 | ), 105 | ...skipTakePipeline, 106 | ], 107 | 'queryGroupData' 108 | ) 109 | .toArray() 110 | .then((r) => 111 | includeDataItems 112 | ? r.map((i) => ({ 113 | ...i, 114 | items: contextOptions.replaceIds 115 | ? i.items.map(replaceId) 116 | : i.items, 117 | })) 118 | : r 119 | ); 120 | 121 | const queryGroup = ( 122 | collection, 123 | groupIndex, 124 | runSummaryQuery, 125 | filterPipelineDetails, 126 | skipTakePipeline = [], 127 | summaryPipeline = [], 128 | matchPipeline = [] 129 | ) => { 130 | const group = loadOptions.group[groupIndex]; 131 | const lastGroup = groupIndex === loadOptions.group.length - 1; 132 | const itemDataRequired = lastGroup && group.isExpanded; 133 | const separateCountRequired = !lastGroup; 134 | 135 | // The current implementation of the dxDataGrid, at least, assumes that sub-group details are 136 | // always included in the result, whether or not the group is marked isExpanded. 137 | const subGroupsRequired = !lastGroup; // && group.isExpanded; 138 | const summariesRequired = 139 | loadOptions.groupSummary && loadOptions.groupSummary.length > 0; 140 | 141 | const groupKeyPipeline = createGroupKeyPipeline( 142 | group.selector, 143 | group.groupInterval, 144 | groupIndex, 145 | contextOptions 146 | ); 147 | 148 | const augmentWithSubGroups = (groupData) => 149 | subGroupsRequired 150 | ? groupData.map((item) => 151 | queryGroup( 152 | collection, 153 | groupIndex + 1, 154 | runSummaryQuery, 155 | filterPipelineDetails, // used unchanged in lower levels 156 | [], // skip/take doesn't apply on lower levels - correct? 157 | summaryPipeline, // unmodified 158 | // matchPipeline modified to filter down into group level 159 | [ 160 | ...matchPipeline, 161 | // not completely clean to include this in the match pipeline, but the field 162 | // added in the groupKeyPipeline is required specifically for the following match 163 | ...groupKeyPipeline, 164 | ...createMatchPipeline( 165 | createGroupFieldName(groupIndex), 166 | item.key, 167 | contextOptions 168 | ), 169 | ] 170 | ).then((r) => { 171 | item.items = r; 172 | item.count = r.length; 173 | return r; 174 | }) 175 | ) 176 | : []; 177 | 178 | const augmentWithSeparateCount = (groupData) => { 179 | if (separateCountRequired) { 180 | // We need to count separately because this is not the lowest level group, 181 | // but since we didn't query details about our nested group, we can't just go 182 | // for the length of the result array. An extra query is required in this case. 183 | // Even though the count is a type of summary for the group, it is special - different 184 | // from other group level summaries. The difference is that for each group, a summary 185 | // is usually calculated with its data, even if that data isn't actually visible in the 186 | // UI at the time. The count on the other hand is meant to represent the number of 187 | // elements in the group, and in case these elements are sub-groups instead of data 188 | // items, count represents a value that must not be calculated using the data items. 189 | 190 | const nextGroup = loadOptions.group[groupIndex + 1]; 191 | const nextGroupKeyPipeline = createGroupKeyPipeline( 192 | nextGroup.selector, 193 | nextGroup.groupInterval, 194 | groupIndex + 1, 195 | contextOptions 196 | ); 197 | return groupData.map((item) => 198 | getCount(collection, [ 199 | ...contextOptions.preProcessingPipeline, 200 | ...filterPipelineDetails.pipeline, 201 | ...groupKeyPipeline, 202 | 203 | ...matchPipeline, 204 | ...createMatchPipeline( 205 | createGroupFieldName(groupIndex), 206 | item.key, 207 | contextOptions 208 | ), 209 | ...createGroupingPipeline( 210 | nextGroup.desc, 211 | false, 212 | true, 213 | nextGroupKeyPipeline, 214 | contextOptions 215 | ), 216 | ...createCountPipeline(contextOptions), 217 | ]).then((r) => { 218 | item.count = r; 219 | return r; 220 | }) 221 | ); 222 | } else return []; 223 | }; 224 | 225 | const augmentWithSummaries = (groupData) => 226 | summariesRequired 227 | ? groupData.map((item) => 228 | runSummaryQuery(() => 229 | aggregateCall( 230 | collection, 231 | [ 232 | ...contextOptions.preProcessingPipeline, 233 | ...filterPipelineDetails.pipeline, 234 | ...groupKeyPipeline, 235 | ...matchPipeline, 236 | ...createMatchPipeline( 237 | createGroupFieldName(groupIndex), 238 | item.key, 239 | contextOptions 240 | ), 241 | ...summaryPipeline, 242 | ], 243 | 'augmentWithSummaries' 244 | ).toArray() 245 | ).then((r) => 246 | populateSummaryResults(item, loadOptions.groupSummary, r[0]) 247 | ) 248 | ) 249 | : []; 250 | 251 | return queryGroupData( 252 | collection, 253 | group.desc, 254 | itemDataRequired, 255 | separateCountRequired, 256 | createSelectProjectExpression(loadOptions.select, true), 257 | groupKeyPipeline, 258 | itemDataRequired 259 | ? createSortPipeline(loadOptions.sort, contextOptions) 260 | : [], 261 | filterPipelineDetails, 262 | skipTakePipeline, 263 | matchPipeline 264 | ).then( 265 | (groupData) => 266 | /* eslint-disable promise/no-nesting */ 267 | Promise.all([ 268 | ...augmentWithSubGroups(groupData), 269 | ...augmentWithSeparateCount(groupData), 270 | ...augmentWithSummaries(groupData), 271 | ]).then(() => groupData) 272 | /* eslint-enable promise/no-nesting */ 273 | ); 274 | }; 275 | 276 | const totalCount = (collection, completeFilterPipelineDetails) => 277 | loadOptions.requireTotalCount || loadOptions.totalSummary 278 | ? contextOptions.preferMetadataCount && 279 | contextOptions.preProcessingPipeline.length === 0 && 280 | completeFilterPipelineDetails.pipeline.length <= 1 281 | ? [ 282 | collection 283 | .count( 284 | completeFilterPipelineDetails.pipeline.length === 1 285 | ? completeFilterPipelineDetails.pipeline[0]['$match'] 286 | : undefined 287 | ) 288 | .then((r) => ({ totalCount: r })), 289 | ] 290 | : [ 291 | getCount(collection, [ 292 | ...contextOptions.preProcessingPipeline, 293 | ...completeFilterPipelineDetails.pipeline, 294 | ...createCountPipeline(contextOptions), 295 | ]).then((r) => ({ totalCount: r })), 296 | ] 297 | : []; 298 | 299 | const summary = 300 | (collection, completeFilterPipelineDetails) => (resultObject) => 301 | resultObject.totalCount > 0 && loadOptions.totalSummary 302 | ? aggregateCall( 303 | collection, 304 | [ 305 | ...contextOptions.preProcessingPipeline, 306 | ...completeFilterPipelineDetails.pipeline, 307 | ...createSummaryPipeline( 308 | loadOptions.totalSummary, 309 | contextOptions 310 | ), 311 | ], 312 | 'summary' 313 | ) 314 | .toArray() 315 | .then((r) => 316 | populateSummaryResults( 317 | resultObject, 318 | loadOptions.totalSummary, 319 | r[0] 320 | ) 321 | ) 322 | : Promise.resolve(resultObject); 323 | 324 | const queryGroups = (collection) => { 325 | const completeFilterPipelineDetails = createCompleteFilterPipeline( 326 | loadOptions.searchExpr, 327 | loadOptions.searchOperation, 328 | loadOptions.searchValue, 329 | loadOptions.filter, 330 | contextOptions 331 | ); 332 | const summaryPipeline = createSummaryPipeline( 333 | loadOptions.groupSummary, 334 | contextOptions 335 | ); 336 | const skipTakePipeline = createSkipTakePipeline( 337 | loadOptions.skip, 338 | loadOptions.take, 339 | contextOptions 340 | ); 341 | 342 | const mainQueryResult = () => 343 | queryGroup( 344 | collection, 345 | 0, 346 | createSummaryQueryExecutor(undefined), 347 | completeFilterPipelineDetails, 348 | skipTakePipeline, 349 | summaryPipeline 350 | ).then((r) => ({ data: r })); 351 | 352 | const groupCount = () => { 353 | if (loadOptions.requireGroupCount) { 354 | const group = loadOptions.group[0]; 355 | 356 | return [ 357 | getCount(collection, [ 358 | ...contextOptions.preProcessingPipeline, 359 | ...completeFilterPipelineDetails.pipeline, 360 | ...createGroupingPipeline( 361 | group.desc, 362 | false, 363 | true, 364 | createGroupKeyPipeline( 365 | group.selector, 366 | group.groupInterval, 367 | 0, 368 | contextOptions 369 | ), 370 | contextOptions 371 | ), 372 | ...createCountPipeline(contextOptions), 373 | ]).then((r) => ({ groupCount: r })), 374 | ]; 375 | } else return []; 376 | }; 377 | 378 | return Promise.all([ 379 | mainQueryResult(), 380 | ...groupCount(), 381 | ...totalCount(collection, completeFilterPipelineDetails), 382 | ]) 383 | .then(merge) 384 | .then(summary(collection, completeFilterPipelineDetails)); 385 | }; 386 | 387 | const querySimple = (collection) => { 388 | const completeFilterPipelineDetails = createCompleteFilterPipeline( 389 | loadOptions.searchExpr, 390 | loadOptions.searchOperation, 391 | loadOptions.searchValue, 392 | loadOptions.filter, 393 | contextOptions 394 | ); 395 | const sortPipeline = createSortPipeline(loadOptions.sort, contextOptions); 396 | const skipTakePipeline = createSkipTakePipeline( 397 | loadOptions.skip, 398 | loadOptions.take, 399 | contextOptions 400 | ); 401 | const selectPipeline = createSelectPipeline( 402 | loadOptions.select, 403 | contextOptions 404 | ); 405 | const removeNestedFieldsPipeline = createRemoveNestedFieldsPipeline( 406 | completeFilterPipelineDetails.nestedFields, 407 | contextOptions 408 | ); 409 | 410 | const mainQueryResult = () => 411 | aggregateCall( 412 | collection, 413 | [ 414 | ...contextOptions.preProcessingPipeline, 415 | ...completeFilterPipelineDetails.pipeline, 416 | ...sortPipeline, 417 | ...skipTakePipeline, 418 | ...selectPipeline, 419 | ...removeNestedFieldsPipeline, 420 | ], 421 | 'mainQueryResult' 422 | ) 423 | .toArray() 424 | .then((r) => (contextOptions.replaceIds ? r.map(replaceId) : r)) 425 | .then((r) => ({ data: r })); 426 | 427 | return Promise.all([ 428 | mainQueryResult(), 429 | ...totalCount(collection, completeFilterPipelineDetails), 430 | ]) 431 | .then(merge) 432 | .then(summary(collection, completeFilterPipelineDetails)); 433 | }; 434 | 435 | return { queryGroups, querySimple }; 436 | } 437 | 438 | function filterAggregateOptions(proposedOptions) { 439 | const acceptableAggregateOptionNames = [ 440 | 'allowDiskUse', 441 | 'maxTimeMS', 442 | 'readConcern', 443 | 'collation', 444 | 'hint', 445 | 'comment', 446 | ]; 447 | return Object.keys(proposedOptions).reduce( 448 | (r, v) => 449 | acceptableAggregateOptionNames.includes(v) 450 | ? { ...r, [v]: proposedOptions[v] } 451 | : r, 452 | {} 453 | ); 454 | } 455 | 456 | function query(collection, loadOptions = {}, options = {}) { 457 | const proposedAggregateOptions = options.aggregateOptions; 458 | delete options.aggregateOptions; 459 | 460 | const standardContextOptions = { 461 | replaceIds: true, 462 | summaryQueryLimit: 100, 463 | // timezone offset for the query, in the form returned by 464 | // Date.getTimezoneOffset 465 | timezoneOffset: 0, 466 | preProcessingPipeline: [], 467 | caseInsensitiveRegex: true, 468 | }; 469 | const contextOptions = Object.assign(standardContextOptions, options); 470 | 471 | if (!options.dynamicAggregateOptions && proposedAggregateOptions) 472 | contextOptions.aggregateOptions = filterAggregateOptions( 473 | proposedAggregateOptions 474 | ); 475 | 476 | const context = createContext(contextOptions, loadOptions); 477 | 478 | return loadOptions.group && loadOptions.group.length > 0 479 | ? context.queryGroups(collection) 480 | : context.querySimple(collection); 481 | } 482 | 483 | module.exports = query; 484 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | //const valueFixers = require('value-fixers'); 2 | const yup = require('yup'); 3 | 4 | var regexBool = /(true|false)/i; 5 | 6 | function OptionError(message = '') { 7 | this.name = 'OptionError'; 8 | this.message = message; 9 | } 10 | OptionError.prototype = Error.prototype; 11 | 12 | const asBool = (v) => { 13 | let match; 14 | if (typeof v === 'string' && (match = v.match(regexBool))) { 15 | return { 16 | true: true, 17 | false: false, 18 | }[match[0].toLowerCase()]; 19 | } else return !!v; 20 | }; 21 | 22 | function fixFilterAndSearch(schema) { 23 | // schema can be 24 | // { 25 | // fieldName1: 'int', 26 | // fieldName2: 'float', 27 | // fieldName3: 'datetime' 28 | // } 29 | 30 | const operators = ['=', '<>', '>', '>=', '<', '<=']; 31 | 32 | function fixValue(type, value) { 33 | return { 34 | int: parseInt, 35 | float: parseFloat, 36 | datetime: (v) => new Date(v), 37 | bool: asBool, 38 | }[type](value); 39 | } 40 | 41 | function fixFilter(f) { 42 | if (!f || !Array.isArray(f)) return f; 43 | if ( 44 | f.length === 3 && 45 | typeof f[2] === 'string' && 46 | schema[f[0]] && 47 | operators.includes(f[1]) 48 | ) 49 | return [f[0], f[1], fixValue(schema[f[0]], f[2])]; 50 | else return f.map((e) => fixFilter(e)); 51 | } 52 | 53 | // According to https://js.devexpress.com/Documentation/ApiReference/Data_Layer/DataSource/Configuration/#searchExpr 54 | // it is possible to pass an array of field values for searchExpr: ["firstName", "lastName"] 55 | // For "fixing" purposes, we need to assume that all such fields have the same 56 | // type. So if an array is passed for searchExpr (se), we simply look for the 57 | // first item in that array with a corresponding schema entry and use this 58 | // going forward. If you have fields in this array which are defined in the 59 | // schema to have different types, then that's your mistake. 60 | 61 | function fixSearch(se, so, sv) { 62 | if (!se || !so || !sv || typeof sv !== 'string') return sv; 63 | const fieldName = 64 | typeof se === 'string' 65 | ? schema[se] 66 | : Array.isArray(se) 67 | ? se.find((e) => (schema[e] ? e : null)) 68 | : null; 69 | return fieldName ? fixValue(schema[fieldName], sv) : sv; 70 | } 71 | 72 | return (qry) => { 73 | if (!qry) return qry; 74 | const fixedFilter = fixFilter(parse(qry.filter)); 75 | const fixedSearchValue = fixSearch( 76 | qry.searchExpr, 77 | qry.searchOperation, 78 | qry.searchValue 79 | ); 80 | 81 | return Object.assign( 82 | {}, 83 | qry, 84 | fixedFilter 85 | ? { 86 | filter: fixedFilter, 87 | } 88 | : {}, 89 | fixedSearchValue 90 | ? { 91 | searchValue: fixedSearchValue, 92 | } 93 | : {} 94 | ); 95 | }; 96 | } 97 | 98 | const wrapYupChecker = (yupChecker) => ({ 99 | validate: (o) => { 100 | try { 101 | yupChecker.validateSync(o, { strict: true }); 102 | return null; 103 | } catch (e) { 104 | return e; 105 | } 106 | }, 107 | }); 108 | 109 | const sortOptionsCheckerYup = yup 110 | .object() 111 | .shape({ 112 | desc: yup.bool().required(), 113 | selector: yup.string().required(), 114 | // Old note - needs rechecking. 115 | // isExpanded doesn't make any sense with sort, but the grid seems 116 | // to include it occasionally - probably a bug 117 | // Btw, this has always been without type restriction - could probably 118 | // be bool. 119 | isExpanded: yup.mixed(), 120 | }) 121 | .noUnknown(); 122 | 123 | const sortOptionsChecker = wrapYupChecker(sortOptionsCheckerYup); 124 | 125 | // Based on sample code 126 | // from https://codesandbox.io/s/example-with-or-validate-for-yup-nodelete-dgm4l?file=/src/index.js:29-497 127 | // from https://github.com/jquense/yup/issues/743 128 | yup.addMethod(yup.mixed, 'or', function (schemas, msg) { 129 | return this.test({ 130 | name: 'or', 131 | message: "Can't find valid schema" || msg, 132 | test: (value) => { 133 | if (!Array.isArray(schemas)) 134 | throw new OptionError('"or" requires schema array'); 135 | 136 | const results = schemas.map((schema) => 137 | schema.isValidSync(value, { strict: true }) 138 | ); 139 | return results.some((res) => !!res); 140 | }, 141 | exclusive: false, 142 | }); 143 | }); 144 | 145 | const groupOptionsCheckerYup = yup 146 | .object() 147 | .shape({ 148 | selector: yup.string().required(), 149 | desc: yup.bool(), 150 | isExpanded: yup.bool(), 151 | groupInterval: yup 152 | .mixed() 153 | .or([ 154 | yup.number().integer(), 155 | yup 156 | .mixed() 157 | .oneOf([ 158 | 'year', 159 | 'quarter', 160 | 'month', 161 | 'day', 162 | 'dayOfWeek', 163 | 'hour', 164 | 'minute', 165 | 'second', 166 | ]), 167 | ]), 168 | }) 169 | .noUnknown(); 170 | 171 | const groupOptionsChecker = wrapYupChecker(groupOptionsCheckerYup); 172 | 173 | const summaryOptionsCheckerYup = yup 174 | .object() 175 | .shape({ 176 | summaryType: yup 177 | .mixed() 178 | .oneOf(['sum', 'avg', 'min', 'max', 'count']) 179 | .required(), 180 | selector: yup.string(), 181 | }) 182 | .noUnknown(); 183 | 184 | const summaryOptionsChecker = wrapYupChecker(summaryOptionsCheckerYup); 185 | 186 | function validateAll(list, checker, short = true) { 187 | return list.reduce( 188 | (r, v) => { 189 | if (short && !r.valid) return r; // short circuiting 190 | const newr = checker.validate(v); 191 | if (newr) { 192 | r.errors.push(newr); 193 | r.valid = false; 194 | } 195 | return r; 196 | }, 197 | { valid: true, errors: [] } 198 | ); 199 | } 200 | 201 | function parse(arg, canBeString = false) { 202 | let ob = arg; 203 | if (typeof arg === 'string') { 204 | try { 205 | ob = JSON.parse(arg); 206 | } catch (e) { 207 | if (!canBeString) throw new OptionError(e.message); 208 | return arg; 209 | } 210 | } 211 | return ob; 212 | } 213 | 214 | function representsTrue(val) { 215 | return val === true || val === 'true'; 216 | } 217 | 218 | function wrapLoadOptions(lo) { 219 | return { 220 | loadOptions: lo, 221 | }; 222 | } 223 | 224 | function wrapProcessingOptions(po) { 225 | return { 226 | processingOptions: po, 227 | }; 228 | } 229 | 230 | function check( 231 | qry, 232 | onames, 233 | checker, 234 | converter = (v /*, vname*/) => v, 235 | defaultValue = {}, 236 | wrapper = wrapLoadOptions 237 | ) { 238 | const options = typeof onames === 'string' ? [onames] : onames; 239 | const allFound = qry && options.reduce((r, v) => r && !!qry[v], true); 240 | 241 | if (!allFound) return defaultValue; 242 | try { 243 | const vals = options.map((o) => converter(qry[o], o)); 244 | 245 | const checkResult = checker(...vals); 246 | 247 | // It's currently not possible to return per-value errors 248 | // If something goes wrong, all tested options will be highlighted 249 | // as errors at the same time. 250 | return checkResult 251 | ? wrapper(checkResult) 252 | : { 253 | errors: options.map((o) => `Invalid '${o}': ${qry[o]}`), 254 | }; 255 | } catch (err) { 256 | //console.log('Error caught in check: ' + JSON.stringify(err)); 257 | //console.log('Error message in check: ' + err.message); 258 | return { 259 | errors: [err], 260 | }; 261 | } 262 | } 263 | 264 | function takeOptions(qry) { 265 | return check( 266 | qry, 267 | 'take', 268 | (take) => 269 | take >= 0 270 | ? { 271 | take, 272 | } 273 | : null, 274 | (take) => parseInt(take) 275 | ); 276 | } 277 | 278 | function skipOptions(qry) { 279 | return check( 280 | qry, 281 | 'skip', 282 | (skip) => 283 | skip >= 0 284 | ? { 285 | skip, 286 | } 287 | : null, 288 | (skip) => parseInt(skip) 289 | ); 290 | } 291 | 292 | function totalCountOptions(qry) { 293 | return check( 294 | qry, 295 | 'requireTotalCount', 296 | (requireTotalCount) => ({ 297 | requireTotalCount, 298 | }), 299 | (requireTotalCount) => representsTrue(requireTotalCount) 300 | ); 301 | } 302 | 303 | function sortOptions(qry) { 304 | return check( 305 | qry, 306 | 'sort', 307 | (sort) => { 308 | const sortOptions = parse(sort); 309 | if (Array.isArray(sortOptions) && sortOptions.length > 0) { 310 | const vr = validateAll(sortOptions, sortOptionsChecker); 311 | if (vr.valid) 312 | return { 313 | sort: sortOptions, 314 | }; 315 | else { 316 | //console.log('Error string generated in sortOptions: ' + JSON.stringify(vr.errors)); 317 | throw new OptionError( 318 | `Sort parameter validation errors: ${JSON.stringify(vr.errors)}` 319 | ); 320 | } 321 | } else return null; 322 | }, 323 | (sort) => { 324 | const sortOptions = parse(sort); 325 | if (Array.isArray(sortOptions)) { 326 | return sortOptions.map((s) => ({ ...s, desc: representsTrue(s.desc) })); 327 | } else return sort; 328 | } 329 | ); 330 | } 331 | 332 | function groupOptions(qry) { 333 | return check( 334 | qry, 335 | 'group', 336 | (group) => { 337 | const groupOptions = parse(group); 338 | if (Array.isArray(groupOptions)) { 339 | if (groupOptions.length > 0) { 340 | const vr = validateAll(groupOptions, groupOptionsChecker); 341 | if (vr.valid) 342 | return mergeResults([ 343 | wrapLoadOptions({ 344 | group: groupOptions, 345 | }), 346 | check( 347 | qry, 348 | 'requireGroupCount', 349 | (requireGroupCount) => ({ 350 | requireGroupCount, 351 | }), 352 | (requireGroupCount) => representsTrue(requireGroupCount) 353 | ), 354 | check(qry, 'groupSummary', (groupSummary) => { 355 | const gsOptions = parse(groupSummary); 356 | if (Array.isArray(gsOptions)) { 357 | if (gsOptions.length > 0) { 358 | const vr = validateAll(gsOptions, summaryOptionsChecker); 359 | if (vr.valid) 360 | return { 361 | groupSummary: gsOptions, 362 | }; 363 | else 364 | throw new OptionError( 365 | `Group summary parameter validation errors: ${JSON.stringify( 366 | vr.errors 367 | )}` 368 | ); 369 | } else return {}; // ignore empty array 370 | } else return null; 371 | }), 372 | ]); 373 | else 374 | throw new OptionError( 375 | `Group parameter validation errors: ${JSON.stringify(vr.errors)}` 376 | ); 377 | } else return {}; // ignore empty array 378 | } else return null; 379 | }, 380 | (group) => { 381 | const groupOptions = parse(group); 382 | if (Array.isArray(groupOptions)) { 383 | return groupOptions.map((g) => ({ 384 | ...g, 385 | isExpanded: representsTrue(g.isExpanded), 386 | })); 387 | } else return group; 388 | }, 389 | undefined, 390 | (o) => o /* deactivate wrapper for the result */ 391 | ); 392 | } 393 | 394 | function totalSummaryOptions(qry) { 395 | return check(qry, 'totalSummary', (totalSummary) => { 396 | const tsOptions = parse(totalSummary); 397 | if (Array.isArray(tsOptions)) { 398 | if (tsOptions.length > 0) { 399 | const vr = validateAll(tsOptions, summaryOptionsChecker); 400 | if (vr.valid) 401 | return { 402 | totalSummary: tsOptions, 403 | }; 404 | else 405 | throw new OptionError( 406 | `Total summary parameter validation errors: ${JSON.stringify( 407 | vr.errors 408 | )}` 409 | ); 410 | } else return {}; // ignore empty array 411 | } else return null; 412 | }); 413 | } 414 | 415 | function filterOptions(qry) { 416 | return check(qry, 'filter', (filter) => { 417 | const filterOptions = parse(filter, true); 418 | if (typeof filterOptions === 'string' || Array.isArray(filterOptions)) 419 | return { 420 | filter: filterOptions, 421 | }; 422 | else return null; 423 | }); 424 | } 425 | 426 | function searchOptions(qry) { 427 | return check( 428 | qry, 429 | ['searchExpr', 'searchOperation', 'searchValue'], 430 | (se, so, sv) => { 431 | if (typeof se === 'string' || Array.isArray(se)) 432 | return { 433 | searchExpr: se, 434 | searchOperation: so, 435 | searchValue: sv, 436 | }; 437 | else return null; 438 | } 439 | ); 440 | } 441 | 442 | function selectOptions(qry) { 443 | return check(qry, 'select', (select) => { 444 | const selectOptions = parse(select, true); 445 | if (typeof selectOptions === 'string') 446 | return { 447 | select: [selectOptions], 448 | }; 449 | else if (Array.isArray(selectOptions)) { 450 | if (selectOptions.length > 0) { 451 | if (selectOptions.reduce((r, v) => r && typeof v === 'string')) 452 | return { 453 | select: selectOptions, 454 | }; 455 | else 456 | throw new OptionError( 457 | `Select array parameter has invalid content: ${JSON.stringify( 458 | selectOptions 459 | )}` 460 | ); 461 | } else return {}; // ignore empty array 462 | } else return null; 463 | }); 464 | } 465 | 466 | function timezoneOptions(qry) { 467 | return check( 468 | qry, 469 | 'tzOffset', 470 | (tzOffset) => ({ 471 | timezoneOffset: parseInt(tzOffset) || 0, 472 | }), 473 | (v) => v, 474 | { 475 | timezoneOffset: 0, 476 | }, 477 | wrapProcessingOptions 478 | ); 479 | } 480 | 481 | function caseInsensitiveRegexOptions(qry) { 482 | return check( 483 | qry, 484 | 'caseInsensitiveRegex', 485 | (caseInsensitiveRegex) => ({ 486 | caseInsensitiveRegex, 487 | }), 488 | (caseInsensitiveRegex) => representsTrue(caseInsensitiveRegex), 489 | { caseInsensitiveRegex: true }, 490 | wrapProcessingOptions 491 | ); 492 | } 493 | 494 | function summaryQueryLimitOptions(qry) { 495 | return check( 496 | qry, 497 | 'summaryQueryLimit', 498 | (sql) => 499 | sql >= 0 500 | ? { 501 | summaryQueryLimit: sql, 502 | } 503 | : {}, 504 | (sql) => parseInt(sql), 505 | {}, 506 | wrapProcessingOptions 507 | ); 508 | } 509 | 510 | function mergeResults(results) { 511 | return results.reduce( 512 | (r, v) => ({ 513 | loadOptions: { 514 | ...(r.loadOptions || {}), 515 | ...(v.loadOptions || {}), 516 | }, 517 | processingOptions: { 518 | ...(r.processingOptions || {}), 519 | ...(v.processingOptions || {}), 520 | }, 521 | errors: [...(r.errors || []), ...(v.errors || [])], 522 | }), 523 | {} 524 | ); 525 | } 526 | 527 | function getOptions(qry, schema) { 528 | if (!qry) return undefined; 529 | 530 | const fixedQry = schema ? fixFilterAndSearch(schema)(qry) : qry; 531 | 532 | // console.log('Fixed query: ', JSON.stringify(fixedQry, null, 2)); 533 | 534 | return mergeResults( 535 | [ 536 | takeOptions, 537 | skipOptions, 538 | totalCountOptions, 539 | sortOptions, 540 | groupOptions, 541 | totalSummaryOptions, 542 | filterOptions, 543 | searchOptions, 544 | selectOptions, 545 | timezoneOptions, 546 | summaryQueryLimitOptions, 547 | caseInsensitiveRegexOptions, 548 | ].map((f) => f(fixedQry)) 549 | ); 550 | } 551 | 552 | module.exports = { 553 | getOptions, 554 | private: { 555 | fixFilterAndSearch, 556 | validateAll, 557 | check, 558 | takeOptions, 559 | skipOptions, 560 | totalCountOptions, 561 | sortOptions, 562 | groupOptions, 563 | totalSummaryOptions, 564 | filterOptions, 565 | searchOptions, 566 | selectOptions, 567 | sortOptionsChecker, 568 | groupOptionsChecker, 569 | summaryOptionsChecker, 570 | asBool, 571 | parse 572 | }, 573 | }; 574 | -------------------------------------------------------------------------------- /src/pipelines.js: -------------------------------------------------------------------------------- 1 | const { ObjectId } = require('mongodb'); 2 | 3 | const createGroupFieldName = (groupIndex) => '___group_key_' + groupIndex; 4 | 5 | // much more complicated than it should be because braindead mongo 6 | // doesn't support integer division by itself 7 | // so I'm doing (dividend - (dividend MOD divisor)) / divisor 8 | const divInt = (dividend, divisor) => ({ 9 | $divide: [subtractMod(dividend, divisor), divisor], 10 | }); 11 | 12 | const subtractMod = (a, b) => ({ 13 | $subtract: [ 14 | a, 15 | { 16 | $mod: [a, b], 17 | }, 18 | ], 19 | }); 20 | 21 | const createGroupKeyPipeline = ( 22 | selector, 23 | groupInterval, 24 | groupIndex, 25 | contextOptions 26 | ) => { 27 | const { timezoneOffset } = contextOptions; 28 | 29 | const wrapGroupKey = (keyExpr) => ({ 30 | $addFields: { [createGroupFieldName(groupIndex)]: keyExpr }, 31 | }); 32 | 33 | const prefix = (s) => '$' + s; 34 | 35 | const pipe = (...args) => { 36 | let result = Array.from(args); 37 | result.groupIndex = groupIndex; 38 | return result; 39 | }; 40 | 41 | if (groupInterval) { 42 | const numericInterval = parseInt(Number(groupInterval)); 43 | if (numericInterval) { 44 | return pipe(wrapGroupKey(subtractMod(prefix(selector), numericInterval))); 45 | } else { 46 | // timezone adjusted field 47 | const tafield = { 48 | $subtract: [prefix(selector), timezoneOffset * 60 * 1000], 49 | }; 50 | 51 | switch (groupInterval) { 52 | case 'year': 53 | return pipe( 54 | wrapGroupKey({ 55 | $year: tafield, 56 | }) 57 | ); 58 | case 'quarter': 59 | return pipe( 60 | { 61 | // need to pre-calculate month(date)+2, because the divInt logic 62 | // will reuse the field and we don't want to calculate it multiple 63 | // times 64 | $addFields: { 65 | ___mp2: { 66 | $add: [ 67 | { 68 | $month: tafield, 69 | }, 70 | 2, 71 | ], 72 | }, 73 | }, 74 | }, 75 | wrapGroupKey(divInt('$___mp2', 3)) 76 | ); 77 | case 'month': 78 | return pipe( 79 | wrapGroupKey({ 80 | $month: tafield, 81 | }) 82 | ); 83 | case 'day': 84 | return pipe( 85 | wrapGroupKey({ 86 | $dayOfMonth: tafield, 87 | }) 88 | ); 89 | case 'dayOfWeek': 90 | return pipe( 91 | wrapGroupKey({ 92 | $subtract: [ 93 | { 94 | $dayOfWeek: tafield, // correct in that it's sunday to saturday, but it's 1-7 (must be 0-6) 95 | }, 96 | 1, 97 | ], 98 | }) 99 | ); 100 | case 'hour': 101 | return pipe( 102 | wrapGroupKey({ 103 | $hour: tafield, 104 | }) 105 | ); 106 | case 'minute': 107 | return pipe( 108 | wrapGroupKey({ 109 | $minute: tafield, 110 | }) 111 | ); 112 | case 'second': 113 | return pipe( 114 | wrapGroupKey({ 115 | $second: tafield, 116 | }) 117 | ); 118 | default: 119 | // unknown grouping operator, ignoring 120 | return pipe(wrapGroupKey(prefix(selector))); 121 | } 122 | } 123 | } else { 124 | return pipe(wrapGroupKey(prefix(selector))); 125 | } 126 | }; 127 | 128 | const createGroupStagePipeline = ( 129 | includeDataItems, 130 | countingSeparately, 131 | itemProjection, 132 | groupKeyPipeline 133 | ) => { 134 | let result = { 135 | $group: { 136 | // must use _id at this point for the group key 137 | _id: '$' + createGroupFieldName(groupKeyPipeline.groupIndex), 138 | }, 139 | }; 140 | if (!countingSeparately) { 141 | // this method of counting results in the number of data items in the group 142 | // if the group has sub-groups, it can't be used 143 | result.$group.count = { 144 | $sum: 1, 145 | }; 146 | } 147 | if (includeDataItems) { 148 | // include items directly if we're expected to do so, and if this is the 149 | // most deeply nested group in case there are several 150 | result.$group.items = { 151 | $push: itemProjection, 152 | }; 153 | } 154 | 155 | return groupKeyPipeline.concat([result]); 156 | }; 157 | 158 | const createGroupingPipeline = ( 159 | desc, 160 | includeDataItems, 161 | countingSeparately, 162 | groupKeyPipeline, 163 | itemProjection = '$$CURRENT' 164 | ) => { 165 | let projectStage = { 166 | $project: { 167 | // rename _id to key 168 | _id: 0, 169 | key: '$_id', 170 | }, 171 | }; 172 | let sortStage = { 173 | $sort: { 174 | key: desc ? -1 : 1, 175 | }, 176 | }; 177 | 178 | let pipeline = createGroupStagePipeline( 179 | includeDataItems, 180 | countingSeparately, 181 | itemProjection, 182 | groupKeyPipeline 183 | ).concat([projectStage, sortStage]); 184 | 185 | if (!countingSeparately) { 186 | // this method of counting results in the number of data items in the group 187 | // if the group has sub-groups, it can't be used 188 | projectStage.$project.count = 1; 189 | } 190 | 191 | if (includeDataItems) { 192 | // include items directly if we're expected to do so, and if this is the 193 | // most deeply nested group in case there are several 194 | projectStage.$project.items = 1; 195 | } else { 196 | // add null items field otherwise 197 | pipeline.push({ 198 | $addFields: { 199 | items: null, // only null works, not [] or leaving out items altogether 200 | }, 201 | }); 202 | } 203 | 204 | return pipeline; 205 | }; 206 | 207 | const createSkipTakePipeline = (skip, take) => { 208 | let pipeline = []; 209 | 210 | if (skip) 211 | pipeline.push({ 212 | $skip: skip, 213 | }); 214 | if (take) 215 | pipeline.push({ 216 | $limit: take, 217 | }); 218 | 219 | return pipeline; 220 | }; 221 | 222 | const createCountPipeline = () => { 223 | return [ 224 | { 225 | $count: 'count', 226 | }, 227 | ]; 228 | }; 229 | 230 | const createMatchPipeline = (selector, value) => [ 231 | { $match: { [selector]: value } }, 232 | ]; 233 | 234 | const construct = (fieldName, operator, compValue) => ({ 235 | [fieldName]: { [operator]: compValue }, 236 | }); 237 | 238 | const constructRegex = (fieldName, regex, caseInsensitive) => ({ 239 | [fieldName]: { $regex: regex, $options: caseInsensitive ? 'i' : '' }, 240 | }); 241 | 242 | const isCorrectFilterOperatorStructure = (element, operator) => 243 | element.reduce( 244 | (r, v) => { 245 | if (r.previous) return { ok: r.ok, previous: false }; 246 | else 247 | return { 248 | ok: r.ok && typeof v === 'string' && v.toLowerCase() === operator, 249 | previous: true, 250 | }; 251 | }, 252 | { ok: true, previous: true } 253 | ).ok; 254 | 255 | const isAndChainWithIncompleteAnds = (element) => { 256 | if (!Array.isArray(element)) return false; 257 | if (element.length < 2) return false; 258 | if (!Array.isArray(element[0])) return false; 259 | // this is important to prevent endless recursion 260 | if (isCorrectFilterOperatorStructure(element, 'and')) return false; 261 | return element.reduce( 262 | (r, v) => 263 | r && 264 | ((typeof v === 'string' && v.toLowerCase() === 'and') || 265 | Array.isArray(v)), 266 | true 267 | ); 268 | }; 269 | 270 | function* _fixAndChainWithIncompleteAnds(chain) { 271 | // the function assumes that 272 | // isAndChainWithIncompleteAnds(chain) === true 273 | let firstDone = false; 274 | let expectAnd = true; 275 | for (const item of chain) { 276 | if (!firstDone) { 277 | yield item; 278 | firstDone = true; 279 | } else { 280 | if (expectAnd) { 281 | if (typeof item === 'string') { 282 | yield 'and'; 283 | expectAnd = false; 284 | } else { 285 | yield 'and'; 286 | yield item; 287 | } 288 | } else { 289 | if (typeof item !== 'string') { 290 | yield item; 291 | expectAnd = true; 292 | } 293 | } 294 | } 295 | } 296 | } 297 | 298 | const fixAndChainWithIncompleteAnds = (element) => 299 | Array.from(_fixAndChainWithIncompleteAnds(element)); 300 | 301 | // eslint-disable-next-line complexity 302 | const parseFilter = (element, contextOptions = {}) => { 303 | // Element can be a string denoting a field name - I don't know if that's a case 304 | // supported by the widgets in any way, but it seems conceivable that somebody constructs 305 | // an expression like [ "!", "boolValueField" ] 306 | // In the string case, I return a truth-checking filter. 307 | // 308 | // Element can be an array with two items 309 | // For two items: 310 | // 0: unary operator 311 | // 1: operand 312 | // 313 | // Element can be an array with an odd number of items 314 | // For three items: 315 | // 0: operand 1 - this is described as the "getter" in the docs - i.e. field name - 316 | // but in the cases of "and" and "or" it could be another nested element 317 | // 1: operator 318 | // 2: operand 2 - the value for comparison - for "and" and "or" can be a nested element 319 | // 320 | // For more than three items, it's assumed that this is a chain of "or" or "and" - 321 | // either one or the other, no combinations 322 | // 0: operand 1 323 | // 1: "or" or "and" 324 | // 2: operand 2 325 | // 3: "or" or "and" - must be the same as (1) - see comment in test "incorrect operator chain" 326 | // 4: operand 3 327 | // .... etc 328 | // 329 | 330 | const { caseInsensitiveRegex } = contextOptions; 331 | 332 | const rval = (match, fieldList) => ({ match, fieldList }); 333 | 334 | if (typeof element === 'string') { 335 | const nf = checkNestedField(element); 336 | const fieldName = nf ? nf.filterFieldName : element; 337 | return rval(construct(fieldName, '$eq', true), [element]); 338 | } else if (Array.isArray(element)) { 339 | if (element.length === 1 && Array.isArray(element[0])) { 340 | // assuming a nested array in this case: 341 | // the pivot grid sometimes does this 342 | // [ [ "field", "=", 5 ] ] 343 | return parseFilter(element[0], contextOptions); 344 | } else if (element.length === 2) { 345 | // unary operator - only one supported 346 | if (element[0] === '!') { 347 | const { match, fieldList } = parseFilter(element[1], contextOptions); 348 | if (match) 349 | return rval( 350 | { 351 | $nor: [match], 352 | }, 353 | fieldList 354 | ); 355 | else return null; 356 | } else if (isAndChainWithIncompleteAnds(element)) 357 | return parseFilter( 358 | fixAndChainWithIncompleteAnds(element), 359 | contextOptions 360 | ); 361 | else return null; 362 | } else { 363 | if (isAndChainWithIncompleteAnds(element)) 364 | return parseFilter( 365 | fixAndChainWithIncompleteAnds(element), 366 | contextOptions 367 | ); 368 | else if (element.length % 2 === 1) { 369 | // odd number of elements - let's see what the operator is 370 | const operator = String(element[1]).toLowerCase(); 371 | 372 | if (['and', 'or'].includes(operator)) { 373 | if (isCorrectFilterOperatorStructure(element, operator)) { 374 | // all operators are the same - build a list of conditions from the nested 375 | // items, combine with the operator 376 | let result = element.reduce( 377 | (r, v) => { 378 | if (r.previous) return { ...r, previous: false }; 379 | else { 380 | const nestedResult = parseFilter(v, contextOptions); 381 | const nestedFilter = nestedResult && nestedResult.match; 382 | const fieldList = nestedResult ? nestedResult.fieldList : []; 383 | if (nestedFilter) r.list.push(nestedFilter); 384 | return { 385 | list: r.list, 386 | fieldList: r.fieldList.concat(fieldList), 387 | previous: true, 388 | }; 389 | } 390 | }, 391 | { list: [], fieldList: [], previous: false } 392 | ); 393 | 394 | return rval({ ['$' + operator]: result.list }, result.fieldList); 395 | } else return null; 396 | } else { 397 | if (element.length === 3) { 398 | const nf = checkNestedField(element[0]); 399 | const fieldName = nf ? nf.filterFieldName : element[0]; 400 | 401 | switch (operator) { 402 | case '=': 403 | return rval(construct(fieldName, '$eq', element[2]), [ 404 | element[0], 405 | ]); 406 | case '<>': 407 | return rval(construct(fieldName, '$ne', element[2]), [ 408 | element[0], 409 | ]); 410 | case '>': 411 | return rval(construct(fieldName, '$gt', element[2]), [ 412 | element[0], 413 | ]); 414 | case '>=': 415 | return rval(construct(fieldName, '$gte', element[2]), [ 416 | element[0], 417 | ]); 418 | case '<': 419 | return rval(construct(fieldName, '$lt', element[2]), [ 420 | element[0], 421 | ]); 422 | case '<=': 423 | return rval(construct(fieldName, '$lte', element[2]), [ 424 | element[0], 425 | ]); 426 | case 'startswith': 427 | return rval( 428 | constructRegex( 429 | fieldName, 430 | '^' + element[2], 431 | caseInsensitiveRegex 432 | ), 433 | [element[0]] 434 | ); 435 | case 'endswith': 436 | return rval( 437 | constructRegex( 438 | fieldName, 439 | element[2] + '$', 440 | caseInsensitiveRegex 441 | ), 442 | [element[0]] 443 | ); 444 | case 'contains': 445 | return rval( 446 | constructRegex(fieldName, element[2], caseInsensitiveRegex), 447 | [element[0]] 448 | ); 449 | case 'notcontains': 450 | return rval( 451 | constructRegex( 452 | fieldName, 453 | '^((?!' + element[2] + ').)*$', 454 | caseInsensitiveRegex 455 | ), 456 | [element[0]] 457 | ); 458 | case 'equalsobjectid': 459 | return rval( 460 | construct(fieldName, '$eq', new ObjectId(element[2])), 461 | [element[0]] 462 | ); 463 | default: 464 | return null; 465 | } 466 | } else return null; 467 | } 468 | } else return null; 469 | } 470 | } else return null; 471 | }; 472 | 473 | const createFilterPipeline = (filter, contextOptions) => { 474 | const dummy = { 475 | pipeline: [], 476 | fieldList: [], 477 | }; 478 | 479 | if (filter) { 480 | const result = parseFilter(filter, contextOptions); 481 | const match = result && result.match; 482 | const fieldList = result ? result.fieldList : []; 483 | if (match) 484 | return { 485 | pipeline: [ 486 | { 487 | $match: match, 488 | }, 489 | ], 490 | fieldList: fieldList, 491 | }; 492 | else return dummy; 493 | } else return dummy; 494 | }; 495 | 496 | const createSortPipeline = (sort) => 497 | sort 498 | ? [ 499 | { 500 | $sort: sort.reduce( 501 | (r, v) => ({ ...r, [v.selector]: v.desc ? -1 : 1 }), 502 | {} 503 | ), 504 | }, 505 | ] 506 | : []; 507 | 508 | const createSummaryPipeline = (summary) => { 509 | if (summary) { 510 | let gc = { _id: null }; 511 | for (const s of summary) { 512 | switch (s.summaryType) { 513 | case 'sum': 514 | case 'avg': 515 | case 'min': 516 | case 'max': 517 | gc[`___${s.summaryType}${s.selector}`] = { 518 | [`$${s.summaryType}`]: `$${s.selector}`, 519 | }; 520 | break; 521 | case 'count': 522 | gc.___count = { $sum: 1 }; 523 | break; 524 | default: 525 | console.error(`Invalid summary type '${s.summaryType}', ignoring`); 526 | break; 527 | } 528 | } 529 | return [ 530 | { 531 | $group: gc, 532 | }, 533 | ]; 534 | } else return []; 535 | }; 536 | 537 | const createSearchPipeline = (expr, op, val, contextOptions) => { 538 | const dummy = { 539 | pipeline: [], 540 | fieldList: [], 541 | }; 542 | 543 | if (!expr || !op || !val) return dummy; 544 | 545 | let criteria; 546 | if (typeof expr === 'string') criteria = [expr, op, val]; 547 | else if (expr.length > 0) { 548 | criteria = []; 549 | for (const exprItem of expr) { 550 | if (criteria.length) criteria.push('or'); 551 | criteria.push([exprItem, op, val]); 552 | } 553 | } else return dummy; 554 | 555 | return createFilterPipeline(criteria, contextOptions); 556 | }; 557 | 558 | const createSelectProjectExpression = (fields, explicitId = false) => { 559 | if (fields && fields.length > 0) { 560 | let project = {}; 561 | if (explicitId) project._id = '$_id'; 562 | for (const field of fields) project[field] = '$' + field; 563 | return project; 564 | } else return undefined; 565 | }; 566 | 567 | const createSelectPipeline = (fields, contextOptions) => { 568 | if (fields && fields.length > 0) { 569 | return [ 570 | { 571 | $project: createSelectProjectExpression(fields, contextOptions), 572 | }, 573 | ]; 574 | } else return []; 575 | }; 576 | 577 | // check whether any of the fields have a path structure ("field.Nested") 578 | // and a recognized element for the nested part 579 | // if so, I need to add the nested fields to the pipeline for filtering 580 | const nestedFieldRegex = /^([^.]+)\.(year|quarter|month|dayofweek|day)$/i; 581 | 582 | const checkNestedField = (fieldName) => { 583 | const match = nestedFieldRegex.exec(fieldName); 584 | if (!match) return undefined; 585 | return { 586 | base: match[1], 587 | nested: match[2], 588 | filterFieldName: `___${match[1]}_${match[2]}`, 589 | }; 590 | }; 591 | 592 | const createAddNestedFieldsPipeline = (fieldNames, contextOptions) => { 593 | const { timezoneOffset } = contextOptions; 594 | // constructing a pipeline that potentially has two parts, because the 595 | // quarter calculation has two steps 596 | // both parts will be wrapped in { $addFields: PART } after this 597 | // reduce call completes 598 | const pr = fieldNames.reduce( 599 | (r, v) => { 600 | const nf = checkNestedField(v); 601 | 602 | if (nf) { 603 | // ignore all unknown cases - perhaps people have actual db fields 604 | // with . in them 605 | const nestedFunction = nf.nested.toLowerCase(); 606 | if ( 607 | ['year', 'quarter', 'month', 'day', 'dayofweek'].includes( 608 | nestedFunction 609 | ) 610 | ) { 611 | // timezone adjusted field 612 | const tafield = { 613 | $subtract: ['$' + nf.base, timezoneOffset * 60 * 1000], 614 | }; 615 | 616 | switch (nestedFunction) { 617 | case 'year': 618 | r.pipeline[1][nf.filterFieldName] = { 619 | $year: tafield, 620 | }; 621 | r.nestedFields.push(nf.filterFieldName); 622 | break; 623 | case 'quarter': { 624 | const tempField = `___${nf.base}_mp2`; 625 | r.pipeline[0][tempField] = { 626 | $add: [ 627 | { 628 | $month: tafield, 629 | }, 630 | 2, 631 | ], 632 | }; 633 | r.nestedFields.push(tempField); 634 | r.pipeline[1][nf.filterFieldName] = divInt('$' + tempField, 3); 635 | r.nestedFields.push(nf.filterFieldName); 636 | break; 637 | } 638 | case 'month': 639 | r.pipeline[1][nf.filterFieldName] = { 640 | $month: tafield, 641 | }; 642 | r.nestedFields.push(nf.filterFieldName); 643 | break; 644 | case 'day': 645 | r.pipeline[1][nf.filterFieldName] = { 646 | $dayOfMonth: tafield, 647 | }; 648 | r.nestedFields.push(nf.filterFieldName); 649 | break; 650 | case 'dayofweek': 651 | r.pipeline[1][nf.filterFieldName] = { 652 | $subtract: [ 653 | { 654 | $dayOfWeek: tafield, 655 | }, 656 | 1, 657 | ], 658 | }; 659 | r.nestedFields.push(nf.filterFieldName); 660 | break; 661 | default: 662 | console.error('Hit a completely impossible default case'); 663 | } 664 | } 665 | } 666 | return r; 667 | }, 668 | { 669 | pipeline: [{}, {}], 670 | nestedFields: [], 671 | } 672 | ); 673 | [1, 0].forEach((i) => { 674 | if (Object.getOwnPropertyNames(pr.pipeline[i]).length === 0) { 675 | pr.pipeline.splice(i, 1); // nothing in this part, remove 676 | } else { 677 | pr.pipeline[i] = { 678 | $addFields: pr.pipeline[i], 679 | }; 680 | } 681 | }); 682 | 683 | return pr; 684 | }; 685 | 686 | const createCompleteFilterPipeline = ( 687 | searchExpr, 688 | searchOperation, 689 | searchValue, 690 | filter, 691 | contextOptions 692 | ) => { 693 | // this pipeline has the search options, the function also returns 694 | // a list of fields that are being accessed 695 | const searchPipeline = createSearchPipeline( 696 | searchExpr, 697 | searchOperation, 698 | searchValue, 699 | contextOptions 700 | ); 701 | // and the same for the filter option 702 | const filterPipeline = createFilterPipeline(filter, contextOptions); 703 | 704 | // this pipeline adds fields in case there are nested elements: 705 | // dateField.Month 706 | const addNestedFieldsPipelineDetails = createAddNestedFieldsPipeline( 707 | searchPipeline.fieldList.concat(filterPipeline.fieldList), 708 | contextOptions 709 | ); 710 | 711 | return { 712 | pipeline: addNestedFieldsPipelineDetails.pipeline.concat( 713 | searchPipeline.pipeline, 714 | filterPipeline.pipeline 715 | ), 716 | nestedFields: addNestedFieldsPipelineDetails.nestedFields, 717 | }; 718 | }; 719 | 720 | const createRemoveNestedFieldsPipeline = (nestedFields) => { 721 | if (nestedFields.length === 0) return []; 722 | 723 | let pd = {}; 724 | for (const f of nestedFields) pd[f] = 0; 725 | return [ 726 | { 727 | $project: pd, 728 | }, 729 | ]; 730 | }; 731 | 732 | module.exports = { 733 | createGroupFieldName, 734 | createGroupKeyPipeline, 735 | createGroupingPipeline, 736 | createSkipTakePipeline, 737 | createCountPipeline, 738 | createMatchPipeline, 739 | createSortPipeline, 740 | createSummaryPipeline, 741 | createSelectProjectExpression, 742 | createSelectPipeline, 743 | createCompleteFilterPipeline, 744 | createRemoveNestedFieldsPipeline, 745 | testing: { 746 | divInt, 747 | subtractMod, 748 | createGroupStagePipeline, 749 | construct, 750 | constructRegex, 751 | parseFilter, 752 | createFilterPipeline, 753 | createSearchPipeline, 754 | checkNestedField, 755 | createAddNestedFieldsPipeline, 756 | isAndChainWithIncompleteAnds, 757 | fixAndChainWithIncompleteAnds, 758 | isCorrectFilterOperatorStructure, 759 | }, 760 | }; 761 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // Mongo doesn't seem to have the ability of simply returning its ids as strings 2 | // to begin with. Bit of a pita, but hey... 3 | // We'll replace ids with strings if required. 4 | const replaceId = item => 5 | item._id ? { ...item, _id: item._id.toHexString() } : item; 6 | 7 | // We can apply a limit for summaries calculated per group query. The realistic problem 8 | // is that if a programmer makes the grid use server-side grouping as well as summaries, 9 | // but *not* groupPaging, there may be enormous numbers of summary queries to run, and because 10 | // this happens across levels, it can't easily be checked elsewhere and the server will just 11 | // keep working on that query as long as it takes. 12 | const createSummaryQueryExecutor = limit => { 13 | let queriesExecuted = 0; 14 | 15 | return fn => 16 | !limit || ++queriesExecuted <= limit ? fn() : Promise.resolve(); 17 | }; 18 | 19 | const merge = os => Object.assign({}, ...os); 20 | 21 | const debug = (id, f, options = {}) => { 22 | const output = options.output || console.log; 23 | const processResult = options.processResult || (result => result); 24 | const processArgs = options.processArgs || (args => args); 25 | return (...args) => { 26 | output(`DEBUG(${id}): `, processArgs(args)); 27 | const result = f(...args); 28 | output(`DEBUG(${id}/result): `, processResult(result)); 29 | return result; 30 | }; 31 | }; 32 | 33 | module.exports = { replaceId, createSummaryQueryExecutor, merge, debug }; 34 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | /* global suite, test */ 2 | 3 | const chai = require('chai'); 4 | const assert = chai.assert; 5 | const sinon = require('sinon'); 6 | 7 | const { replaceId, createSummaryQueryExecutor } = require('./utils'); 8 | 9 | suite('utils', function() { 10 | suite('replaceId', function() { 11 | test('works', function() { 12 | assert.deepEqual(replaceId({ _id: { toHexString: () => '42' } }), { 13 | _id: '42' 14 | }); 15 | }); 16 | }); 17 | 18 | suite('createSummaryQueryExecutor', function() { 19 | test('works', function(done) { 20 | const exec = createSummaryQueryExecutor(3); 21 | const func = sinon.stub().resolves(); 22 | const promises = [ 23 | exec(func), 24 | exec(func), 25 | exec(func), 26 | exec(func), 27 | exec(func) 28 | ]; 29 | /* eslint-disable */ 30 | Promise.all(promises).then(rs => { 31 | // five execs should render five results 32 | assert.equal(rs.length, 5); 33 | // but only three calls, because that's the limit 34 | assert.equal(func.callCount, 3); 35 | done(); 36 | }); 37 | /* eslint-enable */ 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = () => ({ 2 | files: ['src/*.js', { pattern: 'src/*.test.js', ignore: true }], 3 | // excluding index.test.js - these tests require running mongodb 4 | tests: [ 5 | //'src/index.test.js', 6 | 'src/options.test.js', 7 | 'src/pipelines.test.js', 8 | 'src/utils.test.js', 9 | ], 10 | 11 | testFramework: 'mocha', 12 | 13 | env: { type: 'node' }, 14 | 15 | setup: (w) => { 16 | const mocha = w.testFramework; 17 | mocha.ui('tdd'); 18 | }, 19 | }); 20 | --------------------------------------------------------------------------------