├── .gitignore ├── .npmignore ├── .travis.yml ├── Cakefile ├── LICENSE ├── README.md ├── backbone-query.js ├── backbone-query.min.js ├── package.json ├── src └── backbone-query.coffee └── test ├── bq-test.coffee └── mocha.opts /.gitignore: -------------------------------------------------------------------------------- 1 | # Idea IDE files 2 | .idea/ 3 | node_modules/ 4 | backbone_query.iml -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .git/ 3 | node_modules/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.6 4 | 5 | before_script: 6 | - "npm install" 7 | 8 | script: 9 | - "node_modules/.bin/mocha" 10 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | # ** Cakefile Template ** is a Template for a common Cakefile that you may use in a coffeescript nodejs project. 2 | # 3 | # It comes baked in with 5 tasks: 4 | # 5 | # * build - compiles your src directory to your lib directory 6 | # * watch - watches any changes in your src directory and automatically compiles to the lib directory 7 | # * test - runs mocha test framework, you can edit this task to use your favorite test framework 8 | # * docs - generates annotated documentation using docco 9 | # * clean - clean generated .js files 10 | files = [ 11 | '.' 12 | 'src' 13 | ] 14 | 15 | fs = require 'fs' 16 | {print} = require 'util' 17 | {spawn, exec} = require 'child_process' 18 | 19 | try 20 | which = require('which').sync 21 | catch err 22 | if process.platform.match(/^win/)? 23 | console.log 'WARNING: the which module is required for windows\ntry: npm install which' 24 | which = null 25 | 26 | # ANSI Terminal Colors 27 | bold = '\x1b[0;1m' 28 | green = '\x1b[0;32m' 29 | reset = '\x1b[0m' 30 | red = '\x1b[0;31m' 31 | 32 | # Cakefile Tasks 33 | # 34 | # ## *docs* 35 | # 36 | # Generate Annotated Documentation 37 | # 38 | # Usage 39 | # 40 | # ``` 41 | # cake docs 42 | # ``` 43 | task 'docs', 'generate documentation', -> docco() 44 | 45 | # ## *build* 46 | # 47 | # Builds Source 48 | # 49 | # Usage 50 | # 51 | # ``` 52 | # cake build 53 | # ``` 54 | task 'build', 'compile source', -> build -> log ":)", green 55 | 56 | # ## *watch* 57 | # 58 | # Builds your source whenever it changes 59 | # 60 | # Usage 61 | # 62 | # ``` 63 | # cake watch 64 | # ``` 65 | task 'watch', 'compile and watch', -> build true, -> log ":-)", green 66 | 67 | # ## *test* 68 | # 69 | # Runs your test suite. 70 | # 71 | # Usage 72 | # 73 | # ``` 74 | # cake test 75 | # ``` 76 | task 'test', 'run tests', -> build -> mocha -> log ":)", green 77 | 78 | # ## *clean* 79 | # 80 | # Cleans up generated js files 81 | # 82 | # Usage 83 | # 84 | # ``` 85 | # cake clean 86 | # ``` 87 | task 'clean', 'clean generated files', -> clean -> log ";)", green 88 | 89 | 90 | # Internal Functions 91 | # 92 | # ## *walk* 93 | # 94 | # **given** string as dir which represents a directory in relation to local directory 95 | # **and** callback as done in the form of (err, results) 96 | # **then** recurse through directory returning an array of files 97 | # 98 | # Examples 99 | # 100 | # ``` coffeescript 101 | # walk 'src', (err, results) -> console.log results 102 | # ``` 103 | walk = (dir, done) -> 104 | results = [] 105 | fs.readdir dir, (err, list) -> 106 | return done(err, []) if err 107 | pending = list.length 108 | return done(null, results) unless pending 109 | for name in list 110 | file = "#{dir}/#{name}" 111 | try 112 | stat = fs.statSync file 113 | catch err 114 | stat = null 115 | if stat?.isDirectory() 116 | walk file, (err, res) -> 117 | results.push name for name in res 118 | done(null, results) unless --pending 119 | else 120 | results.push file 121 | done(null, results) unless --pending 122 | 123 | # ## *log* 124 | # 125 | # **given** string as a message 126 | # **and** string as a color 127 | # **and** optional string as an explanation 128 | # **then** builds a statement and logs to console. 129 | # 130 | log = (message, color, explanation) -> console.log color + message + reset + ' ' + (explanation or '') 131 | 132 | # ## *launch* 133 | # 134 | # **given** string as a cmd 135 | # **and** optional array and option flags 136 | # **and** optional callback 137 | # **then** spawn cmd with options 138 | # **and** pipe to process stdout and stderr respectively 139 | # **and** on child process exit emit callback if set and status is 0 140 | launch = (cmd, options=[], callback) -> 141 | cmd = which(cmd) if which 142 | app = spawn cmd, options 143 | app.stdout.pipe(process.stdout) 144 | app.stderr.pipe(process.stderr) 145 | app.on 'exit', (status) -> callback?() if status is 0 146 | 147 | # ## *build* 148 | # 149 | # **given** optional boolean as watch 150 | # **and** optional function as callback 151 | # **then** invoke launch passing coffee command 152 | # **and** defaulted options to compile src to lib 153 | build = (watch, callback) -> 154 | if typeof watch is 'function' 155 | callback = watch 156 | watch = false 157 | 158 | options = ['-c', '-b', '-o' ] 159 | options = options.concat files 160 | options.unshift '-w' if watch 161 | launch 'coffee', options, callback 162 | 163 | # ## *unlinkIfCoffeeFile* 164 | # 165 | # **given** string as file 166 | # **and** file ends in '.coffee' 167 | # **then** convert '.coffee' to '.js' 168 | # **and** remove the result 169 | unlinkIfCoffeeFile = (file) -> 170 | if file.match /\.coffee$/ 171 | fs.unlink file.replace(/\.coffee$/, '.js') 172 | true 173 | else false 174 | 175 | # ## *clean* 176 | # 177 | # **given** optional function as callback 178 | # **then** loop through files variable 179 | # **and** call unlinkIfCoffeeFile on each 180 | clean = (callback) -> 181 | try 182 | for file in files 183 | unless unlinkIfCoffeeFile file 184 | walk file, (err, results) -> 185 | for f in results 186 | unlinkIfCoffeeFile f 187 | 188 | callback?() 189 | catch err 190 | 191 | # ## *moduleExists* 192 | # 193 | # **given** name for module 194 | # **when** trying to require module 195 | # **and** not found 196 | # **then* print not found message with install helper in red 197 | # **and* return false if not found 198 | moduleExists = (name) -> 199 | try 200 | require name 201 | catch err 202 | log "#{name} required: npm install #{name}", red 203 | false 204 | 205 | 206 | # ## *mocha* 207 | # 208 | # **given** optional array of option flags 209 | # **and** optional function as callback 210 | # **then** invoke launch passing mocha command 211 | mocha = (options, callback) -> 212 | #if moduleExists('mocha') 213 | if typeof options is 'function' 214 | callback = options 215 | options = [] 216 | 217 | launch 'mocha', options, callback 218 | 219 | # ## *docco* 220 | # 221 | # **given** optional function as callback 222 | # **then** invoke launch passing docco command 223 | docco = (callback) -> 224 | #if moduleExists('docco') 225 | walk 'src', (err, files) -> launch 'docco', files, callback 226 | 227 | task 'uglify', 'Minify and obfuscate', -> 228 | uglify = require 'uglify-js' 229 | jsp = uglify.parser 230 | pro = uglify.uglify 231 | 232 | contents = fs.readFileSync "backbone-query.js", 'utf8' 233 | 234 | ast = jsp.parse contents # parse code and get the initial AST 235 | ast = pro.ast_mangle ast # get a new AST with mangled names 236 | ast = pro.ast_squeeze ast # get an AST with compression optimizations 237 | final_code = pro.gen_code ast # compressed code here 238 | 239 | fs.writeFile 'backbone-query.min.js', final_code 240 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 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 | backbone-query 2 | =================== 3 | 4 | [![Build Status](https://secure.travis-ci.org/davidgtonge/backbone_query.png)](http://travis-ci.org/davidgtonge/backbone_query) 5 | 6 | A lightweight (3KB minified) utility for Backbone projects, that works in the Browser and on the Server. 7 | Adds the ability to search for models with a Query API similar to 8 | [MongoDB](http://www.mongodb.org/display/DOCS/Advanced+Queries) 9 | Please report any bugs, feature requests in the issue tracker. 10 | Pull requests are welcome! 11 | 12 | Compatible with Backbone 0.5 to 0.99 13 | 14 | Usage 15 | ===== 16 | 17 | #### Client Side Installation: 18 | To install, include the `js/backbone-query.min.js` file in your HTML page, after Backbone and it's dependencies. 19 | Then extend your collections from Backbone.QueryCollection rather than from Backbone.Collection. 20 | 21 | Backbone Query is also available via [Jam](http://jamjs.org/). Jam is a package manager for 22 | browser js packages and uses require.js. This is the recommended method of you want to use 23 | this library with require.js. To install, simply run `jam install backbone-query`. 24 | 25 | #### Server side (node.js) installation 26 | You can install with NPM: `npm install backbone-query` 27 | Then simply require in your project: `QueryCollection = require("backbone-query").QueryCollection` 28 | 29 | 30 | Your collections will now have two new methods: `query` and `whereBy`. Both methods accept 2 arguments - 31 | a query object and an options object. The `query` method returns an array of models, but the `whereBy` method 32 | returns a new collection and is therefore useful where you would like to chain multiple collection 33 | methods / whereBy queries (thanks to [Cezary Wojtkowski](https://github.com/cezary) ). 34 | 35 | The library also supports nested compound queries and is AMD compatible (thanks to [Rob W](https://github.com/Rob--W) ). 36 | 37 | The following are some basic examples: 38 | 39 | ```js 40 | MyCollection.query({ 41 | featured:true, 42 | likes: {$gt:10} 43 | }); 44 | // Returns all models where the featured attribute is true and there are 45 | // more than 10 likes 46 | 47 | MyCollection.query( 48 | {tags: { $any: ["coffeescript", "backbone", "mvc"]}}, 49 | {sortBy: "likes", order: "desc", limit:10, page:2, cache:true} 50 | ); 51 | // Finds models that have either "coffeescript", "backbone", "mvc" in their "tags" attribute 52 | // Sorts these models by the "likes" attribute in descending order 53 | // Caches the results and returns only 10 models, starting from the 11th model (page 2) 54 | 55 | MyCollection.query({ 56 | // Models must match all these queries 57 | $and:{ 58 | title: {$like: "news"}, // Title attribute contains the string "news" 59 | likes: {$gt: 10} 60 | }, // Likes attribute is greater than 10 61 | 62 | // Models must match one of these queries 63 | $or:{ 64 | featured: true, // Featured attribute is true 65 | category:{$in:["code","programming","javascript"]} 66 | } 67 | //Category attribute is either "code", "programming", or "javascript" 68 | }); 69 | ``` 70 | 71 | Or if CoffeeScript is your thing (the source is written in CoffeeScript), try this: 72 | 73 | ```coffeescript 74 | MyCollection.query 75 | $and: 76 | likes: $lt: 15 77 | $or: 78 | content: $like: "news" 79 | featured: $exists: true 80 | $not: 81 | colors: $contains: "yellow" 82 | ``` 83 | 84 | Another CoffeeScript example, this time using `whereBy` rather than `query` 85 | 86 | ```coffeescript 87 | query = 88 | $likes: $lt: 10 89 | $downloads: $gt: 20 90 | 91 | MyCollection.whereBy(query).my_custom_collection_method() 92 | ``` 93 | 94 | 95 | Query API 96 | === 97 | 98 | ### $equal 99 | Performs a strict equality test using `===`. If no operator is provided and the query value isn't a regex then `$equal` is assumed. 100 | 101 | 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` 102 | 103 | If the query value is an object (including array) then a deep comparison is performed using underscores `_.isEqual` 104 | 105 | ```javascript 106 | MyCollection.query({ title:"Test" }); 107 | // Returns all models which have a "title" attribute of "Test" 108 | 109 | MyCollection.query({ title: {$equal:"Test"} }); // Same as above 110 | 111 | MyCollection.query({ colors: "red" }); 112 | // Returns models which contain the value "red" in a "colors" attribute that is an array. 113 | 114 | MyCollection.query ({ colors: ["red", "yellow"] }); 115 | // Returns models which contain a colors attribute with the array ["red", "yellow"] 116 | ``` 117 | 118 | ### $contains 119 | Assumes that the model property is an array and searches for the query value in the array 120 | 121 | ```js 122 | MyCollection.query({ colors: {$contains: "red"} }); 123 | // Returns models which contain the value "red" in a "colors" attribute that is an array. 124 | // e.g. a model with this attribute colors:["red","yellow","blue"] would be returned 125 | ``` 126 | 127 | ### $ne 128 | "Not equal", the opposite of $equal, returns all models which don't have the query value 129 | 130 | ```js 131 | MyCollection.query({ title: {$ne:"Test"} }); 132 | // Returns all models which don't have a "title" attribute of "Test" 133 | ``` 134 | 135 | ### $lt, $lte, $gt, $gte 136 | These conditional operators can be used for greater than and less than comparisons in queries 137 | 138 | ```js 139 | MyCollection.query({ likes: {$lt:10} }); 140 | // Returns all models which have a "likes" attribute of less than 10 141 | MyCollection.query({ likes: {$lte:10} }); 142 | // Returns all models which have a "likes" attribute of less than or equal to 10 143 | MyCollection.query({ likes: {$gt:10} }); 144 | // Returns all models which have a "likes" attribute of greater than 10 145 | MyCollection.query({ likes: {$gte:10} }); 146 | // Returns all models which have a "likes" attribute of greater than or equal to 10 147 | ``` 148 | 149 | ### $between 150 | 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 151 | 152 | ```js 153 | MyCollection.query({ likes: {$between:[5,15] } }); 154 | // Returns all models which have a "likes" attribute of greater than 5 and less then 15 155 | ``` 156 | 157 | ### $in 158 | An array of possible values can be supplied using $in, a model will be returned if any of the supplied values is matched 159 | 160 | ```js 161 | MyCollection.query({ title: {$in:["About", "Home", "Contact"] } }); 162 | // Returns all models which have a title attribute of either "About", "Home", or "Contact" 163 | ``` 164 | 165 | ### $nin 166 | "Not in", the opposite of $in. A model will be returned if none of the supplied values is matched 167 | 168 | ```js 169 | MyCollection.query({ title: {$nin:["About", "Home", "Contact"] } }); 170 | // Returns all models which don't have a title attribute of either 171 | // "About", "Home", or "Contact" 172 | ``` 173 | 174 | ### $all 175 | Assumes the model property is an array and only returns models where all supplied values are matched. 176 | 177 | ```js 178 | MyCollection.query({ colors: {$all:["red", "yellow"] } }); 179 | // Returns all models which have "red" and "yellow" in their colors attribute. 180 | // A model with the attribute colors:["red","yellow","blue"] would be returned 181 | // But a model with the attribute colors:["red","blue"] would not be returned 182 | ``` 183 | 184 | ### $any 185 | Assumes the model property is an array and returns models where any of the supplied values are matched. 186 | 187 | ```js 188 | MyCollection.query({ colors: {$any:["red", "yellow"] } }); 189 | // Returns models which have either "red" or "yellow" in their colors attribute. 190 | ``` 191 | 192 | ### $size 193 | Assumes the model property has a length (i.e. is either an array or a string). 194 | Only returns models the model property's length matches the supplied values 195 | 196 | ```js 197 | MyCollection.query({ colors: {$size:2 } }); 198 | // Returns all models which 2 values in the colors attribute 199 | ``` 200 | 201 | ### $exists or $has 202 | Checks for the existence of an attribute. Can be supplied either true or false. 203 | 204 | ```js 205 | MyCollection.query({ title: {$exists: true } }); 206 | // Returns all models which have a "title" attribute 207 | MyCollection.query({ title: {$has: false } }); 208 | // Returns all models which don't have a "title" attribute 209 | ``` 210 | 211 | ### $like 212 | Assumes the model attribute is a string and checks if the supplied query value is a substring of the property. 213 | Uses indexOf rather than regex for performance reasons 214 | 215 | ```js 216 | MyCollection.query({ title: {$like: "Test" } }); 217 | //Returns all models which have a "title" attribute that 218 | //contains the string "Test", e.g. "Testing", "Tests", "Test", etc. 219 | ``` 220 | 221 | ### $likeI 222 | The same as above but performs a case insensitive search using indexOf and toLowerCase (still faster than Regex) 223 | 224 | ```js 225 | MyCollection.query({ title: {$likeI: "Test" } }); 226 | //Returns all models which have a "title" attribute that 227 | //contains the string "Test", "test", "tEst","tesT", etc. 228 | ``` 229 | 230 | ### $regex 231 | Checks if the model attribute matches the supplied regular expression. The regex query can be supplied without the `$regex` keyword 232 | 233 | ```js 234 | MyCollection.query({ content: {$regex: /coffeescript/gi } }); 235 | // Checks for a regex match in the content attribute 236 | MyCollection.query({ content: /coffeescript/gi }); 237 | // Same as above 238 | ``` 239 | 240 | ### $cb 241 | A callback function can be supplied as a test. The callback will receive the attribute and should return either true or false. 242 | `this` will be set to the current model, this can help with tests against computed properties 243 | 244 | ```js 245 | MyCollection.query({ title: {$cb: function(attr){ return attr.charAt(0) === "c";}} }); 246 | // Returns all models that have a title attribute that starts with "c" 247 | 248 | MyCollection.query({ computed_test: {$cb: function(){ return this.computed_property() > 10;}} }); 249 | // Returns all models where the computed_property method returns a value greater than 10. 250 | ``` 251 | 252 | For callbacks that use `this` rather than the model attribute, the key name supplied is arbitrary and has no 253 | effect on the results. If the only test you were performing was like the above test it would make more sense 254 | to simply use `MyCollection.filter`. However if you are performing other tests or are using the paging / sorting / 255 | caching options of backbone query, then this functionality is useful. 256 | 257 | ### $elemMatch 258 | This operator allows you to perform queries in nested arrays similar to [MongoDB](http://www.mongodb.org/display/DOCS/Advanced+Queries#AdvancedQueries-%24elemMatch) 259 | For example you may have a collection of models in with this kind of data stucture: 260 | 261 | ```js 262 | var Posts = new QueryCollection([ 263 | {title: "Home", comments:[ 264 | {text:"I like this post"}, 265 | {text:"I love this post"}, 266 | {text:"I hate this post"} 267 | ]}, 268 | {title: "About", comments:[ 269 | {text:"I like this page"}, 270 | {text:"I love this page"}, 271 | {text:"I really like this page"} 272 | ]} 273 | ]); 274 | ``` 275 | To search for posts which have the text "really" in any of the comments you could search like this: 276 | 277 | ```js 278 | Posts.query({ 279 | comments: { 280 | $elemMatch: { 281 | text: /really/i 282 | } 283 | } 284 | }); 285 | ``` 286 | 287 | All of the operators above can be performed on `$elemMatch` queries, e.g. `$all`, `$size` or `$lt`. 288 | `$elemMatch` queries also accept compound operators, for example this query searches for all posts that 289 | have at least one comment without the word "really" and with the word "totally". 290 | ```js 291 | Posts.query({ 292 | comments: { 293 | $elemMatch: { 294 | $not: { 295 | text: /really/i 296 | }, 297 | $and: { 298 | text: /totally/i 299 | } 300 | } 301 | } 302 | }); 303 | ``` 304 | 305 | 306 | ### $computed 307 | This operator allows you to perform queries on computed properties. For example you may want to perform a query 308 | for a persons full name, even though the first and last name are stored separately in your db / model. 309 | For example 310 | 311 | ```js 312 | testModel = Backbone.Model.extend({ 313 | full_name: function() { 314 | return (this.get('first_name')) + " " + (this.get('last_name')); 315 | } 316 | }); 317 | 318 | a = new testModel({ 319 | first_name: "Dave", 320 | last_name: "Tonge" 321 | }); 322 | 323 | b = new testModel({ 324 | first_name: "John", 325 | last_name: "Smith" 326 | }); 327 | 328 | MyCollection = new QueryCollection([a, b]); 329 | 330 | MyCollection.query({ 331 | full_name: { $computed: "Dave Tonge" } 332 | }); 333 | // Returns the model with the computed `full_name` equal to Dave Tonge 334 | 335 | MyCollection.query({ 336 | full_name: { $computed: { $likeI: "john smi" } } 337 | }); 338 | // Any of the previous operators can be used (including elemMatch is required) 339 | ``` 340 | 341 | 342 | Combined Queries 343 | ================ 344 | 345 | Multiple queries can be combined together. By default all supplied queries use the `$and` operator. However it is possible 346 | to specify either `$or`, `$nor`, `$not` to implement alternate logic. 347 | 348 | ### $and 349 | 350 | ```js 351 | MyCollection.query({ $and: { title: {$like: "News"}, likes: {$gt: 10}}}); 352 | // Returns all models that contain "News" in the title and have more than 10 likes. 353 | MyCollection.query({ title: {$like: "News"}, likes: {$gt: 10} }); 354 | // Same as above as $and is assumed if not supplied 355 | ``` 356 | 357 | ### $or 358 | 359 | ```js 360 | MyCollection.query({ $or: { title: {$like: "News"}, likes: {$gt: 10}}}); 361 | // Returns all models that contain "News" in the title OR have more than 10 likes. 362 | ``` 363 | 364 | ### $nor 365 | The opposite of `$or` 366 | 367 | ```js 368 | MyCollection.query({ $nor: { title: {$like: "News"}, likes: {$gt: 10}}}); 369 | // Returns all models that don't contain "News" in the title NOR have more than 10 likes. 370 | ``` 371 | 372 | ### $not 373 | The opposite of `$and` 374 | 375 | ```js 376 | MyCollection.query({ $not: { title: {$like: "News"}, likes: {$gt: 10}}}); 377 | // Returns all models that don't contain "News" in the title AND DON'T have more than 10 likes. 378 | ``` 379 | 380 | If you need to perform multiple queries on the same key, then you can supply the query as an array: 381 | ```js 382 | MyCollection.query({ 383 | $or:[ 384 | {title:"News"}, 385 | {title:"About"} 386 | ] 387 | }); 388 | // Returns all models with the title "News" or "About". 389 | ``` 390 | 391 | 392 | Compound Queries 393 | ================ 394 | 395 | It is possible to use multiple combined queries, for example searching for models that have a specific title attribute, 396 | and either a category of "abc" or a tag of "xyz" 397 | 398 | ```js 399 | MyCollection.query({ 400 | $and: { title: {$like: "News"}}, 401 | $or: {likes: {$gt: 10}, color:{$contains:"red"}} 402 | }); 403 | //Returns models that have "News" in their title and 404 | //either have more than 10 likes or contain the color red. 405 | ``` 406 | 407 | Sorting 408 | ======= 409 | Optional `sortBy` and `order` attributes can be supplied as part of an options object. 410 | `sortBy` can either be a model key or a callback function which will be called with each model in the array. 411 | 412 | ```js 413 | MyCollection.query({title: {$like: "News"}}, {sortBy: "likes"}); 414 | // Returns all models that contain "News" in the title, 415 | // sorted according to their "likes" attribute (ascending) 416 | 417 | MyCollection.query({title: {$like: "News"}}, {sortBy: "likes", order:"desc"}); 418 | // Same as above, but "descending" 419 | MyCollection.query( 420 | {title: {$like: "News"}}, 421 | {sortBy: function(model){ return model.get("title").charAt(1);}} 422 | ); 423 | // Results sorted according to 2nd character of the title attribute 424 | ``` 425 | 426 | 427 | Paging 428 | ====== 429 | To return only a subset of the results paging properties can be supplied as part of an options object. 430 | A `limit` property must be supplied and optionally a `offset` or a `page` property can be supplied. 431 | 432 | ```js 433 | MyCollection.query({likes:{$gt:10}}, {limit:10}); 434 | // Returns the first 10 models that have more than 10 likes 435 | 436 | MyCollection.query({likes:{$gt:10}}, {limit:10, offset:5}); 437 | // Returns 10 models that have more than 10 likes starting 438 | //at the 6th model in the results 439 | 440 | MyCollection.query({likes:{$gt:10}}, {limit:10, page:2}); 441 | // Returns 10 models that have more than 10 likes starting 442 | //at the 11th model in the results (page 2) 443 | ``` 444 | 445 | When using the paging functionality, you will normally need to know the number of pages so that you can render 446 | the correct interface for the user. Backbone Query can send the number of pages of results to a supplied callback. 447 | The callback should be passed as a `pager` property on the options object. This callback will also receive the sliced 448 | models as a second variable. 449 | 450 | Here is a coffeescript example of a simple paging setup using the pager callback option: 451 | 452 | ```coffeescript 453 | class MyView extends Backbone.View 454 | initialize: -> 455 | @template = -> #templating setup here 456 | 457 | events: 458 | "click .page": "change_page" 459 | 460 | query_collection: (page = 1) -> 461 | #Collection should be passed in when the view is instantiated 462 | @collection.query {category:"javascript"}, {limit:5, page:page, pager:@render_pages} 463 | 464 | change_page: (e) => 465 | page_number = $(e.target).data('page_number') 466 | @query_collection page_number 467 | 468 | render_pages: (total_pages, results) => 469 | content = @template results 470 | pages = [1..total_pages] 471 | nav = """ 472 | " 479 | 480 | @$el.html content + nav 481 | 482 | render: => @query_collection() 483 | 484 | ``` 485 | 486 | 487 | Caching Results 488 | ================ 489 | To enable caching set the cache flag to true in the options object. This can greatly improve performance when paging 490 | through results as the unpaged results will be saved. This options is not enabled by default as if models are changed, 491 | added to, or removed from the collection, then the query cache will be out of date. If you know 492 | that your data is static and won't change then caching can be enabled without any problems. 493 | If your data is dynamic (as in most Backbone Apps) then a helper cache reset method is provided: 494 | `reset_query_cache`. This method should be bound to your collections change, add and remove events 495 | (depending on how your data can be changed). 496 | 497 | Cache will be saved in a `_query_cache` property on each collection where a cache query is performed. 498 | 499 | ```js 500 | MyCollection.query({likes:{$gt:10}}, {limit:10, page:1, cache:true}); 501 | //The first query will operate as normal and return the first page of results 502 | MyCollection.query({likes:{$gt:10}}, {limit:10, page:2, cache:true}); 503 | //The second query has an identical query object to the first query, so therefore the results will be retrieved 504 | //from the cache, before the paging paramaters are applied. 505 | 506 | // Binding the reset_query_cache method 507 | var MyCollection = Backbone.QueryCollection.extend({ 508 | initialize: function(){ 509 | this.bind("change", this.reset_query_cache, this); 510 | } 511 | }); 512 | 513 | 514 | ``` 515 | 516 | 517 | Contributors 518 | =========== 519 | 520 | Dave Tonge - [davidgtonge](http://github.com/davidgtonge) 521 | Rob W - [Rob W](https://github.com/Rob--W) 522 | Cezary Wojtkowski - [cezary](https://github.com/cezary) 523 | -------------------------------------------------------------------------------- /backbone-query.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.3.3 2 | /* 3 | Backbone Query - A lightweight query API for Backbone Collections 4 | (c)2012 - Dave Tonge 5 | May be freely distributed according to MIT license. 6 | */ 7 | 8 | var __slice = [].slice, 9 | __hasProp = {}.hasOwnProperty, 10 | __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; 11 | 12 | (function(define) { 13 | return define('backbone-query', function(require, exports) { 14 | var Backbone, detect, filter, getCache, getSortedModels, getType, iterator, makeObj, pageModels, parseQuery, parseSubQuery, performQuery, processQuery, reject, runQuery, sortModels, testModelAttribute, testQueryValue, _; 15 | _ = require('underscore'); 16 | Backbone = require('backbone'); 17 | /* UTILS 18 | */ 19 | 20 | filter = function(array, test) { 21 | var val, _i, _len, _results; 22 | _results = []; 23 | for (_i = 0, _len = array.length; _i < _len; _i++) { 24 | val = array[_i]; 25 | if (test(val)) { 26 | _results.push(val); 27 | } 28 | } 29 | return _results; 30 | }; 31 | reject = function(array, test) { 32 | var val, _i, _len, _results; 33 | _results = []; 34 | for (_i = 0, _len = array.length; _i < _len; _i++) { 35 | val = array[_i]; 36 | if (!test(val)) { 37 | _results.push(val); 38 | } 39 | } 40 | return _results; 41 | }; 42 | detect = function(array, test) { 43 | var val, _i, _len; 44 | for (_i = 0, _len = array.length; _i < _len; _i++) { 45 | val = array[_i]; 46 | if (test(val)) { 47 | return true; 48 | } 49 | } 50 | return false; 51 | }; 52 | makeObj = function() { 53 | var args, current, key, o, val; 54 | args = 1 <= arguments.length ? __slice.call(arguments, 0) : []; 55 | o = {}; 56 | current = o; 57 | while (args.length) { 58 | key = args.shift(); 59 | val = (args.length === 1 ? args.shift() : {}); 60 | current = current[key] = val; 61 | } 62 | return o; 63 | }; 64 | getType = function(item) { 65 | if (_.isRegExp(item)) { 66 | return "$regex"; 67 | } 68 | if (_.isDate(item)) { 69 | return "$date"; 70 | } 71 | if (_.isObject(item) && !_.isArray(item)) { 72 | return "object"; 73 | } 74 | if (_.isArray(item)) { 75 | return "array"; 76 | } 77 | if (_.isString(item)) { 78 | return "string"; 79 | } 80 | if (_.isNumber(item)) { 81 | return "number"; 82 | } 83 | if (_.isBoolean(item)) { 84 | return "boolean"; 85 | } 86 | if (_.isFunction(item)) { 87 | return "function"; 88 | } 89 | return false; 90 | }; 91 | /* 92 | Function to parse raw queries 93 | @param {mixed} raw query 94 | @return {array} parsed query 95 | 96 | Allows queries of the following forms: 97 | query 98 | name: "test" 99 | id: $gte: 10 100 | 101 | query [ 102 | {name:"test"} 103 | {id:$gte:10} 104 | ] 105 | */ 106 | 107 | parseSubQuery = function(rawQuery) { 108 | var key, o, paramType, q, query, queryArray, queryParam, type, val, value, _i, _len, _results; 109 | if (_.isArray(rawQuery)) { 110 | queryArray = rawQuery; 111 | } else { 112 | queryArray = (function() { 113 | var _results; 114 | _results = []; 115 | for (key in rawQuery) { 116 | if (!__hasProp.call(rawQuery, key)) continue; 117 | val = rawQuery[key]; 118 | _results.push(makeObj(key, val)); 119 | } 120 | return _results; 121 | })(); 122 | } 123 | _results = []; 124 | for (_i = 0, _len = queryArray.length; _i < _len; _i++) { 125 | query = queryArray[_i]; 126 | for (key in query) { 127 | if (!__hasProp.call(query, key)) continue; 128 | queryParam = query[key]; 129 | o = { 130 | key: key 131 | }; 132 | paramType = getType(queryParam); 133 | switch (paramType) { 134 | case "$regex": 135 | case "$date": 136 | o.type = paramType; 137 | o.value = queryParam; 138 | break; 139 | case "object": 140 | if (key === "$and" || key === "$or" || key === "$nor" || key === "$not") { 141 | o.value = parseSubQuery(queryParam); 142 | o.type = key; 143 | o.key = null; 144 | } else { 145 | for (type in queryParam) { 146 | value = queryParam[type]; 147 | if (testQueryValue(type, value)) { 148 | o.type = type; 149 | switch (type) { 150 | case "$elemMatch": 151 | case "$relationMatch": 152 | o.value = parseQuery(value); 153 | break; 154 | case "$computed": 155 | q = makeObj(key, value); 156 | o.value = parseSubQuery(q); 157 | break; 158 | default: 159 | o.value = value; 160 | } 161 | } 162 | } 163 | } 164 | break; 165 | default: 166 | o.type = "$equal"; 167 | o.value = queryParam; 168 | } 169 | if ((o.type === "$equal") && (paramType === "object" || paramType === "array")) { 170 | o.type = "$oEqual"; 171 | } 172 | } 173 | _results.push(o); 174 | } 175 | return _results; 176 | }; 177 | testQueryValue = function(type, value) { 178 | switch (type) { 179 | case "$in": 180 | case "$nin": 181 | case "$all": 182 | case "$any": 183 | return _(value).isArray(); 184 | case "$size": 185 | return _(value).isNumber(); 186 | case "$regex": 187 | return _(value).isRegExp(); 188 | case "$like": 189 | case "$likeI": 190 | return _(value).isString(); 191 | case "$between": 192 | return _(value).isArray() && (value.length === 2); 193 | case "$cb": 194 | return _(value).isFunction(); 195 | default: 196 | return true; 197 | } 198 | }; 199 | testModelAttribute = function(type, value) { 200 | switch (type) { 201 | case "$like": 202 | case "$likeI": 203 | case "$regex": 204 | return _(value).isString(); 205 | case "$contains": 206 | case "$all": 207 | case "$any": 208 | case "$elemMatch": 209 | return _(value).isArray(); 210 | case "$size": 211 | return _(value).isArray() || _(value).isString(); 212 | case "$in": 213 | case "$nin": 214 | return value != null; 215 | case "$relationMatch": 216 | return (value != null) && value.models; 217 | default: 218 | return true; 219 | } 220 | }; 221 | performQuery = function(type, value, attr, model, key) { 222 | switch (type) { 223 | case "$equal": 224 | if (_(attr).isArray()) { 225 | return __indexOf.call(attr, value) >= 0; 226 | } else { 227 | return attr === value; 228 | } 229 | break; 230 | case "$oEqual": 231 | return _(attr).isEqual(value); 232 | case "$contains": 233 | return __indexOf.call(attr, value) >= 0; 234 | case "$ne": 235 | return attr !== value; 236 | case "$lt": 237 | return attr < value; 238 | case "$gt": 239 | return attr > value; 240 | case "$lte": 241 | return attr <= value; 242 | case "$gte": 243 | return attr >= value; 244 | case "$between": 245 | return (value[0] < attr && attr < value[1]); 246 | case "$in": 247 | return __indexOf.call(value, attr) >= 0; 248 | case "$nin": 249 | return __indexOf.call(value, attr) < 0; 250 | case "$all": 251 | return _(value).all(function(item) { 252 | return __indexOf.call(attr, item) >= 0; 253 | }); 254 | case "$any": 255 | return _(attr).any(function(item) { 256 | return __indexOf.call(value, item) >= 0; 257 | }); 258 | case "$size": 259 | return attr.length === value; 260 | case "$exists": 261 | case "$has": 262 | return (attr != null) === value; 263 | case "$like": 264 | return attr.indexOf(value) !== -1; 265 | case "$likeI": 266 | return attr.toLowerCase().indexOf(value.toLowerCase()) !== -1; 267 | case "$regex": 268 | return value.test(attr); 269 | case "$cb": 270 | return value.call(model, attr); 271 | case "$elemMatch": 272 | return (runQuery(attr, value, "elemMatch")).length > 0; 273 | case "$relationMatch": 274 | return (runQuery(attr.models, value, "relationMatch")).length > 0; 275 | case "$computed": 276 | return iterator([model], value, false, detect, "computed"); 277 | case "$and": 278 | case "$or": 279 | case "$nor": 280 | case "$not": 281 | return (processQuery[type]([model], value)).length === 1; 282 | default: 283 | return false; 284 | } 285 | }; 286 | iterator = function(models, query, andOr, filterFunction, itemType) { 287 | if (itemType == null) { 288 | itemType = false; 289 | } 290 | return filterFunction(models, function(model) { 291 | var attr, q, test, _i, _len; 292 | for (_i = 0, _len = query.length; _i < _len; _i++) { 293 | q = query[_i]; 294 | attr = (function() { 295 | switch (itemType) { 296 | case "elemMatch": 297 | return model[q.key]; 298 | case "computed": 299 | return model[q.key](); 300 | default: 301 | return model.get(q.key); 302 | } 303 | })(); 304 | test = testModelAttribute(q.type, attr); 305 | if (test) { 306 | test = performQuery(q.type, q.value, attr, model, q.key); 307 | } 308 | if (andOr === test) { 309 | return andOr; 310 | } 311 | } 312 | return !andOr; 313 | }); 314 | }; 315 | processQuery = { 316 | $and: function(models, query, itemType) { 317 | return iterator(models, query, false, filter, itemType); 318 | }, 319 | $or: function(models, query, itemType) { 320 | return iterator(models, query, true, filter, itemType); 321 | }, 322 | $nor: function(models, query, itemType) { 323 | return iterator(models, query, true, reject, itemType); 324 | }, 325 | $not: function(models, query, itemType) { 326 | return iterator(models, query, false, reject, itemType); 327 | } 328 | }; 329 | parseQuery = function(query) { 330 | var compoundKeys, compoundQuery, key, queryKeys, type, val; 331 | queryKeys = _(query).keys(); 332 | compoundKeys = ["$and", "$not", "$or", "$nor"]; 333 | compoundQuery = _.intersection(compoundKeys, queryKeys); 334 | if (compoundQuery.length === 0) { 335 | return [ 336 | { 337 | type: "$and", 338 | parsedQuery: parseSubQuery(query) 339 | } 340 | ]; 341 | } else { 342 | if (compoundQuery.length !== queryKeys.length) { 343 | if (__indexOf.call(compoundQuery, "$and") < 0) { 344 | query.$and = {}; 345 | compoundQuery.unshift("$and"); 346 | } 347 | for (key in query) { 348 | if (!__hasProp.call(query, key)) continue; 349 | val = query[key]; 350 | if (!(__indexOf.call(compoundKeys, key) < 0)) { 351 | continue; 352 | } 353 | query.$and[key] = val; 354 | delete query[key]; 355 | } 356 | } 357 | return (function() { 358 | var _i, _len, _results; 359 | _results = []; 360 | for (_i = 0, _len = compoundQuery.length; _i < _len; _i++) { 361 | type = compoundQuery[_i]; 362 | _results.push({ 363 | type: type, 364 | parsedQuery: parseSubQuery(query[type]) 365 | }); 366 | } 367 | return _results; 368 | })(); 369 | } 370 | }; 371 | runQuery = function(items, query, itemType) { 372 | var reduceIterator; 373 | if (!itemType) { 374 | query = parseQuery(query); 375 | } 376 | reduceIterator = function(memo, queryItem) { 377 | return processQuery[queryItem.type](memo, queryItem.parsedQuery, itemType); 378 | }; 379 | return _.reduce(query, reduceIterator, items); 380 | }; 381 | getCache = function(collection, query, options) { 382 | var cache, models, queryString, _ref; 383 | queryString = JSON.stringify(query); 384 | cache = (_ref = collection._queryCache) != null ? _ref : collection._queryCache = {}; 385 | models = cache[queryString]; 386 | if (!models) { 387 | models = getSortedModels(collection, query, options); 388 | cache[queryString] = models; 389 | } 390 | return models; 391 | }; 392 | getSortedModels = function(collection, query, options) { 393 | var models; 394 | models = runQuery(collection.models, query); 395 | if (options.sortBy) { 396 | models = sortModels(models, options); 397 | } 398 | return models; 399 | }; 400 | sortModels = function(models, options) { 401 | if (_(options.sortBy).isString()) { 402 | models = _(models).sortBy(function(model) { 403 | return model.get(options.sortBy); 404 | }); 405 | } else if (_(options.sortBy).isFunction()) { 406 | models = _(models).sortBy(options.sortBy); 407 | } 408 | if (options.order === "desc") { 409 | models = models.reverse(); 410 | } 411 | return models; 412 | }; 413 | pageModels = function(models, options) { 414 | var end, sliced_models, start, total_pages; 415 | if (options.offset) { 416 | start = options.offset; 417 | } else if (options.page) { 418 | start = (options.page - 1) * options.limit; 419 | } else { 420 | start = 0; 421 | } 422 | end = start + options.limit; 423 | sliced_models = models.slice(start, end); 424 | if (options.pager && _.isFunction(options.pager)) { 425 | total_pages = Math.ceil(models.length / options.limit); 426 | options.pager(total_pages, sliced_models); 427 | } 428 | return sliced_models; 429 | }; 430 | Backbone.QueryCollection = Backbone.Collection.extend({ 431 | query: function(query, options) { 432 | var models; 433 | if (options == null) { 434 | options = {}; 435 | } 436 | if (options.cache) { 437 | models = getCache(this, query, options); 438 | } else { 439 | models = getSortedModels(this, query, options); 440 | } 441 | if (options.limit) { 442 | models = pageModels(models, options); 443 | } 444 | return models; 445 | }, 446 | findOne: function(query) { 447 | return this.query(query)[0]; 448 | }, 449 | whereBy: function(params, options) { 450 | if (options == null) { 451 | options = {}; 452 | } 453 | return new this.constructor(this.query(params, options)); 454 | }, 455 | resetQueryCache: function() { 456 | return this._queryCache = {}; 457 | } 458 | }); 459 | return exports.QueryCollection = Backbone.QueryCollection; 460 | }); 461 | }).call(this, typeof define === 'function' && define.amd ? define : function(id, factory) { 462 | if (typeof exports !== 'undefined') { 463 | factory((function(id) { 464 | return require(id); 465 | }), exports); 466 | } else { 467 | factory((function(id) { 468 | return this[id === 'underscore' ? '_' : 'Backbone']; 469 | }), {}); 470 | } 471 | }); 472 | -------------------------------------------------------------------------------- /backbone-query.min.js: -------------------------------------------------------------------------------- 1 | var __slice=[].slice,__hasProp={}.hasOwnProperty,__indexOf=[].indexOf||function(a){for(var b=0,c=this.length;b=0:c===b;case"$oEqual":return u(c).isEqual(b);case"$contains":return __indexOf.call(c,b)>=0;case"$ne":return c!==b;case"$lt":return cb;case"$lte":return c<=b;case"$gte":return c>=b;case"$between":return b[0]=0;case"$nin":return __indexOf.call(b,c)<0;case"$all":return u(b).all(function(a){return __indexOf.call(c,a)>=0});case"$any":return u(c).any(function(a){return __indexOf.call(b,a)>=0});case"$size":return c.length===b;case"$exists":case"$has":return c!=null===b;case"$like":return c.indexOf(b)!==-1;case"$likeI":return c.toLowerCase().indexOf(b.toLowerCase())!==-1;case"$regex":return b.test(c);case"$cb":return b.call(e,c);case"$elemMatch":return q(c,b,"elemMatch").length>0;case"$relationMatch":return q(c.models,b,"relationMatch").length>0;case"$computed":return i([e],b,!1,d,"computed");case"$and":case"$or":case"$nor":case"$not":return o[a]([e],b).length===1;default:return!1}},i=function(a,b,c,d,e){return e==null&&(e=!1),d(a,function(a){var d,f,g,h,i;for(h=0,i=b.length;h (https://github.com/davidgtonge)", 6 | "tags": [ 7 | "backbone", 8 | "underscore", 9 | "mongo", 10 | "query" 11 | ], 12 | "main": "./backbone-query", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://davidgtonge@github.com/davidgtonge/backbone_query.git" 16 | }, 17 | "dependencies": { 18 | "backbone": ">=0.5.x", 19 | "underscore": ">=1.1.x" 20 | }, 21 | "devDependencies": { 22 | "coffee-script": ">=1.x.x", 23 | "mocha": "", 24 | "uglify-js": "1.2.2" 25 | }, 26 | "jam": { 27 | "dependencies": { 28 | "underscore": "*", 29 | "backbone": "*", 30 | "jquery": "*" 31 | }, 32 | "include": ["backbone-query.js"], 33 | 34 | "main": "backbone-query.js", 35 | 36 | "shim": { 37 | "deps": ["jquery", "backbone", "underscore"], 38 | "exports": "Backbone.QueryCollection" 39 | } 40 | }, 41 | "scripts": { 42 | "test": "mocha" 43 | } 44 | } -------------------------------------------------------------------------------- /src/backbone-query.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | Backbone Query - A lightweight query API for Backbone Collections 3 | (c)2012 - Dave Tonge 4 | May be freely distributed according to MIT license. 5 | ### 6 | ((define) -> define 'backbone-query', (require, exports) -> 7 | _ = require('underscore') 8 | Backbone = require('backbone') 9 | 10 | ### UTILS ### 11 | 12 | # Custom Filter / Reject methods faster than underscore methods as use for loops 13 | # http://jsperf.com/filter-vs-for-loop2 14 | filter = (array, test) -> (val for val in array when test val) 15 | reject = (array, test) -> (val for val in array when not test val) 16 | detect = (array, test) -> 17 | for val in array 18 | return true if test val 19 | false 20 | 21 | # Utility Function to turn a list of values into an object 22 | makeObj = (args...)-> 23 | o = {} 24 | current = o 25 | while args.length 26 | key = args.shift() 27 | val = (if args.length is 1 then args.shift() else {}) 28 | current = current[key] = val 29 | o 30 | 31 | # Get the type as a string 32 | getType = (item) -> 33 | return "$regex" if _.isRegExp(item) 34 | return "$date" if _.isDate(item) 35 | return "object" if _.isObject(item) and not _.isArray(item) 36 | return "array" if _.isArray(item) 37 | return "string" if _.isString(item) 38 | return "number" if _.isNumber(item) 39 | return "boolean" if _.isBoolean(item) 40 | return "function" if _.isFunction(item) 41 | false 42 | 43 | ### 44 | Function to parse raw queries 45 | @param {mixed} raw query 46 | @return {array} parsed query 47 | 48 | Allows queries of the following forms: 49 | query 50 | name: "test" 51 | id: $gte: 10 52 | 53 | query [ 54 | {name:"test"} 55 | {id:$gte:10} 56 | ] 57 | ### 58 | parseSubQuery = (rawQuery) -> 59 | 60 | if _.isArray(rawQuery) 61 | queryArray = rawQuery 62 | else 63 | queryArray = (makeObj(key, val) for own key, val of rawQuery) 64 | 65 | (for query in queryArray 66 | for own key, queryParam of query 67 | o = {key} 68 | paramType = getType(queryParam) 69 | switch paramType 70 | # Test for Regexs and Dates as they can be supplied without an operator 71 | when "$regex", "$date" 72 | o.type = paramType 73 | o.value = queryParam 74 | 75 | # If the query paramater is an object then extract the key and value 76 | when "object" 77 | if key in ["$and", "$or", "$nor", "$not"] 78 | o.value = parseSubQuery queryParam 79 | o.type = key 80 | o.key = null 81 | else 82 | for type, value of queryParam 83 | # Before adding the query, its value is checked to make sure it is the right type 84 | if testQueryValue type, value 85 | o.type = type 86 | switch type 87 | when "$elemMatch", "$relationMatch" 88 | o.value = parseQuery value 89 | when "$computed" 90 | q = makeObj(key,value) 91 | o.value = parseSubQuery q 92 | else 93 | o.value = value 94 | 95 | # If the query_param is not an object or a regexp then revert to the default operator: $equal 96 | else 97 | o.type = "$equal" 98 | o.value = queryParam 99 | 100 | # For "$equal" queries with arrays or objects we need to perform a deep equal 101 | if (o.type is "$equal") and (paramType in ["object","array"]) 102 | o.type = "$oEqual" 103 | o) 104 | 105 | 106 | 107 | # Tests query value, to ensure that it is of the correct type 108 | testQueryValue = (type, value) -> 109 | switch type 110 | when "$in","$nin","$all", "$any" then _(value).isArray() 111 | when "$size" then _(value).isNumber() 112 | when "$regex" then _(value).isRegExp() 113 | when "$like", "$likeI" then _(value).isString() 114 | when "$between" then _(value).isArray() and (value.length is 2) 115 | when "$cb" then _(value).isFunction() 116 | else true 117 | 118 | # Test each attribute that is being tested to ensure that is of the correct type 119 | testModelAttribute = (type, value) -> 120 | switch type 121 | when "$like", "$likeI", "$regex" then _(value).isString() 122 | when "$contains", "$all", "$any", "$elemMatch" then _(value).isArray() 123 | when "$size" then _(value).isArray() or _(value).isString() 124 | when "$in", "$nin" then value? 125 | when "$relationMatch" then value? and value.models 126 | else true 127 | 128 | # Perform the actual query logic for each query and each model/attribute 129 | performQuery = (type, value, attr, model, key) -> 130 | switch type 131 | when "$equal" 132 | # If the attribute is an array then search for the query value in the array the same as Mongo 133 | if _(attr).isArray() then value in attr else attr is value 134 | when "$oEqual" then _(attr).isEqual value 135 | when "$contains" then value in attr 136 | when "$ne" then attr isnt value 137 | when "$lt" then attr < value 138 | when "$gt" then attr > value 139 | when "$lte" then attr <= value 140 | when "$gte" then attr >= value 141 | when "$between" then value[0] < attr < value[1] 142 | when "$in" then attr in value 143 | when "$nin" then attr not in value 144 | when "$all" then _(value).all (item) -> item in attr 145 | when "$any" then _(attr).any (item) -> item in value 146 | when "$size" then attr.length is value 147 | when "$exists", "$has" then attr? is value 148 | when "$like" then attr.indexOf(value) isnt -1 149 | when "$likeI" then attr.toLowerCase().indexOf(value.toLowerCase()) isnt -1 150 | when "$regex" then value.test attr 151 | when "$cb" then value.call model, attr 152 | when "$elemMatch" then (runQuery(attr,value,"elemMatch")).length > 0 153 | when "$relationMatch" then (runQuery(attr.models,value,"relationMatch")).length > 0 154 | when "$computed" then iterator [model], value, false, detect, "computed" 155 | when "$and", "$or", "$nor", "$not" 156 | (processQuery[type]([model], value)).length is 1 157 | else false 158 | 159 | 160 | # The main iterator that actually applies the query 161 | iterator = (models, query, andOr, filterFunction, itemType = false) -> 162 | # The collections filter or reject method is used to iterate through each model in the collection 163 | filterFunction models, (model) -> 164 | # For each model in the collection, iterate through the supplied queries 165 | for q in query 166 | # Retrieve the attribute value from the model 167 | attr = switch itemType 168 | when "elemMatch" then model[q.key] 169 | when "computed" then model[q.key]() 170 | else model.get(q.key) 171 | # Check if the attribute value is the right type (some operators need a string, or an array) 172 | test = testModelAttribute(q.type, attr) 173 | # If the attribute test is true, perform the query 174 | if test then test = performQuery q.type, q.value, attr, model, q.key 175 | # If the query is an "or" query than as soon as a match is found we return "true" 176 | # Whereas if the query is an "and" query then we return "false" as soon as a match isn't found. 177 | return andOr if andOr is test 178 | 179 | # For an "or" query, if all the queries are false, then we return false 180 | # For an "and" query, if all the queries are true, then we return true 181 | not andOr 182 | 183 | 184 | 185 | # An object with or, and, nor and not methods 186 | processQuery = 187 | $and: (models, query, itemType) -> iterator models, query, false, filter, itemType 188 | $or: (models, query, itemType) -> iterator models, query, true, filter, itemType 189 | $nor: (models, query, itemType) -> iterator models, query, true, reject, itemType 190 | $not: (models, query, itemType) -> iterator models, query, false, reject, itemType 191 | 192 | parseQuery = (query) -> 193 | queryKeys = _(query).keys() 194 | compoundKeys = ["$and", "$not", "$or", "$nor"] 195 | compoundQuery = _.intersection compoundKeys, queryKeys 196 | 197 | # If no compound methods are found then use the "and" iterator 198 | if compoundQuery.length is 0 199 | return [{type:"$and", parsedQuery:parseSubQuery(query)}] 200 | else 201 | # Detect if there is an implicit $and compundQuery operator 202 | if compoundQuery.length isnt queryKeys.length 203 | # Add the and compund query operator (with a sanity check that it doesn't exist) 204 | if "$and" not in compoundQuery 205 | query.$and = {} 206 | compoundQuery.unshift "$and" 207 | for own key, val of query when key not in compoundKeys 208 | query.$and[key] = val 209 | delete query[key] 210 | return (for type in compoundQuery 211 | {type, parsedQuery:parseSubQuery(query[type])}) 212 | 213 | 214 | runQuery = (items, query, itemType) -> 215 | query = parseQuery(query) unless itemType 216 | reduceIterator = (memo, queryItem) -> 217 | processQuery[queryItem.type] memo, queryItem.parsedQuery, itemType 218 | _.reduce query, reduceIterator, items 219 | 220 | 221 | # This method attempts to retrieve the result from the cache. 222 | # If no match is found in the cache, then the query is run and 223 | # the results are saved in the cache 224 | getCache = (collection, query, options) -> 225 | # Convert the query to a string to use as a key in the cache 226 | queryString = JSON.stringify query 227 | # Create cache if doesn't exist 228 | cache = collection._queryCache ?= {} 229 | # Retrieve cached results 230 | models = cache[queryString] 231 | # If no results are retrieved then use the get_models method and cache the result 232 | unless models 233 | models = getSortedModels collection, query, options 234 | cache[queryString] = models 235 | # Return the results 236 | models 237 | 238 | 239 | # Gets the results and optionally sorts them 240 | getSortedModels = (collection, query, options) -> 241 | models = runQuery(collection.models, query) 242 | if options.sortBy then models = sortModels models, options 243 | models 244 | 245 | # Sorts models either be a model attribute or with a callback 246 | sortModels = (models, options) -> 247 | # If the sortBy param is a string then we sort according to the model attribute with that string as a key 248 | if _(options.sortBy).isString() 249 | models = _(models).sortBy (model) -> model.get(options.sortBy) 250 | # If a function is supplied then it is passed directly to the sortBy iterator 251 | else if _(options.sortBy).isFunction() 252 | models = _(models).sortBy(options.sortBy) 253 | 254 | # If there is an order property of "desc" then the results can be reversed 255 | # (sortBy provides result in ascending order by default) 256 | if options.order is "desc" then models = models.reverse() 257 | # The sorted models are returned 258 | models 259 | 260 | # Slices the results set according to the supplied options 261 | pageModels = (models, options) -> 262 | # Expects object in the form: {limit: num, offset: num, page: num, pager:callback} 263 | if options.offset then start = options.offset 264 | else if options.page then start = (options.page - 1) * options.limit 265 | else start = 0 266 | 267 | end = start + options.limit 268 | 269 | # The results are sliced according to the calculated start and end params 270 | sliced_models = models[start...end] 271 | 272 | if options.pager and _.isFunction(options.pager) 273 | total_pages = Math.ceil (models.length / options.limit) 274 | options.pager total_pages, sliced_models 275 | 276 | sliced_models 277 | 278 | 279 | Backbone.QueryCollection = Backbone.Collection.extend 280 | 281 | # The main query method 282 | query: (query, options = {}) -> 283 | 284 | # Retrieve matching models using the supplied query 285 | if options.cache 286 | models = getCache @, query, options 287 | else 288 | models = getSortedModels @, query, options 289 | 290 | # If a limit param is specified than slice the results 291 | if options.limit then models = pageModels models, options 292 | 293 | # Return the results 294 | models 295 | 296 | findOne: (query) -> @query(query)[0] 297 | 298 | # Where method wraps query and returns a new collection 299 | whereBy: (params, options = {})-> 300 | new @constructor @query params, options 301 | 302 | # Helper method to reset the query cache 303 | # Defined as a separate method to make it easy to bind to collection's change/add/remove events 304 | resetQueryCache: -> @_queryCache = {} 305 | 306 | # On the server the new Query Collection is added to exports 307 | exports.QueryCollection = Backbone.QueryCollection 308 | ).call this, if typeof define == 'function' and define.amd then define else (id, factory) -> 309 | unless typeof exports is 'undefined' 310 | factory ((id) -> require id), exports 311 | else 312 | # Load Underscore and backbone. No need to export QueryCollection in an module-less environment 313 | factory ((id) -> this[if id == 'underscore' then '_' else 'Backbone']), {} 314 | return 315 | 316 | -------------------------------------------------------------------------------- /test/bq-test.coffee: -------------------------------------------------------------------------------- 1 | # Requires 2 | assert = require('assert') 3 | {QueryCollection} = require "../src/backbone-query" 4 | Backbone = require('backbone') 5 | 6 | create = -> 7 | new QueryCollection [ 8 | {title:"Home", colors:["red","yellow","blue"], likes:12, featured:true, content: "Dummy content about coffeescript"} 9 | {title:"About", colors:["red"], likes:2, featured:true, content: "dummy content about javascript"} 10 | {title:"Contact", colors:["red","blue"], likes:20, content: "Dummy content about PHP"} 11 | ] 12 | 13 | 14 | 15 | 16 | 17 | describe "Backbone Query Tests", -> 18 | 19 | it "Equals query", -> 20 | a = create() 21 | result = a.query title:"Home" 22 | assert.equal result.length, 1 23 | assert.equal result[0].get("title"), "Home" 24 | 25 | result = a.whereBy colors: "blue" 26 | assert.equal result.length, 2 27 | 28 | result = a.whereBy colors: ["red", "blue"] 29 | assert.equal result.length, 1 30 | 31 | it "Simple equals query (no results)", -> 32 | a = create() 33 | result = a.whereBy title:"Homes" 34 | assert.equal result.length, 0 35 | 36 | it "Simple equals query with explicit $equal", -> 37 | a = create() 38 | result = a.whereBy title: {$equal: "About"} 39 | assert.equal result.length, 1 40 | assert.equal result.at(0).get("title"), "About" 41 | 42 | it "$contains operator", -> 43 | a = create() 44 | result = a.whereBy colors: {$contains: "blue"} 45 | assert.equal result.length, 2 46 | 47 | it "$ne operator", -> 48 | a = create() 49 | result = a.whereBy title: {$ne: "Home"} 50 | assert.equal result.length, 2 51 | 52 | it "$lt operator", -> 53 | a = create() 54 | result = a.whereBy likes: {$lt: 12} 55 | assert.equal result.length, 1 56 | assert.equal result.at(0).get("title"), "About" 57 | 58 | it "$lte operator", -> 59 | a = create() 60 | result = a.whereBy likes: {$lte: 12} 61 | assert.equal result.length, 2 62 | 63 | it "$gt operator", -> 64 | a = create() 65 | result = a.whereBy likes: {$gt: 12} 66 | assert.equal result.length, 1 67 | assert.equal result.at(0).get("title"), "Contact" 68 | 69 | it "$gte operator", -> 70 | a = create() 71 | result = a.whereBy likes: {$gte: 12} 72 | assert.equal result.length, 2 73 | 74 | it "$between operator", -> 75 | a = create() 76 | result = a.whereBy likes: {$between: [1,5]} 77 | assert.equal result.length, 1 78 | assert.equal result.at(0).get("title"), "About" 79 | 80 | it "$in operator", -> 81 | a = create() 82 | result = a.whereBy title: {$in: ["Home","About"]} 83 | assert.equal result.length, 2 84 | 85 | it "$in operator with wrong query value", -> 86 | a = create() 87 | result = a.whereBy title: {$in: "Home"} 88 | assert.equal result.length, 0 89 | 90 | it "$nin operator", -> 91 | a = create() 92 | result = a.whereBy title: {$nin: ["Home","About"]} 93 | assert.equal result.length, 1 94 | assert.equal result.at(0).get("title"), "Contact" 95 | 96 | it "$all operator", -> 97 | a = create() 98 | result = a.whereBy colors: {$all: ["red","blue"]} 99 | assert.equal result.length, 2 100 | 101 | it "$all operator (wrong values)", -> 102 | a = create() 103 | result = a.whereBy title: {$all: ["red","blue"]} 104 | assert.equal result.length, 0 105 | 106 | result = a.whereBy colors: {$all: "red"} 107 | assert.equal result.length, 0 108 | 109 | it "$any operator", -> 110 | a = create() 111 | result = a.whereBy colors: {$any: ["red","blue"]} 112 | assert.equal result.length, 3 113 | 114 | result = a.whereBy colors: {$any: ["yellow","blue"]} 115 | assert.equal result.length, 2 116 | 117 | it "$size operator", -> 118 | a = create() 119 | result = a.whereBy colors: {$size: 3} 120 | assert.equal result.length, 1 121 | assert.equal result.at(0).get("title"), "Home" 122 | 123 | it "$exists operator", -> 124 | a = create() 125 | result = a.whereBy featured: {$exists: true} 126 | assert.equal result.length, 2 127 | 128 | it "$has operator", -> 129 | a = create() 130 | result = a.whereBy featured: {$exists: false} 131 | assert.equal result.length, 1 132 | assert.equal result.at(0).get("title"), "Contact" 133 | 134 | it "$like operator", -> 135 | a = create() 136 | result = a.whereBy content: {$like: "javascript"} 137 | assert.equal result.length, 1 138 | assert.equal result.at(0).get("title"), "About" 139 | 140 | it "$like operator 2", -> 141 | a = create() 142 | result = a.whereBy content: {$like: "content"} 143 | assert.equal result.length, 3 144 | 145 | it "$likeI operator", -> 146 | a = create() 147 | result = a.whereBy content: {$likeI: "dummy"} 148 | assert.equal result.length, 3 149 | result = a.whereBy content: {$like: "dummy"} 150 | assert.equal result.length, 1 151 | 152 | it "$regex", -> 153 | a = create() 154 | result = a.whereBy content: {$regex: /javascript/gi} 155 | assert.equal result.length, 1 156 | assert.equal result.at(0).get("title"), "About" 157 | 158 | it "$regex2", -> 159 | a = create() 160 | result = a.whereBy content: {$regex: /dummy/} 161 | assert.equal result.length, 1 162 | 163 | it "$regex3", -> 164 | a = create() 165 | result = a.whereBy content: {$regex: /dummy/i} 166 | assert.equal result.length, 3 167 | 168 | it "$regex4", -> 169 | a = create() 170 | result = a.whereBy content: /javascript/i 171 | assert.equal result.length, 1 172 | 173 | it "$cb - callback", -> 174 | a = create() 175 | result = a.whereBy title: {$cb: (attr) -> attr.charAt(0).toLowerCase() is "c"} 176 | assert.equal result.length, 1 177 | assert.equal result.at(0).get("title"), "Contact" 178 | 179 | it "$cb - callback - checking 'this' is the model", -> 180 | a = create() 181 | result = a.whereBy title: 182 | $cb: (attr) -> @get("title") is "Home" 183 | assert.equal result.length, 1 184 | assert.equal result.at(0).get("title"), "Home" 185 | 186 | it "$and operator", -> 187 | a = create() 188 | result = a.whereBy likes: {$gt: 5}, colors: {$contains: "yellow"} 189 | assert.equal result.length, 1 190 | assert.equal result.at(0).get("title"), "Home" 191 | 192 | it "$and operator (explicit)", -> 193 | a = create() 194 | result = a.whereBy $and: {likes: {$gt: 5}, colors: {$contains: "yellow"}} 195 | assert.equal result.length, 1 196 | assert.equal result.at(0).get("title"), "Home" 197 | 198 | it "$or operator", -> 199 | a = create() 200 | result = a.whereBy $or: {likes: {$gt: 5}, colors: {$contains: "yellow"}} 201 | assert.equal result.length, 2 202 | 203 | it "$or2 operator", -> 204 | a = create() 205 | result = a.whereBy $or: {likes: {$gt: 5}, featured: true} 206 | assert.equal result.length, 3 207 | 208 | it "$nor operator", -> 209 | a = create() 210 | result = a.whereBy $nor: {likes: {$gt: 5}, colors: {$contains: "yellow"}} 211 | assert.equal result.length, 1 212 | assert.equal result.at(0).get("title"), "About" 213 | 214 | it "Compound Queries", -> 215 | a = create() 216 | result = a.whereBy $and: {likes: {$gt: 5}}, $or: {content: {$like: "PHP"}, colors: {$contains: "yellow"}} 217 | assert.equal result.length, 2 218 | 219 | result = a.whereBy 220 | $and: 221 | likes: $lt: 15 222 | $or: 223 | content: 224 | $like: "Dummy" 225 | featured: 226 | $exists:true 227 | $not: 228 | colors: $contains: "yellow" 229 | assert.equal result.length, 1 230 | assert.equal result.at(0).get("title"), "About" 231 | 232 | 233 | 234 | it "Limit", -> 235 | a = create() 236 | result = a.whereBy {likes: {$gt: 1}}, {limit:2} 237 | assert.equal result.length, 2 238 | 239 | it "Offset", -> 240 | a = create() 241 | result = a.whereBy {likes: {$gt: 1}}, {limit:2, offset:2} 242 | assert.equal result.length, 1 243 | 244 | it "Page", -> 245 | a = create() 246 | result = a.whereBy {likes: {$gt: 1}}, {limit:3, page:2} 247 | assert.equal result.length, 0 248 | 249 | it "Sorder by model key", -> 250 | a = create() 251 | result = a.query {likes: {$gt: 1}}, {sortBy:"likes"} 252 | assert.equal result.length, 3 253 | assert.equal result[0].get("title"), "About" 254 | assert.equal result[1].get("title"), "Home" 255 | assert.equal result[2].get("title"), "Contact" 256 | 257 | it "Sorder by model key with descending order", -> 258 | a = create() 259 | result = a.query {likes: {$gt: 1}}, {sortBy:"likes", order:"desc"} 260 | assert.equal result.length, 3 261 | assert.equal result[2].get("title"), "About" 262 | assert.equal result[1].get("title"), "Home" 263 | assert.equal result[0].get("title"), "Contact" 264 | 265 | it "Sorder by function", -> 266 | a = create() 267 | result = a.query {likes: {$gt: 1}}, {sortBy: (model) -> model.get("title").charAt(2) } 268 | assert.equal result.length, 3 269 | assert.equal result[2].get("title"), "About" 270 | assert.equal result[0].get("title"), "Home" 271 | assert.equal result[1].get("title"), "Contact" 272 | 273 | it "cache", -> 274 | a = create() 275 | result = a.whereBy {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 276 | assert.equal result.length, 3 277 | result = a.whereBy {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 278 | assert.equal result.length, 3 279 | a.remove result.at(0) 280 | result = a.whereBy {likes: {$gt: 1}}, {sortBy: (model) -> model.get("title").charAt(2) } 281 | assert.equal result.length, 2 282 | result = a.whereBy {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 283 | assert.equal result.length, 3 284 | 285 | 286 | it "cache with multiple collections", -> 287 | a = create() 288 | b = create() 289 | b.remove b.at(0) 290 | assert.equal b.length, 2 291 | assert.equal a.length, 3 292 | 293 | 294 | a_result = a.query {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 295 | assert.equal a_result.length, 3 296 | b_result = b.query {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 297 | assert.equal b_result.length, 2 298 | 299 | a.remove a_result[0] 300 | b.remove b_result[0] 301 | 302 | a_result = a.query {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 303 | assert.equal a_result.length, 3 304 | assert.equal a.length, 2 305 | 306 | 307 | b_result = b.query {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 308 | assert.equal b_result.length, 2 309 | assert.equal b.length, 1 310 | 311 | a.resetQueryCache() 312 | a_result = a.query {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 313 | assert.equal a_result.length, 2 314 | assert.equal a.length, 2 315 | 316 | b_result = b.query {likes: {$gt: 1}}, {cache:true, sortBy: (model) -> model.get("title").charAt(2) } 317 | assert.equal b_result.length, 2 318 | assert.equal b.length, 1 319 | 320 | 321 | it "null attribute with various operators", -> 322 | a = create() 323 | result = a.whereBy wrong_key: {$like: "test"} 324 | assert.equal result.length, 0 325 | result = a.whereBy wrong_key: {$regex: /test/} 326 | assert.equal result.length, 0 327 | result = a.whereBy wrong_key: {$contains: "test"} 328 | assert.equal result.length, 0 329 | result = a.whereBy wrong_key: {$all: [12,23]} 330 | assert.equal result.length, 0 331 | result = a.whereBy wrong_key: {$any: [12,23]} 332 | assert.equal result.length, 0 333 | result = a.whereBy wrong_key: {$size: 10} 334 | assert.equal result.length, 0 335 | result = a.whereBy wrong_key: {$in: [12,23]} 336 | assert.equal result.length, 0 337 | result = a.whereBy wrong_key: {$nin: [12,23]} 338 | assert.equal result.length, 0 339 | 340 | it "Where method", -> 341 | a = create() 342 | result = a.whereBy likes: $gt: 5 343 | assert.equal result.length, 2 344 | assert.equal result.models.length, result.length 345 | 346 | 347 | it "$computed", -> 348 | class testModel extends Backbone.Model 349 | full_name: -> "#{@get 'first_name'} #{@get 'last_name'}" 350 | 351 | a = new testModel 352 | first_name: "Dave" 353 | last_name: "Tonge" 354 | b = new testModel 355 | first_name: "John" 356 | last_name: "Smith" 357 | c = new QueryCollection [a,b] 358 | 359 | result = c.query 360 | full_name: $computed: "Dave Tonge" 361 | 362 | assert.equal result.length, 1 363 | assert.equal result[0].get("first_name"), "Dave" 364 | 365 | result = c.query 366 | full_name: $computed: $likeI: "n sm" 367 | assert.equal result.length, 1 368 | assert.equal result[0].get("first_name"), "John" 369 | 370 | 371 | it "$elemMatch", -> 372 | a = new QueryCollection [ 373 | {title: "Home", comments:[ 374 | {text:"I like this post"} 375 | {text:"I love this post"} 376 | {text:"I hate this post"} 377 | ]} 378 | {title: "About", comments:[ 379 | {text:"I like this page"} 380 | {text:"I love this page"} 381 | {text:"I really like this page"} 382 | ]} 383 | ] 384 | 385 | b = new QueryCollection [ 386 | {foo: [ 387 | {shape: "square", color: "purple", thick: false} 388 | {shape: "circle", color: "red", thick: true} 389 | ]} 390 | {foo: [ 391 | {shape: "square", color: "red", thick: true} 392 | {shape: "circle", color: "purple", thick: false} 393 | ]} 394 | ] 395 | 396 | text_search = {$likeI: "love"} 397 | 398 | result = a.query $or: 399 | comments: 400 | $elemMatch: 401 | text: text_search 402 | title: text_search 403 | assert.equal result.length, 2 404 | 405 | result = a.query $or: 406 | comments: 407 | $elemMatch: 408 | text: /post/ 409 | assert.equal result.length, 1 410 | 411 | result = a.query $or: 412 | comments: 413 | $elemMatch: 414 | text: /post/ 415 | title: /about/i 416 | assert.equal result.length, 2 417 | 418 | result = a.query $or: 419 | comments: 420 | $elemMatch: 421 | text: /really/ 422 | assert.equal result.length, 1 423 | 424 | result = b.query 425 | foo: 426 | $elemMatch: 427 | shape:"square" 428 | color:"purple" 429 | 430 | assert.equal result.length, 1 431 | assert.equal result[0].get("foo")[0].shape, "square" 432 | assert.equal result[0].get("foo")[0].color, "purple" 433 | assert.equal result[0].get("foo")[0].thick, false 434 | 435 | 436 | it "$any and $all", -> 437 | a = name: "test", tags1: ["red","yellow"], tags2: ["orange", "green", "red", "blue"] 438 | b = name: "test1", tags1: ["purple","blue"], tags2: ["orange", "red", "blue"] 439 | c = name: "test2", tags1: ["black","yellow"], tags2: ["green", "orange", "blue"] 440 | d = name: "test3", tags1: ["red","yellow","blue"], tags2: ["green"] 441 | e = new QueryCollection [a,b,c,d] 442 | 443 | result = e.query 444 | tags1: $any: ["red","purple"] # should match a, b, d 445 | tags2: $all: ["orange","green"] # should match a, c 446 | 447 | assert.equal result.length, 1 448 | assert.equal result[0].get("name"), "test" 449 | 450 | it "$elemMatch - compound queries", -> 451 | a = new QueryCollection [ 452 | {title: "Home", comments:[ 453 | {text:"I like this post"} 454 | {text:"I love this post"} 455 | {text:"I hate this post"} 456 | ]} 457 | {title: "About", comments:[ 458 | {text:"I like this page"} 459 | {text:"I love this page"} 460 | {text:"I really like this page"} 461 | ]} 462 | ] 463 | 464 | result = a.query 465 | comments: 466 | $elemMatch: 467 | $not: 468 | text:/page/ 469 | 470 | assert.equal result.length, 1 471 | 472 | 473 | # Test from RobW - https://github.com/Rob--W 474 | it "Explicit $and combined with matching $or must return the correct number of items", -> 475 | Col = new QueryCollection [ 476 | {equ:'ok', same: 'ok'}, 477 | {equ:'ok', same: 'ok'} 478 | ] 479 | result = Col.query 480 | $and: 481 | equ: 'ok' # Matches both items 482 | $or: 483 | same: 'ok' # Matches both items 484 | assert.equal result.length, 2 485 | 486 | # Test from RobW - https://github.com/Rob--W 487 | it "Implicit $and consisting of non-matching subquery and $or must return empty list", -> 488 | Col = new QueryCollection [ 489 | {equ:'ok', same: 'ok'}, 490 | {equ:'ok', same: 'ok'} 491 | ] 492 | result = Col.query 493 | equ: 'bogus' # Matches nothing 494 | $or: 495 | same: 'ok' # Matches all items, but due to implicit $and, this subquery should not affect the result 496 | assert.equal result.length, 0 497 | 498 | it "Testing nested compound operators", -> 499 | a = create() 500 | result = a.whereBy 501 | $and: 502 | colors: $contains: "blue" # Matches 1,3 503 | $or: 504 | featured:true # Matches 1,2 505 | likes:12 # Matches 1 506 | # And only matches 1 507 | 508 | $or:[ 509 | {content:$like:"dummy"} # Matches 2 510 | {content:$like:"Dummy"} # Matches 1,3 511 | ] 512 | # Or matches 3 513 | assert.equal result.length, 1 514 | 515 | result = a.whereBy 516 | $and: 517 | colors: $contains: "blue" # Matches 1,3 518 | $or: 519 | featured:true # Matches 1,2 520 | likes:20 # Matches 3 521 | # And only matches 2 522 | 523 | $or:[ 524 | {content:$like:"dummy"} # Matches 2 525 | {content:$like:"Dummy"} # Matches 1,3 526 | ] 527 | # Or matches 3 528 | assert.equal result.length, 2 529 | 530 | it "works with queries supplied as arrays", -> 531 | a = create() 532 | result = a.query 533 | $or: [ 534 | {title:"Home"} 535 | {title:"About"} 536 | ] 537 | assert.equal result.length, 2 538 | assert.equal result[0].get("title"), "Home" 539 | assert.equal result[1].get("title"), "About" 540 | 541 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --ui bdd 3 | --ignore-leaks 4 | --growl 5 | --bail 6 | --compilers coffee:coffee-script --------------------------------------------------------------------------------