@Tomdarkness, first of all, I share your pain in terms of getting ember-data to work in a handful of what appears to be basic use cases.
While I love the library on a overall basis, it’s far from finished - and has been the cause of a myriad of problems for me, too. I’ve seriously considered ditching it at least two or three times now, but the work being put into this from the entire community is worth a million - and I’m sure things will get better soon.
TL;DR: You need to handle associations yourself in order to make things work properly in all cases. There’s just no other way at this point.
Below, I’ll share my own experiences in the hope that you may find my ramblings somewhat useful. It is my intention to first describe what works (creating/finding records), then explain at least one use case that doesn’t work as expected (loading records), and finally attempt to come up with a solution (using Em.ArrayController
).
Here goes:
Pain point #1: ember-data not updating associated records automatically in all cases
At this point, you cannot rely on ember-data updating all associated records automatically whenever you’re dealing with records, at least not in all cases. Consider this scenario:
Models:
App.Basket = DS.Model.extend({
name: DS.attr('string'),
fruits: DS.hasMany('App.Fruit')
});
App.Fruit = DS.Model.extend({
name: DS.attr('string'),
basket: DS.belongsTo('App.Basket')
});
Then, you make the following calls in your code:
Calls:
var basket = App.Basket.createRecord({ name: 'My fruit basket', id: '60' });
var banana = App.Fruit.createRecord({ name: 'Banana', basket: basket, id: '61' });
Results:
Playing around in the console in Chrome, getting the fruits
from the basket
results in this:
basket.get('fruits') -> Class {type: function, store: Class, _changesToSync: Ember.OrderedSet, owner: Class, name: "fruits"…}
Or, accessing the fruits.content
directly:
basket.get('fruits.content') -> [ Object ]
So far, this seems to work the way it should. However, say you retrieve some data from a pub/sub setup. If you do, you might want to App.store.load
those data into the store. This is one of the scenarios where the automagic gets medieval - or straight up just doesn’t work.
Loading data into the store:
Loading data into the store is as simple as calling App.store.load(App.Fruit, { basket_id: '60', id: '80', name: 'Pear' });
. Assuming everything works as it should, we should be able to create (or find) a basket
, create (or find) a banana
, load a pear
, and have both fruits show up when we get them from the basket
.
Attempt this in the Chrome console:
Calls (creating):
var basket = App.Basket.createRecord({ name: 'My fruit basket', id: '60' });
var banana = App.Fruit.createRecord({ name: 'Banana', basket: basket, id: '61' });
Results:
basket.get('fruits.content'); // -> returns [ Object ].
Calls (loading):
App.store.load(App.Fruit, { basket_id: '60', id: '80', name: 'Pear' });
Results:
basket.get('fruits.content'); // -> still only returns [ Object ]!
This suggests that the automagic surrounding the handling of associations does not entirely work at this point. Put differently, even though you have your JSON behave exactly as required out-of-the-box, you’ll run into trouble. Therefore, you need to do something else if you wish to continue to use ember-data.
(So far, this is exactly what you’ve come up with yourself. I’m just writing this in an explanatory style for other people to potentially benefit from our common experiences.)
Pain point #2: handling the associations manually:
Months ago, I came to the same conclusion as you: to handle the associations on my own. Yet this results in some other problems - or one, at least, which is quite significant.
Whenever a record is loaded, didLoad
is triggered. And hooray! We can get the associations of a record by iterating through this.eachRelationship(...)
. That way, we can fetch all parents via checking for kind === 'belongsTo'
iterating through the assocations.
However, the problem is that adding a child record to a parent’s hasMany
relation will result in the parent being dirtied, just as you’ve discovered. Although this makes sense from a strict client-side perspective, it’s not the truth since the parent is not dirty in reality; it might just be that another user has added an additional instance of App.Fruit
that belongs to our basket
. This instance gets pushed to our client and we want to update the association accordingly, accidentally dirtying the parent as a result. Ouch!
So far, my own experiments have all stranded here and my ninja coding skills have taken over. What I have done in my own project is to introduce a common model that every other model extends.
Attempting to make things work using an Em.ArrayController:
My App.Model
has didInit
implemented. When the event is triggered, I run through each association in order to create an Em.ArrayController
for each hasMany
relationship. Here’s a snippet from my model.js
file:
didInit: function() {
var self = this;
this.eachRelationship(function(name, meta) {
if (meta.kind === 'hasMany') {
self.set('loaded' + Em.String.classify(name), Em.ArrayController.extend().create());
}
});
},
In the case of our App.Basket
model, creating a new instance would result in basket.loadedFruits
being available. And inside this ArrayController
is where I store the associated records.
A trimmed-down version of model.js
source is included here:
App.Model = DS.Model.extend({
// Primary key:
primaryKey: 'id',
// Toggles:
hasBeenDeleted: false,
hasBeenLoaded: false,
// Events:
didCreate: function() {
this.didLoad();
},
didDelete: function() {
if (!this.get('hasBeenDeleted')) {
this.trigger('deleteContexts');
this.toggleProperty('hasBeenDeleted');
}
},
didInit: function() {
var self = this;
this.eachRelationship(function(name, meta) {
if (meta.kind === 'hasMany') {
self.set('loaded' + Em.String.classify(name), Em.ArrayController.extend().create());
}
});
},
didLoad: function() {
if (!this.get('hasBeenLoaded')) {
// Trigger init and load contexts:
this.trigger('didInit');
this.loadContexts();
// Toggle the hasBeenLoaded property:
this.toggleProperty('hasBeenLoaded');
}
},
// Relations:
addRelation: function(record, relation) {
this.handleRelation(record, relation, 'addObject');
},
deleteRelation: function(record, relation) {
this.handleRelation(record, relation, 'removeObject');
},
handleRelation: function(record, relation, type) {
relation = 'loaded' + Em.String.classify(relation) + '.content';
if (typeof record !== "undefined" && record !== null) {
var self = this;
if (record.get('isLoaded')) {
record.get(relation)[type](self);
} else {
record.one('didLoad', function() { record.get(relation)[type](self); });
}
}
},
// Functions:
deleteContexts: function() {
var self = this;
this.eachRelationship(function(name, meta) {
if (meta.kind === 'belongsTo') {
self.deleteRelation(self.get(name), Em.String.decamelize(meta.parentType.toString().split('.')[1]));
}
});
},
loadContexts: function() {
var self = this;
this.eachRelationship(function(name, meta) {
if (meta.kind === 'belongsTo') {
self.addRelation(self.get(name), Em.String.decamelize(meta.parentType.toString().split('.')[1]));
}
});
}
});
Notice that in the didLoad
implementation, loadContexts()
is called, making sure to add the record to each of the parents that the record belongs to.
Intermezzo: this is a terrible workaround and I really don’t fancy doing it this way, but I’ve run into the same limitations as you, forcing me to follow down this path.
Worth noting #1: attempting to manually change the state of records by send('becameClean')
etc. will break the internal bookkeeping of changes to a record - it cannot be done that way.
Worth noting #2: having records stored in separate controllers allows for you to keep track of things such as offset, limit, and whether or not additional records are available on the server. This is great for pagination/infinite scrolling.
Closing comments:
I have great faith in what will happen to ember-data in the coming months in terms of stabilizing the library and weeding out these bugs so we don’t need to create layers upon layers on top of ember-data that rely on the triggering of events to make associations work as expected in all cases. It seems like a fragile approach at best - but hey, it works. <insert ninja coding GIF here>
Please do share any additional findings. I promise to do so myself. And hang in there! I’m sure things will improve pretty soon.