micromodel
Version:
Model that can be used on both Client & Server
405 lines (326 loc) • 12.4 kB
text/coffeescript
# Model and Collection for working on both Client and Server sides. Uses [functional mixins](http://jslang.info/blog/functional-mixins).
# Support for both Browser and Node.js environments.
if module?.exports?
exports = module.exports
_ = require('underscore')
requireEventEmitter = -> require('events').EventEmitter
requireBackboneEvents = -> require('backbone').Events
else
exports = window
_ = window._ || require('underscore')
requireEventEmitter = -> window.EventEmitter || require('events').EventEmitter
requireBackboneEvents = -> global.Backbone?.Events || require('backbone').Events
# # Model
#
# Attributes stored and accessed as properties `model.name` but it shoud be setted only
# via the `set` method - `model.set name: 'foo'`.
#
# Properties with `_` prefix are ignored.
#
# Define validation rules using `model.validations` or `model.validate`, check validity of model
# with `model.isValid()`.
attributeRe = /^_/
exports.Model = (klass) ->
klass ?= exports.BaseClass()
proto = klass.prototype
proto.isModel = true
# Initializing model from `attrs` and `defaults` property.
proto.initialize = (attrs, options) ->
@set @defaults, options if @defaults
@set attrs, options if attrs
@
# Equality check based on the content, deep.
proto.isEqual = (other) -> # , strict = false
return true if @ == other
return false unless other and @id == other.id and other.isModel
# Checking if atributes and size of models are the same.
size = 0
for own name, value of @ when not attributeRe.test name
size += 1
return false unless _.isEqual value, other[name]
otherSize = 0
otherSize += 1 for own name of other when not attributeRe.test name
return size == otherSize
# Set attributes.
proto.set = (attrs, options) ->
return {} unless attrs?
changes = {}
for own name, newValue of attrs when not attributeRe.test name
# Tracking changes.
oldValue = @[name]
changes[name] = oldValue unless _.isEqual oldValue, newValue
# Updating.
@[name] = newValue
changes
# Shallow clone.
proto.clone = -> new @constructor @attributes()
# Clear attributes.
proto.clear = (options) ->
changes = attributes()
delete @[name] for own name of @
changes
# Validating attributes, returns `null` if attributes valid or any not null object as error.
proto.validate = ->
return null unless @validations
errors = {}
for own name, validator of @validations
(errors[name] ?= []).push msg if msg = validator @[name]
if _.isEmpty errors then null else errors
# Define validation rules and store errors in `errors` property `@errors.add name: "can't be blank"`.
proto.isValid = -> not @validate()?
proto.attributes = ->
attrs = {}
attrs[name] = value for own name, value of @ when not attributeRe.test name
attrs
proto.toJSON = -> @attributes()
proto.inspect = -> JSON.stringify @toJSON()
proto.toString = -> @inspect()
proto.cid = -> @_cid = _.uniqueId()
klass
# Adding 'change' and `change:attr` events, supply `silent: true` option to suppress it.
exports.Model.Events = (klass, type) ->
exports.Events klass, type
proto = klass.prototype
# Adding events to `set` method.
proto.setWithoutEvents = proto.set
proto.set = (attrs, options) ->
changes = @setWithoutEvents attrs, options
@_emitChanges changes, options
changes
# Adding events to `clear` method.
proto.clearWithoutEvents = proto.clear
proto.clear = (options) ->
changes = @clearWithoutEvents options
@_emitChanges changes, options
changes
# Emitt changes.
proto._emitChanges = (changes, options) ->
unless _.isEmpty(changes) and not options?.silent
@trigger "change:#{name}", @, oldValue for name, oldValue of changes
@trigger 'change', @, changes
klass
# # Collection.
#
# Collection store models.
exports.Collection = (klass) ->
klass ?= exports.BaseClass()
proto = klass.prototype
proto.isCollection = true
# Initialize collection, You may provide array of objects or models and options.
proto.initialize = (args...) ->
[@models, @length, @ids] = [[], 0, {}]
@add args...
@
# Add model or models.
proto.add = (args...) ->
[models, options] = if _.isArray args[0] then args else [args, {}]
options ?= {}
@_add models, options
proto._add = (models, options) ->
# Adding to collection.
added = []
for model in models
# Transforming objects to model if it isn't.
unless model.isModel
klass = @model || throw new Error "no Model for Collection (#{@})!"
model = new klass model
# Requiring id presence.
throw new Error "no id for Model (#{model})!" unless model.id?
# Model can be added only once, ignoring if it tried to be added twice.
continue if model.id of @ids
@ids[model.id] = @models.length
@models.push model
added.push model
@length = @models.length
added
# Delete model or models.
proto.delete = (args...) ->
[models, options] = if _.isArray args[0] then args else [args, {}]
options ?= {}
@_delete models, options
proto.del = (args...) -> @delete args...
proto._delete = (models, options) ->
# Marking models for delete.
[deleted, deletedIndexes] = [[], {}]
# Ignoring objects that aren't in collection.
for model in models when model.id of @ids
deleted.push model
deletedIndexes[@ids[model.id]] = true
# Deleting.
unless _.isEmpty deletedIndexes
oldModels = @models
[@models, @ids] = [[], {}]
@models.push model for model, index in oldModels when index not of deletedIndexes
@ids[model.id] = index for model, index in @models
@length = @models.length
deleted
# Get model by id.
proto.get = (id) -> if (index = @ids[id])? then @models[index]
proto.has = (id) -> id of @ids
# Get model by index.
proto.at = (index) -> @models[index]
# Clear collection.
proto.clear = (options = {}) ->
deleted = @models
[@models, @length, @ids] = [[], 0, {}]
deleted
proto.inspect = -> JSON.stringify @toJSON()
proto.toString = -> @inspect()
proto.toJSON = -> @models
# Equality check based on content, deep.
proto.isEqual = (other) ->
return true if @ == other
return false unless other and other.length == @length and other.isCollection
for model, index in @models
return false unless model.isEqual other.at(index)
true
# Making Underscore.js methods available directly on Collection.
underscoreMethodsReturningCollection = [
'forEach', 'each', 'map', 'filter', 'select', 'reject',
'every', 'all', 'some', 'any', 'sortBy', 'toArray', 'rest', 'without',
'shuffle'];
for method in underscoreMethodsReturningCollection
do (method) ->
proto[method] = ->
list = _[method].apply _, @models, arguments...
new @constructor list
underscoreMethods = [
'reduce', 'reduceRight', 'find', 'detect',
'include', 'contains', 'invoke', 'max', 'min', 'sortedIndex',
'size', 'first', 'initial', 'last', 'indexOf',
'lastIndexOf', 'isEmpty', 'groupBy', 'countBy'];
for method in underscoreMethods
do (method) ->
proto[method] = ->
_[method].apply _, @models, arguments...
klass
# Sorted Collection.
exports.Collection.Sorted = (klass) ->
proto = klass.prototype
proto.isSortedCollection = true
# Sorted mixin should be applied before Events, checking for that.
throw new Error "Sorted mixin should be applied before Events!" if proto.hasEvents
# Define comparator and collection always will be automatically sorted.
proto.sort = (options) -> @_sort options
proto._sort = (options) ->
@comparator = options.comparator if options?.comparator?
return false unless @comparator
# Sorting.
if @comparator.length == 1 then @models = _(@models).sortBy @comparator
else @models.sort @comparator
# Updating ids.
changed = false
for model, index in @models
changed = true if @ids[model.id] != index
@ids[model.id] = index
changed
# Adding support for sorting in `add` method.
proto._addWithoutSort = proto._add
proto._add = (models, options) ->
added = @_addWithoutSort models, options
# Sorting.
if added.length > 0 then @_sort options
else if options?.comparator? then @comparator = options.comparator
added
klass
# Collection with events, emit `add`, `delete`, `change` and `model:change`
# events.
exports.Collection.Events = (klass, type) ->
exports.Events klass, type
proto = klass.prototype
# Helper for proxying model events to collection listeners.
proto.initializeWithoutEvents = proto.initialize
proto.initialize = (args...) ->
@_forwardModelChangeEvent = (model, changes) => @trigger 'model:change', model, changes, @
@initializeWithoutEvents args...
# Adding events for `sort` method.
if proto.sort?
proto.sortWithoutEvents = proto.sort
proto.sort = (options) ->
changed = @sortWithoutEvents options
@trigger 'change', @ if changed and not options?.silent
changed
# Adding events for `add` method.
proto._addWithoutEvents = proto._add
proto._add = (models, options) ->
added = @_addWithoutEvents models, options
@_emitAddChanges added, options
added
# Adding events for `delete` method.
proto._deleteWithoutEvents = proto._delete
proto._delete = (models, options) ->
deleted = @_deleteWithoutEvents models, options
@_emitDeleteChanges deleted, options
deleted
# Adding events for `clear` method.
proto.clearWithoutEvents = proto._delete
proto.clear = (options) ->
deleted = @clearWithoutEvents options
@_emitDeleteChanges deleted, options
deleted
# Emit changes.
proto._emitAddChanges = (added, options) ->
# Forwarding model change event.
model.on 'change', @_forwardModelChangeEvent for model in added when model.hasEvents
# Emitting events.
if (added.length > 0) and not options?.silent
@trigger 'add', model, @ for model in added
@trigger 'change', @
proto._emitDeleteChanges = (deleted, options) ->
# Removing forwarding model change event.
model.off 'change', @_forwardModelChangeEvent for model in deleted when model.hasEvents
# Emitting events.
if (deleted.length > 0) and not options?.silent
@trigger 'delete', model, @ for model in deleted
@trigger 'change', @
klass
# # Utilities.
# Events.
exports.Events = (klass, type='EventEmitter') ->
proto = klass.prototype
proto.hasEvents = true
# Integration with EventEmitter.
if type == 'EventEmitter'
EventEmitter = requireEventEmitter()
_(proto).extend EventEmitter.prototype
# Adding initialization.
initializeWithoutEventEmitter = proto.initialize
proto.initialize = ->
EventEmitter.apply @
initializeWithoutEventEmitter.apply @, arguments
# Adding shortcuts.
proto.on = -> @addListener.apply @, arguments
proto.off = -> @removeListener.apply @, arguments
proto.trigger = -> @emit.apply @, arguments
# Integration with BackboneEvents.
else if type == 'Backbone.Events'
_(proto).extend requireBackboneEvents()
else throw new Error "unknown type #{type}"
klass
# Base class.
exports.BaseClass = -> -> @initialize?.apply(@, arguments); @
# Base Model and Collection.
exports.BaseModel = exports.Model()
exports.BaseCollection = exports.Collection()
# Full Model and Collection.
exports.FullModel = exports.Model.Events exports.Model()
exports.FullCollection = exports.Collection.Events exports.Collection.Sorted exports.Collection()
# Cast value to type, override it to provide more types.
# MicroModel.cast = (value, type) ->
# if _.isFunction type then type value
# else if type == String then v.toString()
# else if type == Number
# if _.isNumber(v) then v
# else if _.isString v
# tmp = parseInt v
# tmp if _.isNumber tmp
# else if type == Boolean
# if _.isBoolean v then v
# else if _.isString v then v == 'true'
# else if type == Date
# if _.isDate v then v
# else if _.isString v
# tmp = new Date v
# tmp if _.isDate tmp
# else
# throw "can't cast to unknown type (#{type})!"