Apr 14, 2012

Update to previous Mongoid id serialization fix

Previously I had blogged about one way to get an id attribute included in the serialization of Mongoid documents.

The original solution involved a patch to the as_json method from ActiveModel::Serialization applied to Mongoid::Document. This patch ensured that the id attribute was included in the serialization for persisted documents.

There was, however, a shortcoming of this solution: id would appear in the JSON for the top-level object (i.e. the object on which as_json is directly called). However, the id attribute would NOT appear in the JSON of children specified to be added to the serialization via the as_json :include option.

Here’s an illustration of what goes wrong:

# example: serialize only the id attribute of the user and any comments
user.as_json(only: :id, include: { comments: { only: :id } } )
# returns the following using the as_json patch
{ id: BSON::ObjectId('ABC123'), comments: [{}] }  # the comment has no id attribute!

Enter #serializable_hash

In doing some digging into the as_json method in ActiveModel::Serialization, it’s clear that as_json is not triggered recursively on the child objects specified by the :include option. Instead, it’s the #serializable_hash method that acts recursively.

# File activemodel/lib/active_model/serializers/json.rb, line 89
def as_json(options = nil)
  root = include_root_in_json
  root = options[:root] if options.try(:key?, :root)
  if root
    root = self.class.model_name.element if root == true
    { root => serializable_hash(options) }
  else
    serializable_hash(options)
  end
end

Therefore it’s serializable_hash that needs to be hooked into to cause id to appear in the nested records.

The patch to #serializable_hash

Here’s one way to hook into serializable_hash:

class ApplicationModel
  class << self
    def inherited(base)
      base.class_eval do
        include Mongoid::Document

        # juicy bits
        serializable_hash_method = instance_method(:serializable_hash)
        define_method :serializable_hash do |options=nil|
          self.attributes["id"] = self.persisted? ? self._id : nil
          serializable_hash_method.bind(self).call(options)
        end

      end
    end
  end
end

You can add the juicy bits directly into your individual models or, far better, into a base class your models inherit from (as illustrated here).

(It’s unwise to patch out serializable_hash globally as it is likely used in other parts of your application. Doing it like this makes sure that the behavior is modified only the context of your models.)

Here’s the result when running the aforementioned snippet with this patch:

# returns the expected id attributes
{ id: BSON::ObjectId('ABC123'),
  comments: [{ id: BSON::ObjectId('ABC123') }] } # the comment has an id attribute!
Question or comment?