Understanding many-to-many relationships
Look under the hood at Mirage’s database and learn how it stores and syncs the foreign keys of an association
Transcript
In this video we’re going to build a feature that relies on a many-to-many relationship. We’ll learn about how to create dummy data for this association, and also a bit about how Mirage stores this data under the hood. All this will give you a better idea about what your actual server needs produce in order to support these types of associations.
We’re working on this CMS application and we need to add tags to our blog posts. I have some mockups here of what we want to build - first, on this blog posts route, we want to show the tags for each blog post over here. And then on the left we also have this Tags route, which lists out all the tags in our CMS. And it also shows us how many posts are associated with each tag.
So this is what we’ll build, and it uses a many to many relationship - a post hasMany tags and a tag hasMany posts. These can be hard to get right, so let me show you how I would prototype this feature using Mirage.
—
Alright let’s come to the app - I’ve already defined the routes so let’s come to the tags route, and we see just a header here, and if we look at the template and route they’re basically empty. So our first step is to load the list of tags here.
Let’s start by generating the tag
model
ember g model tag
Tags have a name
and a slug
export default DS.Model.extend({
name: DS.attr(),
slug: DS.attr()
});
Now let’s go to the tags index route, and we’ll add a findAll to the model hook
model() {
return this.get('store').findAll('tag');
}
And now in the app, we can see the Mirage request going out when we visit /tags
. And we can see there’s no data in the response. So let’s come to our default scenario and create some tags.
We’ll come here right above our posts, and create our tags
// mirage/scenarios/default.js
server.create('tag', {
name: 'JavaScript',
slug: 'javascript'
});
server.create('tag', {
name: 'CSS',
slug: 'css'
});
server.create('tag', {
name: 'Opinion',
slug: 'opinion'
});
Let’s save that, and back in the app we can see the data in the console. Let’s add a simple list to our template so we can see them.
<ul class='bullets'>
{{#each model as |tag|}}
<li class='mb2'>
{{tag.name}}
</li>
{{/each}}
</ul>
Alright, and there we can see our data being rendered.
Ok, if you recall from our mockups, each tag in this list showed the number of posts associated with it. So now we need to add the association between tags and posts.
The association here is a many to many, because a post (like our Top 10 Javascript libraries) could be tagged both javascript
and opinion
, but the javascript
tag could have other posts as well.
So let’s add that association now. So let’s open the tag model and add posts, which is a hasMany, and then we’ll open the post, and add the tags side. And that should do it.
—
How to decide whether a new association should be many-to-many, one-to-many, or something else falls under the topic of domain modeling and is out of the scope for this video. But good domain modeling is extremely important and these decisions have huge effects that can ripple throughout your application. So it’s always important to think through these decisions carefully.
Now depending on your experience, you might be used to creating a new join model when adding many-to-many relationships to your apps. This is a valid approach but I wanted to make sure you also know that Ember Data and modern JSON:API backends can support many-to-many relationships without any knowledge of a join record. So we’ll go ahead and continue on with our two models, each of them having their own hasMany association to the other.
Alright, now that we have these associations set up, we want to actually create the data in Mirage. So we’ll come back to the default scenario, and we’ll start by assigning these tag models to local variables. And then down where we create our posts, we can actually pass in an array of tags to each post - and this is possiblebecause of our new tags
association. So we’ll give this first post the javascript and opinion tags, this second one is an opinion, and the third one is css.
let javascript = server.create('tag', {
name: 'JavaScript',
slug: 'javascript'
});
let css = server.create('tag', {
name: 'CSS',
slug: 'css'
});
let opinion = server.create('tag', {
name: 'Opinion',
slug: 'opinion'
});
server.create('post', {
title: 'Top 10 JavaScript libraries to learn',
tags: [ javascript, opinion ]
});
server.create('post', {
title: "Why Silicon Valley needs South Dakota",
tags: [ opinion ]
});
server.create('post', {
title: "Mastering the Grid",
tags: [ css ]
});
Now, let’s put a debugger right after we finish creating these tags and posts, and take a look at Mirage’s database. We can type server.db.dump()
to see a copy of all the raw data powering Mirage.
If we open posts
we can see that we have 3 of them, and if we open the first we can see here this post has a tagIds
property of 1 and 3. Let’s come down to our tags
, and if we open 1, we can see it has a postIds that has a 1
.
Now, these tagIds
and postIds
are actually foreign keys, and they’re how Mirage keeps track of associations. And we can see that, even though we passed the tags into the posts when we created our data in the scenario file, Mirage made sure that both sides of this many-to-many relationship were updated. And that’s because these two hasMany
relationships are inverses of each other, because of the way we defined them in Ember Data.
Now let me explain why this works. Remember, Mirage has its own ORM, its own models, its own database and everything. It’s completely isolated from the Ember Data models that exist in our Ember app; and in fact, Mirage has basically no notion that our Ember app even exists at all. But we’re using a recent version of Mirage that automatically uses Ember Data’s model definitions when setting up its own models, so they match. It’s just an easy way to get going without having to duplicate models. Because of this, when we defined our two hasMany associations in Ember Data, Mirage knows about these associations, and that’s why we can just pass these tags into our posts on creation.
Now, these two hasMany relationships are inverses of each other, and we’ve already seen how Mirage keeps these foreign keys in sync. But it actually regardless of how you update them. To prove this, let’s come back to our default scenario, delete the tags from the posts creation, and assign these posts to their own local variables.
Now let’s save this and check our database. The 3 posts and 3 tags exist, but all their foreign keys are empty.
Ok, now we call the update
method on any of these models to update the associations. Let’s start with post1.
post1.update({
tags: [ javascript, opinion ]
});
Save this, and check it out. Post 1 has the correct tagIds, and the javascript and opinion tags have the correct postIds.
Back in the code, we can also update a tag directly, and give it some posts:
css.update({
posts: [ post2 ]
});
Again, the database has updated and Mirage is syncing the keys on both sides of this association.
The larger lesson here is that, when seeding Mirage with dummy data for a specific scenario or within a test, what matters is getting all the attributes and foreign keys to be correct within the database. That’s Mirage’s source of truth. And there are plenty of ways to do this - passing associations in during server.create
, calling model.update
later like we just saw, or even loading raw JS objects directly into the database. Once the database is consistent with what you expect, the rest of your Mirage code will function just as you expect.
Ultimately when creating data, we should go with the method that’s most clear for other developers who are reading this code, so in our case, let’s come back and revert this second method we just went over and go back to passing the tags into the posts on creation. I think this is the most natural way to express this data scenario.
Alright, now that our data is working, lets remove the debugger and come back to our app! If we look at our response, it still has the collection of 3 tags, but we know we want to display the number of posts in the list. So let’s come back to our Tags index route and side load the posts alongside these tags.
return this.get('store').findAll('tag', {
include: 'posts'
});
Whenever I need to show new data in a template, I like to first come back to the route and make sure the new data is loaded there. That way when I’m working in the template or on a controller, I know I already have all the data I need, which helps me avoid tricky issues like async bugs or my template unexpectedly re-rendering.
So now in the console, we can see the posts are being included. That means we have the data we want, and we can come to the template and add the amount of posts
{{tag.name}} ({{tag.posts.length}} posts)
And just like that, this page is looking great!
Alright, let’s flip back to our posts page. We load all the posts here and we want to display the tags as well. So let’s do the same thing - we’ll open the posts index route, and we’ll add tags
to the includes. Once we do, we can see the 3 tags showing up in our payload.
Now let’s come to our template, and in this last empty <td>
here, we can see we’re iterating over the list of posts, and we want to render a post.tags
<td>{{tag-list tags=}}</td>
And just like that, we see all the tags that we set up in mirage showing up for our posts. So both sides of this relationship are working and everything’s synced up correctly.
So, to recap, we started by adding the hasMany relationship to both sides of our new many-to-many association, and this set up Mirage with some new foreign keys. We then passed in the tags when creating our posts, which updated MIrage’s database with the correct foreign keys for both sides of the association. Finally we used side-loading to include the associated data on our two routes.
And just like that, we were able to use Mirage to create data for our new many-to-many relationship! With all of this wired up we can get our app in any state we need, whether we’re developing a new feature or writing a test.