-
-
Save cyk/6fa5af98928426e1d829 to your computer and use it in GitHub Desktop.
/** | |
Ember Data: Dependent Relationships (Ember Data v1.13.x) | |
This package extends Ember Data to support creating relationships | |
where a model's dirty state depends not only on its own attributes | |
but on the dirty state of models in dependent relationships as well. | |
```javascript | |
App.Thing = DS.Model.extend({ | |
name : DS.attr('string'), | |
children : DS.hasMany('thing', { dependent: true }) | |
}); | |
// Load all the things | |
var thing = store.findById('thing', '1'); | |
var child = thing.get('children.firstObject'); | |
thing.get('hasDirtyAttributes'); // false | |
child.get('name'); // 'foo' | |
child.set('name', 'bar'); | |
thing.get('hasDirtyAttributes'); // true | |
thing.rollback(); | |
child.get('name'); // 'foo' | |
``` | |
Note that saving dependent relations automatically, and handling | |
'isValid' state based on dependent relations is not supported. | |
*/ | |
/* global Ember, DS */ | |
(function() { | |
var get = Ember.get; | |
var set = Ember.set; | |
// Returns the internal model for the given record | |
function internalModelFor(record) { | |
var internalModel = record._internalModel; | |
// Ensure the internal model has a dependent relationship hash, since we can't override the | |
// constructor function anymore | |
if (!internalModel._dependentRelationships) { | |
internalModel._dependentRelationships = {}; | |
} | |
return internalModel; | |
} | |
function resolveDependentRelationship(dependentRelations, name) { | |
var canonicalState = dependentRelations[name].canonicalState; | |
// Replace relation with its canonical state | |
if (canonicalState) { | |
dependentRelations[name] = Ember.isArray(canonicalState) ? canonicalState.mapBy('record') : canonicalState.record; | |
} | |
return dependentRelations[name]; | |
} | |
// Replace a method on an object with a new one that calls the original and then | |
// invokes a function with the result | |
function decorateMethod(obj, name, fn) { | |
var originalFn = obj[name]; | |
obj[name] = function() { | |
var value = originalFn.apply(this, arguments); | |
return fn.call(this, value, arguments); | |
}; | |
} | |
// | |
// State machine handlers | |
// | |
// Object/array agnostic 'hasDirtyAttributes' check | |
function isRelatedRecordDirty(value) { | |
return Ember.isArray(value) ? Ember.A(value).isAny('hasDirtyAttributes') : get(value, 'hasDirtyAttributes'); | |
} | |
// Original-state aware dirty check | |
function isRelationshipDirty(internalModel, key) { | |
var rel = get(internalModel.record, key); | |
var value = Ember.isArray(rel) ? rel.toArray() : rel; | |
var originalValue = internalModel._dependentRelationships[key]; | |
return Ember.compare(value, originalValue) !== 0; | |
} | |
// The new de facto check to determine if a record is dirty | |
function isRecordDirty(internalModel) { | |
// First check normal attributes | |
if (Object.keys(internalModel._attributes).length) { | |
return true; | |
} | |
if (internalModel._dependentRelationships) { | |
// Then check dependent relations | |
return Ember.A(Object.keys(internalModel._dependentRelationships)).any(function(key) { | |
return isRelationshipDirty(internalModel, key) || isRelatedRecordDirty(get(internalModel.record, key)); | |
}); | |
} | |
} | |
// A dependent relationship can change if: | |
// * a belongsTo gets changed to another record | |
// * a belongsTo record dirties/cleans | |
// * a hasMany array gets added to or removed from | |
// * a hasMany array has a record that dirties/cleans | |
var dependentRelationshipDidChange = function(internalModel, context) { | |
var compareRelation = function(value, originalValue) { | |
if (Ember.compare(value, originalValue) !== 0 || isRelatedRecordDirty(context.value)) { | |
internalModel.send('becomeDirty'); | |
} else { | |
internalModel.send('propertyWasReset'); | |
} | |
}; | |
if(Ember.isArray(context.value) && Ember.isArray(context.originalValue)) { | |
compareRelation(context.value.sortBy('id'), context.originalValue.sortBy('id')); | |
} else { | |
compareRelation(context.value, context.originalValue); | |
} | |
}; | |
// The check for whether the record is still dirty now has to account for dependent relations | |
var propertyWasReset = function(internalModel) { | |
if (!isRecordDirty(internalModel)) { | |
internalModel.send('rolledBack'); | |
} | |
}; | |
// Check to see if the saved record is dirty | |
var savedSetup = function(internalModel) { | |
if (isRecordDirty(internalModel)) { | |
internalModel.adapterDidDirty(); | |
} | |
}; | |
// | |
// Perform some state machine surgery | |
// TODO: figure out how to make this less ass | |
// | |
// Handle dependent relationship change | |
DS.RootState.loaded.dependentRelationshipDidChange = dependentRelationshipDidChange; | |
// Changes to dependent relations while in-flight, invalid, or deleted should not alter its state | |
DS.RootState.loaded.created.inFlight.dependentRelationshipDidChange = Ember.K; | |
DS.RootState.loaded.updated.inFlight.dependentRelationshipDidChange = Ember.K; | |
DS.RootState.loaded.created.invalid.dependentRelationshipDidChange = Ember.K; | |
DS.RootState.loaded.updated.invalid.dependentRelationshipDidChange = Ember.K; | |
DS.RootState.deleted.dependentRelationshipDidChange = Ember.K; | |
// Override the property reset handler to account for dependent relations | |
DS.RootState.loaded.created.uncommitted.propertyWasReset = propertyWasReset; | |
DS.RootState.loaded.updated.uncommitted.propertyWasReset = propertyWasReset; | |
// Handle the case when a record that is in the 'root.deleted.uncommitted' state | |
// is rolled back but has dirty dependent relations | |
DS.RootState.loaded.saved.setup = savedSetup; | |
// | |
// Modify DS.Model | |
// | |
// Add dependent property helpers | |
DS.Model.reopenClass({ | |
// Loop over each dependent relation, passing the property name and the relationship meta | |
eachDependentRelationship: function(callback, binding) { | |
get(this, 'relationshipsByName').forEach(function(relationship, name) { | |
if (relationship.options.dependent) { | |
callback.call(binding, name, relationship); | |
} | |
}); | |
} | |
}); | |
DS.Model.reopen(Ember.Comparable, { | |
// Loop over each dependent property | |
eachDependentRelationship: function(callback, binding) { | |
this.constructor.eachDependentRelationship(callback, binding || this); | |
}, | |
// Hook into the object creation lifecycle in order to add dirty observers | |
didDefineProperty: function(proto, key, value) { | |
this._super(proto, key, value); | |
if (value && typeof value === 'object' && value.isDescriptor) { | |
var meta = value.meta(); | |
if (meta.isRelationship && meta.options.dependent) { | |
if (meta.kind === 'belongsTo') { | |
Ember.addObserver(proto, key + '.hasDirtyAttributes', null, 'dependentRelationshipDidChange'); | |
} else if (meta.kind === 'hasMany') { | |
Ember.addObserver(proto, key + '[email protected]', null, 'dependentRelationshipDidChange'); | |
} | |
} | |
} | |
}, | |
// Returns object describing of changed relationships, like `changedAttributes` | |
changedRelationships: function() { | |
var record = this; | |
var internalModel = internalModelFor(record); | |
var dependentRelations = internalModel._dependentRelationships; | |
var relationship; | |
var changed = {}; | |
record.eachDependentRelationship(function(name, relationshipMeta) { | |
relationship = get(record, name); | |
if (relationship && isRelationshipDirty(internalModel, name)) { | |
changed[name] = [ | |
Ember.copy(dependentRelations[name]), | |
relationshipMeta.kind === 'belongsTo' ? relationship : relationship.toArray() | |
]; | |
} | |
}); | |
return changed; | |
}, | |
// Observer for relationship change, should send state machine message 'dependentRelationshipDidChange' | |
dependentRelationshipDidChange: Ember.observer(function(record, key) { | |
var dependentRelations = internalModelFor(record)._dependentRelationships; | |
var name = key.split('.')[0]; | |
if (name in dependentRelations) { | |
var value = get(record, name); | |
var dependentRelation = resolveDependentRelationship(dependentRelations, name); | |
// Make DS.ManyArray into a vanilla array for comparison with original | |
value = Ember.isArray(value) ? value.toArray() : value; | |
record.send('dependentRelationshipDidChange', { | |
name : name, | |
value : value, | |
originalValue : dependentRelation | |
}); | |
} | |
}), | |
// When the record is loaded/saved, save its relations so they can be reverted | |
snapshotDependentRelations: function() { | |
var record = this; | |
var internalModel = internalModelFor(record); | |
var relationships = internalModel._relationships; | |
var dependentRelations = internalModel._dependentRelationships; | |
var relationship; | |
record.eachDependentRelationship(function(name, relationshipMeta) { | |
if (relationship = relationships.get(name)) { | |
dependentRelations[name] = relationship; | |
} | |
}); | |
// Pre-compute as dependent relations rely on the 'hasDirtyAttributes' CP, which may not get called | |
get(record, 'hasDirtyAttributes'); | |
}.on('didLoad'), | |
// Basic identity comparison to allow `Ember.compare` to work on models | |
compare: function(r1, r2) { | |
return r1 === r2 ? 0 : 1; | |
} | |
}); | |
// | |
// Modify DS.InternalModel.prototype | |
// | |
var InternalModelPrototype = DS.InternalModel.prototype; | |
/** | |
Update the dependent relations when the adapter loads new data | |
@method adapterDidCommit | |
*/ | |
decorateMethod(InternalModelPrototype, 'adapterDidCommit', function adapterDidCommit() { | |
var record = this.record; | |
record.snapshotDependentRelations(); | |
// Relationship updates don't trigger data changes anymore, so manually | |
// notify all relationship properties of possible change | |
record.eachDependentRelationship(function(name, relationship) { | |
if (relationship.kind === 'hasMany') { | |
record.dependentRelationshipDidChange(this, name); | |
} | |
}); | |
}); | |
/** | |
Rollback relations as well as attributes | |
@method rollbackAttributes | |
*/ | |
decorateMethod(InternalModelPrototype, 'rollbackAttributes', function rollbackDependentRelationships() { | |
var internalModel = this; | |
var dependentRelations = internalModel._dependentRelationships; | |
var record = internalModel.record; | |
record.eachDependentRelationship(function(name, relationshipMeta) { | |
if (name in dependentRelations) { | |
var originalRelationship = dependentRelations[name]; | |
if (relationshipMeta.kind === 'belongsTo') { | |
set(record, name, originalRelationship); | |
} else { | |
get(record, name).setObjects(originalRelationship); | |
} | |
// Rollback child/field records that have changed as well | |
Ember.makeArray(originalRelationship).filterBy('hasDirtyAttributes').invoke('rollbackAttributes'); | |
} | |
}); | |
}); | |
}()); |
Just implemented this using EmberData 2.1.0 and when I set a belongsTo to dependent:true they dirty upon load.
How do I debug this?
@cyk I'm in the process of updating to ED v1.13.x and I'm using your version now, so thanks 😄
I don't use it with async relationships, so I started with the previous revision like you suggest above. I ran into an issue with the snapshotting being async, and tracked it down to a change that makes relationship normalization not happen until after the didLoad
hook fires. I solved it by using the store's internal backburner instance and running the snapshot method in the finished
queue:
// Relationship normalizing doesn't happen until after the `didLoad` hook fires
deferRelationshipSnapshot: function() {
this.store._backburner.schedule('finished', this, 'snapshotDependentRelations');
}.on('didLoad'),
The call to RSVP.all
can be removed at that point, since the relationships have been updated (incidentally I think the only affect that using promises had is to defer execution – it behaves this same as Ember.run.next
in this case)
Thanks again for the updates!
Issue resolved.
Took care of my issue,
isDescriptor
does not yet exist in my version of Ember.