Ember Data: A Comprehensive Tutorial for the ember-data Library
Ember Data is a library for robustly managing model data in Ember.js applications. Ember Data provides a more flexible and streamlined development workflow, minimizing code churn in response to what would otherwise be high impact changes. This thorough guide introduces Ember Data’s key constructs and paradigms, demonstrating the value it can provide to you as a developer.
Ember Data is a library for robustly managing model data in Ember.js applications. Ember Data provides a more flexible and streamlined development workflow, minimizing code churn in response to what would otherwise be high impact changes. This thorough guide introduces Ember Data’s key constructs and paradigms, demonstrating the value it can provide to you as a developer.
Pooyan is a data scientist, a neuroscientist, a software engineer, and an entrepreneur out to ensure that no one lives with undiagnosed ADHD
Expertise
Ember Data (a.k.a ember-data or ember.data) is a library for robustly managing model data in Ember.js applications. The developers of Ember Data state that it is designed to be agnostic to the underlying persistence mechanism, so it works just as well with JSON APIs over HTTP as it does with streaming WebSockets or local IndexedDB storage. It provides many of the facilities you’d find in server-side object relational mappings (ORMs) like ActiveRecord, but is designed specifically for the unique environment of JavaScript in the browser.
While Ember Data may take some time to grok, once you’ve done so, you will likely find it to have been well worth the investment. It will ultimately make development, enhancement, and maintenance of your system that much easier.
When an API is represented using Ember Data models, adapters and serializers, each association simply becomes a field name. This encapsulates the internal details of each association, thereby insulating the rest of your code from changes to the associations themselves. The rest of your code won’t care, for example, if a particular association is polymorphic or is the result of a map of many associations.
Moreover, your code base is largely insulated from backend changes, even if they are significant, since all your code base expects is fields and functions on models, not a JSON or XML or YAML representation of the model.
In this tutorial, we’ll introduce the most salient features of Ember Data and demonstrate how it helps minimize code churn, through a focus on a real world example.
An appendix is also provided that discusses a number more advanced Ember Data topics and examples.
Note: This article presumes some basic familiarity with Ember.js. If you aren’t familiar with Ember.js, check out our popular Ember.js tutorial for an introduction. We also have a full-stack JavaScript guide available in Russian, Portuguese and Spanish.
The Ember Data Value Proposition
Let’s begin by considering a simple example.
Say we have a working code base for a basic blog system. The system contains Posts and Tags, which have a many-to-many relationship with one another.
All is fine until we get a requirement to support Pages. The requirement also states that, since it’s possible to tag a Page in WordPress, we should be able to do so as well.
So now, Tags will no longer apply only to Posts, they may also apply to Pages. As a result, our simple association between Tags and Posts will no longer be adequate. Instead, we’ll need a many-to-many one-sided polymorphic relationship, such as the following:
- Each Post is a Taggable and has many Tags
- Each Page is a Taggable and has many Tags
- Each Tag has many polymorphic Taggables
Transitioning to this new, more complex, set of associations is likely to have significant ramifications throughout our code, resulting in lots of churn. Since we have no idea how to serialize a polymorphic association to JSON, we’ll probably just create more API endpoints like GET /posts/:id/tags
and GET /pages/:id/tags
. And then, we’ll throw away all of our existing JSON parser functions and write new ones for the new resources added. Ugh. Tedious and painful.
Now let’s consider how we would approach this using Ember Data.
In Ember Data, accommodating this modified set of associations would simply involve moving from:
App.Post = DS.Model.extend({
tags: DS.hasMany('tag', {async: true})
});
App.Tag = DS.Model.extend({
post: DS.belongsTo('post', {async: true})
});
to:
App.PostType = DS.Model.extend({
tags: DS.hasMany('tag', {async: true})
});
App.Post = App.PostType.extend({
});
App.Page = App.PostType.extend({
});
App.Tag = DS.Model.extend({
posts: DS.hasMany('postType', {polymorphic: true, async: true})
});
The resulting churn in the rest of our code would be minimal and we’d be able to reuse most of our templates. Note in particular that the tags
association name on Post remains unchanged. In addition, the rest of our code base relies only on the existence of the tags
association, and is oblivious to its details.
An Ember Data Primer
Before diving into a real world example, let’s review some Ember Data fundamentals.
Routes and Models
In Ember.js, the router is responsible for displaying templates, loading data, and otherwise setting up application state. The router matches the current URL to the routes that you’ve defined, so a Route is responsible for specifying the model that a template is to display (Ember expects this model to be subclass of Ember.Object
):
App.ItemsRoute = Ember.Route.extend({
model: function(){
// GET /items
// Retrieves all items.
return this.modelFor('orders.show').get('items');
}
});
Ember Data provides DS.Model
which is a subclass of Ember.Object
and adds capabilities like saving or updating a single record or multiple records for convenience.
To create a new Model, we create a subclass of DS.Model
(e.g., App.User = DS.Model.extend({})
).
Ember Data expects a well-defined, intuitive JSON structure from the server and serializes newly created records to same structured JSON.
Ember Data also provides a suite of array classes like DS.RecordArray
for working with Models. These have responsibilities such as handling one-to-many or many-to-many relationships, handling asynchronous retrieval of data, and so on.
Model Attributes
Basic model attributes are defined using DS.attr
; e.g.:
App.User = DS.Model.extend({
firstName: DS.attr('string'),
lastName: DS.attr('string')
});
Only fields created by DS.attr
will be included in the payload that’s passed to the server for creating or updating records.
DS.attr
accepts four data types: string
, number
, boolean
and date
.
RESTSerializer
By default:
- Ember Data employs
RESTSerializer
for creating objects from API responses (deserialization) and for generating JSON for API requests (serialization). -
RESTSerializer
expects fields created byDS.belongsTo
to have a field nameduser
included in the JSON response from the server. That field contains the id of the referenced record. -
RESTSerializer
adds auser
field to the payload passed to the API with the id of the associated order.
A response might, for example, look like this:
GET http://api.example.com/orders?ids[]=19&ids[]=28
{
"orders": [
{
"id": "19",
"createdAt": "1401492647008",
"user": "1"
},
{
"id": "28",
"createdAt": "1401492647008",
"user": "1"
}
]
}
And an HTTP request created by RESTSerializer
for saving an order might look like this:
POST http://api.example.com/orders
{
"order": {
"createdAt": "1401492647008",
"user": "1"
}
}
One-to-one Relationships
Say, for example, that each User has a unique Profile. We can represent this relationship in Ember Data using DS.belongsTo
on both User and Profile:
App.User = DS.Model.extend({
profile: DS.belongsTo('profile', {async: true})
});
App.Profile = DS.Model.extend({
user: DS.belongsTo('user', {async: true})
});
We can then get the association with user.get('profile')
or set it with user.set('profile', aProfile)
.
RESTSerializer
expects the ID of the associated model to be provided for each models; e.g.:
GET /users
{
"users": [
{
"id": "14",
"profile": "1" /* ID of profile associated with this user */
}
]
}
GET /profiles
{
"profiles": [
{
"id": "1",
"user": "14" /* ID of user associated with this profile */
}
]
}
Similarly, it includes the associated model’s ID in a request payload:
POST /profiles
{
"profile": {
"user": "17" /* ID of user associated with this profile */
}
}
One-to-many and Many-to-one Relationships
Say we have a model where a Post has many Comments. In Ember Data, we can represent this relationship with DS.hasMany('comment', {async: true})
on Post and DS.belongsTo('post', {async: true})
on Comment:
App.Post = DS.Model.extend({
content: DS.attr('string'),
comments: DS.hasMany('comment', {async: true})
});
App.Comment = DS.Model.extend({
message: DS.attr('string'),
post: DS.belongsTo('post', {async: true})
});
We can then get associated items with post.get('comments', {async: true})
and add a new association with post.get('comments').then(function(comments){ return comments.pushObject(aComment);})
.
The server will then respond with an array of IDs for the corresponding comments on a Post:
GET /posts
{
"posts": [
{
"id": "12",
"content": "",
"comments": ["56", "58"]
}
]
}
… and with an ID for each Comment:
GET /comments?ids[]=56&ids[]=58
{
"comments": [
{
"id": "56",
"message": "",
"post": "12"
},
{
"id": "58",
"message": "",
"post": "12"
}
]
}
RESTSerializer
adds the id of the associated Post to the Comment:
POST /comments
{
"comment": {
"message": "",
"post": "12" /* ID of post associated with this comment */
}
}
Note though that, by default, RESTSerializer, will not add DS.hasMany
associated IDs to the objects that it serializes, since those associations are specified on the “many” side (i.e., those which have a DS.belongsTo
association). So, in our example, although a Post has many comments, those IDs will not be added to the Post object:
POST /posts
{
"post": {
"content": "" /* no associated post IDs added here */
}
}
To “force” DS.hasMany
IDs to be serialized as well, you can use the Embedded Records Mixin.
Many-to-many Relationships
Say that in our model an Author may have multiple Posts and a Post may have multiple Authors.
To represent this relationship in Ember Data, we can use DS.hasMany('author', {async: true})
on Post and DS.hasMany('post', {async: true})
on Author:
App.Author = DS.Model.extend({
name: DS.attr('string'),
posts: DS.hasMany('post', {async: true})
});
App.Post = DS.Model.extend({
content: DS.attr('string'),
authors: DS.hasMany('author', {async: true})
});
We can then get associated items with author.get('posts')
and add a new association with author.get('posts').then(function(posts){ return posts.pushObject(aPost);})
.
The server will then respond with an array of IDs for the corresponding objects; e.g.:
GET /authors
{
"authors": [
{
"id": "1",
"name": "",
"posts": ["12"] /* IDs of posts associated with this author */
}
]
}
GET /posts
{
"posts": [
{
"id": "12",
"content": "",
"authors": ["1"] /* IDs of authors associated with this post */
}
]
}
Since this is a many-to-many relationship, RESTSerializer adds an array of IDs of associated objects; e.g:
POST /posts
{
"post": {
"content": "",
"authors": ["1", "4"] /* IDs of authors associated with this post */
}
}
A Real World Example: Enhancing an Existing Ordering System
In our existing ordering system, each User has many Orders and each Order has many Items. Our system has multiple Providers (i.e., vendors) from whom products can be ordered, but each order can only contain items from a single provider.
New requirement #1: Enable a single order to include items from multiple providers.
In the existing system, there is a one-to-many relationship between Providers and Orders. Once we extend an order to include items from multiple providers, though, this simple relationship will no longer be adequate.
Specifically, if a provider is associated with an entire order, in the enhanced system that order may very well include items ordered from other providers as well. There needs to be a way, therefore, of indicating which portion of each order is relevant to each provider. Moreover, when a provider accesses their orders, they should only have visibility into the items ordered from them, not any other items that the customer may have ordered from other providers.
One approach could be to introduce two new many-to-many associations; one between Order and Item and another between Order and Provider.
However, to keep things simpler, we introduce a new construct into the data model which we refer to as a “ProviderOrder”.
Drafting Relationships
The enhanced data model will need to accommodate the following associations:
-
One-to-many relationship between Users and Orders (each User may be associated with 0 to n Orders) and a One-to-many relationship between Users and Providers (each User may be associated with 0 to n Providers)
App.User = DS.Model.extend({ firstName: DS.attr('string'), lastName: DS.attr('string'), isAdmin: DS.attr('boolean'), orders: DS.hasMany('order', {async: true}), providers: DS.hasMany('provider', {async: true}) });
-
One-to-many relationship between Orders and ProviderOrders (each Order consists of 1 to n ProviderOrders):
App.Order = DS.Model.extend({ createdAt: DS.attr('date'), user: DS.belongsTo('user', {async: true}), providerOrders: DS.hasMany('providerOrders', {async: true}) });
-
One-to-many relationship between Providers and ProviderOrders (each Provider may be associated with 0 to n ProviderOrders):
App.Provider = DS.Model.extend({ link: DS.attr('string'), admin: DS.belongsTo('user', {async: true}), orders: DS.belongsTo('providerOrder', {async: true}), items: DS.hasMany('items', {async: true}) });
-
One-to-many relationship between ProviderOrders and Items (each ProviderOrder consists of 1 to n items):
App.ProviderOrder = DS.Model.extend({ // One of ['processed', 'in_delivery', 'delivered'] status: DS.attr('string'), provider: DS.belongsTo('provider', {async: true}), order: DS.belongsTo('order', {async: true}), items: DS.hasMany('item', {async: true}) }); App.Item = DS.Model.extend({ name: DS.attr('string'), price: DS.attr('number'), order: DS.belongsTo('order', {async: true}), provider: DS.belongsTo('provider', {async: true}) });
And let’s not forget our Route definition:
App.OrdersRoute = Ember.Route.extend({
model: function(){
// GET /orders
// Retrieves all orders.
return this.store.find('order');
}
});
Now each ProviderOrder has one Provider, which was our main goal. Items are moved from Order to ProviderOrder and assumption is that all items in one ProviderOrder belong to a single Provider.
Minimizing Code Churn
Unfortunately, there are some breaking changes here. So let’s see how Ember Data can help us minimize any resulting code churn in our code base.
Previously, we were pushing items with items.pushObject(item)
. Now we need to first find the appropriate ProviderOrder and push an Item to it:
order.get('providerOrders').then(function(providerOrders){
return providerOrders.findBy('id', item.get('provider.id') )
.get('items').then(functions(items){
return items.pushObject(item);
});
});
Since this is a lot of churn, and more Order’s job than controller’s, it’s better if we move this code into Order#pushItem
:
App.Order = DS.Model.extend({
createdAt: DS.attr('date'),
user: DS.belongsTo('user', {async: true}),
providerOrders: DS.hasMany('providerOrders', {async: true}),
/** returns a promise */
pushItem: function(item){
return this.get('providerOrders').then(function(providerOrders){
return providerOrders.findBy('id', item.get('provider.id') )
.get('items').then(functions(items){
return items.pushObject(item);
});
});
}
});
Now we can add items directly on order like order.pushItem(item)
.
And for listing Items of each Order:
App.Order = DS.Model.extend({
/* ... */
/** returns a promise */
items: function(){
return this.get('restaurantOrders').then(function(restaurantOrders){
var arrayOfPromisesContainingItems = restaurantOrders.mapBy('items')
return arrayOfPromisesContainingItems.then(function(items){
return items.reduce(function flattenByReduce(memo, index, element){
return memo.pushObject(element);
}, Ember.A([]));
});
});
}.property('restaurantOrders.@each.items')
/* ... */
});
App.ItemsRoute = Ember.Route.extend({
model: function(){
// Multiple GET /items with ids[] query parameter.
// Returns a promise.
return this.modelFor('orders.show').get('items');
}
});
Polymorphic Relationships
Let’s now introduce an additional enhancement request to our system that complicates things further:
New requirement #2: Support multiple types of Providers.
For our simple example, let’s say that two types of Providers (“Shop” and “Bookstore”) are defined:
App.Shop = App.Provider.extend({
status: DS.attr('string')
});
App.BookStore = App.Provider.extend({
name: DS.attr('string')
});
Here’s where Ember Data’s support for polymorphic relationships will come in handy. Ember Data supports one-to-one, one-to-many, and many-to-many polymorphic relationships. This is done simply by adding the attribute polymorphic: true
to the specification of the association. For example:
App.Provider = DS.Model.extend({
providerOrders: DS.hasMany('providerOrder', {async: true})
});
App.ProviderOrder = DS.Model.extend({
provider: DS.belongsTo('provider', {polymorphic: true, async: true})
});
The above polymorphic
flag indicates that there are various types of Providers that can be associated with a ProviderOrder (in our case, either a Shop or a Bookstore).
When a relationship is polymorphic, the server response should indicate both the ID and the type of the returned object (the RESTSerializer
does this by default); e.g.:
GET /providerOrders
{
"providerOrders": [{
"status": "in_delivery",
"provider": 1,
"providerType": "shop"
}]
}
Meeting the New Requirements
We need polymorphic Providers and Items to meet the requirements. Since ProviderOrder connects Providers with Items, we can change it’s associations to polymorphic associations:
App.ProviderOrder = DS.Model.extend({
provider: DS.belongsTo('provider', {polymorphic: true, async: true}),
items: DS.hasMany('item', {polymorphic: true, async: true})
});
There is a remaining issue, though: Provider has a non-polymorphic association to Items but Item is an abstract type. We therefore have two options to address this:
- Require all providers to be associated with the same item type (i.e., declare a specific type of Item for the association with Provider)
- Declare the
items
association on Provider as polymorphic
In our case, we need to go with option #2 and declare the items
association on Provider as polymorphic:
App.Provider = DS.Model.extend({
/* ... */
items: DS.hasMany('items', {polymorphic: true, async: true})
});
Note that this does not introduce any code churn; all associations simply work just the way they did prior to this change. The power of Ember Data at its best!
Can Ember Data really model all of my data?
There are exceptions of course, but I consider ActiveRecord conventions as a standard and flexible way of structuring and modeling data, so let me show you how ActiveRecord conventions map to Ember Data:
has_many :users through: :ownerships or Representing Intermediate Models
This will consult a pivot model called Ownership to find associated Users. If the pivot model is basically a pivot table, you can avoid creating an intermediate model in Ember Data and represent the relationship with DS.hasMany
on both sides.
However if you need that pivot relationship inside your front-end, set up an Ownership model that includes DS.belongsTo('user', {async: true})
and DS.belongsTo('provider', {async: true})
, and then add a property on both Users and Providers that maps through to the association using Ownership; e.g.:
App.User = DS.Model.extend({
// Omitted
ownerships: DS.hasMany('ownership'),
/** returns a promise */
providers: function(){
return this.get('ownerships').then(function(ownerships){
return ownerships.mapBy('provider');
});
}.property('ownerships.@each.provider')
});
App.Ownership = DS.Model.extend({
// One of ['approved', 'pending']
status: DS.attr('string'),
user: DS.belongsTo('user', {async: true}),
provider: DS.belongsTo('provider', {async: true})
});
App.Provider = DS.Model.extend({
// Omitted
ownerships: DS.hasMany('ownership', {async: true}),
/** returns a promise */
users: function(){
return this.get('ownerships').then(function(ownerships){
return ownerships.mapBy('user');
});
}.property('ownerships.@each.user')
});
has_many :mappings, as: locatable
In our ActiveRecord object, we have a typical polymorphic relationship:
class User < ActiveRecord::Base
has_many :mappings, as: locatable
has_many :locations, through: :mappings
end
class Mapping < ActiveRecord::Base
belongs_to :locatable, polymorphic: true
belongs_to :location
end
class Location < ActiveRecord::Base
has_many :mappings
has_many :users, through: :mappings, source: :locatable, source_type: 'User'
has_many :providers, through: :mappings, source: :locatable, source_type: 'Provider'
def locatables
users + providers
end
end
This is a many (polymorphic) to many (normal non-polymorphic) relationship. In Ember Data we can express this with a polymorphic DS.hasMany('locatable', {polymorphic: true, async: true})
and a static DS.hasMany('location', {async: true})
:
App.Locatable = DS.Model.extend({
locations: DS.hasMany('location', {async: true})
});
App.User = App.Locatable.extend({
userName: DS.attr('string')
});
App.Location = DS.Model.extend({
locatables: DS.hasMany('locatable', {polymorphic: true, async: true})
});
For Locatables, like User, the server should return IDs for the associated locations:
GET /users
{
"users": [
{
"id": "1",
"userName": "Pooyan",
"locations": ["1"]
}
]
}
For Location, the server should return both the ID and type of Locatable in the array of objects:
GET /locations
{
"locations": [
{
"id": "1",
"locatables": [
{"id": "1", "type": "user"},
{"id": "2", "type": "provider"}
]
}
]
}
Also, you can represent relationships by type with a static many-to-many relationship:
App.User = App.Locatable.extend({
userName: DS.attr('string'),
locations: DS.hasMany('location', {async: true})
});
App.Provider = App.Locatable.extend({
link: DS.attr('string'),
locations: DS.hasMany('location, {async: true}
});
App.Location = DS.Model.extend({
users: DS.hasMany('user', {async: true}),
providers: DS.hasMnay('provider', {async: true})
});
And what about realtime data?
Ember Data has push
, pushPayload
and update
. You can always manually push new/updated records into Ember Data’s local cache (called store) and it will handle all the rest.
App.ApplicationRoute = Ember.Route.extend({
activate: function(){
socket.on('recordUpdated', function(response){
var type = response.type;
var payload = response.payload;
this.store.pushPayload(type, payload);
});
}
});
I personally only use sockets for events with very small payloads. A typical event is ‘recordUpdated’ with payload of {"type": "shop", "id": "14"}
and then in ApplicationRoute I’ll check if that record is in local cache (store) and if it is I’ll just refetch it.
App.ApplicationRoute = Ember.Route.extend({
activate: function(){
socket.on('recordUpdated', function(response){
var type = response.type;
var id = response.id;
if( this.store.hasRecordForId(type, id) ){
this.store.find(type, id);
}
});
}
});
This way we can send record updated events to all clients without unacceptable overhead.
There are essentially two approaches in Ember Data for dealing with realtime data:
- Write an Adapter for your realtime communication channel and use it instead of RESTAdapter.
- Push records to the main store whenever they’re available.
The downside with the first option is that it’s somewhat akin to reinventing the wheel. For the second option, we need to access the main store, which is available on all routes as route#store
.
Wrap-up
In this article, we’ve introduced you to Ember Data’s key constructs and paradigms, demonstrating the value it can provide to you as a developer. Ember Data provides a more flexible and streamlined development workflow, minimizing code churn in response to what would otherwise be high impact changes.
The upfront investment (time and learning curve) that you make in using Ember Data for your project will undoubtedly prove worthwhile as your system inevitably evolves and needs to be extended, modified, and enhanced.
APPENDIX: Advanced Ember Data Topics
This appendix introduces a number of more advanced Ember Data topics including:
- Ember’s Modular Design
- Sideloading
- Links
- Active Model Serializer and Adapter
- Embedded Records Mixin
- Association Modifiers (Async, Inverse, and Polymorphic)
- ‘ids’ Parameter in GET Requests
Modular Design
Ember Data has a modular design under the hood. Key components include:
-
Adapters
are responsible for handling communication, currently only REST over HTTP. -
Serializers
manage the creation of models from JSON or the reverse. -
Store
caches created records. -
Container
glues all these together.
Benefits of this design include:
- Deserialization and storage of data works independently of the communication channel employed and resource requested.
- Working configurations, such as ActiveModelSerializer or EmbeddedRecordsMixin, are provided out-of-the-box.
- Data sources (e.g., LocalStorage, CouchDB implementation, etc.) can be swapped in and out by changing adapters.
- Despite much convention over configuration, it is possible to configure everything and share your configuration/implementation with the community.
Sideloading
Ember Data supports “sideloading” of data; i.e., indicating ancillary data that should be retrieved (along with the primary data requested) so as to help consolidate multiple related HTTP requests.
A common use case is sideloading associated models. For example, each Shop has many groceries, so we can include all related groceries in /shops
response:
GET /shops
{
"shops": [
{
"id": "14",
"groceries": ["98", "99", "112"]
}
]
}
When the groceries association is accessed, Ember Data will issue:
GET /groceries?ids[]=98&ids[]=99&ids[]=112
{
"groceries": [
{ "id": "98", "provider": "14", "type": "shop" },
{ "id": "99", "provider": "14", "type": "shop" },
{ "id": "112", "provider": "14", "type": "shop" }
]
}
However, if we instead return associated Groceries in /shops
endpoint, Ember Data won’t need to issue another request:
GET /shops
{
"shops": [
{
"id": "14",
"groceries": ["98", "99", "112"]
}
],
"groceries": [
{ "id": "98", "provider": "14", "type": "shop" },
{ "id": "99", "provider": "14", "type": "shop" },
{ "id": "112", "provider": "14", "type": "shop" }
]
}
Links
Ember Data accepts links in place of association IDs. When an association specified as a link is accessed, Ember Data will issue a GET request to that link in order to get the associated records.
For example, we could return a link for a groceries association:
GET /shops
{
"shops": [
{
"id": "14",
"links": {
"groceries": "/shops/14/groceries"
}
}
]
}
And Ember Data would then issue a request to /shops/14/groceries
GET /shops/14/groceries
{
"groceries": [
{ "id": "98", "provider": "14", "type": "shop" },
{ "id": "99", "provider": "14", "type": "shop" },
{ "id": "112", "provider": "14", "type": "shop" }
]
}
Keep in mind that you still need to represent the association in the data; links just suggest a new HTTP request and won’t affect associations.
Active Model Serializer and Adapter
Arguably, ActiveModelSerializer
and ActiveModelAdapter
are more used in practice than RESTSerializer
and RESTAdapter
. In particular, when the backend uses Ruby on Rails and the ActiveModel::Serializers
gem, the best option is to use ActiveModelSerializer
and ActiveModelAdapter
, since they support ActiveModel::Serializers
out of the box.
Fortunately, though, the usage differences between ActiveModelSerializer
/ActiveModelAdapter
and RESTSerializer
/RESTAdapter
are quite limited; namely:
-
ActiveModelSerializer
will use snake_case field names whileRESTSerializer
requires camelCased field names. -
ActiveModelAdapter
issues requests to snake_case API methods whileRESTSerializer
issues to camelCased API methods. -
ActiveModelSerializer
expects association related field names to end in_id
or_ids
whileRESTSerializer
expects association related field names to be the same as the association field.
Regardless of the Adapter and Serializer choice, Ember Data models will be exactly the same. Only JSON representation and API endpoints will be different.
Take our final ProviderOrder as an example:
App.ApplicationSerializer = DS.ActiveModelSerializer.extend({
});
App.ProviderOrder = DS.Model.extend({
// One of ['processed', 'in_delivery', 'delivered']
status: DS.attr('string'),
provider: DS.belongsTo('provider', {polymorphic: true, async: true}),
order: DS.belongsTo('order', {async: true}),
items: DS.hasMany('item', {polymorphic: true, async: true})
});
With Active Model Serializer and Adapter, server should expect:
Post /provider_orders
{
"provider_order": [
"status": "",
"provider": {"id": "13", "type": "shop"}
"order_id": "68",
]
}
… and should respond with:
GET /provider_orders
{
"provider_orders": [
"id": "1",
"status": "",
"provider": {"id": "13", "type": "shop"}
"order_id": "68",
"items": [
{"id": "57", "type": "grocery"},
{"id": "89", "type": "grocery"}
]
]
}
Embedded Records Mixin
DS.EmbeddedRecordsMixin
is an extension for DS.ActiveModelSerializer
which allows configuring of how associations get serialized or deserialized. Although not yet complete (especially with regard to polymorphic associations), it is intriguing nonetheless.
You can choose:
- Not to serialize or deserialize associations.
- To serialize or deserialize associations with id or ids.
- To serialize or deserialize associations with embedded models.
This is particularly useful in one-to-many relationships where, by default, DS.hasMany
associated IDs are not added to the objects that are serialized. Take a shopping cart that has many items as an example. In this example, Cart is being created while Items are known. However, when you’re saving the Cart, Ember Data won’t automatically put the IDs of associated Items on the request payload.
Using DS.EmbeddedRecordsMixin
, however, it is possible to tell Ember Data to serialize the item IDs on Cart as follows:
App.CartSerializer = DS.ActiveModelSerializer
.extend(DS.EmbeddedRecordsMixin)
.extend{
attrs: {
// thanks EmbeddedRecordsMixin!
items: {serialize: 'ids', deserialize: 'ids'}
}
});
App.Cart = DS.Model.extend({
items: DS.hasMany('item', {async: true})
});
App.Item = DS.Model.extend({
cart: DS.belongsTo('item', {async: true})
});
As shown in the above example, the EmbeddedRecordsMixin allows for explicit specification of which associations to serialize and/or deserialize via the attrs
object. Valid values for serialize
and deserialize
are:
- 'no'
: do not include association in serialized/deserialized data
- 'id'
or 'ids'
: include associated ID(s) in serialized/deserialized data
- 'records
’: include actual properties (i.e., record field values) as an array in serialized/deserialized data
Association Modifiers (Async, Inverse, and Polymorphic)
The following association modifiers are supported: polymorphic
, inverse
, and async
Polymorphic Modifier
In a polymorphic association, one or both sides of the association represent a class of objects, rather than a specific object.
Recall our earlier example of a blog where we needed to support the ability to tag both posts and pages. To support this, we had arrived at the following model:
- Each Post is a Taggable and has many Tags
- Each Page is a Taggable and has many Tags
- Each Tag has many polymorphic Taggables
Following that model, a polymorphic
modifier can be used to declare that Tags are related to any type of “Taggable” (which may either be a Post or a Page), as follows:
// A Taggable is something that can be tagged (i.e., that has tags)
App.Taggable = DS.Model.extend({
tags: DS.hasMany('tag')
});
// A Page is a type of Taggable
App.Page = App.Taggable.extend({});
// A Post is a type of Taggable
App.Post = App.Taggable.extend
App.Tag = DS.Model.extend({
// the "other side" of this association (i.e., the 'taggable') is polymorphic
taggable: DS.belongsTo('taggable', {polymorphic: true})
});
Inverse Modifier
Usually associations are bidirectional. For example, “Post has many Comments” would be one direction of an association, while “Comment belongs to a Post” would be the other (i.e., “inverse”) direction of that association.
In cases where there is no ambiguity in the association, only one direction needs to be specified since Ember Data can deduce the inverse portion of the association.
However, in cases where objects in your model have multiple associations with one another, Ember Data cannot derive the inverse of each association automatically and it therefore needs to be specified using the invers
modifier.
Consider, for example, a case where:
- Each Page may have many Users as Collaborators
- Each Page may have many Users as Maintainers
- Each User may have many Favorite Pages
- Each User may have many Followed Pages
This would need to be specified as follows in our model:
App.User = DS.Model.extend({
favoritePages: DS.hasMany('page', {inverse: 'favoritors}),
followedPages: DS.hasMany('page', {inverse: 'followers'}),
collaboratePages: DS.hasMany('page', {inverse: 'collaborators'}),
maintainedPages: DS.hasMany('page', {inverse: 'maintainers'})
});
App.Page = DS.Model.extend({
favoritors: DS.hasMany('user', {inverse: 'favoritePages'}),
followers: DS.hasMany('user', {inverse: 'followedPages'}),
collaborators: DS.hasMany('user', {inverse: 'collaboratedPages}),
maintainers: DS.hasMany('user', {inverse: 'maintainedPages'})
});
Async Modifier
When data needs to be retrieved based on relevant associations, that associated data may or may not already have been loaded. If not, a synchronous association will throw an error since the associated data has not been loaded.
{async: true}
indicates that the request for the associated data should be handled asynchronously. The request therefore immediately returns a promise and the supplied callback is invoked once the associated data has been retrieved and is available.
When async
is false
, getting associated objects would be done as follows:
post.get('comments').pushObject(comment);
When async
is true
, getting associated objects would be done as follows (note the callback function specified):
post.get('comments').then(function(comments){
comments.pushObject(comment);
})
Note well: The current default value of async
is false
, but in Ember Data 1.0 the default will be true
.
‘ids’ Parameter in GET Requests
By default, Ember Data expects that resources aren’t nested. Take Posts which have many Comments as an example. In a typical interpretation of REST, API endpoints might look like these:
GET /users
GET /users/:id/posts
GET /users/:id/posts/:id/comments
However, Ember Data expects API endpoints to be flat, and not nested; e.g.:
GET /users with ?ids[]=4 as query parameters.
GET /posts with ?ids[]=18&ids[]=27 as query parameters.
GET /comments with ?ids[]=74&ids[]=78&ids[]=114&ids[]=117 as query parameters.
In the above example, ids
is the name of the array, []
indicates that this query parameter is an array, and =
indicates that a new ID is being pushed onto the array.
Now Ember Data can avoid downloading resources it already has or defer downloading them to very last moment.
Also, to white list an array parameter using the StrongParameters gem, you can declare it as params.require(:shop).permit(item_ids: [])
.