How to return a promise composed of nested models

2019-02-05 02:19发布

问题:

Enviroment

# Ember       : 1.4.0
# Ember Data  : 1.0.0-beta.7+canary.b45e23ba

Model

I have simplified my use case to make the question easier to understand and anwser. Let's assume we have 3 models: Country, Region and Area:

Country:
  - id: DS.attr('number')
  - name: DS.attr('string')
  - regions: DS.hasMany('region')

Region:
  - id: DS.attr('number')
  - name: DS.attr('string')
  - country: DS.belongsTo('country')
  - areas: DS.hasMany('area')

Area:
  - id: DS.attr('number')
  - name: DS.attr('string')
  - region: DS.belongsTo('region')

Expected results

The Route's model hook should return an array of objects. Like this:

Note: The indentations are only to make the example more readable.

 Country I
   Region A
      Area 1
      Area 2
   Region B
      Area 3
 Country II
   Region C
      Area 4
 Country III
   Region D
      Area 5

Current approach

App.MyRoute = Ember.Route.extend({
  model: function() {
    return this.store.find('country').then(function(countries){
      // promise all counties

      // map resolved countires into an array of promises for owned regions
      var regions = countries.map(function(country){
        return country.get('regions');
      });

      // map resolved regions into an array of promises for owned areas
      var areas = regions.then(function(regions){
        return regions.map(function(region){
          return region.get('areas');
        });
      });

      // do not return until ALL promises are resolved
      return Ember.RSVP.all(countries, regions, areas).then(function(data){
        // here somehow transform the data into expected output format and return it
      });
    });
  }
)};

Error

I'm getting Error while loading route: TypeError: Object [object Array] has no method 'then' which obviously comes from this code:

var regions = countries.map(function(country){
  return country.get('regions');
});
var areas = regions.then(function(regions){
// regions is not a promise

However this should show the real problem I have:

The problem

I need countries resolved to get the regions, which in turn I need to get the areas. I've been checking the RSVP.hash and RSVP.all functions, reading the official API and watching this talk, however I somewhat fail to create the correct code to chain promises and in the final then modify the returned result to match my expectations.

Final thoughts

I have been told that loading data like this may cause many HTTP requests and probably this would be solved better by sideloading, but:

  • at this moment, I use FixturesAdapter, so HTTP requests are not an issue
  • I really want to understand RSVP and Promises better

Thats why it is important to me to figure out how this should be done correctly.

Edit 1: applying changes suggested by kingpin2k

I've created a JSBin for my example with changes suggested by kingpin2k's anwser.

While the code works, the results are... unexpected:

  • in the countries array I found both country and region objects. Why?
  • the country and region objects seem to be loaded, but area's don't (see console log results in the JSBin).. Why?

Edit 2: Explanation of unexpected behaviour from Edit1.

So I've finally noticed where I went astray from the righteous path of Ember. Kingpin2k's anwser was a huge step forward, but it contains a little error:

return this.store.find('country').then(function(countries){
  // this does not return an array of regions, but an array of region SETs
  var regionPromises = countries.getEach('regions');

  // wait for regions to resolve to get the areas
  return Ember.RSVP.all(regionPromises).then(function(regions){
    // thats why here the variable shouldn't be called "regions"
    // but "regionSets" to clearly indicate what it holds

    // for this example i'll just reassign it to new var name
    var regionSets = regions;

    // now compare these two lines (old code commented out)
    //var areaPromises = regions.getEach('areas'); 
    var areaPromises = regionSets.getEach('areas');

    // since regionSet does not have a property "areas" it
    // won't return a promise or ever resolve (it will be undefined)

    // the correct approach would be reduceing the array of sets
    // an array of regions
    var regionsArray = regionSets.reduce(function(sum, val){
      // since val is a "Ember.Set" object, we must use it's "toArray()" method
      // to get an array of contents and then push it to the resulting array
      return sum.pushObjects(val.toArray());
    }, []);

    // NOW we can get "areas"
    var realAreaPromises = regionsArray.getEach('areas');

    // and now we can use Ember.RSVP to wait for them to resolve
    return Ember.RSVP.all(realAreaPromises).then(function(areaSets){
    // note: here again, we don't get an array of areas
    // we get an array of area sets - each set for the corresponding region
      var results = [];

So.. now I've finally got all the objects correctly resolved (countries, regions, areas) and can continue my work :)

EDIT 3: Working solution in this JSBin!

回答1:

The trick is you need to resolve certain promises before you can access the properties on those records. Ember.RSVP.all takes an Array of promises. Ember.RSVP.hash takes a hash of promises. Unfortunately you're in the situation where you can't construct your promises until the previous promises have resolved (a la, you don't know which regions to get until the countries are resolved, and you don't know which areas to get until the regions are resolved). That being the case you really have a serial set of promises to fetch (albeit arrays of promises at each level). Ember knows to wait until the deepest promise has resolved and to use that value as the model.

Now we need to pretend that regions and area are async, if they aren't, you're telling Ember Data the information will be included in the request with country, or in the request with region and those collections won't be promises so the code I've included below wouldn't work.

regions: DS.hasMany('region', {async: true})

areas: DS.hasMany('area', {async: true})

App.IndexRoute = Ember.Route.extend({
  controllerName: 'application',

  model: function() {
    return this.store.find('country').then(function(countries){
      // get each country promises
      var regionCollectionPromises = countries.getEach('regions');

      // wait for regions to resolve to get the areas
      return Ember.RSVP.all(regionCollectionPromises).then(function(regionCollections){

        var regions = regionCollections.reduce(function(sum, val){
            return sum.pushObjects(val.toArray());
        }, []);

        var areaCollectionPromises = regions.getEach('areas');
        //wait on the areas to resolve
        return Ember.RSVP.all(areaCollectionPromises).then(function(areaCollections){

          // yay, we have countries, regions, and areas resolved

          return countries;
        });
      });
    });

  }
});

All this being said, since it appears you're using Ember Data, I'd just return this.store.find('country') and let Ember Data fetch the data when it's used... This template would work without all of that promise code, and would populate as Ember Data fulfill's the promises on its own (it will request the data once it sees you've attempted to use the data, good ol' lazy loading).

{{#each country in model}}
  Country: {{country.name}}
  {{#each region in country.regions}}
    Region: {{region.name}}
      {{#each area in region.areas}}
        Area: {{area.name}}
     {{/each}}
  {{/each}}
{{/each}}


回答2:

What you could do instead:

If you're here, you're probably doing the same mistake as I did :)

If you need a complicated tree of objects to display your route, you could also:

  • If you use RESTAdapter you could sideload data in one HTTP request.
  • If you use FixturesAdapter (eg. in development phase) with a fixed set of data, you could switch to LocalStorageAdapter - as when you request a model, it loads all the associated models. So it will be as easy as a simple this.store.find('mymodel', model_id)

However I'm leaving the original anwser marked as "accepted" as it actually anwsers the original question, and this anwser is just a note for future reference/other users.