├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── Bug_report.md │ ├── Feature_request.md │ ├── Question.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── stale.yml ├── .gitignore ├── .travis.yml ├── CHANGES.md ├── CODEOWNERS ├── LICENSE ├── README.md ├── index.js ├── lib └── geo.js ├── package.json └── test └── filter.test.js /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/strongloop/loopback-filters/d09f80be4345bb2dab5ddc16405a9219369e7f36/.eslintignore -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "loopback", 3 | "rules": { 4 | "max-len": ["error", 100, 4, { 5 | "ignoreComments": true, 6 | "ignoreUrls": true, 7 | "ignorePattern": "^\\s*var\\s.+=\\s*(require\\s*\\()|(/)" 8 | }] 9 | } 10 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | labels: bug 5 | 6 | --- 7 | 8 | 18 | 19 | ## Steps to reproduce 20 | 21 | 22 | 23 | ## Current Behavior 24 | 25 | 26 | 27 | ## Expected Behavior 28 | 29 | 30 | 31 | ## Link to reproduction sandbox 32 | 33 | 37 | 38 | ## Additional information 39 | 40 | 45 | 46 | ## Related Issues 47 | 48 | 49 | 50 | _See [Reporting Issues](http://loopback.io/doc/en/contrib/Reporting-issues.html) for more tips on writing good issues_ 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: feature 5 | 6 | --- 7 | 8 | ## Suggestion 9 | 10 | 11 | 12 | ## Use Cases 13 | 14 | 18 | 19 | ## Examples 20 | 21 | 22 | 23 | ## Acceptance criteria 24 | 25 | TBD - will be filled by the team. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: The issue tracker is not for questions. Please use Stack Overflow or other resources for help. 4 | labels: question 5 | 6 | --- 7 | 8 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Report a security vulnerability 4 | url: https://loopback.io/doc/en/contrib/Reporting-issues.html#security-issues 5 | about: Do not report security vulnerabilities using GitHub issues. Please send an email to `reachsl@us.ibm.com` instead. 6 | - name: Get help on StackOverflow 7 | url: https://stackoverflow.com/tags/loopbackjs 8 | about: Please ask and answer questions on StackOverflow. 9 | - name: Join our mailing list 10 | url: https://groups.google.com/forum/#!forum/loopbackjs 11 | about: You can also post your question to our mailing list. 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 10 | 11 | ## Checklist 12 | 13 | 👉 [Read and sign the CLA (Contributor License Agreement)](https://cla.strongloop.com/agreements/strongloop/loopback-filters) 👈 14 | 15 | - [ ] `npm test` passes on your machine 16 | - [ ] New tests added or existing tests modified to cover all changes 17 | - [ ] Code conforms with the [style guide](https://loopback.io/doc/en/contrib/style-guide-es6.html) 18 | - [ ] Commit messages are following our [guidelines](https://loopback.io/doc/en/contrib/git-commit-messages.html) 19 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 14 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - critical 10 | - p1 11 | - major 12 | - good first issue 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: > 22 | This issue has been closed due to continued inactivity. Thank you for your understanding. 23 | If you believe this to be in error, please contact one of the code owners, 24 | listed in the [`CODEOWNERS`](https://github.com/strongloop/loopback-filters/blob/master/CODEOWNERS) file at the top-level of this repository. 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .project 3 | .DS_Store 4 | *.sublime* 5 | *.seed 6 | *.log 7 | *.csv 8 | *.dat 9 | *.out 10 | *.pid 11 | *.swp 12 | *.swo 13 | node_modules 14 | dist 15 | *xunit.xml 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 4 4 | - 6 5 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | 2020-09-01, Version 1.1.1 2 | ========================= 3 | 4 | * fix: filter correctly array property (Matteo Padovano) 5 | 6 | 7 | 2020-03-06, Version 1.1.0 8 | ========================= 9 | 10 | * Update LTS status in README (Miroslav Bajtoš) 11 | 12 | * chore: update copyrights year (Diana Lau) 13 | 14 | * Implement insensitive filtering (Jee Mok) 15 | 16 | * chore: improve issue and PR templates (Nora) 17 | 18 | * chore: exclude "good first issues" from stalebot (Miroslav Bajtoš) 19 | 20 | * chore: update CODEOWNERS (Diana Lau) 21 | 22 | * chore: add stalebot (Diana Lau) 23 | 24 | * update eslint dependency (Nora) 25 | 26 | * chore: update copyrights years (Agnes Lin) 27 | 28 | * Implement regexp and nin filtering (Y.Shing) 29 | 30 | 31 | 2017-12-05, Version 1.0.0 32 | ========================= 33 | 34 | * Upgrade deps + fix linting issues (Miroslav Bajtoš) 35 | 36 | * Drop support for legacy Node.js versions (0.x) (Miroslav Bajtoš) 37 | 38 | * chore: update license (Diana Lau) 39 | 40 | * Add npm script to start tests in watch mode (Bram Borggreve) 41 | 42 | * Add tests for 'and' and 'or' filters (Bram Borggreve) 43 | 44 | * Create Issue and PR Templates (#19) (Sakib Hasan) 45 | 46 | * Add CODEOWNER file (Diana Lau) 47 | 48 | * Corrected a typo in the filter.order error message (Hannu Kärkkäinen) 49 | 50 | * Removed unnecessary logging (Hannu Kärkkäinen) 51 | 52 | * Update where filter in readme (#15) (Sakib Hasan) 53 | 54 | * Add travis CI (#16) (Sakib Hasan) 55 | 56 | * Replicate new issue_template from loopback (Siddhi Pai) 57 | 58 | * Replicate issue_template from loopback repo (Siddhi Pai) 59 | 60 | * Update paid support URL (Siddhi Pai) 61 | 62 | * Fix ESLint errors for CI (Siddhi Pai) 63 | 64 | 65 | 2016-10-13, Version 0.1.3 66 | ========================= 67 | 68 | * update eslint infrastructure (Amir Jafarian) 69 | 70 | 71 | 2016-05-06, Version 0.1.2 72 | ========================= 73 | 74 | * update copyright notices and license (Ryan Graham) 75 | 76 | * Fix linting errors (Amir Jafarian) 77 | 78 | * Auto-update by eslint --fix (Amir Jafarian) 79 | 80 | * Add eslint infrastructure (Amir Jafarian) 81 | 82 | * Refer to licenses with a link (Sam Roberts) 83 | 84 | * Use strongloop conventions for licensing (Sam Roberts) 85 | 86 | * Improve readme with details about features (Ritchie Martori) 87 | 88 | 89 | 2015-06-23, Version 0.1.1 90 | ========================= 91 | 92 | * Rename (Ritchie Martori) 93 | 94 | 95 | 2015-06-23, Version 0.1.0 96 | ========================= 97 | 98 | * First release! 99 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners, 3 | # the last matching pattern has the most precedence. 4 | 5 | # Alumni maintainers 6 | # @kjdelisle @loay @b-admike @ssh24 @virkt25 7 | 8 | # Core team members from IBM 9 | * @jannyHou @dhmlau 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) IBM Corp. 2015,2017. All Rights Reserved. 2 | Node module: loopback-filters 3 | This project is licensed under the MIT License, full text below. 4 | 5 | -------- 6 | 7 | MIT license 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # loopback-filters 2 | 3 | **⚠️ LoopBack 3 has reached end of life. We are no longer accepting pull requests or providing 4 | support for community users. The only exception is fixes for critical bugs and security 5 | vulnerabilities provided as part of support for IBM API Connect customers. (See 6 | [Module Long Term Support Policy](#module-long-term-support-policy) below.)** 7 | 8 | We urge all LoopBack 3 users to migrate their applications to LoopBack 4 as 9 | soon as possible. Refer to our 10 | [Migration Guide](https://loopback.io/doc/en/lb4/migration-overview.html) 11 | for more information on how to upgrade. 12 | 13 | ## Overview 14 | 15 | This module implements LoopBack style filtering **without any dependencies on 16 | LoopBack**. 17 | 18 | ## Install 19 | 20 | ```sh 21 | $ npm install loopback-filters 22 | ``` 23 | 24 | ## Usage 25 | 26 | Below is a basic example using the module 27 | 28 | ```js 29 | var applyFilter = require('loopback-filters'); 30 | var data = [{foo: 'bar'}, {bat: 'baz'}, {foo: 'bar'}]; 31 | var filter = {where: {foo: 'bar'}}; 32 | 33 | var filtered = applyFilter(data, filter); 34 | 35 | console.log(filtered); 36 | ``` 37 | 38 | The output would be: 39 | 40 | ```js 41 | [{foo: 'bar'}, {foo: 'bar'}] 42 | ``` 43 | 44 | ## Features 45 | 46 | **[Where](http://docs.strongloop.com/display/public/LB/Where+filter)** 47 | 48 | ```js 49 | // return items where 50 | applyFilter({ 51 | where: { 52 | // the price > 10 && price < 100 53 | and: [ 54 | { 55 | price: { 56 | gt: 10 57 | } 58 | }, 59 | { 60 | price: { 61 | lt: 100 62 | } 63 | }, 64 | ], 65 | 66 | // match Mens Shoes and Womens Shoes and any other type of Shoe 67 | category: {like: '.* Shoes'}, 68 | 69 | // the status is either in-stock or available 70 | status: {inq: ['in-stock', 'available']} 71 | } 72 | }) 73 | ``` 74 | 75 | Only include objects that match the specified where clause. See [full list of supported operators](http://docs.strongloop.com/display/public/LB/Where+filter#Wherefilter-Operators). 76 | 77 | **[Geo Filter / Near](http://docs.strongloop.com/display/public/LB/Where+filter#Wherefilter-near)** 78 | 79 | ```js 80 | applyFilter(data, { 81 | where: { 82 | location: {near: '153.536,-28'} 83 | }, 84 | limit: 10 85 | }) 86 | ``` 87 | 88 | Sort objects by distance to a specified `GeoPoint`. 89 | 90 | **[Order](http://docs.strongloop.com/display/public/LB/Order+filter)** 91 | 92 | Sort objects by one or more properties. 93 | 94 | **[Limit](http://docs.strongloop.com/display/public/LB/Limit+filter) / [Skip](http://docs.strongloop.com/display/public/LB/Skip+filter)** 95 | 96 | Limit the results to a specified number. Skip a specified number of results. 97 | 98 | **[Fields](http://docs.strongloop.com/display/public/LB/Fields+filter)** 99 | 100 | Include or exclude a set of fields in the result. 101 | 102 | **Note: [Inclusion](http://docs.strongloop.com/display/public/LB/Include+filter) from loopback is not supported!** 103 | 104 | ## Docs 105 | 106 | [See the LoopBack docs](http://docs.strongloop.com/display/public/LB/Querying+data) for the filter syntax. 107 | 108 | ## Module Long Term Support Policy 109 | 110 | This module adopts the [ 111 | Module Long Term Support (LTS)](http://github.com/CloudNativeJS/ModuleLTS) policy, 112 | with the following End Of Life (EOL) dates: 113 | 114 | | Version | Status | Published | EOL | 115 | | ------- | --------------- | --------- | -------- | 116 | | 1.x | End-of-Life | Dec 2017 | Dec 2020 | 117 | 118 | Learn more about our LTS plan in [docs](https://loopback.io/doc/en/contrib/Long-term-support.html). 119 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2015,2019. All Rights Reserved. 2 | // Node module: loopback-filters 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | 'use strict'; 7 | var debug = require('debug')('loopback:filter'); 8 | var geo = require('./lib/geo'); 9 | 10 | module.exports = function filterNodes(nodes, filter) { 11 | if (filter) { 12 | debug('filter %j', filter); 13 | // do we need some sorting? 14 | if (filter.order) { 15 | nodes = nodes.sort(sorting.bind(normalizeOrder(filter))); 16 | } 17 | 18 | var nearFilter = geo.nearFilter(filter.where); 19 | 20 | // geo sorting 21 | if (nearFilter) { 22 | nodes = geo.filter(nodes, nearFilter); 23 | } 24 | 25 | // do we need some filtration? 26 | if (filter.where) { 27 | nodes = nodes ? nodes.filter(applyFilter(filter)) : nodes; 28 | } 29 | 30 | // field selection 31 | if (filter.fields) { 32 | nodes = nodes.map(selectFields(filter.fields)); 33 | } 34 | 35 | // limit/skip 36 | var skip = filter.skip || filter.offset || 0; 37 | var limit = filter.limit || nodes.length; 38 | nodes = nodes.slice(skip, skip + limit); 39 | } 40 | 41 | return nodes; 42 | }; 43 | 44 | function applyFilter(filter) { 45 | var where = filter.where; 46 | if (typeof where === 'function') { 47 | return where; 48 | } 49 | return function(obj) { 50 | return matchesFilter(obj, filter); 51 | }; 52 | } 53 | 54 | function matchesFilter(obj, filter) { 55 | var where = filter.where; 56 | var pass = true; 57 | var keys = Object.keys(where); 58 | keys.forEach(function(key) { 59 | if (key === 'and' || key === 'or') { 60 | if (Array.isArray(where[key])) { 61 | if (key === 'and') { 62 | pass = where[key].every(function(cond) { 63 | return applyFilter({where: cond})(obj); 64 | }); 65 | return pass; 66 | } 67 | if (key === 'or') { 68 | pass = where[key].some(function(cond) { 69 | return applyFilter({where: cond})(obj); 70 | }); 71 | return pass; 72 | } 73 | } 74 | } 75 | if (!test(where[key], getValue(obj, key))) { 76 | pass = false; 77 | } 78 | }); 79 | return pass; 80 | } 81 | 82 | function toRegExp(pattern) { 83 | if (pattern instanceof RegExp) { 84 | return pattern; 85 | } 86 | var regex = ''; 87 | // Escaping user input to be treated as a literal string within a regex 88 | // https://developer.mozilla.org 89 | // /en-US/docs/Web/JavaScript/Guide/Regular_Expressions 90 | // #Writing_a_Regular_Expression_Pattern 91 | pattern = pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1'); 92 | for (var i = 0, n = pattern.length; i < n; i++) { 93 | var char = pattern.charAt(i); 94 | if (char === '\\') { 95 | i++; // Skip to next char 96 | if (i < n) { 97 | regex += pattern.charAt(i); 98 | } 99 | continue; 100 | } else if (char === '%') { 101 | regex += '.*'; 102 | } else if (char === '_') { 103 | regex += '.'; 104 | } else if (char === '.') { 105 | regex += '\\.'; 106 | } else if (char === '*') { 107 | regex += '\\*'; 108 | } else { 109 | regex += char; 110 | } 111 | } 112 | return regex; 113 | } 114 | 115 | function test(example, value) { 116 | if (typeof value === 'string' && (example instanceof RegExp)) { 117 | return value.match(example); 118 | } 119 | 120 | if (example === undefined) { 121 | return undefined; 122 | } 123 | 124 | if (typeof example === 'object' && example !== null) { 125 | // ignore geo near filter 126 | if (example.near) { 127 | return true; 128 | } 129 | 130 | if (example.inq) { 131 | if (Array.isArray(value)) { 132 | return example.inq.every(function(v) { 133 | return value.indexOf(v) !== -1; 134 | }); 135 | } 136 | 137 | for (var i = 0; i < example.inq.length; i++) { 138 | if (example.inq[i] == value) { 139 | return true; 140 | } 141 | } 142 | return false; 143 | } 144 | 145 | if (example.nin) { 146 | if (!Array.isArray(example.nin)) 147 | throw TypeError('Invalid nin query, you must pass an array to nin query.'); 148 | if (example.nin.length === 0) return true; // not in [] should be always true; 149 | 150 | if (Array.isArray(value)) { 151 | return example.nin.some(function(v) { 152 | return value.indexOf(v) === -1; 153 | }); 154 | } 155 | 156 | for (var j = 0; j < example.nin.length; j++) { 157 | if (example.nin[j] == value) { 158 | return false; 159 | } 160 | } 161 | return true; 162 | } 163 | 164 | if ('neq' in example) { 165 | return compare(example.neq, value) !== 0; 166 | } 167 | 168 | if ('between' in example) { 169 | return (testInEquality({gte: example.between[0]}, value) && 170 | testInEquality({lte: example.between[1]}, value)); 171 | } 172 | 173 | if (example.regexp) { 174 | var regexp; 175 | if (example.regexp instanceof RegExp) { 176 | regexp = example.regexp; 177 | } else if (typeof example.regexp === 'string') { 178 | regexp = toRegExp(example.regexp); 179 | } else { 180 | throw new TypeError('Invalid regular expression passed to regexp query.'); 181 | } 182 | return regexp.test(value); 183 | } 184 | 185 | if (example.like || example.nlike) { 186 | var like = example.like || example.nlike; 187 | if (typeof like === 'string') { 188 | like = toRegExp(like); 189 | } 190 | if (example.like) { 191 | return new RegExp(like, example.options).test(value); 192 | } 193 | 194 | if (example.nlike) { 195 | return !new RegExp(like, example.options).test(value); 196 | } 197 | } 198 | 199 | if (testInEquality(example, value)) { 200 | return true; 201 | } 202 | } 203 | // not strict equality 204 | return (example !== null ? example.toString() : example) == (value != null ? 205 | value.toString() : value); 206 | } 207 | 208 | /** 209 | * Compare two values 210 | * @param {*} val1 The 1st value 211 | * @param {*} val2 The 2nd value 212 | * @returns {number} 0: =, positive: >, negative < 213 | * @private 214 | */ 215 | function compare(val1, val2) { 216 | if (val1 == null || val2 == null) { 217 | // Either val1 or val2 is null or undefined 218 | return val1 == val2 ? 0 : NaN; 219 | } 220 | if (typeof val1 === 'number') { 221 | return val1 - val2; 222 | } 223 | if (typeof val1 === 'string') { 224 | return (val1 > val2) ? 1 : ((val1 < val2) ? -1 : (val1 == val2) ? 0 : NaN); 225 | } 226 | if (typeof val1 === 'boolean') { 227 | return val1 - val2; 228 | } 229 | if (val1 instanceof Date) { 230 | var result = val1 - val2; 231 | return result; 232 | } 233 | // Return NaN if we don't know how to compare 234 | return (val1 == val2) ? 0 : NaN; 235 | } 236 | 237 | function testInEquality(example, val) { 238 | if ('gt' in example) { 239 | return compare(val, example.gt) > 0; 240 | } 241 | if ('gte' in example) { 242 | return compare(val, example.gte) >= 0; 243 | } 244 | if ('lt' in example) { 245 | return compare(val, example.lt) < 0; 246 | } 247 | if ('lte' in example) { 248 | return compare(val, example.lte) <= 0; 249 | } 250 | return false; 251 | } 252 | 253 | function getValue(obj, path) { 254 | if (obj == null) { 255 | return undefined; 256 | } 257 | var keys = path.split('.'); 258 | var val = obj; 259 | for (var i = 0, n = keys.length; i < n; i++) { 260 | val = val[keys[i]]; 261 | if (val == null) { 262 | return val; 263 | } 264 | } 265 | return val; 266 | } 267 | 268 | function selectFields(fields) { 269 | // map function 270 | return function(obj) { 271 | var result = {}; 272 | var key; 273 | 274 | for (var i = 0; i < fields.length; i++) { 275 | key = fields[i]; 276 | 277 | result[key] = obj[key]; 278 | } 279 | return result; 280 | }; 281 | } 282 | 283 | function sorting(a, b) { 284 | var undefinedA, undefinedB; 285 | 286 | for (var i = 0, l = this.length; i < l; i++) { 287 | var aVal = getValue(a, this[i].key); 288 | var bVal = getValue(b, this[i].key); 289 | undefinedB = bVal === undefined && aVal !== undefined; 290 | undefinedA = aVal === undefined && bVal !== undefined; 291 | 292 | if (undefinedB || aVal > bVal) { 293 | return 1 * this[i].reverse; 294 | } else if (undefinedA || aVal < bVal) { 295 | return -1 * this[i].reverse; 296 | } 297 | } 298 | 299 | return 0; 300 | } 301 | 302 | function normalizeOrder(filter) { 303 | var orders = filter.order; 304 | 305 | // transform into an array 306 | if (typeof orders === 'string') { 307 | if (filter.order.indexOf(',') > -1) { 308 | orders = filter.order.split(/,\s*/); 309 | } else { 310 | orders = [filter.order]; 311 | } 312 | } 313 | 314 | orders.forEach(function(key, i) { 315 | var reverse = 1; 316 | var m = key.match(/\s+(A|DE)SC$/i); 317 | if (m) { 318 | key = key.replace(/\s+(A|DE)SC/i, ''); 319 | if (m[1].toLowerCase() === 'de') reverse = -1; 320 | } else { 321 | var Ctor = SyntaxError || Error; 322 | throw new Ctor('filter.order must include ASC or DESC'); 323 | } 324 | orders[i] = {'key': key, 'reverse': reverse}; 325 | }); 326 | 327 | return (filter.orders = orders); 328 | } 329 | -------------------------------------------------------------------------------- /lib/geo.js: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2015,2019. All Rights Reserved. 2 | // Node module: loopback-filters 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | 'use strict'; 7 | var assert = require('assert'); 8 | 9 | /*! 10 | * Get a near filter from a given where object. For connector use only. 11 | */ 12 | 13 | exports.nearFilter = function nearFilter(where) { 14 | var result = false; 15 | 16 | if (where && typeof where === 'object') { 17 | Object.keys(where).forEach(function(key) { 18 | var ex = where[key]; 19 | 20 | if (ex && ex.near) { 21 | result = { 22 | near: ex.near, 23 | maxDistance: ex.maxDistance, 24 | key: key, 25 | }; 26 | } 27 | }); 28 | } 29 | 30 | return result; 31 | }; 32 | 33 | /*! 34 | * Filter a set of objects using the given `nearFilter`. 35 | */ 36 | 37 | exports.filter = function(arr, filter) { 38 | var origin = filter.near; 39 | var max = filter.maxDistance > 0 ? filter.maxDistance : false; 40 | var key = filter.key; 41 | 42 | // create distance index 43 | var distances = {}; 44 | var result = []; 45 | 46 | arr.forEach(function(obj) { 47 | var loc = obj[key]; 48 | 49 | // filter out objects without locations 50 | if (!loc) return; 51 | 52 | if (!(loc instanceof GeoPoint)) { 53 | loc = new GeoPoint(loc); 54 | } 55 | 56 | if (typeof loc.lat !== 'number') return; 57 | if (typeof loc.lng !== 'number') return; 58 | 59 | var d = GeoPoint.distanceBetween(origin, loc); 60 | 61 | if (max && d > max) { 62 | // dont add 63 | } else { 64 | distances[obj.id] = d; 65 | result.push(obj); 66 | } 67 | }); 68 | 69 | return result.sort(function(objA, objB) { 70 | var a = objB[key]; 71 | var b = objB[key]; 72 | 73 | if (a && b) { 74 | var da = distances[objA.id]; 75 | var db = distances[objB.id]; 76 | 77 | if (db === da) return 0; 78 | return da > db ? 1 : -1; 79 | } else { 80 | return 0; 81 | } 82 | }); 83 | }; 84 | 85 | exports.GeoPoint = GeoPoint; 86 | 87 | /** 88 | * The GeoPoint object represents a physical location. 89 | * 90 | * For example: 91 | * 92 | * ```js 93 | * var loopback = require(‘loopback’); 94 | * var here = new loopback.GeoPoint({lat: 10.32424, lng: 5.84978}); 95 | * ``` 96 | * 97 | * Embed a latitude / longitude point in a model. 98 | * 99 | * ```js 100 | * var CoffeeShop = loopback.createModel('coffee-shop', { 101 | * location: 'GeoPoint' 102 | * }); 103 | * ``` 104 | * 105 | * @class GeoPoint 106 | * @property {Number} lat The latitude in degrees. 107 | * @property {Number} lng The longitude in degrees. 108 | * 109 | * @options {Object} Options Object with two Number properties: lat and long. 110 | * @property {Number} lat The latitude point in degrees. Range: -90 to 90. 111 | * @property {Number} lng The longitude point in degrees. Range: -180 to 180. 112 | * 113 | * @options {Array} Options Array with two Number entries: [lat,long]. 114 | * @property {Number} lat The latitude point in degrees. Range: -90 to 90. 115 | * @property {Number} lng The longitude point in degrees. Range: -180 to 180. 116 | */ 117 | 118 | function GeoPoint(data) { 119 | if (!(this instanceof GeoPoint)) { 120 | return new GeoPoint(data); 121 | } 122 | 123 | if (arguments.length === 2) { 124 | data = { 125 | lat: arguments[0], 126 | lng: arguments[1], 127 | }; 128 | } 129 | 130 | assert(Array.isArray(data) || 131 | typeof data === 'object' || typeof data === 'string', 132 | 'must provide valid geo-coordinates array [lat, lng] or object' + 133 | ' or a "lat, lng" string'); 134 | 135 | if (typeof data === 'string') { 136 | data = data.split(/,\s*/); 137 | assert(data.length === 2, 138 | 'must provide a string "lat,lng" creating a GeoPoint with a string'); 139 | } 140 | 141 | if (Array.isArray(data)) { 142 | data = { 143 | lat: Number(data[0]), 144 | lng: Number(data[1]), 145 | }; 146 | } else { 147 | data.lng = Number(data.lng); 148 | data.lat = Number(data.lat); 149 | } 150 | 151 | assert(typeof data === 'object', 152 | 'must provide a lat and lng object when creating a GeoPoint'); 153 | assert(typeof data.lat === 'number' && !isNaN(data.lat), 154 | 'lat must be a number when creating a GeoPoint'); 155 | assert(typeof data.lng === 'number' && !isNaN(data.lng), 156 | 'lng must be a number when creating a GeoPoint'); 157 | assert(data.lng <= 180, 'lng must be <= 180'); 158 | assert(data.lng >= -180, 'lng must be >= -180'); 159 | assert(data.lat <= 90, 'lat must be <= 90'); 160 | assert(data.lat >= -90, 'lat must be >= -90'); 161 | 162 | this.lat = data.lat; 163 | this.lng = data.lng; 164 | } 165 | 166 | /** 167 | * Determine the spherical distance between two GeoPoints. 168 | * 169 | * @param {GeoPoint} pointA Point A 170 | * @param {GeoPoint} pointB Point B 171 | * @options {Object} options Options object with one key, 'type'. See below. 172 | * @property {String} type Unit of measurement, one of: 173 | * 174 | * - `miles` (default) 175 | * - `radians` 176 | * - `kilometers` 177 | * - `meters` 178 | * - `miles` 179 | * - `feet` 180 | * - `degrees` 181 | */ 182 | 183 | GeoPoint.distanceBetween = function distanceBetween(a, b, options) { 184 | if (!(a instanceof GeoPoint)) { 185 | a = new GeoPoint(a); 186 | } 187 | if (!(b instanceof GeoPoint)) { 188 | b = new GeoPoint(b); 189 | } 190 | 191 | var x1 = a.lat; 192 | var y1 = a.lng; 193 | 194 | var x2 = b.lat; 195 | var y2 = b.lng; 196 | 197 | return geoDistance(x1, y1, x2, y2, options); 198 | }; 199 | 200 | /** 201 | * Determine the spherical distance to the given point. 202 | * Example: 203 | * ```js 204 | * var loopback = require(‘loopback’); 205 | * 206 | * var here = new loopback.GeoPoint({lat: 10, lng: 10}); 207 | * var there = new loopback.GeoPoint({lat: 5, lng: 5}); 208 | * 209 | * loopback.GeoPoint.distanceBetween(here, there, {type: 'miles'}) // 438 210 | * ``` 211 | * @param {Object} point GeoPoint object to which to measure distance. 212 | * @options {Object} options Options object with one key, 'type'. See below. 213 | * @property {String} type Unit of measurement, one of: 214 | * 215 | * - `miles` (default) 216 | * - `radians` 217 | * - `kilometers` 218 | * - `meters` 219 | * - `miles` 220 | * - `feet` 221 | * - `degrees` 222 | */ 223 | 224 | GeoPoint.prototype.distanceTo = function(point, options) { 225 | return GeoPoint.distanceBetween(this, point, options); 226 | }; 227 | 228 | /** 229 | * Simple serialization. 230 | */ 231 | 232 | GeoPoint.prototype.toString = function() { 233 | return this.lat + ',' + this.lng; 234 | }; 235 | 236 | /** 237 | * @property {Number} DEG2RAD - Factor to convert degrees to radians. 238 | * @property {Number} RAD2DEG - Factor to convert radians to degrees. 239 | * @property {Object} EARTH_RADIUS - Radius of the earth. 240 | */ 241 | 242 | // factor to convert degrees to radians 243 | var DEG2RAD = 0.01745329252; 244 | 245 | // factor to convert radians degrees to degrees 246 | var RAD2DEG = 57.29577951308; 247 | 248 | // radius of the earth 249 | var EARTH_RADIUS = { 250 | kilometers: 6370.99056, 251 | meters: 6370990.56, 252 | miles: 3958.75, 253 | feet: 20902200, 254 | radians: 1, 255 | degrees: RAD2DEG, 256 | }; 257 | 258 | function geoDistance(x1, y1, x2, y2, options) { 259 | var type = (options && options.type) || 'miles'; 260 | 261 | // Convert to radians 262 | x1 = x1 * DEG2RAD; 263 | y1 = y1 * DEG2RAD; 264 | x2 = x2 * DEG2RAD; 265 | y2 = y2 * DEG2RAD; 266 | 267 | // use the haversine formula to calculate distance 268 | // for any 2 points on a sphere. 269 | // ref http://en.wikipedia.org/wiki/Haversine_formula 270 | var haversine = function(a) { 271 | return Math.pow(Math.sin(a / 2.0), 2); 272 | }; 273 | 274 | var f = Math.sqrt(haversine(x2 - x1) + 275 | Math.cos(x2) * Math.cos(x1) * haversine(y2 - y1)); 276 | 277 | return 2 * Math.asin(f) * EARTH_RADIUS[type]; 278 | } 279 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-filters", 3 | "version": "1.1.1", 4 | "description": "Apply LoopBack filters to Arrays", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=4.0.0" 8 | }, 9 | "directories": { 10 | "test": "test" 11 | }, 12 | "dependencies": { 13 | "debug": "^3.1.0" 14 | }, 15 | "devDependencies": { 16 | "eslint": "^4.18.2", 17 | "eslint-config-loopback": "^8.0.0", 18 | "mocha": "^4.0.1", 19 | "should": "^13.1.3" 20 | }, 21 | "scripts": { 22 | "lint": "eslint .", 23 | "test": "mocha", 24 | "test:watch": "mocha --watch", 25 | "posttest": "npm run lint" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/strongloop/loopback-filters.git" 30 | }, 31 | "keywords": [ 32 | "LoopBack", 33 | "filter", 34 | "sort", 35 | "sorting", 36 | "query" 37 | ], 38 | "author": "IBM Corp.", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/strongloop/loopback-filters/issues" 42 | }, 43 | "homepage": "https://github.com/strongloop/loopback-filters" 44 | } 45 | -------------------------------------------------------------------------------- /test/filter.test.js: -------------------------------------------------------------------------------- 1 | // Copyright IBM Corp. 2015,2019. All Rights Reserved. 2 | // Node module: loopback-filters 3 | // This file is licensed under the MIT License. 4 | // License text available at https://opensource.org/licenses/MIT 5 | 6 | 'use strict'; 7 | var assert = require('assert'); 8 | var should = require('should'); 9 | var filter = require('../'); 10 | var users; 11 | 12 | describe('filter', function() { 13 | before(seed); 14 | 15 | it('should allow to find using like', function(done) { 16 | applyFilter({where: {name: {like: '%St%'}}}, function(err, users) { 17 | should.not.exist(err); 18 | users.should.have.property('length', 2); 19 | done(); 20 | }); 21 | }); 22 | 23 | it('should allow to find using like with options', function(done) { 24 | applyFilter({where: {name: {like: '%St%', options: 'i'}}}, function(err, users) { 25 | should.not.exist(err); 26 | users.should.have.property('length', 3); 27 | done(); 28 | }); 29 | }); 30 | 31 | it('should support like for no match', function(done) { 32 | applyFilter({where: {name: {like: 'M%XY'}}}, function(err, users) { 33 | should.not.exist(err); 34 | users.should.have.property('length', 0); 35 | done(); 36 | }); 37 | }); 38 | 39 | it('should allow to find using nlike', function(done) { 40 | applyFilter({where: {name: {nlike: '%St%'}}}, function(err, users) { 41 | should.not.exist(err); 42 | users.should.have.property('length', 4); 43 | done(); 44 | }); 45 | }); 46 | 47 | it('should allow to find using nlike with options', function(done) { 48 | applyFilter({where: {name: {nlike: '%St%', options: 'i'}}}, function(err, users) { 49 | should.not.exist(err); 50 | users.should.have.property('length', 3); 51 | done(); 52 | }); 53 | }); 54 | 55 | it('should support nlike for no match', function(done) { 56 | applyFilter({where: {name: {nlike: 'M%XY'}}}, function(err, users) { 57 | should.not.exist(err); 58 | users.should.have.property('length', 6); 59 | done(); 60 | }); 61 | }); 62 | 63 | it('should allow to find using an \'and\' filter ', function(done) { 64 | var andFilter = [ 65 | {vip: true}, 66 | {role: 'lead'}, 67 | ]; 68 | applyFilter({where: {and: andFilter}}, function(err, users) { 69 | should.not.exist(err); 70 | users.should.have.property('length', 2); 71 | done(); 72 | }); 73 | }); 74 | it('should allow to find using regexp', function(done) { 75 | applyFilter({where: {name: {regexp: /John/}}}, function(err, users) { 76 | should.not.exist(err); 77 | users.should.have.property('length', 1); 78 | done(); 79 | }); 80 | }); 81 | 82 | it('should allow to find using an \'or\' filter ', function(done) { 83 | var orFilter = [ 84 | {name: 'John Lennon'}, 85 | {name: 'Paul McCartney'}, 86 | ]; 87 | applyFilter({where: {or: orFilter}}, function(err, users) { 88 | should.not.exist(err); 89 | users.should.have.property('length', 2); 90 | done(); 91 | }); 92 | }); 93 | 94 | it('should allow to find using inq - array property', function(done) { 95 | applyFilter({where: {topics: {inq: ['game', 'dance']}}}, function(err, users) { 96 | should.not.exist(err); 97 | users.should.have.property('length', 2); 98 | done(); 99 | }); 100 | }); 101 | 102 | it('should allow to find using nin', function(done) { 103 | applyFilter({where: {name: {nin: ['George Harrison']}}}, function(err, users) { 104 | should.not.exist(err); 105 | users.should.have.property('length', 5); 106 | done(); 107 | }); 108 | }); 109 | 110 | it('should allow to find using nin - array property', function(done) { 111 | applyFilter({where: {topics: {nin: ['music']}}}, function(err, users) { 112 | should.not.exist(err); 113 | users.should.have.property('length', 5); 114 | done(); 115 | }); 116 | }); 117 | 118 | // input validation 119 | describe.skip('invalid input', function() { 120 | it( 121 | 'should throw if the regexp value is not string or regexp', 122 | function(done) { 123 | applyFilter({where: {name: {regexp: 123}}}, function(err, users) { 124 | should.exist(err); 125 | should.equal(err.message, 'Invalid regular expression passed to regexp query.'); 126 | done(); 127 | }); 128 | } 129 | ); 130 | 131 | it( 132 | 'should throw if the like value is not string or regexp', 133 | function(done) { 134 | applyFilter({where: {name: {like: 123}}}, function(err, users) { 135 | should.exist(err); 136 | done(); 137 | }); 138 | } 139 | ); 140 | 141 | it( 142 | 'should throw if the nlike value is not string or regexp', 143 | function(done) { 144 | applyFilter({where: {name: {nlike: 123}}}, function(err, users) { 145 | should.exist(err); 146 | done(); 147 | }); 148 | } 149 | ); 150 | 151 | it('should throw if the inq value is not an array', function(done) { 152 | applyFilter({where: {name: {inq: '12'}}}, function(err, users) { 153 | should.exist(err); 154 | done(); 155 | }); 156 | }); 157 | 158 | it('should throw if the nin value is not an array', function(done) { 159 | applyFilter({where: {name: {nin: '12'}}}, function(err, users) { 160 | should.exist(err); 161 | done(); 162 | }); 163 | }); 164 | 165 | it('should throw if the between value is not an array', function(done) { 166 | applyFilter({where: {name: {between: '12'}}}, function(err, users) { 167 | should.exist(err); 168 | done(); 169 | }); 170 | }); 171 | 172 | it( 173 | 'should throw if the between value is not an array of length 2', 174 | function(done) { 175 | applyFilter({where: {name: {between: ['12']}}}, function(err, users) { 176 | should.exist(err); 177 | done(); 178 | }); 179 | } 180 | ); 181 | }); 182 | 183 | it('should successfully extract 5 users from the db', function(done) { 184 | applyFilter({where: {seq: {between: [1, 5]}}}, function(err, users) { 185 | should(users.length).be.equal(5); 186 | done(); 187 | }); 188 | }); 189 | 190 | it('should successfully extract 1 user (Lennon) from the db', function(done) { 191 | applyFilter({ 192 | where: {birthday: {between: [new Date(1970, 0), new Date(1990, 0)]}}, 193 | }, 194 | function(err, users) { 195 | should(users.length).be.equal(1); 196 | should(users[0].name).be.equal('John Lennon'); 197 | done(); 198 | }); 199 | }); 200 | 201 | it('should successfully extract 2 users from the db', function(done) { 202 | applyFilter({ 203 | where: {birthday: {between: [new Date(1940, 0), new Date(1990, 0)]}}, 204 | }, 205 | function(err, users) { 206 | should(users.length).be.equal(2); 207 | done(); 208 | }); 209 | }); 210 | 211 | it('should successfully extract 0 user from the db', function(done) { 212 | applyFilter({where: {birthday: {between: [new Date(1990, 0), Date.now()]}}}, 213 | function(err, users) { 214 | should(users.length).be.equal(0); 215 | done(); 216 | }); 217 | }); 218 | 219 | it('should support order with multiple fields', function(done) { 220 | applyFilter({order: 'vip ASC, seq DESC'}, function(err, users) { 221 | should.not.exist(err); 222 | users[0].seq.should.be.eql(4); 223 | users[1].seq.should.be.eql(3); 224 | done(); 225 | }); 226 | }); 227 | 228 | it('should sort undefined values to the end when ordered DESC', 229 | function(done) { 230 | applyFilter({order: 'vip ASC, order DESC'}, function(err, users) { 231 | should.not.exist(err); 232 | 233 | users[4].seq.should.be.eql(1); 234 | users[5].seq.should.be.eql(0); 235 | done(); 236 | }); 237 | }); 238 | 239 | it('should throw if order has wrong direction', function(done) { 240 | applyFilter({order: 'seq ABC'}, function(err, users) { 241 | should.exist(err); 242 | done(); 243 | }); 244 | }); 245 | 246 | it('should support neq operator for number', function(done) { 247 | applyFilter({where: {seq: {neq: 4}}}, function(err, users) { 248 | should.not.exist(err); 249 | users.length.should.be.equal(5); 250 | for (var i = 0; i < users.length; i++) { 251 | users[i].seq.should.not.be.equal(4); 252 | } 253 | done(); 254 | }); 255 | }); 256 | 257 | it('should support neq operator for string', function(done) { 258 | applyFilter({where: {role: {neq: 'lead'}}}, function(err, users) { 259 | should.not.exist(err); 260 | users.length.should.be.equal(4); 261 | for (var i = 0; i < users.length; i++) { 262 | if (users[i].role) { 263 | users[i].role.not.be.equal('lead'); 264 | } 265 | } 266 | done(); 267 | }); 268 | }); 269 | 270 | it('should support neq operator for null', function(done) { 271 | applyFilter({where: {role: {neq: null}}}, function(err, users) { 272 | should.not.exist(err); 273 | users.length.should.be.equal(2); 274 | for (var i = 0; i < users.length; i++) { 275 | should.exist(users[i].role); 276 | } 277 | done(); 278 | }); 279 | }); 280 | 281 | it('should support nested property in query', function(done) { 282 | applyFilter({where: {'address.city': 'San Jose'}}, function(err, users) { 283 | should.not.exist(err); 284 | users.length.should.be.equal(1); 285 | for (var i = 0; i < users.length; i++) { 286 | users[i].address.city.should.be.eql('San Jose'); 287 | } 288 | done(); 289 | }); 290 | }); 291 | 292 | it('should support nested property with gt in query', function(done) { 293 | applyFilter({where: {'address.city': {gt: 'San'}}}, function(err, users) { 294 | should.not.exist(err); 295 | users.length.should.be.equal(2); 296 | for (var i = 0; i < users.length; i++) { 297 | users[i].address.state.should.be.eql('CA'); 298 | } 299 | done(); 300 | }); 301 | }); 302 | 303 | it('should support nested property for order in query', function(done) { 304 | applyFilter({where: {'address.state': 'CA'}, order: 'address.city DESC'}, 305 | function(err, users) { 306 | should.not.exist(err); 307 | users.length.should.be.equal(2); 308 | users[0].address.city.should.be.eql('San Mateo'); 309 | users[1].address.city.should.be.eql('San Jose'); 310 | done(); 311 | }); 312 | }); 313 | }); 314 | 315 | // this weird function allows us to 316 | // re-use the tests from juggler 317 | function applyFilter(filterObj, cb) { 318 | var result; 319 | 320 | try { 321 | result = filter(users, filterObj); 322 | } catch (e) { 323 | return cb(e); 324 | } 325 | 326 | cb(null, result); 327 | } 328 | 329 | function seed() { 330 | users = [ 331 | { 332 | seq: 0, 333 | name: 'John Lennon', 334 | email: 'john@b3atl3s.co.uk', 335 | role: 'lead', 336 | birthday: new Date('1980-12-08'), 337 | vip: true, 338 | address: { 339 | street: '123 A St', 340 | city: 'San Jose', 341 | state: 'CA', 342 | zipCode: '95131', 343 | }, 344 | topics: ['game', 'music', 'sport', 'dance'], 345 | }, 346 | { 347 | seq: 1, 348 | name: 'Paul McCartney', 349 | email: 'paul@b3atl3s.co.uk', 350 | role: 'lead', 351 | birthday: new Date('1942-06-18'), 352 | order: 1, 353 | vip: true, 354 | address: { 355 | street: '456 B St', 356 | city: 'San Mateo', 357 | state: 'CA', 358 | zipCode: '94065', 359 | }, 360 | topics: ['game', 'dance'], 361 | }, 362 | {seq: 2, name: 'George Harrison', order: 5, vip: false, topics: ['sport']}, 363 | {seq: 3, name: 'Ringo Starr', order: 6, vip: false}, 364 | {seq: 4, name: 'Pete Best', order: 4}, 365 | {seq: 5, name: 'Stuart Sutcliffe', order: 3, vip: true}, 366 | ]; 367 | } 368 | --------------------------------------------------------------------------------