Two-way symmetric relationships in Ember with JSON API - Part 1

17 November 2016

Definition

In data modelling, a symmetric relationship is a special kind of relationship where the description of the relationship from the perspective of one end of the relationship is identical to looking at it from the perspective of the other end.

Friendship between people is a good example. If Megan is Selma's friend, it follows that Selma is Megan's friend, too. On the other hand, the "knows" relationship between two people is not a symmetric one. I might know Danny Carey (the drummer of Tool), but that does not imply he knows me.

Historical background

My research into how to model and implement such a relationship in an Ember application was sparked by this Stack Overflow question that was posed by a reader of my book. It was more difficult than I thought it would be so I was intrigued to find the (an) answer.

My solution turned out to have a fairly large API component, too, so the following post will show both the server-side implementation (in Rails) and the client-side one (in Ember).

If you don't speak Rails, fear not. The code is straightforward and easy to understand without any substantial Rails knowledge, thanks in most part to the gem that makes it extremely easy to serialize data models and relationships to json:api format, jsonapi-resources.

Data modelling

We'll start with the data modelling part, which is the Rails side.

To be able to model our problem in the data layer, let's say that Friendships have a friender and a friended end of the relationship and a strength attribute that measures how strong their friendship is.

We should create a (data) migration that will create a database table when run:

1    $ rails g migration create_friendships

Let's fill in the generated migration with the above attributes:

 1class CreateFriendships < ActiveRecord::Migration
 2  def change
 3    create_table :friendships do |t|
 4      t.integer :friender_id
 5      t.integer :friended_id
 6      t.integer :strength
 7      t.timestamps null: false
 8    end
 9  end
10end

A Friendship, then, is between two people (Persons), so let's define that in the corresponding model file:

1# app/models/friendship.rb
2class Friendship < ActiveRecord::Base
3  belongs_to :friender, class_name: Person
4  belongs_to :friended, class_name: Person
5end

We'll want to list all the friendships a person has so a friendships method needs to be added to the Person class:

1# app/models/person.rb
2class Person < ActiveRecord::Base
3  def friendships
4    Friendship.where("friender_id = ? OR friended_id = ?", id, id);
5  end
6end

We select the friendships where either the friender or the friended is the person we query it for. This is where the symmetric aspect of the relationship is implemented. We don't care if the person friended somebody or if that somebody friended him, they are friends.

Note that modelling it this way, we could split up the symmetric relationship into the two constituent parts. We could return only the friendships where the person in question "initiated" it (was the friender), or "let himself be friended" (was the friender).

Server endpoints, resources, serializing relationships

We could now turn our attention to setting up the endpoints and serializing the model, and relationship data for the client application to consume. First, let's install the jsonapi-resources gem:

1    $ gem install jsonapi-resources

This gives us a jsonapi:resource generator that we can use to create both the endpoints and the serializer for our resources.

1    $ rails generate jsonapi:resource person
2    $ rails generate jsonapi:resource friendship

The created resources are placed in the app/resources folder. Let's add the attributes we want to serialize for each one:

1# app/resources/person_resource.rb
2class PersonResource < JSONAPI::Resource
3  attributes :name
4  has_many :friendships, class_name: "Friendship"
5end
1# app/resources/friendship_resource.rb
2class FriendshipResource < JSONAPI::Resource
3  has_one :friender
4  has_one :friended
5  attributes :strength
6end

Creating the endpoints is no more work than adding a jsonapi_resources call for each resource in the router configuration:

1# config/routes.rb
2Rails.application.routes.draw do
3  jsonapi_resources :people
4  jsonapi_resources :friendships
5end

The gem also provides a controller generator so let's use it to create controllers for our resources:

1    $ rails generate jsonapi:controller person
2    $ rails generate jsonapi:controller friendship

They can be left empty but they need to be created in a way that they are descendants of JSONAPI::ResourceController (the generator takes care of that):

1# app/controllers/people_controller.rb
2class PeopleController < JSONAPI::ResourceController
3end
1# app/controllers/friendships_controller.rb
2class FriendshipsController < JSONAPI::ResourceController
3end

The back-end is now done, we can switch our focus to the Ember app.

The front-end

We want a list of people (rock stars, of course) and then have a list of their friendships on the person details page.

Mike McCready's frienships - Part 1

The first step is to set up the routes:

1(...)
2Router.map(function() {
3  this.route('people', { path: '/' }, function() {
4    this.route('show', { path: '/people/:person_id' });
5  });
6});
7
8export default Router;

The model hooks for these routes are the classic, "fetch'em all" and "fetch the one that matches the id" methods of Ember Data's store:

1// app/routes/people.js
2import Ember from 'ember';
3
4export default Ember.Route.extend({
5  model() {
6    return this.store.findAll('person');
7  }
8});
1// app/routes/people/show.js
2import Ember from 'ember';
3
4export default Ember.Route.extend({
5  model(params) {
6    return this.store.findRecord('person', params.person_id);
7  }
8});

Before we move on to writing the templates, let's define the models:

 1// app/models/person.js
 2import DS from 'ember-data';
 3
 4const { Model, attr, hasMany } = DS;
 5
 6export default Model.extend({
 7  name: attr(),
 8  friendships: hasMany(),
 9  frienderFriendships: hasMany('friendship', { inverse: 'friender' }),
10  friendedFriendships: hasMany('friendship', { inverse: 'friended' }),
11});
 1// app/models/friendship.js
 2import DS from 'ember-data';
 3
 4const { Model, attr, belongsTo } = DS;
 5
 6export default Model.extend({
 7  strength: attr('number'),
 8  friender: belongsTo('person', { inverse: 'frienderFriendships' }),
 9  friended: belongsTo('person', { inverse: 'friendedFriendships' }),
10});

This is rather standard Ember Data stuff, possibly with the exception of the inverse definitions. Since we have two relationships between Person and Friendship we need to specify the other end of each relationship and that's what we do with the inverse option.

With the models and routes in place, we can now see what the templates should look like.

The top-level people route is again fairly straightforward:

 1// app/templates/people.hbs
 2<div class="col-md-4">
 3  <div class="list-group">
 4    {{#each model as |person|}}
 5      {{link-to person.name 'people.show' person.id class="list-group-item"}}
 6    {{/each}}
 7  </div>
 8</div>
 9<div class="col-md-8">
10  {{outlet}}
11</div>

The each loop iterates through each person and renders a link for each of those that will take us to the person details page, which will display the person's friendships.

List of people

Listing a person's friendships

 1// app/templates/people/show.hbs
 2<div class="panel panel-default">
 3  <div class="panel-heading">
 4    <h3 class="panel-title">Friends of {{model.name}}</h3>
 5  </div>
 6  <div class="panel-body">
 7    <ul class="friend-list">
 8      {{#each model.friendships as |friendship|}}
 9        <li class="friend-list-item">
10          <span class="name">{{friendship.friender.name}}</span>
11          <span class="name">{{friendship.friended.name}}</span>
12          <span class="badge">{{friendship.strength}}</span>
13        </li>
14      {{/each}}
15    </ul>
16  </div>
17</div>

There is nothing fancy going on here, either. The model is the person retrieved in the route. For each friendship that he has, the friender's and the friended's name are rendered along with the strength of the relationship. (Either friender or friended will be the person itself, but we can ignore that in the first version.)

This naive approach works, the friendships for the selected person are listed correctly:

Mike McCready's friendships - Part 1

A 2N+1 problem

However, looking at the requests to the backend for just one page, one gets the impression that we're not done yet:

Too many XHRs

For each friendship the person has, two requests are sent to the backend. One to fetch the friender and another one to fetch the friended person. This is not an N+1 query problem, this is worse, a 2N+1 query problem!

On top of that, those requests are sent for no good reason as we'd previously loaded the people referred by those friended and friended relationships.

In the next part, we'll see how these wasteful requests can be eliminated and we'll also make the person details page less perplexing by not displaying the person whose page we're looking at in the relationships. Stay tuned!

UPDATE: Part 2 is now available!

Share on Twitter