require 'nokogiri' module Sequel module Plugins # The xml_serializer plugin handles serializing entire Sequel::Model # objects to XML, and deserializing XML into a single Sequel::Model # object or an array of Sequel::Model objects. It requires the # nokogiri library. # # Basic Example: # # album = Album[1] # puts album.to_xml # # Output: # # # # # # 1 # # RF # # 2 # # # # You can provide options to control the XML output: # # puts album.to_xml(:only=>:name) # puts album.to_xml(:except=>[:id, :artist_id]) # # Output: # # # # # # RF # # # # album.to_xml(:include=>:artist) # # Output: # # # # # # 1 # # RF # # 2 # # # # 2 # # YJM # # # # # # You can use a hash value with :include to pass options # to associations: # # album.to_xml(:include=>{:artist=>{:only=>:name}}) # # Output: # # # # # # 1 # # RF # # 2 # # # # YJM # # # # # # +to_xml+ also exists as a class and dataset method, both # of which return all objects in the dataset: # # Album.to_xml # Album.filter(:artist_id=>1).to_xml(:include=>:tags) # # If you have an existing array of model instances you want to convert to # XML, you can call the class to_xml method with the :array option: # # Album.to_xml(:array=>[Album[1], Album[2]]) # # In addition to creating XML, this plugin also enables Sequel::Model # classes to create instances directly from XML using the from_xml class # method: # # xml = album.to_xml # album = Album.from_xml(xml) # # The array_from_xml class method exists to parse arrays of model instances # from xml: # # xml = Album.filter(:artist_id=>1).to_xml # albums = Album.array_from_xml(xml) # # These does not necessarily round trip, since doing so would let users # create model objects with arbitrary values. By default, from_xml will # call set using values from the tags in the xml. 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_xml(album.to_xml, :fields=>%w'id name') # # If you want to update an existing instance, you can use the from_xml # instance method: # # album.from_xml(xml) # # Both of these allow creation of cached associated objects, if you provide # the :associations option: # # album.from_xml(xml, :associations=>:artist) # # You can even provide options when setting up the associated objects: # # album.from_xml(xml, :associations=>{:artist=>{:fields=>%w'id name', :associations=>:tags}}) # # Usage: # # # Add XML output capability to all model subclass instances (called before loading subclasses) # Sequel::Model.plugin :xml_serializer # # # Add XML output capability to Album class instances # Album.plugin :xml_serializer module XmlSerializer module ClassMethods # Proc that camelizes the input string, used for the :camelize option CAMELIZE = proc{|s| s.camelize} # Proc that dasherizes the input string, used for the :dasherize option DASHERIZE = proc{|s| s.dasherize} # Proc that returns the input string as is, used if # no :name_proc, :dasherize, or :camelize option is used. IDENTITY = proc{|s| s} # Proc that underscores the input string, used for the :underscore option UNDERSCORE = proc{|s| s.underscore} # Return an array of instances of this class based on # the provided XML. def array_from_xml(xml, opts=OPTS) node = Nokogiri::XML(xml).children.first unless node raise Error, "Malformed XML used" end node.children.reject{|c| c.is_a?(Nokogiri::XML::Text)}.map{|c| from_xml_node(c, opts)} end # Return an instance of this class based on the provided # XML. def from_xml(xml, opts=OPTS) from_xml_node(Nokogiri::XML(xml).children.first, opts) end # Return an instance of this class based on the given # XML node, which should be Nokogiri::XML::Node instance. # This should probably not be used directly by user code. def from_xml_node(parent, opts=OPTS) new.from_xml_node(parent, opts) end # Return an appropriate Nokogiri::XML::Builder instance # used to create the XML. This should probably not be used # directly by user code. def xml_builder(opts=OPTS) if opts[:builder] opts[:builder] else builder_opts = if opts[:builder_opts] opts[:builder_opts] else {} end builder_opts[:encoding] = opts[:encoding] if opts.has_key?(:encoding) Nokogiri::XML::Builder.new(builder_opts) end end # Return a proc (or any other object that responds to []), # used for formatting XML tag names when serializing to XML. # This should probably not be used directly by user code. def xml_deserialize_name_proc(opts=OPTS) if opts[:name_proc] opts[:name_proc] elsif opts[:underscore] UNDERSCORE else IDENTITY end end # Return a proc (or any other object that responds to []), # used for formatting XML tag names when serializing to XML. # This should probably not be used directly by user code. def xml_serialize_name_proc(opts=OPTS) pr = if opts[:name_proc] opts[:name_proc] elsif opts[:dasherize] DASHERIZE elsif opts[:camelize] CAMELIZE else IDENTITY end proc{|s| "#{pr[s]}_"} end Plugins.def_dataset_methods(self, :to_xml) end module InstanceMethods # Update the contents of this instance based on the given XML. # Accepts the following options: # # :name_proc :: Proc or Hash that accepts a string and returns # a string, used to convert tag names to column or # association names. # :underscore :: Sets the :name_proc option to one that calls +underscore+ # on the input string. Requires that you load the inflector # extension or another library that adds String#underscore. def from_xml(xml, opts=OPTS) from_xml_node(Nokogiri::XML(xml).children.first, opts) end # Update the contents of this instance based on the given # XML node, which should be a Nokogiri::XML::Node instance. # By default, just calls set with a hash created from the content of the node. # # 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_xml_node(parent, opts=OPTS) unless parent raise Error, "Malformed XML used" end if !parent.children.empty? && parent.children.all?{|node| node.is_a?(Nokogiri::XML::Text)} raise Error, "XML consisting of just text nodes used" end 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_hash = {} assocs.each{|k,v| assocs_hash[k.to_s] = v} assocs_present = [] end hash = {} populate_associations = {} name_proc = model.xml_deserialize_name_proc(opts) parent.children.each do |node| next if node.is_a?(Nokogiri::XML::Text) k = name_proc[node.name] if assocs_hash && assocs_hash[k] assocs_present << [k.to_sym, node] else hash[k] = node.key?('nil') ? nil : node.children.first.to_s end end if assocs_present assocs_present.each do |assoc, node| assoc_opts = assocs[assoc] unless r = model.association_reflection(assoc) raise Error, "Association #{assoc} is not defined for #{model}" end populate_associations[assoc] = if r.returns_array? node.children.reject{|c| c.is_a?(Nokogiri::XML::Text)}.map{|c| r.associated_class.from_xml_node(c, assoc_opts)} else r.associated_class.from_xml_node(node, assoc_opts) 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 # Return a string in XML format. If a block is given, yields the XML # builder object so you can add additional XML tags. # Accepts the following options: # # :builder :: The builder instance used to build the XML, # which should be an instance of Nokogiri::XML::Node. This # is necessary if you are serializing entire object graphs, # like associated objects. # :builder_opts :: Options to pass to the Nokogiri::XML::Builder # initializer, if the :builder option is not provided. # :camelize:: Sets the :name_proc option to one that calls +camelize+ # on the input string. Requires that you load the inflector # extension or another library that adds String#camelize. # :dasherize :: Sets the :name_proc option to one that calls +dasherize+ # on the input string. Requires that you load the inflector # extension or another library that adds String#dasherize. # :encoding :: The encoding to use for the XML output, passed # to the Nokogiri::XML::Builder initializer. # :except :: Symbol or Array of Symbols of columns not # to include in the XML 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 XML output. Using a nested # hash, you can pass options to associations # to affect the XML used for associated objects. # :name_proc :: Proc or Hash that accepts a string and returns # a string, used to format tag names. # :only :: Symbol or Array of Symbols of columns to only # include in the JSON output, ignoring all other # columns. # :root_name :: The base name to use for the XML tag that # contains the data for this instance. This will # be the name of the root node if you are only serializing # a single object, but not if you are serializing # an array of objects using Model.to_xml or Dataset#to_xml. # :types :: Set to true to include type information for # all of the columns, pulled from the db_schema. def to_xml(opts=OPTS) vals = values types = opts[:types] inc = opts[:include] cols = if only = opts[:only] Array(only) else vals.keys - Array(opts[:except]) end name_proc = model.xml_serialize_name_proc(opts) x = model.xml_builder(opts) x.send(name_proc[opts.fetch(:root_name, model.send(:underscore, model.name).gsub('/', '__')).to_s]) do |x1| cols.each do |c| attrs = {} if types attrs[:type] = db_schema.fetch(c, {})[:type] end v = vals[c] if v.nil? attrs[:nil] = '' end x1.send(name_proc[c.to_s], v, attrs) end if inc.is_a?(Hash) inc.each{|k, v| to_xml_include(x1, k, v)} else Array(inc).each{|i| to_xml_include(x1, i)} end yield x1 if block_given? end x.to_xml end private # Handle associated objects and virtual attributes when creating # the xml. def to_xml_include(node, i, opts=OPTS) name_proc = model.xml_serialize_name_proc(opts) objs = send(i) if objs.is_a?(Array) && objs.all?{|x| x.is_a?(Sequel::Model)} node.send(name_proc[i.to_s]) do |x2| objs.each{|obj| obj.to_xml(opts.merge(:builder=>x2))} end elsif objs.is_a?(Sequel::Model) objs.to_xml(opts.merge(:builder=>node, :root_name=>i)) else node.send(name_proc[i.to_s], objs) end end end module DatasetMethods # Return an XML string containing all model objects specified with # this dataset. Takes all of the options available to Model#to_xml, # as well as the :array_root_name option for specifying the name of # the root node that contains the nodes for all of the instances. def to_xml(opts=OPTS) raise(Sequel::Error, "Dataset#to_xml") unless row_proc x = model.xml_builder(opts) name_proc = model.xml_serialize_name_proc(opts) array = if opts[:array] opts = opts.dup opts.delete(:array) else all end x.send(name_proc[opts.fetch(:array_root_name, model.send(:pluralize, model.send(:underscore, model.name))).to_s]) do |x1| array.each do |obj| obj.to_xml(opts.merge(:builder=>x1)) end end x.to_xml end end end end end