Skip to content

Instantly share code, notes, and snippets.

@slindberg
Last active November 5, 2021 21:41
Show Gist options
  • Star 26 You must be signed in to star a gist
  • Fork 11 You must be signed in to fork a gist
  • Save slindberg/8660986 to your computer and use it in GitHub Desktop.
Save slindberg/8660986 to your computer and use it in GitHub Desktop.
Dependent Relationships in Ember Data
/**
Ember Data: Dependent Relationships
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('isDirty'); // false
child.get('name'); // 'foo'
child.set('name', 'bar');
thing.get('isDirty'); // 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;
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;
}
//
// State machine handlers
//
// Object/array agnostic 'isDirty' check
function isRelatedRecordDirty(value) {
return Ember.isArray(value) ? Ember.A(value).isAny('isDirty') : get(value, 'isDirty');
}
// Original-state aware dirty check
function isRelationshipDirty(record, key) {
var value = get(record, key).toArray();
var originalValue = record._dependentRelationships[key];
return Ember.compare(value, originalValue) !== 0;
}
// The new de facto check to determine if a record is dirty
function isRecordDirty(record) {
// First check normal attributes
if (Ember.keys(record._attributes).length) {
return true;
}
// Then check dependent relations
return Ember.A(Ember.keys(record._dependentRelationships)).any(function(key) {
return isRelationshipDirty(record, key) || isRelatedRecordDirty(get(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(record, context) {
if (Ember.compare(context.value, context.originalValue) !== 0 || isRelatedRecordDirty(context.value)) {
record.send('becomeDirty');
} else {
record.send('propertyWasReset', context.name);
}
};
// The check for whether the record is still dirty now has to account for dependent relations
var propertyWasReset = function(record) {
if (!isRecordDirty(record)) {
record.send('rolledBack');
}
};
// Check to see if the saved record is dirty
var savedSetup = function(record) {
if (isRecordDirty(record)) {
record.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, {
// Initialize dependent relationship snapshot object
_setup: function() {
this._super();
this._dependentRelationships = {};
},
// 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 (isDescriptor(value)) {
var meta = value.meta();
if (meta.isRelationship && meta.options.dependent) {
if (meta.kind === 'belongsTo') {
Ember.addObserver(proto, key + '.isDirty', null, 'dependentRelationshipDidChange');
} else if (meta.kind === 'hasMany') {
Ember.addObserver(proto, key + '.@each.isDirty', null, 'dependentRelationshipDidChange');
}
}
}
},
// Returns object describing of changed relationships, like `changedAttributes`
changedRelationships: function() {
var record = this;
var dependentRelations = record._dependentRelationships;
var relationship;
var changed = {};
record.eachDependentRelationship(function(name, relationshipMeta) {
if (record._relationships[name] && isRelationshipDirty(record, name)) {
relationship = get(record, 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.immediateObserver(function(record, key) {
var dependentRelations = record._dependentRelationships;
var name = key.split('.')[0];
if (name in dependentRelations) {
var value = get(record, name);
// Make DS.ManyArray into a vanilla array for comparison with original
if (Ember.isArray(value)) {
value = value.toArray();
}
record.send('dependentRelationshipDidChange', {
name : name,
value : value,
originalValue : dependentRelations[name],
});
}
}),
// Update the dependent relations when the adapter loads new data
adapterDidCommit: function() {
this.snapshotDependentRelations();
this._super.apply(this, arguments);
// Relationship updates don't trigger data changes anymore, so manually
// notify all relationship properties of possible change
this.eachDependentRelationship(function(name, relationship) {
if (relationship.kind === 'hasMany') {
this.dependentRelationshipDidChange(this, name);
}
});
},
// When the record is loaded/saved, save its relations so they can be reverted
snapshotDependentRelations: function() {
var record = this;
var dependentRelations = record._dependentRelationships;
var relationship;
record.eachDependentRelationship(function(name, relationshipMeta) {
if (record._relationships[name]) {
relationship = get(record, name);
dependentRelations[name] = relationshipMeta.kind === 'belongsTo' ? relationship : relationship.toArray();
}
});
}.on('didLoad'),
// Dependent relations rely on the 'isDirty' CP, which may not get called
precomputeIsDirty: function() {
get(this, 'isDirty');
}.on('init'),
// Rollback relations as well as attributes
rollback: function() {
// Revert attributes like normal
this._super();
var record = this;
var dependentRelations = this._dependentRelationships;
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('isDirty').invoke('rollback');
}
});
},
// Basic identity comparison to allow `Ember.compare` to work on models
compare: function(r1, r2) {
return r1 === r2 ? 0 : 1;
},
});
}());
@Gowiem
Copy link

Gowiem commented Feb 11, 2015

This has been great, so I just wanted to say thanks. It saved me a ton of time not having to roll my own solution.

Also, for anyone using embedded relationships I created this Mixin which has worked out great alongside this.

@joshfarrant
Copy link

Thanks for this, seems to work well! Added it as a new initializer to app/initializers, is this where you'd recommend implementing it?

Only issue I came across is that I'm getting an 'undefined is not a function' error from the first line of the isRelationshipDirty function when trying to rollback a model with a belongsTo relationship. If I just remove toArray from that line it seems to work fine. Any thoughts on this?

Ember: 1.11.0
Ember Data : 1.0.0-beta.15

@kellyselden
Copy link

Ember.Descriptor has been removed in Ember 1.12. Polyfill code:

\\if (isDescriptor(value)) {

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;
}

@kellyselden
Copy link

@slindberg
Copy link
Author

Thanks @kellyselden, updated.

@kellyselden
Copy link

This isn't working in ember-data 1.0.0-beta.19.1. It breaks on https://gist.github.com/slindberg/8660986#file-ember-data-dependent-relations-js-L71 where record._dependentRelationships is undefined. record was previously a DS.Model instance, but now it is a DS.InternalModel, which is a pojo that cannot be reopened.

@pilaf
Copy link

pilaf commented Jun 12, 2015

Applied a fix for the toArray() error on belongsTo associations here: https://gist.github.com/pilaf/795409f42468546728a0

@Leooo
Copy link

Leooo commented Jul 24, 2015

@kellyselden is right. Anyone has a fix?

@seanrucker
Copy link

@Leooo @kellyselden I need a fix for this as well. Did you guys make any progress?

@cyk
Copy link

cyk commented Sep 9, 2015

Here is an attempt at internal model compatibility for dependent relations that is based on lytics/ember-data.model-fragments's compatibility work.

@dio-el-claire
Copy link

Ember 1.13.7
Data 1.13.14

got error here
if (value && typeof value === 'object' && value.isDescriptor) {
var meta = value.meta(); <-- meta is not a function

I'm not a familiar with ember data, so my solution is quite simple
if (value && typeof value === 'object' && value.isDescriptor && typeof value.meta === 'function')

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment