Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using custom to_json method in nested objects

I have a data structure that uses the Set class from the Ruby Standard Library. I'd like to be able to serialize my data structure to a JSON string.

By default, Set serializes as an Array:

>> s = Set.new [1,2,3]
>> s.to_json
=> "[1,2,3]"

Which is fine until you try to deserialize it.

So I defined a custom to_json method:

class Set
  def to_json(*a)
    {
      "json_class" => self.class.name,
      "data" => {
        "elements" => self.to_a
      }
    }.to_json(*a)
  end

  def self.json_create(o)
    new o["data"]["elements"]
  end
end

Which works great:

>> s = Set.new [1,2,3]
>> s.to_json
=> "{\"data\":{\"elements\":[1,2,3]},\"json_class\":\"Set\"}"

Until I put the Set into a Hash or something:

>> a = { 'set' => s }
>> a.to_json
=> "{\"set\":[1,2,3]}"

Any idea why my custom to_json doesn't get called when the Set is nested inside another object?

like image 955
emh Avatar asked Jul 29 '11 22:07

emh


2 Answers

Here is my approach to getting to_json method for custom classes which most probably wouldn't contain to_a method (it has been removed from Object class implementation lately)

There is a little magic here using self.included in a module. Here is a very nice article from 2006 about module having both instance and class methods http://blog.jayfields.com/2006/12/ruby-instance-and-class-methods-from.html

The module is designed to be included in any class to provide seamless to_json functionality. It intercepts attr_accessor method rather than uses its own in order to require minimal changes for existing classes.

module JSONable
  module ClassMethods
    attr_accessor :attributes

    def attr_accessor *attrs
      self.attributes = Array attrs
      super
    end
  end

  def self.included(base)
    base.extend(ClassMethods)
  end

  def as_json options = {}
    serialized = Hash.new
    self.class.attributes.each do |attribute|
      serialized[attribute] = self.public_send attribute
    end
    serialized
  end

  def to_json *a
    as_json.to_json *a
  end
end


class CustomClass
  include JSONable
  attr_accessor :b, :c 

  def initialize b: nil, c: nil
    self.b, self.c = b, c
  end
end

a = CustomClass.new(b: "q", c: 23)
puts JSON.pretty_generate a

{
  "b": "q",
  "c": 23
}
like image 149
Vadym Tyemirov Avatar answered Oct 21 '22 19:10

Vadym Tyemirov


The first chunk is for Rails 3.1 (older versions will be pretty much the same); the second chunk is for the standard non-Rails JSON. Skip to the end if tl;dr.


Your problem is that Rails does this:

[Object, Array, FalseClass, Float, Hash, Integer, NilClass, String, TrueClass].each do |klass|
  klass.class_eval <<-RUBY, __FILE__, __LINE__
    # Dumps object in JSON (JavaScript Object Notation). See www.json.org for more info.
    def to_json(options = nil)
      ActiveSupport::JSON.encode(self, options)
    end
  RUBY
end

in active_support/core_ext/object/to_json.rb. In particular, that changes Hash's to_json method into just an ActiveSupport::JSON.encode call.

Then, looking at ActiveSupport::JSON::Encoding::Encoder, we see this:

def encode(value, use_options = true)
  check_for_circular_references(value) do
    jsonified = use_options ? value.as_json(options_for(value)) : value.as_json
    jsonified.encode_json(self)
  end   
end

So all the Rails JSON encoding goes through as_json. But, you're not defining your own as_json for Set, you're just setting up to_json and getting confused when Rails ignores something that it doesn't use.

If you set up your own Set#as_json:

class Set
    def as_json(options = { })
        {
            "json_class" => self.class.name,
            "data" => { "elements" => self.to_a }
        }
    end
end

then you'll get what you're after in the Rails console and Rails in general:

> require 'set'
> s = Set.new([1,2,3])
> s.to_json
 => "{\"json_class\":\"Set\",\"data\":{\"elements\":[1,2,3]}}"
> h = { :set => s }
> h.to_json
 => "{\"set\":{\"json_class\":\"Set\",\"data\":{\"elements\":[1,2,3]}}}" 

Keep in mind that as_json is used to prepare an object for JSON serialization and then to_json produces the actual JSON string. The as_json methods generally return simple serializable data structures, such as Hash and Array, and have direct analogues in JSON; then, once you have something that is structured like JSON, to_json is used to serialize it into a linear JSON string.


When we look at the standard non-Rails JSON library, we see things like this:

def to_json(*a)
  as_json.to_json(*a)
end

monkey patched into the basic classes (Symbol, Time, Date, ...). So once again, to_json is generally implemented in terms of as_json. In this environment, we need to include the standard to_json as well as the above as_json for Set:

class Set
    def as_json(options = { })
        {
            "json_class" => self.class.name,
            "data" => { "elements" => self.to_a }
        }
    end
    def to_json(*a)
        as_json.to_json(*a)
    end
    def self.json_create(o)
        new o["data"]["elements"]
    end
end

And we include your json_create class method for the decoder. Once that's all properly set up, we get things like this in irb:

>> s = Set.new([1,2,3])
>> s.as_json
=> {"json_class"=>"Set", "data"=>{"elements"=>[1, 2, 3]}}
>> h = { :set => s }
>> h.to_json
=> "{"set":{"json_class":"Set","data":{"elements":[1,2,3]}}}"

Executive Summary: If you're in Rails, don't worry about doing anything with to_json, as_json is what you want to play with. If you're not in Rails, implement most of your logic in as_json (despite what the documentation says) and add the standard to_json implementation (def to_json(*a);as_json.to_json(*a);end) as well.

like image 36
mu is too short Avatar answered Oct 21 '22 18:10

mu is too short