passive-model
Version:
Model that can be used on both Client & Server
468 lines (396 loc) • 14.4 kB
text/coffeescript
# If it's Browser, making it looks like "standard" JS.
global ?= window
_ = global._ || require 'underscore'
raise = (msg) -> throw new Error msg
# # Model
#
# Model for representing Business Data & Logic.
#
# Attributes stored as properties You can get it as `model.name` but You should set it only via `set` method,
# like `model.set name: 'foo'`.
# Atributes starting with `_` prefix are ignored, You can use it for temporarry things like
# caching `_cache`.
#
# By default model has no schema or attribute types, but You can define attribute types if You need it,
# see `cast` method.
#
# It has three special properties `id`, `errors` - containing current errors
# and `changed` - containing attributes changed from last `set` operation.
#
# You can define validation rules in `validate` method and run validation validity of model by
# using `valid` method.
#
# Model can be used on both Client and Server with differrent persistency providers, see `mongo-lite`
# and `rest-lite` adapters. You can also serialize model to and from hash by using `toHash`
# and 'fromHash` methods.
#
# If You need notifications (for example to use it with Backbone framework) it can be integrated with
# Backbone.Events module, and will trigger `change` and `change:attr` events.
#
# Use `_(Model.prototype).extend Backbone.Events` to integrate it with `Backbone.Events`.
class Model
# You can initialize model with `attributes`, arguments are the same as for `set` method.
# You can also define the `defaults` property on the model with default attribute values.
constructor: (attributes, options) ->
@set @defaults, options if @defaults
@set attributes if attributes
@_wrapErrors()
@changed = {}
# Check for equality based on the content of objects, deep.
eq: (other) -> _.isEqual @, other
# Set attributes of model, if the attribute is the same it will be ignored, list
# of changed attributes will be available in `changed` variable.
#
# If model implements `trigger` method (for example by extending Backbone.Events module) the
# following events will be triggered (except if new value of attribute is equal to old in that case
# no event will be triggered): `change:attr` for every changed attribute and`change`.
#
# - if `cast` option provided it will set only attributes that explicitly defined in
# schema (see `cast` method) and ignore others, You may use it as a vay to safe update attributes.
# - if `silent` option provided no event will be triggered.
#
set: (attributes, options) ->
attributes ?= {}
options ?= {}
# Casting attributes.
attributes = @castAttributes attributes if options.cast
# Updating attributes & tracking changes.
@changed = {}
for own k, v of attributes
@changed[k] = @[k] unless _.isEqual @[k], v
@[k] = v
# Wrapping errors in handy wrapper.
@_wrapErrors()
# Notifying observers if Events module enabled.
if @trigger? and !_.isEmpty(@changed) and (options.silent != true)
for k, v of @changed
event = "change:#{k}"
@trigger event, event, @
@trigger 'change', 'change', @
@
# Clone model
clone: -> new @constructor @attributes()
# Clear model.
clear: ->
delete @[k] for own k, v of @
@errors = new Model.Errors()
@changed = {}
@
# Check model for validity using `validate` method, if there will be errors - they will be saved in
# `errors` property. If model implements `trigger` method `change:errors` & `change` events will
# be trigerred.
#
# Model is valid when `errors` property is empty.
valid: (options = {}) ->
# Returning result without validation.
return _(@errors).size() == 0 if options.validate == false
# Running validations.
oldErrors = @errors
@errors = new Model.Errors()
@validate()
newErrors = @errors
@errors = oldErrors
# Triggering events.
@set errors: newErrors, silent: options.silent
# Returning result.
_(@errors).size() == 0
invalid: -> !@valid()
# Define validation rules and store errors in `errors` property `@errors.add name: "can't be blank"`.
validate: ->
# Return list of model attributes, properties starting from `_` prefix are ignored, so are special
# `errors` and `changed` properties.
attributes: ->
attrs = {}
attrs[k] = v for own k, v of @ when not /^_/.test k
delete attrs.errors
delete attrs.changed
attrs
# Wraps errors object into special wrapper with andy helper methods.
_wrapErrors: ->
unless @errors?.constructor == Model.Errors
old = @errors || {}
@errors = new Model.Errors()
@errors[k] = v for own k, v of old
# Utility helper for adding methods to object without making it enumerable.
definePropertyWithoutEnumeration = (obj, name, value) ->
Object.defineProperty obj, name,
enumerable: false
writable: true
configurable: true
value: value
# # Errors
#
# Error messages stored in `errors` property of model in arbitrary format, but usually its strucrue
# looks like this:
#
# errors:
# name : ["can't be blank"]
# accept : ['must be accepted']
#
# in order to easy working with errors we adding helper methods `add` and `clear`.
class Model.Errors
# Clearing error messages.
definePropertyWithoutEnumeration Model.Errors.prototype, 'clear', ->
delete @[k] for own k, v of @
# Adding message to errors, use `@errors.add name: "can't be blank"` it will be added as
# `{name: ["can't be blank"]}`.
definePropertyWithoutEnumeration Model.Errors.prototype, 'add', (args...) ->
if args.length == 1
@add attr, message for attr, message of args[0]
else
[attr, message] = args
@[attr] ?= []
@[attr].push message
# Size of error messages.
definePropertyWithoutEnumeration Model.Errors.prototype, 'size', (args...) ->
_(@).size()
# # Conversions.
#
# Convert model to and from Hash, also supports child models.
# Adding conversion methods to Model prototype.
_(Model.prototype).extend
# Marker to easy distinguish model from other objects.
_model: true
# Convert model to hash, You can use `only` and `except` options to specify exactly what
# attributes do You need. It also converts child models.
toHash: (options) ->
options ?= {}
# Converting Attributes.
hash = {}
if options.only
hash[k] = @[k] for k in options.only
else if options.except
hash = @attributes()
delete hash[k] for k in options.except
else
hash = @attributes()
# Adding errors.
hash.errors = @errors unless options.errors == false
# Converting children objects.
that = @
for k in @constructor._children
continue if options.only and !(options.only.indexOf(k) > 0)
continue if options.except and (options.except.indexOf(k) > 0)
if obj = that[k]
if obj.toHash
r = obj.toHash options
# if obj.toArray
# r = obj.toArray()
else if _.isArray obj
r = []
for v in obj
v = if v.toHash then v.toHash(options) else v
r.push v
else if _.isObject obj
r = {}
for own k, v of obj
v = if v.toHash then v.toHash(options) else v
r[k] = v
else
r = obj
hash[k] = r
# Adding class.
if options.class
klass = @constructor.name || raise "no constructor name!"
hash.class = klass
hash
# Updates model from Hash, also updates child models.
fromHash: (hash) ->
model = Model.fromHash hash, @constructor
attributes = model.attributes()
attributes.errors = model.errors
@set attributes
@
# Addig conversion methods to Model class.
_(Model).extend
# Declare embedded child models `@children 'comments'`.
children: (args...) -> @_children = @_children.concat args
# By default there's no child models.
_children: []
# Creates new model from Hash, also works with child models.
fromHash: (hash, klass) ->
raise "can't unmarshal model, no class provided!" unless klass
raise Error "#{klass} isn't ancestor of Model!" unless klass.prototype._model
# Creating object.
obj = new klass()
# Restoring attributes.
obj[k] = v for own k, v of hash
delete obj.class
obj._wrapErrors()
# Restoring children.
for k in (klass._children || [])
if o = hash[k]
if o.class
klass = Model.getClass o.class
r = Model.fromHash o, klass
else if _.isArray o
r = []
for v in o
if v.class
klass = Model.getClass v.class
v = Model.fromHash v, klass
r.push v
else if _.isObject o
r = {}
for own k, v of o
if v.class
klass = Model.getClass v.class
v = Model.fromHash v, klass
r[k] = v
obj[k] = r
# Allow custom processing to be added.
# klass.afterFromHash? obj, hash
obj
# Takes string - name of class and returns class function.
#
# In order to deserialize model from hash we need a way to get a class from its string name.
# There may be different strategies, for example You may store Your class globally `global.Post`
# or in some namespace for example `app.Post` or `models.Post`, or use other strategy.
#
# Override it if You need other strategy.
getClass: (name) ->
global.models?[name] || global.app?[name] || global[name] ||
raise "can't get '#{name}' class!"
# # Attribute types
#
# You can specify attribyte tupes for model, and use it to automatically cast string values to
# correct types.
#
# For example if You declare `count` as having Number type then `model.set {count: '2'}, cast: true`
# will cast String `'2'` to Number and only then assign it to model.
# Extending Model with attribute types.
_(Model).extend
# Use `cast count: Number` to declare that `count` attribute has Number type.
cast: (args...) ->
if args.length == 1
@cast attr, type for own attr, type of args[0]
else
[attr, type] = args
caster = "cast#{attr[0..0].toUpperCase()}#{attr[1..attr.length]}"
@prototype[caster] = (v) ->
if type then Model._cast(v, type) else v
# Cast string value to given type, You may override and extend it to provide more types or Your
# own custom types.
_cast: (v, type) ->
type ?= String
casted = if type == String
v.toString()
else if type == Number
if _.isNumber(v)
v
else if _.isString v
tmp = parseInt v
tmp if _.isNumber tmp
else if type == Boolean
if _.isBoolean v
v
else if _.isString v
v == 'true'
else if type == Date
if _.isDate v
v
else if _.isString v
tmp = new Date v
tmp if _.isDate tmp
else
raise "can't cast, unknown type #{type}!"
raise "can't cast, invalid value #{v}!" unless casted?
casted
# Extending model with attribute types.
_(Model.prototype).extend
# Cast string attributes to correct types, if attribute have no type it will be ignored and skipped.
castAttributes: (attributes = {}) ->
casted = {}
for own k, v of attributes
caster = "cast#{k[0..0].toUpperCase()}#{k[1..k.length]}"
casted[k] = @[caster] v if caster of @
casted
# # Collection
#
# Collection can store models, automatically sort it with given order and
# notify watchers with `add`, `change`, and `delete` events if Events module provided.
class Model.Collection
# Initialize collection, You may provide array of models and options.
constructor: (models, options = {}) ->
[@models, @length, @ids] = [[], 0, {}]
@comparator = options.comparator
@add models if models
# Define comparator and collection always will be automatically sorted.
sort: (options) ->
# Add model or models, `add` and `change` events will be triggered (if Events module provided).
add: (args...) ->
if args.length == 1 and _.isArray(args[0])
[models, options] = [args[0], {}]
else
options = unless args[args.length - 1]?._model then args.pop() else {}
models = args
# Adding.
for model in models
@models.push model
@ids[model.id] = model unless _.isEmpty(model.id)
@length = @models.length
# Sorting.
@sort silent: true
# Notifying
if @trigger and (options.silent != true)
@trigger 'add', model, @ for model in models
@trigger 'change', @
@
# Delete model or models, `delete` and `change` events will be triggered (if Events module provided).
delete: (args...) ->
if args.length == 1 and _.isArray(args[0])
[models, options] = [args[0], {}]
else
options = unless args[args.length - 1]?._model then args.pop() else {}
models = args
# Deleting
deleted = []
for model in models
id = model.id
unless _.isEmpty id
if id of @ids
deleted.push model
delete @ids[id]
index = @models.indexOf model
@models.splice index, 1
else
for m, index in @models when model.eq m
deleted.push m
delete @ids[m.id]
@models.splice index, 1
@length = @models.length
# Notifying
if @trigger and (options.silent != true) and (deleted.length > 0)
@trigger 'delete', model, @ for model in deleted
@trigger 'change', @
@
# Get model by id.
get: (id) -> @ids[id]
# Get model by index.
at: (index) -> @models[index]
# Clear collection, `delete` and `change` events will be triggered (if Events module provided).
clear: (options = {}) ->
# Deleting
deleted = @models
[@models, @length, @ids] = [[], 0, {}]
# Notifying
if @trigger and (options.silent != true) and (deleted.length > 0)
@trigger 'delete', model, @ for model in deleted
@trigger 'change', @
@
# # Validations
#
# A shortcuts for couple of most frequently used valiations.
_(Model.prototype).extend
# Validates presence of attribute or attributes.
validatesPresenceOf: (attrs...) ->
for attr in attrs
v = @[attr]
blank = (v == null) or (v == undefined) or
(_.isString(v) and (v.replace(/\s+/g, '') == ''))
@errors.add attr, "can't be blank" if blank
# Exporting to outer world.
if module?
module.exports = Model
else
global.PassiveModel = Model