micro-model
Version:
Model that can be used on both Client & Server
415 lines (345 loc) • 13.3 kB
text/coffeescript
# Module declarations.
global = @
MicroModel = if exports? then exports else global.MicroModel = {}
_ = global._ || require 'underscore'
# Making Underscore.js methods available directly on Collection.
underscoreMethods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find',
'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any',
'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex',
'toArray', 'size', 'first', 'initial', 'rest', 'last', 'without', 'indexOf',
'shuffle', 'lastIndexOf', 'isEmpty', 'groupBy'];
MicroModel.withUnderscoreCollection = (klass) ->
proto = klass.prototype
# Mixing each Underscore method as a proxy to `Collection.models`.
_.each underscoreMethods, (method) ->
proto[method] = ->
_[method].apply _, [@models].concat _.toArray(arguments)
# Integration with underscore's `_.isEqual`.
MicroModel.withUnderscoreEqual = (klass) ->
proto = klass.prototype
proto.isEqual = proto.eql
# Integration with EventEmitter.
MicroModel.withEventEmitter = (klass) ->
proto = klass.prototype
EventEmitter = global.EventEmitter || require('events').EventEmitter
initializeWithoutEventEmitter = proto.initialize
methods =
initialize : ->
EventEmitter.apply @
initializeWithoutEventEmitter.apply @, arguments
proto.isEventEmitter = true
_(proto).extend EventEmitter.prototype
_(proto).extend methods
# Integration with BackboneEvents.
MicroModel.withBackboneEvents = (klass) ->
proto = klass.prototype
Events = global.Backbone.Events || require('backbone').Events
# Making `Backbone.Events` looks like it's `EventEmitter`.
methods =
addListener : -> @on.apply @, arguments
removeListener : -> @off.apply @, arguments
emit : -> @trigger.apply @, arguments
proto.isEventEmitter = true
_(proto).extend Events
_(proto).extend methods
# # Model
#
# Attributes stored as properties `model.name` but it shoud be setted only
# via the `set` method - `model.set name: 'foo'`.
#
# Properties with `_` prefix have special meaning (like caching `model._cache`) and
# ignored.
#
# By default model has no schema or attribute types, but it can be defined using `model.cast`
# method.
#
# There are special property `model._changed` - it contains changes from the last `set` operation.
#
# Define validation rules using `model.validations` and `model.validate`, there are
# also `isValid` method.
#
# Model can be serialized by usng `toHash` and `fromHash` methods.
#
# Use `model.on 'change', fn` to listen for `change` and `change:attrName` events.
#
# Specify `model.schema` to cast attributes to specified types.
#
# Example:
#
# class User extends Model
# validations:
# name: (v) -> "can't be empty" if /^\s*$/.test v
# schema:
# age : Number
# enabled : (v) -> v == 'yes'
#
MicroModel.withModel = (klass) ->
proto = klass.prototype
# Helper, get attributes as hash from.
attributes = (obj) ->
attrs = {}
attrs[name] = value for own name, value of obj when not /^_/.test name
attrs
# Methods.
initializeWithoutModel = proto.initialize
methods =
# Initializing new model from `attrs` and `defaults` property.
initialize: (attrs, options) ->
initializeWithoutModel?.call @, attrs, options
[@_cid, @_changed] = [_.uniqueId('c'), {}]
@set @defaults, options if @defaults
@set attrs, options if attrs
# Equality check based on the content of model, deep.
eql: (other, strict = false) ->
return true if @ == other
return false unless other and @id == other.id and _.isObject(other)
if strict
return false unless @_cid == other._cid and @constructor == other.constructor
# Checking if atributes and size of objects are the same.
size = 0
for own name, value of @ when not /^_/.test name
size += 1
return false unless _.isEqual value, other[name]
otherSize = 0
otherSize += 1 for own name of other when not /^_/.test name
return size == otherSize
equal: (other) -> @eql other, true
# Set attributes of model, changed attributes will be available as `model._changed`.
#
# If model uses events (see `useBackboneEvents` or `useEventEmitter`)
# following events will be emitted `change:attr` and `change`.
#
# `silent: false` - to suppres events and `validate: false` to suppress validation.
# `permit: ['name', 'email'] - to set only permitted attributes.
set: (obj, options = {}) ->
return unless obj
# Selecting attributes only.
attrs = attributes obj
# Selecting only permited attributes.
if permit = options.permit
permited = {}
permited[name] = value for name, value of attrs when name in permit
attrs = permited
# Casting attributes to specified types.
attrs[name] = MicroModel.cast(attrs[name], type) for name, type of @schema if @schema
# Validating attributes.
return false if @validate(attrs) and options.validate != false
# Updating and tracking changes.
@_changed = {}
unless options.silent
for name, newValue of attrs
currentValue = @[name]
unless _.isEqual currentValue, newValue
@_changed[name] = currentValue
@[name] = newValue
else
@[name] = newValue for name, newValue of attrs
# Emitting changes.
if @isEventEmitter and not options.silent and not _.isEmpty(@_changed)
@emit "change:#{name}", @ for name of @_changed
@emit 'change', @
true
# Shallow clone of model.
clone: -> new @constructor @attributes()
# Clear model.
clear: ->
delete @[name] for own name of @
@_changed = {}
@
# Validating attributes, returns `null` if attributes valid or any not null object as error.
validate: (attrs = {}) ->
return null unless @validations
errors = {}
for name, value of attrs
(errors[name] ?= []).push msg if msg = @validations[name]?(value)
if _.isEmpty(errors) then null else errors
# Define validation rules and store errors in `errors` property `@errors.add name: "can't be blank"`.
isValid: -> @validate @
attributes: -> attributes @
inspect: -> JSON.stringify @attributes()
toString: -> @inspect()
toJSON: -> @attributes()
# Adding klass information.
proto.isModel = true
# Adding methods.
_(proto).extend methods
# Adding another mixins.
MicroModel.withUnderscoreEqual klass
# 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})!"
# # Collection of models.
#
# Collection can store models, automatically sort it with given order and
# emit `add`, `change`, `delete` and `model:change`, `model:change:attr` events if
# Events module provided.
MicroModel.withCollection = (klass) ->
proto = klass.prototype
# Helper for proxying model events to collection listeners.
_proxyModelEvent = (model) -> @emit 'model:change', model, @
# Methods.
initializeWithoutCollection = proto.initialize
methods =
# Initialize collection, You may provide array of models and options.
initialize: (models, options = {}) ->
initializeWithoutCollection?.call @, models, options
@_proxyModelEvent = _proxyModelEvent.bind @
[@models, @length, @ids, @cids] = [[], 0, {}, {}]
@comparator = options.comparator
@add models, options if models
# Define comparator and collection always will be automatically sorted.
sort: (options) ->
@comparator = options.comparator if options.comparator
throw "no comparator!" unless @comparator
# Sorting.
if @comparator.length == 1
@models = _(@models).sortBy @comparator
else
@models.sort @comparator
# Emitting changes.
@emit 'change', @ if @isEventEmitter and options.silent != true
@
# Add model or models, `add` and `change` events will be triggered.
add: (args...) ->
if _.isArray args[0]
[models, options] = args
else
lastArgument = args[args.length - 1]
options = unless lastArgument.isModel then args.pop() else {}
models = args
options ?= {}
return unless models.length > 0
# Transforming object to model if it isn't.
tmp = models
models = []
for model in tmp
unless model.isModel
klass = @model || throw "no Model class for Collection (#{@})!"
model = new klass model
models.push model
# Adding to collection.
added = []
for model in models
# Model can be added only once, ignoring if it tried to be added twice.
continue if (model.id of @ids) or (model._cid of @cids)
@ids[model.id] = model
@cids[model._cid] = model
@models.push model
added.push model
@length = @models.length
# Proxing model events.
if @isEventEmitter and model.isEventEmitter
model.addListener 'change', @_proxyModelEvent for model in added
# Sorting.
@sort silent: true if @comparator
# Emitting events.
if @isEventEmitter and not options.silent and added.length > 0
@emit 'add', model, @ for model in added
@emit 'change', @
@
# Delete model or models, `delete` and `change` events will be emitted.
delete: (args...) ->
if _.isArray args[0]
[models, options] = args
else
lastArgument = args[args.length - 1]
options = unless lastArgument.isModel then args.pop() else {}
models = args
options ?= {}
return unless models.length > 0
# Deleting
deleted = []
for model in models
# Ignoring models that aren't in collection.
continue unless (model.id of @ids) or (model._cid of @cids)
index = @models.indexOf model
delete @ids[model.id]
delete @cids[model._cid]
@models.splice index, 1
deleted.push model
@length = @models.length
# Removing model events proxy.
if @isEventEmitter and model.isEventEmitter
model.removeListener 'change', @_proxyModelEvent for model in deleted
# Emitting events.
if @isEventEmitter and not options.silent and deleted.length > 0
@emit 'delete', model, @ for model in deleted
@emit 'change', @
@
# Get model by id.
get: (id) -> @ids[id] || @cids[id]
has: (id) -> (id of @ids) or (id of @cids)
# Get model by index.
at: (index) -> @models[index]
# Clear collection, `delete` and `change` events will be triggered.
clear: (options = {}) ->
# Deleting
deleted = @models
[@models, @length, @ids, @cids] = [[], 0, {}, {}]
# Removing model events proxy.
if @isEventEmitter and model.isEventEmitter
model.removeListener 'change', @_proxyModelEvent for model in deleted
# Emitting events.
if @isEventEmitter and not options.silent and deleted.length > 0
@emit 'delete', model, @ for model in deleted
@emit 'change', @
@
# Reset collection with new models.
reset: (args...) ->
@clear()
@add args...
inspect: -> JSON.stringify @models
toString: -> @inspect()
toJSON: -> @models
# Equality check based on list of models.
eql: (other, strict = false) ->
return true if @ == other
return false unless other and other.length == @length and other.models
if strict
return false unless other and @constructor == other.constructor
for model, index in @models
return false unless model.eql other.models[index], strict
true
equal: (other) -> @eql other, true
# Klass information.
proto.isCollection = true
# Adding methods.
_(proto).extend methods
# Adding another mixins.
MicroModel.withUnderscoreCollection klass
MicroModel.withUnderscoreEqual klass
# Class helper.
MicroModel.klass = (args...) ->
name = if _(args[0]).isString() then args.shift() else null
methods = unless _(args[args.length - 1]).isFunction() then args.pop() else {}
mixins = args
# Creating empty class.
klass = -> @initialize?.apply @, arguments
proto = klass.prototype
# Adding name to class and special property to check if object is instance of this class.
if name
klass.name = name
proto["is#{name}"] = true
# Adding mixings and methods.
mixin klass for mixin in mixins
_(proto).extend methods
klass
# Default Model and Collection.
MicroModel.Model = MicroModel.klass 'Model', MicroModel.withModel
MicroModel.Collection = MicroModel.klass 'Collection', MicroModel.withCollection