├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── MIT-LICENSE ├── README.md ├── lib ├── poutine.js └── poutine │ ├── collection.coffee │ ├── connect.coffee │ ├── database.coffee │ ├── index.coffee │ └── model.coffee ├── package.json └── spec ├── collection ├── cursor_spec.coffee ├── insert_spec.coffee ├── query_spec.coffee └── scope_spec.coffee ├── connection ├── insert_spec.coffee └── query_spec.coffee ├── fixtures └── posts.json ├── helpers.coffee └── model ├── insert_spec.coffee ├── query_spec.coffee └── update_spec.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | README.html 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .git* 3 | Makefile 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.6 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 0.3 2 | 3 | Added insert operations on connection, collection and model. For example: 4 | 5 | user = new User(name: "Assaf") 6 | User.insert user 7 | console.log "Inserted #{user._id}" 8 | 9 | posts = connect().find("posts") 10 | posts.insert { title: "New and exciting" }, { safe: true }, (error, post)-> 11 | console.log "Inserted #{post._id}" 12 | 13 | connect().insert "posts", title: "Directly to connection" 14 | 15 | Added support for hooks with callbacks. `afterLoad` becomes a hook. For example: 16 | 17 | class User 18 | @afterLoad (callback)-> 19 | # Example. You don't really want to do this, since it will make 1+N queries. 20 | Author.find @author_id, (error, author)=> 21 | @author = author 22 | callback error 23 | 24 | 25 | 26 | ## Version 0.2 2011-11-24 27 | 28 | This is a complete rewrite based on some ideas I haven't seen anywhere else. 29 | 30 | Tell me this is not awesome: 31 | 32 | class User extends Model 33 | @collection "users" 34 | 35 | @field "name", String 36 | 37 | @field "password", String 38 | @set "password", (clear)-> 39 | @_.password = crypt(clear) 40 | 41 | @field "email", String 42 | 43 | @get "posts", -> 44 | Post.where(author_id: @_id) 45 | 46 | 47 | me = User.where(name: "Assaf") 48 | me.one (error, user)-> 49 | console.log "Loaded #{user.name}" 50 | user.posts.count (error, count)-> 51 | console.log "Published #{count} posts" 52 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Assaf Arkin, Jerome Gravel-Niquet 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Killing It With Poutine 2 | 3 | If you're using MongoDB, developing with Node.js and like CoffeeScript, we've got just the thing for you. It's easier to 4 | work with than any driver, it will map your model objects to MongoDB documents, and it got a sweet API that will make 5 | your code beautiful. 6 | 7 | Check this out: 8 | 9 | ```coffee 10 | { Model } = require("poutine") 11 | 12 | 13 | class User extends Model 14 | @collection "users" 15 | 16 | @field "name", String 17 | 18 | @field "password", String 19 | @set "password", (clear)-> 20 | @_.password = crypt(clear) 21 | 22 | @field "email", String 23 | 24 | @get "posts", -> 25 | Post.where(author_id: @_id) 26 | 27 | 28 | user = new User(name: "Assaf") 29 | name.save() 30 | ``` 31 | 32 | ## A Better Driver API 33 | 34 | The MongoDB driver exposes all the features and complexities of the MongoDB API. For example, to run a simple query you 35 | need to initialize a Server object and a Db object. 36 | 37 | From there, you can open a new connection. You need a callback. Next, you can get hold of a collection. You need 38 | another callback. Last, you can run a `find` operation. That takes another callback. 39 | 40 | APIs like that make us sad. They're as powerful as they are verbose. So we decided to improve that using a combination 41 | of techniques. 42 | 43 | For starters, we're separating connection configuration from the act of acquiring and using a connection. That allows 44 | you to configure all your connections in one place. You may have multiple for different environments, e.g. development, 45 | test and production. 46 | 47 | With Poutine you can write: 48 | 49 | configure = require("poutine").configure 50 | configure "development", host: "localhost" 51 | configure "production", host: "db.jupiter", pool: 50 52 | configure.default = process.env.NODE_ENV 53 | 54 | Then, elsewhere in your application, access the right connection by calling the `connect` function. 55 | 56 | Poutine gives you a chained API that makes everything easier, and will lazily acquire a pooled connection when you 57 | actually execute a command. So to find all posts by an author you could: 58 | 59 | connect = require("poutine").connect 60 | posts = connect().collection("posts") 61 | posts.where(author_id: author._id).desc("created_at").all (error, posts, db)-> 62 | ... 63 | 64 | The chained API gives you a higher level of abstraction, for example: 65 | 66 | Posts.prototype.byAuthor = (author) -> 67 | return connect().collection("posts").where(author_id: author._id) 68 | 69 | Of course, you can also go straight for the kill and do this: 70 | 71 | connect().find("posts", { author_id: author.id }, order: [["created_at", -1]], (error, posts, db)-> 72 | ... 73 | 74 | Other operations work the same way. You can perform them directly on the connection object, or use the chaining API for 75 | easier composition. 76 | 77 | 78 | ## KISS Models 79 | 80 | Naturally. And we've got a particular opinion on how to best use them. 81 | 82 | Models can, and sometimes do, abstract the underlying database in ways that hurt you, and so we opted to create an API 83 | that is just as easy to use with models as it is with Plain Old JavaScript Objects. We want to give you the option to 84 | use both, and make it particularly easy to mix both in the same code base. 85 | 86 | Finders, scopes and much of the API you'll read about in details soon can be used the same way, whether you're asking it 87 | to load a plain JavaScript object, a Poutine model or your own hybrid class. 88 | 89 | We kept the API simple by using class functions to mix logic and meta-data within the same class definition. This 90 | allows you to define a field, add an index, write accessor functions and related methods, all in consecutive lines. In 91 | our experience, this makes model definition much cleaner and easier to maintain. 92 | 93 | We kept the API clean by only including methods you would use in the Model class. Poutine needs a lot more lifecycle 94 | methods to manage models and give you all those features, but we keep those in separate namespace. Having a base class 95 | with hundreds of implementation methods is an anti-pattern we don't like. 96 | 97 | Last, at minimum a model is just a constructor with two properties: `collection_name` and `fields`. If you don't like 98 | Poutine's `Model` class, don't use it. Write your own code. We'll stay out of the way, but still do the heavy lifting 99 | for you. 100 | 101 | Time to walk the walk. Here's a model example: 102 | 103 | class User extends Model 104 | @collection "users" 105 | 106 | @field "name", String 107 | 108 | @field "password", String 109 | @set "password", (clear)-> 110 | @_.password = crypt(clear) 111 | 112 | @field "email", String 113 | 114 | @get "posts", -> 115 | Post.where(author_id: @_id) 116 | 117 | 118 | Each model is associated with a single collection, specified by the `collection_name` property. You can use the 119 | `Model.collection` method to set the collection name. 120 | 121 | Only defined fields are loaded and saved. A field definition requires a name and optional type. Fields are loaded into 122 | the property `_` (underscore). When you define a field by calling `field`, Poutine adds getter and setter functions 123 | for you, but you can always write your own accessors. 124 | 125 | In the example above we define a setter that takes a clear-text password and sets the internal field value to be salted 126 | and encrypted. 127 | 128 | Let's look at a simple example for working with models: 129 | 130 | # me is a scope 131 | me = User.where(name: "Assaf") 132 | me.one (error, user)-> 133 | console.log "Loaded #{user.name}" 134 | user.posts.count (error, count)-> 135 | console.log "Published #{count} posts" 136 | 137 | 138 | ## Working With The Database 139 | 140 | 141 | ### Configuring Database Access 142 | 143 | You use the `connect` function to obtain a new `Database` object, representing a logical database connection. 144 | 145 | You can call `connect()` with a database name, in which case it will return a database connection based on the named 146 | configuration, or with no arguments, in which case it will return the default configuration. 147 | 148 | To create a database configuration, use `configure()`. For example: 149 | 150 | { connect, configure } = require("poutine") 151 | configure "blog", host: "127.0.0.1" 152 | connect().count "posts", (err, count, db)-> 153 | console.log "There are #{count} posts in the database" 154 | 155 | Poutine picks the first configuration as the default configuration, making it easy to work with a single configuration. 156 | 157 | Another common case is having one configuration per environment, and then using the configuration suitable for the 158 | current environment. For example: 159 | 160 | configure = require("poutine").configure 161 | configure "development", name: "myapp", host: "127.0.0.1" 162 | configure "test", name: "myapp-test", host: "127.0.0.1" 163 | configure "production", name: "myapp", host: "db.jupiter", pool: 50 164 | configure.default = process.env.NODE_ENV 165 | 166 | Alternatively, you can load configurations from a JSON document: 167 | 168 | configure = require("poutine").configure 169 | configure fs.readFileSync("databases.json") 170 | configure.default = process.env.NODE_ENV 171 | 172 | The JSON configuration document would look like this: 173 | 174 | { "development": { 175 | name: "myapp", 176 | host: "127.0.0.1" 177 | }, 178 | "test": { 179 | name: "myapp-test", 180 | host: "127.0.0.1" 181 | }, 182 | "production": { 183 | name: "myapp", 184 | host: "db.jupiter", 185 | pool: 50 186 | } 187 | } 188 | 189 | 190 | ## Managing Connections 191 | 192 | (This is heavy and we won't get offended if you skip this section for now and come back to it later, when you need to 193 | worry about scaling and concurrency) 194 | 195 | Mostly it's just works out, but you still need to understand what to do to handle special cases. 196 | 197 | MongoDB uses one thread per TCP connection. That should tell you two things. First, if your application opens and uses 198 | a single connection, all the workload will be serialized in a single thread. You won't get a lot of scalability that 199 | way. If it's a Web server, you'll want each request to be using its own connection. 200 | 201 | Second, if your application keeps opening and closing connections, there's a lot of overhead involved in establishing 202 | these connections, both TCP overhead and threads. You want to reuse connections through some pooling mechanism. 203 | 204 | Poutine solves this by allowing you to open as many logical connections as you want, but using a pool of TCP connections 205 | to handle those requests. Whenever you do an operation, like insert or query, it grabs a connection from the pool, 206 | performs that operation, and then returns the connection back to the pool. 207 | 208 | That means that all you need to do is grab a connection and use it. You can use one connection throughout the 209 | application (but read below why it's not such a good idea), or grab a new connection for each request. You don't have 210 | to worry about closing the connection, the logical connection is just a wrapper, and the TCP connections are pooled. 211 | 212 | And this works flawlessly for most things you do, but there are a couple of exceptions. Say you're inserting a record 213 | into the database and then using the same logical connection to query the database. By default Poutine grabs a TCP 214 | connection from the database for each of these operations. It's possible that the insert operation will not complete 215 | before the find operation is started and you won't be able to query the object you just created. 216 | 217 | There are two ways around this. You can insert safely, which blocks until the insert operation completes. Or you can 218 | tell Poutine to reuse the same TCP connection. 219 | 220 | Another scenario is using replica sets where each TCP connection may read from a different slave. It takes slaves some 221 | time to replicate, so it's possible that one query will hit one server and find an object, but another query will hit a 222 | different server and not find the very same object. Again, you can solve that by telling Poutine to reuse the same TCP 223 | connection. 224 | 225 | You do that by calling `begin` and `end`. Calling `begin` fixes the TCP connection, so all subsequent operations on 226 | that connection object will use the same TCP connection. You must follow up with a call to `end`, otherwise the TCP 227 | connection is not available for other requests. 228 | 229 | There's reference tracking, so if you're passing the connection to another function that calls `begin` followed by 230 | `end`, the connection doesn't get released on you. 231 | 232 | Here's a simple example: 233 | 234 | # Use the same TCP connection for insert and find. 235 | db.begin (end)-> 236 | db.insert "posts", { title: "Find me" }, (err, id)-> 237 | db.find("posts", id).one (err, post)-> 238 | assert post 239 | end() 240 | 241 | Alternatively, with one less callback: 242 | 243 | 244 | # Use the same TCP connection for insert and find. 245 | end = db.begin() 246 | db.insert "posts", { title: "Find me" }, (err, id)-> 247 | db.find("posts", id).one (err, post)-> 248 | assert post 249 | end() 250 | 251 | 252 | ## Queries 253 | 254 | You can query the connection directly by using methods like `find`, `count` and `distinct`. These methods take a 255 | collection name/model as the first argument. The same methods are also available on a collection. 256 | 257 | 258 | ### Loading Objects 259 | 260 | To load a single object by ID, call `find` with that ID. For example: 261 | 262 | connect().find "posts", post_id, (error, post)-> 263 | if post 264 | console.log "Found post" 265 | else 266 | console.log "No such post" 267 | 268 | You can also load multiple objects by passing an array of IDs. For example: 269 | 270 | connect().find "posts", [id1, id2], (error, posts)-> 271 | console.log "Found #{posts.length} posts" 272 | 273 | To find objects by any other properties, use a query selector. For example: 274 | 275 | connect().find "posts", author_id: author._id, (error, posts)-> 276 | console.log "Found #{posts.length} posts by #{author.name}" 277 | 278 | You can also pass query options as the third argument. For example: 279 | 280 | connect().find "posts", { author_id: author._id }, fields: ["title"], (error, posts)-> 281 | console.log "Found #{posts.length} posts by #{author.name}" 282 | 283 | The callback receives three arguments. If an error occurs, the first argument is the error. If successful, the first 284 | argument is null, the second argument is either the object or objects you're querying, and the last argument is a 285 | reference to the database connection. 286 | 287 | If you call `find` without a callback, it returns a `Scope` object that you can further refine. We'll talk about 288 | queries in a moment. 289 | 290 | The same method is available on a collection. For example: 291 | 292 | posts = connect().collection("posts") 293 | posts.find author_id: author._id, (error, posts)-> 294 | console.log "Found #{posts.length} posts by #{author.name}" 295 | 296 | If you're only interested in loading a single object, you can call the method `one` with query selector or object 297 | identifier. For example: 298 | 299 | posts = connect().collection("posts") 300 | posts.one author_id: author._id, (error, post)-> 301 | console.log "Found this post:", post.title 302 | 303 | You can load all objects by calling `all` with query selector or array of object identifiers. For example: 304 | 305 | posts = connect().collection("posts") 306 | posts.all author_id: author._id, (error, posts)-> 307 | console.log "Found #{posts.length} posts by #{author.name}" 308 | 309 | And you can also use `each`, which will be called once for each object loaded, and finally with null. For example: 310 | 311 | posts = connect().collection("posts") 312 | console.log "Loading ..." 313 | posts.each author_id: author._id, (error, post)-> 314 | if post 315 | console.log post.title 316 | else 317 | console.log "Done" 318 | 319 | The real beautify of `one`, `each` and `all` is when used in combination with scopes, as you'll see below. 320 | 321 | 322 | ### Counting Objects 323 | 324 | You can count how many objects are in a given collection by calling `count`, with or without a selector. For example: 325 | 326 | connect().count "posts", (error, count)-> 327 | console.log "There are #{count} posts" 328 | 329 | connect().count "posts", author_id: author._id, (error, count)-> 330 | console.log "There are #{count} posts by #{author.name}" 331 | 332 | As with `find`, these methods are also available on a collection. Unlike `find`, a callback is required. For example: 333 | 334 | posts.count (error, posts)-> 335 | console.log "There are #{count} posts" 336 | 337 | 338 | ### Distinct Values 339 | 340 | You can retrieve distinct values from a set of objects using `distinct`, with or without a selector. The `distinct` 341 | method requires a field name and provides an array of values. For example: 342 | 343 | connect().distinct "posts", "author_id", (error, author_ids, db)-> 344 | db.find "authors", author_ids, (error, authors)-> 345 | names = (author.name for author in authors) 346 | console.log "Post authored by #{name.join(", ")}" 347 | 348 | As with `find`, these methods are also available on a collection. Unlike `find`, a callback is required. For example: 349 | 350 | posts.distinct "date", author_id: author._id, (error, dates)-> 351 | console.log "#{author.name} posted on #{dates.join(", ")}" 352 | 353 | 354 | ### Queries 355 | 356 | The `Scope` object allows you to refine the query using chained methods calls, and to retrieve objects in a variety of 357 | different ways. 358 | 359 | You can get a `Scope` object by calling the `find` method with no callback, or by calling `where` on the collection. 360 | You can chain `where` methods together to create more specific scopes. For example: 361 | 362 | # All posts 363 | posts = connect().find("posts") 364 | # For specific author 365 | for_author = posts.where(author_id: author._id) 366 | # Written today 367 | today = for_author.where(created_at: { $gt: (new Date).beginningOfDay() }) 368 | 369 | You can also use chain methods to modify the query options, using any of the following methods: 370 | 371 | query.fields(...) # Specify which fields to load 372 | query.asc(...) # Sort by ascending order 373 | query.desc(...) # Sort by descending order 374 | query.limit(n) # Load at most n records 375 | query.skip(n) # Skip the first n records 376 | 377 | For example: 378 | 379 | posts.where(author_id: author._id).fields("title").desc("created_at").all (error, posts)-> 380 | titles = (post.title for post in posts) 381 | console.log "Posts from newest to oldest:", titles 382 | 383 | The `field`, `asc` and `desc` methods accept a list of fields, either as multiple arguments, or an array. 384 | 385 | To get all the objects selected by a scope you can use `all` and `each`. You can also get a single object (the first 386 | match) by calling `one`, the number of objects by calling `count` and distinct values by calling `distinct`. These 387 | methods operate the same way as the collection methods of the same name. 388 | 389 | For example: 390 | 391 | posts.where(author_id: author._id).fields("title").all (error, posts)-> 392 | titles = (post.title for post in posts) 393 | console.log "Found these posts:", titles 394 | 395 | posts.where(created_at: { $gt: (new Date).beginningOfDay() }).all (error, count)-> 396 | console.log "Published #{count} posts today" 397 | 398 | posts.desc("created_at").fields("title").each (error, post)-> 399 | console.log "Published #{post.title}" 400 | 401 | In addition to `each`, you can also call `map`, `filter` and `reduce`. The `map` method takes two arguments, the first 402 | is the mapping function that is called for each object, and the last is an object that it passed the mapped array. For 403 | example: 404 | 405 | connect().find("posts").map ((post)-> "#{post.title} on #{post.created_at}"), (error, posts)-> 406 | console.log posts 407 | 408 | The `filter` method takes two arguments, the first is the filtering function that is called for each object. It 409 | collects each object for which the filtering function returns true, and passes that array to the callback. For example: 410 | 411 | connect().find("posts").filter ((post)-> post.body.length > 500), (error, posts)-> 412 | console.log "Found #{posts.count} posts longer than 500 characters" 413 | 414 | You can call `reduce` with two arguments, the first being the reduce function, which takes a value and an object, and 415 | returns the new value. The final value is passed to the callback. 416 | 417 | The initial value is null, but you can also call `reduce` with three arguments, passing the initial value as the first 418 | argument. For example: 419 | 420 | connect().find("posts").reduce ((total, post)-> total + post.body.length), (error, total)-> 421 | console.log "Wrote #{total} characters" 422 | 423 | You can call `update` with three arguments, the first is the updated document (don't forget $set if you don't want to replace the found document(s)), the second is the options for your update (`multi` and `upsert`, both default to false). Last argument is the callback as always, returns an error if any, but nothing else. 424 | 425 | posts.where({permalink: {$exists: false}}).update {$set: {permalink: linkGenerator(post)}}, {multi: true}, (error)-> 426 | console.log "All posts without permalinks now got some link love." 427 | 428 | There's a shorthand `update_all` version of `update` to update multiple documents at once without dealing with an options object. 429 | 430 | posts.where({permalink: {$exists: false}}).update_all {$set: {permalink: linkGenerator(post)}}, (error)-> 431 | console.log "All posts without permalinks now got some link love." 432 | 433 | There's a shorthand for `upsert`ing too. 434 | 435 | posts.upsert {title: "First blog post"}, {title: "First and best blog post"}, (error)-> 436 | console.log "If the blog post was found, it was updated, else it was inserted." 437 | 438 | 439 | ### Cursors 440 | 441 | You can also use a cursor to iterate over a query. Call `next` to query and pass the next object to the callback. When 442 | there are no more objects to read, it will pass `null` to the callback. You can rewind the cursor by calling `rewind` 443 | and don't forget to close it by calling `close`. 444 | 445 | For example: 446 | 447 | scope = connect().find("posts") 448 | each = (error, post)-> 449 | if post 450 | console.log post.title 451 | scope.next each 452 | else 453 | console.log "Done" 454 | scope.close() 455 | console.log "Finding ..." 456 | scope.next each 457 | 458 | 459 | ### Model Finders 460 | 461 | You can use `find` and `where` directly with a model class, for example: 462 | 463 | me = User.where(name: "Assaf") 464 | me.one (error, user)-> 465 | console.log "Loaded #{user.name}" 466 | User.find post.author_id, (error, user)-> 467 | console.log "Loaded #{user.name}" 468 | 469 | You can also use any of the connection/collection finder methods with models, just pass a model class (constructor 470 | function) instead of collection name. For example: 471 | 472 | connect().find User, name: "Assaf", (error, user)-> 473 | console.log "Loaded #{user.name}" 474 | 475 | You can use either `Model.finder` or `find(Model)`, they both map to the same behavior. The main difference is, your 476 | code may be easier to read if you use the `Model.finder` pattern, and you'll probably prefer to use it often. 477 | 478 | On the other hand, `Model.finder` may use a different TCP connection for each request. If you need to use the same TCP 479 | connection (see `begin` and `end`), then you have to go through the connection/collection objects. 480 | 481 | 482 | ### afterLoad 483 | 484 | If the model defines a method called `afterLoad`, that method is called after the properties are set. 485 | 486 | For example: 487 | 488 | class Post extends Model 489 | field "author_id" 490 | afterLoad: -> 491 | # All fields set, load associated object. 492 | @author = Author.find(@author_id) 493 | 494 | -------------------------------------------------------------------------------- /lib/poutine.js: -------------------------------------------------------------------------------- 1 | var coffee = require("coffee-script"); 2 | var File = require("fs"); 3 | if (!require.extensions[".coffee"]) { 4 | require.extensions[".coffee"] = function (module, filename) { 5 | var source = coffee.compile(File.readFileSync(filename, "utf8")); 6 | return module._compile(source, filename); 7 | }; 8 | } 9 | module.exports = require(__filename.replace(/\.js$/, "/index.coffee")); 10 | -------------------------------------------------------------------------------- /lib/poutine/collection.coffee: -------------------------------------------------------------------------------- 1 | # Collection represents a MongoDB collection. 2 | # 3 | # Scope limits operations on a collection to particular records (based on query selector) and allows specifying query 4 | # and update options (e.g. fields, limit, sorting). 5 | # 6 | # Cursor represents a MongoDB cursor that can be used to retrieve one object at a time. 7 | 8 | 9 | assert = require("assert") 10 | { Model } = require("./model") 11 | 12 | 13 | # Represents a collection and all the operation you can do on one. Database 14 | # methods like find and insert operate through a collection. 15 | class Collection 16 | constructor: (@name, @database, @model)-> 17 | 18 | # -- Finders -- 19 | 20 | # Finds all objects that match the query selector. 21 | # 22 | # If the last argument is a callback, load all matching objects and pass them 23 | # to callback. Callback receives error, objects and connection. 24 | # 25 | # Without callback, returns a new Scope object. 26 | # 27 | # The first argument is optional and specifies the query selector. You can 28 | # also pass an array of identifier to return specific objects, or a single 29 | # identifier to return a single object. 30 | # 31 | # Second argument is optional and specifies query options (limit, sort, etc). 32 | # When passing options you must also pass the query selector. 33 | # 34 | # Examples: 35 | # posts = connect().collection("posts") 36 | # posts.find { author_id: author._id }, limit: 50, (err, posts, db)-> 37 | # . . . 38 | # 39 | # posts.find "posts", id, (err, post, db)-> 40 | # . . . 41 | # 42 | # query = posts.find(author_id: author._id) 43 | # query.all (err, posts, db)-> 44 | # . . . 45 | find: (selector, options, callback)-> 46 | if !callback && options instanceof Function 47 | [callback, options] = [options, null] 48 | if !callback && !options && selector instanceof Function 49 | [callback, selector] = [selector, null] 50 | if @model 51 | options ||= {} 52 | options.fields ||= Object.keys(@model.fields) 53 | if selector instanceof @database.ObjectID || selector instanceof String 54 | if callback 55 | @one selector, options, callback 56 | else 57 | return @where(_id: selector).extend(options) 58 | else 59 | if callback 60 | @all selector, options, callback 61 | else 62 | return @where(selector).extend(options) 63 | 64 | # Passes matching object from this query to callback. 65 | # 66 | # Takes three arguments, selector, options and callback. Can also be called 67 | # with selector and callback or callback alone. 68 | one: (selector, options, callback)-> 69 | unless callback 70 | if options 71 | [callback, options] = [options, null] 72 | else 73 | [callback, selector] = [selector, null] 74 | assert callback instanceof Function, "This function requires a callback" 75 | @_connect (error, collection, database)=> 76 | return callback error if error 77 | collection.findOne selector || {}, options || {}, (error, object)=> 78 | database.end() 79 | if @model && object 80 | Model.lifecycle.load @model, object, callback 81 | else 82 | callback error, object, database 83 | return 84 | 85 | # Passes each objects from this query to callback. Passes null after the 86 | # last object. 87 | # 88 | # Takes three arguments, selector, options and callback. Can also be called 89 | # with selector and callback or callback alone. 90 | each: (selector, options, callback)-> 91 | unless callback 92 | if options 93 | [callback, options] = [options, null] 94 | else 95 | [callback, selector] = [selector, null] 96 | assert callback instanceof Function, "This function requires a callback" 97 | if selector instanceof Array 98 | selector = { _id: { $in: selector } } 99 | @_query selector, options, (error, cursor)=> 100 | return callback error if error 101 | # Retrieve next object and pass to callback. 102 | readNext = => 103 | cursor.nextObject (error, object)=> 104 | if error 105 | cursor.close() 106 | callback error 107 | return 108 | # No object, we're done with this query 109 | unless object 110 | cursor.close() 111 | callback null 112 | return 113 | 114 | if @model 115 | Model.lifecycle.load @model, object, (error, instance)-> 116 | if error 117 | callback error 118 | cursor.close() 119 | else 120 | callback null, instance 121 | # Use nextTick to avoid stack overflow on large result sets. 122 | process.nextTick readNext 123 | else 124 | callback null, object 125 | # Use nextTick to avoid stack overflow on large result sets. 126 | process.nextTick readNext 127 | readNext() 128 | return 129 | 130 | # Passes all objects from this query to callback. 131 | # 132 | # Takes three arguments, selector, options and callback. Can also be called 133 | # with selector and callback or callback alone. 134 | all: (selector, options, callback)-> 135 | unless callback 136 | if options 137 | [callback, options] = [options, null] 138 | else 139 | [callback, selector] = [selector, null] 140 | assert callback instanceof Function, "This function requires a callback" 141 | objects = [] 142 | @each selector, options, (error, object)=> 143 | return callback error if error 144 | if object 145 | objects.push object 146 | else 147 | callback null, objects, @database 148 | 149 | # Passes number of records in this query to callback. 150 | # 151 | # If called with single argument (callback), counts all objects in the 152 | # collection. 153 | count: (selector, callback)-> 154 | unless callback 155 | [callback, selector] = [selector, null] 156 | assert callback instanceof Function, "This function requires a callback" 157 | @_connect (error, collection, database)=> 158 | return callback error if error 159 | collection.count selector || {}, (error, count)=> 160 | database.end() 161 | callback error, count, database 162 | return 163 | 164 | # Passes distinct values to callback. 165 | # 166 | # If called with two arguments (key and callback), finds all distinct values 167 | # in the collection. With three arguments, only looks at objects that match 168 | # the selector. 169 | distinct: (key, selector, callback)-> 170 | assert key, "This function requires a key as its first argument" 171 | unless callback 172 | [callback, selector] = [selector, null] 173 | assert callback instanceof Function, "This function requires a callback" 174 | @_connect (error, collection, database)=> 175 | return callback error if error 176 | collection.distinct key, selector || {}, (error, count)=> 177 | database.end() 178 | callback error, count, database 179 | return 180 | 181 | # Returns a Scope on this collection. 182 | # 183 | # Example: 184 | # my_posts = connect().find("posts").where(author_id: me._id).desc("created_at") 185 | # my_posts.count (err, count, db)-> 186 | # console.log "I wrote #{count} posts" 187 | where: (selector)-> 188 | return new Scope(this, selector) 189 | 190 | 191 | # -- Insert/update/remove -- 192 | 193 | # Inserts document(s) into the database. 194 | # 195 | # If the document(s) do not have an ID, sets the ID before insertion. This method does not block, with a callback it 196 | # simply passes the document(s) to the callback. You can use the callback if you're inserting with `safe: true` or 197 | # want to wait for after-save hooks. 198 | # 199 | # When called with a single document, passes it to the callback. WHen called with an array, inserts all the documents 200 | # and passes that array to the callback. 201 | # 202 | # Example: 203 | # posts = connect().find("posts") 204 | # posts.insert title: "New and exciting", (error, post)-> 205 | # console.log "Inserted #{post._id}" 206 | insert: (objects, options, callback)-> 207 | if !callback && typeof options == "function" 208 | [options, callback] = [null, options] 209 | multi = Array.isArray(objects) 210 | objects = [objects] unless multi 211 | documents = ((if Model.isModel(object) then object._ else object) for object in objects) 212 | @_connect (error, collection, database)=> 213 | return callback error if error 214 | database.end() 215 | if callback 216 | collection.insert documents, options, (error, results)-> 217 | if multi 218 | callback error, objects 219 | else 220 | callback error, objects[0] 221 | else 222 | collection.insert documents, options 223 | 224 | 225 | # Updates document(s) into the database. 226 | # 227 | # Arguments: 228 | # - selector criteria to find the document(s) to update 229 | # - document to update the found document(s) to (use $set to not replace the document(s)) 230 | # - options (optional) 231 | # - upsert (false) boolean indicating if the document should be created if not found 232 | # - multi (false) boolean indicating if more than one documents should be updated if found 233 | # - callback (optional) 234 | # - error if there's an error 235 | # 236 | # Example: 237 | # Post.update {title: "New and exciting"}, {title: "Old and boring"}, (error, updated)-> 238 | # console.log "Updated post" if updated 239 | update: (selector, document, options, callback)-> 240 | assert selector instanceof Object, "`update` selector is required." 241 | assert document instanceof Object, "`document` to update the found documents is required." 242 | if !callback && typeof options == "function" 243 | [options, callback] = [null, options] 244 | selector = if Model.isModel(selector) then selector._ else selector 245 | @_connect (error, collection, database)=> 246 | return callback error if error 247 | database.end() 248 | 249 | if callback 250 | collection.update selector, document, options, callback 251 | else 252 | collection.update selector, document, options 253 | 254 | 255 | # -- Implementation details -- 256 | 257 | # Passes error, collection and database to callback. 258 | _connect: (callback)-> 259 | assert callback instanceof Function, "This function requires a callback" 260 | @database.driver (error, connection, end)=> 261 | return callback error if error 262 | connection.collection @name, (error, collection)=> 263 | if error 264 | end() 265 | callback error 266 | else 267 | callback null, collection, @database 268 | 269 | # Used internally to open a cursor for queries. 270 | _query: (selector, options, callback)-> 271 | unless callback 272 | if options 273 | [callback, options] = [options, null] 274 | else 275 | [callback, selector] = [selector, null] 276 | assert callback instanceof Function, "This function requires a callback" 277 | @_connect (error, collection, database)=> 278 | return callback error if error 279 | collection.find selector || {}, options || {}, (error, cursor)=> 280 | database.end() 281 | callback error, cursor 282 | 283 | 284 | 285 | # A scope limits objects returned by a query. Scopes can also be used to 286 | # insert, update and remove selected objects. 287 | # 288 | # Scopes are returned when you call `find` with no callback, or call `where` on 289 | # a collection. 290 | # 291 | # A scope can be further refined by calling `where`. You can also modify query 292 | # options by calling `fields`, `asc`, `desc`, `limit` and `skip`. 293 | # 294 | # You can retrieve objects by calling `one`, `all`, `each`, `map`, `filter` or 295 | # `reduce`. 296 | # 297 | # For example, to find 50 posts by certain author and only return their title: 298 | # 299 | # posts = connect().find("posts") 300 | # by_author = posts.where(author_id: author._id) 301 | # limited = by_author.fields("title").limit(50) 302 | # limited.all (err, posts)-> 303 | # titles = (post.title for post in posts) 304 | # console.log "Found these posts:", titles 305 | class Scope 306 | constructor: (@collection, @selector, @options)-> 307 | 308 | # -- Refine selector/options -- 309 | 310 | # Refines query selector. 311 | # 312 | # These two are equivalent: 313 | # connect().find("posts", author_id: author._id) 314 | # connect().find("posts").where(author_id: author._id) 315 | where: (selector)-> 316 | combined = {} 317 | if @selector 318 | combined[k] = v for k, v of @selector 319 | if selector 320 | combined[k] = v for k, v of selector 321 | return new Scope(@collection, combined, @options) 322 | 323 | # Instructs query to return only named fields. You can call with multiple 324 | # arguments, an array argument or no arguments if you're only interested in 325 | # the object IDs. 326 | # 327 | # Example: 328 | # connect().find("posts").fields("author_id", "title") 329 | # connect().find("posts").fields(["author_id", "title"]) 330 | fields: -> 331 | fields = [] 332 | for arg in arguments 333 | if Array.isArray(arg) 334 | fields = fields.concat(arg) 335 | else 336 | fields.push arg.toString() 337 | return @extend(fields: fields) 338 | 339 | # Instructs query to sort object by ascending order. You can call with 340 | # multiple arguments, an array argument or no arguments if you're only 341 | # interested in the object IDs. 342 | # 343 | # Example: 344 | # connect().find("posts").asc("created_at") 345 | asc: -> 346 | return @sort(arguments, 1) 347 | 348 | # Instructs query to sort object by descending order. You can call with 349 | # multiple arguments, an array argument or no arguments if you're only 350 | # interested in the object IDs. 351 | # 352 | # Example: 353 | # connect().find("posts").desc("created_at") 354 | desc: -> 355 | return @sort(arguments, -1) 356 | 357 | # Limit number of records returned. 358 | # 359 | # Example: 360 | # first_ten = posts.limit(10) 361 | limit: (limit)-> 362 | assert limit, "This function requires limit as its first argument" 363 | return @extend(limit: limit) 364 | 365 | # Return records from specified offset. 366 | # 367 | # Example: 368 | # next_ten = posts.skip(10).limit(10) 369 | skip: (skip)-> 370 | assert skip, "This function requires skip as its first argument" 371 | return @extend(skip: skip) 372 | 373 | # Returns a scope with combined options. 374 | extend: (options)-> 375 | combined = {} 376 | if @options 377 | combined[k] = v for k,v of @options 378 | if options 379 | combined[k] = v for k,v of options 380 | return new Scope(@collection, @selector, combined) 381 | 382 | # Changes sorting order. 383 | sort: (fields, dir)-> 384 | assert dir == 1 || dir == -1, "Direction must be 1 (asc) or -1 (desc)" 385 | sort = @options.sort || [] 386 | for field in fields 387 | if Array.isArray(field) 388 | sort = sort.concat([f, dir] for f in field) 389 | else 390 | sort = sort.concat([[field, dir]]) 391 | return @extend(sort: sort) 392 | 393 | 394 | # -- Load objects -- 395 | 396 | # Passes object to callback. 397 | # 398 | # Example: 399 | # connect().find("posts", author_id: id).one (err, post, db)-> 400 | one: (callback)-> 401 | @collection.one @selector, @options, callback 402 | 403 | # Passes each object to callback. Passes null as last object. 404 | # 405 | # Example: 406 | # connect().find("posts").each (err, post, db)-> 407 | each: (callback)-> 408 | @collection.each @selector, @options, callback 409 | 410 | # Passes all objects to callback. 411 | # 412 | # Example: 413 | # connect().find("posts").all (err, posts)-> 414 | all: (callback)-> 415 | @collection.all @selector, @options, callback 416 | 417 | # Passes number of records in this query to callback. 418 | # 419 | # Example: 420 | # connect().find("posts", author_id: id).count (err, count)-> 421 | count: (callback)-> 422 | @collection.count @selector, callback 423 | 424 | # Passes distinct values callback. 425 | # 426 | # Example: 427 | # connect().find("posts").distinct "author_id", (err, author_ids)-> 428 | distinct: (key, callback)-> 429 | @collection.distinct key, @selector, callback 430 | 431 | 432 | # -- Transformation -- 433 | 434 | # Passes each object to the mapping function, and passes the result to the 435 | # callback. You can also call with a function name, in which case it will 436 | # call that function on each object. 437 | # 438 | # Example: 439 | # connect().find("posts").map ((post)-> "#{post.title} on #{post.created_at}"), (error, posts)-> 440 | # console.log posts 441 | map: (fn, callback)-> 442 | assert fn, "This function requires a mapping function as its first argument" 443 | unless typeof fn == "function" 444 | name = fn 445 | fn = (object)-> object[name] 446 | assert callback instanceof Function, "This function requires a callback" 447 | results = [] 448 | @collection.each @selector, @options, (error, object)-> 449 | return callback error if error 450 | if object 451 | try 452 | results.push fn(object) 453 | catch error 454 | callback error 455 | else 456 | callback null, results 457 | 458 | # Passes each object to the filtering function, select only objects for which 459 | # the filtering function returns true, and pass that selection to the 460 | # callback. You can also call with a function name, in which case it will 461 | # call that function on each object. 462 | # 463 | # Example: 464 | # connect().find("posts").filter ((post)-> post.body.length > 500), (error, posts)-> 465 | # console.log "Found #{posts.count} posts longer than 500 characters" 466 | filter: (fn, callback)-> 467 | assert fn, "This function requires a filter function as its first argument" 468 | unless typeof fn == "function" 469 | name = fn 470 | fn = (object)-> object[name] 471 | assert callback instanceof Function, "This function requires a callback" 472 | results = [] 473 | @collection.each @selector, @options, (error, object)-> 474 | return callback error if error 475 | if object 476 | try 477 | results.push object if fn(object) 478 | catch error 479 | callback error 480 | else 481 | callback null, results 482 | 483 | # Passes each object to the reduce function, collects the reduce value, and 484 | # passes that to the callback. 485 | # 486 | # With two arguments, the first argument is the reduce function that accepts 487 | # value and object, and the second argument is the callback. The initial 488 | # value is null. 489 | # 490 | # With three arguments, the first arguments supplies the initial value. 491 | # 492 | # Example: 493 | # connect().find("posts").reduce ((total, post)-> total + post.body.length), (error, total)-> 494 | # console.log "Wrote #{total} characters" 495 | reduce: (value, fn, callback)-> 496 | assert fn, "This function requires a reduce function" 497 | if arguments.length < 3 498 | [value, fn, callback] = [null, value, fn] 499 | assert callback instanceof Function, "This function requires a callback" 500 | @collection.each @selector, @options, (error, object)-> 501 | return callback error if error 502 | if object 503 | try 504 | value = fn(value, object) 505 | catch error 506 | callback error 507 | else 508 | callback null, value 509 | 510 | 511 | # -- Insertion, updating -- 512 | 513 | # Updates document(s) matching the scope 514 | update: (object, options, callback)-> 515 | @collection.update @selector, object, options, callback 516 | 517 | # Updates all the documents matching the scope 518 | update_all: (object, options, callback)-> 519 | options ||= {} 520 | options.multi = true 521 | @collection.update @selector, object, options, callback 522 | 523 | 524 | # -- Cursors -- 525 | 526 | # Opens cursor and passes next result to query. Passes null if there are no 527 | # more results. 528 | next: (callback)-> 529 | assert callback instanceof Function, "This function requires a callback" 530 | if @_cursor 531 | @_cursor.nextObject (error, object)-> 532 | callback error, object 533 | else 534 | @collection._query @selector, @options, (error, @_cursor)=> 535 | return callback error if error 536 | @_cursor.nextObject callback 537 | return 538 | 539 | # Rewind cursor to beginning. 540 | rewind: -> 541 | if @_cursor 542 | @_cursor.rewind() 543 | return 544 | 545 | close: -> 546 | if @_cursor 547 | @_cursor.close() 548 | return 549 | 550 | 551 | exports.Collection = Collection 552 | -------------------------------------------------------------------------------- /lib/poutine/connect.coffee: -------------------------------------------------------------------------------- 1 | # The configure function is used to configure a database connection. 2 | # 3 | # The connect function is used to acquire logicl connection to a database. 4 | 5 | 6 | assert = require("assert") 7 | 8 | 9 | # Database configurations. We use this to configure database access and then 10 | # get the driver instance (Db object). 11 | databases = {} 12 | 13 | # Configure a database. 14 | # 15 | # You can call this with one argument, a database name, and it will apply the 16 | # default configuration. 17 | # 18 | # You can call this with database name and an object containing configuration 19 | # options. Supported options are: 20 | # - host -- Database host name (defaults to 127.0.0.1) 21 | # - port -- Database port number (defaults to 27017) 22 | # - pool -- Connection pool size (defaults to 10) 23 | # - name -- Actual database name if different from name argument 24 | # 25 | # You can also call this with an object, where each key is a database name, and 26 | # the corresponding value the database configuration. 27 | configure = (name, options = {})-> 28 | assert name, "This function requires a database name" 29 | { Configuration } = require("./database") 30 | if name.constructor == Object 31 | configs = name 32 | for name, options of configs 33 | configure name, options 34 | else 35 | assert !databases[name], "Already have configuration named #{name}" 36 | options ||= {} 37 | config = new Configuration(options.name || name, options) 38 | databases[name] = config 39 | configure.default ||= name 40 | 41 | # Default database name. If not set, pick the first database. 42 | configure.default = null 43 | configure.DEFAULT = "development" 44 | 45 | # Provides access to the specified database (null for default database). 46 | connect = (name)-> 47 | { Database } = require("./database") 48 | name ||= configure.default || process.env.NODE_ENV || configure.DEFAULT 49 | unless databases[name] 50 | configure name 51 | return new Database(databases[name]) 52 | 53 | 54 | exports.configure = configure 55 | exports.connect = connect 56 | -------------------------------------------------------------------------------- /lib/poutine/database.coffee: -------------------------------------------------------------------------------- 1 | assert = require("assert") 2 | { Collection } = require("./collection") 3 | { Db, Server } = require("mongodb") 4 | { EventEmitter } = require("events") 5 | { Pool } = require("generic-pool") 6 | { Model } = require("./model") 7 | # Cleanup on weak references. Optional for now because I don't know how well the 8 | # node-weak works for other people. 9 | try 10 | weak = require("weak") 11 | catch ex 12 | 13 | 14 | # Database configuration. This is basically a wrapped around the Mongodb 15 | # driver, specifically it's Db object. 16 | class Configuration 17 | constructor: (name, options = {})-> 18 | server = new Server(options.host || "127.0.0.1", options.port || 27017) 19 | @_client = new Db(name, server, options) 20 | @_pool = new Pool 21 | name: name 22 | create: (callback)=> 23 | @_client.open callback 24 | destroy: (connection)-> 25 | connection.close() 26 | max: options.pool || 50 27 | idleTimeoutMillis: 600000 28 | 29 | # Acquire new connection. 30 | acquire: (callback)-> 31 | @_pool.acquire callback 32 | 33 | # Releases open connection. 34 | release: (connection)-> 35 | @_pool.release connection 36 | 37 | 38 | 39 | # Represents a logical connection to the database. Calling mongo() returns a 40 | # new connection that you can use to access the database. 41 | # 42 | # Phytical connections are lazily initialized and pooled. 43 | class Database extends EventEmitter 44 | constructor: (@_configuration)-> 45 | @_collections = [] 46 | @db = @_configuration._client 47 | @ObjectID = require("mongodb").BSONPure.ObjectID 48 | # Tracks how many times we called begin, only release TCP connection 49 | # when zero. 50 | @_lock = 0 51 | if weak 52 | weak this, -> 53 | if @_connecting 54 | @_configuration.release @_connection 55 | return this 56 | 57 | # Use this if you need access to the MongoDB driver's connection object. It 58 | # passes, error, a connection and a reference to the end method. Don't forget 59 | # to call the end function once done with the connection. 60 | driver: (callback)-> 61 | assert callback instanceof Function, "This function requires a callback" 62 | end = @end.bind(this) 63 | # This is the TCP connection, which we use until it's returned to 64 | # the pool (see end method). 65 | if connection = @_connection 66 | @_lock += 1 67 | process.nextTick -> 68 | callback null, connection, end 69 | else 70 | this.once "connected", (connection)-> 71 | callback null, connection, end 72 | this.once "error", callback 73 | unless @_connecting 74 | # Pool looks at argument count, so we can't use => here. 75 | self = this 76 | self._connecting = true 77 | @_configuration.acquire (error, connection)-> 78 | self._connecting = false 79 | if error 80 | self.emit "error", error 81 | else 82 | self._connection = connection 83 | self._lock += self.listeners("connected").length 84 | self.emit "connected", connection 85 | return 86 | 87 | # Call this at the start of a sequence of operations that must all use 88 | # the same TCP connection. For example, if you're inserting an object 89 | # and immediately querying and expect to find it. 90 | # 91 | # If called with no arguments, returns a reference to the end method. 92 | # 93 | # If called with a callback, passes the end method to the callback. 94 | # 95 | # Examples 96 | # db.begin (end)-> 97 | # db.insert "posts", { text: "Find me" }, (err, id)-> 98 | # db.find("posts", id).one (err, post)-> 99 | # assert post 100 | # end() 101 | # 102 | # end = db.begin() 103 | # db.insert "posts", { text: "Find me" }, (err, id)-> 104 | # db.find("posts", id).one (err, post)-> 105 | # assert post 106 | # end() 107 | begin: (callback)-> 108 | @_lock += 1 109 | if callback 110 | callback @end.bind(this), this 111 | else 112 | return @end.find(this) 113 | 114 | # Call this at the end of a sequence of operations that must all use 115 | # the same TCP connection. See the `begin` method for examples. 116 | # 117 | # Every flow that calls `begin` once, must also call `end` once. If 118 | # you pass this object to another function that calls `begin`, it must 119 | # also call `end` before passing control back. 120 | end: -> 121 | @_lock -= 1 if @_lock > 0 122 | if @_lock == 0 && @_connection 123 | @_configuration.release @_connection 124 | delete @_connection 125 | 126 | # Returns the named collection. 127 | collection: (name)-> 128 | if name instanceof Function 129 | model = name 130 | name = model.collection_name 131 | assert name, "#{model.constructor}.collection_name is undefined, can't determine which collection to access" 132 | @_collections[name] ||= new Collection(name, this, model) 133 | 134 | # Finds all objects that match the query selector. 135 | # 136 | # If the last argument is a callback, load all matching objects and pass them 137 | # to callback. Callback receives error, objects and connection. 138 | # 139 | # Without callback, returns a new Scope object. 140 | # 141 | # The first argument is the collection name. Second argument is optional and 142 | # specifies the query selector. You can also pass an array of identifier to 143 | # return specific objects, or a single identifier to return a single object. 144 | # 145 | # Third argument is optional and specifies query options (limit, sort, etc). 146 | # When passing options you must also pass the query selector. 147 | # 148 | # Examples: 149 | # mongo().find "posts", { author_id: author._id }, limit: 50, (err, posts, db)-> 150 | # . . . 151 | # 152 | # mongo().find "posts", id, (err, post, db)-> 153 | # . . . 154 | # 155 | # scope = mongo().find("posts", author_id: author._id) 156 | # scope.all (err, posts, db)-> 157 | # . . . 158 | find: (name, selector, options, callback)-> 159 | return @collection(name).find(selector, options, callback) 160 | 161 | # Counts unique objects based on query selector, and passes error, count and 162 | # connection to callback. 163 | # 164 | # The first argument is the collection name. Second argument is optional and 165 | # specifies the query selector. 166 | # 167 | # Example: 168 | # mongo().count "posts", author_id: author._id, (err, count, db)-> 169 | # . . . 170 | count: (name, selector, callback)-> 171 | @collection(name).count selector, callback 172 | 173 | # Finds distinct values based on query selector, and passes error, values and 174 | # connection to callback. 175 | # 176 | # The first argument is the collection name. Second argument if the field 177 | # name. Third argument is optional and specifies the query selector. 178 | # 179 | # Example: 180 | # mongo().distinct "posts", "author_id", (err, author_ids, db)-> 181 | # . . . 182 | distinct: (name, key, selector, callback)-> 183 | @collection(name).distinct key, selector, callback 184 | 185 | 186 | # -- Insert/update/remove -- 187 | 188 | # Inserts document(s) into the database. 189 | # 190 | # If the document(s) do not have an ID, sets the ID before insertion. This method does not block, with a callback it 191 | # simply passes the document(s) to the callback. You can use the callback if you're inserting with `safe: true` or 192 | # want to wait for after-save hooks. 193 | # 194 | # When called with a single document, passes it to the callback. WHen called with an array, inserts all the documents 195 | # and passes that array to the callback. 196 | # 197 | # Example: 198 | # connect().insert "posts", title: "New and exciting" 199 | # 200 | # posts = [new Post(title: "First!")] 201 | # connect().insert posts 202 | insert: (name, object, options, callback)-> 203 | # First argument is a model, insert it. We have 3 arguments top in this case. 204 | if Model.isModel(name) 205 | [object, options, callback] = [name, object, options] 206 | @collection(object.constructor).insert object, options, callback 207 | return 208 | 209 | # First argument is an array of models, insert them. Array may be empty. 210 | if Array.isArray(name) 211 | [objects, options, callback] = [name, object, options] 212 | first = objects[0] 213 | if first 214 | @collection(first.constructor).insert objects, options, callback 215 | else if callback 216 | callback null, [] 217 | return 218 | 219 | # The more common case, we have multiple objects to insert into a named collection. 220 | @collection(name).insert object, options, callback 221 | 222 | 223 | exports.Configuration = Configuration 224 | exports.Database = Database 225 | -------------------------------------------------------------------------------- /lib/poutine/index.coffee: -------------------------------------------------------------------------------- 1 | { connect, configure } = require("./connect") 2 | { Model } = require("./model") 3 | 4 | exports.connect = connect 5 | exports.configure = configure 6 | exports.Model = Model 7 | -------------------------------------------------------------------------------- /lib/poutine/model.coffee: -------------------------------------------------------------------------------- 1 | # Basis for all Poutine models. 2 | 3 | 4 | assert = require("assert") 5 | connect = require("./connect").connect 6 | { BSONPure } = require("mongodb") 7 | 8 | 9 | # Use this class to easily define model classes that can be loaded and saved by Poutine. 10 | # 11 | # Model classes have all sorts of interesting capabilities like field mapping, validation, before/after hooks, etc. 12 | # 13 | # Example: 14 | # class User extends Poutine 15 | # @collection "users" 16 | # @field "name", String 17 | # @field "password", String 18 | # @set "password", (clear)-> 19 | # _.password = encrypt(clear) 20 | # 21 | # User.where(name: "Assaf").one (error, user)-> 22 | # console.log "Loaded #{user.name}" 23 | class Model 24 | # Default constructor assigns defined fields from any object you pass, e.g. 25 | # new User(name: "Assaf") 26 | constructor: (document)-> 27 | if document 28 | for name of @constructor.fields 29 | value = document[name] 30 | @[name] = value if value 31 | 32 | # -- Schema -- 33 | 34 | # ObjectID class. 35 | @ObjectID = BSONPure.ObjectID 36 | 37 | # Sets or returns the collection name (the collection_name property). 38 | # 39 | # Examples: 40 | # class Post extends Poutine 41 | # @collection "posts" 42 | # 43 | # console.log Port.collection() 44 | @collection: (name)-> 45 | @lifecycle.prepare this 46 | if name 47 | @collection_name = name 48 | else 49 | return @collection_name 50 | 51 | # Defines a field and adds property accessors. 52 | # 53 | # First argument is the field name, second argument is the field type (optional). 54 | # 55 | # Only defined fields are loaded and saved. Fields are loaded into the _ property, and accessors are defined to 56 | # get/set the field value. You can write your own accessors. 57 | # 58 | # Examples: 59 | # class User extends Poutine 60 | # @collection "users" 61 | # @field "name", String 62 | # @field "password", String 63 | # @set "password", (clear)-> 64 | # _.password = encrypt(clear) 65 | # 66 | # User.find().one (error, user)-> 67 | # console.log user.name 68 | @field: (name, type)-> 69 | assert name, "Missing field name" 70 | @lifecycle.prepare this 71 | @fields ||= {} 72 | @fields[name] = type || Object 73 | private = "_#{name}" 74 | @prototype.__defineGetter__ name, -> 75 | this._?[name] 76 | @prototype.__defineSetter__ name, (value)-> 77 | this._ ||= {} 78 | this._[name] = value 79 | 80 | # Example: 81 | # class Post extends Poutine 82 | # @collection "posts" 83 | # @field "author", Author 84 | # @get "author", -> 85 | # @author ||= Author.find(@author_id) 86 | # @set "author", (@author)-> 87 | # @author_id = @author?._id 88 | @get: (name, getter)-> 89 | assert name, "Missing property name" 90 | assert setter, "Missing getter function" 91 | @prototype.__defineGetter__ name, getter 92 | 93 | # Convenience method for adding a setter property accessor. 94 | # 95 | # Examples: 96 | # class User extends Poutine 97 | # @collection "users" 98 | # @field "password", String 99 | # @set "password", (clear)-> 100 | # _.password = encrypt(clear) 101 | @set: (name, setter)-> 102 | assert name, "Missing property name" 103 | assert setter, "Missing setter function" 104 | @prototype.__defineSetter__ name, setter 105 | 106 | # Returns true if we think the object is an instance of a model 107 | @isModel: (instance)-> 108 | model = instance.constructor 109 | return model.collection_name && model.fields 110 | 111 | 112 | # -- Finders -- 113 | 114 | # Finds all objects that match the query selector. 115 | # 116 | # If the last argument is a callback, load all matching objects and pass them to callback. Callback receives error, 117 | # objects and connection. 118 | # 119 | # Without callback, returns a new Scope object. 120 | # 121 | # The first argument is the query selector. You can also pass an array of identifiers to load specific objects, or a 122 | # single identifier to load a single object. If missing, all objects are loaded from the collection. 123 | # 124 | # The second argument are query options (limit, sort, etc). If you want to specify query options, you must also 125 | # specify a query selector. 126 | # 127 | # Examples: 128 | # Post.find { author_id: author._id }, limit: 50, (err, posts, db)-> 129 | # . . . 130 | # 131 | # Post.find id, (err, post, db)-> 132 | # . . . 133 | # 134 | # scope = Post.find(author_id: author._id) 135 | # scope.all (err, posts, db)-> 136 | # . . . 137 | @find: (selector, options, callback)-> 138 | connect().find(this, selector, options, callback) 139 | 140 | # Returns a Scope for selecting objects from this model. 141 | # 142 | # Example: 143 | # my_posts = Post.where(author_id: me._id).desc("created_at") 144 | # my_posts.count (err, count, db)-> 145 | # console.log "I wrote #{count} posts" 146 | @where: (selector)-> 147 | connect().find(this, selector) 148 | 149 | # Adds an afterLoaded hook, called after the model instance is set from the document. Raising an error will stop 150 | # loading any more objects. 151 | # 152 | # Example: 153 | # class User 154 | # @afterLoad (callback)-> 155 | # # Example. You don't really want to do this, since it will make 1+N queries. 156 | # Author.find @author_id, (error, author)=> 157 | # @author = author 158 | # callback error 159 | @afterLoad: (hook)-> 160 | @lifecycle.addHook this, "afterLoad", hook 161 | 162 | 163 | # -- Insert/update/remove -- 164 | 165 | @insert: (document, options, callback)-> 166 | connect().collection(this).insert document, options, callback 167 | 168 | @update: (selector, document, options, callback)-> 169 | connect().collection(this).update selector, document, options, callback 170 | 171 | @update_all: (selector, document, options, callback)-> 172 | if !callback && typeof options == "function" 173 | [options, callback] = [{}, options] 174 | options ||= {} 175 | options.multi = true 176 | connect().collection(this).update selector, document, options, callback 177 | 178 | @upsert: (selector, document, options, callback)-> 179 | if !callback && typeof options == "function" 180 | [options, callback] = [{}, options] 181 | options.upsert = true 182 | connect().collection(this).update selector, document, options, callback 183 | 184 | # Poutine uses these lifecycle methods to perform operations on models, but keeps them separate so we don't pollute the 185 | # Model prototype with methods that are never used directly by actual model classes. Inheriting from a class that has 186 | # hundreds of implementation methods is an anti-pattern we dislike. 187 | Model.lifecycle = 188 | # Used to instantiate a new instance from a loaded object. 189 | load: (model, document, callback)-> 190 | instance = new model() 191 | instance._ = document 192 | @callHook "afterLoad", instance, callback, document 193 | 194 | # Need to call this at least once per model. Takes care of defining accessors for _id, ... 195 | prepare: (model)-> 196 | unless model._id 197 | model.prototype.__defineGetter__ "_id", -> 198 | this._?._id 199 | model.prototype.__defineSetter__ "_id", (id)-> 200 | this._ ||= {} 201 | this._._id = id 202 | 203 | 204 | # -- Hooks -- 205 | 206 | # Add the named hook. 207 | # model - The model 208 | # hook - Hook name 209 | # fn - Function to be called 210 | addHook: (model, name, fn)-> 211 | assert fn, "This method requires a function argument" 212 | named = model._hooks ||= {} 213 | hooks = named[name] ||= [] 214 | hooks.push fn 215 | 216 | # Call the named hooks and pass control to callback when done. 217 | # name - The hook name, e.g. beforeSave 218 | # model - The model class 219 | # instance - The model instance 220 | # args - Optional arguments to pass to hooks 221 | callHook: (name, instance, callback, args...)-> 222 | model = instance.constructor 223 | hooks = model._hooks?[name] 224 | # No hooks, just go back to callback 225 | unless hooks 226 | callback null, instance 227 | return 228 | 229 | # Call the next hook in the chain, until we're done or get an error. 230 | call = (hooks, index)-> 231 | hook = hooks[index] 232 | if hook 233 | try 234 | # If we get a result, continue to next hook, otherwise, have callback deal with it. 235 | result = hook.call(instance, args..., (error)-> 236 | return callback error if error 237 | if result 238 | process.emit "error", new Error("#{name} hook on #{model.name}/#{instance._id} returned value *and* called callback") 239 | else 240 | call hooks, index + 1 241 | ) 242 | if result 243 | call hooks, index + 1 244 | catch error 245 | callback error 246 | else 247 | callback null, instance 248 | call hooks, 0 249 | 250 | 251 | exports.Model = Model 252 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { "name": "poutine", 2 | "description": "MongoDB object document mapper made of unicorns", 3 | "version": "0.2.0", 4 | "contributors": [ 5 | "Assaf Arkin (http://labnotes.org/)", 6 | "Jerome Gravel-Niquet (http://jgn.me/)" 7 | ], 8 | "keywords": [], 9 | "main": "./lib/poutine", 10 | "directories": { 11 | "doc": "./doc", 12 | "lib": "./lib" 13 | }, 14 | "engines": { 15 | "node": "~v0.6.0" 16 | }, 17 | "scripts": { 18 | "test": "vows --spec" 19 | }, 20 | "dependencies": { 21 | "coffee-script": "~1.1.3", 22 | "mongodb": "~0.9.7", 23 | "generic-pool": "~1.0.7", 24 | "weak": "~0.1.1" 25 | }, 26 | "devDependencies": { 27 | "vows": "~0.6.0" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/jeromegn/poutine" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/jeromegn/poutine/issues" 35 | }, 36 | "licenses": [ 37 | { "type": "MIT", 38 | "url": "https://github.com/jeromegn/poutine/blob/master/MIT-LICENSE" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /spec/collection/cursor_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup } = require("../helpers") 2 | 3 | 4 | vows.describe("Cursor").addBatch 5 | 6 | "find next": 7 | topic: -> 8 | setup => 9 | scope = connect().find("posts") 10 | posts = [] 11 | each = (error, post)=> 12 | if post 13 | posts.push post 14 | scope.next each 15 | else 16 | scope.close() 17 | @callback null, posts 18 | scope.next each 19 | "should call once for each post": (posts)-> 20 | assert.lengthOf posts, 3 21 | 22 | "rewind": 23 | topic: -> 24 | setup => 25 | scope = connect().find("posts").where(title: "Post 2") 26 | posts = [] 27 | each = (error, post)=> 28 | if post 29 | posts.push post 30 | scope.next each 31 | else if posts.length == 1 32 | scope.rewind() 33 | scope.next each 34 | else 35 | scope.close() 36 | @callback null, posts 37 | scope.next each 38 | "should call once for each post": (posts)-> 39 | assert.lengthOf posts, 2 40 | 41 | 42 | .export(module) 43 | -------------------------------------------------------------------------------- /spec/collection/insert_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup } = require("../helpers") 2 | 3 | 4 | vows.describe("Collection insert").addBatch 5 | 6 | # -- collection().insert -- 7 | 8 | "insert": 9 | topic: -> 10 | setup => 11 | @callback null, connect().collection("posts") 12 | 13 | "document only": 14 | topic: (collection)-> 15 | @post = { title: "Insert 2.1" } 16 | result = collection.insert(@post) 17 | return result || "nothing" 18 | "should return null": (result)-> 19 | assert.equal result, "nothing" 20 | "should set document ID": -> 21 | assert @post._id 22 | "new document": 23 | topic: (result, collection)-> 24 | collection.find(title: "Insert 2.1").one @callback 25 | "should exist in database": (object)-> 26 | assert object 27 | 28 | "document and options": 29 | topic: (collection)-> 30 | result = collection.insert({ title: "Insert 2.2" }, { safe: true }) 31 | return result || "nothing" 32 | "should return null": (result)-> 33 | assert.equal result, "nothing" 34 | "new document": 35 | topic: (result, collection)-> 36 | collection.find(title: "Insert 2.2").one @callback 37 | "should exist in database": (object)-> 38 | assert object 39 | 40 | "document, options and callback": 41 | topic: (collection)-> 42 | collection.insert { title: "Insert 2.3" }, { safe: true }, @callback 43 | "should pass document to callback": (post)-> 44 | assert post 45 | assert.equal post.title, "Insert 2.3" 46 | "should set document ID": (post)-> 47 | assert post._id 48 | "new document": 49 | topic: (post, collection)-> 50 | collection.find post._id, @callback 51 | "should exist in database": (post)-> 52 | assert post 53 | assert.equal post.title, "Insert 2.3" 54 | 55 | "document and callback": 56 | topic: (collection)-> 57 | collection.insert title: "Insert 2.4", @callback 58 | "should pass document to callback": (post)-> 59 | assert post 60 | assert.equal post.title, "Insert 2.4" 61 | "should set document ID": (post)-> 62 | assert post._id 63 | "new document": 64 | topic: (post, collection)-> 65 | collection.find post._id, @callback 66 | "should exist in database": (post)-> 67 | assert post 68 | assert.equal post.title, "Insert 2.4" 69 | 70 | "multiple documents, no callback": 71 | topic: (collection)-> 72 | @posts = [{ title: "Insert 2.5", category: "foo" }, { title: "Insert 2.5", category: "bar" }] 73 | result = collection.insert(@posts) 74 | return result || "nothing" 75 | "should return null": (result)-> 76 | assert.equal result, "nothing" 77 | "should set document ID": -> 78 | for post in @posts 79 | assert post._id 80 | "new documents": 81 | topic: (result, collection)-> 82 | collection.find(title: "Insert 2.5").all @callback 83 | "should all exist in database": (posts)-> 84 | assert.lengthOf posts, 2 85 | categories = (post.category for post in posts) 86 | assert.include categories, "foo" 87 | assert.include categories, "bar" 88 | 89 | "multiple documents and callback": 90 | topic: (collection)-> 91 | collection.insert [{ title: "Insert 2.6", category: "foo" }, { title: "Insert 2.6", category: "bar" }], @callback 92 | "should pass all document to callback": (posts)-> 93 | assert.lengthOf posts, 2 94 | for post in posts 95 | assert.equal post.title, "Insert 2.6" 96 | "should set document ID": (posts)-> 97 | for post in posts 98 | assert post._id 99 | "new documents": 100 | topic: (posts, collection)-> 101 | ids = (post._id for post in posts) 102 | collection.find ids, @callback 103 | "should exist in database": (posts)-> 104 | assert.lengthOf posts, 2 105 | categories = (post.category for post in posts) 106 | assert.include categories, "foo" 107 | assert.include categories, "bar" 108 | 109 | 110 | .export(module) 111 | -------------------------------------------------------------------------------- /spec/collection/query_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup } = require("../helpers") 2 | 3 | 4 | vows.describe("Collection query").addBatch 5 | 6 | # -- collection().find() -- 7 | 8 | "find": 9 | topic: -> 10 | setup (error)=> 11 | @callback error, connect().collection("posts") 12 | 13 | "query, options and callback": 14 | topic: (collection)-> 15 | collection.find { author_id: 1 }, fields: ["title"], @callback 16 | "should return all matching posts": (posts)-> 17 | assert.lengthOf posts, 2 18 | "should return specified fields": (posts)-> 19 | for post in posts 20 | assert post.title 21 | 22 | "query and callback": 23 | topic: (collection)-> 24 | collection.find author_id: 1, @callback 25 | "should return all matching posts": (posts)-> 26 | assert.lengthOf posts, 2 27 | 28 | "query only": 29 | topic: (collection)-> 30 | collection.find author_id: 1 31 | "should return a Scope object": (scope)-> 32 | assert scope.where && scope.count 33 | 34 | "IDs, options and callback": 35 | topic: (collection)-> 36 | collection.distinct "_id", (err, ids)=> 37 | collection.find ids, fields: ["title"], @callback 38 | "should return all matching posts": (posts)-> 39 | assert.lengthOf posts, 3 40 | "should return specified fields": (posts)-> 41 | for post in posts 42 | assert post.title 43 | assert !post.author_id 44 | 45 | "IDs only": 46 | topic: (collection)-> 47 | collection.distinct "_id", (err, ids)=> 48 | @callback null, collection.find(ids) 49 | "should return a Scope": (scope)-> 50 | assert scope.all && scope.one 51 | "executed": 52 | topic: (scope)-> 53 | scope.all @callback 54 | "should find all objects": (posts)-> 55 | assert.lengthOf posts, 3 56 | 57 | "ID, options and callback": 58 | topic: (collection)-> 59 | collection.all (err, posts, db)=> 60 | id = (post._id for post in posts)[1] 61 | collection.find id, fields: ["title"], @callback 62 | "should return a single post": (post)-> 63 | assert.equal post.title, "Post 2" 64 | "should return specified fields": (post)-> 65 | assert !post.author_id 66 | 67 | "ID and callback": 68 | topic: (collection)-> 69 | collection.all (err, posts, db)=> 70 | id = (post._id for post in posts)[1] 71 | collection.find id, @callback 72 | "should return a single post": (post)-> 73 | assert.equal post.title, "Post 2" 74 | "should return all fields": (post)-> 75 | assert post.author_id 76 | assert post.title 77 | 78 | "ID only": 79 | topic: (collection)-> 80 | collection.all (err, posts, db)=> 81 | id = (post._id for post in posts)[1] 82 | @callback null, collection.find(id) 83 | "should return a Scope": (scope)-> 84 | assert scope.all && scope.one 85 | "executed": 86 | topic: (scope)-> 87 | scope.one @callback 88 | "should find specific object": (post)-> 89 | assert.equal post.title, "Post 2" 90 | 91 | 92 | .addBatch 93 | 94 | # -- collection().one() -- 95 | 96 | "one": 97 | topic: -> 98 | setup (error)=> 99 | @callback error, connect().collection("posts") 100 | 101 | "query, options and callback": 102 | topic: (collection)-> 103 | collection.one { author_id: 1 }, fields: ["author_id"], @callback 104 | "should return matching post": (post)-> 105 | assert.equal post.author_id, 1 106 | "should return specified fields": (post)-> 107 | assert post.author_id 108 | assert !post.title 109 | 110 | "query and callback": 111 | topic: (collection)-> 112 | collection.one author_id: 1, @callback 113 | "should return matching post": (post)-> 114 | assert.equal post.author_id, 1 115 | 116 | "no callback": 117 | topic: (collection)-> 118 | try 119 | collection.one author_id: 1 120 | @callback null 121 | catch ex 122 | @callback null, ex 123 | "should fail": (error)-> 124 | assert.instanceOf error, Error 125 | 126 | "ID, options and callback": 127 | topic: (collection)-> 128 | collection.all (err, posts, db)=> 129 | id = (post._id for post in posts)[1] 130 | collection.one id, fields: ["title"], @callback 131 | "should return matching post": (post)-> 132 | assert.equal post.title, "Post 2" 133 | "should return specified fields": (post)-> 134 | assert post.title 135 | assert !post.author_id 136 | 137 | "ID and callback": 138 | topic: (collection)-> 139 | collection.all (err, posts, db)=> 140 | id = (post._id for post in posts)[1] 141 | collection.one id, @callback 142 | "should return a single post": (post)-> 143 | assert.equal post.title, "Post 2" 144 | "should return all fields": (post)-> 145 | assert post.author_id 146 | assert post.title 147 | 148 | 149 | .addBatch 150 | 151 | # -- collection().all() -- 152 | 153 | "all": 154 | topic: -> 155 | setup (error)=> 156 | @callback error, connect().collection("posts") 157 | 158 | "query, options and callback": 159 | topic: (collection)-> 160 | collection.all { author_id: 1 }, fields: ["title"], @callback 161 | "should return all matching posts": (posts)-> 162 | assert.lengthOf posts, 2 163 | "should return specified fields": (posts)-> 164 | for post in posts 165 | assert post.title 166 | 167 | "query and callback": 168 | topic: (collection)-> 169 | collection.all author_id: 1, @callback 170 | "should return all matching posts": (posts)-> 171 | assert.lengthOf posts, 2 172 | 173 | "no callback": 174 | topic: (collection)-> 175 | try 176 | collection.all author_id: 1 177 | @callback null 178 | catch ex 179 | @callback null, ex 180 | "should fail": (error)-> 181 | assert.instanceOf error, Error 182 | 183 | "IDs, options and callback": 184 | topic: (collection)-> 185 | collection.distinct "_id", (err, ids)=> 186 | collection.all ids, fields: ["title"], @callback 187 | "should return all matching posts": (posts)-> 188 | assert.lengthOf posts, 3 189 | "should return specified fields": (posts)-> 190 | for post in posts 191 | assert post.title 192 | assert !post.author_id 193 | 194 | 195 | .addBatch 196 | 197 | # -- collection().each() -- 198 | 199 | "each": 200 | topic: -> 201 | setup (error)=> 202 | @callback error, connect().collection("posts") 203 | 204 | "query, options and callback": 205 | topic: (collection)-> 206 | posts = [] 207 | collection.each { author_id: 1 }, fields: ["title"], (error, post)=> 208 | if post 209 | posts.push post 210 | else 211 | @callback null, posts 212 | "should return all matching posts": (posts)-> 213 | assert.lengthOf posts, 2 214 | "should return specified fields": (posts)-> 215 | for post in posts 216 | assert post.title 217 | 218 | "query and callback": 219 | topic: (collection)-> 220 | posts = [] 221 | collection.each author_id: 1, (error, post)=> 222 | if post 223 | posts.push post 224 | else 225 | @callback null, posts 226 | "should return all matching posts": (posts)-> 227 | assert.lengthOf posts, 2 228 | 229 | "no callback": 230 | topic: (collection)-> 231 | try 232 | collection.each author_id: 1 233 | @callback null 234 | catch ex 235 | @callback null, ex 236 | "should fail": (error)-> 237 | assert.instanceOf error, Error 238 | 239 | "IDs, options and callback": 240 | topic: (collection)-> 241 | posts = [] 242 | collection.distinct "_id", (err, ids)=> 243 | collection.each ids, fields: ["title"], (error, post)=> 244 | if post 245 | posts.push post 246 | else 247 | @callback null, posts 248 | "should return all matching posts": (posts)-> 249 | assert.lengthOf posts, 3 250 | "should return specified fields": (posts)-> 251 | for post in posts 252 | assert post.title 253 | assert !post.author_id 254 | 255 | 256 | .addBatch 257 | 258 | # -- collection().count() -- 259 | 260 | "count": 261 | topic: -> 262 | setup (error)=> 263 | @callback error, connect().collection("posts") 264 | 265 | "query and callback": 266 | topic: (collection)-> 267 | collection.count author_id: 1, @callback 268 | "should return number of posts": (count)-> 269 | assert.equal count, 2 270 | 271 | "callback only": 272 | topic: (collection)-> 273 | collection.count @callback 274 | "should return number of posts": (count)-> 275 | assert.equal count, 3 276 | 277 | "no callback": 278 | topic: (collection)-> 279 | try 280 | collection.count author_id: 1 281 | @callback null 282 | catch ex 283 | @callback null, ex 284 | "should fail": (error)-> 285 | assert.instanceOf error, Error 286 | 287 | 288 | .addBatch 289 | 290 | # -- collection().distinct() -- 291 | 292 | "distinct": 293 | topic: -> 294 | setup (error)=> 295 | @callback error, connect().collection("posts") 296 | 297 | "query and callback": 298 | topic: (collection)-> 299 | collection.distinct "title", author_id: 1, @callback 300 | "should return distinct values": (values)-> 301 | assert.lengthOf values, 2 302 | assert.deepEqual values, ["Post 1", "Post 2"] 303 | 304 | "callback only": 305 | topic: (collection)-> 306 | collection.distinct "title", @callback 307 | "should return distinct values": (values)-> 308 | assert.lengthOf values, 3 309 | assert.deepEqual values, ["Post 1", "Post 2", "Post 3"] 310 | 311 | "no callback": 312 | topic: (collection)-> 313 | try 314 | collection.distinct "title" 315 | @callback null 316 | catch ex 317 | @callback null, ex 318 | "should fail": (error)-> 319 | assert.instanceOf error, Error 320 | 321 | 322 | .addBatch 323 | 324 | # -- collection().where() -- 325 | 326 | "where": 327 | topic: -> 328 | setup (error)=> 329 | @callback error, connect().collection("posts") 330 | 331 | "no criteria": 332 | topic: (collection)-> 333 | collection.where() 334 | "should return a Scope": (scope)-> 335 | assert scope.all && scope.one 336 | "executed": 337 | topic: (scope)-> 338 | scope.all @callback 339 | "should find all objects in the collection": (posts)-> 340 | assert.lengthOf posts, 3 341 | assert.include (post.title for post in posts), "Post 2" 342 | 343 | "with criteria": 344 | topic: (collection)-> 345 | scope = collection.where(author_id: 1) 346 | scope.all @callback 347 | "should find specific objects in the collection": (posts)-> 348 | assert.lengthOf posts, 2 349 | for post in posts 350 | assert.equal post.author_id, 1 351 | 352 | "nested": 353 | topic: (collection)-> 354 | scope = collection.where(author_id: 1) 355 | scope = scope.where(title: "Post 2") 356 | scope.all @callback 357 | "should use combined scope": (posts)-> 358 | assert.lengthOf posts, 1 359 | assert.equal posts[0].title, "Post 2" 360 | 361 | 362 | .export(module) 363 | -------------------------------------------------------------------------------- /spec/collection/scope_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup } = require("../helpers") 2 | 3 | 4 | vows.describe("Scope queries").addBatch 5 | 6 | # -- scope.fields() -- 7 | 8 | "fields": 9 | topic: -> 10 | setup (error)=> 11 | @callback error, connect().find("posts") 12 | 13 | "no argument": 14 | topic: (scope)-> 15 | scope.fields().all @callback 16 | "should return no fields": (posts)-> 17 | for post in posts 18 | assert.lengthOf Object.keys(post), 1 19 | 20 | "single argument": 21 | topic: (scope)-> 22 | scope.fields("title").all @callback 23 | "should return only specified field": (posts)-> 24 | for post in posts 25 | assert post.title 26 | assert !post.author_id 27 | assert !post.created_at 28 | 29 | "multiple arguments": 30 | topic: (scope)-> 31 | scope.fields("title", "author_id").all @callback 32 | "should return only specified field": (posts)-> 33 | for post in posts 34 | assert post.title 35 | assert post.author_id 36 | assert !post.created_at 37 | 38 | "array arguments": 39 | topic: (scope)-> 40 | scope.fields(["title", "created_at"]).all @callback 41 | "should return only specified field": (posts)-> 42 | for post in posts 43 | assert post.title 44 | assert !post.author_id 45 | assert post.created_at 46 | 47 | 48 | .addBatch 49 | 50 | # -- scope.asc() scope.desc() -- 51 | 52 | "asc": 53 | topic: -> 54 | setup (error)=> 55 | @callback error, connect().find("posts") 56 | 57 | "no argument": 58 | topic: (scope)-> 59 | scope.asc().all @callback 60 | "should return in natural order": (posts)-> 61 | titles = (post.title for post in posts) 62 | assert.deepEqual titles, ["Post 1", "Post 2", "Post 3"] 63 | 64 | "single argument": 65 | topic: (scope)-> 66 | scope.asc("category").all @callback 67 | "should return in specified order": (posts)-> 68 | titles = (post.title for post in posts) 69 | assert.deepEqual titles, ["Post 2", "Post 1", "Post 3"] 70 | 71 | "multiple arguments": 72 | topic: (scope)-> 73 | scope.asc("category", "title").all @callback 74 | "should return in specified order": (posts)-> 75 | titles = (post.title for post in posts) 76 | assert.deepEqual titles, ["Post 2", "Post 1", "Post 3"] 77 | 78 | "array arguments": 79 | topic: (scope)-> 80 | scope.asc(["category", "title"]).all @callback 81 | "should return in specified order": (posts)-> 82 | titles = (post.title for post in posts) 83 | assert.deepEqual titles, ["Post 2", "Post 1", "Post 3"] 84 | 85 | 86 | "desc": 87 | topic: -> 88 | setup (error)=> 89 | @callback error, connect().find("posts") 90 | 91 | "no argument": 92 | topic: (scope)-> 93 | scope.desc().all @callback 94 | "should return in natural order": (posts)-> 95 | titles = (post.title for post in posts) 96 | assert.deepEqual titles, ["Post 1", "Post 2", "Post 3"] 97 | 98 | "single argument": 99 | topic: (scope)-> 100 | scope.desc("title").all @callback 101 | "should return in specified order": (posts)-> 102 | titles = (post.title for post in posts) 103 | assert.deepEqual titles, ["Post 3", "Post 2", "Post 1"] 104 | 105 | "multiple arguments": 106 | topic: (scope)-> 107 | scope.desc("title", "category").all @callback 108 | "should return in specified order": (posts)-> 109 | titles = (post.title for post in posts) 110 | assert.deepEqual titles, ["Post 3", "Post 2", "Post 1"] 111 | 112 | "array arguments": 113 | topic: (scope)-> 114 | scope.desc(["title", "category"]).all @callback 115 | "should return in specified order": (posts)-> 116 | titles = (post.title for post in posts) 117 | assert.deepEqual titles, ["Post 3", "Post 2", "Post 1"] 118 | 119 | 120 | "combined asc, desc": 121 | topic: -> 122 | setup (error)=> 123 | @callback error, connect().find("posts") 124 | 125 | "same order": 126 | topic: (scope)-> 127 | scope.asc("category").desc("title").all @callback 128 | "should return in specified order": (posts)-> 129 | titles = (post.title for post in posts) 130 | assert.deepEqual titles, ["Post 2", "Post 3", "Post 1"] 131 | 132 | "mixed order": 133 | topic: (scope)-> 134 | scope.asc("category").asc("title").all @callback 135 | "should return in specified order": (posts)-> 136 | titles = (post.title for post in posts) 137 | assert.deepEqual titles, ["Post 2", "Post 1", "Post 3"] 138 | 139 | 140 | .addBatch 141 | 142 | # -- scope.limit() scope.skip() -- 143 | 144 | "limit": 145 | topic: -> 146 | setup => 147 | scope = connect().find("posts") 148 | scope.limit(1).all @callback 149 | 150 | "should return specified number of objects": (posts)-> 151 | assert.lengthOf posts, 1 152 | 153 | "should return first results": (posts)-> 154 | titles = (post.title for post in posts) 155 | assert.deepEqual titles, ["Post 1"] 156 | 157 | 158 | "skip": 159 | topic: -> 160 | setup => 161 | scope = connect().find("posts") 162 | scope.skip(1).all @callback 163 | 164 | "should return specified number of objects": (posts)-> 165 | assert.lengthOf posts, 2 166 | 167 | "should skip first result": (posts)-> 168 | titles = (post.title for post in posts) 169 | assert.deepEqual titles, ["Post 2", "Post 3"] 170 | 171 | 172 | "limit, skip": 173 | topic: -> 174 | setup => 175 | scope = connect().find("posts") 176 | scope.skip(1).limit(1).all @callback 177 | 178 | "should return specified number of objects": (posts)-> 179 | assert.lengthOf posts, 1 180 | 181 | "should skip first result, return only one": (posts)-> 182 | titles = (post.title for post in posts) 183 | assert.deepEqual titles, ["Post 2"] 184 | 185 | 186 | .addBatch 187 | 188 | # -- scope.one() scope.each() scope.all() -- 189 | 190 | "one": 191 | topic: -> 192 | setup => 193 | connect().find("posts").where(title: "Post 2").one @callback 194 | "should return one object": (post)-> 195 | assert post 196 | assert.equal post.title, "Post 2" 197 | 198 | 199 | "each": 200 | topic: -> 201 | setup => 202 | titles = [] 203 | connect().find("posts").where(author_id: 1).each (error, post)=> 204 | if post 205 | titles.push post.title 206 | else 207 | @callback null, titles 208 | "should pass each object, then null": (titles)-> 209 | assert.lengthOf titles, 2 210 | assert.deepEqual titles, ["Post 1", "Post 2"] 211 | 212 | 213 | "all": 214 | topic: -> 215 | setup => 216 | connect().find("posts").where(author_id: 1).all @callback 217 | "should return all matching objects": (posts)-> 218 | assert.lengthOf posts, 2 219 | for post in posts 220 | assert.equal post.author_id, 1 221 | 222 | 223 | .addBatch 224 | 225 | # -- scope.count() scope.distinct() -- 226 | 227 | "count": 228 | topic: -> 229 | setup => 230 | connect().find("posts").where(author_id: 1).count @callback 231 | "should return number of matching objects": (count)-> 232 | assert.equal count, 2 233 | 234 | 235 | "distinct": 236 | topic: -> 237 | setup => 238 | connect().find("posts").where(author_id: 1).distinct "title", @callback 239 | "should return distinct values": (titles)-> 240 | assert.deepEqual titles, ["Post 1", "Post 2"] 241 | 242 | 243 | .addBatch 244 | 245 | # -- scope.map() scope.filter() scope.reduce() -- 246 | 247 | "map": 248 | topic: -> 249 | setup (error)=> 250 | @callback error, connect().find("posts") 251 | "function": 252 | topic: (scope)-> 253 | scope.map ((post)-> post.title + "!"), @callback 254 | "should return mapped objects": (titles)-> 255 | assert.deepEqual titles, ["Post 1!", "Post 2!", "Post 3!"] 256 | 257 | "name": 258 | topic: (scope)-> 259 | scope.map "title", @callback 260 | "should return mapped objects": (titles)-> 261 | assert.deepEqual titles, ["Post 1", "Post 2", "Post 3"] 262 | 263 | 264 | "filter": 265 | topic: -> 266 | setup (error)=> 267 | @callback error, connect().find("posts") 268 | "function": 269 | topic: (scope)-> 270 | scope.filter ((post)-> post.author_id < 2), @callback 271 | "should return filtered objects": (posts)-> 272 | for post in posts 273 | assert post.author_id < 2 274 | 275 | "name": 276 | topic: (scope)-> 277 | scope.filter "title", @callback 278 | "should return filtered objects": (posts)-> 279 | assert.lengthOf posts, 3 280 | 281 | 282 | "reduce": 283 | topic: -> 284 | setup (error)=> 285 | @callback error, connect().find("posts") 286 | "initial value and function": 287 | topic: (scope)-> 288 | scope.reduce 0, ((memo, post)-> memo + post.title.length), @callback 289 | "should return reduced value": (total)-> 290 | assert.equal total, 18 291 | 292 | "function only": 293 | topic: (scope)-> 294 | scope.reduce ((memo, post)-> memo + post.title.length), @callback 295 | "should return reduced value": (total)-> 296 | assert.equal total, 18 297 | 298 | 299 | .export(module) 300 | -------------------------------------------------------------------------------- /spec/connection/insert_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup, Model } = require("../helpers") 2 | 3 | 4 | class Post extends Model 5 | @collection "posts" 6 | @field "title" 7 | @field "category" 8 | 9 | 10 | vows.describe("Connection insert").addBatch 11 | 12 | # -- connect().insert -- 13 | 14 | "insert": 15 | topic: -> 16 | setup @callback 17 | 18 | "document only": 19 | topic: -> 20 | @post = { "posts", title: "Insert 1.1" } 21 | result = connect().insert("posts", @post) 22 | return result || "nothing" 23 | "should return null": (result)-> 24 | assert.equal result, "nothing" 25 | "should set document ID": -> 26 | assert @post._id 27 | "new document": 28 | topic: -> 29 | connect().find("posts", title: "Insert 1.1").one @callback 30 | "should exist in database": (object)-> 31 | assert object 32 | 33 | "document and options": 34 | topic: -> 35 | result = connect().insert("posts", { title: "Insert 1.2" }, { safe: true }) 36 | return result || "nothing" 37 | "should return null": (result)-> 38 | assert.equal result, "nothing" 39 | "new document": 40 | topic: -> 41 | connect().find("posts", title: "Insert 1.2").one @callback 42 | "should exist in database": (object)-> 43 | assert object 44 | 45 | "document, options and callback": 46 | topic: -> 47 | connect().insert "posts", { title: "Insert 1.3" }, { safe: true }, @callback 48 | "should pass document to callback": (post)-> 49 | assert post 50 | assert.equal post.title, "Insert 1.3" 51 | "should set document ID": (post)-> 52 | assert post._id 53 | "new document": 54 | topic: (post)-> 55 | connect().find "posts", post._id, @callback 56 | "should exist in database": (post)-> 57 | assert post 58 | assert.equal post.title, "Insert 1.3" 59 | 60 | "document and callback": 61 | topic: -> 62 | connect().insert "posts", title: "Insert 1.4", @callback 63 | "should pass document to callback": (post)-> 64 | assert post 65 | assert.equal post.title, "Insert 1.4" 66 | "should set document ID": (post)-> 67 | assert post._id 68 | "new document": 69 | topic: (post)-> 70 | connect().find "posts", post._id, @callback 71 | "should exist in database": (post)-> 72 | assert post 73 | assert.equal post.title, "Insert 1.4" 74 | 75 | "multiple documents, no callback": 76 | topic: -> 77 | @posts = [{ title: "Insert 1.5", category: "foo" }, { title: "Insert 1.5", category: "bar" }] 78 | result = connect().insert("posts", @posts) 79 | return result || "nothing" 80 | "should return null": (result)-> 81 | assert.equal result, "nothing" 82 | "should set document ID": -> 83 | for post in @posts 84 | assert post._id 85 | "new documents": 86 | topic: -> 87 | connect().find("posts", title: "Insert 1.5").all @callback 88 | "should all exist in database": (posts)-> 89 | assert.lengthOf posts, 2 90 | categories = (post.category for post in posts) 91 | assert.include categories, "foo" 92 | assert.include categories, "bar" 93 | 94 | "multiple documents and callback": 95 | topic: -> 96 | connect().insert "posts", [{ title: "Insert 1.6", category: "foo" }, { title: "Insert 1.6", category: "bar" }], @callback 97 | "should pass all document to callback": (posts)-> 98 | assert.lengthOf posts, 2 99 | for post in posts 100 | assert.equal post.title, "Insert 1.6" 101 | "should set document ID": (posts)-> 102 | for post in posts 103 | assert post._id 104 | "new documents": 105 | topic: (posts)-> 106 | ids = (post._id for post in posts) 107 | connect().find "posts", ids, @callback 108 | "should exist in database": (posts)-> 109 | assert.lengthOf posts, 2 110 | categories = (post.category for post in posts) 111 | assert.include categories, "foo" 112 | assert.include categories, "bar" 113 | 114 | "model and callback": 115 | topic: -> 116 | post = new Post(title: "Insert 1.7") 117 | connect().insert post, @callback 118 | "should pass document to callback": (post)-> 119 | assert post 120 | assert.equal post.title, "Insert 1.7" 121 | "should set document ID": (post)-> 122 | assert post._id 123 | "should pass a model to callback": (post)-> 124 | assert.instanceOf post, Post 125 | "new document": 126 | topic: (post)-> 127 | connect().find "posts", post._id, @callback 128 | "should exist in database": (post)-> 129 | assert post 130 | assert.equal post.title, "Insert 1.7" 131 | 132 | "multiple models and callback": 133 | topic: -> 134 | posts = [new Post(title: "Insert 1.8", category: "foo"), new Post(title: "Insert 1.8", category: "bar")] 135 | connect().insert posts, @callback 136 | "should pass all document to callback": (posts)-> 137 | assert.lengthOf posts, 2 138 | for post in posts 139 | assert.equal post.title, "Insert 1.8" 140 | "should set document ID": (posts)-> 141 | for post in posts 142 | assert post._id 143 | "should pass models to callback": (posts)-> 144 | for post in posts 145 | assert.instanceOf post, Post 146 | "new documents": 147 | topic: (posts)-> 148 | ids = (post._id for post in posts) 149 | connect().find "posts", ids, @callback 150 | "should exist in database": (posts)-> 151 | assert.lengthOf posts, 2 152 | categories = (post.category for post in posts) 153 | assert.include categories, "foo" 154 | assert.include categories, "bar" 155 | 156 | 157 | .export(module) 158 | -------------------------------------------------------------------------------- /spec/connection/query_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup } = require("../helpers") 2 | 3 | 4 | vows.describe("Connection query").addBatch 5 | 6 | # -- connect().find -- 7 | 8 | "find": 9 | topic: -> 10 | setup @callback 11 | "query, options and callback": 12 | topic: -> 13 | connect().find "posts", { author_id: 1 }, sort: [["title", -1]], @callback 14 | "should return all posts": (posts)-> 15 | assert.lengthOf posts, 2 16 | for post in posts 17 | assert.equal post.author_id, 1 18 | "should return all posts in order": (posts)-> 19 | title = (post.title for post in posts) 20 | assert.deepEqual title, ["Post 2", "Post 1"] 21 | 22 | "query and callback": 23 | topic: -> 24 | connect().find "posts", author_id: 1, @callback 25 | "should return all posts": (posts)-> 26 | assert.lengthOf posts, 2 27 | for post in posts 28 | assert.equal post.author_id, 1 29 | 30 | "callback only": 31 | topic: -> 32 | connect().find "posts", @callback 33 | "should return all posts": (posts)-> 34 | assert.lengthOf posts, 3 35 | 36 | "query and options": 37 | topic: -> 38 | scope = connect().find("posts", { author_id: 1 }, sort: [["title", -1]]) 39 | scope.all @callback 40 | "should return all posts": (posts)-> 41 | assert.lengthOf posts, 2 42 | for post in posts 43 | assert.equal post.author_id, 1 44 | "should return all posts in order": (posts)-> 45 | title = (post.title for post in posts) 46 | assert.deepEqual title, ["Post 2", "Post 1"] 47 | 48 | "query only": 49 | topic: -> 50 | scope = connect().find("posts", author_id: 1) 51 | scope.all @callback 52 | "should return all posts": (posts)-> 53 | assert.lengthOf posts, 2 54 | for post in posts 55 | assert.equal post.author_id, 1 56 | 57 | "no arguments": 58 | topic: -> 59 | scope = connect().find("posts") 60 | scope.all @callback 61 | "should return all posts": (posts)-> 62 | assert.lengthOf posts, 3 63 | 64 | "IDs, options and callback": 65 | topic: -> 66 | connect().distinct "posts", "_id", (err, ids, db)=> 67 | db.find "posts", ids, sort: [["title", -1]], @callback 68 | "should return all posts": (posts)-> 69 | assert.lengthOf posts, 3 70 | "should return all posts in order": (posts)-> 71 | title = (post.title for post in posts) 72 | assert.deepEqual title, ["Post 3", "Post 2", "Post 1"] 73 | 74 | "IDs and callback": 75 | topic: -> 76 | connect().distinct "posts", "_id", (err, ids, db)=> 77 | db.find "posts", ids, @callback 78 | "should return all posts": (posts)-> 79 | assert.lengthOf posts, 3 80 | 81 | "IDs only": 82 | topic: -> 83 | connect().distinct "posts", "_id", (err, ids, db)=> 84 | scope = connect().find("posts", ids) 85 | scope.all @callback 86 | "should return all posts": (posts)-> 87 | assert.lengthOf posts, 3 88 | 89 | "ID, options and callback": 90 | topic: -> 91 | connect().find "posts", (err, posts, db)=> 92 | id = (post._id for post in posts)[0] 93 | connect().find "posts", id, fields: ["title"], @callback 94 | "should return a single post": (post)-> 95 | assert.equal post.title, "Post 1" 96 | "should return specified fields": (post)-> 97 | assert !post.author_id 98 | 99 | "ID and callback": 100 | topic: -> 101 | connect().find "posts", (err, posts, db)=> 102 | id = (post._id for post in posts)[0] 103 | connect().find "posts", id, @callback 104 | "should return a single post": (post)-> 105 | assert.equal post.title, "Post 1" 106 | 107 | "ID and options": 108 | topic: -> 109 | connect().find "posts", (err, posts, db)=> 110 | id = (post._id for post in posts)[0] 111 | scope = connect().find("posts", id, fields: ["title"]) 112 | scope.one @callback 113 | "should return a single post": (post)-> 114 | assert.equal post.title, "Post 1" 115 | "should return specified fields": (post)-> 116 | assert !post.author_id 117 | 118 | "ID only": 119 | topic: -> 120 | connect().find "posts", (err, posts, db)=> 121 | id = (post._id for post in posts)[0] 122 | scope = connect().find("posts", id) 123 | scope.one @callback 124 | "should return a single post": (post)-> 125 | assert.equal post.title, "Post 1" 126 | 127 | 128 | .addBatch 129 | 130 | # -- connect().count() -- 131 | 132 | "count": 133 | topic: -> 134 | setup @callback 135 | "query and callback": 136 | topic: -> 137 | connect().count "posts", author_id: 1, @callback 138 | "should return number of posts": (count)-> 139 | assert.equal count, 2 140 | 141 | "callback only": 142 | topic: -> 143 | connect().count "posts", @callback 144 | "should return number of posts": (count)-> 145 | assert.equal count, 3 146 | 147 | "no callback": 148 | topic: -> 149 | try 150 | connect().count "posts", { author_id: 1} 151 | @callback null 152 | catch ex 153 | @callback null, ex 154 | "should fail": (error)-> 155 | assert.instanceOf error, Error 156 | 157 | 158 | .addBatch 159 | 160 | # -- connect().distinct() -- 161 | 162 | "distinct": 163 | topic: -> 164 | setup @callback 165 | "query and callback": 166 | topic: -> 167 | connect().distinct "posts", "title", author_id: 1, @callback 168 | "should return distinct values": (values)-> 169 | assert.lengthOf values, 2 170 | assert.deepEqual values, ["Post 1", "Post 2"] 171 | 172 | "callback only": 173 | topic: -> 174 | connect().distinct "posts", "title", @callback 175 | "should return distinct values": (values)-> 176 | assert.lengthOf values, 3 177 | assert.deepEqual values, ["Post 1", "Post 2", "Post 3"] 178 | 179 | "no callback": 180 | topic: -> 181 | try 182 | connect().distinct "posts", "title" 183 | @callback null 184 | catch ex 185 | @callback null, ex 186 | "should fail": (error)-> 187 | assert.instanceOf error, Error 188 | 189 | 190 | .export(module) 191 | -------------------------------------------------------------------------------- /spec/fixtures/posts.json: -------------------------------------------------------------------------------- 1 | [ { "title": "Post 1", "author_id": 1, "category": "low", "created_at": "2011-11-09T15:40:05" }, 2 | { "title": "Post 2", "author_id": 1, "category": "high", "created_at": "2011-11-09T15:40:06" }, 3 | { "title": "Post 3", "author_id": 2, "category": "low", "created_at": "2011-11-09T15:40:07" } 4 | ] 5 | -------------------------------------------------------------------------------- /spec/helpers.coffee: -------------------------------------------------------------------------------- 1 | { connect, configure, Model } = require("../lib/poutine") 2 | { Db, Server } = require("mongodb") 3 | { EventEmitter } = require("events") 4 | File = require("fs") 5 | Path = require("path") 6 | 7 | 8 | # Configure default database. 9 | configure "poutine-test", pool: 10 10 | 11 | 12 | # Load named fixture from a file in spec/fixtures 13 | loadFixture = (connection, name, callback)-> 14 | File.readFile "#{__dirname}/fixtures/#{name}.json", (error, json)-> 15 | return callback error if error 16 | try 17 | records = JSON.parse(json) 18 | catch error 19 | callback error 20 | return 21 | 22 | connection.collection name, (error, collection)-> 23 | return callback error if error 24 | collection.remove {}, safe: true, (error)-> 25 | return callback error if error 26 | for record in records 27 | collection.insert record 28 | connection.lastError -> 29 | callback() 30 | 31 | # Load the named fixtures from files in spec/fixtures 32 | loadFixtures = (connection, names, callback)-> 33 | if name = names[0] 34 | loadFixture connection, name, (error)-> 35 | return callback error if error 36 | loadFixtures connection, names.slice(1), callback 37 | else 38 | callback null 39 | 40 | # Delete collections and load fixtures. 41 | loading = new EventEmitter 42 | loading.setMaxListeners 0 43 | setup = (callback)-> 44 | loading.once "loaded", callback 45 | 46 | if loading.loaded 47 | loading.emit "loaded" 48 | return 49 | if loading.listeners("loaded").length == 1 50 | db = new Db("poutine-test", new Server("127.0.0.1", 27017), {}) 51 | db.open (error, connection)-> 52 | if error 53 | loading.emit "error", error 54 | return 55 | names = File.readdirSync("#{__dirname}/fixtures").map((name)-> Path.basename(name, ".json")) 56 | loadFixtures connection, names, (error)-> 57 | if error 58 | loading.emit "error", error 59 | else 60 | setup.loaded = true 61 | loading.emit "loaded" 62 | 63 | 64 | exports.assert = require("assert") 65 | exports.connect = connect 66 | exports.setup = setup 67 | exports.vows = require("vows") 68 | exports.Model = Model 69 | -------------------------------------------------------------------------------- /spec/model/insert_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup, Model } = require("../helpers") 2 | 3 | 4 | class Post extends Model 5 | @collection "posts" 6 | 7 | @field "title", String 8 | @field "author_id" 9 | @field "category", String 10 | @field "created_at", Date 11 | 12 | 13 | vows.describe("Model insert").addBatch 14 | 15 | # -- Model.insert -- 16 | 17 | "Model.insert": 18 | topic: -> 19 | setup @callback 20 | 21 | "POJO": 22 | "single": 23 | "no callback": 24 | topic: -> 25 | @post = { title: "Insert 3.1" } 26 | result = Post.insert(@post) 27 | return result || "nothing" 28 | "should return nothing": (result)-> 29 | assert.equal result, "nothing" 30 | "should set document ID": -> 31 | assert @post._id 32 | "new document": 33 | topic: -> 34 | Post.find title: "Insert 3.1", @callback 35 | "should exist in database": (posts)-> 36 | assert.equal posts[0].title, "Insert 3.1" 37 | 38 | "with callback": 39 | topic: -> 40 | Post.insert title: "Insert 3.2", @callback 41 | "should pass document to callback": (post)-> 42 | assert.equal post.title, "Insert 3.2" 43 | "should pass a POJO to callback": (post)-> 44 | assert !(post instanceof Post) 45 | "should set document ID": (post)-> 46 | assert post._id 47 | "new document": 48 | topic: (post)-> 49 | Post.find post._id, @callback 50 | "should exist in database": (post)-> 51 | assert.equal post.title, "Insert 3.2" 52 | 53 | "multiple": 54 | "no callback": 55 | topic: -> 56 | @posts = [{ title: "Insert 3.3", category: "foo" }, { title: "Insert 3.3", category: "bar" }] 57 | result = Post.insert(@posts) 58 | return result || "nothing" 59 | "should return nothing": (result)-> 60 | assert.equal result, "nothing" 61 | "should set document ID": -> 62 | for post in @posts 63 | assert post._id 64 | "new documents": 65 | topic: -> 66 | Post.find title: "Insert 3.3", @callback 67 | "should exist in database": (posts)-> 68 | assert.lengthOf posts, 2 69 | categories = (post.category for post in posts).join(" ") 70 | assert.include categories, "foo" 71 | assert.include categories, "bar" 72 | 73 | "with callback": 74 | topic: -> 75 | Post.insert [{ title: "Insert 3.4", category: "foo" }, { title: "Insert 3.4", category: "bar" }], @callback 76 | "should pass documents to callback": (posts)-> 77 | for post in posts 78 | assert.equal post.title, "Insert 3.4" 79 | "should pass POJOs to callback": (posts)-> 80 | for post in posts 81 | assert !(post instanceof Post) 82 | "should set document ID": (posts)-> 83 | for post in posts 84 | assert post._id 85 | "new documents": 86 | topic: (posts)-> 87 | ids = (post._id for post in posts) 88 | Post.find ids, @callback 89 | "should exist in database": (posts)-> 90 | assert.lengthOf posts, 2 91 | categories = (post.category for post in posts).join(" ") 92 | assert.include categories, "foo" 93 | assert.include categories, "bar" 94 | 95 | 96 | "Model": 97 | "no callback": 98 | topic: -> 99 | @post = new Post(title: "Insert 3.5") 100 | result = Post.insert(@post) 101 | return result || "nothing" 102 | "should return nothing": (result)-> 103 | assert.equal result, "nothing" 104 | "should set document ID": -> 105 | assert @post._id 106 | "new document": 107 | topic: -> 108 | Post.find title: "Insert 3.5", @callback 109 | "should exist in database": (posts)-> 110 | assert.equal posts[0].title, "Insert 3.5" 111 | 112 | "with callback": 113 | topic: (post)-> 114 | post = new Post(title: "Insert 3.6") 115 | Post.insert post, @callback 116 | "should pass document to callback": (post)-> 117 | assert.equal post.title, "Insert 3.6" 118 | "should pass a model to callback": (post)-> 119 | assert.instanceOf post, Post 120 | "should set document ID": (post)-> 121 | assert post._id 122 | "new document": 123 | topic: (post)-> 124 | Post.find post._id, @callback 125 | "should exist in database": (post)-> 126 | assert.equal post.title, "Insert 3.6" 127 | 128 | "multiple": 129 | "no callback": 130 | topic: -> 131 | @posts = [new Post( title: "Insert 3.7", category: "foo" ), new Post( title: "Insert 3.7", category: "bar" )] 132 | result = Post.insert(@posts) 133 | return result || "nothing" 134 | "should return nothing": (result)-> 135 | assert.equal result, "nothing" 136 | "should set document ID": -> 137 | for post in @posts 138 | assert post._id 139 | "new documents": 140 | topic: -> 141 | Post.find title: "Insert 3.7", @callback 142 | "should exist in database": (posts)-> 143 | assert.lengthOf posts, 2 144 | categories = (post.category for post in posts).join(" ") 145 | assert.include categories, "foo" 146 | assert.include categories, "bar" 147 | 148 | "with callback": 149 | topic: -> 150 | posts = [new Post( title: "Insert 3.8", category: "foo" ), new Post( title: "Insert 3.8", category: "bar" )] 151 | Post.insert posts, @callback 152 | "should pass documents to callback": (posts)-> 153 | for post in posts 154 | assert.equal post.title, "Insert 3.8" 155 | "should pass models to callback": (posts)-> 156 | for post in posts 157 | assert.instanceOf post, Post 158 | "should set document ID": (posts)-> 159 | for post in posts 160 | assert post._id 161 | "new documents": 162 | topic: (posts)-> 163 | ids = (post._id for post in posts) 164 | Post.find ids, @callback 165 | "should exist in database": (posts)-> 166 | assert.lengthOf posts, 2 167 | categories = (post.category for post in posts).join(" ") 168 | assert.include categories, "foo" 169 | assert.include categories, "bar" 170 | 171 | 172 | .export(module) 173 | -------------------------------------------------------------------------------- /spec/model/query_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup, Model } = require("../helpers") 2 | 3 | 4 | class Post extends Model 5 | @collection "posts" 6 | 7 | @field "title", String 8 | @field "author_id" 9 | @field "created_at", Date 10 | 11 | 12 | vows.describe("Model query").addBatch 13 | 14 | # -- Model.find -- 15 | 16 | "Model.find": 17 | topic: -> 18 | setup @callback 19 | 20 | "query, options and callback": 21 | topic: -> 22 | Post.find { author_id: 1 }, sort: [["title", -1]], @callback 23 | "should return Post objects": (posts)-> 24 | for post in posts 25 | assert.instanceOf post, Post 26 | "should return all posts": (posts)-> 27 | assert.lengthOf posts, 2 28 | for post in posts 29 | assert.equal post.author_id, 1 30 | "should return all posts in order": (posts)-> 31 | title = (post.title for post in posts) 32 | assert.deepEqual title, ["Post 2", "Post 1"] 33 | 34 | "query and callback": 35 | topic: -> 36 | Post.find author_id: 1, @callback 37 | "should return all posts": (posts)-> 38 | assert.lengthOf posts, 2 39 | for post in posts 40 | assert.equal post.author_id, 1 41 | 42 | "callback only": 43 | topic: -> 44 | Post.find @callback 45 | "should return Post objects": (posts)-> 46 | for post in posts 47 | assert.instanceOf post, Post 48 | "should return all posts": (posts)-> 49 | assert.lengthOf posts, 3 50 | 51 | "query and options": 52 | topic: -> 53 | scope = Post.find({ author_id: 1 }, sort: [["title", -1]]) 54 | scope.all @callback 55 | "should return all posts": (posts)-> 56 | assert.lengthOf posts, 2 57 | for post in posts 58 | assert.equal post.author_id, 1 59 | "should return all posts in order": (posts)-> 60 | title = (post.title for post in posts) 61 | assert.deepEqual title, ["Post 2", "Post 1"] 62 | 63 | "query only": 64 | topic: -> 65 | scope = Post.find(author_id: 1) 66 | scope.all @callback 67 | "should return all posts": (posts)-> 68 | assert.lengthOf posts, 2 69 | for post in posts 70 | assert.equal post.author_id, 1 71 | 72 | "no arguments": 73 | topic: -> 74 | scope = Post.find() 75 | scope.all @callback 76 | "should return Post objects": (posts)-> 77 | for post in posts 78 | assert.instanceOf post, Post 79 | "should return all posts": (posts)-> 80 | assert.lengthOf posts, 3 81 | 82 | "IDs, options and callback": 83 | topic: -> 84 | connect().distinct "posts", "_id", (err, ids, db)=> 85 | Post.find ids, sort: [["title", -1]], @callback 86 | "should return Post objects": (posts)-> 87 | for post in posts 88 | assert.instanceOf post, Post 89 | "should return all posts": (posts)-> 90 | assert.lengthOf posts, 3 91 | "should return all posts in order": (posts)-> 92 | title = (post.title for post in posts) 93 | assert.deepEqual title, ["Post 3", "Post 2", "Post 1"] 94 | 95 | "IDs and callback": 96 | topic: -> 97 | connect().distinct "posts", "_id", (err, ids, db)=> 98 | Post.find ids, @callback 99 | "should return all posts": (posts)-> 100 | assert.lengthOf posts, 3 101 | 102 | "IDs only": 103 | topic: -> 104 | connect().distinct "posts", "_id", (err, ids, db)=> 105 | scope = Post.find(ids) 106 | scope.all @callback 107 | "should return Post objects": (posts)-> 108 | for post in posts 109 | assert.instanceOf post, Post 110 | "should return all posts": (posts)-> 111 | assert.lengthOf posts, 3 112 | 113 | "ID, options and callback": 114 | topic: -> 115 | connect().distinct "posts", "_id", (err, ids, db)=> 116 | Post.find ids[0], fields: ["title"], @callback 117 | "should return Post object": (post)-> 118 | assert.instanceOf post, Post 119 | "should return a single post": (post)-> 120 | assert.equal post.title, "Post 1" 121 | "should return specified fields": (post)-> 122 | assert !post.author_id 123 | 124 | "ID and callback": 125 | topic: -> 126 | connect().distinct "posts", "_id", (err, ids, db)=> 127 | Post.find ids[0], @callback 128 | "should return a single post": (post)-> 129 | assert.equal post.title, "Post 1" 130 | 131 | "ID and options": 132 | topic: -> 133 | connect().distinct "posts", "_id", (err, ids, db)=> 134 | scope = Post.find(ids[0], fields: ["title"]) 135 | scope.one @callback 136 | "should return a single post": (post)-> 137 | assert.equal post.title, "Post 1" 138 | "should return specified fields": (post)-> 139 | assert !post.author_id 140 | 141 | "ID only": 142 | topic: -> 143 | connect().distinct "posts", "_id", (err, ids, db)=> 144 | scope = Post.find(ids[0]) 145 | scope.one @callback 146 | "should return Post object": (post)-> 147 | assert.instanceOf post, Post 148 | "should return the selected post": (post)-> 149 | assert.equal post.title, "Post 1" 150 | 151 | 152 | .addBatch 153 | 154 | 155 | # -- Model.where -- 156 | 157 | "Model.where": 158 | topic: -> 159 | setup => 160 | @callback null, Post.where(title: "Post 2") 161 | "should return scope": (scope)-> 162 | assert scope.where 163 | assert scope.desc 164 | "query": 165 | topic: (scope)-> 166 | scope.all @callback 167 | "should return only selected posts": (posts)-> 168 | assert.lengthOf posts, 1 169 | assert.equal posts[0].title, "Post 2" 170 | 171 | 172 | .addBatch 173 | 174 | 175 | # -- Model loading -- 176 | 177 | "ID accessor": 178 | topic: -> 179 | setup => 180 | Post.find(title: "Post 2").one @callback 181 | "should return post ID": (post)-> 182 | assert post._id 183 | assert.instanceOf post._id, Post.ObjectID 184 | 185 | "default accessor": 186 | topic: -> 187 | setup => 188 | Post.find(title: "Post 2").one @callback 189 | "should return field value": (post)-> 190 | assert.equal post.title, "Post 2" 191 | "should set field value": (post)-> 192 | post.title = "modified" 193 | assert.equal post.title, "modified" 194 | 195 | "custom accessor": 196 | topic: -> 197 | setup => 198 | class Custom extends Model 199 | @collection "posts" 200 | @field "title" 201 | @set "title", (title)-> 202 | @x_title = "!#{title}!" 203 | Custom.find(title: "Post 2").one @callback 204 | 205 | # Proves that setting post.title does set post.x_title 206 | post = new Custom 207 | post.title = "Post 2" 208 | assert.equal post.x_title, "!Post 2!" 209 | "should not be used to load field value": (post)-> 210 | assert.equal post.title, "Post 2" 211 | assert !post.x_title 212 | 213 | "some fields": 214 | topic: -> 215 | setup => 216 | class Missing extends Model 217 | @collection "posts" 218 | @field "title" 219 | @field "created_at" 220 | Missing.find(title: "Post 2").one @callback 221 | "should load defined fields": (post)-> 222 | assert post.title 223 | assert post._.created_at 224 | "should not load undefined fiels": (post)-> 225 | assert !post.author_id 226 | assert !post._.category 227 | 228 | "afterLoad": 229 | "returns value": 230 | "successful": 231 | topic: -> 232 | setup => 233 | class AfterLoad extends Model 234 | @collection "posts" 235 | @field "title" 236 | @afterLoad (document)-> 237 | @loaded = @title 238 | @document = !!document.title 239 | AfterLoad.find(title: "Post 2").one @callback 240 | "should call afterLoad hooks after assigning fields": (post)-> 241 | assert.equal post.loaded, "Post 2" 242 | "should call afterLoad hooks with document": (post)-> 243 | assert post.document 244 | 245 | "error": 246 | topic: -> 247 | setup => 248 | class Failed extends Model 249 | @collection "posts" 250 | @field "title" 251 | @afterLoad -> 252 | throw new Error("Fail!") 253 | Failed.find(title: "Post 2").one (error)=> 254 | @callback null, error 255 | "should pass error to callback": (error)-> 256 | assert.equal error.message, "Fail!" 257 | 258 | "uses callback": 259 | "successful": 260 | topic: -> 261 | setup => 262 | class AfterLoad extends Model 263 | @collection "posts" 264 | @field "title" 265 | @afterLoad (document, callback)-> 266 | @loaded = @title 267 | callback() 268 | return 269 | AfterLoad.find(title: "Post 2").one @callback 270 | "should call afterLoad hooks after assigning fields": (post)-> 271 | assert.equal post.loaded, "Post 2" 272 | 273 | "error": 274 | topic: -> 275 | setup => 276 | class Failed extends Model 277 | @collection "posts" 278 | @field "title" 279 | @afterLoad (document, callback)-> 280 | callback new Error("Fail!") 281 | Failed.find(title: "Post 2").one (error)=> 282 | @callback null, error 283 | "should pass error to callback": (error)-> 284 | assert.equal error.message, "Fail!" 285 | 286 | .export(module) 287 | -------------------------------------------------------------------------------- /spec/model/update_spec.coffee: -------------------------------------------------------------------------------- 1 | { assert, vows, connect, setup, Model } = require("../helpers") 2 | 3 | 4 | class Post extends Model 5 | @collection "posts" 6 | 7 | @field "title", String 8 | 9 | 10 | vows.describe("Model update").addBatch 11 | 12 | # -- Model.update -- 13 | 14 | "Model.update": 15 | topic: -> 16 | setup @callback 17 | 18 | "POJO": 19 | "single": 20 | topic: -> 21 | posts = [ 22 | {title: "Update 1"} 23 | {title: "Update 1"} 24 | ] 25 | Post.insert posts, {safe: true}, @callback 26 | 27 | "no callback": 28 | topic: -> 29 | update = Post.update {title: "Update 1"}, {title: "Update 1.1"} 30 | return update || "nothing" 31 | "should return nothing": (update)-> 32 | assert.equal update, "nothing" 33 | "updated document": 34 | topic: -> 35 | Post.find title: "Update 1.1", @callback 36 | "should exist in database": (posts)-> 37 | assert.equal posts[0].title, "Update 1.1" 38 | 39 | "with callback": 40 | topic: -> 41 | Post.update {title: "Update 1"}, {$set: {title: "Update 1.2"}}, {safe: true}, @callback 42 | "should pass the number of updated documents to the callback": (updated)-> 43 | assert.equal updated, 1 44 | "updated document": 45 | topic: -> 46 | Post.find title: "Update 1.2", @callback 47 | "should exist in database": (posts)-> 48 | assert.equal posts[0].title, "Update 1.2" 49 | 50 | 51 | "multiple": 52 | "long form": 53 | topic: -> 54 | posts = [ 55 | {title: "Update 2"} 56 | {title: "Update 2"} 57 | {title: "Update 3"} 58 | {title: "Update 3"} 59 | ] 60 | Post.insert posts, {safe: true}, @callback 61 | 62 | "no callback": 63 | topic: (posts) -> 64 | update = Post.update {title: "Update 2"}, {title: "Update 2.1"}, {multi: true} 65 | return update || "nothing" 66 | "should return nothing": (update)-> 67 | assert.equal update, "nothing" 68 | "updated document": 69 | topic: -> 70 | Post.find title: "Update 2.1", @callback 71 | "should exist in database": (posts)-> 72 | assert.lengthOf posts, 2 73 | posts.forEach (post) -> 74 | assert.equal post.title, "Update 2.1" 75 | 76 | "with callback": 77 | topic: -> 78 | Post.update {title: "Update 3"}, {$set: {title: "Update 3.1"}}, {safe: true, multi: true}, @callback 79 | "should pass the number of updated documents to the callback": (updated)-> 80 | assert.equal updated, 2 81 | "updated document": 82 | topic: -> 83 | Post.find title: "Update 3.1", @callback 84 | "should exist in database": (posts)-> 85 | assert.lengthOf posts, 2 86 | posts.forEach (post) -> 87 | assert.equal post.title, "Update 3.1" 88 | 89 | 90 | "with convenience method": 91 | topic: -> 92 | posts = [ 93 | {title: "Update 4"} 94 | {title: "Update 4"} 95 | {title: "Update 5"} 96 | {title: "Update 5"} 97 | ] 98 | Post.insert posts, {safe: true}, @callback 99 | 100 | "no callback": 101 | topic: (posts) -> 102 | update = Post.update_all {title: "Update 4"}, {title: "Update 4.1"} 103 | return update || "nothing" 104 | "should return nothing": (update)-> 105 | assert.equal update, "nothing" 106 | "updated document": 107 | topic: -> 108 | Post.find title: "Update 4.1", @callback 109 | "should exist in database": (posts)-> 110 | assert.lengthOf posts, 2 111 | posts.forEach (post) -> 112 | assert.equal post.title, "Update 4.1" 113 | 114 | "with callback": 115 | topic: -> 116 | Post.update_all {title: "Update 5"}, {$set: {title: "Update 5.1"}}, {safe: true}, @callback 117 | "should pass the number of updated documents to the callback": (updated)-> 118 | assert.equal updated, 2 119 | "updated document": 120 | topic: -> 121 | Post.find title: "Update 5.1", @callback 122 | "should exist in database": (posts)-> 123 | assert.lengthOf posts, 2 124 | posts.forEach (post) -> 125 | assert.equal post.title, "Update 5.1" 126 | 127 | "Model": 128 | "single": 129 | topic: -> 130 | @posts = [ 131 | new Post(title: "Update 6") 132 | new Post(title: "Update 6") 133 | ] 134 | Post.insert @posts, {safe: true}, @callback 135 | 136 | "no callback": 137 | topic: -> 138 | update = Post.update @posts[0], {$set: {title: "Update 6.1"}} 139 | return update || "nothing" 140 | "should return nothing": (update)-> 141 | console.log @posts[0] 142 | assert.equal update, "nothing" 143 | "updated document": 144 | topic: -> 145 | Post.find title: "Update 6.1", @callback 146 | "should exist in database": (posts)-> 147 | assert.equal posts[0].title, "Update 6.1" 148 | 149 | "with callback": 150 | topic: -> 151 | Post.update @posts[1], {$set: {title: "Update 6.2"}}, {safe: true}, @callback 152 | "should pass the number of updated documents to the callback": (updated)-> 153 | assert.equal updated, 1 154 | "updated document": 155 | topic: -> 156 | Post.find title: "Update 6.2", @callback 157 | "should exist in database": (posts)-> 158 | assert.equal posts[0].title, "Update 6.2" 159 | 160 | 161 | ### 162 | "Model.upsert": 163 | "shorthand": 164 | topic: -> 165 | post = { title: "to be upserted, short form" } 166 | Post.upsert post, post, {safe: true}, @callback 167 | 168 | "when querying for it": 169 | topic: -> 170 | Post.find(title: "to be upserted, short form").one @callback 171 | "should exist in the database": (post)-> 172 | assert.equal post.title, "to be upserted, short form" 173 | 174 | "normal": 175 | topic: -> 176 | post = { title: "to be upserted, long form" } 177 | Post.update post, post, {upsert: true, safe: true}, @callback 178 | 179 | "when querying for it": 180 | topic: -> 181 | Post.find(title: "to be upserted, long form").one @callback 182 | "should exist in the database": (post)-> 183 | assert.equal post.title, "to be upserted, long form" 184 | ### 185 | 186 | 187 | .export(module) 188 | --------------------------------------------------------------------------------