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.