# frozen-string-literal: true 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 # # => '{"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) # # => '{"id":1,"name":"RF","artist_id":2, # # "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}}) # # => '{"id":1,"name":"RF","artist_id":2, # # "artist":{"name":"YJM"}}' # # You can specify a name for a given association by using an aliased # expression as the key in the :include hash # # album.to_json(include: {Sequel.as(:artist, :singer)=>{only: :name}}) # # => '{"id":1,"name":"RF","artist_id":2, # # "singer":{"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.where(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]]) # # All to_json methods take blocks, and if a block is given, it will yield # the array or hash before serialization, and will serialize the value # the block returns. This allows you to customize the resulting JSON format # on a per-call basis. # # 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.where(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: # # module ActiveSupportBrokenJSONFix # def to_json(options = {}) # JSON.generate(self) # end # end # Array.send(:prepend, ActiveSupportBrokenJSONFix) # Hash.send(:prepend, ActiveSupportBrokenJSONFix) # # 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=OPTS) model.instance_exec do @json_serializer_opts = (@json_serializer_opts || OPTS).merge(opts) end end # SEQUEL6: Remove # :nocov: class Literal def initialize(json) @json = json end def to_json(*a) @json end end # :nocov: Sequel::Deprecation.deprecate_constant(self, :Literal) # Convert the given object to a JSON data structure using the given arguments. def self.object_to_json_data(obj, *args, &block) if obj.is_a?(Array) obj.map{|x| object_to_json_data(x, *args, &block)} else if obj.respond_to?(:to_json_data) obj.to_json_data(*args, &block) else begin Sequel.parse_json(Sequel.object_to_json(obj, *args, &block)) # :nocov: rescue Sequel.json_parser_error_class # Support for old Ruby code that only supports parsing JSON object/array Sequel.parse_json(Sequel.object_to_json([obj], *args, &block))[0] # :nocov: end end end end module ClassMethods # The default opts to use when serializing model objects to JSON. attr_reader :json_serializer_opts # Freeze json serializier opts when freezing model class def freeze @json_serializer_opts.freeze.each_value do |v| v.freeze if v.is_a?(Array) || v.is_a?(Hash) end super end # 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=>OPTS} when Array assocs_tmp = {} assocs.each{|v| assocs_tmp[v] = OPTS} 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 = (@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 = opts.merge(@json_serializer_opts) if @json_serializer_opts if (arg_opts = a.first).is_a?(Hash) opts = 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| if k.is_a?(Sequel::SQL::AliasedExpression) key_name = k.alias.to_s k = k.expression else key_name = k.to_s end v = v.empty? ? [] : [v] h[key_name] = JsonSerializer.object_to_json_data(public_send(k), *v) end else Array(inc).each do |c| if c.is_a?(Sequel::SQL::AliasedExpression) key_name = c.alias.to_s c = c.expression else key_name = c.to_s end h[key_name] = JsonSerializer.object_to_json_data(public_send(c)) end 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 h = yield h if block_given? Sequel.object_to_json(h, *a) end # Convert the receiver to a JSON data structure using the given arguments. def to_json_data(*args, &block) if block to_json(*args){|x| return block.call(x)} else to_json(*args){|x| return x} end end end module DatasetMethods # Store default options used when calling to_json on this dataset. # These options take precedence over the class level options, # and can be overridden by passing options directly to to_json. def json_serializer_opts(opts=OPTS) clone(:json_serializer_opts=>opts) end # 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. # :instance_block :: A block to pass to #to_json for each # value in the dataset (or :array option). # :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) opts = model.json_serializer_opts if ds_opts = @opts[:json_serializer_opts] opts = opts.merge(ds_opts) end if (arg = a.first).is_a?(Hash) opts = opts.merge(arg) a = [] 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 || @opts[:eager_graph] array = if opts[:array] opts = opts.dup opts.delete(:array) else all end JsonSerializer.object_to_json_data(array, opts, &opts[:instance_block]) else all end res = {collection_root => res} if collection_root res = yield res if block_given? Sequel.object_to_json(res, *a) end end end end end