├── .gitignore ├── .npmignore ├── .travis.yml ├── Cakefile ├── Changelog.md ├── LICENSE ├── README.md ├── bower.json ├── lib ├── underscore-query.amd.js ├── underscore-query.amd.min.js ├── underscore-query.js └── underscore-query.min.js ├── package.json ├── src └── underscore-query.coffee └── test ├── blank.coffee ├── lodash.coffee ├── mocha.opts ├── suite.coffee └── test.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | # Idea IDE files 2 | .idea/ 3 | node_modules/ 4 | *.iml 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .git/ 3 | node_modules/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 0.10 5 | - 4 6 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | # CakeFile inspired by chosen: 2 | # https://raw.github.com/harvesthq/chosen/master/Cakefile 3 | # 4 | 5 | fs = require 'fs' 6 | path = require 'path' 7 | {spawn, exec} = require 'child_process' 8 | CoffeeScript = require 'coffee-script' 9 | handlebars = require "handlebars" 10 | uglify = require 'uglify-js' 11 | wrapper = handlebars.compile fs.readFileSync("build/wrapper.js").toString() 12 | 13 | output = 14 | 'lib/underscore-query.js': ["src/underscore-query.coffee"] 15 | 'lib/underscore-query.amd.js': ["src/underscore-query.coffee"] 16 | 17 | 18 | wrap = (code) -> wrapper({code}) 19 | 20 | task 'build', 'build from source', -> 21 | for js, sources of output 22 | isAMD = js.indexOf("amd") isnt -1 23 | js = path.join(__dirname, js) 24 | 25 | code = '' 26 | for source in sources 27 | source = path.join(__dirname, source) 28 | file_contents = "#{fs.readFileSync source}" 29 | code += CoffeeScript.compile file_contents, {bare:isAMD} 30 | if isAMD 31 | code = wrap(code) 32 | fs.writeFileSync js, code 33 | minName = js.replace(/\.js$/,'.min.js') 34 | minCode = uglify.minify(code, {fromString:true}) 35 | fs.writeFileSync minName, minCode.code -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # 2.0 2 | - (**Breaking**) Made compound and subqueries align with the mongodb API ([#19](https://github.com/davidgtonge/underscore-query/issues/19), [#16](https://github.com/davidgtonge/underscore-query/issues/16), [#17](https://github.com/davidgtonge/underscore-query/issues/17), [#10](https://github.com/davidgtonge/underscore-query/issues/10)) 3 | - (**Breaking**) Removed ES5 underscore shim. An `lodash` or `underscore` instance now must be passed to `underscore-query`. 4 | - (Change) Functions as query properties are now dynamically evaluated ([#22](https://github.com/davidgtonge/underscore-query/pull/22)) 5 | - (Change) Added support for querying properties of items in an array ([#21](https://github.com/davidgtonge/underscore-query/pull/21)) 6 | - (Fix) Fixed an issue where the provided getter would sometimes be overwritten if `q.getter` is assigned ([`16bf75`](https://github.com/davidgtonge/underscore-query/commit/16bf7529e20b886d717ba1e979db52dd313ea1bd)) 7 | - (Fix) Added support for Lodash@4 8 | - (Fix) Null is no longer evaluated as 0 in numerical comparisions ([#23](https://github.com/davidgtonge/underscore-query/issues/23)) 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Dave Tonge 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | underscore-query (v2.0) 2 | =================== 3 | 4 | [![Build Status](https://secure.travis-ci.org/davidgtonge/underscore-query.png)](http://travis-ci.org/davidgtonge/underscore-query) 5 | 6 | A lightweight query API plugin for Underscore.js - works in the Browser and on the Server. 7 | This project was originally [Backbone Query](https://github.com/davidgtonge/backbone_query), however I found that it 8 | was useful to have the ability to query arrays as well as Backbone Collections. So the library has been ported to 9 | underscore, and backbone-query now uses underscore-query. 10 | 11 | In updating the project several new features have been added, including the ability to use a chainable query api. 12 | 13 | ### Features 14 | 15 | - Search for objects with a Query API similar to [MongoDB](http://www.mongodb.org/display/DOCS/Advanced+Queries) 16 | - Use a complex query object, or build queries up with a chainable api 17 | - Full support for compound queries ($not, $nor, $or, $and), including nested compound queries. 18 | - Full support for querying nested arrays (see `$elemMatch`) 19 | - Accepts dot notation to query deep properties (e.g. `{"stats.views.december": 100}`) 20 | - Custom getters can be defined, (e.g. `.get` for Backbone) 21 | - Works well with underscore chaining 22 | - Dynamically evaluates functions on query (e.g. `{"startTime": {$gt: () => new Date}}` will ensure `startTime` property is greater than now) 23 | 24 | Please report any bugs, feature requests in the issue tracker. 25 | Pull requests are welcome! 26 | 27 | 28 | Installation 29 | ============ 30 | 31 | #### Client Side Installation: 32 | To install, include the `lib/underscore-query.min.js` file in your HTML page, after Underscore (or Lodash). 33 | `_.query` will now be available for you to use. 34 | 35 | If you use AMD, then you can use "lib/underscore-query.amd.js". 36 | This will return a function that accepts either underscore or lodash. This function 37 | also accepts an optional boolean argument on whether to mixin the query methods to underscore/lodash. 38 | If you are using AMD and you want the methods mixed in, then you'd only need to require "underscore-query" once 39 | probably in your init script: 40 | 41 | ```js 42 | define('myModule', 43 | ['underscore', 'underscore-query'], 44 | function ( _, underscoreQuery ) { 45 | // opt 1 46 | underscoreQuery(_); // _.query is now available on the underscore module 47 | // opt 2 48 | var query = underscoreQuery(_, false) // query is available as a local variable with all the query methods 49 | } 50 | ``` 51 | 52 | #### Server side (node.js) installation 53 | You can install with NPM: `npm install underscore-query` 54 | The library can work with either lodash or underscore, when you first require it in it exposes a method that takes 55 | either underscore or lodash: 56 | 57 | ```ja 58 | // With Underscore 59 | _ = require("underscore"); 60 | require("underscore-query")(_); 61 | 62 | // With Lodash 63 | _ = require("lodash"); 64 | require("underscore-query")(_); 65 | 66 | // If you don't want the query methods 'mixed in' 67 | _ = require("underscore"); 68 | query = require("underscore-query")(_, false); 69 | ``` 70 | 71 | 72 | 73 | Basic Usage 74 | =========== 75 | 76 | The following are some basic examples: 77 | 78 | ```js 79 | _.query( MyCollection, { 80 | featured:true, 81 | likes: {$gt:10} 82 | }); 83 | // Returns all models where the featured attribute is true and there are 84 | // more than 10 likes 85 | 86 | _.query( MyCollection, {tags: { $any: ["coffeescript", "backbone", "mvc"]}}); 87 | // Finds models that have either "coffeescript", "backbone", "mvc" in their "tags" attribute 88 | 89 | _.query(MyCollection, { 90 | // Models must match all these queries 91 | $and: [ 92 | {title: {$like: "news"}}, // Title attribute contains the string "news" 93 | {likes: {$gt: 10}} 94 | ], // Likes attribute is greater than 10 95 | 96 | // Models must match one of these queries 97 | $or: [ 98 | {featured: true}, // Featured attribute is true 99 | {category:{$in:["code","programming","javascript"]}} 100 | ] 101 | //Category attribute is either "code", "programming", or "javascript" 102 | }); 103 | 104 | titles = _.query.build( MyCollection ) 105 | .and("published", true) 106 | .or("likes", {$gt:10}) 107 | .or("tags":["javascript", "coffeescript"]) 108 | .chain() 109 | .sortBy(_.query.get("likes")) 110 | .pluck("title") 111 | .value(); 112 | // Builds a query up programatically 113 | // Runs the query, sort's by likes, and plucks the titles. 114 | 115 | 116 | query = _.query.build() 117 | .and("published", true) 118 | .or("likes", {$gt:10}) 119 | .or("tags":["javascript", "coffeescript"]) 120 | 121 | resultsA = query.all(collectionA) 122 | resultsB = query.all(collectionB) 123 | // Builds a query and then runs it on 2 seperate collections 124 | 125 | ``` 126 | 127 | Or if CoffeeScript is your thing (the source is written in CoffeeScript), try this: 128 | 129 | ```coffeescript 130 | _.query MyCollection, 131 | $and: [ 132 | likes: $lt: 15 133 | ] 134 | $or: [ 135 | {content: $like: "news"} 136 | {featured: $exists: true} 137 | ] 138 | $not: 139 | colors: $contains: "yellow" 140 | ``` 141 | 142 | 143 | 144 | Query API 145 | === 146 | 147 | ### $equal 148 | Performs a strict equality test using `===`. If no operator is provided and the query value isn't a regex then `$equal` is assumed. 149 | 150 | If the attribute in the model is an array then the query value is searched for in the array in the same way as `$contains` 151 | 152 | If the query value is an object (including array) then a deep comparison is performed using underscores `_.isEqual` 153 | 154 | ```javascript 155 | _.query( MyCollection, { title:"Test" }); 156 | // Returns all models which have a "title" attribute of "Test" 157 | 158 | _.query( MyCollection, { title: {$equal:"Test"} }); // Same as above 159 | 160 | _.query( MyCollection, { colors: "red" }); 161 | // Returns models which contain the value "red" in a "colors" attribute that is an array. 162 | 163 | MyCollection.query ({ colors: ["red", "yellow"] }); 164 | // Returns models which contain a colors attribute with the array ["red", "yellow"] 165 | ``` 166 | 167 | ### $contains 168 | Assumes that the model property is an array and searches for the query value in the array 169 | 170 | ```js 171 | _.query( MyCollection, { colors: {$contains: "red"} }); 172 | // Returns models which contain the value "red" in a "colors" attribute that is an array. 173 | // e.g. a model with this attribute colors:["red","yellow","blue"] would be returned 174 | ``` 175 | 176 | ### $ne 177 | "Not equal", the opposite of $equal, returns all models which don't have the query value 178 | 179 | ```js 180 | _.query( MyCollection, { title: {$ne:"Test"} }); 181 | // Returns all models which don't have a "title" attribute of "Test" 182 | ``` 183 | 184 | ### $lt, $lte, $gt, $gte 185 | These conditional operators can be used for greater than and less than comparisons in queries 186 | 187 | ```js 188 | _.query( MyCollection, { likes: {$lt: () -> 10} }); 189 | // Returns all models which have a "likes" attribute of less than 10 190 | _.query( MyCollection, { likes: {$lte: () -> 10} }); 191 | // Returns all models which have a "likes" attribute of less than or equal to 10 192 | _.query( MyCollection, { likes: {$gt:10} }); 193 | // Returns all models which have a "likes" attribute of greater than 10 194 | _.query( MyCollection, { likes: {$gte:10} }); 195 | // Returns all models which have a "likes" attribute of greater than or equal to 10 196 | ``` 197 | 198 | These may further be combined: 199 | 200 | ```js 201 | _.query( MyCollection, { likes: {$gt:2, $lt:20} }); 202 | // Returns all models which have a "likes" attribute of greater than 2 or less than 20 203 | // This example is also equivalent to $between: [2,20] 204 | _.query( MyCollection, { likes: {$gte:2, $lte:20} }); 205 | // Returns all models which have a "likes" attribute of greater than or equal to 2, and less than or equal to 20 206 | _.query( MyCollection, { likes: {$gte:2, $lte: 20, $ne: 12} }); 207 | // Returns all models which have a "likes" attribute between 2 and 20 inclusive, but not equal to 12 208 | ``` 209 | 210 | 211 | 212 | ### $between, $betweene 213 | To check if a value is in-between 2 query values use the $between operator and supply an array with the min and max value 214 | 215 | ```js 216 | // Returns all models which have a "likes" attribute of greater than 5 and less than 15 217 | _.query( MyCollection, { likes: {$between:[5,15] } }); 218 | // Returns all models which have a "likes" attribute of greater-equal-to 5 and less-equal-to 15 219 | _.query( MyCollection, { likes: {$betweene:[5,15] } }); 220 | ``` 221 | 222 | ### $in 223 | An array of possible values can be supplied using $in, a model will be returned if any of the supplied values is matched 224 | 225 | ```js 226 | _.query( MyCollection, { title: {$in:["About", "Home", "Contact"] } }); 227 | // Returns all models which have a title attribute of either "About", "Home", or "Contact" 228 | ``` 229 | 230 | ### $nin 231 | "Not in", the opposite of $in. A model will be returned if none of the supplied values is matched 232 | 233 | ```js 234 | _.query( MyCollection, { title: {$nin:["About", "Home", "Contact"] } }); 235 | // Returns all models which don't have a title attribute of either 236 | // "About", "Home", or "Contact" 237 | ``` 238 | 239 | ### $all 240 | Assumes the model property is an array and only returns models where all supplied values are matched. 241 | 242 | ```js 243 | _.query( MyCollection, { colors: {$all:["red", "yellow"] } }); 244 | // Returns all models which have "red" and "yellow" in their colors attribute. 245 | // A model with the attribute colors:["red","yellow","blue"] would be returned 246 | // But a model with the attribute colors:["red","blue"] would not be returned 247 | ``` 248 | 249 | ### $any 250 | Assumes the model property is an array and returns models where any of the supplied values are matched. 251 | 252 | ```js 253 | _.query( MyCollection, { colors: {$any:["red", "yellow"] } }); 254 | // Returns models which have either "red" or "yellow" in their colors attribute. 255 | ``` 256 | 257 | ### $none 258 | Inverse of $any. Returns an array of items where none of the results match 259 | 260 | ```js 261 | _.query( MyCollection, { colors: {$none:["yellow", "black"] } }); 262 | // Returns models which are neither "black" or "yellow" in their colors attribute. 263 | ``` 264 | 265 | ### $size 266 | Assumes the model property has a length (i.e. is either an array or a string). 267 | Only returns models the model property's length matches the supplied values 268 | 269 | ```js 270 | _.query( MyCollection, { colors: {$size:2 } }); 271 | // Returns all models which 2 values in the colors attribute 272 | ``` 273 | 274 | ### $exists or $has 275 | Checks for the existence of an attribute. Can be supplied either true or false. 276 | 277 | ```js 278 | _.query( MyCollection, { title: {$exists: true } }); 279 | // Returns all models which have a "title" attribute 280 | _.query( MyCollection, { title: {$has: false } }); 281 | // Returns all models which don't have a "title" attribute 282 | ``` 283 | 284 | ### $like 285 | Assumes the model attribute is a string and checks if the supplied query value is a substring of the property. 286 | Uses indexOf rather than regex for performance reasons 287 | 288 | ```js 289 | _.query( MyCollection, { title: {$like: "Test" } }); 290 | //Returns all models which have a "title" attribute that 291 | //contains the string "Test", e.g. "Testing", "Tests", "Test", etc. 292 | ``` 293 | 294 | ### $likeI 295 | The same as above but performs a case insensitive search using indexOf and toLowerCase (still faster than Regex) 296 | 297 | ```js 298 | _.query( MyCollection, { title: {$likeI: "Test" } }); 299 | //Returns all models which have a "title" attribute that 300 | //contains the string "Test", "test", "tEst","tesT", etc. 301 | ``` 302 | 303 | ### $regex 304 | Checks if the model attribute matches the supplied regular expression. The regex query can be supplied without the `$regex` keyword 305 | 306 | ```js 307 | _.query( MyCollection, { content: {$regex: /coffeescript/gi } }); 308 | // Checks for a regex match in the content attribute 309 | _.query( MyCollection, { content: /coffeescript/gi }); 310 | // Same as above 311 | ``` 312 | 313 | ### $cb 314 | A callback function can be supplied as a test. The callback will receive the attribute and should return either true or false. 315 | `this` will be set to the current model, this can help with tests against computed properties 316 | 317 | ```js 318 | _.query( MyCollection, { title: {$cb: function(attr){ return attr.charAt(0) === "c";}} }); 319 | // Returns all models that have a title attribute that starts with "c" 320 | 321 | _.query( MyCollection, { computed_test: {$cb: function(){ return this.computed_property() > 10;}} }); 322 | // Returns all models where the computed_property method returns a value greater than 10. 323 | ``` 324 | 325 | For callbacks that use `this` rather than the model attribute, the key name supplied is arbitrary and has no 326 | effect on the results. If the only test you were performing was like the above test it would make more sense 327 | to simply use `MyCollection.filter`. However if you are performing other tests or are using the paging / sorting / 328 | caching options of backbone query, then this functionality is useful. 329 | 330 | ### $elemMatch 331 | This operator allows you to perform queries in nested arrays similar to [MongoDB](http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24elemMatch) 332 | For example you may have a collection of models in with this kind of data stucture: 333 | 334 | ```js 335 | var Posts = new QueryCollection([ 336 | {title: "Home", comments:[ 337 | {text:"I like this post"}, 338 | {text:"I love this post"}, 339 | {text:"I hate this post"} 340 | ]}, 341 | {title: "About", comments:[ 342 | {text:"I like this page"}, 343 | {text:"I love this page"}, 344 | {text:"I really like this page"} 345 | ]} 346 | ]); 347 | ``` 348 | To search for posts which have the text "really" in any of the comments you could search like this: 349 | 350 | ```js 351 | Posts.query({ 352 | comments: { 353 | $elemMatch: { 354 | text: /really/i 355 | } 356 | } 357 | }); 358 | ``` 359 | 360 | All of the operators above can be performed on `$elemMatch` queries, e.g. `$all`, `$size` or `$lt`. 361 | `$elemMatch` queries also accept compound operators, for example this query searches for all posts that 362 | have at least one comment without the word "really" and with the word "totally". 363 | ```js 364 | Posts.query({ 365 | comments: { 366 | $elemMatch: { 367 | $not: { 368 | text: /really/i 369 | }, 370 | $and: [{ 371 | text: /totally/i 372 | }] 373 | } 374 | } 375 | }); 376 | ``` 377 | 378 | 379 | ### $computed 380 | This operator allows you to perform queries on computed properties. For example you may want to perform a query 381 | for a persons full name, even though the first and last name are stored separately in your db / model. 382 | For example 383 | 384 | ```js 385 | testModel = Backbone.Model.extend({ 386 | full_name: function() { 387 | return (this.get('first_name')) + " " + (this.get('last_name')); 388 | } 389 | }); 390 | 391 | a = new testModel({ 392 | first_name: "Dave", 393 | last_name: "Tonge" 394 | }); 395 | 396 | b = new testModel({ 397 | first_name: "John", 398 | last_name: "Smith" 399 | }); 400 | 401 | MyCollection = new QueryCollection([a, b]); 402 | 403 | _.query( MyCollection, { 404 | full_name: { $computed: "Dave Tonge" } 405 | }); 406 | // Returns the model with the computed `full_name` equal to Dave Tonge 407 | 408 | _.query( MyCollection, { 409 | full_name: { $computed: { $likeI: "john smi" } } 410 | }); 411 | // Any of the previous operators can be used (including elemMatch is required) 412 | ``` 413 | 414 | 415 | Combined Queries 416 | ================ 417 | 418 | Multiple queries can be combined together. By default all supplied queries use the `$and` operator. However it is possible 419 | to specify either `$or`, `$nor`, `$not` to implement alternate logic. 420 | 421 | ### $and 422 | 423 | ```js 424 | _.query( MyCollection, { $and: [{ title: {$like: "News"} }, { likes: {$gt: 10}} ]}); 425 | // Returns all models that contain "News" in the title and have more than 10 likes. 426 | _.query( MyCollection, { title: {$like: "News"}, likes: {$gt: 10} }); 427 | // Same as above as $and is assumed if not supplied 428 | ``` 429 | 430 | ### $or 431 | 432 | ```js 433 | _.query( MyCollection, { $or: [{ title: {$like: "News"}}, { likes: {$gt: 10}}]}); 434 | // Returns all models that contain "News" in the title OR have more than 10 likes. 435 | ``` 436 | 437 | ### $nor 438 | The opposite of `$or` 439 | 440 | ```js 441 | _.query( MyCollection, { $nor: [{ title: {$like: "News"}}, { likes: {$gt: 10}}]}); 442 | // Returns all models that don't contain "News" in the title NOR have more than 10 likes. 443 | ``` 444 | 445 | ### $not 446 | The opposite of `$and` 447 | 448 | ```js 449 | _.query( MyCollection, { $not: { title: {$like: "News"}, likes: {$gt: 10}}}); 450 | // Returns all models that don't contain "News" in the title AND DON'T have more than 10 likes. 451 | ``` 452 | 453 | If you need to perform multiple queries on the same key, then you can supply the query as an array: 454 | ```js 455 | _.query( MyCollection, { 456 | $or:[ 457 | {title:"News"}, 458 | {title:"About"} 459 | ] 460 | }); 461 | // Returns all models with the title "News" or "About". 462 | ``` 463 | 464 | 465 | Compound Queries 466 | ================ 467 | 468 | It is possible to use multiple combined queries, for example searching for models that have a specific title attribute, 469 | and either a category of "abc" or a tag of "xyz" 470 | 471 | ```js 472 | _.query( MyCollection, { 473 | $and: [{ title: {$like: "News"}]}, 474 | $or: [{ likes: {$gt: 10}}, { color:{$contains:"red"}]} 475 | }); 476 | //Returns models that have "News" in their title and 477 | //either have more than 10 likes or contain the color red. 478 | ``` 479 | 480 | 481 | Chainable API 482 | ============= 483 | 484 | Rather than supplying a single query object, you can build up the query bit by bit: 485 | 486 | ```javascript 487 | _.query.build( MyCollection ) 488 | .and("published", true) 489 | .or("likes", {$gt:10}) 490 | .or("tags":["javascript", "coffeescript"]) 491 | .run() 492 | ``` 493 | 494 | Instead of calling `_.query`, we call `_.query.build`. This returns a query object that we can build before running. 495 | `_.query.build` can take the collection that you want to query, or alternatively you can pass the collection in when 496 | running the query. Therefore these 2 both give the same results: 497 | 498 | ```javascript 499 | results = _.query.build( MyCollection ).and("published", true).run() 500 | results = _.query.build().and("published", true).run( MyCollection ) 501 | ``` 502 | 503 | To build the query you can call `.and`, `.or`, `.nor` and `.not`. 504 | These methods can accept either a query object, or a query key and a query value. For example the following two examples 505 | are the same. 506 | 507 | ```javascript 508 | results = _.query.build( MyCollection ).and({"published":true}).run() 509 | results = _.query.build( MyCollection ).and("published", true).run() 510 | ``` 511 | 512 | To run the query you can call either `.run`, `.all`, `.find`, or `.all`. 513 | These methods are all aliases too each other and will run the query returning an array of results. 514 | 515 | To retrieve just the first results you can use `.first`. For example: 516 | 517 | ```javascript 518 | firstResult = _.query.build( MyCollection ).and({"published":true}).first() 519 | ``` 520 | 521 | If you wish to perform further data manipulation using underscore, you can call the `.chain` method. 522 | This will run the query and return the results as a wrapped underscore object, whcih you can then use methods like 523 | `.sortBy`, `.groupBy`, `.map`, etc. 524 | 525 | ```javascript 526 | titles = _.query.build( MyCollection ) 527 | .and("published", true) 528 | .or("likes", {$gt:10}) 529 | .or("tags":["javascript", "coffeescript"]) 530 | .chain() 531 | .sortBy(function(item) { return item.likes; }) 532 | .pluck("title") 533 | .value(); 534 | ``` 535 | 536 | Indexing 537 | ======== 538 | 539 | 540 | More documentation coming... 541 | Essentially you can add indexes when using the chainable syntax. 542 | You can then perform queries as usual, but the results, should be faster on larger sets 543 | I suggest that you benchmark your code to test this out. 544 | The index method takes either a single key, or a key and a function. 545 | 546 | 547 | ```coffeescript 548 | 549 | query = _.query(array) 550 | .index("title") 551 | 552 | # could have been .index("title", (obj) -> obj.title) 553 | 554 | result = query.and("title", "Home").run() 555 | ``` 556 | 557 | 558 | 559 | Contributors 560 | =========== 561 | 562 | Dave Tonge - [davidgtonge](https://github.com/davidgtonge) 563 | Benjamin Caldwell - [benjamincaldwell](https://github.com/benjamincaldwell) 564 | Rob W - [Rob W](https://github.com/Rob--W) 565 | Cezary Wojtkowski - [cezary](https://github.com/cezary) 566 | Graeme Yeates - [megawac](https://github.com/megawac) 567 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "underscore-query", 3 | "homepage": "https://github.com/davidgtonge/underscore-query", 4 | "authors": [ 5 | "Dave Tonge ", 6 | "Graeme Yeates " 7 | ], 8 | "license": "MIT", 9 | "ignore": [ 10 | "**/.*", 11 | "node_modules", 12 | "bower_components", 13 | "test", 14 | "build", 15 | "Cakefile" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /lib/underscore-query.amd.js: -------------------------------------------------------------------------------- 1 | // AMD Wrapper 2 | define(function(){ 3 | 4 | /* 5 | Underscore Query - A lightweight query API for JavaScript collections 6 | (c)2016 - Dave Tonge 7 | May be freely distributed according to MIT license. 8 | 9 | This is small library that provides a query api for JavaScript arrays similar to *mongo db*. 10 | The aim of the project is to provide a simple, well tested, way of filtering data in JavaScript. 11 | */ 12 | var QueryBuilder, addToQuery, buildQuery, createUtils, expose, findOne, i, key, len, lookup, makeTest, multipleConditions, parseGetter, parseParamType, parseQuery, parseSubQuery, performQuery, performQuerySingle, ref, root, runQuery, score, single, tag_sort_order, testModelAttribute, testQueryValue, utils, 13 | hasProp = {}.hasOwnProperty; 14 | 15 | root = this; 16 | 17 | 18 | /* UTILS */ 19 | 20 | utils = {}; 21 | 22 | createUtils = function(_) { 23 | var i, key, len, ref; 24 | ref = ["every", "some", "filter", "first", "find", "reject", "reduce", "property", "sortBy", "indexOf", "intersection", "isEqual", "keys", "isArray", "result", "map", "includes", "isNaN"]; 25 | for (i = 0, len = ref.length; i < len; i++) { 26 | key = ref[i]; 27 | utils[key] = _[key]; 28 | if (!utils[key]) { 29 | throw new Error(key + " missing. Please ensure that you first initialize underscore-query with either lodash or underscore"); 30 | } 31 | } 32 | }; 33 | 34 | utils.getType = function(obj) { 35 | var type; 36 | type = Object.prototype.toString.call(obj).substr(8); 37 | return type.substr(0, type.length - 1); 38 | }; 39 | 40 | utils.makeObj = function(key, val) { 41 | var o; 42 | (o = {})[key] = val; 43 | return o; 44 | }; 45 | 46 | utils.reverseString = function(str) { 47 | return str.toLowerCase().split("").reverse().join(""); 48 | }; 49 | 50 | utils.compoundKeys = ["$and", "$not", "$or", "$nor"]; 51 | 52 | utils.expectedArrayQueries = ["$and", "$or", "$nor"]; 53 | 54 | lookup = function(keys, obj) { 55 | var i, idx, key, len, out, remainingKeys; 56 | out = obj; 57 | for (idx = i = 0, len = keys.length; i < len; idx = ++i) { 58 | key = keys[idx]; 59 | if (utils.isArray(out)) { 60 | remainingKeys = keys.slice(idx); 61 | out = utils.map(out, function(v) { 62 | return lookup(remainingKeys, v); 63 | }); 64 | } else if (out) { 65 | out = utils.result(out, key); 66 | } else { 67 | break; 68 | } 69 | } 70 | return out; 71 | }; 72 | 73 | utils.makeGetter = function(keys) { 74 | keys = keys.split("."); 75 | return function(obj) { 76 | return lookup(keys, obj); 77 | }; 78 | }; 79 | 80 | multipleConditions = function(key, queries) { 81 | var results, type, val; 82 | results = []; 83 | for (type in queries) { 84 | val = queries[type]; 85 | results.push(utils.makeObj(key, utils.makeObj(type, val))); 86 | } 87 | return results; 88 | }; 89 | 90 | parseParamType = function(query) { 91 | var key, o, paramType, queryParam, result, size, type, value; 92 | result = []; 93 | for (key in query) { 94 | if (!hasProp.call(query, key)) continue; 95 | queryParam = query[key]; 96 | o = { 97 | key: key 98 | }; 99 | if (queryParam != null ? queryParam.$boost : void 0) { 100 | o.boost = queryParam.$boost; 101 | delete queryParam.$boost; 102 | } 103 | if (key.indexOf(".") !== -1) { 104 | o.getter = utils.makeGetter(key); 105 | } 106 | paramType = utils.getType(queryParam); 107 | switch (paramType) { 108 | case "RegExp": 109 | case "Date": 110 | o.type = "$" + (paramType.toLowerCase()); 111 | o.value = queryParam; 112 | break; 113 | case "Array": 114 | if (utils.includes(utils.compoundKeys, key)) { 115 | o.type = key; 116 | o.value = parseSubQuery(queryParam, key); 117 | o.key = null; 118 | } else { 119 | o.type = "$equal"; 120 | o.value = queryParam; 121 | } 122 | break; 123 | case "Object": 124 | size = utils.keys(queryParam).length; 125 | if (utils.includes(utils.compoundKeys, key)) { 126 | o.type = key; 127 | o.value = parseSubQuery(queryParam, key); 128 | o.key = null; 129 | } else if (!(size === 1 || (size === 2 && '$options' in queryParam))) { 130 | o.type = "$and"; 131 | o.value = parseSubQuery(multipleConditions(key, queryParam)); 132 | o.key = null; 133 | } else { 134 | for (type in queryParam) { 135 | if (!hasProp.call(queryParam, type)) continue; 136 | value = queryParam[type]; 137 | if (type === "$options") { 138 | if ("$regex" in queryParam || "regexp" in queryParam) { 139 | continue; 140 | } 141 | throw new Error("$options needs a $regex"); 142 | } 143 | if (testQueryValue(type, value)) { 144 | o.type = type; 145 | switch (type) { 146 | case "$elemMatch": 147 | o.value = single(parseQuery(value)); 148 | break; 149 | case "$endsWith": 150 | o.value = utils.reverseString(value); 151 | break; 152 | case "$likeI": 153 | case "$startsWith": 154 | o.value = value.toLowerCase(); 155 | break; 156 | case "$regex": 157 | case "$regexp": 158 | if (typeof value === "string") { 159 | o.value = new RegExp(value, queryParam.$options || ""); 160 | } else { 161 | o.value = value; 162 | } 163 | break; 164 | case "$not": 165 | case "$nor": 166 | case "$or": 167 | case "$and": 168 | o.value = parseSubQuery(utils.makeObj(o.key, value)); 169 | o.key = null; 170 | break; 171 | case "$computed": 172 | o = utils.first(parseParamType(utils.makeObj(key, value))); 173 | o.getter = utils.makeGetter(key); 174 | break; 175 | default: 176 | o.value = value; 177 | } 178 | } else { 179 | throw new Error("Query value (" + value + ") doesn't match query type: (" + type + ")"); 180 | } 181 | } 182 | } 183 | break; 184 | default: 185 | o.type = "$equal"; 186 | o.value = queryParam; 187 | } 188 | if ((o.type === "$equal") && (utils.includes(["Object", "Array"], paramType))) { 189 | o.type = "$deepEqual"; 190 | } else if (utils.isNaN(o.value)) { 191 | o.type = "$deepEqual"; 192 | } 193 | result.push(o); 194 | } 195 | return result; 196 | }; 197 | 198 | tag_sort_order = ["$lt", "$lte", "$gt", "$gte", "$exists", "$has", "$type", "$ne", "$equal", "$mod", "$size", "$between", "$betweene", "$startsWith", "$endsWith", "$like", "$likeI", "$contains", "$in", "$nin", "$all", "$any", "$none", "$cb", "$regex", "$regexp", "$deepEqual", "$elemMatch", "$not", "$and", "$or", "$nor"]; 199 | 200 | parseSubQuery = function(rawQuery, type) { 201 | var iteratee, key, queryArray, result, val; 202 | if (utils.isArray(rawQuery)) { 203 | queryArray = rawQuery; 204 | } else { 205 | queryArray = (function() { 206 | var results; 207 | results = []; 208 | for (key in rawQuery) { 209 | if (!hasProp.call(rawQuery, key)) continue; 210 | val = rawQuery[key]; 211 | results.push(utils.makeObj(key, val)); 212 | } 213 | return results; 214 | })(); 215 | } 216 | iteratee = function(memo, query) { 217 | var parsed; 218 | parsed = parseParamType(query); 219 | if (type === "$or" && parsed.length >= 2) { 220 | memo.push({ 221 | type: "$and", 222 | parsedQuery: parsed 223 | }); 224 | return memo; 225 | } else { 226 | return memo.concat(parsed); 227 | } 228 | }; 229 | result = utils.reduce(queryArray, iteratee, []); 230 | return utils.sortBy(result, function(x) { 231 | var index; 232 | index = utils.indexOf(tag_sort_order, x.type); 233 | if (index >= 0) { 234 | return index; 235 | } else { 236 | return Infinity; 237 | } 238 | }); 239 | }; 240 | 241 | testQueryValue = function(queryType, value) { 242 | var valueType; 243 | valueType = utils.getType(value); 244 | switch (queryType) { 245 | case "$in": 246 | case "$nin": 247 | case "$all": 248 | case "$any": 249 | case "$none": 250 | return valueType === "Array"; 251 | case "$size": 252 | return valueType === "Number"; 253 | case "$regex": 254 | case "$regexp": 255 | return utils.includes(["RegExp", "String"], valueType); 256 | case "$like": 257 | case "$likeI": 258 | return valueType === "String"; 259 | case "$between": 260 | case "$mod": 261 | return (valueType === "Array") && (value.length === 2); 262 | case "$cb": 263 | return valueType === "Function"; 264 | default: 265 | return true; 266 | } 267 | }; 268 | 269 | testModelAttribute = function(queryType, value) { 270 | var valueType; 271 | valueType = utils.getType(value); 272 | switch (queryType) { 273 | case "$like": 274 | case "$likeI": 275 | case "$regex": 276 | case "$startsWith": 277 | case "$endsWith": 278 | return valueType === "String"; 279 | case "$contains": 280 | case "$all": 281 | case "$any": 282 | case "$elemMatch": 283 | return valueType === "Array"; 284 | case "$size": 285 | return utils.includes(["String", "Array"], valueType); 286 | case "$in": 287 | case "$nin": 288 | return value != null; 289 | default: 290 | return true; 291 | } 292 | }; 293 | 294 | performQuery = function(type, value, attr, model, getter) { 295 | switch (type) { 296 | case "$and": 297 | case "$or": 298 | case "$nor": 299 | case "$not": 300 | return performQuerySingle(type, value, getter, model); 301 | case "$cb": 302 | return value.call(model, attr); 303 | case "$elemMatch": 304 | return runQuery(attr, value, null, true); 305 | } 306 | if (typeof value === 'function') { 307 | value = value(); 308 | } 309 | switch (type) { 310 | case "$equal": 311 | if (utils.isArray(attr)) { 312 | return utils.includes(attr, value); 313 | } else { 314 | return attr === value; 315 | } 316 | break; 317 | case "$deepEqual": 318 | return utils.isEqual(attr, value); 319 | case "$ne": 320 | return attr !== value; 321 | case "$type": 322 | return typeof attr === value; 323 | case "$lt": 324 | return (value != null) && attr < value; 325 | case "$gt": 326 | return (value != null) && attr > value; 327 | case "$lte": 328 | return (value != null) && attr <= value; 329 | case "$gte": 330 | return (value != null) && attr >= value; 331 | case "$between": 332 | return (value[0] != null) && (value[1] != null) && (value[0] < attr && attr < value[1]); 333 | case "$betweene": 334 | return (value[0] != null) && (value[1] != null) && (value[0] <= attr && attr <= value[1]); 335 | case "$size": 336 | return attr.length === value; 337 | case "$exists": 338 | case "$has": 339 | return (attr != null) === value; 340 | case "$contains": 341 | return utils.includes(attr, value); 342 | case "$in": 343 | return utils.includes(value, attr); 344 | case "$nin": 345 | return !utils.includes(value, attr); 346 | case "$all": 347 | return utils.every(value, function(item) { 348 | return utils.includes(attr, item); 349 | }); 350 | case "$any": 351 | return utils.some(attr, function(item) { 352 | return utils.includes(value, item); 353 | }); 354 | case "$none": 355 | return !utils.some(attr, function(item) { 356 | return utils.includes(value, item); 357 | }); 358 | case "$like": 359 | return attr.indexOf(value) !== -1; 360 | case "$likeI": 361 | return attr.toLowerCase().indexOf(value) !== -1; 362 | case "$startsWith": 363 | return attr.toLowerCase().indexOf(value) === 0; 364 | case "$endsWith": 365 | return utils.reverseString(attr).indexOf(value) === 0; 366 | case "$regex": 367 | case "$regexp": 368 | return value.test(attr); 369 | case "$mod": 370 | return (attr % value[0]) === value[1]; 371 | default: 372 | return false; 373 | } 374 | }; 375 | 376 | single = function(queries, getter, isScore) { 377 | var queryObj; 378 | if (getter) { 379 | getter = parseGetter(getter); 380 | } 381 | if (isScore) { 382 | if (queries.length !== 1) { 383 | throw new Error("score operations currently don't work on compound queries"); 384 | } 385 | queryObj = queries[0]; 386 | if (queryObj.type !== "$and") { 387 | throw new Error("score operations only work on $and queries (not " + queryObj.type); 388 | } 389 | return function(model) { 390 | model._score = performQuerySingle(queryObj.type, queryObj.parsedQuery, getter, model, true); 391 | return model; 392 | }; 393 | } else { 394 | return function(model) { 395 | var i, len; 396 | for (i = 0, len = queries.length; i < len; i++) { 397 | queryObj = queries[i]; 398 | if (!performQuerySingle(queryObj.type, queryObj.parsedQuery, getter, model, isScore)) { 399 | return false; 400 | } 401 | } 402 | return true; 403 | }; 404 | } 405 | }; 406 | 407 | performQuerySingle = function(type, query, getter, model, isScore) { 408 | var attr, boost, i, len, passes, q, ref, score, scoreInc, test; 409 | passes = 0; 410 | score = 0; 411 | scoreInc = 1 / query.length; 412 | for (i = 0, len = query.length; i < len; i++) { 413 | q = query[i]; 414 | if (getter) { 415 | attr = getter(model, q.key); 416 | } else if (q.getter) { 417 | attr = q.getter(model, q.key); 418 | } else { 419 | attr = model[q.key]; 420 | } 421 | test = testModelAttribute(q.type, attr); 422 | if (test) { 423 | if (q.parsedQuery) { 424 | test = single([q], getter, isScore)(model); 425 | } else { 426 | test = performQuery(q.type, q.value, attr, model, getter); 427 | } 428 | } 429 | if (test) { 430 | passes++; 431 | if (isScore) { 432 | boost = (ref = q.boost) != null ? ref : 1; 433 | score += scoreInc * boost; 434 | } 435 | } 436 | switch (type) { 437 | case "$and": 438 | if (!(isScore || test)) { 439 | return false; 440 | } 441 | break; 442 | case "$not": 443 | if (test) { 444 | return false; 445 | } 446 | break; 447 | case "$or": 448 | if (test) { 449 | return true; 450 | } 451 | break; 452 | case "$nor": 453 | if (test) { 454 | return false; 455 | } 456 | break; 457 | default: 458 | throw new Error("Invalid compound method"); 459 | } 460 | } 461 | if (isScore) { 462 | return score; 463 | } else if (type === "$not") { 464 | return passes === 0; 465 | } else { 466 | return type !== "$or"; 467 | } 468 | }; 469 | 470 | parseQuery = function(query) { 471 | var compoundQuery, i, j, key, len, len1, queryKeys, results, type, val; 472 | queryKeys = utils.keys(query); 473 | if (!queryKeys.length) { 474 | return []; 475 | } 476 | compoundQuery = utils.intersection(utils.compoundKeys, queryKeys); 477 | for (i = 0, len = compoundQuery.length; i < len; i++) { 478 | type = compoundQuery[i]; 479 | if (!utils.isArray(query[type]) && utils.includes(utils.expectedArrayQueries, type)) { 480 | throw new Error(type + ' query must be an array'); 481 | } 482 | } 483 | if (compoundQuery.length === 0) { 484 | return [ 485 | { 486 | type: "$and", 487 | parsedQuery: parseSubQuery(query) 488 | } 489 | ]; 490 | } else { 491 | if (compoundQuery.length !== queryKeys.length) { 492 | if (!utils.includes(compoundQuery, "$and")) { 493 | query.$and = {}; 494 | compoundQuery.unshift("$and"); 495 | } 496 | for (key in query) { 497 | if (!hasProp.call(query, key)) continue; 498 | val = query[key]; 499 | if (!(!utils.includes(utils.compundKeys, key))) { 500 | continue; 501 | } 502 | query.$and[key] = val; 503 | delete query[key]; 504 | } 505 | } 506 | results = []; 507 | for (j = 0, len1 = compoundQuery.length; j < len1; j++) { 508 | type = compoundQuery[j]; 509 | results.push({ 510 | type: type, 511 | parsedQuery: parseSubQuery(query[type], type) 512 | }); 513 | } 514 | return results; 515 | } 516 | }; 517 | 518 | parseGetter = function(getter) { 519 | if (typeof getter === 'string') { 520 | return function(obj, key) { 521 | return obj[getter](key); 522 | }; 523 | } else { 524 | return getter; 525 | } 526 | }; 527 | 528 | QueryBuilder = (function() { 529 | function QueryBuilder(items1, _getter) { 530 | this.items = items1; 531 | this._getter = _getter; 532 | this.theQuery = {}; 533 | } 534 | 535 | QueryBuilder.prototype.all = function(items, first) { 536 | if (items) { 537 | this.items = items; 538 | } 539 | if (this.indexes) { 540 | items = this.getIndexedItems(this.items); 541 | } else { 542 | items = this.items; 543 | } 544 | return runQuery(items, this.theQuery, this._getter, first); 545 | }; 546 | 547 | QueryBuilder.prototype.chain = function() { 548 | return _.chain(this.all.apply(this, arguments)); 549 | }; 550 | 551 | QueryBuilder.prototype.tester = function() { 552 | return makeTest(this.theQuery, this._getter); 553 | }; 554 | 555 | QueryBuilder.prototype.first = function(items) { 556 | return this.all(items, true); 557 | }; 558 | 559 | QueryBuilder.prototype.getter = function(_getter) { 560 | this._getter = _getter; 561 | return this; 562 | }; 563 | 564 | return QueryBuilder; 565 | 566 | })(); 567 | 568 | addToQuery = function(type) { 569 | return function(params, qVal) { 570 | var base; 571 | if (qVal) { 572 | params = utils.makeObj(params, qVal); 573 | } 574 | if ((base = this.theQuery)[type] == null) { 575 | base[type] = []; 576 | } 577 | this.theQuery[type].push(params); 578 | return this; 579 | }; 580 | }; 581 | 582 | ref = utils.compoundKeys; 583 | for (i = 0, len = ref.length; i < len; i++) { 584 | key = ref[i]; 585 | QueryBuilder.prototype[key.substr(1)] = addToQuery(key); 586 | } 587 | 588 | QueryBuilder.prototype.find = QueryBuilder.prototype.query = QueryBuilder.prototype.run = QueryBuilder.prototype.all; 589 | 590 | buildQuery = function(items, getter) { 591 | return new QueryBuilder(items, getter); 592 | }; 593 | 594 | makeTest = function(query, getter) { 595 | return single(parseQuery(query), parseGetter(getter)); 596 | }; 597 | 598 | findOne = function(items, query, getter) { 599 | return runQuery(items, query, getter, true); 600 | }; 601 | 602 | runQuery = function(items, query, getter, first, isScore) { 603 | var fn; 604 | if (arguments.length < 2) { 605 | return buildQuery.apply(this, arguments); 606 | } 607 | if (getter) { 608 | getter = parseGetter(getter); 609 | } 610 | if (!(utils.getType(query) === "Function")) { 611 | query = single(parseQuery(query), getter, isScore); 612 | } 613 | if (isScore) { 614 | fn = utils.map; 615 | } else if (first) { 616 | fn = utils.find; 617 | } else { 618 | fn = utils.filter; 619 | } 620 | return fn(items, query); 621 | }; 622 | 623 | score = function(items, query, getter) { 624 | return runQuery(items, query, getter, false, true); 625 | }; 626 | 627 | runQuery.build = buildQuery; 628 | 629 | runQuery.parse = parseQuery; 630 | 631 | runQuery.findOne = runQuery.first = findOne; 632 | 633 | runQuery.score = score; 634 | 635 | runQuery.tester = runQuery.testWith = makeTest; 636 | 637 | runQuery.getter = runQuery.pluckWith = utils.makeGetter; 638 | 639 | expose = function(_, mixin) { 640 | if (mixin == null) { 641 | mixin = true; 642 | } 643 | createUtils(_); 644 | if (mixin) { 645 | _.mixin({ 646 | query: runQuery, 647 | q: runQuery 648 | }); 649 | } 650 | return runQuery; 651 | }; 652 | 653 | if (typeof exports !== "undefined" && (typeof module !== "undefined" && module !== null ? module.exports : void 0)) { 654 | return module.exports = expose; 655 | } else if (root._) { 656 | return expose(root._); 657 | } 658 | 659 | return expose; 660 | 661 | }); -------------------------------------------------------------------------------- /lib/underscore-query.amd.min.js: -------------------------------------------------------------------------------- 1 | define(function(){var e,r,t,n,u,s,a,i,o,c,l,$,p,f,y,d,h,g,k,m,v,b,w,x,q,O,E,Q={}.hasOwnProperty;m=this,E={},n=function(e){var r,t,n,u;for(u=["every","some","filter","first","find","reject","reduce","property","sortBy","indexOf","intersection","isEqual","keys","isArray","result","map","includes","isNaN"],r=0,n=u.length;r=2?(e.push({type:"$and",parsedQuery:n}),e):e.concat(n)},s=E.reduce(u,t,[]),E.sortBy(s,function(e){var r;return r=E.indexOf(x,e.type),r>=0?r:1/0})},O=function(e,r){var t;switch(t=E.getType(r),e){case"$in":case"$nin":case"$all":case"$any":case"$none":return"Array"===t;case"$size":return"Number"===t;case"$regex":case"$regexp":return E.includes(["RegExp","String"],t);case"$like":case"$likeI":return"String"===t;case"$between":case"$mod":return"Array"===t&&2===r.length;case"$cb":return"Function"===t;default:return!0}},q=function(e,r){var t;switch(t=E.getType(r),e){case"$like":case"$likeI":case"$regex":case"$startsWith":case"$endsWith":return"String"===t;case"$contains":case"$all":case"$any":case"$elemMatch":return"Array"===t;case"$size":return E.includes(["String","Array"],t);case"$in":case"$nin":return null!=r;default:return!0}},h=function(e,r,t,n,u){switch(e){case"$and":case"$or":case"$nor":case"$not":return g(e,r,u,n);case"$cb":return r.call(n,t);case"$elemMatch":return v(t,r,null,!0)}switch("function"==typeof r&&(r=r()),e){case"$equal":return E.isArray(t)?E.includes(t,r):t===r;case"$deepEqual":return E.isEqual(t,r);case"$ne":return t!==r;case"$type":return typeof t===r;case"$lt":return null!=r&&tr;case"$lte":return null!=r&&t<=r;case"$gte":return null!=r&&t>=r;case"$between":return null!=r[0]&&null!=r[1]&&r[0]= 2) { 220 | memo.push({ 221 | type: "$and", 222 | parsedQuery: parsed 223 | }); 224 | return memo; 225 | } else { 226 | return memo.concat(parsed); 227 | } 228 | }; 229 | result = utils.reduce(queryArray, iteratee, []); 230 | return utils.sortBy(result, function(x) { 231 | var index; 232 | index = utils.indexOf(tag_sort_order, x.type); 233 | if (index >= 0) { 234 | return index; 235 | } else { 236 | return Infinity; 237 | } 238 | }); 239 | }; 240 | 241 | testQueryValue = function(queryType, value) { 242 | var valueType; 243 | valueType = utils.getType(value); 244 | switch (queryType) { 245 | case "$in": 246 | case "$nin": 247 | case "$all": 248 | case "$any": 249 | case "$none": 250 | return valueType === "Array"; 251 | case "$size": 252 | return valueType === "Number"; 253 | case "$regex": 254 | case "$regexp": 255 | return utils.includes(["RegExp", "String"], valueType); 256 | case "$like": 257 | case "$likeI": 258 | return valueType === "String"; 259 | case "$between": 260 | case "$mod": 261 | return (valueType === "Array") && (value.length === 2); 262 | case "$cb": 263 | return valueType === "Function"; 264 | default: 265 | return true; 266 | } 267 | }; 268 | 269 | testModelAttribute = function(queryType, value) { 270 | var valueType; 271 | valueType = utils.getType(value); 272 | switch (queryType) { 273 | case "$like": 274 | case "$likeI": 275 | case "$regex": 276 | case "$startsWith": 277 | case "$endsWith": 278 | return valueType === "String"; 279 | case "$contains": 280 | case "$all": 281 | case "$any": 282 | case "$elemMatch": 283 | return valueType === "Array"; 284 | case "$size": 285 | return utils.includes(["String", "Array"], valueType); 286 | case "$in": 287 | case "$nin": 288 | return value != null; 289 | default: 290 | return true; 291 | } 292 | }; 293 | 294 | performQuery = function(type, value, attr, model, getter) { 295 | switch (type) { 296 | case "$and": 297 | case "$or": 298 | case "$nor": 299 | case "$not": 300 | return performQuerySingle(type, value, getter, model); 301 | case "$cb": 302 | return value.call(model, attr); 303 | case "$elemMatch": 304 | return runQuery(attr, value, null, true); 305 | } 306 | if (typeof value === 'function') { 307 | value = value(); 308 | } 309 | switch (type) { 310 | case "$equal": 311 | if (utils.isArray(attr)) { 312 | return utils.includes(attr, value); 313 | } else { 314 | return attr === value; 315 | } 316 | break; 317 | case "$deepEqual": 318 | return utils.isEqual(attr, value); 319 | case "$ne": 320 | return attr !== value; 321 | case "$type": 322 | return typeof attr === value; 323 | case "$lt": 324 | return (value != null) && attr < value; 325 | case "$gt": 326 | return (value != null) && attr > value; 327 | case "$lte": 328 | return (value != null) && attr <= value; 329 | case "$gte": 330 | return (value != null) && attr >= value; 331 | case "$between": 332 | return (value[0] != null) && (value[1] != null) && (value[0] < attr && attr < value[1]); 333 | case "$betweene": 334 | return (value[0] != null) && (value[1] != null) && (value[0] <= attr && attr <= value[1]); 335 | case "$size": 336 | return attr.length === value; 337 | case "$exists": 338 | case "$has": 339 | return (attr != null) === value; 340 | case "$contains": 341 | return utils.includes(attr, value); 342 | case "$in": 343 | return utils.includes(value, attr); 344 | case "$nin": 345 | return !utils.includes(value, attr); 346 | case "$all": 347 | return utils.every(value, function(item) { 348 | return utils.includes(attr, item); 349 | }); 350 | case "$any": 351 | return utils.some(attr, function(item) { 352 | return utils.includes(value, item); 353 | }); 354 | case "$none": 355 | return !utils.some(attr, function(item) { 356 | return utils.includes(value, item); 357 | }); 358 | case "$like": 359 | return attr.indexOf(value) !== -1; 360 | case "$likeI": 361 | return attr.toLowerCase().indexOf(value) !== -1; 362 | case "$startsWith": 363 | return attr.toLowerCase().indexOf(value) === 0; 364 | case "$endsWith": 365 | return utils.reverseString(attr).indexOf(value) === 0; 366 | case "$regex": 367 | case "$regexp": 368 | return value.test(attr); 369 | case "$mod": 370 | return (attr % value[0]) === value[1]; 371 | default: 372 | return false; 373 | } 374 | }; 375 | 376 | single = function(queries, getter, isScore) { 377 | var queryObj; 378 | if (getter) { 379 | getter = parseGetter(getter); 380 | } 381 | if (isScore) { 382 | if (queries.length !== 1) { 383 | throw new Error("score operations currently don't work on compound queries"); 384 | } 385 | queryObj = queries[0]; 386 | if (queryObj.type !== "$and") { 387 | throw new Error("score operations only work on $and queries (not " + queryObj.type); 388 | } 389 | return function(model) { 390 | model._score = performQuerySingle(queryObj.type, queryObj.parsedQuery, getter, model, true); 391 | return model; 392 | }; 393 | } else { 394 | return function(model) { 395 | var i, len; 396 | for (i = 0, len = queries.length; i < len; i++) { 397 | queryObj = queries[i]; 398 | if (!performQuerySingle(queryObj.type, queryObj.parsedQuery, getter, model, isScore)) { 399 | return false; 400 | } 401 | } 402 | return true; 403 | }; 404 | } 405 | }; 406 | 407 | performQuerySingle = function(type, query, getter, model, isScore) { 408 | var attr, boost, i, len, passes, q, ref, score, scoreInc, test; 409 | passes = 0; 410 | score = 0; 411 | scoreInc = 1 / query.length; 412 | for (i = 0, len = query.length; i < len; i++) { 413 | q = query[i]; 414 | if (getter) { 415 | attr = getter(model, q.key); 416 | } else if (q.getter) { 417 | attr = q.getter(model, q.key); 418 | } else { 419 | attr = model[q.key]; 420 | } 421 | test = testModelAttribute(q.type, attr); 422 | if (test) { 423 | if (q.parsedQuery) { 424 | test = single([q], getter, isScore)(model); 425 | } else { 426 | test = performQuery(q.type, q.value, attr, model, getter); 427 | } 428 | } 429 | if (test) { 430 | passes++; 431 | if (isScore) { 432 | boost = (ref = q.boost) != null ? ref : 1; 433 | score += scoreInc * boost; 434 | } 435 | } 436 | switch (type) { 437 | case "$and": 438 | if (!(isScore || test)) { 439 | return false; 440 | } 441 | break; 442 | case "$not": 443 | if (test) { 444 | return false; 445 | } 446 | break; 447 | case "$or": 448 | if (test) { 449 | return true; 450 | } 451 | break; 452 | case "$nor": 453 | if (test) { 454 | return false; 455 | } 456 | break; 457 | default: 458 | throw new Error("Invalid compound method"); 459 | } 460 | } 461 | if (isScore) { 462 | return score; 463 | } else if (type === "$not") { 464 | return passes === 0; 465 | } else { 466 | return type !== "$or"; 467 | } 468 | }; 469 | 470 | parseQuery = function(query) { 471 | var compoundQuery, i, j, key, len, len1, queryKeys, results, type, val; 472 | queryKeys = utils.keys(query); 473 | if (!queryKeys.length) { 474 | return []; 475 | } 476 | compoundQuery = utils.intersection(utils.compoundKeys, queryKeys); 477 | for (i = 0, len = compoundQuery.length; i < len; i++) { 478 | type = compoundQuery[i]; 479 | if (!utils.isArray(query[type]) && utils.includes(utils.expectedArrayQueries, type)) { 480 | throw new Error(type + ' query must be an array'); 481 | } 482 | } 483 | if (compoundQuery.length === 0) { 484 | return [ 485 | { 486 | type: "$and", 487 | parsedQuery: parseSubQuery(query) 488 | } 489 | ]; 490 | } else { 491 | if (compoundQuery.length !== queryKeys.length) { 492 | if (!utils.includes(compoundQuery, "$and")) { 493 | query.$and = {}; 494 | compoundQuery.unshift("$and"); 495 | } 496 | for (key in query) { 497 | if (!hasProp.call(query, key)) continue; 498 | val = query[key]; 499 | if (!(!utils.includes(utils.compundKeys, key))) { 500 | continue; 501 | } 502 | query.$and[key] = val; 503 | delete query[key]; 504 | } 505 | } 506 | results = []; 507 | for (j = 0, len1 = compoundQuery.length; j < len1; j++) { 508 | type = compoundQuery[j]; 509 | results.push({ 510 | type: type, 511 | parsedQuery: parseSubQuery(query[type], type) 512 | }); 513 | } 514 | return results; 515 | } 516 | }; 517 | 518 | parseGetter = function(getter) { 519 | if (typeof getter === 'string') { 520 | return function(obj, key) { 521 | return obj[getter](key); 522 | }; 523 | } else { 524 | return getter; 525 | } 526 | }; 527 | 528 | QueryBuilder = (function() { 529 | function QueryBuilder(items1, _getter) { 530 | this.items = items1; 531 | this._getter = _getter; 532 | this.theQuery = {}; 533 | } 534 | 535 | QueryBuilder.prototype.all = function(items, first) { 536 | if (items) { 537 | this.items = items; 538 | } 539 | if (this.indexes) { 540 | items = this.getIndexedItems(this.items); 541 | } else { 542 | items = this.items; 543 | } 544 | return runQuery(items, this.theQuery, this._getter, first); 545 | }; 546 | 547 | QueryBuilder.prototype.chain = function() { 548 | return _.chain(this.all.apply(this, arguments)); 549 | }; 550 | 551 | QueryBuilder.prototype.tester = function() { 552 | return makeTest(this.theQuery, this._getter); 553 | }; 554 | 555 | QueryBuilder.prototype.first = function(items) { 556 | return this.all(items, true); 557 | }; 558 | 559 | QueryBuilder.prototype.getter = function(_getter) { 560 | this._getter = _getter; 561 | return this; 562 | }; 563 | 564 | return QueryBuilder; 565 | 566 | })(); 567 | 568 | addToQuery = function(type) { 569 | return function(params, qVal) { 570 | var base; 571 | if (qVal) { 572 | params = utils.makeObj(params, qVal); 573 | } 574 | if ((base = this.theQuery)[type] == null) { 575 | base[type] = []; 576 | } 577 | this.theQuery[type].push(params); 578 | return this; 579 | }; 580 | }; 581 | 582 | ref = utils.compoundKeys; 583 | for (i = 0, len = ref.length; i < len; i++) { 584 | key = ref[i]; 585 | QueryBuilder.prototype[key.substr(1)] = addToQuery(key); 586 | } 587 | 588 | QueryBuilder.prototype.find = QueryBuilder.prototype.query = QueryBuilder.prototype.run = QueryBuilder.prototype.all; 589 | 590 | buildQuery = function(items, getter) { 591 | return new QueryBuilder(items, getter); 592 | }; 593 | 594 | makeTest = function(query, getter) { 595 | return single(parseQuery(query), parseGetter(getter)); 596 | }; 597 | 598 | findOne = function(items, query, getter) { 599 | return runQuery(items, query, getter, true); 600 | }; 601 | 602 | runQuery = function(items, query, getter, first, isScore) { 603 | var fn; 604 | if (arguments.length < 2) { 605 | return buildQuery.apply(this, arguments); 606 | } 607 | if (getter) { 608 | getter = parseGetter(getter); 609 | } 610 | if (!(utils.getType(query) === "Function")) { 611 | query = single(parseQuery(query), getter, isScore); 612 | } 613 | if (isScore) { 614 | fn = utils.map; 615 | } else if (first) { 616 | fn = utils.find; 617 | } else { 618 | fn = utils.filter; 619 | } 620 | return fn(items, query); 621 | }; 622 | 623 | score = function(items, query, getter) { 624 | return runQuery(items, query, getter, false, true); 625 | }; 626 | 627 | runQuery.build = buildQuery; 628 | 629 | runQuery.parse = parseQuery; 630 | 631 | runQuery.findOne = runQuery.first = findOne; 632 | 633 | runQuery.score = score; 634 | 635 | runQuery.tester = runQuery.testWith = makeTest; 636 | 637 | runQuery.getter = runQuery.pluckWith = utils.makeGetter; 638 | 639 | expose = function(_, mixin) { 640 | if (mixin == null) { 641 | mixin = true; 642 | } 643 | createUtils(_); 644 | if (mixin) { 645 | _.mixin({ 646 | query: runQuery, 647 | q: runQuery 648 | }); 649 | } 650 | return runQuery; 651 | }; 652 | 653 | if (typeof exports !== "undefined" && (typeof module !== "undefined" && module !== null ? module.exports : void 0)) { 654 | return module.exports = expose; 655 | } else if (root._) { 656 | return expose(root._); 657 | } 658 | 659 | return expose; 660 | 661 | }).call(this); 662 | -------------------------------------------------------------------------------- /lib/underscore-query.min.js: -------------------------------------------------------------------------------- 1 | (function(){var e,r,t,n,u,s,a,i,o,c,l,$,p,f,y,d,h,g,k,m,v,b,w,x,q,O,E,Q={}.hasOwnProperty;m=this,E={},n=function(e){var r,t,n,u;for(u=["every","some","filter","first","find","reject","reduce","property","sortBy","indexOf","intersection","isEqual","keys","isArray","result","map","includes","isNaN"],r=0,n=u.length;r=2?(e.push({type:"$and",parsedQuery:n}),e):e.concat(n)},s=E.reduce(u,t,[]),E.sortBy(s,function(e){var r;return r=E.indexOf(x,e.type),r>=0?r:1/0})},O=function(e,r){var t;switch(t=E.getType(r),e){case"$in":case"$nin":case"$all":case"$any":case"$none":return"Array"===t;case"$size":return"Number"===t;case"$regex":case"$regexp":return E.includes(["RegExp","String"],t);case"$like":case"$likeI":return"String"===t;case"$between":case"$mod":return"Array"===t&&2===r.length;case"$cb":return"Function"===t;default:return!0}},q=function(e,r){var t;switch(t=E.getType(r),e){case"$like":case"$likeI":case"$regex":case"$startsWith":case"$endsWith":return"String"===t;case"$contains":case"$all":case"$any":case"$elemMatch":return"Array"===t;case"$size":return E.includes(["String","Array"],t);case"$in":case"$nin":return null!=r;default:return!0}},h=function(e,r,t,n,u){switch(e){case"$and":case"$or":case"$nor":case"$not":return g(e,r,u,n);case"$cb":return r.call(n,t);case"$elemMatch":return v(t,r,null,!0)}switch("function"==typeof r&&(r=r()),e){case"$equal":return E.isArray(t)?E.includes(t,r):t===r;case"$deepEqual":return E.isEqual(t,r);case"$ne":return t!==r;case"$type":return typeof t===r;case"$lt":return null!=r&&tr;case"$lte":return null!=r&&t<=r;case"$gte":return null!=r&&t>=r;case"$between":return null!=r[0]&&null!=r[1]&&r[0] (https://github.com/davidgtonge)", 6 | "tags": [ 7 | "underscore", 8 | "mongo", 9 | "ES5", 10 | "lodash", 11 | "elasticsearch", 12 | "score", 13 | "boost", 14 | "query" 15 | ], 16 | "main": "./lib/underscore-query", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/davidgtonge/underscore-query.git" 20 | }, 21 | "dependencies": {}, 22 | "devDependencies": { 23 | "coffee-script": ">=1.7.x", 24 | "mocha": "", 25 | "backbone": "", 26 | "uglify-js": "", 27 | "handlebars": "", 28 | "lodash": "3.x", 29 | "underscore": ">=1.6.x" 30 | }, 31 | "scripts": { 32 | "test": "mocha", 33 | "build": "cake build", 34 | "prepublish": "npm run build" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/underscore-query.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | Underscore Query - A lightweight query API for JavaScript collections 3 | (c)2016 - Dave Tonge 4 | May be freely distributed according to MIT license. 5 | 6 | This is small library that provides a query api for JavaScript arrays similar to *mongo db*. 7 | The aim of the project is to provide a simple, well tested, way of filtering data in JavaScript. 8 | ### 9 | 10 | root = this 11 | 12 | ### UTILS ### 13 | utils = {} 14 | 15 | # We assign local references to the underscore methods used. 16 | # If underscore is not supplied we use the above ES5 methods 17 | createUtils = (_) -> 18 | for key in ["every", "some", "filter", "first", "find", "reject", "reduce", "property", "sortBy" 19 | "indexOf", "intersection", "isEqual", "keys", "isArray", "result", "map", "includes", "isNaN"] 20 | utils[key] = _[key] 21 | throw new Error("#{key} missing. Please ensure that you first initialize 22 | underscore-query with either lodash or underscore") unless utils[key] 23 | return 24 | 25 | 26 | # Returns a string denoting the type of object 27 | utils.getType = (obj) -> 28 | type = Object.prototype.toString.call(obj).substr(8) 29 | type.substr(0, (type.length - 1)) 30 | 31 | # Utility Function to turn 2 values into an object 32 | utils.makeObj = (key, val)-> 33 | (o = {})[key] = val 34 | o 35 | 36 | # Reverses a string 37 | utils.reverseString = (str) -> str.toLowerCase().split("").reverse().join("") 38 | 39 | # An array of the compound modifers that can be used in queries 40 | utils.compoundKeys = ["$and", "$not", "$or", "$nor"] 41 | 42 | utils.expectedArrayQueries = ["$and", "$or", "$nor"] 43 | 44 | lookup = (keys, obj) -> 45 | out = obj 46 | for key, idx in keys 47 | # Add support for #21 48 | if utils.isArray(out) 49 | remainingKeys = keys.slice(idx) 50 | out = utils.map(out, (v) -> lookup(remainingKeys, v)) 51 | else if out then out = utils.result(out,key) 52 | else break 53 | out 54 | 55 | # Returns a getter function that works with dot notation and named functions 56 | utils.makeGetter = (keys) -> 57 | keys = keys.split(".") 58 | (obj) -> lookup(keys, obj) 59 | 60 | multipleConditions = (key, queries) -> 61 | (for type, val of queries 62 | utils.makeObj key, utils.makeObj(type, val)) 63 | 64 | parseParamType = (query) -> 65 | result = [] 66 | for own key, queryParam of query 67 | o = {key} 68 | if queryParam?.$boost 69 | o.boost = queryParam.$boost 70 | delete queryParam.$boost 71 | 72 | # If the key uses dot notation, then create a getter function 73 | if key.indexOf(".") isnt -1 74 | o.getter = utils.makeGetter(key) 75 | 76 | paramType = utils.getType(queryParam) 77 | switch paramType 78 | # Test for Regexs and Dates as they can be supplied without an operator 79 | when "RegExp", "Date" 80 | o.type = "$#{paramType.toLowerCase()}" 81 | o.value = queryParam 82 | 83 | when "Array" 84 | if utils.includes(utils.compoundKeys, key) 85 | o.type = key 86 | o.value = parseSubQuery queryParam, key 87 | o.key = null 88 | else 89 | o.type = "$equal" 90 | o.value = queryParam 91 | 92 | when "Object" 93 | size = utils.keys(queryParam).length 94 | # If the key is one of the compound keys, then parse the param as a raw query 95 | if utils.includes(utils.compoundKeys, key) 96 | o.type = key 97 | o.value = parseSubQuery queryParam, key 98 | o.key = null 99 | 100 | # Multiple conditions for the same key 101 | else if not (size == 1 or (size == 2 and '$options' of queryParam)) 102 | o.type = "$and" 103 | o.value = parseSubQuery multipleConditions(key, queryParam) 104 | o.key = null 105 | 106 | # Otherwise extract the key and value 107 | else 108 | for own type, value of queryParam 109 | if type == "$options" 110 | if "$regex" of queryParam or "regexp" of queryParam then continue 111 | throw new Error("$options needs a $regex") 112 | # Before adding the query, its value is checked to make sure it is the right type 113 | if testQueryValue type, value 114 | o.type = type 115 | switch type 116 | when "$elemMatch" then o.value = single(parseQuery(value)) 117 | when "$endsWith" then o.value = utils.reverseString(value) 118 | when "$likeI", "$startsWith" then o.value = value.toLowerCase() 119 | when "$regex", "$regexp" 120 | if typeof value == "string" 121 | o.value = new RegExp(value, queryParam.$options or "") 122 | else 123 | o.value = value 124 | when "$not", "$nor", "$or", "$and" 125 | o.value = parseSubQuery utils.makeObj(o.key, value) 126 | o.key = null 127 | when "$computed" 128 | o = utils.first parseParamType(utils.makeObj(key, value)) 129 | o.getter = utils.makeGetter(key) 130 | else o.value = value 131 | else throw new Error("Query value (#{value}) doesn't match query type: (#{type})") 132 | # If the query_param is not an object or a regexp then revert to the default operator: $equal 133 | else 134 | o.type = "$equal" 135 | o.value = queryParam 136 | 137 | # For "$equal" queries with arrays or objects we need to perform a deep equal 138 | if (o.type is "$equal") and (utils.includes(["Object", "Array"], paramType)) 139 | o.type = "$deepEqual" 140 | else if utils.isNaN(o.value) 141 | o.type = "$deepEqual" 142 | result.push(o) 143 | 144 | # Return the query object 145 | return result 146 | 147 | # Order in which to sort tags in order to optimize speed. Tags at the start of this array 148 | # are ones which can be evaluated quickly in order to avoid running the slower tags 149 | tag_sort_order = [ 150 | "$lt", "$lte", "$gt", "$gte", 151 | "$exists", "$has", "$type", "$ne", "$equal", 152 | "$mod", "$size", "$between", "$betweene", 153 | "$startsWith", "$endsWith", "$like", "$likeI", 154 | "$contains", "$in", "$nin", "$all", "$any", "$none", 155 | "$cb", "$regex", "$regexp", 156 | "$deepEqual", "$elemMatch", 157 | "$not", "$and", "$or", "$nor" 158 | ] 159 | 160 | # This function parses and normalizes raw queries. 161 | parseSubQuery = (rawQuery, type) -> 162 | # Ensure that the query is an array 163 | if utils.isArray(rawQuery) 164 | queryArray = rawQuery 165 | else 166 | queryArray = (utils.makeObj(key, val) for own key, val of rawQuery) 167 | 168 | iteratee = (memo, query) -> 169 | parsed = parseParamType(query) 170 | if (type == "$or" && parsed.length >= 2) # support $or with 2 or more conditions 171 | memo.push {type:"$and", parsedQuery: parsed} 172 | return memo 173 | else 174 | memo.concat parsed 175 | 176 | # Loop through all the different queries 177 | result = utils.reduce(queryArray, iteratee, []) 178 | # Attempt to optimize the query path 179 | utils.sortBy(result, (x) -> 180 | index = utils.indexOf(tag_sort_order, x.type) 181 | if index >= 0 then index else Infinity 182 | ) 183 | 184 | # Tests query value, to ensure that it is of the correct type 185 | testQueryValue = (queryType, value) -> 186 | valueType = utils.getType(value) 187 | switch queryType 188 | when "$in", "$nin", "$all", "$any", "$none" then valueType is "Array" 189 | when "$size" then valueType is "Number" 190 | when "$regex", "$regexp" then utils.includes(["RegExp", "String"], valueType) 191 | when "$like", "$likeI" then valueType is "String" 192 | when "$between", "$mod" then (valueType is "Array") and (value.length is 2) 193 | when "$cb" then valueType is "Function" 194 | else true 195 | 196 | # Test each attribute that is being tested to ensure that is of the correct type 197 | testModelAttribute = (queryType, value) -> 198 | valueType = utils.getType(value) 199 | switch queryType 200 | when "$like", "$likeI", "$regex", "$startsWith", "$endsWith" then valueType is "String" 201 | when "$contains", "$all", "$any", "$elemMatch" then valueType is "Array" 202 | when "$size" then utils.includes(["String", "Array"], valueType) 203 | when "$in", "$nin" then value? 204 | else true 205 | 206 | # Perform the actual query logic for each query and each model/attribute 207 | performQuery = (type, value, attr, model, getter) -> 208 | # Handle types of queries that should not be dynamic first 209 | switch type 210 | when "$and", "$or", "$nor", "$not" 211 | return performQuerySingle(type, value, getter, model) 212 | when "$cb" then return value.call model, attr 213 | when "$elemMatch" then return (runQuery(attr,value, null, true)) 214 | 215 | # If the query attribute is a function and the value isn't, it should be dynamically evaluated. 216 | value = value() if typeof value is 'function' 217 | 218 | switch type 219 | when "$equal" 220 | # If the attribute is an array then search for the query value in the array the same as Mongo 221 | if utils.isArray(attr) then utils.includes(attr, value) else (attr is value) 222 | when "$deepEqual" then utils.isEqual(attr, value) 223 | when "$ne" then attr isnt value 224 | when "$type" then typeof attr is value 225 | when "$lt" then value? and attr < value 226 | when "$gt" then value? and attr > value 227 | when "$lte" then value? and attr <= value 228 | when "$gte" then value? and attr >= value 229 | when "$between" then value[0]? and value[1]? and value[0] < attr < value[1] 230 | when "$betweene" then value[0]? and value[1]? and value[0] <= attr <= value[1] 231 | when "$size" then attr.length is value 232 | when "$exists", "$has" then attr? is value 233 | when "$contains" then utils.includes(attr, value) 234 | when "$in" then utils.includes(value, attr) 235 | when "$nin" then not utils.includes(value, attr) 236 | when "$all" then utils.every value, (item) -> utils.includes(attr, item) 237 | when "$any" then utils.some attr, (item) -> utils.includes(value, item) 238 | when "$none" then not utils.some attr, (item) -> utils.includes(value, item) 239 | when "$like" then attr.indexOf(value) isnt -1 240 | when "$likeI" then attr.toLowerCase().indexOf(value) isnt -1 241 | when "$startsWith" then attr.toLowerCase().indexOf(value) is 0 242 | when "$endsWith" then utils.reverseString(attr).indexOf(value) is 0 243 | when "$regex", "$regexp" then value.test attr 244 | when "$mod" then (attr % value[0]) is value[1] 245 | else false 246 | 247 | # This function should accept an obj like this: 248 | # $and: [queries], $or: [queries] 249 | # should return false if fails 250 | single = (queries, getter, isScore) -> 251 | getter = parseGetter(getter) if getter 252 | 253 | if isScore 254 | throw new Error("score operations currently don't work on compound queries") unless queries.length is 1 255 | queryObj = queries[0] 256 | throw new Error("score operations only work on $and queries (not #{queryObj.type}") unless queryObj.type is "$and" 257 | (model) -> 258 | model._score = performQuerySingle(queryObj.type, queryObj.parsedQuery, getter, model, true) 259 | model 260 | else 261 | (model) -> 262 | for queryObj in queries 263 | # Early false return if any of the queries fail 264 | return false unless performQuerySingle(queryObj.type, queryObj.parsedQuery, getter, model, isScore) 265 | # All queries passes, so return true 266 | true 267 | 268 | performQuerySingle = (type, query, getter, model, isScore) -> 269 | passes = 0 270 | score = 0 271 | scoreInc = 1 / query.length 272 | 273 | for q in query 274 | if getter 275 | attr = getter model, q.key 276 | else if q.getter 277 | attr = q.getter model, q.key 278 | else 279 | attr = model[q.key] 280 | # Check if the attribute value is the right type (some operators need a string, or an array) 281 | test = testModelAttribute(q.type, attr) 282 | # If the attribute test is true, perform the query 283 | if test 284 | if q.parsedQuery #nested queries 285 | test = single([q], getter, isScore)(model) 286 | else test = performQuery q.type, q.value, attr, model, getter 287 | if test 288 | passes++ 289 | if isScore 290 | boost = q.boost ? 1 291 | score += (scoreInc * boost) 292 | switch type 293 | when "$and" 294 | # Early false return for $and queries when any test fails 295 | return false unless isScore or test 296 | when "$not" 297 | # Early false return for $not queries when any test passes 298 | return false if test 299 | when "$or" 300 | # Early true return for $or queries when any test passes 301 | return true if test 302 | when "$nor" 303 | # Early false return for $nor queries when any test passes 304 | return false if test 305 | else 306 | throw new Error("Invalid compound method") 307 | 308 | if isScore 309 | score 310 | # For not queries, check that all tests have failed 311 | else if type is "$not" 312 | passes is 0 313 | # $or queries have failed as no tests have passed 314 | # $and queries have passed as no tests failed 315 | # $nor queries have passed as no tests passed 316 | else 317 | type isnt "$or" 318 | 319 | 320 | # The main function to parse raw queries. 321 | # Queries are split according to the compound type ($and, $or, etc.) before being parsed with parseSubQuery 322 | parseQuery = (query) -> 323 | queryKeys = utils.keys(query) 324 | return [] unless queryKeys.length 325 | compoundQuery = utils.intersection utils.compoundKeys, queryKeys 326 | 327 | for type in compoundQuery 328 | if not utils.isArray(query[type]) and utils.includes(utils.expectedArrayQueries, type) 329 | throw new Error(type + ' query must be an array') 330 | 331 | # If no compound methods are found then use the "and" iterator 332 | if compoundQuery.length is 0 333 | return [{type:"$and", parsedQuery:parseSubQuery(query)}] 334 | else 335 | # find if there is an implicit $and compundQuery operator 336 | if compoundQuery.length isnt queryKeys.length 337 | # Add the and compund query operator (with a sanity check that it doesn't exist) 338 | if not utils.includes(compoundQuery, "$and") 339 | query.$and = {} 340 | compoundQuery.unshift "$and" 341 | for own key, val of query when not utils.includes(utils.compundKeys, key) 342 | query.$and[key] = val 343 | delete query[key] 344 | (for type in compoundQuery 345 | {type, parsedQuery:parseSubQuery(query[type], type)}) 346 | 347 | 348 | parseGetter = (getter) -> 349 | return if typeof getter is 'string' then (obj, key) -> obj[getter](key) else getter 350 | 351 | class QueryBuilder 352 | constructor: (@items, @_getter) -> 353 | @theQuery = {} 354 | 355 | all: (items, first) -> 356 | if items then @items = items 357 | if @indexes 358 | items = @getIndexedItems(@items) 359 | else 360 | items = @items 361 | 362 | runQuery(items, @theQuery, @_getter, first) 363 | 364 | chain: -> _.chain(@all.apply(this, arguments)) 365 | 366 | tester: -> makeTest(@theQuery, @_getter) 367 | 368 | first: (items) -> 369 | @all(items, true) 370 | 371 | getter: (@_getter) -> 372 | this 373 | 374 | 375 | addToQuery = (type) -> 376 | (params, qVal) -> 377 | if qVal 378 | params = utils.makeObj params, qVal 379 | @theQuery[type] ?= [] 380 | @theQuery[type].push params 381 | this 382 | 383 | for key in utils.compoundKeys 384 | QueryBuilder::[key.substr(1)] = addToQuery(key) 385 | 386 | QueryBuilder::find = QueryBuilder::query = QueryBuilder::run = QueryBuilder::all 387 | 388 | # Build Query function for progamatically building up queries before running them. 389 | buildQuery = (items, getter) -> new QueryBuilder(items, getter) 390 | 391 | # Create a *test* function that checks if the object or objects match the query 392 | makeTest = (query, getter) -> single(parseQuery(query), parseGetter(getter)) 393 | 394 | # Find one function that returns first matching result 395 | findOne = (items, query, getter) -> runQuery(items, query, getter, true) 396 | 397 | # The main function to be mxied into underscore that takes a collection and a raw query 398 | runQuery = (items, query, getter, first, isScore) -> 399 | if arguments.length < 2 400 | # If no arguments or only the items are provided, then use the buildQuery interface 401 | return buildQuery.apply this, arguments 402 | if getter then getter = parseGetter(getter) 403 | query = single(parseQuery(query), getter, isScore) unless (utils.getType(query) is "Function") 404 | if isScore 405 | fn = utils.map 406 | else if first 407 | fn = utils.find 408 | else 409 | fn = utils.filter 410 | fn items, query 411 | 412 | score = (items, query, getter) -> 413 | runQuery(items, query, getter, false, true) 414 | 415 | runQuery.build = buildQuery 416 | runQuery.parse = parseQuery 417 | runQuery.findOne = runQuery.first = findOne 418 | runQuery.score = score 419 | runQuery.tester = runQuery.testWith = makeTest 420 | runQuery.getter = runQuery.pluckWith = utils.makeGetter 421 | 422 | expose = (_, mixin = true) -> 423 | createUtils(_) 424 | if mixin then _.mixin {query:runQuery, q:runQuery} 425 | runQuery 426 | 427 | # We now need to determine the environment that we are in 428 | 429 | # If no globals, then lets return the expose method, so users can explicitly pass in 430 | # their lodash or underscore reference 431 | if typeof exports != "undefined" and module?.exports 432 | # we're in node land 433 | return module.exports = expose 434 | 435 | # underscore / lodash is exposed globally, so lets mixin for the user 436 | else if root._ then return expose(root._) 437 | 438 | # assuming we're in AMD land??? 439 | return expose 440 | -------------------------------------------------------------------------------- /test/blank.coffee: -------------------------------------------------------------------------------- 1 | # Requires 2 | require "coffee-script" 3 | assert = require('assert') 4 | _ = require "underscore" 5 | require("../src/underscore-query")(_) 6 | 7 | _collection = [ 8 | {title:"Home", colors:["red","yellow","blue"], likes:12, featured:true, content: "Dummy content about coffeescript", blank: null} 9 | {title:"About", colors:["red"], likes:2, featured:true, content: "dummy content about javascript", blank: ''} 10 | {title:"Contact", colors:["red","blue"], likes:20, content: "Dummy content about PHP", blank: undefined}, 11 | {title:"Careers", colors:["purple","white"], likes:10, content: "Dummy content about Careers"}, # blank simply omitted 12 | {title:"Sponsors", colors:["green","gold"], likes:14, content: "Dummy content about Sponsors", blank: []} 13 | ] 14 | 15 | create = -> _.clone(_collection) 16 | 17 | describe "Underscore Query Tests: Blanks", -> 18 | 19 | it "handles null values", -> 20 | 21 | a = create() 22 | result = _.query a, blank:null 23 | assert.equal result.length, 1 24 | assert.equal result[0].title, "Home" 25 | 26 | 27 | it "handles empty values", -> 28 | 29 | a = create() 30 | result = _.query a, blank: "" 31 | assert.equal result.length, 1 32 | assert.equal result[0].title, "About" 33 | 34 | 35 | it "handles undefined values", -> 36 | 37 | a = create() 38 | result = _.query a, blank: undefined 39 | assert.equal result.length, 2 40 | assert.equal result[0].title, "Contact" 41 | assert.equal result[1].title, "Careers" 42 | 43 | 44 | it "handles empty array values", -> 45 | 46 | a = create() 47 | result = _.query a, blank: [] 48 | assert.equal result.length, 1 49 | assert.equal result[0].title, "Sponsors" 50 | 51 | it "handles empty values in $cb", -> 52 | 53 | a = create() 54 | $blank = $cb: (attr) -> 55 | attr is null or attr is `undefined` or attr is "" or (attr.length is 0) 56 | result = _.query a, blank: $blank 57 | assert.equal result.length, 5 58 | 59 | 60 | -------------------------------------------------------------------------------- /test/lodash.coffee: -------------------------------------------------------------------------------- 1 | # Requires 2 | require "coffee-script" 3 | 4 | assert = require "assert" 5 | _ = require "lodash" 6 | require("../src/underscore-query")(_) 7 | 8 | suite = require "./suite" 9 | 10 | describe "Underscore Query Tests", -> 11 | suite(_.query) 12 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --ui bdd 3 | --bail 4 | --compilers coffee:coffee-script/register -------------------------------------------------------------------------------- /test/suite.coffee: -------------------------------------------------------------------------------- 1 | require "coffee-script" 2 | assert = require "assert" 3 | _ = require "underscore" 4 | 5 | _collection = [ 6 | {title:"Home", colors:["red","yellow","blue"], likes:12, featured:true, content: "Dummy content about coffeescript", score: 0} 7 | {title:"About", colors:["red"], likes:2, featured:true, content: "dummy content about javascript", score: 5} 8 | {title:"Contact", colors:["red","blue"], likes:20, content: "Dummy content about PHP", score: -1, total:NaN} 9 | ] 10 | 11 | create = -> _.clone(_collection) 12 | 13 | module.exports = (_query) -> 14 | it "Equals query", -> 15 | a = create() 16 | result = _query a, title:"Home" 17 | assert.equal result.length, 1 18 | assert.equal result[0].title, "Home" 19 | 20 | result = _query a, colors: "blue" 21 | assert.equal result.length, 2 22 | 23 | result = _query a, colors: ["red", "blue"] 24 | assert.equal result.length, 1 25 | 26 | it "Simple equals query (no results)", -> 27 | a = create() 28 | result = _query a, title:"Homes" 29 | assert.equal result.length, 0 30 | 31 | it "equal null doesn't match 0", -> 32 | a = create() 33 | result = _query a, score:null 34 | assert.equal result.length, 0 35 | 36 | it "equal NaN matches NaNs", -> 37 | a = create() 38 | result = _query a, total:NaN 39 | assert.equal result.length, 1 40 | assert.equal result[0].title, "Contact" 41 | 42 | it "Simple equals query with explicit $equal", -> 43 | a = create() 44 | result = _query a, title: {$equal: "About"} 45 | assert.equal result.length, 1 46 | assert.equal result[0].title, "About" 47 | 48 | it "$contains operator", -> 49 | a = create() 50 | result = _query a, colors: {$contains: "blue"} 51 | assert.equal result.length, 2 52 | 53 | it "$ne operator", -> 54 | a = create() 55 | result = _query a, title: {$ne: "Home"} 56 | assert.equal result.length, 2 57 | 58 | it "$lt operator", -> 59 | a = create() 60 | result = _query a, likes: {$lt: 12} 61 | assert.equal result.length, 1 62 | assert.equal result[0].title, "About" 63 | 64 | it "$lt operator", -> 65 | a = create() 66 | result = _query a, score: {$lt: null} 67 | assert.equal result.length, 0 68 | 69 | it "$lte operator", -> 70 | a = create() 71 | result = _query a, likes: {$lte: 12} 72 | assert.equal result.length, 2 73 | 74 | it "$lte operator", -> 75 | a = create() 76 | result = _query a, score: {$lte: null} 77 | assert.equal result.length, 0 78 | 79 | it "$gt operator", -> 80 | a = create() 81 | result = _query a, likes: {$gt: 12} 82 | assert.equal result.length, 1 83 | assert.equal result[0].title, "Contact" 84 | 85 | it "$gt null", -> 86 | a = create() 87 | result = _query a, likes: {$gt: null} 88 | assert.equal result.length, 0 89 | 90 | it "$gte operator", -> 91 | a = create() 92 | result = _query a, likes: {$gte: 12} 93 | assert.equal result.length, 2 94 | 95 | it "$gte null", -> 96 | a = create() 97 | result = _query a, likes: {$gte: null} 98 | assert.equal result.length, 0 99 | 100 | it "$between operator", -> 101 | a = create() 102 | result = _query a, likes: {$between: [1,5]} 103 | assert.equal result.length, 1 104 | assert.equal result[0].title, "About" 105 | 106 | it "$between operator is exclusive", -> 107 | a = create() 108 | result = _query a, likes: {$between: [1,2]} 109 | assert.equal result.length, 0 110 | 111 | it "$between operator with null", -> 112 | a = create() 113 | result = _query a, likes: {$between: [null, 5]} 114 | assert.equal result.length, 0 115 | 116 | it "$between errors with not enough args", -> 117 | a = create() 118 | assert.throws -> 119 | result = _query a, likes: {$between: []} 120 | assert.throws -> 121 | result = _query a, likes: {$between: [5]} 122 | 123 | it "$betweene operator is inclusive", -> 124 | a = create() 125 | result = _query a, likes: {$betweene: [1,2]} 126 | assert.equal result.length, 1 127 | assert.equal result[0].title, "About" 128 | 129 | it "$betweene operator with null", -> 130 | a = create() 131 | result = _query a, likes: {$betweene: [null, 10]} 132 | assert.equal result.length, 0 133 | 134 | it "$mod operator", -> 135 | a = create() 136 | result = _query a, likes: {$mod: [3,0]} 137 | assert.equal result.length, 1 138 | assert.equal result[0].title, "Home" 139 | 140 | 141 | it "$mod operator with null", -> 142 | a = create() 143 | result = _query a, likes: {$mod: [null, 5]} 144 | assert.equal result.length, 0 145 | result = _query a, likes: {$mod: [3, null]} 146 | assert.equal result.length, 0 147 | 148 | it "$mod errors with not enough args", -> 149 | a = create() 150 | assert.throws -> 151 | result = _query a, likes: {$mod: []} 152 | assert.throws -> 153 | result = _query a, likes: {$mod: [5]} 154 | 155 | it "$in operator", -> 156 | a = create() 157 | result = _query a, title: {$in: ["Home","About"]} 158 | assert.equal result.length, 2 159 | 160 | it "$in operator with wrong query value", -> 161 | a = create() 162 | assert.throws -> 163 | _query a, title: {$in: "Home"} 164 | 165 | it "$nin operator", -> 166 | a = create() 167 | result = _query a, title: {$nin: ["Home","About"]} 168 | assert.equal result.length, 1 169 | assert.equal result[0].title, "Contact" 170 | 171 | it "$all operator", -> 172 | a = create() 173 | result = _query a, colors: {$all: ["red","blue"]} 174 | assert.equal result.length, 2 175 | 176 | it "$all operator (wrong values)", -> 177 | a = create() 178 | result = _query a, title: {$all: ["red","blue"]} 179 | assert.equal result.length, 0 180 | 181 | assert.throws -> 182 | _query a, colors: {$all: "red"} 183 | 184 | it "$any operator", -> 185 | a = create() 186 | result = _query a, colors: {$any: ["red","blue"]} 187 | assert.equal result.length, 3 188 | 189 | result = _query a, colors: {$any: ["yellow","blue"]} 190 | assert.equal result.length, 2 191 | 192 | it "$none operator", -> 193 | a = create() 194 | result = _query a, colors: {$none: ["yellow","blue"]} 195 | assert.deepEqual result, [a[1]] 196 | 197 | it "$size operator", -> 198 | a = create() 199 | result = _query a, colors: {$size: 3} 200 | assert.equal result.length, 1 201 | assert.equal result[0].title, "Home" 202 | 203 | it "$exists operator", -> 204 | a = create() 205 | result = _query a, featured: {$exists: true} 206 | assert.equal result.length, 2 207 | 208 | it "$has operator", -> 209 | a = create() 210 | result = _query a, featured: {$exists: false} 211 | assert.equal result.length, 1 212 | assert.equal result[0].title, "Contact" 213 | 214 | it "$like operator", -> 215 | a = create() 216 | result = _query a, content: {$like: "javascript"} 217 | assert.equal result.length, 1 218 | assert.equal result[0].title, "About" 219 | 220 | it "$like operator 2", -> 221 | a = create() 222 | result = _query a, content: {$like: "content"} 223 | assert.equal result.length, 3 224 | 225 | it "$likeI operator", -> 226 | a = create() 227 | result = _query a, content: {$likeI: "dummy"} 228 | assert.equal result.length, 3 229 | result = _query a, content: {$like: "dummy"} 230 | assert.equal result.length, 1 231 | 232 | it "$startsWith operator", -> 233 | a = create() 234 | result = _query a, title: {$startsWith: "Ho"} 235 | assert.equal result.length, 1 236 | assert.equal result[0].title, "Home" 237 | 238 | it "$endsWith operator", -> 239 | a = create() 240 | result = _query a, title: {$endsWith: "me"} 241 | assert.equal result.length, 1 242 | assert.equal result[0].title, "Home" 243 | 244 | 245 | it "$regex", -> 246 | a = create() 247 | result = _query a, content: {$regex: /javascript/gi} 248 | assert.equal result.length, 1 249 | assert.equal result[0].title, "About" 250 | 251 | it "$regex2", -> 252 | a = create() 253 | result = _query a, content: {$regex: /dummy/} 254 | assert.equal result.length, 1 255 | 256 | it "$regex3", -> 257 | a = create() 258 | result = _query a, content: {$regex: /dummy/i} 259 | assert.equal result.length, 3 260 | 261 | it "$regex4", -> 262 | a = create() 263 | result = _query a, content: /javascript/i 264 | assert.equal result.length, 1 265 | 266 | it "$regex with object", -> 267 | a = create() 268 | result = _query a, content: {$regex: 'dummy'} 269 | assert.equal result.length, 1 270 | 271 | it "$regex with object+options", -> 272 | a = create() 273 | result = _query a, content: {$regex: 'dummy', $options: 'i'} 274 | assert.equal result.length, 3 275 | 276 | it "$options errors without regexp", -> 277 | a = create() 278 | assert.throws(-> 279 | result = _query a, content: {$options: 'i'} 280 | ) 281 | 282 | it "$cb - callback", -> 283 | a = create() 284 | fn = (attr) -> 285 | attr.charAt(0).toLowerCase() is "c" 286 | result = _query a, 287 | title: $cb: fn 288 | 289 | assert.equal result.length, 1 290 | assert.equal result[0].title, "Contact" 291 | 292 | it "$cb - callback - checking 'this' is the model", -> 293 | a = create() 294 | result = _query a, title: 295 | $cb: (attr) -> @title is "Home" 296 | assert.equal result.length, 1 297 | assert.equal result[0].title, "Home" 298 | 299 | it "Dynamic equals query", -> 300 | a = create() 301 | result = _query a, title:()->"Homes" 302 | assert.equal result.length, 0 303 | result = _query a, title:()->"Home" 304 | assert.equal result.length, 1 305 | 306 | it "ensure dynamic query not cached", -> 307 | a = create() 308 | count = 12 - a.length 309 | query = _query.testWith(likes: $lt: -> count += 1) 310 | 311 | result = _.filter(a, query) 312 | assert.equal (result).length, 1 313 | result = _.filter(a, query) 314 | assert.equal (result).length, 2 315 | 316 | it "$and operator", -> 317 | a = create() 318 | result = _query a, likes: {$gt: 5}, colors: {$contains: "yellow"} 319 | assert.equal result.length, 1 320 | assert.equal result[0].title, "Home" 321 | 322 | it "$and operator (explicit)", -> 323 | a = create() 324 | result = _query a, $and: [{likes: {$gt: 5}, colors: {$contains: "yellow"}}] 325 | assert.equal result.length, 1 326 | assert.equal result[0].title, "Home" 327 | 328 | it "$or operator", -> 329 | a = create() 330 | result = _query a, $or: [{likes: {$gt: 5}}, {colors: {$contains: "yellow"}}] 331 | assert.equal result.length, 2 332 | 333 | it "$or2 operator", -> 334 | a = create() 335 | result = _query a, $or: [{likes: {$gt: 5}}, {featured: true}] 336 | assert.equal result.length, 3 337 | 338 | it "$or with multiple params in a condition", -> 339 | dataset = [{x: 1, y: 2}, {x: 1.25, y: 3}, {x: 1.5, y: 3}, {x: 2, y: 4}] 340 | result = _query(dataset, { 341 | $or: [{ 342 | x: { 343 | $gt: 1 344 | }, 345 | y: { 346 | $lt: 4 347 | } 348 | }, { 349 | foo: 1 350 | }] 351 | }) 352 | assert.equal result.length, 2 353 | 354 | it "$nor operator", -> 355 | a = create() 356 | result = _query a, $nor: [{likes: {$gt: 5}}, {colors: {$contains: "yellow"}}] 357 | assert.equal result.length, 1 358 | assert.equal result[0].title, "About" 359 | 360 | for type in ["$and", "$or", "$nor"] 361 | it type + " throws error when not an array", -> 362 | a = create() 363 | query = {} 364 | query[type] = { 365 | a: 1 366 | } 367 | assert.throws((-> _query(a, query)), Error); 368 | 369 | it "Compound Queries", -> 370 | a = create() 371 | result = _query a, $and: [{likes: {$gt: 5}}], $or: [{content: {$like: "PHP"}}, {colors: {$contains: "yellow"}}] 372 | assert.equal result.length, 2 373 | 374 | result = _query a, 375 | $and: [ 376 | likes: $lt: 15 377 | ] 378 | $or: [ 379 | { 380 | content: 381 | $like: "Dummy" 382 | }, 383 | { 384 | featured: 385 | $exists:true 386 | } 387 | ] 388 | $not: 389 | colors: $contains: "yellow" 390 | assert.equal result.length, 1 391 | assert.equal result[0].title, "About" 392 | 393 | 394 | it "$not operator", -> 395 | a = create() 396 | result = _query a, {$not: {likes: {$lt: 12}}} 397 | assert.equal result.length, 2 398 | 399 | #These tests fail, but would pass if it $not worked parallel to MongoDB 400 | it "$not operator", -> 401 | a = create() 402 | result = _query a, {likes: {$not: {$lt: 12}}} 403 | assert.equal result.length, 2 404 | 405 | it "$not operator", -> 406 | a = create() 407 | result = _query a, likes: {$not: 12} 408 | assert.equal result.length, 2 409 | 410 | it "$not $equal operator", -> 411 | a = create() 412 | result = _query a, likes: {$not: {$equal: 12}} 413 | assert.equal result.length, 2 414 | 415 | it "$not $equal operator", -> 416 | a = create() 417 | result = _query a, likes: {$not: {$ne: 12}} 418 | assert.equal result.length, 1 419 | 420 | 421 | 422 | it "$elemMatch", -> 423 | a = [ 424 | {title: "Home", comments:[ 425 | {text:"I like this post"} 426 | {text:"I love this post"} 427 | {text:"I hate this post"} 428 | ]} 429 | {title: "About", comments:[ 430 | {text:"I like this page"} 431 | {text:"I love this page"} 432 | {text:"I really like this page"} 433 | ]} 434 | ] 435 | 436 | b = [ 437 | {foo: [ 438 | {shape: "square", color: "purple", thick: false} 439 | {shape: "circle", color: "red", thick: true} 440 | ]} 441 | {foo: [ 442 | {shape: "square", color: "red", thick: true} 443 | {shape: "circle", color: "purple", thick: false} 444 | ]} 445 | ] 446 | 447 | text_search = {$likeI: "love"} 448 | 449 | result = _query a, $or: [ 450 | { 451 | comments: 452 | $elemMatch: 453 | text: text_search 454 | }, 455 | {title: text_search} 456 | ] 457 | assert.equal result.length, 2 458 | 459 | result = _query a, $or: [ 460 | comments: 461 | $elemMatch: 462 | text: /post/ 463 | ] 464 | assert.equal result.length, 1 465 | 466 | result = _query a, $or: [ 467 | { 468 | comments: 469 | $elemMatch: 470 | text: /post/ 471 | }, 472 | {title: /about/i} 473 | ] 474 | assert.equal result.length, 2 475 | 476 | result = _query a, $or: [ 477 | comments: 478 | $elemMatch: 479 | text: /really/ 480 | ] 481 | assert.equal result.length, 1 482 | 483 | result = _query b, 484 | foo: 485 | $elemMatch: 486 | shape:"square" 487 | color:"purple" 488 | 489 | assert.equal result.length, 1 490 | assert.equal result[0].foo[0].shape, "square" 491 | assert.equal result[0].foo[0].color, "purple" 492 | assert.equal result[0].foo[0].thick, false 493 | 494 | 495 | it "$any and $all", -> 496 | a = name: "test", tags1: ["red","yellow"], tags2: ["orange", "green", "red", "blue"] 497 | b = name: "test1", tags1: ["purple","blue"], tags2: ["orange", "red", "blue"] 498 | c = name: "test2", tags1: ["black","yellow"], tags2: ["green", "orange", "blue"] 499 | d = name: "test3", tags1: ["red","yellow","blue"], tags2: ["green"] 500 | e = [a,b,c,d] 501 | 502 | result = _query e, 503 | tags1: $any: ["red","purple"] # should match a, b, d 504 | tags2: $all: ["orange","green"] # should match a, c 505 | 506 | assert.equal result.length, 1 507 | assert.equal result[0].name, "test" 508 | 509 | it "$elemMatch - compound queries", -> 510 | a = [ 511 | {title: "Home", comments:[ 512 | {text:"I like this post"} 513 | {text:"I love this post"} 514 | {text:"I hate this post"} 515 | ]} 516 | {title: "About", comments:[ 517 | {text:"I like this page"} 518 | {text:"I love this page"} 519 | {text:"I really like this page"} 520 | ]} 521 | ] 522 | 523 | result = _query a, 524 | comments: 525 | $elemMatch: 526 | $not: 527 | text:/page/ 528 | 529 | assert.equal result.length, 1 530 | 531 | 532 | # Test from RobW - https://github.com/Rob--W 533 | it "Explicit $and combined with matching $or must return the correct number of items", -> 534 | Col = [ 535 | {equ:'ok', same: 'ok'}, 536 | {equ:'ok', same: 'ok'} 537 | ] 538 | result = _query Col, 539 | $and: [ # Matches both items 540 | {equ: 'ok'}, # Matches both items 541 | $or: 542 | same: 'ok' 543 | ] 544 | assert.equal result.length, 2 545 | 546 | # Test from RobW - https://github.com/Rob--W 547 | it "Implicit $and consisting of non-matching subquery and $or must return empty list", -> 548 | Col = [ 549 | {equ:'ok', same: 'ok'}, 550 | {equ:'ok', same: 'ok'} 551 | ] 552 | result = _query Col, 553 | $and: [{equ: 'bogus'}] # Matches nothing 554 | $or: [ 555 | same: 'ok' # Matches all items, but due to implicit $and, this subquery should not affect the result 556 | ] 557 | assert.equal result.length, 0 558 | 559 | it.skip "Testing nested compound operators", -> 560 | a = create() 561 | result = _query a, 562 | $and: [ 563 | {colors: $contains: "blue"} # Matches 1,3 564 | $or: [ 565 | {featured:true} # Matches 1,2 566 | {likes:12} # Matches 1 567 | ] 568 | ] 569 | # And only matches 1 570 | 571 | $or:[ 572 | {content:$like:"dummy"} # Matches 2 573 | {content:$like:"Dummy"} # Matches 1,3 574 | ] 575 | # Or matches 3 576 | assert.equal result.length, 1 577 | 578 | result = _query a, 579 | $and: [ 580 | {colors: $contains: "blue"} # Matches 1,3 581 | $or: [ 582 | {featured:true} # Matches 1,2 583 | {likes:20} # Matches 3 584 | ] 585 | ] 586 | # And only matches 2 587 | 588 | $or:[ 589 | {content:$like:"dummy"} # Matches 2 590 | {content:$like:"Dummy"} # Matches 1,3 591 | ] 592 | # Or matches 3 593 | assert.equal result.length, 2 594 | 595 | it "works with queries supplied as arrays", -> 596 | a = create() 597 | result = _query a, 598 | $or: [ 599 | {title:"Home"} 600 | {title:"About"} 601 | ] 602 | assert.equal result.length, 2 603 | assert.equal result[0].title, "Home" 604 | assert.equal result[1].title, "About" 605 | 606 | it "works with underscore chain", -> 607 | a = create() 608 | q = 609 | $or: [ 610 | {title:"Home"} 611 | {title:"About"} 612 | ] 613 | result = _.chain(a).query(q).pluck("title").value() 614 | 615 | assert.equal result.length, 2 616 | assert.equal result[0], "Home" 617 | assert.equal result[1], "About" 618 | 619 | it "works with a getter property", -> 620 | Backbone = require "backbone" 621 | a = new Backbone.Collection [ 622 | {id:1, title:"test"} 623 | {id:2, title:"about"} 624 | ] 625 | result = _query a.models, {title:"about"}, "get" 626 | assert.equal result.length, 1 627 | assert.equal result[0].get("title"), "about" 628 | 629 | it "can be mixed into backbone collections", -> 630 | Backbone = require "backbone" 631 | class Collection extends Backbone.Collection 632 | query: (params) -> _query @models, params, "get" 633 | whereBy: (params) -> new @constructor @query(params) 634 | buildQuery: -> _query.build @models, "get" 635 | 636 | a = new Collection [ 637 | {id:1, title:"test"} 638 | {id:2, title:"about"} 639 | ] 640 | result = a.query {title:"about"} 641 | assert.equal result.length, 1 642 | assert.equal result[0].get("title"), "about" 643 | 644 | 645 | result2 = a.whereBy {title:"about"} 646 | assert.equal result2.length, 1 647 | assert.equal result2.at(0).get("title"), "about" 648 | assert.equal result2.pluck("title")[0], "about" 649 | 650 | result3 = a.buildQuery().not(title:"test").run() 651 | assert.equal result3.length, 1 652 | assert.equal result3[0].get("title"), "about" 653 | 654 | 655 | 656 | 657 | it "can be used for live collections", -> 658 | Backbone = require "backbone" 659 | class Collection extends Backbone.Collection 660 | query: (params) -> 661 | if params 662 | _query @models, params, "get" 663 | else 664 | _query.build @models, "get" 665 | whereBy: (params) -> new @constructor @query(params) 666 | setFilter: (parent, query) -> 667 | 668 | check = _query.tester(query, "get") 669 | 670 | @listenTo parent, 671 | add: (model) -> if check(model) then @add(model) 672 | remove: @remove 673 | change: (model) -> 674 | if check(model) then @add(model) else @remove(model) 675 | 676 | @add _query(parent.models, query, "get") 677 | 678 | parent = new Collection [ 679 | {title:"Home", colors:["red","yellow","blue"], likes:12, featured:true, content: "Dummy content about coffeescript"} 680 | {title:"About", colors:["red"], likes:2, featured:true, content: "dummy content about javascript"} 681 | {title:"Contact", colors:["red","blue"], likes:20, content: "Dummy content about PHP"} 682 | ] 683 | live = new Collection 684 | live.setFilter parent, {likes:$gt:15} 685 | 686 | assert.equal parent.length, 3 687 | assert.equal live.length, 1 688 | 689 | # Change Events 690 | parent.at(0).set("likes",16) 691 | assert.equal live.length, 2 692 | parent.at(2).set("likes",2) 693 | assert.equal live.length, 1 694 | 695 | # Add to Parent 696 | parent.add [{title:"New", likes:21}, {title:"New2", likes:3}] 697 | assert.equal live.length, 2 698 | assert.equal parent.length, 5 699 | 700 | # Remove from Parent 701 | parent.pop() 702 | parent.pop() 703 | assert.equal live.length, 1 704 | 705 | it "buildQuery works in oo fashion", -> 706 | a = create() 707 | query = _query.build(a) 708 | .and({likes: {$gt: 5}}) 709 | .or({content: {$like: "PHP"}}) 710 | .or({colors: {$contains: "yellow"}}) 711 | 712 | result = query.run() 713 | 714 | assert.equal result.length, 2 715 | 716 | result = _query.build() 717 | .and(likes: $lt: 15) 718 | .or(content: $like: "Dummy") 719 | .or(featured: $exists: true) 720 | .not(colors: $contains: "yellow") 721 | .run(a) 722 | 723 | assert.equal result.length, 1 724 | assert.equal result[0].title, "About" 725 | 726 | 727 | 728 | it "works with dot notation", -> 729 | collection = [ 730 | {title:"Home", stats:{likes:10, views:{a:{b:500}}}} 731 | {title:"About", stats:{likes:5, views:{a:{b:234}}}} 732 | {title:"Code", stats:{likes:25, views:{a:{b:796}}}} 733 | ] 734 | 735 | result = _query collection, {"stats.likes":5} 736 | assert.equal result.length, 1 737 | assert.equal result[0].title, "About" 738 | 739 | result = _query collection, {"stats.views.a.b":796} 740 | assert.equal result.length, 1 741 | assert.equal result[0].title, "Code" 742 | 743 | it "works with seperate query args", -> 744 | collection = [ 745 | {title:"Home", stats:{likes:10, views:{a:{b:500}}}} 746 | {title:"About", stats:{likes:5, views:{a:{b:234}}}} 747 | {title:"Code", stats:{likes:25, views:{a:{b:796}}}} 748 | ] 749 | query = _query.build(collection) 750 | .and("title", "Home") 751 | result = query.run() 752 | 753 | assert.equal result.length, 1 754 | assert.equal result[0].title, "Home" 755 | 756 | it "$computed", -> 757 | Backbone = require "backbone" 758 | class testModel extends Backbone.Model 759 | full_name: -> "#{@get 'first_name'} #{@get 'last_name'}" 760 | 761 | a = new testModel 762 | first_name: "Dave" 763 | last_name: "Tonge" 764 | b = new testModel 765 | first_name: "John" 766 | last_name: "Smith" 767 | c = [a,b] 768 | 769 | result = _query c, 770 | full_name: $computed: "Dave Tonge" 771 | 772 | assert.equal result.length, 1 773 | assert.equal result[0].get("first_name"), "Dave" 774 | 775 | result = _query c, 776 | full_name: $computed: $likeI: "n sm" 777 | assert.equal result.length, 1 778 | assert.equal result[0].get("first_name"), "John" 779 | 780 | it "Handles multiple inequalities", -> 781 | a = create() 782 | result = _query a, likes: { $gt: 2, $lt: 20 } 783 | assert.equal result.length, 1 784 | assert.equal result[0].title, "Home" 785 | 786 | result = _query a, likes: { $gte: 2, $lt: 20 } 787 | assert.equal result.length, 2 788 | assert.equal result[0].title, "Home" 789 | assert.equal result[1].title, "About" 790 | 791 | result = _query a, likes: { $gt: 2, $lte: 20 } 792 | assert.equal result.length, 2 793 | assert.equal result[0].title, "Home" 794 | assert.equal result[1].title, "Contact" 795 | 796 | result = _query a, likes: { $gte: 2, $lte: 20 } 797 | assert.equal result.length, 3 798 | assert.equal result[0].title, "Home" 799 | assert.equal result[1].title, "About" 800 | assert.equal result[2].title, "Contact" 801 | 802 | result = _query a, likes: { $gte: 2, $lte: 20, $ne: 12 } 803 | assert.equal result.length, 2 804 | assert.equal result[0].title, "About" 805 | assert.equal result[1].title, "Contact" 806 | 807 | it "Handles nested multiple inequalities", -> 808 | a = create() 809 | result = _query a, $and: [likes: { $gt: 2, $lt: 20 }] 810 | assert.equal result.length, 1 811 | assert.equal result[0].title, "Home" 812 | 813 | it "has a score method", -> 814 | collection = [ 815 | { name:'dress', color:'red', price:100 } 816 | { name:'shoes', color:'black', price:120 } 817 | { name:'jacket', color:'blue', price:150 } 818 | ] 819 | 820 | results = _query.score( collection, { price: {$lt:140}, color: {$in:['red', 'blue'] }}) 821 | 822 | assert.equal _.findWhere(results, {name:'dress'})._score, 1 823 | assert.equal _.findWhere(results, {name:'shoes'})._score, 0.5 824 | 825 | it "has a score method with a $boost operator", -> 826 | collection = [ 827 | { name:'dress', color:'red', price:100 } 828 | { name:'shoes', color:'black', price:120 } 829 | { name:'jacket', color:'blue', price:150 } 830 | ] 831 | 832 | results = _query.score( collection, { price: 100, color: {$in:['black'], $boost:3 }}) 833 | 834 | assert.equal _.findWhere(results, {name:'dress'})._score, 0.5 835 | assert.equal _.findWhere(results, {name:'shoes'})._score, 1.5 836 | 837 | it "has a score method with a $boost operator - 2", -> 838 | collection = [ 839 | { name:'dress', color:'red', price:100 } 840 | { name:'shoes', color:'black', price:120 } 841 | { name:'jacket', color:'blue', price:150 } 842 | ] 843 | 844 | results = _query.score( collection, { name: {$like:'dre', $boost:5}, color: {$in:['black'], $boost:2 }}) 845 | 846 | assert.equal _.findWhere(results, {name:'dress'})._score, 2.5 847 | assert.equal _.findWhere(results, {name:'shoes'})._score, 1 848 | 849 | it "score method throws if compound query", -> 850 | collection = [ 851 | { name:'dress', color:'red', price:100 } 852 | { name:'shoes', color:'black', price:120 } 853 | { name:'jacket', color:'blue', price:150 } 854 | ] 855 | 856 | assert.throws -> 857 | _query.score collection, 858 | $and: price: 100 859 | $or: [ 860 | {color: 'red'} 861 | {color: 'blue'} 862 | ] 863 | 864 | it "score method throws if non $and query", -> 865 | collection = [ 866 | { name:'dress', color:'red', price:100 } 867 | { name:'shoes', color:'black', price:120 } 868 | { name:'jacket', color:'blue', price:150 } 869 | ] 870 | 871 | assert.throws -> 872 | _query.score collection, 873 | $or: [ 874 | {color: 'red'} 875 | {color: 'blue'} 876 | ] 877 | 878 | # not parallel to MongoDB 879 | it "$not operator", -> 880 | a = create() 881 | result = _query a, {$not: {likes: {$lt: 12}}} 882 | assert.equal result.length, 2 883 | 884 | # This is parallel to MongoDB 885 | it "$not operator - mongo style", -> 886 | a = create() 887 | result = _query a, {likes: {$not: {$lt: 12}}} 888 | assert.equal result.length, 2 889 | 890 | # This is parallel to MongoDB 891 | it "$not operator - mongo style", -> 892 | a = create() 893 | result = _query a, {likes: {$not: 12}} 894 | assert.equal result.length, 2 895 | 896 | it "combination of $gt and $lt - mongo style", -> 897 | a = create() 898 | result = _query a, {likes: { $gt: 2, $lt: 20}} 899 | assert.equal result.length, 1 900 | 901 | it "$not combination of $gt and $lt - mongo style", -> 902 | a = create() 903 | result = _query a, {likes: {$not: { $gt: 2, $lt: 20}}} 904 | assert.equal result.length, 2 905 | 906 | it "$nor combination of $gt and $lt - expressions ", -> 907 | a = create() 908 | result = _query a, {$nor: [{likes: { $gt: 2}}, {likes: { $lt: 20}}]} 909 | assert.equal result.length, 0 910 | 911 | # This query is not a valid MongoDB query, but if it were one would expect it to yield an empty set 912 | # it "$nor combination of $gt and $lt - values", -> 913 | # a = create() 914 | # result = _query a, {likes: {$nor: [{ $gt: 2}, {$lt: 20}]}} 915 | # assert.equal result.length, 0 916 | 917 | it "combination of $gt and $not", -> 918 | a = create() 919 | result = _query a, {likes: { $not: 2, $lt: 20}} 920 | assert.equal result.length, 1 921 | 922 | it "equal within an array (#21)", -> 923 | tweets = [{ 924 | "entities": { 925 | "user_mentions": [{ 926 | "id_str": "10228271" 927 | }] 928 | } 929 | }, { 930 | "entities": { 931 | "user_mentions": [{ 932 | "id_str": "10228272" 933 | }] 934 | } 935 | }] 936 | 937 | res = _query tweets, {"entities.user_mentions.id_str": "10228272"} 938 | assert.equal(res.length, 1) 939 | res = _query tweets, {"entities.user_mentions.id_str": "10228273"} 940 | assert.equal(res.length, 0) 941 | 942 | 943 | it "compound $ands (#29)", -> 944 | a = create() 945 | 946 | res = _query(a, { 947 | $and: [{ 948 | $and: [ likes: {$gt: 5 } ] 949 | }] 950 | }) 951 | 952 | assert.equal(res.length, 2) 953 | -------------------------------------------------------------------------------- /test/test.coffee: -------------------------------------------------------------------------------- 1 | # Requires 2 | require "coffee-script" 3 | 4 | assert = require "assert" 5 | _ = require "underscore" 6 | require("../src/underscore-query")(_) 7 | 8 | suite = require "./suite" 9 | 10 | describe "Underscore Query Tests", -> 11 | suite(_.query) 12 | --------------------------------------------------------------------------------