require 'json' module Sequel module Plugins # The json_serializer plugin handles serializing entire Sequel::Model # objects to JSON, as well as support for deserializing JSON directly # into Sequel::Model objects. It requires the json library, and can # work with either the pure ruby version or the C extension. # # Basic Example: # # album = Album[1] # album.to_json # # => '{"json_class"=>"Album","id"=>1,"name"=>"RF","artist_id"=>2}' # # In addition, you can provide options to control the JSON output: # # album.to_json(:only=>:name) # album.to_json(:except=>[:id, :artist_id]) # # => '{"json_class"="Album","name"=>"RF"}' # # album.to_json(:include=>:artist) # # => '{"json_class":"Album","id":1,"name":"RF","artist_id":2, # # "artist":{"json_class":"Artist","id":2,"name":"YJM"}}' # # You can use a hash value with :include to pass options # to associations: # # album.to_json(:include=>{:artist=>{:only=>:name}}) # # => '{"json_class":"Album","id":1,"name":"RF","artist_id":2, # # "artist":{"json_class":"Artist","name":"YJM"}}' # # You can specify the :root option to nest the JSON under the # name of the model: # # album.to_json(:root => true) # # => '{"album":{"id":1,"name":"RF","artist_id":2}}' # # You can specify JSON serialization options to use later: # # album.json_serializer_opts(:root => true) # [album].to_json # # => '[{"album":{"id":1,"name":"RF","artist_id":2}}]' # # Additionally, +to_json+ also exists as a class and dataset method, both # of which return all objects in the dataset: # # Album.to_json # Album.filter(:artist_id=>1).to_json(:include=>:tags) # # If you have an existing array of model instances you want to convert to # JSON, you can call the class to_json method with the :array option: # # Album.to_json(:array=>[Album[1], Album[2]]) # # In addition to creating JSON, this plugin also enables Sequel::Model # classes to create instances directly from JSON using the from_json class # method: # # json = album.to_json # album = Album.from_json(json) # # The array_from_json class method exists to parse arrays of model instances # from json: # # json = Album.filter(:artist_id=>1).to_json # albums = Album.array_from_json(json) # # These does not necessarily round trip, since doing so would let users # create model objects with arbitrary values. By default, from_json will # call set with the values in the hash. If you want to specify the allowed # fields, you can use the :fields option, which will call set_fields with # the given fields: # # Album.from_json(album.to_json, :fields=>%w'id name') # # If you want to update an existing instance, you can use the from_json # instance method: # # album.from_json(json) # # Both of these allow creation of cached associated objects, if you provide # the :associations option: # # album.from_json(json, :associations=>:artist) # # You can even provide options when setting up the associated objects: # # album.from_json(json, :associations=>{:artist=>{:fields=>%w'id name', :associations=>:tags}}) # # Note that active_support/json makes incompatible changes to the to_json API, # and breaks some aspects of the json_serializer plugin. You can undo the damage # done by active_support/json by doing: # # class Array # def to_json(options = {}) # JSON.generate(self) # end # end # # class Hash # def to_json(options = {}) # JSON.generate(self) # end # end # # Note that this will probably cause active_support/json to no longer work # correctly in some cases. # # Usage: # # # Add JSON output capability to all model subclass instances (called before loading subclasses) # Sequel::Model.plugin :json_serializer # # # Add JSON output capability to Album class instances # Album.plugin :json_serializer module JsonSerializer # Set up the column readers to do deserialization and the column writers # to save the value in deserialized_values. def self.configure(model, opts={}) model.instance_eval do @json_serializer_opts = (@json_serializer_opts || {}).merge(opts) end end # Helper class used for making sure that cascading options # for model associations works correctly. Cascaded options # work by creating instances of this class, which take a # literal JSON string and have +to_json+ return it. class Literal # Store the literal JSON to use def initialize(json) @json = json end # Return the literal JSON to use def to_json(*a) @json end end module ClassMethods # The default opts to use when serializing model objects to JSON. attr_reader :json_serializer_opts # Attempt to parse a single instance from the given JSON string, # with options passed to InstanceMethods#from_json_node. def from_json(json, opts=OPTS) v = Sequel.parse_json(json) case v when self v when Hash new.from_json_node(v, opts) else raise Error, "parsed json doesn't return a hash or instance of #{self}" end end # Attempt to parse an array of instances from the given JSON string, # with options passed to InstanceMethods#from_json_node. def array_from_json(json, opts=OPTS) v = Sequel.parse_json(json) if v.is_a?(Array) raise(Error, 'parsed json returned an array containing non-hashes') unless v.all?{|ve| ve.is_a?(Hash) || ve.is_a?(self)} v.map{|ve| ve.is_a?(self) ? ve : new.from_json_node(ve, opts)} else raise(Error, 'parsed json did not return an array') end end Plugins.inherited_instance_variables(self, :@json_serializer_opts=>lambda do |json_serializer_opts| opts = {} json_serializer_opts.each{|k, v| opts[k] = (v.is_a?(Array) || v.is_a?(Hash)) ? v.dup : v} opts end) Plugins.def_dataset_methods(self, :to_json) end module InstanceMethods # Parse the provided JSON, which should return a hash, # and process the hash with from_json_node. def from_json(json, opts=OPTS) from_json_node(Sequel.parse_json(json), opts) end # Using the provided hash, update the instance with data contained in the hash. By default, just # calls set with the hash values. # # Options: # :associations :: Indicates that the associations cache should be updated by creating # a new associated object using data from the hash. Should be a Symbol # for a single association, an array of symbols for multiple associations, # or a hash with symbol keys and dependent association option hash values. # :fields :: Changes the behavior to call set_fields using the provided fields, instead of calling set. def from_json_node(hash, opts=OPTS) unless hash.is_a?(Hash) raise Error, "parsed json doesn't return a hash" end populate_associations = {} if assocs = opts[:associations] assocs = case assocs when Symbol {assocs=>{}} when Array assocs_tmp = {} assocs.each{|v| assocs_tmp[v] = {}} assocs_tmp when Hash assocs else raise Error, ":associations should be Symbol, Array, or Hash if present" end assocs.each do |assoc, assoc_opts| if assoc_values = hash.delete(assoc.to_s) unless r = model.association_reflection(assoc) raise Error, "Association #{assoc} is not defined for #{model}" end populate_associations[assoc] = if r.returns_array? raise Error, "Attempt to populate array association with a non-array" unless assoc_values.is_a?(Array) assoc_values.map{|v| v.is_a?(r.associated_class) ? v : r.associated_class.new.from_json_node(v, assoc_opts)} else raise Error, "Attempt to populate non-array association with an array" if assoc_values.is_a?(Array) assoc_values.is_a?(r.associated_class) ? assoc_values : r.associated_class.new.from_json_node(assoc_values, assoc_opts) end end end end if fields = opts[:fields] set_fields(hash, fields, opts) else set(hash) end populate_associations.each do |assoc, values| associations[assoc] = values end self end # Set the json serialization options that will be used by default # in future calls to +to_json+. This is designed for cases where # the model object will be used inside another data structure # which to_json is called on, and as such will not allow passing # of arguments to +to_json+. # # Example: # # obj.json_serializer_opts(:only=>:name) # [obj].to_json # => '[{"name":"..."}]' def json_serializer_opts(opts=OPTS) @json_serializer_opts = Hash[@json_serializer_opts||OPTS].merge!(opts) end # Return a string in JSON format. Accepts the following # options: # # :except :: Symbol or Array of Symbols of columns not # to include in the JSON output. # :include :: Symbol, Array of Symbols, or a Hash with # Symbol keys and Hash values specifying # associations or other non-column attributes # to include in the JSON output. Using a nested # hash, you can pass options to associations # to affect the JSON used for associated objects. # :only :: Symbol or Array of Symbols of columns to only # include in the JSON output, ignoring all other # columns. # :root :: Qualify the JSON with the name of the object. If a # string is given, use the string as the key, otherwise # use an underscored version of the model's name. def to_json(*a) opts = model.json_serializer_opts opts = Hash[opts].merge!(@json_serializer_opts) if @json_serializer_opts if (arg_opts = a.first).is_a?(Hash) opts = Hash[opts].merge!(arg_opts) a = [] end vals = values cols = if only = opts[:only] Array(only) else vals.keys - Array(opts[:except]) end h = {} cols.each{|c| h[c.to_s] = get_column_value(c)} if inc = opts[:include] if inc.is_a?(Hash) inc.each do |k, v| v = v.empty? ? [] : [v] h[k.to_s] = case objs = send(k) when Array objs.map{|obj| Literal.new(Sequel.object_to_json(obj, *v))} else Literal.new(Sequel.object_to_json(objs, *v)) end end else Array(inc).each{|c| h[c.to_s] = send(c)} end end if root = opts[:root] unless root.is_a?(String) root = model.send(:underscore, model.send(:demodulize, model.to_s)) end h = {root => h} end Sequel.object_to_json(h, *a) end end module DatasetMethods # Return a JSON string representing an array of all objects in # this dataset. Takes the same options as the instance # method, and passes them to every instance. Additionally, # respects the following options: # # :array :: An array of instances. If this is not provided, # calls #all on the receiver to get the array. # :root :: If set to :collection, wraps the collection # in a root object using the pluralized, underscored model # name as the key. If set to :instance, only wraps # the instances in a root object. If set to :both, # wraps both the collection and instances in a root # object. If set to a string, wraps the collection in # a root object using the string as the key. def to_json(*a) if opts = a.first.is_a?(Hash) opts = model.json_serializer_opts.merge(a.first) a = [] else opts = model.json_serializer_opts end case collection_root = opts[:root] when nil, false, :instance collection_root = false else opts = opts.dup unless collection_root == :both opts.delete(:root) end unless collection_root.is_a?(String) collection_root = model.send(:pluralize, model.send(:underscore, model.send(:demodulize, model.to_s))) end end res = if row_proc array = if opts[:array] opts = opts.dup opts.delete(:array) else all end array.map{|obj| Literal.new(Sequel.object_to_json(obj, opts))} else all end if collection_root Sequel.object_to_json({collection_root => res}, *a) else Sequel.object_to_json(res, *a) end end end end end end