-
-
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'); | |
} | |
}); | |
}); | |
}()); |
@cyk I am currently working on an Ember project using Ember Data 1.13.11 and Ember 1.10.x
I attempted to include it and can see that some functionality such as 'hasDirtyAttributes' are available to me, however for belongsTo AND hasMany relationships (regardless of Async) 'hasDirtyAttributes' returns false on the parent. Only the child shows that it is true.
In addition I noticed that 'changedRelationships()' does show the child being changed, yet still the parent is not dirty. Trying to rollback on the parent has no effect on the child.
I notice that in the example at the beginning of this source that both the parent and child are of type 'thing' is that the intention or merely a coincidence? My relationships are of different types, an example would be:
App.Organization = DS.Model.extend({
name: DS.attr('string'),
orgstores: DS.hasMany('orgstore', {dependent:true})
})
var organization = store.find('organization', 1);
var stores = organization.get('orgstores');
//organization - hasDirtyAttributes : false
//stores - hasDirtyAttributes : false
stores.get('name');
//organization - hasDirtyAttributes : false
//stores - hasDirtyAttributes : true
stores.set('name', 'store123')
//Does not change stores
organization.rollback()
@cyk @ptgamr I have narrowed down the issue to the dependentRelationshipDidChange
listener not being fired. If I call the function this points to directly, the parent will be updated as expected. Any thoughts as to why the observer would not be firing?
My initial thoughts are regarding the version of Ember used for testing this functionality.
I am on Ember: 1.10.x
What version is this directed at?
Issue resolved.
function isDescriptor(value) {
// Ember < 1.11
if (Ember.Descriptor !== undefined) {
return value instanceof Ember.Descriptor;
}
// Ember >= 1.11
return value && typeof value === 'object' && value.isDescriptor;
}
Took care of my issue, isDescriptor
does not yet exist in my version of Ember.
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!
Thanks for the fix and the detailed feedback, @ptgamr. In addition to that belongsTo fix, I think I was able to get async
working againalmost working by lazily resolving dependent relationships (via system relationship's canonicalState) on first change. I've updated the gist with these changes.Update: This gist isn't fully working yet — I'm working thru issues with dependent hasMany canonicalState being stale after a record is deleted and committed (parent hasDirtyAttributes remains dirty).
In the meantime, the previous revision should still work if async is not necessary.