Transactionless Nested Ember Data Resources

Update 4/7/13

This article references a pre-release version of Ember.js. Now that Ember.js has reached a 1.0 API this article is basically no longer relevant.


Working with Ember and Ember Data during its real-time development have been exciting and frustrating times. Ember offers a powerful convention-driven approach to building robust apps on the client-side and Ember Data aims to simplify the the way your app’s data is retreived and stored. Powerful things get boiled down to simple functions like Store.commit() or App.Post.find(). Currently though, Ember Data is still in alpha and undergoing frequent API breaking changes with an emphasis on breaking.

In an Ember app I’m currently working on, I had the need to nest things within a parent object. For example: a campaign has one address (street name, city, zip, etc) and an array of links (each with a title and url). On the Rails REST API, I only needed one endpoint since I don’t need to query campaigns by links or addresses, so I am just serializing them to the campaign model instead of creating separate models for them.

1
2
3
4
5
class Campaign < ActiveRecord::Base
  ...
  serialize :links, Array
  serialize :location, Hash
end

Ember Data by default though, wants to save each resource type at its own API end-point and only save the ID references of the related objects within the parent object. To get around this in a previous revision of Ember Data, I could simply add the embedded option when defining a relationship and adding the associations option into the object’s toData function when sending the campaign back to the server.

1
2
3
4
5
6
7
8
App.Campaign = DS.Model.extend
  ...
  links: DS.hasMany('App.CampaignLink', { embedded: true })
  location: DS.hasMany('App.CampaignLocation', { embedded: true })
  toData: (options) ->
    options = options or {}
    options['associations'] = true
    @_super(options)

A recent code refactoring revision undid this functionality though, so I was forced to find another solution. I tried muddling around building an extended REST API adapter and serializer but I could never seem to break free of deeply engrained conventions of state and transaction management where Ember Data always seemed to care about campaign links and locations as their own object entities. Every time Store.commit() was called, a 404-ed POST request was sent to /campaign_links and /campaign_locations and I ended up with dirty transactions. Ew. All I wanted was for Ember Data to chill and not give these objects any special treatment as they are basically just array and object attributes of the Campaign. The workaround I created was to register transforms for attribute types called DS.attr("embeddedObject") and DS.attr("embeddedObjectArray").

embedded-transforms.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
stringToFunction = (str) ->
  arr = str.split(".")
  fn = window or @
  fn = fn[f] for f in arr
  throw new Error "function not found" if typeof fn is not "function"
  fn

createRecordFromData = (obj) ->
  if obj.type? and obj.type != "undefined"
    obj.transaction = null
    stringToFunction(obj.type).createRecord(obj)
  else
    obj

toDataWithType = (obj) ->
  if obj.constructor? and obj.constructor.name != "Object"
    o = obj.toData()
    o.type = obj.constructor.toString()
    o
  else
    obj

DS.Transforms.reopen

  embeddedObject:
    fromData: (serialized) ->
      if Em.none(serialized)
        null
      else
        createRecordFromData(serialized)
    toData: (deserialized) ->
      if Em.none(deserialized)
        null
      else
        toDataWithType(deserialized)

  embeddedObjectArray:
    fromData: (serialized) ->
      if Em.none(serialized)
        null
      else
        if Em.isArray(serialized) and serialized.length > 0
          createRecordFromData(obj) for obj in Em.makeArray serialized
        else
          []
    toData: (deserialized) ->
      if Em.none(deserialized)
        null
      else
        if Em.isArray(deserialized) and deserialized.length > 0
          toDataWithType(obj) for obj in Em.makeArray deserialized
        else
          []

In order to reap all the non-syncing benefits of DS.Model with these nested objects and arrays, I have to save the model class name locally to each nested object (i.e. type: "App.CampaignLink") before sending it to the server, so that when it is returned, I can instantiate it with createRecord via the nifty stringToFunction. If the embedded object(s) are plain old javascript objects, this class typecasting is bypassed and it works as well.

I’m sure there is room for improvement and perhaps this is an unideal solution, but it allows me to press forward with development and rely less on Ember Data’s changing conventions as a 1.0 release gets ironed out.

Comments