Plato on Github
Report Home
ampersand-state/ampersand-state.js
Maintainability
66.57
Lines of code
798
Difficulty
86.17
Estimated Errors
8.47
Function weight
By Complexity
By SLOC
/*$AMPERSAND_VERSION*/ var uniqueId = require('lodash.uniqueid'); var assign = require('lodash.assign'); var omit = require('lodash.omit'); var escape = require('lodash.escape'); var forEach = require('lodash.foreach'); var includes = require('lodash.includes'); var isString = require('lodash.isstring'); var isObject = require('lodash.isobject'); var isArray = require('lodash.isarray'); var isDate = require('lodash.isdate'); var isUndefined = require('lodash.isundefined'); var isFunction = require('lodash.isfunction'); var isNull = require('lodash.isnull'); var isEmpty = require('lodash.isempty'); var isEqual = require('lodash.isequal'); var clone = require('lodash.clone'); var has = require('lodash.has'); var result = require('lodash.result'); var keys = require('lodash.keys'); var bind = require('lodash.bind'); var defaults = require('lodash.defaults'); var union = require('lodash.union'); var Events = require('ampersand-events'); var KeyTree = require('key-tree-store'); var arrayNext = require('array-next'); var changeRE = /^change:/; function Base(attrs, options) { options || (options = {}); this.cid || (this.cid = uniqueId('state')); this._events = {}; this._values = {}; this._definition = Object.create(this._definition); if (options.parse) attrs = this.parse(attrs, options); this.parent = options.parent; this.collection = options.collection; this._keyTree = new KeyTree(); this._initCollections(); this._initChildren(); this._cache = {}; this._previousAttributes = {}; if (attrs) this.set(attrs, assign({silent: true, initial: true}, options)); this._changed = {}; if (this._derived) this._initDerived(); if (options.init !== false) this.initialize.apply(this, arguments); } assign(Base.prototype, Events, { // can be allow, ignore, reject extraProperties: 'ignore', idAttribute: 'id', namespaceAttribute: 'namespace', typeAttribute: 'modelType', // Stubbed out to be overwritten initialize: function () { return this; }, // Get ID of model per configuration. // Should *always* be how ID is determined by other code. getId: function () { return this[this.idAttribute]; }, // Get namespace of model per configuration. // Should *always* be how namespace is determined by other code. getNamespace: function () { return this[this.namespaceAttribute]; }, // Get type of model per configuration. // Should *always* be how type is determined by other code. getType: function () { return this[this.typeAttribute]; }, // A model is new if it has never been saved to the server, and lacks an id. isNew: function () { return this.getId() == null; }, // get HTML-escaped value of attribute escape: function (attr) { return escape(this.get(attr)); }, // Check if the model is currently in a valid state. isValid: function (options) { return this._validate({}, assign(options || {}, { validate: true })); }, // Parse can be used remap/restructure/rename incoming properties // before they are applied to attributes. parse: function (resp, options) { //jshint unused:false return resp; }, // Serialize is the inverse of `parse` it lets you massage data // on the way out. Before, sending to server, for example. serialize: function () { var res = this.getAttributes({props: true}, true); forEach(this._children, function (value, key) { res[key] = this[key].serialize(); }, this); forEach(this._collections, function (value, key) { res[key] = this[key].serialize(); }, this); return res; }, // Main set method used by generated setters/getters and can // be used directly if you need to pass options or set multiple // properties at once. set: function (key, value, options) { var self = this; var extraProperties = this.extraProperties; var changing, changes, newType, newVal, def, cast, err, attr, attrs, dataType, silent, unset, currentVal, initial, hasChanged, isEqual; // 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.attributes; this._changed = {}; } // 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 this is a child model or collection if (this._children[attr] || this._collections[attr]) { this[attr].set(newVal, options); continue; } else if (extraProperties === 'ignore') { continue; } else if (extraProperties === 'reject') { throw new TypeError('No "' + attr + '" property defined on ' + (this.type || 'this') + ' model and extraProperties not set to "ignore" or "allow"'); } else if (extraProperties === 'allow') { def = this._createPropertyDefinition(attr, 'any'); } else if (extraProperties) { throw new TypeError('Invalid value for extraProperties: "' + extraProperties + '"'); } } isEqual = this._getCompareForType(def.type); dataType = this._dataTypes[def.type]; // check type if we have one if (dataType && dataType.set) { cast = dataType.set(newVal); newVal = cast.val; newType = cast.type; } // If we've defined a test, run it if (def.test) { 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 && !includes(def.values, newVal)) { throw new TypeError('Property \'' + attr + '\' must be one of values: ' + def.values.join(', ') + '. Tried to set ' + newVal); } hasChanged = !isEqual(currentVal, newVal, attr); // enforce `setOnce` for properties if set if (def.setOnce && currentVal !== undefined && hasChanged && !initial) { throw new TypeError('Property \'' + attr + '\' can only be set once.'); } // keep track of changed attributes // and push to changes array if (hasChanged) { changes.push({prev: currentVal, val: newVal, key: attr}); self._changed[attr] = newVal; } else { delete self._changed[attr]; } } // actually update our values forEach(changes, function (change) { self._previousAttributes[change.key] = change.prev; if (unset) { delete self._values[change.key]; } else { self._values[change.key] = change.val; } }); if (!silent && changes.length) self._pending = true; if (!silent) { forEach(changes, function (change) { self.trigger('change:' + change.key, self, change.val, options); }); } // 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.attributes; var def, isEqual; for (var attr in diff) { def = this._definition[attr]; if (!def) continue; isEqual = this._getCompareForType(def.type); if (isEqual(old[attr], (val = diff[attr]))) continue; (changed || (changed = {}))[attr] = val; } return changed; }, toJSON: function () { return this.serialize(); }, unset: function (attrs, options) { attrs = Array.isArray(attrs) ? attrs : [attrs]; forEach(attrs, function (key) { var def = this._definition[key]; var val; if (def.required) { val = result(def, 'default'); return this.set(key, val, options); } else { return this.set(key, val, assign({}, options, {unset: true})); } }, this); }, clear: function (options) { var self = this; forEach(keys(this.attributes), function (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) { var dataType = this._dataTypes[type]; return dataType && dataType['default']; }, // Determine which comparison algorithm to use for comparing a property _getCompareForType: function (type) { var dataType = this._dataTypes[type]; if (dataType && dataType.compare) return bind(dataType.compare, this); return isEqual; }, // 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 = assign({}, this.attributes, attrs); var error = this.validationError = this.validate(attrs, options) || null; if (!error) return true; this.trigger('invalid', this, error, assign(options || {}, {validationError: error})); return false; }, _createPropertyDefinition: function (name, desc, isSession) { return createPropertyDefinition(this, name, desc, isSession); }, // just makes friendlier errors when trying to define a new model // only used when setting up original property definitions _ensureValidType: function (type) { return includes(['string', 'number', 'boolean', 'array', 'object', 'date', 'any'].concat(keys(this._dataTypes)), type) ? type : undefined; }, getAttributes: function (options, raw) { options || (options = {}); defaults(options, { session: false, props: false, derived: false }); var res = {}; var val, item, def; for (item in this._definition) { def = this._definition[item]; if ((options.session && def.session) || (options.props && !def.session)) { val = (raw) ? this._values[item] : this[item]; if (typeof val === 'undefined') val = result(def, 'default'); if (typeof val !== 'undefined') res[item] = val; } } if (options.derived) { for (item in this._derived) res[item] = this[item]; } return res; }, _initDerived: function () { var self = this; forEach(this._derived, function (value, name) { var def = self._derived[name]; def.deps = def.depList; var update = function (options) { options = options || {}; var newVal = def.fn.call(self); if (self._cache[name] !== newVal || !def.cache) { if (def.cache) { self._previousAttributes[name] = self._cache[name]; } self._cache[name] = newVal; self.trigger('change:' + name, self, self._cache[name]); } }; def.deps.forEach(function (propString) { self._keyTree.add(propString, update); }); }); this.on('all', function (eventName) { if (changeRE.test(eventName)) { self._keyTree.get(eventName.split(':')[1]).forEach(function (fn) { fn(); }); } }, this); }, _getDerivedProperty: function (name, flushCache) { // is this a derived property that is cached if (this._derived[name].cache) { //set if this is the first time, or flushCache is set if (flushCache || !this._cache.hasOwnProperty(name)) { this._cache[name] = this._derived[name].fn.apply(this); } return this._cache[name]; } 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](null, {parent: this}); } }, _initChildren: function () { var child; if (!this._children) return; for (child in this._children) { this[child] = new this._children[child]({}, {parent: this}); this.listenTo(this[child], 'all', this._getEventBubblingHandler(child)); } }, // Returns a bound handler for doing event bubbling while // adding a name to the change string. _getEventBubblingHandler: function (propertyName) { return bind(function (name, model, newValue) { if (changeRE.test(name)) { this.trigger('change:' + propertyName + '.' + name.split(':')[1], model, newValue); } else if (name === 'change') { this.trigger('change', this); } }, this); }, // Check that all required attributes are present _verifyRequired: function () { var attrs = this.attributes; // should include session for (var def in this._definition) { if (this._definition[def].required && typeof attrs[def] === 'undefined') { return false; } } return true; } }); // getter for attributes Object.defineProperties(Base.prototype, { attributes: { get: function () { return this.getAttributes({props: true, session: true}); } }, all: { get: function () { return this.getAttributes({ session: true, props: true, derived: true }); } }, isState: { get: function () { return true; }, set: function () { } } }); // helper for creating/storing property definitions and creating // appropriate getters/setters function createPropertyDefinition(object, name, desc, isSession) { var def = object._definition[name] = {}; var type, descArray; if (isString(desc)) { // grab our type if all we've got is a string type = object._ensureValidType(desc); if (type) def.type = type; } else { //Transform array of ['type', required, default] to object form if (isArray(desc)) { descArray = desc; desc = { type: descArray[0], required: descArray[1], 'default': descArray[2] }; } type = object._ensureValidType(desc.type); if (type) def.type = type; if (desc.required) def.required = true; if (desc['default'] && typeof desc['default'] === 'object') { throw new TypeError('The default value for ' + name + ' cannot be an object/array, must be a value or a function which returns a value/object/array'); } def['default'] = desc['default']; def.allowNull = desc.allowNull ? desc.allowNull : false; if (desc.setOnce) def.setOnce = true; if (def.required && isUndefined(def['default']) && !def.setOnce) def['default'] = object._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(object, name, { set: function (val) { this.set(name, val); }, get: function () { var value = this._values[name]; var typeDef = this._dataTypes[def.type]; if (typeof value !== 'undefined') { if (typeDef && typeDef.get) { value = typeDef.get(value); } return value; } value = result(def, 'default'); this._values[name] = value; return value; } }); return def; } // helper for creating derived property definitions function createDerivedProperty(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 forEach(def.depList, function (dep) { modelProto._deps[dep] = union(modelProto._deps[dep] || [], [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 dataTypes = { string: { 'default': function () { return ''; } }, date: { set: function (newVal) { var newType; if (newVal == null) { newType = typeof null; } else if (!isDate(newVal)) { try { var dateVal = new Date(newVal).valueOf(); if (isNaN(dateVal)) { // If the newVal cant be parsed, then try parseInt first dateVal = new Date(parseInt(newVal, 10)).valueOf(); if (isNaN(dateVal)) throw TypeError; } newVal = dateVal; newType = 'date'; } catch (e) { newType = typeof newVal; } } else { newType = 'date'; newVal = newVal.valueOf(); } return { val: newVal, type: newType }; }, get: function (val) { if (val == null) { return val; } return new Date(val); }, 'default': function () { return new Date(); } }, array: { set: function (newVal) { return { val: newVal, type: isArray(newVal) ? 'array' : typeof newVal }; }, 'default': function () { return []; } }, 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 }; }, 'default': function () { return {}; } }, // the `state` data type is a bit special in that setting it should // also bubble events state: { set: function (newVal) { var isInstance = newVal instanceof Base || (newVal && newVal.isState); if (isInstance) { return { val: newVal, type: 'state' }; } else { return { val: newVal, type: typeof newVal }; } }, compare: function (currentVal, newVal, attributeName) { var isSame = currentVal === newVal; // if this has changed we want to also handle // event propagation if (!isSame) { if (currentVal) { this.stopListening(currentVal); } if (newVal != null) { this.listenTo(newVal, 'all', this._getEventBubblingHandler(attributeName)); } } return isSame; } } }; // the extend method used to extend prototypes, maintain inheritance chains for instanceof // and allow for additions to the model definitions. function extend(protoProps) { var parent = this; var child; var args = [].slice.call(arguments); // The constructor function for the new subclass is either defined by you // (the "constructor" property in your `extend` definition), or defaulted // by us to simply call the parent's constructor. if (protoProps && protoProps.hasOwnProperty('constructor')) { child = protoProps.constructor; } else { child = function () { return parent.apply(this, arguments); }; } // Add static properties to the constructor function from parent assign(child, parent); // Set the prototype chain to inherit from `parent`, without calling // `parent`'s constructor function. var Surrogate = function () { this.constructor = child; }; Surrogate.prototype = parent.prototype; child.prototype = new Surrogate(); // set prototype level objects child.prototype._derived = assign({}, parent.prototype._derived); child.prototype._deps = assign({}, parent.prototype._deps); child.prototype._definition = assign({}, parent.prototype._definition); child.prototype._collections = assign({}, parent.prototype._collections); child.prototype._children = assign({}, parent.prototype._children); child.prototype._dataTypes = assign({}, parent.prototype._dataTypes || dataTypes); // Mix in all prototype properties to the subclass if supplied. if (protoProps) { var omitFromExtend = [ 'dataTypes', 'props', 'session', 'derived', 'collections', 'children' ]; args.forEach(function processArg(def) { if (def.dataTypes) { forEach(def.dataTypes, function (def, name) { child.prototype._dataTypes[name] = def; }); } if (def.props) { forEach(def.props, function (def, name) { createPropertyDefinition(child.prototype, name, def); }); } if (def.session) { forEach(def.session, function (def, name) { createPropertyDefinition(child.prototype, name, def, true); }); } if (def.derived) { forEach(def.derived, function (def, name) { createDerivedProperty(child.prototype, name, def); }); } if (def.collections) { forEach(def.collections, function (constructor, name) { child.prototype._collections[name] = constructor; }); } if (def.children) { forEach(def.children, function (constructor, name) { child.prototype._children[name] = constructor; }); } assign(child.prototype, omit(def, omitFromExtend)); }); } // Set a convenience property in case the parent's prototype is needed // later. child.__super__ = parent.prototype; return child; } Base.extend = extend; // Our main exports module.exports = Base;