ampersand-state
Version:
An observable, extensible state object with derived watchable properties.
619 lines (544 loc) • 20.9 kB
JavaScript
// (c) 2013 Henrik Joreteg
// MIT Licensed
// For all details and documentation:
// https://github.com/HenrikJoreteg/human-model
//
var _ = require('underscore');
var BBEvents = require('backbone-events-standalone');
function Base(attrs, options) {
options || (options = {});
this.cid = _.uniqueId('model');
// set collection/registry if passed in
this.collection = options.collection;
if (options.registry) this.registry = options.registry;
if (options.parse) attrs = this.parse(attrs, options);
options._attrs = attrs;
this._namespace = options.namespace;
this._initted = false;
this._values = {};
this._initCollections();
this._cache = {};
this._previousAttributes = {};
this._events = {};
if (attrs) this.set(attrs, _.extend({silent: true, initial: true}, options));
this._changed = {};
this.initialize.apply(this, arguments);
if (attrs && attrs[this.idAttribute] && this.registry) _.result(this, 'registry').store(this);
this._initted = true;
if (this.seal) Object.seal(this);
};
var accessors = {
attributes: {
get: function () {
return this._getAttributes(true);
}
},
derived: {
get: function () {
var res = {};
for (var item in this._derived) res[item] = this._derived[item].fn.apply(this);
return res;
}
}
};
var prototypeMixins = {
idAttribute: 'id',
// can be allow, ignore, reject
extraProperties: 'ignore',
getId: function () {
return this.get(this.idAttribute);
},
// stubbed out to be overwritten
initialize: function () {
return this;
},
// backbone compatibility
parse: function (resp, options) {
return resp;
},
// serialize gets props in raw form
serialize: function () {
return this._getAttributes(false, true);
},
set: function (key, value, options) {
var self = this;
var extraProperties = this.extraProperties;
var changing, previous, changes, newType,
interpretedType, newVal, def, attr, attrs, silent, unset, currentVal, initial;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (_.isObject(key) || key === null) {
attrs = key;
options = value;
} else {
attrs = {};
attrs[key] = value;
}
options = options || {};
if (!this._validate(attrs, options)) return false;
// Extract attributes and options.
unset = options.unset;
silent = options.silent;
initial = options.initial;
changes = [];
changing = this._changing;
this._changing = true;
// if not already changing, store previous
if (!changing) {
this._previousAttributes = this._getAttributes(true);
this._changed = {};
}
previous = this._previousAttributes;
// For each `set` attribute...
for (attr in attrs) {
newVal = attrs[attr];
newType = typeof newVal;
currentVal = this._values[attr];
def = this._definition[attr];
if (!def) {
if (extraProperties === 'ignore') {
continue;
} else if (extraProperties === 'reject') {
throw new TypeError('No "' + attr + '" property defined on ' + (this.type || 'this') + ' model and allowOtherProperties not set.');
} else if (extraProperties === 'allow') {
def = this._createPropertyDefinition(attr, 'any');
}
}
// check type if we have one
if (dataTypes[def.type]) {
var cast = dataTypes[def.type].set(newVal);
newVal = cast.val;
newType = cast.type;
}
// If we've defined a test, run it
if (def.test) {
var err = def.test.call(this, newVal, newType);
if (err) {
throw new TypeError('Property \'' + attr + '\' failed validation with error: ' + err);
}
}
// If we are required but undefined, throw error.
// If we are null and are not allowing null, throw error
// If we have a defined type and the new type doesn't match, and we are not null, throw error.
if (_.isUndefined(newVal) && def.required) {
throw new TypeError('Required property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal);
}
if (_.isNull(newVal) && def.required && !def.allowNull) {
throw new TypeError('Property \'' + attr + '\' must be of type ' + def.type + ' (cannot be null). Tried to set ' + newVal);
}
if ((def.type && def.type !== 'any' && def.type !== newType) && !_.isNull(newVal) && !_.isUndefined(newVal)) {
throw new TypeError('Property \'' + attr + '\' must be of type ' + def.type + '. Tried to set ' + newVal);
}
if (def.values && !_.contains(def.values, newVal)) {
throw new TypeError('Property \'' + attr + '\' must be one of values: ' + def.values.map(function (item) { return item.toString(); }).join(', '));
}
// enforce `setOnce` for properties if set
if (def.setOnce && currentVal !== undefined && !_.isEqual(currentVal, newVal)) {
throw new TypeError('Property \'' + key + '\' can only be set once.');
}
// push to changes array if different
if (!_.isEqual(currentVal, newVal)) {
changes.push({prev: currentVal, val: newVal, key: attr});
}
// keep track of changed attributes
if (!_.isEqual(previous[attr], newVal)) {
self._changed[attr] = newVal;
} else {
delete self._changed[attr];
}
}
// actually update our values
_.each(changes, function (change) {
self._previousAttributes[change.key] = change.prev;
if (unset) {
delete self._values[change.key];
} else {
self._values[change.key] = change.val;
}
});
var triggers = [];
function gatherTriggers(key) {
triggers.push(key);
_.each((self._deps[key] || []), function (derTrigger) {
gatherTriggers(derTrigger);
});
}
if (!silent && changes.length) self._pending = true;
_.each(changes, function (change) {
gatherTriggers(change.key);
});
_.each(_.uniq(triggers), function (key) {
var derived = self._derived[key];
if (derived && derived.cache && !initial) {
var oldDerived = self._cache[key];
var newDerived = self._getDerivedProperty(key, true);
if (!_.isEqual(oldDerived, newDerived)) {
self._previousAttributes[key] = oldDerived;
if (!silent) self.trigger('change:' + key, self, newDerived);
}
} else {
if (!silent) self.trigger('change:' + key, self, self[key]);
}
});
// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
if (changing) return this;
if (!silent) {
while (this._pending) {
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
return this;
},
get: function (attr) {
return this[attr];
},
// Toggle boolean properties or properties that have a `values`
// array in its definition.
toggle: function (property) {
var def = this._definition[property];
if (def.type === 'boolean') {
// if it's a bool, just flip it
this[property] = !this[property];
} else if (def && def.values) {
// If it's a property with an array of values
// skip to the next one looping back if at end.
this[property] = arrayNext(def.values, this[property]);
} else {
throw new TypeError('Can only toggle properties that are type `boolean` or have `values` array.');
}
return this;
},
// Get all of the attributes of the model at the time of the previous
// `"change"` event.
previousAttributes: function () {
return _.clone(this._previousAttributes);
},
// Determine if the model has changed since the last `"change"` event.
// If you specify an attribute name, determine if that attribute has changed.
hasChanged: function (attr) {
if (attr == null) return !_.isEmpty(this._changed);
return _.has(this._changed, attr);
},
// Return an object containing all the attributes that have changed, or
// false if there are no changed attributes. Useful for determining what
// parts of a view need to be updated and/or what attributes need to be
// persisted to the server. Unset attributes will be set to undefined.
// You can also pass an attributes object to diff against the model,
// determining if there *would be* a change.
changedAttributes: function (diff) {
if (!diff) return this.hasChanged() ? _.clone(this._changed) : false;
var val, changed = false;
var old = this._changing ? this._previousAttributes : this._getAttributes(true);
for (var attr in diff) {
if (_.isEqual(old[attr], (val = diff[attr]))) continue;
(changed || (changed = {}))[attr] = val;
}
return changed;
},
toJSON: function () {
return this.serialize();
},
// Returns `true` if the attribute contains a value that is not null
// or undefined.
has: function (attr) {
return this.get(attr) != null;
},
// return copy of model
clone: function () {
return new this.constructor(this._getAttributes(true));
},
unset: function (attr, options) {
var def = this._definition[attr];
var type = def.type;
var val;
if (def.required) {
if (!_.isUndefined(def.default)) {
val = def.default;
} else {
val = this._getDefaultForType(type);
}
return this.set(attr, val, options);
} else {
return this.set(attr, val, _.extend({}, options, {unset: true}));
}
},
clear: function (options) {
var self = this;
_.each(this._getAttributes(true), function (val, key) {
self.unset(key, options);
});
return this;
},
previous: function (attr) {
if (attr == null || !Object.keys(this._previousAttributes).length) return null;
return this._previousAttributes[attr];
},
// Get default values for a certain type
_getDefaultForType: function (type) {
if (type === 'string') {
return '';
} else if (type === 'object') {
return {};
} else if (type === 'array') {
return [];
}
},
// Run validation against the next complete set of model attributes,
// returning `true` if all is well. Otherwise, fire an `"invalid"` event.
_validate: function (attrs, options) {
if (!options.validate || !this.validate) return true;
attrs = _.extend({}, this.attributes, attrs);
var error = this.validationError = this.validate(attrs, options) || null;
if (!error) return true;
this.trigger('invalid', this, error, _.extend(options || {}, {validationError: error}));
return false;
},
_createPropertyDefinition: function (name, desc, isSession) {
var self = this;
var def = this._definition[name] = {};
var type;
if (_.isString(desc)) {
// grab our type if all we've got is a string
type = this._ensureValidType(desc);
if (type) def.type = type;
} else {
type = this._ensureValidType(desc[0] || desc.type);
if (type) def.type = type;
if (desc[1] || desc.required) def.required = true;
// set default if defined
def.default = !_.isUndefined(desc[2]) ? desc[2] : desc.default;
def.allowNull = desc.allowNull ? desc.allowNull : false;
if (desc.setOnce) def.setOnce = true;
if (def.required && _.isUndefined(def.default)) def.default = this._getDefaultForType(type);
def.test = desc.test;
def.values = desc.values;
}
if (isSession) def.session = true;
// define a getter/setter on the prototype
// but they get/set on the instance
Object.defineProperty(self, name, {
set: function (val) {
this.set(name, val);
},
get: function () {
var result = this._values[name];
if (typeof result !== 'undefined') {
if (dataTypes[def.type] && dataTypes[def.type].get) {
result = dataTypes[def.type].get(result);
}
return result;
}
return def.default;
}
});
return def;
},
// just makes friendlier errors when trying to define a new model
// only used when setting up original property definitions
_ensureValidType: function (type) {
return _.contains(['string', 'number', 'boolean', 'array', 'object', 'date', 'any'].concat(_.keys(dataTypes)), type) ? type : undefined;
},
_getAttributes: function (includeSession, raw) {
var res = {};
var val, item, def;
for (item in this._definition) {
def = this._definition[item];
if (!def.session || (includeSession && def.session)) {
val = (raw) ? this._values[item] : this[item];
if (typeof val === 'undefined') val = def.default;
if (typeof val !== 'undefined') res[item] = val;
}
}
return res;
},
_getDerivedProperty: function (name, flushCache) {
// is this a derived property that is cached
if (this._derived[name].cache) {
// read through cache
if (!flushCache && this._cache.hasOwnProperty(name)) {
return this._cache[name];
} else {
return this._cache[name] = this._derived[name].fn.apply(this);
}
} else {
return this._derived[name].fn.apply(this);
}
},
_initCollections: function () {
var coll;
if (!this._collections) return;
for (coll in this._collections) {
this[coll] = new this._collections[coll]();
this[coll].parent = this;
}
},
// Check that all required attributes are present
_verifyRequired: function () {
var attrs = this._getAttributes(true); // should include session
for (var def in this._definition) {
if (this._definition[def].required && typeof attrs[def] === 'undefined') {
return false;
}
}
return true;
}
};
// Underscore methods we want to add
_.each(['keys', 'values', 'pairs', 'invert', 'pick', 'omit'], function (method) {
prototypeMixins[method] = function () {
var args = Array.prototype.slice.call(arguments);
args.unshift(this.attributes);
return _[method].apply(_, args);
};
});
// add event methods
BBEvents.mixin(prototypeMixins)
// our dataTypes
var dataTypes = {
date: {
set: function (newVal) {
var newType;
if (!_.isDate(newVal)) {
try {
newVal = (new Date(parseInt(newVal, 10))).valueOf();
newType = 'date';
} catch (e) {
newType = typeof newVal;
}
} else {
newType = 'date';
newVal = newVal.valueOf();
}
return {
val: newVal,
type: newType
};
},
get: function (val) {
return new Date(val);
}
},
array: {
set: function (newVal) {
return {
val: newVal,
type: _.isArray(newVal) ? 'array' : typeof newVal
};
}
},
object: {
set: function (newVal) {
var newType = typeof newVal;
// we have to have a way of supporting "missing" objects.
// Null is an object, but setting a value to undefined
// should work too, IMO. We just override it, in that case.
if (newType !== 'object' && _.isUndefined(newVal)) {
newVal = null;
newType = 'object';
}
return {
val: newVal,
type: newType
};
}
}
};
var arrayNext = function (array, currentItem) {
var len = array.length;
var newIndex = array.indexOf(currentItem) + 1;
if (newIndex > (len - 1)) newIndex = 0;
return array[newIndex];
};
var createDerivedProperty = function (modelProto, name, definition) {
var def = modelProto._derived[name] = {
fn: _.isFunction(definition) ? definition : definition.fn,
cache: (definition.cache !== false),
depList: definition.deps || []
};
// add to our shared dependency list
_.each(def.depList, function (dep) {
modelProto._deps[dep] = _(modelProto._deps[dep] || []).union([name]);
});
// defined a top-level getter for derived names
Object.defineProperty(modelProto, name, {
get: function () {
return this._getDerivedProperty(name);
},
set: function () {
throw new TypeError('"' + name + '" is a derived property, it can\'t be set directly.');
}
});
};
var extend = function (spec) {
var parent = this;
var BaseClass = this._super || Base;
var props, session, derived, collections;
function State() {
BaseClass.apply(this, arguments);
}
// Add our special accessor properties
Object.defineProperties(State.prototype, accessors);
// Mix in our methods
_.extend(State.prototype, prototypeMixins, {
// storage for our rules about derived properties
_derived: {},
_deps: {},
_definition: {}
});
// Copy any methods from existing prototype
_.each(BaseClass.prototype, function (value, key) {
if (value instanceof Function) {
State.prototype[key] = value;
}
});
// Pull out previous model spec
if (this._spec) {
props = this._spec.props;
session = this._spec.session;
derived = this._spec.derived;
collections = this._spec.collections;
}
// Extend previous with new special attributes
State._spec = {
props: _.extend({}, props, spec.props),
session: _.extend({}, session, spec.session),
derived: _.extend({}, derived, spec.derived),
collections: _.extend({}, collections, spec.collections)
};
// remove handled references before we loop
delete spec.props;
delete spec.session;
delete spec.derived;
delete spec.collections;
// Extend spec with any new proto methods we may have just added
_.extend(State._spec, spec);
_.each(State._spec, function (value, key) {
if (key === 'props' || key === 'session') {
_.each(value, function (def, name) {
State.prototype._createPropertyDefinition.call(State.prototype, name, def, key === 'session');
});
} else if (key === 'derived') {
_.each(value, function (def, name) {
createDerivedProperty(State.prototype, name, def);
});
} else if (key === 'collections') {
State.prototype._collections = value;
} else {
State.prototype[key] = value;
}
});
// Keep reference to super human™
State._super = State;
// Maintain ability to further extend
State.extend = extend;
State.dataTypes = dataTypes;
return State;
};
// also expose data types in our export
Base.dataTypes = dataTypes;
Base.extend = extend;
// Our main exports
module.exports = Base;