Skip to content

Instantly share code, notes, and snippets.

@slindberg
Last active August 29, 2015 13:56
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save slindberg/9057018 to your computer and use it in GitHub Desktop.
Save slindberg/9057018 to your computer and use it in GitHub Desktop.
Model 'Fragments' in Ember Data
/**
Ember Data: Model Fragments
This package provides support for sub-models that can be treated much like
`belongsTo` and `hasMany` relationships are, but whose persistence is managed
completely through the parent object.
```javascript
App.Thing = DS.Model.extend({
name : DS.attr('string'),
fields : DS.hasManyFragments('thing_field')
});
App.ThingField = DS.ModelFragment.extend({
name : DS.attr('string'),
value : DS.attr('string')
});
```
With a JSON payload of:
```json
{
"id": "thing1",
"name": "Thingy",
"fields": [
{
"name": "foo",
"value": "Mr. Anderson"
},
{
"name": "bar",
"value": "Agent No. 1"
}
]
}
```
The `fields` attribute can be treated similar to a `hasMany` relationship:
```javascript
var thing = store.findById('thing', '1');
var field = thing.get('fields.lastObject');
thing.get('isDirty'); // false
field.get('value'); // 'Agent No. 1'
field.set('value', 'Agent No. 2');
thing.get('isDirty'); // true
thing.rollback();
field.get('name'); // 'Agent No. 1'
```
*/
(function() {
var get = Ember.get;
var map = Ember.EnumerableUtils.map;
var splice = Array.prototype.splice;
//
// Add fragment support to `DS.Model`
// TODO: handle the case where there's no response to a commit, and
// in-flight attributes just get merged
//
DS.Model.reopen({
_setup: function() {
this._super();
this._fragments = {};
},
// Update all fragment data before the owner's observes fire to ensure that
// fragment observers aren't working with stale data (this works because the
// owner's `_data` hash has already changed by this time)
updateFragmentData: Ember.beforeObserver('data', function(record) {
var fragment;
for (var key in record._fragments) {
fragment = record._fragments[key];
// The data may have updated, but not changed at all, in which case
// treat the update as a rollback
if (fragment && fragment !== record._data[key]) {
fragment.setupData(record._data[key]);
record._data[key] = fragment;
}
}
}),
rollback: function() {
this._super();
// Rollback fragments after data changes -- otherwise observers get tangled up
this.rollbackFragments();
},
rollbackFragments: function() {
var fragment;
for (var key in this._fragments) {
fragment = this._fragments[key] = this._data[key];
fragment.rollback();
}
},
// A fragment property became dirty
fragmentDidDirty: function(key, fragment) {
if (!get(this, 'isDeleted')) {
// Add the fragment as a placeholder in the owner record's
// `_attributes` hash to indicate it is dirty
this._attributes[key] = fragment;
this.send('becomeDirty');
}
},
// A fragment property became clean
fragmentDidReset: function(key, fragment) {
// Make sure there's no entry in the owner record's
// `_attributes` hash to indicate the fragment is dirty
delete this._attributes[key];
// Don't reset if the record is new, otherwise it will enter the 'deleted' state
// NOTE: This case almost never happens with attributes because their initial value
// is always undefined, which is *usually* not what attributes get 'reset' to
if (!get(this, 'isNew')) {
this.send('propertyWasReset', key);
}
}
});
//
// Fragment State Machine
//
var didSetProperty = DS.RootState.loaded.saved.didSetProperty;
var propertyWasReset = DS.RootState.loaded.updated.uncommitted.propertyWasReset;
var dirtySetup = function(fragment) {
var record = get(fragment, '_owner');
var key = get(fragment, '_name');
// A newly created fragment may not have an owner yet
if (record) {
record.fragmentDidDirty(key, fragment);
}
};
var RootState = {
isEmpty: false,
isLoading: false,
isLoaded: false,
isDirty: false,
isSaving: false,
isDeleted: false,
isNew: false,
isValid: true,
didSetProperty: didSetProperty,
propertyWasReset: Ember.K,
becomeDirty: Ember.K,
rolledBack: Ember.K,
empty: {
isEmpty: true,
loadedData: function(fragment) {
fragment.transitionTo('loaded.created');
},
pushedData: function(fragment) {
fragment.transitionTo('loaded.saved');
}
},
loaded: {
pushedData: function(fragment) {
fragment.transitionTo('saved');
},
saved: {
setup: function(fragment) {
var record = get(fragment, '_owner');
var key = get(fragment, '_name');
// Abort if fragment is still initializing
if (!record._fragments[key]) { return; }
// Reset the property on the owner record if no other siblings
// are dirty (or there are no siblings)
if (!get(record, key + '.isDirty')) {
record.fragmentDidReset(key, fragment);
}
},
pushedData: Ember.K,
becomeDirty: function(fragment) {
fragment.transitionTo('updated');
}
},
created: {
isDirty: true,
setup: dirtySetup,
},
updated: {
isDirty: true,
setup: dirtySetup,
propertyWasReset: propertyWasReset,
rolledBack: function(fragment) {
fragment.transitionTo('saved');
}
}
}
};
function mixin(original, hash) {
for (var prop in hash) {
original[prop] = hash[prop];
}
return original;
}
// Wouldn't it be awesome if this was public?
function wireState(object, parent, name) {
object = mixin(parent ? Ember.create(parent) : {}, object);
object.parentState = parent;
object.stateName = name;
for (var prop in object) {
if (!object.hasOwnProperty(prop) || prop === 'parentState' || prop === 'stateName') {
continue;
}
if (typeof object[prop] === 'object') {
object[prop] = wireState(object[prop], object, name + "." + prop);
}
}
return object;
}
DS.FragmentRootState = wireState(RootState, null, 'root');
//
// Model Fragment
//
DS.ModelFragment = Ember.Object.extend(Ember.Comparable, Ember.Copyable, {
_name: null,
_owner: null,
currentState: DS.FragmentRootState.empty,
// Initialize/merge data
setupData: function(data) {
var store = get(this, 'store');
var key = get(this, 'name');
var type = store.modelFor(this.constructor);
var serializer = store.serializerFor(type);
// Setting data means the record is now clean
this._attributes = {};
// TODO: do normalization in the transform, not on the fly
this._data = serializer.normalize(type, data, key);
this.send('pushedData');
this.notifyPropertyChange('data');
},
// Rollback the fragment
rollback: function() {
this._attributes = {};
this.rollbackFragments();
this.send('rolledBack');
this.notifyPropertyChange('data');
},
// Basic identity comparison to allow `FragmentArray` to diff arrays
compare: function(f1, f2) {
return f1 === f2 ? 0 : 1;
},
// Copying a fragment has special semantics: a new fragment is created
// in the `loaded.created` state, without the same owner set, so that it
// can be added to another record safely
// TODO: handle copying sub-fragments
copy: function() {
var store = get(this, 'store');
var type = store.modelFor(this.constructor);
var data = {};
Ember.merge(data, this._data);
Ember.merge(data, this._attributes);
return this.store.createFragment(type, data);
},
toStringExtension: function() {
return 'owner(' + get(this, '_owner.id') + ')';
},
init: function() {
this._super();
this._setup();
}
});
//
// Borrow functionality from DS.Model
// TODO: is it easier to extend from DS.Model and disable functionality than to
// cherry-pick common functionality?
//
// Ember object prototypes are lazy-loaded
DS.Model.proto();
var protoPropNames = [
'_setup',
'_unhandledEvent',
'send',
'transitionTo',
'data',
'isEmpty',
'isLoading',
'isLoaded',
'isDirty',
'isSaving',
'isDeleted',
'isNew',
'isValid',
'serialize',
'eachAttribute',
'fragmentDidDirty',
'fragmentDidReset',
'rollbackFragments'
];
var protoProps = protoPropNames.reduce(function(props, name) {
props[name] = DS.Model.prototype[name] || Ember.meta(DS.Model.prototype).descs[name];
return props;
}, {});
DS.ModelFragment.reopen(protoProps, {
eachRelationship: Ember.K,
updateRecordArraysLater: Ember.K
});
var classPropNames = [
'attributes',
'eachAttribute',
'transformedAttributes',
'eachTransformedAttribute'
];
var classProps = classPropNames.reduce(function(props, name) {
props[name] = DS.Model[name] || Ember.meta(DS.Model).descs[name];
return props;
}, {});
DS.ModelFragment.reopenClass(classProps, {
eachRelationship: Ember.K
});
//
// Fragment Creation
//
DS.Store.reopen({
// Create a fragment with injections applied that starts
// in the 'empty' state
buildFragment: function(type) {
type = this.modelFor(type);
return type.create({
store: this
});
},
// Create a fragment that starts in the 'created' state
createFragment: function(type, props) {
var fragment = this.buildFragment(type);
if (props) {
fragment.setProperties(props);
}
fragment.send('loadedData');
return fragment;
}
});
//
// Primitive Arrays
//
DS.PrimitiveArray = Ember.ArrayProxy.extend({
owner: null,
name: null,
init: function() {
this._super();
this.originalState = [];
},
content: function() {
return Ember.A();
}.property(),
// Set new data array
setupData: function(data) {
var content = get(this, 'content');
data = this.originalState = Ember.makeArray(data);
// Use non-KVO mutator to prevent parent record from dirtying
splice.apply(content, [ 0, content.length ].concat(data));
},
isDirty: function() {
return Ember.compare(this.toArray(), this.originalState) !== 0;
}.property('[]'),
rollback: function() {
this.setObjects(this.originalState);
},
serialize: function() {
return this.toArray();
},
// Any change to the size of the fragment array means a potential state change
arrayContentDidChange: function() {
this._super.apply(this, arguments);
var record = get(this, 'owner');
var key = get(this, 'name');
if (this.get('isDirty')) {
record.fragmentDidDirty(key, this);
} else {
record.fragmentDidReset(key, this);
}
},
toStringExtension: function() {
return 'owner(' + get(this, 'owner.id') + ')';
}
});
//
// Fragment Arrays
//
DS.FragmentArray = DS.PrimitiveArray.extend({
type: null,
// Initialize/merge fragments with data array
setupData: function(data) {
var record = get(this, 'owner');
var store = get(record, 'store');
var type = get(this, 'type');
var key = get(this, 'name');
var content = get(this, 'content');
// Map data to existing fragments and create new ones where necessary
data = map(Ember.makeArray(data), function(data, i) {
var fragment = content[i];
if (!fragment) {
fragment = store.buildFragment(type);
fragment.setProperties({
_owner : record,
_name : key
});
}
fragment.setupData(data);
return fragment;
});
this._super(data);
},
isDirty: function() {
return this._super() || this.isAny('isDirty');
}.property('@each.isDirty'),
rollback: function() {
this._super();
this.invoke('rollback');
},
serialize: function() {
return this.invoke('serialize');
},
// All array manipulation methods end up using this method, which
// is a good place to ensure fragments have the correct props set
replaceContent: function(idx, amt, fragments) {
var record = get(this, 'owner');
var store = get(record, 'store');
var type = get(this, 'type');
var key = get(this, 'name');
var originalState = this.originalState;
// Ensure all fragments have their owner/name set
if (fragments) {
fragments.forEach(function(fragment) {
var owner = get(fragment, '_owner');
Ember.assert("You can only add '" + type + "' fragments to this property", fragment instanceof store.modelFor(type));
Ember.assert("Fragments can only belong to one owner, try copying instead", !owner || owner === record);
if (!owner) {
fragment.setProperties({
_owner : record,
_name : key
});
}
});
}
return get(this, 'content').replace(idx, amt, fragments);
},
addFragment: function(fragment) {
return get(this, 'content').addObject(fragment);
},
removeFragment: function(fragment) {
return get(this, 'content').removeObject(fragment);
},
createFragment: function(props) {
var record = get(this, 'owner');
var store = get(record, 'store');
var type = get(this, 'type');
var fragment = store.createFragment(type, props);
return this.pushObject(fragment);
}
});
//
// Attribute helpers
//
// The default value of a fragment is either an array or an object,
// which should automatically get deep copied
function getDefaultValue(record, options, type) {
var value;
if (typeof options.defaultValue === "function") {
value = options.defaultValue();
} else if (options.defaultValue) {
value = options.defaultValue;
} else {
return null;
}
Ember.assert("The fragment's default value must be an " + type, Ember.typeOf(value) == type);
return Ember.copy(value, true);
}
// Like `DS.hasMany`, declares that the property contains an array of
// either primitives, or model fragments of the given type
DS.hasManyFragments = function(type, options) {
// If a type is not given, it implies an array of primitives
if (Ember.typeOf(type) !== 'string') {
options = type;
type = null;
}
options = options || {};
var meta = {
type: 'fragment',
isAttribute: true,
isFragment: true,
options: options,
kind: 'hasMany'
};
return Ember.computed(function(key, value) {
var record = this;
var data = this._data[key] || getDefaultValue(this, options, 'array');
var fragments = this._fragments[key] || null;
function createArray() {
var arrayClass = type ? DS.FragmentArray : DS.PrimitiveArray;
return arrayClass.create({
type : type,
name : key,
owner : record
});
}
// Create a fragment array and initialize with data
if (data && data !== fragments) {
fragments || (fragments = createArray());
fragments.setupData(data);
this._data[key] = fragments;
}
if (arguments.length > 1) {
if (Ember.isArray(value)) {
fragments || (fragments = createArray());
fragments.setObjects(value);
} else if (value === null) {
fragments = null;
} else {
Ember.assert("A fragment array property can only be assigned an array or null");
}
if (this._data[key] !== fragments || get(fragments, 'isDirty')) {
this.fragmentDidDirty(key, fragments);
} else {
this.fragmentDidReset(key, fragments);
}
}
return this._fragments[key] = fragments;
}).property('data').meta(meta);
};
// Like `DS.belongsTo`, declares that the property contains a single
// model fragment of the given type
DS.hasOneFragment = function(type, options) {
options = options || {};
var meta = {
type: 'fragment',
isAttribute: true,
isFragment: true,
options: options
};
return Ember.computed(function(key, value) {
var data = this._data[key] || getDefaultValue(this, options, 'array');
var fragment = this._fragments[key];
if (data && data !== fragment) {
if (!fragment) {
fragment = this.store.buildFragment(type);
// Set the correct owner/name on the fragment
fragment.setProperties({
_owner : this,
_name : key
});
}
fragment.setupData(data);
this._data[key] = fragment;
}
if (arguments.length > 1) {
Ember.assert("You can only assign a '" + type + "' fragment to this property", value instanceof store.modelFor(type));
fragment = value;
if (this._data[key] !== fragment) {
this.fragmentDidDirty(key, fragment);
} else {
this.fragmentDidReset(key, fragment);
}
}
return this._fragments[key] = fragment;
}).property('data').meta(meta);
};
// Like `DS.belongsTo`, when used within a model fragment is a reference
// to the owner record
DS.fragmentOwner = function() {
// TODO: add a warning when this is used on a non-fragment
return Ember.computed.alias('_owner').readOnly();
};
//
// Fragment Transform
//
// Delegate to the specific serializer for the fragment
DS.FragmentTransform = DS.Transform.extend({
deserialize: function(data) {
// TODO: figure out how to get a handle to the fragment here
// without having to patch `DS.JSONSerializer#applyTransforms`
return data;
},
serialize: function(fragment) {
return fragment ? fragment.serialize() : null;
}
});
Ember.onLoad('Ember.Application', function(Application) {
Application.initializer({
name: "fragmentTransform",
before: "store",
initialize: function(container, application) {
application.register('transform:fragment', DS.FragmentTransform);
}
});
});
}());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment