At this point, students have already learned how to:
- Create Ember Components to represent UI elements and encapsulate related data and behavior.
- Use
ember-data
to set up Models representing business data. - Link the
ember-data
data store to a JSON API through an Adapter. - Set up a mock back-end using
ember-cli-mirage
By the end of this lesson, students should be able to:
- Define a one-to-one relationship between different Models.
- Define a one-to-many relationship between different Models.
- Define a many-to-many relationship between different Models.
- Define a self-referential relationship on a single Model.
- Set up mocks for interrelated models (of each type above) using Mirage Fixtures.
- Fork and clone this repo.
- Run
npm install && bower install
- Run
ember install ember-legacy-views
- Run
ember install ember-cli-mirage
, but do not overwrite theconfig.js
file. Additionally, delete thescenarios
directory.
By the end of the last lesson, ember-crud
, we set out to have an Ember application that could perform CRUD on two separate and unrelated resources, 'pokemon' and 'items'. This is useful for a demonstration, but most of the time (as you probably know by now) your application will need to employ some form of relationship. In this lesson, we'll explore how three different kinds of relationships - one-to-one, one-to-many, and many-to-many - can be implemented in an Ember application, and how test fixtures can be built for that kind of relationship. We will not be focusing on CRUD; that's the topic of the next lesson.
Pokemon games have been produced since 1996, and there are currently six generations of games. Each generation of games takes place in a different region of the 'world', and consequently introduces a new set of Pokemon into the large (and growing) Pokemon universe.
A 'generation' has :
- a name (with date range)
- a description (taken from Wikipedia) - one long string
- a list of games in that generation, represented as an array of strings
Let's go ahead and create a Model and Adapter for the 'generation' resource; afterwards, we'll make a Mirage fixture for them to hook into.
ember g model generation
ember g model adapter
Inside the 'generation' Model, let's define the following schema:
export default DS.Model.extend({
name: DS.attr('string'),
description: DS.attr('string'),
games: DS.attr()
});
games
is going to be an array, which is not one of the standard types that DS.attr can create, so we're just going to leave it unconfigured.
We'll also need to make some small modifications to the adapter - basically, it should be the same as the adapter for 'item'.
import ApplicationAdapter from '../application/adapter';
export default ApplicationAdapter.extend({
namespace: 'api'
});
Next, we would typically need to make a Mirage test fixture with some data inside Mirage; fortunately, one has already been created for you at fixtures/generations.js
.
Let's add routes to our new resource inside config.js
.
this.get('/api/generations');
this.get('/api/generations/:id');
this.post('/api/generations');
this.put('/api/generations/:id');
this.del('/api/generations/:id');
Just for testing purposes, let's create a new Route for the 'index' view state (ember g route index
), and add both 'pokemon' and 'generations' to the model
method on that Route.
export default Ember.Route.extend({
model: function(){
return {
generations: this.store.findAll('generation'),
pokemon: this.store.findAll('pokemon')
}
}
});
If we then open up the Ember Inspector to the 'Data' tab, all of our data should be visible!
Now that the groundwork has been laid, let's come back to the topic of associations. In this app, we will model the relationship between resources 'generation' and 'pokemon' as a one-to-many relationship, where one 'generation' is associated with many different 'pokemon'.
We can define this relationship in the 'generation' Model as follows:
export default DS.Model.extend({
name: DS.attr('string'),
description: DS.attr('string'),
games: DS.attr(),
pokemon: DS.hasMany('pokemon', {async: true})
});
{async: true}
is a configuration setting onDS.hasMany
that controls a kind of behavior called 'eager/lazy loading'. Don't worry about the details on this right now.
In the 'pokemon' Model, we need to change the 'generation' property from being a number to being a relationship.
export default DS.Model.extend({
nationalPokeNum: DS.attr('number'),
name: DS.attr('string'),
typeOne: DS.attr('string'),
typeTwo: DS.attr('string'),
generation: DS.belongsTo('generation', {async: true})
});
If we open up the Ember Inspector again, in the Data tab, we should be able to see all of the different data types that have been loaded. If we click one of the Pokemon (say, Bulbasaur), navigate to where it says generation
, send that value to $E
, and then write $E.get('content').get('name')
, we should get back the string "First Generation (1996–1998)".
If we go the opposite way, by clicking the first Generation, sending its pokemon
property to $E
, and then writing $E.get('content').forEach(function(p){console.log(p.get('name'));})
, we should see a response like this:
Bulbasaur
Ivysaur
Venusaur
Charmander
Charmeleon
Charizard
Squirtle
Wartortle
Blastoise
As mentioned above, each generation (with one exception, which we'll ignore) takes place in one region of the world. If we have another resource called 'region', it would have a one-to-one relationship with 'generation'.
A 'region' has :
- a name
- an array of names of badges that can be collected in the region
Thus, we need a model like this:
export default DS.Model.extend({
name: DS.attr('string'),
badges: DS.attr()
});
Our adapter should be the same as previous; Mirage routes should follow the same pattern; as with the previous example, the tedious data entry work has been taken care of, and a fixture file has been prepared for you (fixtures/regions.js
). With all that in place, if we add Regions to our 'index' Route's model
function, we should be able to see it in the Data tab.
So far so good. Now let's add relationships.
To make a one-to-one relationship in Ember, both models must have a belongsTo
property, each pointing at the other.
region
export default DS.Model.extend({
name: DS.attr('string'),
badges: DS.attr(),
generation: DS.belongsTo('generation', {async: true})
});
generation
export default DS.Model.extend({
name: DS.attr('string'),
description: DS.attr('string'),
games: DS.attr(),
pokemon: DS.hasMany('pokemon', {async: true}),
region: DS.belongsTo('region', {async: true})
});
We also need to add in ID values to the fixture data for both regions and generations.
{
id: 1,
name: 'Kanto',
badges: ['boulder', 'cascade', 'thunder', 'rainbow', 'soul', 'marsh', 'volcano', 'earth'],
generation: 1
},
//...
{
id: 1,
name: 'First Generation (1996–1998)',
description: 'The original Pokémon games are Japanese role-playing video games (RPGs) with an element of strategy, and were created by Satoshi Tajiri for the Game Boy. These role-playing games, and their sequels, remakes, and English language translations, are still considered the "main" Pokémon games, and the games with which most fans of the series are familiar.',
games: ['red', 'green', 'blue', 'yellow'],
region: 1
},
To test these relationships, all we need to do is follow the same approach as the previous time vis-a-vis the Ember Inspector.
One of the core ideas of the Pokemon games is that Pokemon come in different 'types' (sometimes more than one); these types not only describe the nature of a particular Pokemon, they also define the types of attacks that a Pokemon is weak (or strong) against.
For now, let's focus on the interplay between 'pokemon' and 'types'. There are many different Pokemon with a given type, and a given Pokemon can have either one or two types; as such, this is a many-to-many relationship.
A 'type' has :
- a name
Just one property for now, but we'll be adding a bunch more in the next example.
The model looks like:
export default DS.Model.extend({
name: DS.attr('string')
});
Test data for this resource is available at fixtures/types.js
This is a many-to-many relationship, but just barely. To add the relationships, we need to (a) edit the models to reflect the relationship, and (b) update the fixtures with the appropriate data.
In order for the many-to-many to be handled correctly, we'll need to replace typeOne
and typeTwo
in the Pokemon model with a hasMany
property, types
. This will mean needing to change some UI code, but we can get to that at another time.
export default DS.Model.extend({
nationalPokeNum: DS.attr('number'),
name: DS.attr('string'),
// typeOne: DS.attr('string'),
// typeTwo: DS.attr('string'),
types: DS.hasMany('type', {async: true}),
generation: DS.belongsTo('generation', {async: true})
});
As mentioned earlier, the key thing that a Pokemon's type does is define the types of attacks that some particular Pokemon is weak (or strong) against. In essence, a type is related to other types, in one of four ways:
- "Super-effective" attack (2X normal damage)
- Normal attack
- "Not very effective" attack (0.5X normal damage)
- "Had no effect" (0 damage)
Because the 'item' resource will need to refer any number of other 'items', this relationship could be described as "self-referential".
As you can see in the diagram, the interrelationship between the various different types of Pokemon is complex. Don't worry - it doesn't matter if we get the data right!