Friday, June 1, 2007

Quick mapping between XML and Ruby objects

Often you'll find yourself reading an XML file and writing boilerplate code with REXML.

I was in the middle of a tool that'd generate Ruby code from an XML Schema when I started feeling the pain of working directly with REXML. From that point it was only a matter of hours to come up with this simple and elegant solution:

require 'rexml/document'

class XMLDef
  attr_accessor :text

  def self.xml_attribute(attr_name, xml_elem)
    attr_accessor attr_name

    @attributes = Hash.new unless defined?(@attributes)
    @attributes[attr_name] = xml_elem
  end
  
  def self.xml_array(array_name, xml_elem, xml_class)
    attr_reader array_name
    
    @arrays = Hash.new unless defined?(@arrays)
    @arrays[array_name] = [xml_elem, xml_class]
  end
  
  def self.xml_object(object_ref_name, xml_elem, xml_class)
    attr_reader object_ref_name

    @objects = Hash.new unless defined?(@objects)
    @objects[object_ref_name] = [xml_elem, xml_class]
  end
  
  def self.load_element(elem)
    result = new
    
    result.text = elem.text
    
    if @attributes
      @attributes.each do |k, v|
        result.send(k.to_s + '=', elem.attributes[v])
      end
    end
    
    if @arrays
      @arrays.each do |k, v|
        result.instance_variable_set('@' + k.to_s, Array.new)
        
        elem.each_element(v[0]) do |x|
          result.send(k).send('<<', v[1].load_element(x))
        end
      end
    end
    
    if @objects
      @objects.each do |k, v|
        if x = elem.elements[v[0]]
          result.instance_variable_set('@' + k.to_s, v[1].load_element(x))
        end
      end
    end
    
    return result
  end
  
  def self.load_xml(xml)
    return load_element(REXML::Document.new(xml).root)
  end
end

To create a mapping you simply inherit from XMLDef. xml_attribute, xml_array and xml_object create the accessors. load_element recursively reads the XML into the objects you define. load_xml is the method you call on the root of your mapping to read the actual XML.

For example, suppose you want a mapping for the following XML:

<planetSystem name='Solar System'>
  <dimensions diameter='100000 AU' mass='2e30 kg'/>
  <planet name='Earth' lifeFriendly='1'>
    <dimensions diameter='12745 km' mass='5.97e24 kg'/>
    <description>The planet were we live</description>
  </planet>
  <planet name='Mars' lifeFriendly='0'>
    <dimensions diameter='6805 km' mass='6.42e23 kg'/>
  </planet>
  <planet name='Jupiter'>
    <description>Biggest planet in the solar system</description>
  </planet>
</planetSystem>

You'll write:
class Description < XMLDef
end

class Dimensions < XMLDef
  xml_attribute :diameter, "diameter"
  xml_attribute :mass, "mass"
end

class Planet < XMLDef
  xml_attribute :name, "name"
  xml_attribute :life_friendly, "lifeFriendly"
  xml_object :dimensions, "dimensions", Dimensions
  xml_object :description, "description", Description
end

class PlanetSystem < XMLDef
  xml_attribute :name, "name"
  xml_array :planets, "planet", Planet
  xml_object :dimensions, "dimensions", Dimensions
end

I don't think you could write anything more obvious. The annotations make the code pretty straightforward to understand and maintain. I was coding a lot of test-cases for my XML Schema reader. The mapper rendered them completely useless.

This is how you'd read the XML above:

x = PlanetSystem.load_xml(File.open('planets.xml'))

puts x.name
puts "Diameter: " + x.dimensions.diameter                     
puts "Mass: " + x.dimensions.mass

puts ""

p = x.planets[0]
puts p.name + ': ' + p.description.text
puts "Life friendly: " + p.life_friendly
puts "Diameter: " + p.dimensions.diameter
puts "Mass: " + p.dimensions.mass

That's not hard to extend the class to save data back to XML or create a new one from scratch.

Today, after spending a few hours coding the class above, I found ROXML, which follows the same idea as mine. Some of the similarities are interesting, by the way. ROXML also has the methods xml_attribute and xml_object. And an example included in the source code references the Pickaxe book, which I did in the test-cases for XMLDef. I guess the author also had the book over the table when thinking out an example.

0 comments: