Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Fetching contributors…

Cannot retrieve contributors at this time

433 lines (333 sloc) 20.943 kB
## Batman.Model
`Batman.Model` is responsible for representing data in your application, and for providing a fluid interface to moving in to and out of your backend.
_Note_: This documentation uses the term _model_ to refer to the class `Model` or a `Model` subclass, and the term _record_ to refer to one instance of `Model` or of a `Model` subclass.
### The Asynchronous Nature of the World
`Batman.Model`'s operations on both the class and instance level are asynchronous and always will be. This means that the operation functions all accept node style callback functions as the last argument, and only call these callbacks when the operation is complete. Complete in this context means for example with `RestStorage` only when the entire HTTP response has been received from the server, which can be many seconds after the original call to the operation function.
These callbacks follow the nodejs convention for their signatures. They should regard the first argument as an error: if it is present, an error has occured, and if it is null or undefined, the operation was successful. Successive arguments represent the result of the operation, or in another world, what would have been returned from the operation function if the operation was synchronous. records returned, a boolean representing status, or response JSON.
### The Identity Map
Batman uses an identity map when fetching and storing records to do its best to only ever represent a backend record with exactly one client side record. This means that if you use `Model.find` twice to fetch a record with the same ID, you will get back the same (`===`) instance in each callback. This is useful because it means that any state the instance might be in is available and preserved no matter which piece of code asked for it, and so that bindings to the instance update no matter which piece of code actually updates the model.
Practically, the identity map is an implementation detail on Batman's end and developers shouldn't have to actually interact with it, but the knowledge that you have the "one true instance" is helpful when reasoning about code and bindings.
### Subclassing
Models in your applications should be subclasses of `Batman.Model`, or subclasses of subclasses, and so on. Extending `Batman.Model` will give your domain-modeling class all the functionality described here. Subclasses can also be subclassed once more if you so desire. Things like encoders, validations, and storage adapters will be inherited by sub-subclasses.
### Storage Adapters
`Batman.Model` alone only defines the logic surrounding loading and saving, but not the actual mechanism for doing so. This is left to a `Batman.StorageAdaper` subclass, 4 of which are included with Batman or in extras:
1. `Batman.LocalStorage` for storing data in the browsers' `localStorage`, if available
2. `Batman.SessionStorage` for storing data in the browser's `sessionStorage`, if available.
3. `Batman.RestStorage` for using HTTP GET, POST, PUT, and DELETE to store data in a backend.
4. `Batman.RailsStorage` which extends `Batman.RestStorage` with some handy Rails specific functionality like parsing out validation errors.
### @primaryKey : string
`primaryKey` is a class level configuration option to change which key Batman uses as the primary key. Change the option using `set`, like so:
!!!
test 'primary key can be set using @set', ->
show(class Shop extends Batman.Model
@set 'primaryKey', 'shop_id'
)
equal Shop.get('primaryKey'), 'shop_id'
!!!
The `primaryKey` is what Batman uses to compare instances to see if they represent the same domain-level object: if two records have the same value at the key specified by `primaryKey`, only one will be in the identity map. The key specified by `primaryKey` is also used by the associations system when determining if a record is related to another record, and by the remote storage adapters to generate URLs for records.
Note_: The default primaryKey is 'id'.
### @storageKey : string
`storageKey` is a class level option which gives the storage adapters something to interpolate into their specific key generation schemes. In the case of `LocalStorage` or `SessionStorage` adapters, the `storageKey` defines what namespace to store this record under in the `localStorage` or `sessionStorage` host objects, and with the case of the `RestStorage` family of adapters, the `storageKey` assists in URL generation. See the documentation for the storage adapter of your choice for more information.
The default `storageKey` is `null`.
### @persist(mechanism : StorageAdapter) : StorageAdapter
`@persist` is how a `Model` subclass is told to persist itself by means of a `StorageAdapter`. `@persist` accepts either a `StorageAdapter` class or instance and will return either the instantiated class or the instance passed to it for further modification.
!!!
test 'models can be told to persist via a storage adapter', ->
show(class Shop extends Batman.Model
@persist TestStorageAdapter
)
show record = new Shop
ok record.hasStorage()
!!!
!!!
test '@persist returns the instantiated storage adapter', ->
show adapter = false
show(class Shop extends Batman.Model
adapter = @persist TestStorageAdapter
)
ok adapter instanceof Batman.StorageAdapter
!!!
!!!
test '@persist accepts already instantiated storage adapters', ->
show adapter = new Batman.StorageAdapter
show adapter.someHandyConfigurationOption = true
show(class Shop extends Batman.Model
@persist adapter
)
show record = new Shop
ok record.hasStorage()
!!!
### @encode(keys...[, encoderObject : [Object|Function]])
`@encode` specifies a list of `keys` a model should expect from and send back to a storage adapter, and any transforms to apply to those attributes as they enter and exit the world of Batman in the optional `encoderObject`.
The `encoderObject` should have an `encode` and/or a `decode` key which point to functions. The functions accept the "raw" data (the Batman land value in the case of `encode`, and the backend land value in the case of `decode`), and should return the data suitable for the other side of the link. The functions should have the following signatures:
```coffeescript
encoderObject = {
encode: (value, key, builtJSON, record) ->
decode: (value, key, incomingJSON, outgoingObject, record) ->
}
```
By default these functions are the identity functions. They apply no transformation. The arguments for `encode` functions are as follows:
+ `value` is the client side value of the `key` on the `record`
+ `key` is the key which the `value` is stored under on the `record`. This is useful when passing the same `encoderObject` which needs to pivot on what key is being encoded to different calls to `encode`.
+ `builtJSON` is the object which is modified by each encoder which will eventually be returned by `toJSON`. To send the server the encoded value under a different key than the `key`, modify this object by putting the value under the desired key, and return `undefined`.
+ `record` is the record on which `toJSON` has been called.
For `decode` functions:
+ `value` is the server side value of the `key` which will end up on the `record`.
+ `key` is the key which the `value` is stored under the incoming JSON.
+ `incomingJSON` is the JSON which is being decoded into the `record`. This can be used to create compound key decoders.
+ `outgoingObject` is the object which is built up by the decoders and then `mixin`'d to the record.
+ `record` is the record on which `fromJSON` has been called.
The `encode` and `decode` keys can also be false to avoid the default identity function encoder or decoder from being used.
_Note_: `Batman.Model` subclasses have no encoders by default, except for one which automatically decodes the `primaryKey` of the model, which is usually `id`. To get any data into or out of your model, you must white-list the keys you expect from the server or storage attribute.
!!!
test '@encode accepts a list of keys which are used during decoding', ->
show(class Shop extends Batman.Model
@encode 'name', 'url', 'email', 'country'
)
show json = {name: "Snowdevil", url: "snowdevil.ca"}
show record = new Shop()
show record.fromJSON(json)
equal record.get('name'), "Snowdevil"
!!!
!!!
test '@encode accepts a list of keys which are used during encoding', ->
show(class Shop extends Batman.Model
@encode 'name', 'url', 'email', 'country'
)
show record = new Shop(name: "Snowdevil", url: "snowdevil.ca")
deepEqual record.toJSON(), {name: "Snowdevil", url: "snowdevil.ca"}
!!!
!!!
test '@encode accepts custom encoders', ->
show(class Shop extends Batman.Model
@encode 'name'
encode: (name) -> name.toUpperCase()
)
show record = new Shop(name: "Snowdevil")
deepEqual record.toJSON(), {name: "SNOWDEVIL"}
!!!
!!!
test '@encode accepts custom decoders', ->
show(class Shop extends Batman.Model
@encode 'name'
decode: (name) -> name.replace('_', ' ')
)
show record = new Shop()
show record.fromJSON {name: "Snow_devil"}
equal record.get('name'), "Snow devil"
!!!
!!!
test '@encode can be passed an encoderObject with false to prevent the default encoder or decoder', ->
show(class Shop extends Batman.Model
@encode 'name', {encode: false, decode: (x) -> x}
@encode 'url'
)
show record = new Shop()
show record.fromJSON {name: "Snowdevil", url: "snowdevil.ca"}
equal record.get('name'), 'Snowdevil'
equal record.get('url'), "snowdevil.ca"
deepEqual record.toJSON(), {url: "snowdevil.ca"}, 'The name key is absent because of encode: false'
!!!
Some more handy examples:
!!!
test '@encode can be used to turn comma separated values into arrays', ->
show(class Post extends Batman.Model
@encode 'tags',
decode: (string) -> string.split(', ')
encode: (array) -> array.join(', ')
)
show record = new Post()
show record.fromJSON({tags: 'new, hot, cool'})
deepEqual record.get('tags'), ['new', 'hot', 'cool']
deepEqual record.toJSON(), {tags: 'new, hot, cool'}
!!!
!!!
test '@encode can be used to turn arrays into sets', ->
show(class Post extends Batman.Model
@encode 'tags',
decode: (array) -> new Batman.Set(array...)
encode: (set) -> set.toArray()
)
show record = new Post()
show record.fromJSON({tags: ['new', 'hot', 'cool']})
ok record.get('tags') instanceof Batman.Set
deepEqual record.toJSON(), {tags: ['new', 'hot', 'cool']}
!!!
### @validate(keys...[, options : [Object|Function]])
Validations allow a model to be marked as `valid` or `invalid` based on a set of programmatic rules. By validating a model's data before it gets to the server we can provide immediate feedback to the user about what they have entered and forgo waiting on a round trip to the server. `validate` allows the attachment of validations to the model on particular keys, where the validation is either a built in one (invoked by use of options to pass to them) or a custom one (invoked by use of a custom function as the second argument).
_Note_: Validation in Batman is always asynchronous, despite the fact that none of the validations may use an asynchronous operation to check for validity. This is so that the API is consistent regardless of the validations used.
Built in validators are attached by calling `@validate` with options designating how to calculate the validity of the key:
!!!
test '@validate accepts options to check for validity', ->
QUnit.expect(0)
show(class Post extends Batman.Model
@validate 'title', 'body', {presence: true}
)
!!!
The built in validation options are listed below:
+ `presence : boolean`: Assert that the string value is existant (not undefined nor null) and has length greather than 0.
+ `numeric : true`: Assert that the value can be is or can be coerced into a number using `parseFloat`.
+ `minLength : number`: Assert that the value's `length` property is greater than the given number.
+ `maxLength : number`: Assert that the value's `length` property is less than the given number.
+ `length : number`: Assert that the value's `length` property is exactly the given number.
+ `lengthWithin : [number, number]` or `lengthIn : [number, number]`: Assert that the value's `length` property is within the ranger specified by the given array of two numbers, where the first number is the lower bound and the second number is the upper bound.
Custom validators should have the signature `(errors, record, key, callback)`. The arguments are as follows:
+ `errors`: an `ErrorsSet` instance which expects to have `add` called on it to add errors to the model
+ `record`: the record being validated
+ `key`: the key to which the validation has been attached
+ `callback`: a function to call once validation has been complete. Calling this function is ++mandatory++.
See `Model::validate` for information on how to get a particular record's validity.
### @loaded : Set
The `loaded` set is available on every model class and holds every model instance seen by the system in order to function as an identity map. Successful loading or saving individual records or batches of records will result in those records being added to the `loaded` set. Destroying instances will remove records from the identity set.
!!!
test 'the loaded set stores all records seen', ->
show(class Post extends Batman.Model
@persist TestStorageAdapter
@encode 'name'
)
ok Post.get('loaded') instanceof Batman.Set
equal Post.get('loaded.length'), 0
show post = new Post()
show post.save()
equal Post.get('loaded.length'), 1
!!!
!!!
test 'the loaded adds new records caused by loads and removes records caused by destroys', ->
show(class Post extends Batman.Model
@encode 'name'
)
show(adapter = new TestStorageAdapter(Post))
show(adapter.storage =
'posts1': {name: "One", id:1}
'posts2': {name: "Two", id:2}
)
show(Post.persist(adapter))
show Post.load()
equal Post.get('loaded.length'), 2
show post = false
show Post.find(1, (err, result) -> post = result)
show post.destroy()
equal Post.get('loaded.length'), 1
!!!
### @all : Set
The `all` set is an alias to the `loaded` set but with an added implicit `load` on the model. `Model.get('all')` will synchronously return the `loaded` set and asynchronously call `Model.load()` without options to load a batch of records and populate the set originally returned (the `loaded` set) with the records returned by the server.
_Note_: The notion of "all the records" is relative only to the client. It completely depends on the storage adapter in use and any backends which they may contact to determine what comes back during a `Model.load`. This means that if for example your API paginates records, the set found in `all` may hold on the first 50 records instead of the entire backend set.
`all` is useful for listing every instance of a model in a view, and since the `loaded` set will change when the `load` returns, it can be safely bound to.
!!!
asyncTest 'the all set asynchronously fetches records when gotten', ->
show(class Post extends Batman.Model
@encode 'name'
)
show(adapter = new AsyncTestStorageAdapter(Post))
show(adapter.storage =
'posts1': {name: "One", id:1}
'posts2': {name: "Two", id:2}
)
show(Post.persist(adapter))
equal Post.get('all.length'), 0, "The synchronously returned set is empty"
delay ->
equal Post.get('all.length'), 2, "After the async load the set is populated"
!!!
### @clear() : Set
`Model.clear()` empties that `Model`'s identity map. This is useful for tests and other unnatural situations where records new to the system are guaranteed to be as such.
!!!
test 'clearing a model removes all records from the identity map', ->
show(class Post extends Batman.Model
@encode 'name'
)
adapter = new TestStorageAdapter(Post)
adapter.storage =
'posts1': {name: "One", id:1}
'posts2': {name: "Two", id:2}
Post.persist(adapter)
Post.load()
equal Post.get('loaded.length'), 2
show Post.clear()
equal Post.get('loaded.length'), 0, "After clear() the loaded set is empty"
!!!
### @find(id, callback : Function) : Model
`Model.find()` retrieves a record with the specified `id` from the storage adapter and calls back with an error if one occurred and the record if one didn't. `find` delegates to the storage adapter the `Model` has been `@persist`ed with, so it is up to the storage adapter's semantics to determine what type of errors may return and the timeline on which the callback may be called. The `callback` is a non-optional function which should adopt the node style callback signature which accepts two arguments, an error, and the record asked for. `find` returns an "unloaded" record which after the load completes will be populated with the data from the storage adapter.
_Note_: `find` gives two results to calling code: one immediately, and one later. `find` returns a record synchronously as it is called and calls back with a record and importantly these two records are __not__ guaranteed to be the same instance. This is because Batman maps the identities of incoming and outgoing records such that there is only ever one canonical instance representing a record, which is useful so bindings are always bound to the same thing. In practice, this means that calling code should use the record `find` calls back with if anything is going to bind to that object, which is most of the time. The returned record however remains useful for state inspection as well as bookkeeping.
!!!
asyncTest '@find calls back the requested model if no error occurs', ->
show(class Post extends Batman.Model
@encode 'name'
@persist AsyncTestStorageAdapter,
storage:
'posts2': {name: "Two", id:2}
)
show post = Post.find 2, (err, result) ->
throw err if err
post = result
equal post.get('name'), undefined
delay ->
equal post.get('name'), "Two"
!!!
_Note_: `find` must be passed a callback function. This is for two reasons: calling code must be aware that `find`'s return value is not necessarily the canonical instance, and calling code must be able to handle errors.
!!!
asyncTest '@find calls back with the error if an error occurs', ->
show(class Post extends Batman.Model
@encode 'name'
@persist AsyncTestStorageAdapter
)
show error = false
show post = Post.find 3, (err, result) ->
error = err
delay ->
ok error instanceof Error
!!!
### @load(options = {}, callback : Function)
`Model.load()` retrieves an array of records according to the given `options` from the storage adapter and calls back with an error if one occurred and the set of records if one didn't. `load` delegates to the storage adapter the `Model` has been `@persist`ed with, so it is up to the storage adapter's semantics to determine what the options do, what kind of errors may arise, and the timeline on which the callback may be called. The `callback` is a non-optional function which should adopt the node style callback signature which accepts two arguments, an error, and the array of records. `load` returns undefined.
For the two main `StorageAdapter`s Batman provides the `options` do different things:
+ For `Batman.LocalStorage`, the `options` act as a filter. The adapter will scan all the records in `localStorage` and return only those records who match all the key/value pairs as given in the options.
+ For `Batman.RestStorage`, the `options` are serialized into query parameters on the `GET` request.
!!!
asyncTest '@load calls back an array of records retrieved from the storage adapter', ->
show(class Post extends Batman.Model
@encode 'name'
@persist TestStorageAdapter,
storage:
'posts1': {name: "One", id:1}
'posts2': {name: "Two", id:2}
)
show(posts = false)
show Post.load (err, result) ->
throw err if err
posts = result
delay ->
equal posts.length, 2
equal posts[0].get('name'), "One"
!!!
!!!
asyncTest '@load calls back with an empty array if no records are found', ->
show(class Post extends Batman.Model
@encode 'name'
@persist TestStorageAdapter
)
show(posts = false)
show Post.load (err, result) ->
throw err if err
posts = result
delay ->
equal posts.length, 0
!!!
### @create(attributes = {}, callback) : Model
### @findOrCreate(attributes = {}, callback) : Model
### id : value
### dirtyKeys : Set
### errors : ErrorsSet
### constructor(idOrAttributes = {}) : Model
### isNew() : boolean
### updateAttributes(attributes) : Model
### toString() : string
### toJSON() : Object
### fromJSON() : Model
### toParam() : value
### state() : string
### hasStorage() : boolean
### load(options = {}, callback)
### save(options = {}, callback)
### destroy(options = {}, callback)
### validate(callback)
## Batman.ValidationError
## Batman.ErrorsSet
Jump to Line
Something went wrong with that request. Please try again.