10

UPDATE

Note that this question applies to Ember Data pre-1.0 beta, the mechanism for loading relationships via URL has changed significantly post-1.0 beta!


I asked a much longer question a while back, but since the library has changed since then, I'll ask a much simpler version:

How do you use DS.Adapter.findHasMany? I am building an adapter and I want to be able to load the contents of a relationship on get of the relationship property, and this looks like the way to do it. However, looking at the Ember Data code, I don't see how this function can ever be called (I can explain in comments if needed).

There's not an easy way with my backend to include an array of ids in the property key in the JSON I send--the serializer I'm using doesn't allow me to hook in anywhere good to change that, and it would also be computationally expensive.

Once upon a time, the Ember Data front page showed an example of doing this "lazy loading"...Is this possible, or is this "Handle partially-loaded records" as listed on the Roadmap, and can't yet be done.?

I'm on API revision 11, master branch as of Jan 15.

Update

Okay, the following mostly works. First, I made the following findHasMany method in my adapter, based on the test case's implementation:

findHasMany: function(store, record, relationship, details) {
    var type = relationship.type;
    var root = this.rootForType(type);
    var url = (typeof(details) == 'string' || details instanceof String) ? details : this.buildURL(root);

    this.ajax(url, "GET", {
        success: function(json) {
            var serializer = this.get('serializer');
            var pluralRoot = serializer.pluralize(root);
            var hashes = json[pluralRoot]; //FIXME: Should call some serializer method to get this?
            store.loadMany(type, hashes);

            // add ids to record...
            var ids = [];
            var len = hashes.length;
            for(var i = 0; i < len; i++){
                ids.push(serializer.extractId(type, hashes[i]));
            }
            store.loadHasMany(record, relationship.key, ids);
        }
    });
}

Prerequisite for above is you have to have a well-working extractId method in your serializer, but the built-in one from RESTAdapter will probably do in most cases.

This works, but has one significant problem that I haven't yet really gotten around in any attempt at this lazy-loading approach: if the original record is reloaded from the server, everything goes to pot. The simplest use case that shows this is if you load a single record, then retrieve the hasMany, then later load all the parent records. For example:

var p = App.Post.find(1);
var comments = p.get('comments');
// ...later...
App.Post.find();

In the case of only the code above, what happens is that when Ember Data re-materializes the record it recognizes that there was already a value on the record (posts/1), tries to re-populate it, and follows a different code path which treats the URL string in the JSON hash as an array of single-character IDs. Specifically, it passes the value from the JSON to Ember.EnumerableUtils.map, which understandably enumerates the string's characters as array members.

Therefore, I tried to work around this by "patching" DS.Model.hasManyDidChange, where this occurs, like so:

// Need this function for transplanted hasManyDidChange function...
var map = Ember.EnumerableUtils.map;

DS.Model.reopen({

});

(^ Never mind, this was a really bad idea.)

Update 2

I found I had to do (at least) one more thing to solve the problem mentioned above, when a parent model is re-loaded from the server. The code path where the URL was getting split into single-characters was in DS.Model.reloadHasManys. So, I overrode this method with the following code:

DS.Model.reopen({

  reloadHasManys: function() {
    var relationships = get(this.constructor, 'relationshipsByName');
    this.updateRecordArraysLater();
    relationships.forEach(function(name, relationship) {
      if (relationship.kind === 'hasMany') {

        // BEGIN FIX FOR OPAQUE HASMANY DATA
        var cachedValue = this.cacheFor(relationship.key);
        var idsOrReferencesOrOpaque = this._data.hasMany[relationship.key] || [];
        if(cachedValue && !Ember.isArray(idsOrReferencesOrOpaque)){
          var adapter = this.store.adapterForType(relationship.type);
          var reloadBehavior = relationship.options.reloadBehavior;
          relationship.name = relationship.name || relationship.key; // workaround bug in DS.Model.clearHasMany()?
          if (adapter && adapter.findHasMany) {
            switch (reloadBehavior) {
              case 'ignore':
                //FIXME: Should probably replace this._data with references/ids, currently has a string!
                break;
              case 'force':
              case 'reset':
              default:
                this.clearHasMany(relationship);
                cachedValue.set('isLoaded', false);
                if (reloadBehavior == 'force' || Ember.meta(this).watching[relationship.key]) {
                  // reload the data now...
                  adapter.findHasMany(this.store, this, relationship, idsOrReferencesOrOpaque);
                } else {
                  // force getter code to rerun next time the property is accessed...
                  delete Ember.meta(this).cache[relationship.key];
                }
                break;
            }
          } else if (idsOrReferencesOrOpaque !== undefined) {
            Ember.assert("You tried to load many records but you have no adapter (for " + type + ")", adapter);
            Ember.assert("You tried to load many records but your adapter does not implement `findHasMany`", adapter.findHasMany);
          }  
        } else {
          this.hasManyDidChange(relationship.key);
        }
        //- this.hasManyDidChange(relationship.key);
        // END FIX FOR OPAQUE HASMANY DATA

      }
    }, this);
  }

});

With that addition, using URL-based hasManys is almost usable, with two main remaining problems:

First, inverse belongsTo relationships don't work correctly--you'll have to remove them all. This appears to be a problem with the way RecordArrays are done using ArrayProxies, but it's complicated. When the parent record gets reloaded, both relationships get processed for "removal", so while a loop is iterating over the array, the belongsTo disassociation code removes items from the array at the same time and then the loop freaks out because it tries to access an index that is no longer there. I haven't figured this one out yet, and it's tough.

Second, it's often inefficient--I end up reloading the hasMany from the server too often...but at least maybe I can work around this by sending a few cache headers on the server side.

Anyone trying to use the solutions in this question, I suggest you add the code above to your app, it may get you somewhere finally. But this really needs to get fixed in Ember Data for it to work right, I think.

I'm hoping this gets better supported eventually. On the one hand, the JSONAPI direction they're going explicitly says that this kind of thing is part of the spec. But on the other hand, Ember Data 0.13 (or rev 12?) changed the default serialized format so that if you want to do this, your URL has to be in a JSON property called *_ids... e.g. child_object_ids ... when it's not even IDs you're sending in this case! This seems to suggest that not using an array of IDs is not high on their list of use-cases. Any Ember Data devs reading this: PLEASE SUPPORT THIS FEATURE!

Welcome further thoughts on this!

S'pht'Kr
  • 2,616
  • 1
  • 21
  • 40
  • 1
    See my [answer](http://stackoverflow.com/questions/13699796/ember-data-loading-hasmany-association-on-demand/14532845#14532845) to a related question. – mike Jan 29 '13 at 18:40
  • the cached reload in my update, seems to work. I am also not using the belongsTo relationship. – sfossen Jun 28 '13 at 05:57

4 Answers4

3

Instead of an array of ids, the payload needs to contain "something else" than an array.

In the case of the RESTAdapter, the returned JSON is like that:

{blog: {id: 1, comments: [1, 2, 3]}

If you want to handle manually/differently the association, you can return a JSON like that instead:

{blog: {id: 1, comments: "/posts/1/comments"}

It's up to your adapter then to fetch the data from the specified URL.

See the associated test: https://github.com/emberjs/data/blob/master/packages/ember-data/tests/integration/has_many_test.js#L112

Cyril Fluck
  • 1,551
  • 7
  • 9
  • Fascinating--props for pointing me to the test, I hadn't been shown that before. What's most interesting to me is the test's implementation of `adapter.findHasMany`, which I've been trying to do on my own but never succeeding...I will try their implementation and see if the nasty side-effects I'd been seeing in mine disappear. – S'pht'Kr Apr 23 '13 at 09:31
  • Okay, very close--it actually works the first time I retrieve a record! But, say I do the following: `var p = App.Post.find(1); var comments = p.get('comments'); App.Post.find();`... What I'm getting is, the first time, it works, and `comments` has a populated `DS.ManyArray` (after it loads of course). But when I retrieve all `App.Post`, when it gets post 1 again, it tries to load `/comments/%2F`, `/comments/p`, `/comments/o`, `/comments/s` and so on--that is, it splits the URL string up as if it were an array of IDs, and tries to fetch those entities. Is there something I'm missing here? – S'pht'Kr Apr 24 '13 at 08:04
  • Confirmed above with Ember 1.0 RC3 and latest Ember Data master, FWIW. – S'pht'Kr Apr 24 '13 at 08:59
  • 1
    Giving credit because this answered the core original question: the way you're supposed to get to the `findHasMany` method is by returning a string/URL (or other non-array truthy value) from the server API. Still--STILL--trying to really make this work, I'm not sure if Ember Data is there yet for this use case. – S'pht'Kr May 03 '13 at 06:38
1

I was glad to find this post, helped me. Here is my version, based off the current ember-data and your code.

findHasMany: function(store, record, relationship, details) {
    var adapter = this;
    var serializer = this.get('serializer');
    var type = relationship.type;
    var root = this.rootForType(type);
    var url = (typeof(details) == 'string' || details instanceof String) ? details : this.buildURL(root);
    return this.ajax(url, "GET", {}).then(function(json) {
            adapter.didFindMany(store, type, json);
            var list = $.map(json[relationship.key], function(o){ return serializer.extractId(type, o);});
            store.loadHasMany(record, relationship.key, list);
        }).then(null, $.rejectionHandler);
},

for the reload issue, I did this, based on code I found in another spot, inside the serializer I overrode:

materializeHasMany: function(name, record, hash, relationship) {
    var type = record.constructor,
            key = this._keyForHasMany(type, relationship.key),
            cache = record.cacheFor('data');
    if(cache) {
        var hasMany = cache.hasMany[relationship.key];
        if (typeof(hasMany) == 'object' || hasMany instanceof Object) {
            record.materializeHasMany(name, hasMany);
            return;
        }
    }
    var value = this.extractHasMany(type, hash, key);
    record.materializeHasMany(name, value);
}

I'm still working on figuring out paging, since some of the collections I'm working with need it.

sfossen
  • 4,678
  • 22
  • 18
0

I got a small step closer to getting it working with revision 13 and based myself on sfossen's findHasMany implementation.

For an Ember model 'Author' with a hasMany relationship 'blogPosts', my rest api looks like '/api/authors/:author_id/blog_posts'. When querying the rest api for an author with id 11 the blog_posts field reads '/authors/11/blog_posts'.

I now see the related blog posts being returned by the server, but Ember still throws an obscure error that it can not read 'id' from an undefined model object when rendering the page. So I'm not quite there yet, but at least the related data is correctly requested from the rest service.

My complete adapter:

App.Adapter = DS.RESTAdapter.extend({
    url: 'http://localhost:3000',
    namespace: 'api',
    serializer: DS.RESTSerializer.extend({
        keyForHasMany: function(type, name) {
            return Ember.String.underscore(name);
        },
        extractHasMany: function(record, json, relationship) {
            var relationShip = relationship + '_path';
            return { url : json[relationShip] }
        }
    }),
    findHasMany: function(store, record, relationship, details) {
        var type = relationship.type;
        var root = this.rootForType(type);
        var url = this.url + '/' + this.namespace + details.url;
        var serializer = this.get('serializer');

        return this.ajax(url, "GET", {}).then(
            function(json) {
                var relationship_key = Ember.String.underscore(relationship.key);
                store.loadMany(type, json[relationship_key]);
                var list = $.map(json[relationship_key], function(o){
                        return serializer.extractId(type, o);}
                );
                store.loadHasMany(record, relationship.key, list);
            }).then(null, $.rejectionHandler);
    }
});
jonne
  • 146
  • 1
  • 4
  • Thanks! Does your BlogPost model have a reciprocal belongsTo relationship? Try removing that and see if the error goes away--it sounds like the same thing I've been fighting, and so far I've only been able to work around it by removing all my inverse belongsTo relationships. – S'pht'Kr Jun 23 '13 at 20:35
  • The error seemed to be not directly related with this solution, but some problem with my controller (I'm still learning ember). What is more bothering me is that I now also get that problem you mentioned on Apr 24, i.e. the url for children is later broken up into pieces as if it were id's. I get the impression that this solution may just not work with Ember data. It wants to be able to reload individual records in a ManyArray for which it uses the findMany function. The value for blog_posts should be either empty or a list of id's. – jonne Jun 24 '13 at 18:04
  • Okay, see the new code I added in my "Update 2", it might help you. – S'pht'Kr Jun 25 '13 at 10:52
  • I updated my Adapter with my latest version, added your code from Update 2 and now I'm stuck at the exact same point as you :-) – jonne Jun 25 '13 at 21:04
  • try the materalizeHasMany, I added. – sfossen Jun 28 '13 at 16:03
0

Here is my solution but it is on Ember-data 0.14, so the world has moved on, even if we are still on this code base:

  findHasMany: function(store, record, relationship, details) {
    if(relationship.key !== 'activities') {
      return;
    }

    var type = relationship.type,
        root = this.rootForType(type),
        url = this.url + details.url,
        self = this;

    this.ajax(url, "GET", {
      data: {page: 1}
    }).then(function(json) {
      var data = record.get('data'),
        ids = [],
        references = json[relationship.key];

      ids = references.map(function(ref){
        return ref.id;
      });

      data[relationship.key] = ids;

      record.set('data', data);

      self.didFindMany(store, type, json);
      record.suspendRelationshipObservers(function() {
        record.hasManyDidChange(relationship.key);
      });
    }).then(null, DS.rejectionHandler);
  },

I found replacing the data with the ids worked for me.

dagda1
  • 21,477
  • 48
  • 188
  • 367