lib/happymapper.rb in nokogiri-happymapper-0.9.0 vs lib/happymapper.rb in nokogiri-happymapper-0.10.0

- old
+ new

@@ -1,12 +1,13 @@ # frozen_string_literal: true -require 'nokogiri' -require 'date' -require 'time' -require 'happymapper/version' -require 'happymapper/anonymous_mapper' +require "nokogiri" +require "date" +require "time" +require "happymapper/version" +require "happymapper/anonymous_mapper" +require "happymapper/class_methods" module HappyMapper class Boolean; end class XmlContent; end @@ -37,464 +38,15 @@ end 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[name] = 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.values - 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] name the xml prefix - # @param [String] href url for the xml namespace - # - def register_namespace(name, href) - @registered_namespaces.merge!(name => href) - 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) - attr_accessor element.method_name.intern unless @elements[name] - @elements[name] = element - 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.values - end - - # - # 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. By Default String class will be taken. - # @param [Hash] options additional parameters to send to the relationship - # - def content(name, type = String, 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 - - # - # The list of registered after_parse callbacks. - # - def after_parse_callbacks - @after_parse_callbacks ||= [] - end - - # - # Register a new after_parse callback, given as a block. - # - # @yield [object] Yields the newly-parsed object to the block after parsing. - # Sub-objects will be already populated. - def after_parse(&block) - after_parse_callbacks.push(block) - 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 if defined? @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 ||= name && name.to_s.split('::')[-1].downcase - end - - # There is an XML tag that needs to be known for parsing and should be generated - # during a to_xml. But it doesn't need to be a class and the contained elements should - # be made available on the parent class - # - # @param [String] name the name of the element that is just a place holder - # @param [Proc] blk the element definitions inside the place holder tag - # - def wrap(name, &blk) - # Get an anonymous HappyMapper that has 'name' as its tag and defined - # in '&blk'. Then save that to a class instance variable for later use - wrapper = AnonymousWrapperClassFactory.get(name, &blk) - wrapper_key = wrapper.inspect - @wrapper_anonymous_classes[wrapper_key] = wrapper - - # Create getter/setter for each element and attribute defined on the anonymous HappyMapper - # onto this class. They get/set the value by passing thru to the anonymous class. - passthrus = wrapper.attributes + wrapper.elements - passthrus.each do |item| - method_name = item.method_name - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method_name} # def property - @#{name} ||= # @wrapper ||= - wrapper_anonymous_classes['#{wrapper_key}'].new # wrapper_anonymous_classes['#<Class:0x0000555b7d0b9220>'].new - @#{name}.#{method_name} # @wrapper.property - end # end - - def #{method_name}=(value) # def property=(value) - @#{name} ||= # @wrapper ||= - wrapper_anonymous_classes['#{wrapper_key}'].new # wrapper_anonymous_classes['#<Class:0x0000555b7d0b9220>'].new - @#{name}.#{method_name} = value # @wrapper.property = value - end # end - RUBY - end - - has_one name, wrapper - end - - # The callback defined through {.with_nokogiri_config}. - # - # @return [Proc] the proc to pass to Nokogiri to setup parse options. nil if empty. - # - attr_reader :nokogiri_config_callback - - # Register a config callback according to the block Nokogori expects when - # calling Nokogiri::XML::Document.parse(). - # - # See http://nokogiri.org/Nokogiri/XML/Document.html#method-c-parse - # - # @param [Proc] the proc to pass to Nokogiri to setup parse options - # - def with_nokogiri_config(&blk) - @nokogiri_config_callback = blk - 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 = {}) - # Capture any provided namespaces and merge in any namespaces that have - # been registered on the object. - namespaces = options[:namespaces] || {} - namespaces = namespaces.merge(@registered_namespaces) - - # 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 - - unless xml.is_a?(Nokogiri::XML::Document) - # 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, - &nokogiri_config_callback - ) - end - # Now xml is certainly an XML document: Select the root node of the document - node = xml.root - - # merge any namespaces found on the xml node into the namespace hash - namespaces = namespaces.merge(xml.collect_namespaces) - - # 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 - - # If the :single option has been specified or we are at the root element - # then we are going to return a single element or nil if no nodes are found - single = root || options[:single] - - # if a namespace has been provided then set the current namespace to it - # or use the namespace provided by the class - # or use the 'xmlns' namespace if defined - - namespace = options[:namespace] || self.namespace || namespaces.key?('xmlns') && 'xmlns' - - # 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 - find_nodes_to_parse(options, namespace, tag_name, namespaces, node, root) - end - - # Nothing matching found, we can go ahead and return - return (single ? nil : []) if nodes.empty? - - # 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. - - 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 - - collection = [] - - nodes.each_slice(limit) do |slice| - part = slice.map do |n| - parse_node(n, options, namespace, namespaces) - 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 - - if block_given? && options[:in_groups_of] - yield part - else - collection += part - end - end - - # If we're parsing a single 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 single - collection.first - else - collection - end - end - - # @private - def defined_content - @content if defined? @content - end - - private - - def find_nodes_to_parse(options, namespace, tag_name, namespaces, node, root) - # 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 = if options[:xpath] - options[:xpath].to_s.sub(%r{([^/])$}, '\1/') - elsif root - '/' - else - './/' - end - if namespace - return [] unless namespaces.find { |name, _url| ["xmlns:#{namespace}", namespace].include? name } - - xpath += "#{namespace}:" - end - - nodes = [] - - # when finding nodes, do it in this order: - # 1. specified tag if one has been provided - # 2. name of element - # 3. tag_name (derived from class name by default) - - # If a tag has been provided we need to search for it. - - if options.key?(:tag) - nodes = node.xpath(xpath + options[:tag].to_s, namespaces) - else - - # This is the default case when no tag value is provided. - # First we use the name of the element `items` in `has_many items` - # Second we use the tag name which is the name of the class cleaned up - - [options[:name], tag_name].compact.each do |xpath_ext| - nodes = node.xpath(xpath + xpath_ext.to_s, namespaces) - break if nodes && !nodes.empty? - end - - end - - nodes - end - - def parse_node(node, options, namespace, namespaces) - # If an existing HappyMapper object is provided, update it with the - # values from the xml being parsed. Otherwise, create a new object - - obj = options[:update] || new - - attributes.each do |attr| - value = attr.from_xml_node(node, namespace, namespaces) - value = attr.default if value.nil? - obj.send("#{attr.method_name}=", value) - end - - elements.each do |elem| - obj.send("#{elem.method_name}=", elem.from_xml_node(node, namespace, namespaces)) - end - - if (content = defined_content) - obj.send("#{content.method_name}=", content.from_xml_node(node, 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=) - obj.xml_value = node.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_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=) - node = node.children if node.respond_to?(:children) - obj.xml_content = node.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML) - end - - # Call any registered after_parse callbacks for the object's class - - obj.class.after_parse_callbacks.each { |callback| callback.call(obj) } - - # collect the object that we have created - - obj - end - end - # Set all attributes with a default to their default values def initialize super self.class.attributes.reject { |attr| attr.default.nil? }.each do |attr| - send("#{attr.method_name}=", attr.default) + send(:"#{attr.method_name}=", attr.default) end end # # Create an xml representation of the specified class based on defined @@ -547,11 +99,13 @@ # # Create a tag in the builder that matches the class's tag name unless a tag was passed # in a recursive call from the parent doc. Then append # any attributes to the element that were defined above. # - builder.send("#{tag_from_parent || self.class.tag_name}_", attributes) do |xml| + + tag_name = tag_from_parent || self.class.tag_name + builder.send(:"#{tag_name}_", attributes) do |xml| register_namespaces_with_builder(builder) xml.parent.namespace = builder.doc.root.namespace_definitions.find { |x| x.prefix == namespace_name } @@ -579,11 +133,11 @@ # 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.force_encoding('UTF-8') : builder + write_out_to_xml ? builder.to_xml.force_encoding("UTF-8") : builder end # Parse the xml and update this instance. This does not update instances # of HappyMappers that are children of this object. New instances will be # created for any HappyMapper children of this object. @@ -629,11 +183,11 @@ def collect_writable_attributes # # 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| + attributes = self.class.attributes.filter_map 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 will not go into any of # the below process # @@ -652,12 +206,12 @@ # state that they should be expressed in the output. # next if value.nil? && !attribute.options[:state_when_nil] attribute_namespace = attribute.options[:namespace] - ["#{attribute_namespace ? "#{attribute_namespace}:" : ''}#{attribute.tag}", value] - end.compact + ["#{attribute_namespace ? "#{attribute_namespace}:" : ""}#{attribute.tag}", value] + end attributes.to_h end # @@ -670,11 +224,11 @@ # def register_namespaces_with_builder(builder) return unless self.class.instance_variable_get(:@registered_namespaces) self.class.instance_variable_get(:@registered_namespaces).sort.each do |name, href| - name = nil if name == 'xmlns' + name = nil if name == "xmlns" builder.doc.root.add_namespace(name, href) end end # Persist a single nested element as xml @@ -721,30 +275,33 @@ element.options[:namespace], element.options[:tag] || nil) elsif !item.nil? || element.options[:state_when_nil] - item_namespace = element.options[:namespace] || self.class.namespace || default_namespace + item_namespace = + element.options[:namespace] || + self.class.namespace || + default_namespace # # When a value exists or the tag should always be emitted, # we should append the value for the tag # if item_namespace - xml[item_namespace].send("#{tag}_", item.to_s) + xml[item_namespace].send(:"#{tag}_", item.to_s) else - xml.send("#{tag}_", item.to_s) + xml.send(:"#{tag}_", item.to_s) end end end end def wrapper_anonymous_classes self.class.instance_variable_get(:@wrapper_anonymous_classes) end end -require 'happymapper/supported_types' -require 'happymapper/item' -require 'happymapper/attribute' -require 'happymapper/element' -require 'happymapper/text_node' +require "happymapper/supported_types" +require "happymapper/item" +require "happymapper/attribute" +require "happymapper/element" +require "happymapper/text_node"