Many-to-Many Relationships with Ember CLI Mirage

Ember.js and Ember Data, with their convention over configuration mindset, and JSON API make it easy to set up and manage many-to-many relationships between models. At the database and API layers, the set up can be more complex. This becomes apparent when using Ember CLI Mirage.

The key difference between the setup in the Ember models and in Mirage is that Mirage requires an extra join model and a serializer on each of the associated models. To demonstrate, create two models, Class and Student. This demo assumes you have a working Ember App, have Ember CLI Mirage installed, and have Ember Faker installed (to populate test data using faker.js).

/app/model/class.js

/app/model/class.js import DS from 'ember-data'; export default DS.Model.extend({ name: DS.attr('string'), students: DS.hasMany(), }); 1 2 3 4 5 6 import DS from 'ember-data' ; export default DS . Model . extend ( { name : DS . attr ( 'string' ) , students : DS . hasMany ( ) , } ) ;

/app/model/student.js

/app/model/student.js import DS from 'ember-data'; export default DS.Model.extend({ firstName: DS.attr('string'), lastName: DS.attr('string'), classes: DS.hasMany(), }); 1 2 3 4 5 6 7 import DS from 'ember-data' ; export default DS . Model . extend ( { firstName : DS . attr ( 'string' ) , lastName : DS . attr ( 'string' ) , classes : DS . hasMany ( ) , } ) ;

Easy enough. Assuming you load these in your route’s model, you can access the relationships in your templates like this:

<h2>Classes</h2> {{#each model.classes as |class|}} <h3>{{class.name}}</h3> {{#each class.students as |student|}} <p>{{student.firstName}} {{student.lastName}}</p> {{/each}} {{/each}} <h2>Students</h2> {{#each model.students as |student|}} <h3>{{student.firstName}} {{student.lastName}}</h3> {{#each student.classes as |class|}} <p>{{class.name}}</p> {{/each}} {{/each}} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 < h2 > Classes < / h2 > { { #each model.classes as |class|}} < h3 > { { class . name } } < / h3 > { { #each class.students as |student|}} < p > { { student . firstName } } { { student . lastName } } < / p > { { / each } } { { / each } } < h2 > Students < / h2 > { { #each model.students as |student|}} < h3 > { { student . firstName } } { { student . lastName } } < / h3 > { { #each student.classes as |class|}} < p > { { class . name } } < / p > { { / each } } { { / each } }

Next we need to replicate this data structure in Mirage. Create one extra model ClassStudent, to connect Class and Student behind the scenes. The order in the class name does not matter, but I tend to put them in alphabetical order as a convention.

/mirage/models/class-student.js

/mirage/models/class-student.js import { Model, belongsTo } from 'ember-cli-mirage'; export default Model.extend({ class: belongsTo(), student: belongsTo(), }); 1 2 3 4 5 6 import { Model , belongsTo } from 'ember-cli-mirage' ; export default Model . extend ( { class : belongsTo ( ) , student : belongsTo ( ) , } ) ;

Now add Mirage versions of the Class and Student models and associate to the ClassStudent model:

/mirage/models/class.js

/mirage/models/class.js import { Model, hasMany } from 'ember-cli-mirage'; export default Model.extend({ students: hasMany('class-student'), }); 1 2 3 4 5 import { Model , hasMany } from 'ember-cli-mirage' ; export default Model . extend ( { students : hasMany ( 'class-student' ) , } ) ;

/mirage/models/student.js

/mirage/models/student.js import { Model, hasMany } from 'ember-cli-mirage'; export default Model.extend({ classes: hasMany('class-student'), }); 1 2 3 4 5 import { Model , hasMany } from 'ember-cli-mirage' ; export default Model . extend ( { classes : hasMany ( 'class-student' ) , } ) ;

Don’t forget to add the factories and add the endpoints in the Mirage config.

/mirage/factories/class.js

import { Factory } from 'ember-cli-mirage'; import faker from 'faker'; export default Factory.extend({ name() { return faker.company.catchPhrase(); }, }); 1 2 3 4 5 6 import { Factory } from 'ember-cli-mirage' ; import faker from 'faker' ; export default Factory . extend ( { name ( ) { return faker . company . catchPhrase ( ) ; } , } ) ;

/mirage/factories/student.js

import { Factory } from 'ember-cli-mirage'; import faker from 'faker'; export default Factory.extend({ firstName() { return faker.name.firstName(); }, lastName() { return faker.name.lastName(); }, }); 1 2 3 4 5 6 7 import { Factory } from 'ember-cli-mirage' ; import faker from 'faker' ; export default Factory . extend ( { firstName ( ) { return faker . name . firstName ( ) ; } , lastName ( ) { return faker . name . lastName ( ) ; } , } ) ;

/mirage/config.js

export default function() { this.get('/classes'); this.get('/classes/:id'); this.get('/students'); this.get('/students/:id'); } 1 2 3 4 5 6 export default function ( ) { this . get ( '/classes' ) ; this . get ( '/classes/:id' ) ; this . get ( '/students' ) ; this . get ( '/students/:id' ) ; }

We’re almost finished, but there is one more adjustment to make. When we ask Mirage for class.students, it’s going to give us a list of ClassStudent models by default. We can use a serializer to switch these out with the Student models, and the same to grab the Class models when requesting student.classes.

/mirage/serializers/class.js

import { JSONAPISerializer } from 'ember-cli-mirage'; export default JSONAPISerializer.extend({ serialize(){ let json = JSONAPISerializer.prototype.serialize.apply(this, arguments); if (Array.isArray(json.data)) { json.data.forEach((data, i) => { json.data[i].relationships.students.data = this.studentSerialize(data); }); } else { json.data.relationships.students.data = this.studentSerialize(json.data); } return json; }, studentSerialize(data) { return data.relationships.students.data.map(classStudent => ({ id: this.registry.schema.classStudents.find(classStudent.id).studentId, type: 'student', })); } }); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { JSONAPISerializer } from 'ember-cli-mirage' ; export default JSONAPISerializer . extend ( { serialize ( ) { let json = JSONAPISerializer . prototype . serialize . apply ( this , arguments ) ; if ( Array . isArray ( json . data ) ) { json . data . forEach ( ( data , i ) = > { json . data [ i ] . relationships . students . data = this . studentSerialize ( data ) ; } ) ; } else { json . data . relationships . students . data = this . studentSerialize ( json . data ) ; } return json ; } , studentSerialize ( data ) { return data . relationships . students . data . map ( classStudent = > ( { id : this . registry . schema . classStudents . find ( classStudent . id ) . studentId , type : 'student' , } ) ) ; } } ) ;

/mirage/serializers/student.js

import { JSONAPISerializer } from 'ember-cli-mirage'; export default JSONAPISerializer.extend({ serialize(){ let json = JSONAPISerializer.prototype.serialize.apply(this, arguments); if (Array.isArray(json.data)) { json.data.forEach((data, i) => { json.data[i].relationships.classes.data = this.classSerialize(data); }); } else { json.data.relationships.classes.data = this.classSerialize(json.data); } return json; }, classSerialize(data) { return data.relationships.classes.data.map(classStudent => ({ id: this.registry.schema.classStudents.find(classStudent.id).classId, type: 'class', })); }, }); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { JSONAPISerializer } from 'ember-cli-mirage' ; export default JSONAPISerializer . extend ( { serialize ( ) { let json = JSONAPISerializer . prototype . serialize . apply ( this , arguments ) ; if ( Array . isArray ( json . data ) ) { json . data . forEach ( ( data , i ) = > { json . data [ i ] . relationships . classes . data = this . classSerialize ( data ) ; } ) ; } else { json . data . relationships . classes . data = this . classSerialize ( json . data ) ; } return json ; } , classSerialize ( data ) { return data . relationships . classes . data . map ( classStudent = > ( { id : this . registry . schema . classStudents . find ( classStudent . id ) . classId , type : 'class' , } ) ) ; } , } ) ;

This completes the Mirage data structure, now it is time to populate some sample data. The factories will take care of filling in the names, but the code below will create 2 classes and 5 students. It will then semi-randomly associate some of the classes with students.

/mirage/scenarios/default.js

export default function(server) { let numberOfClasses = 2; let numberOfStudents = 5; server.createList('class', numberOfClasses); server.createList('student', numberOfStudents); for(let c=1;c<=numberOfClasses;c++) { let numberOfStudentsForClass = random(2, numberOfStudents); for(let s=1;s<=numberOfStudentsForClass;s++) { server.create('class-student', { classId: c, studentId: s }); } } function random(min, max) { return Math.floor(Math.random() * (max - min)) + min; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 export default function ( server ) { let numberOfClasses = 2 ; let numberOfStudents = 5 ; server . createList ( 'class' , numberOfClasses ) ; server . createList ( 'student' , numberOfStudents ) ; for ( let c = 1 ; c <= numberOfClasses ; c ++ ) { let numberOfStudentsForClass = random ( 2 , numberOfStudents ) ; for ( let s = 1 ; s <= numberOfStudentsForClass ; s ++ ) { server . create ( 'class-student' , { classId : c , studentId : s } ) ; } } function random ( min , max ) { return Math . floor ( Math . random ( ) * ( max - min ) ) + min ; } }

That’s it!

Code: https://github.com/ricog/ember-cli-mirage-many-to-many-demo

Demo: http://mirage-many-to-many.surge.sh/

Thanks to https://github.com/samselikoff/ember-cli-mirage/issues/606 for clues on how to implement a many-to-many relationship in Ember CLI Mirage. The discussion also contains a hint of a future has_and_belongs_to_many feature to make implementation a lot easier.