lib/happymapper.rb in nokogiri-happymapper-0.3.6 vs lib/happymapper.rb in nokogiri-happymapper-0.5.1

- old
+ new

@@ -16,153 +16,603 @@ base.extend ClassMethods end module ClassMethods + + # + # The xml has the following attributes defined. + # + # @example + # + # "<country code='de'>Germany</country>" + # + # # definition of the 'code' attribute within the class + # attribute :code, String + # + # @param [Symbol] name the name of the accessor that is created + # @param [String,Class] type the class name of the name of the class whcih + # the object will be converted upon parsing + # @param [Hash] options additional parameters to send to the relationship + # def attribute(name, type, options={}) attribute = Attribute.new(name, type, options) @attributes[to_s] ||= [] @attributes[to_s] << attribute attr_accessor attribute.method_name.intern end - + + # + # The elements defined through {#attribute}. + # + # @return [Array<Attribute>] a list of the attributes defined for this class; + # an empty array is returned when there have been no attributes defined. + # def attributes @attributes[to_s] || [] end + # + # Register a namespace that is used to persist the object namespace back to + # XML. + # + # @example + # + # register_namespace 'prefix', 'http://www.unicornland.com/prefix' + # + # # the output will contain the namespace defined + # + # "<outputXML xmlns:prefix="http://www.unicornland.com/prefix"> + # ... + # </outputXML>" + # + # @param [String] namespace the xml prefix + # @param [String] ns url for the xml namespace + # def register_namespace(namespace, ns) @registered_namespaces.merge!({namespace => ns}) end - + + # + # An element defined in the XML that is parsed. + # + # @example + # + # "<address location='home'> + # <city>Oldenburg</city> + # </address>" + # + # # definition of the 'city' element within the class + # + # element :city, String + # + # @param [Symbol] name the name of the accessor that is created + # @param [String,Class] type the class name of the name of the class whcih + # the object will be converted upon parsing + # @param [Hash] options additional parameters to send to the relationship + # def element(name, type, options={}) element = Element.new(name, type, options) @elements[to_s] ||= [] @elements[to_s] << element attr_accessor element.method_name.intern end + # + # The elements defined through {#element}, {#has_one}, and {#has_many}. + # + # @return [Array<Element>] a list of the elements contained defined for this + # class; an empty array is returned when there have been no elements + # defined. + # def elements @elements[to_s] || [] end - - def text_node(name, type, options={}) - @text_node = TextNode.new(name, type, options) - attr_accessor @text_node.method_name.intern + + # + # The value stored in the text node of the current element. + # + # @example + # + # "<firstName>Michael Jackson</firstName>" + # + # # definition of the 'firstName' text node within the class + # + # content :first_name, String + # + # @param [Symbol] name the name of the accessor that is created + # @param [String,Class] type the class name of the name of the class whcih + # the object will be converted upon parsing + # @param [Hash] options additional parameters to send to the relationship + # + def content(name, type, options={}) + @content = TextNode.new(name, type, options) + attr_accessor @content.method_name.intern end + # + # Sets the object to have xml content, this will assign the XML contents + # that are parsed to the attribute accessor xml_content. The object will + # respond to the method #xml_content and will return the XML data that + # it has parsed. + # def has_xml_content attr_accessor :xml_content end + # + # The object has one of these elements in the XML. If there are multiple, + # the last one will be set to this value. + # + # @param [Symbol] name the name of the accessor that is created + # @param [String,Class] type the class name of the name of the class whcih + # the object will be converted upon parsing + # @param [Hash] options additional parameters to send to the relationship + # + # @see #element + # def has_one(name, type, options={}) element name, type, {:single => true}.merge(options) end - + + # + # The object has many of these elements in the XML. + # + # @param [Symbol] name the name of accessor that is created + # @param [String,Class] type the class name or the name of the class which + # the object will be converted upon parsing. + # @param [Hash] options additional parameters to send to the relationship + # + # @see #element + # def has_many(name, type, options={}) element name, type, {:single => false}.merge(options) end - + + # # Specify a namespace if a node and all its children are all namespaced # elements. This is simpler than passing the :namespace option to each # defined element. + # + # @param [String] namespace the namespace to set as default for the class + # element. + # def namespace(namespace = nil) @namespace = namespace if namespace @namespace end + # + # @param [String] new_tag_name the name for the tag + # def tag(new_tag_name) @tag_name = new_tag_name.to_s unless new_tag_name.nil? || new_tag_name.to_s.empty? end + # + # The name of the tag + # + # @return [String] the name of the tag as a string, downcased + # def tag_name @tag_name ||= to_s.split('::')[-1].downcase end + # + # @param [Nokogiri::XML::Node,Nokogiri:XML::Document,String] xml the XML + # contents to convert into Object. + # @param [Hash] options additional information for parsing. :single => true + # if requesting a single object, otherwise it defaults to retuning an + # array of multiple items. :xpath information where to start the parsing + # :namespace is the namespace to use for additional information. + # def parse(xml, options = {}) - # locally scoped copy of namespace for this parse run + + # create a local copy of the objects namespace value for this parse execution namespace = @namespace - - if xml.is_a?(Nokogiri::XML::Node) + + # If the XML specified is an Node then we have what we need. + if xml.is_a?(Nokogiri::XML::Node) && !xml.is_a?(Nokogiri::XML::Document) node = xml else + + # If xml is an XML document select the root node of the document if xml.is_a?(Nokogiri::XML::Document) node = xml.root else - xml = Nokogiri::XML(xml) + + # Attempt to parse the xml value with Nokogiri XML as a document + # and select the root element + + xml = Nokogiri::XML(xml, nil, nil, Nokogiri::XML::ParseOptions::STRICT) node = xml.root end + # if the node name is equal to the tag name then the we are parsing the + # root element and that is important to record so that we can apply + # the correct xpath on the elements of this document. + root = node.name == tag_name end - # This is the entry point into the parsing pipeline, so the default - # namespace prefix registered here will propagate down - namespaces = options[:namespaces] - namespaces ||= {} - namespaces = namespaces.merge(xml.collect_namespaces) if xml.respond_to?(:collect_namespaces) - namespaces = namespaces.merge(@registered_namespaces) + # if any namespaces have been provied then we should capture those and then + # merge them with any namespaces found on the xml node and merge all that + # with any namespaces that have been registered on the object + + namespaces = options[:namespaces] || {} + namespaces = namespaces.merge(xml.collect_namespaces) if xml.respond_to?(:collect_namespaces) + namespaces = namespaces.merge(@registered_namespaces) + + # if a namespace has been provided then set the current namespace to it + # or set the default namespace to the one defined under 'xmlns' + # or set the default namespace to the namespace that matches 'happymapper's - if namespaces.has_key?("xmlns") + if options[:namespace] + namespace = options[:namespace] + elsif namespaces.has_key?("xmlns") namespace ||= DEFAULT_NS namespaces[namespace] = namespaces.delete("xmlns") elsif namespaces.has_key?(DEFAULT_NS) namespace ||= DEFAULT_NS end - + + # from the options grab any nodes present and if none are present then + # perform the following to find the nodes for the given class + nodes = options.fetch(:nodes) do + + # when at the root use the xpath '/' otherwise use a more gready './/' + # unless an xpath has been specified, which should overwrite default + # and finally attach the current namespace if one has been defined + # + xpath = (root ? '/' : './/') xpath = options[:xpath].to_s.sub(/([^\/])$/, '\1/') if options[:xpath] xpath += "#{namespace}:" if namespace - #puts "parse: #{xpath}" nodes = [] # when finding nodes, do it in this order: # 1. specified tag # 2. name of element # 3. tag_name (derived from class name by default) + + [options[:tag], options[:name], tag_name].compact.each do |xpath_ext| - nodes = node.xpath(xpath + xpath_ext.to_s, namespaces) + begin + nodes = node.xpath(xpath + xpath_ext.to_s, namespaces) + rescue + break + end break if nodes && !nodes.empty? end nodes end - collection = nodes.collect do |n| - obj = new + # Nothing matching found, we can go ahead and return + return ( ( options[:single] || root ) ? nil : [] ) if nodes.size == 0 - attributes.each do |attr| - obj.send("#{attr.method_name}=", - attr.from_xml_node(n, namespace, namespaces)) - end + # If the :limit option has been specified then we are going to slice + # our node results by that amount to allow us the ability to deal with + # a large result set of data. - elements.each do |elem| - obj.send("#{elem.method_name}=", - elem.from_xml_node(n, namespace, namespaces)) - end + limit = options[:in_groups_of] || nodes.size + + # If the limit of 0 has been specified then the user obviously wants + # none of the nodes that we are serving within this batch of nodes. + + return [] if limit == 0 - obj.send("#{@text_node.method_name}=", - @text_node.from_xml_node(n, namespace, namespaces)) if @text_node + collection = [] + + nodes.each_slice(limit) do |slice| + + part = slice.map do |n| + obj = new - if obj.respond_to?('xml_content=') - n = n.children if n.respond_to?(:children) - obj.xml_content = n.to_xml + attributes.each do |attr| + obj.send("#{attr.method_name}=",attr.from_xml_node(n, namespace, namespaces)) + end + + elements.each do |elem| + obj.send("#{elem.method_name}=",elem.from_xml_node(n, namespace, namespaces)) + end + + if @content + obj.send("#{@content.method_name}=",@content.from_xml_node(n, namespace, namespaces)) + end + + # If the HappyMapper class has the method #xml_value=, + # attr_writer :xml_value, or attr_accessor :xml_value then we want to + # assign the current xml that we just parsed to the xml_value + + if obj.respond_to?('xml_value=') + n.namespaces.each {|name,path| n[name] = path } + obj.xml_value = n.to_xml + end + + # If the HappyMapper class has the method #xml_content=, + # attr_write :xml_content, or attr_accessor :xml_content then we want to + # assign the child xml that we just parsed to the xml_content + + if obj.respond_to?('xml_content=') + n = n.children if n.respond_to?(:children) + obj.xml_content = n.to_xml + end + + # collect the object that we have created + + obj end + + # If a block has been provided and the user has requested that the objects + # be handled in groups then we should yield the slice of the objects to them + # otherwise continue to lump them together - obj + if block_given? and options[:in_groups_of] + yield part + else + collection += part + end + end # per http://libxml.rubyforge.org/rdoc/classes/LibXML/XML/Document.html#M000354 nodes = nil - if options[:single] || root + # If the :single option has been specified or we are at the root element + # then we are going to return the first item in the collection. Otherwise + # the return response is going to be an entire array of items. + + if options[:single] or root collection.first else collection end end + end + + # + # Create an xml representation of the specified class based on defined + # HappyMapper elements and attributes. The method is defined in a way + # that it can be called recursively by classes that are also HappyMapper + # classes, allowg for the composition of classes. + # + # @param [Nokogiri::XML::Builder] builder an instance of the XML builder which + # is being used when called recursively. + # @param [String] default_namespace the name of the namespace which is the + # default for the xml being produced; this is specified by the element + # declaration when calling #to_xml recursively. + # + # @return [String,Nokogiri::XML::Builder] return XML representation of the + # HappyMapper object; when called recursively this is going to return + # and Nokogiri::XML::Builder object. + # + def to_xml(builder = nil,default_namespace = nil) + + # + # If to_xml has been called without a passed in builder instance that + # means we are going to return xml output. When it has been called with + # a builder instance that means we most likely being called recursively + # and will return the end product as a builder instance. + # + unless builder + write_out_to_xml = true + builder = Nokogiri::XML::Builder.new + end + + # + # Find the attributes for the class and collect them into an array + # that will be placed into a Hash structure + # + attributes = self.class.attributes.collect do |attribute| + + # + # If an attribute is marked as read_only then we want to ignore the attribute + # when it comes to saving the xml document; so we wiill not go into any of + # the below process + # + unless attribute.options[:read_only] + + value = send(attribute.method_name) + + # + # If the attribute defines an on_save lambda/proc or value that maps to + # a method that the class has defined, then call it with the value as a + # parameter. + # + if on_save_action = attribute.options[:on_save] + if on_save_action.is_a?(Proc) + value = on_save_action.call(value) + elsif respond_to?(on_save_action) + value = send(on_save_action,value) + end + end + + # + # Attributes that have a nil value should be ignored unless they explicitly + # state that they should be expressed in the output. + # + if value || attribute.options[:state_when_nil] + attribute_namespace = attribute.options[:namespace] || default_namespace + [ "#{attribute_namespace ? "#{attribute_namespace}:" : ""}#{attribute.tag}", value ] + else + [] + end + + else + [] + end + + end.flatten + + attributes = Hash[ *attributes ] + + # + # Create a tag in the builder that matches the class's tag name and append + # any attributes to the element that were defined above. + # + builder.send("#{self.class.tag_name}_",attributes) do |xml| + + # + # Add all the registered namespaces to the root element. + # When this is called recurisvely by composed classes the namespaces + # are still added to the root element + # + # However, we do not want to add the namespace if the namespace is 'xmlns' + # which means that it is the default namesapce of the code. + # + if self.class.instance_variable_get('@registered_namespaces') && builder.doc.root + self.class.instance_variable_get('@registered_namespaces').each_pair do |name,href| + name = nil if name == "xmlns" + builder.doc.root.add_namespace(name,href) + end + end + + # + # If the object we are persisting has a namespace declaration we will want + # to use that namespace or we will use the default namespace. + # When neither are specifed we are simply using whatever is default to the + # builder + # + if self.class.respond_to?(:namespace) && self.class.namespace + xml.parent.namespace = builder.doc.root.namespace_definitions.find { |x| x.prefix == self.class.namespace } + elsif default_namespace + xml.parent.namespace = builder.doc.root.namespace_definitions.find { |x| x.prefix == default_namespace } + end + + + # + # When a content has been defined we add the resulting value + # the output xml + # + if content = self.class.instance_variable_get('@content') + + unless content.options[:read_only] + text_accessor = content.tag || content.name + value = send(text_accessor) + + if on_save_action = content.options[:on_save] + if on_save_action.is_a?(Proc) + value = on_save_action.call(value) + elsif respond_to?(on_save_action) + value = send(on_save_action,value) + end + end + + builder.text(value) + end + + end + + # + # for every define element (i.e. has_one, has_many, element) we are + # going to persist each one + # + self.class.elements.each do |element| + + # + # If an element is marked as read only do not consider at all when + # saving to XML. + # + unless element.options[:read_only] + + tag = element.tag || element.name + + # + # The value to store is the result of the method call to the element, + # by default this is simply utilizing the attr_accessor defined. However, + # this allows for this method to be overridden + # + value = send(element.name) + + # + # If the element defines an on_save lambda/proc then we will call that + # operation on the specified value. This allows for operations to be + # performed to convert the value to a specific value to be saved to the xml. + # + if on_save_action = element.options[:on_save] + if on_save_action.is_a?(Proc) + value = on_save_action.call(value) + elsif respond_to?(on_save_action) + value = send(on_save_action,value) + end + end + + # + # Normally a nil value would be ignored, however if specified then + # an empty element will be written to the xml + # + if value.nil? && element.options[:single] && element.options[:state_when_nil] + xml.send("#{tag}_","") + end + + # + # To allow for us to treat both groups of items and singular items + # equally we wrap the value and treat it as an array. + # + if value.nil? + values = [] + elsif value.respond_to?(:to_ary) && !element.options[:single] + values = value.to_ary + else + values = [value] + end + + values.each do |item| + + if item.is_a?(HappyMapper) + + # + # Other items are convertable to xml through the xml builder + # process should have their contents retrieved and attached + # to the builder structure + # + item.to_xml(xml,element.options[:namespace]) + + elsif item + + item_namespace = element.options[:namespace] || self.class.namespace || default_namespace + + # + # When a value exists we should append the value for the tag + # + if item_namespace + xml[item_namespace].send("#{tag}_",item.to_s) + else + xml.send("#{tag}_",item.to_s) + end + + else + + # + # Normally a nil value would be ignored, however if specified then + # an empty element will be written to the xml + # + xml.send("#{tag}_","") if element.options[:state_when_nil] + + end + + end + + end + end + + end + + # Write out to XML, this value was set above, based on whether or not an XML + # builder object was passed to it as a parameter. When there was no parameter + # we assume we are at the root level of the #to_xml call and want the actual + # xml generated from the object. If an XML builder instance was specified + # then we assume that has been called recursively to generate a larger + # XML document. + write_out_to_xml ? builder.to_xml : builder + + end + + end require 'happymapper/item' require 'happymapper/attribute' require 'happymapper/element'