transactional
Version:
Reactive objects with transactional updates and automatic serialization
530 lines (418 loc) • 17.9 kB
JavaScript
var Backbone = require( './backbone+' ),
BaseModel = Backbone.Model,
modelSet = require( './modelset' ),
attrOptions = require( './attribute' ),
error = require( './errors' ),
_ = require( 'underscore' ),
ValidationMixin = require( './validation-mixin' ),
RestMixin = require( './rest-mixin' ).Model,
UnderscoreMixin = require( './underscore-mixin' );
var setSingleAttr = modelSet.setSingleAttr,
setAttrs = modelSet.setAttrs,
applyTransform = modelSet.transform;
function deepCloneAttrs( model, a_attrs ){
var attrs = new model.Attributes( a_attrs ),
attrSpecs = model.__attributes,
options = { deep : true };
model.forEachAttr( attrs, function( value, name ){
attrs[ name ] = attrSpecs[ name ].clone( value, options );
} );
return attrs;
}
var _cidCount = 1;
var Model = BaseModel.extend( {
mixins : [ ValidationMixin, RestMixin, UnderscoreMixin.Model ],
triggerWhenChanged : 'change',
properties : {
_clonedProps : {
enumerable : false,
get : function(){
var props = {};
this.forEachProp( this, function( value, name ){
props[ name ] = value;
} );
return props;
}
},
id : {
get : function(){
var name = this.idAttribute;
// TODO: get hook doesn't work for idAttribute === 'id'
return name === 'id' ? this.attributes.id : this[ name ];
},
set : function( value ){
var name = this.idAttribute;
setSingleAttr( this, name, value, this.__attributes[ name ] );
}
},
changed : {
enumerable : false,
get : function(){
var changed = this._changed;
if( !changed ){
var last = this.attributes,
prev = this._previousAttributes;
changed = {};
this.forEachAttr( this.__attributes, function( attrSpec, name ){
if( attrSpec.isChanged( last[ name ], prev[ name ] ) ){
changed[ name ] = last[ name ];
}
} );
this._changed = changed;
}
return changed;
}
}
},
_validateNested : function( errors ){
var attrSpecs = this.__attributes,
length = 0,
model = this;
this.forEachAttr( this.attributes, function( value, name ){
var error = attrSpecs[ name ].validate( model, value, name );
if( error ){
errors[ name ] = error;
length++;
}
} );
return length;
},
getStore : function(){
var owner = this._owner || this.collection;
return owner ? owner.getStore() : this._defaultStore;
},
getOwner : function(){
return this._owner || ( this.collection && this.collection._owner );
},
sync : function(){
var store = this.getStore() || Backbone;
return store.sync.apply( this, arguments );
},
_owner : null,
__attributes : { id : attrOptions( { value : undefined } ).createAttribute( 'id' ) },
Attributes : function( x ){ this.id = x.id; },
__class : 'Model',
__duringSet : 0,
_changed : null,
_changeToken : {},
forEachAttr : function( obj, fun ){ this.id === void 0 || fun( this.id, 'id' ); },
defaults : function( attrs, options ){ return new this.Attributes( attrs ); },
__begin : modelSet.__begin,
__commit : modelSet.__commit,
transaction : modelSet.transaction,
// 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 this.__attributes[ attr ].isChanged( this.attributes[ attr ], this._previousAttributes[ 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.
// TODO: Test it
changedAttributes : function( diff ){
if( !diff ) return this.hasChanged() ? _.clone( this.changed ) : false;
var val, changed = false,
old = this._changing ? this._previousAttributes : this.attributes,
attrSpecs = this.__attributes;
for( var attr in diff ){
if( !attrSpecs[ attr ].isChanged( old[ attr ], ( val = diff[ attr ] ) ) ) continue;
(changed || (changed = {}))[ attr ] = val;
}
return changed;
},
// Get all of the attributes of the model at the time of the previous
// `"change"` event.
previousAttributes : function(){
return new this.Attributes( this._previousAttributes );
},
set : function( a, b, c ){
switch( typeof a ){
case 'string' :
var attrSpec = this.__attributes[ a ];
if( attrSpec && !attrSpec.isBackboneType && !c ){
return setSingleAttr( this, a, b, attrSpec );
}
var attrs = {};
attrs[ a ] = b;
return setAttrs( this, attrs, c );
case 'object' :
if( a && Object.getPrototypeOf( a ) === Object.prototype ){
return setAttrs( this, a, b );
}
default :
error.argumentIsNotAnObject( this, a );
}
},
// Return model's value for dot-separated 'deep reference'.
// Model id and cid are allowed for collection elements.
// If path is not exist, 'undefined' is returned.
// model.deepGet( 'a.b.c123.x' )
deepGet : function( path ){
return this._deepGet( path.split( '.' ) );
},
deepValidationError : function( name ){
var path = name.split( '.' ),
attr = path.pop(),
model = this._deepGet( path ) || null;
return model && model.getValidationError( attr );
},
_deepGet : function( path ){
var value = this;
for( var i = 0, l = path.length; value && i < l; i++ ){
value = value.get ? value.get( path[ i ] ) : value[ path[ i ] ];
}
return value;
},
// Set model's value for dot separated 'deep reference'.
// If model doesn't exist at some path, create default models
// if options.nullify is given, assign attributes with nulls
deepSet : function( name, value, options ){
var path = name.split( '.' ),
l = path.length - 1,
model = this,
attr = path[ l ];
for( var i = 0; i < l; i++ ){
var current = path[ i ],
next = model.get ? model.get( current ) : model[ current ];
// Create models in path, if they are not exist.
if( !next ){
var attrSpecs = model.__attributes;
if( attrSpecs ){
// If current object is model, create default attribute
var newModel = attrSpecs[ current ].create( null, options );
// If created object is model, nullify attributes when requested
if( options && options.nullify && newModel.__attributes ){
var nulls = new newModel.Attributes( {} );
for( var key in nulls ){
nulls[ key ] = null;
}
newModel.set( nulls );
}
model[ current ] = next = newModel;
}
else{
return;
} // silently fail in other case
}
model = next;
}
return model.set ? model.set( attr, value, options ) : model[ attr ] = value;
},
cidPrefix : 'c',
constructor : function( attributes, opts ){
var attrSpecs = this.__attributes,
attrs = attributes || {},
options = opts || {};
this.__duringSet = 0;
this._changing = this._pending = false;
this._changeToken = {};
this.attributes = {};
this.cid = this.cidPrefix + _cidCount++;
if( options.parse ){
attrs = this.parse( attrs, options ) || {};
}
// Make this.collection accessible in initialize
if( options.collection ){
this.collection = options.collection;
// do not pass it to nested objects.
// No side effect here, options copied at the upper level in this case
options.collection = null;
}
if( typeof attrs !== 'object' || Object.getPrototypeOf( attrs ) !== Object.prototype ){
error.argumentIsNotAnObject( this, attrs );
attrs = {};
}
attrs = options.deep ? deepCloneAttrs( this, attrs ) : this.defaults( attrs );
// Execute attributes transform function instead of this.set
applyTransform( this, attrs, attrSpecs, options );
this._previousAttributes = this.attributes = attrs;
this.initialize.apply( this, arguments );
},
// override get to invoke native getter...
get : function( name ){ return this[ name ]; },
// override clone to pass options to constructor
clone : function( options ){
return new this.constructor( this.attributes, options );
},
// Create deep copy for all nested objects...
deepClone : function(){ return this.clone( { deep : true } ); },
// Support for nested models and objects.
// Apply toJSON recursively to produce correct JSON.
toJSON : function(){
var self = this,
res = {},
attrSpecs = this.__attributes;
this.forEachAttr( this.attributes, function( value, key ){
var attrSpec = attrSpecs[ key ],
toJSON = attrSpec && attrSpec.toJSON;
if( toJSON ){
res[ key ] = toJSON.call( self, value, key );
}
} );
return res;
},
parse : function( resp ){ return this._parse( resp ); },
_parse : _.identity,
_ : _ // add underscore to be accessible in templates
}, {
// shorthand for inline nested model definitions
defaults : function( attrs ){ return this.extend( { defaults : attrs } ); },
// extend Model and its Collection
extend : function( protoProps, staticProps ){
var Child;
if( typeof protoProps === 'function' ){
Child = protoProps;
protoProps = null;
}
else if( protoProps && protoProps.hasOwnProperty( 'constructor' ) ){
Child = protoProps.constructor;
}
else{
var Parent = this;
Child = function Model( attrs, options ){ return Parent.call( this, attrs, options ); };
}
var This = Object.extend.call( this, Child );
This.Collection = this.Collection.extend();
return protoProps ? This.define( protoProps, staticProps ) : This;
},
// define Model and its Collection. All the magic starts here.
define : function( protoProps, staticProps ){
var Base = Object.getPrototypeOf( this.prototype ).constructor,
spec = createDefinition( protoProps, Base ),
This = this;
Object.extend.Class.define.call( This, spec, staticProps );
attachMixins( This );
// define Collection
var collectionSpec = { model : This };
spec.urlRoot && ( collectionSpec.url = spec.urlRoot );
This.Collection.define( _.defaults( protoProps.collection || {}, collectionSpec ) );
return This;
}
} );
function attachMixins( Type ){
var self = Type.prototype,
attrSpecs = self.__attributes;
for( name in attrSpecs ){
attrSpecs[ name ].attachMixins( self );
}
}
// Create model definition from protoProps spec.
function createDefinition( protoProps, Base ){
var defaults = protoProps.defaults || protoProps.attributes || {},
defaultsAsFunction = typeof defaults == 'function' && defaults,
baseAttrSpecs = Base.prototype.__attributes;
// Support for legacy backbone defaults as functions.
if( defaultsAsFunction ){
defaults = defaults();
}
var attrSpecs = Object.transform( {}, defaults, attrOptions.create );
// Create attribute for idAttribute, if it's not declared explicitly
var idAttribute = protoProps.idAttribute;
if( idAttribute && !attrSpecs[ idAttribute ] ){
attrSpecs[ idAttribute ] = attrOptions( { value : undefined } ).createAttribute( idAttribute );
}
// Prevent conflict with backbone model's 'id' property
if( attrSpecs[ 'id' ] ){
attrSpecs[ 'id' ].createPropertySpec = false;
}
var allAttrSpecs = _.defaults( {}, attrSpecs, baseAttrSpecs ),
Attributes = Object.createCloneCtor( allAttrSpecs );
return _.extend( _.omit( protoProps, 'collection', 'attributes' ), {
__attributes : new Attributes( allAttrSpecs ),
forEachAttr : Object.createForEach( allAttrSpecs ),
_parse : createParse( allAttrSpecs, attrSpecs ) || Base.prototype._parse,
defaults : defaultsAsFunction || createDefaults( allAttrSpecs ),
properties : createAttrsNativeProps( protoProps.properties, attrSpecs ),
Attributes : Attributes
} );
}
// Create attributes 'parse' option function only if local 'parse' options present.
// Otherwise return null.
function createParse( allAttrSpecs, attrSpecs ){
var statements = [ 'var a = this.__attributes;' ],
create = false;
for( var name in allAttrSpecs ){
// Is there any 'parse' option in local model definition?
if( attrSpecs[ name ] && attrSpecs[ name ].parse ) create = true;
// Add statement for each attribute with 'parse' option.
if( allAttrSpecs[ name ].parse ){
var s = 'if("' + name + '" in r) r.' + name + '=a.' + name + '.parse.call(this,r.' + name + ',"' + name + '");';
statements.push( s );
}
}
statements.push( 'return r;' );
return create ? new Function( 'r', statements.join( '' ) ) : null;
}
// Check if value is valid JSON.
function isValidJSON( value ){
if( value === null ){
return true;
}
switch( typeof value ){
case 'number' :
case 'string' :
case 'boolean' :
return true;
case 'object':
var proto = Object.getPrototypeOf( value );
if( proto === Object.prototype || proto === Array.prototype ){
return _.every( value, isValidJSON );
}
}
return false;
}
// Create optimized model.defaults( attrs, options ) function
function createDefaults( attrSpecs ){
var assign_f = [], create_f = [];
function appendExpr( name, expr ){
assign_f.push( 'this.' + name + '=a.' + name + '===undefined?' + expr + ':a.' + name + ';' );
create_f.push( 'this.' + name + '=' + expr + ';' );
}
// Compile optimized constructor function for efficient deep copy of JSON literals in defaults.
_.each( attrSpecs, function( attrSpec, name ){
if( attrSpec.value === undefined && attrSpec.type ){
// if type with no value is given, create an empty object
appendExpr( name, 'i.' + name + '.create()' );
}
else{
// If value is given, type casting logic will do the job later, converting value to the proper type.
if( isValidJSON( attrSpec.value ) ){
// JSON literals must be deep copied.
appendExpr( name, JSON.stringify( attrSpec.value ) );
}
else if( attrSpec.value === undefined ){
// handle undefined value separately. Usual case for model ids.
appendExpr( name, 'undefined' );
}
else{
// otherwise, copy value by reference.
appendExpr( name, 'i.' + name + '.value' );
}
}
} );
var CreateDefaults = new Function( 'i', create_f.join( '' ) ),
AssignDefaults = new Function( 'a', 'i', assign_f.join( '' ) );
CreateDefaults.prototype = AssignDefaults.prototype = Object.prototype;
// Create model.defaults( attrs, options ) function
// 'attrs' will override default values, options will be passed to nested backbone types
return function( attrs ){
return attrs ? new AssignDefaults( attrs || {}, this.__attributes ) : new CreateDefaults( this.__attributes );
}
}
// Create native properties for model's attributes
function createAttrsNativeProps( properties, attrSpecs ){
if( properties === false ){
return {};
}
properties || ( properties = {} );
return Object.transform( properties, attrSpecs, function( attrSpec, name ){
if( !properties[ name ] && attrSpec.createPropertySpec ){
return attrSpec.createPropertySpec();
}
} );
}
module.exports = Model;