module Sequel
tsk_require 'json'
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}}'
#
# In addition to creating JSON, this plugin also enables Sequel::Model
# objects to be automatically created when JSON is parsed:
#
# json = album.to_json
# album = JSON.parse(json)
#
# In addition, you can update existing model objects directly from JSON
# using +from_json+:
#
# album.from_json(json)
#
# This works by parsing the JSON (which should return a hash), and then
# calling +set+ with the returned hash.
#
# 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]])
#
# 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
# Create a new model object from the hash provided by parsing
# JSON. Handles column values (stored in +values+), associations
# (stored in +associations+), and other values (by calling a
# setter method). If an entry in the hash is not a column or
# an association, and no setter method exists, raises an Error.
def json_create(hash)
obj = new
cols = columns.map{|x| x.to_s}
assocs = associations.map{|x| x.to_s}
meths = obj.send(:setter_methods, nil, nil)
hash.delete(JSON.create_id)
hash.each do |k, v|
if assocs.include?(k)
obj.associations[k.to_sym] = v
elsif meths.include?("#{k}=")
obj.send("#{k}=", v)
elsif cols.include?(k)
obj.values[k.to_sym] = v
else
raise Error, "Entry in JSON hash not an association or column and no setter method exists: #{k}"
end
end
obj
end
# Call the dataset +to_json+ method.
def to_json(*a)
dataset.to_json(*a)
end
# Copy the current model object's default json options into the subclass.
def inherited(subclass)
super
opts = {}
json_serializer_opts.each{|k, v| opts[k] = (v.is_a?(Array) || v.is_a?(Hash)) ? v.dup : v}
subclass.instance_variable_set(:@json_serializer_opts, opts)
end
end
module InstanceMethods
# Parse the provided JSON, which should return a hash,
# and call +set+ with that hash.
def from_json(json, opts={})
h = JSON.parse(json)
if fields = opts[:fields]
set_fields(h, fields, opts)
else
set(h)
end
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.
# :naked :: Not to add the JSON.create_id (json_class) key to the JSON
# output hash, so when the JSON is parsed, it
# will yield a hash instead of a model object.
# :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.
# Implies :naked since the object name is explicit.
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
vals = values
cols = if only = opts[:only]
Array(only)
else
vals.keys - Array(opts[:except])
end
h = (JSON.create_id && !opts[:naked] && !opts[:root]) ? {JSON.create_id=>model.name} : {}
cols.each{|c| h[c.to_s] = send(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(obj.to_json(*v))}
else
Literal.new(objs.to_json(*v))
end
end
else
Array(inc).each{|c| h[c.to_s] = send(c)}
end
end
h = {model.send(:underscore, model.to_s) => h} if opts[:root]
h.to_json(*a)
end
end
module DatasetMethods
# Return a JSON string representing an array of all objects in
# this dataset. Takes the same options as the 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, only wraps the collection
# in a root object. 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. Unfortunately, for backwards compatibility,
# if this option is true and doesn't match one of those
# symbols, it defaults to both. That may change in a
# future version, so for forwards compatibility, you
# should pick a specific symbol for your desired
# behavior.
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
collection_root = case opts[:root]
when nil, false, :instance
false
when :collection
opts = opts.dup
opts.delete(:root)
opts[:naked] = true unless opts.has_key?(:naked)
true
else
true
end
res = if row_proc
array = if opts[:array]
opts = opts.dup
opts.delete(:array)
else
all
end
array.map{|obj| Literal.new(obj.to_json(opts))}
else
all
end
if collection_root
{model.send(:pluralize, model.send(:underscore, model.to_s)) => res}.to_json(*a)
else
res.to_json(*a)
end
end
end
end
end
end