From 36dc94a6a1e742848c5a80975b8bf5d216f54022 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 16 Jun 2006 10:07:13 +0000 Subject: Added Hash.create_from_xml(string) which will create a hash from a XML string and even typecast if possible [DHH] git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@4453 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- .../lib/action_controller/cgi_ext/cgi_methods.rb | 208 ++-- .../lib/action_controller/vendor/xml_simple.rb | 1019 -------------------- actionpack/test/controller/webservice_test.rb | 7 - activesupport/CHANGELOG | 13 + .../active_support/core_ext/hash/conversions.rb | 70 ++ .../active_support/core_ext/string/inflections.rb | 2 +- .../lib/active_support/vendor/xml_simple.rb | 1019 ++++++++++++++++++++ activesupport/test/core_ext/hash_ext_test.rb | 79 +- 8 files changed, 1255 insertions(+), 1162 deletions(-) delete mode 100644 actionpack/lib/action_controller/vendor/xml_simple.rb create mode 100644 activesupport/lib/active_support/vendor/xml_simple.rb diff --git a/actionpack/lib/action_controller/cgi_ext/cgi_methods.rb b/actionpack/lib/action_controller/cgi_ext/cgi_methods.rb index be5923e8de..1af837c39a 100755 --- a/actionpack/lib/action_controller/cgi_ext/cgi_methods.rb +++ b/actionpack/lib/action_controller/cgi_ext/cgi_methods.rb @@ -1,14 +1,13 @@ require 'cgi' -require 'action_controller/vendor/xml_simple' require 'action_controller/vendor/xml_node' # Static methods for parsing the query and request parameters that can be used in # a CGI extension class or testing in isolation. class CGIMethods #:nodoc: - public + class << self # Returns a hash with the pairs from the query string. The implicit hash construction that is done in # parse_request_params is not done here. - def CGIMethods.parse_query_parameters(query_string) + def parse_query_parameters(query_string) parsed_params = {} query_string.split(/[&;]/).each { |p| @@ -41,7 +40,7 @@ class CGIMethods #:nodoc: # Returns the request (POST/GET) parameters in a parsed form where pairs such as "customer[address][street]" / # "Somewhere cool!" are translated into a full hash hierarchy, like # { "customer" => { "address" => { "street" => "Somewhere cool!" } } } - def CGIMethods.parse_request_parameters(params) + def parse_request_parameters(params) parsed_params = {} for key, value in params @@ -59,162 +58,103 @@ class CGIMethods #:nodoc: parsed_params end - def self.parse_formatted_request_parameters(mime_type, raw_post_data) - params = case strategy = ActionController::Base.param_parsers[mime_type] + def parse_formatted_request_parameters(mime_type, raw_post_data) + case strategy = ActionController::Base.param_parsers[mime_type] when Proc strategy.call(raw_post_data) when :xml_simple - raw_post_data.blank? ? nil : - typecast_xml_value(XmlSimple.xml_in(raw_post_data, - 'forcearray' => false, - 'forcecontent' => true, - 'keeproot' => true, - 'contentkey' => '__content__')) + raw_post_data.blank? ? {} : Hash.create_from_xml(raw_post_data) when :yaml YAML.load(raw_post_data) when :xml_node node = XmlNode.from_xml(raw_post_data) { node.node_name => node } end - - dasherize_keys(params || {}) rescue Object => e { "exception" => "#{e.message} (#{e.class})", "backtrace" => e.backtrace, "raw_post_data" => raw_post_data, "format" => mime_type } end - def self.typecast_xml_value(value) - case value - when Hash - if value.has_key?("__content__") - content = translate_xml_entities(value["__content__"]) - case value["type"] - when "integer" then content.to_i - when "boolean" then content == "true" - when "datetime" then Time.parse(content) - when "date" then Date.parse(content) - else content - end - else - value.empty? ? nil : value.inject({}) do |h,(k,v)| - h[k] = typecast_xml_value(v) - h - end - end - when Array - value.map! { |i| typecast_xml_value(i) } - case value.length - when 0 then nil - when 1 then value.first - else value - end - else - raise "can't typecast #{value.inspect}" - end - end - - private - - def self.translate_xml_entities(value) - value.gsub(/</, "<"). - gsub(/>/, ">"). - gsub(/"/, '"'). - gsub(/'/, "'"). - gsub(/&/, "&") - end - - def self.dasherize_keys(params) - case params.class.to_s - when "Hash" - params.inject({}) do |h,(k,v)| - h[k.to_s.tr("-", "_")] = dasherize_keys(v) - h - end - when "Array" - params.map { |v| dasherize_keys(v) } - else - params - end - end - - # Splits the given key into several pieces. Example keys are 'name', 'person[name]', - # 'person[name][first]', and 'people[]'. In each instance, an Array instance is returned. - # 'person[name][first]' produces ['person', 'name', 'first']; 'people[]' produces ['people', ''] - def CGIMethods.split_key(key) - if /^([^\[]+)((?:\[[^\]]*\])+)$/ =~ key - keys = [$1] + private + # Splits the given key into several pieces. Example keys are 'name', 'person[name]', + # 'person[name][first]', and 'people[]'. In each instance, an Array instance is returned. + # 'person[name][first]' produces ['person', 'name', 'first']; 'people[]' produces ['people', ''] + def split_key(key) + if /^([^\[]+)((?:\[[^\]]*\])+)$/ =~ key + keys = [$1] - keys.concat($2[1..-2].split('][')) - keys << '' if key[-2..-1] == '[]' # Have to add it since split will drop empty strings + keys.concat($2[1..-2].split('][')) + keys << '' if key[-2..-1] == '[]' # Have to add it since split will drop empty strings - keys - else - [key] + keys + else + [key] + end end - end - def CGIMethods.get_typed_value(value) - # test most frequent case first - if value.is_a?(String) - value - elsif value.respond_to?(:content_type) && ! value.content_type.blank? - # Uploaded file - unless value.respond_to?(:full_original_filename) - class << value - alias_method :full_original_filename, :original_filename - - # Take the basename of the upload's original filename. - # This handles the full Windows paths given by Internet Explorer - # (and perhaps other broken user agents) without affecting - # those which give the lone filename. - # The Windows regexp is adapted from Perl's File::Basename. - def original_filename - if md = /^(?:.*[:\\\/])?(.*)/m.match(full_original_filename) - md.captures.first - else - File.basename full_original_filename + def get_typed_value(value) + # test most frequent case first + if value.is_a?(String) + value + elsif value.respond_to?(:content_type) && ! value.content_type.blank? + # Uploaded file + unless value.respond_to?(:full_original_filename) + class << value + alias_method :full_original_filename, :original_filename + + # Take the basename of the upload's original filename. + # This handles the full Windows paths given by Internet Explorer + # (and perhaps other broken user agents) without affecting + # those which give the lone filename. + # The Windows regexp is adapted from Perl's File::Basename. + def original_filename + if md = /^(?:.*[:\\\/])?(.*)/m.match(full_original_filename) + md.captures.first + else + File.basename full_original_filename + end end end end - end - # Return the same value after overriding original_filename. - value + # Return the same value after overriding original_filename. + value - elsif value.respond_to?(:read) - # Value as part of a multipart request - result = value.read - value.rewind - result - elsif value.class == Array - value.collect { |v| CGIMethods.get_typed_value(v) } - else - # other value (neither string nor a multipart request) - value.to_s + elsif value.respond_to?(:read) + # Value as part of a multipart request + result = value.read + value.rewind + result + elsif value.class == Array + value.collect { |v| get_typed_value(v) } + else + # other value (neither string nor a multipart request) + value.to_s + end end - end - PARAMS_HASH_RE = /^([^\[]+)(\[.*\])?(.)?.*$/ - def CGIMethods.get_levels(key) - all, main, bracketed, trailing = PARAMS_HASH_RE.match(key).to_a - if main.nil? - [] - elsif trailing - [key] - elsif bracketed - [main] + bracketed.slice(1...-1).split('][') - else - [main] + PARAMS_HASH_RE = /^([^\[]+)(\[.*\])?(.)?.*$/ + def get_levels(key) + all, main, bracketed, trailing = PARAMS_HASH_RE.match(key).to_a + if main.nil? + [] + elsif trailing + [key] + elsif bracketed + [main] + bracketed.slice(1...-1).split('][') + else + [main] + end end - end - def CGIMethods.build_deep_hash(value, hash, levels) - if levels.length == 0 - value - elsif hash.nil? - { levels.first => CGIMethods.build_deep_hash(value, nil, levels[1..-1]) } - else - hash.update({ levels.first => CGIMethods.build_deep_hash(value, hash[levels.first], levels[1..-1]) }) + def build_deep_hash(value, hash, levels) + if levels.length == 0 + value + elsif hash.nil? + { levels.first => build_deep_hash(value, nil, levels[1..-1]) } + else + hash.update({ levels.first => build_deep_hash(value, hash[levels.first], levels[1..-1]) }) + end end - end + end end diff --git a/actionpack/lib/action_controller/vendor/xml_simple.rb b/actionpack/lib/action_controller/vendor/xml_simple.rb deleted file mode 100644 index e6ce63a839..0000000000 --- a/actionpack/lib/action_controller/vendor/xml_simple.rb +++ /dev/null @@ -1,1019 +0,0 @@ -# = XmlSimple -# -# Author:: Maik Schmidt -# Copyright:: Copyright (c) 2003 Maik Schmidt -# License:: Distributes under the same terms as Ruby. -# -require 'rexml/document' - -# Easy API to maintain XML (especially configuration files). -class XmlSimple #:nodoc: - include REXML - - @@VERSION = '1.0.2' - - # A simple cache for XML documents that were already transformed - # by xml_in. - class Cache #:nodoc: - # Creates and initializes a new Cache object. - def initialize - @mem_share_cache = {} - @mem_copy_cache = {} - end - - # Saves a data structure into a file. - # - # data:: - # Data structure to be saved. - # filename:: - # Name of the file belonging to the data structure. - def save_storable(data, filename) - cache_file = get_cache_filename(filename) - File.open(cache_file, "w+") { |f| Marshal.dump(data, f) } - end - - # Restores a data structure from a file. If restoring the data - # structure failed for any reason, nil will be returned. - # - # filename:: - # Name of the file belonging to the data structure. - def restore_storable(filename) - cache_file = get_cache_filename(filename) - return nil unless File::exist?(cache_file) - return nil unless File::mtime(cache_file).to_i > File::mtime(filename).to_i - data = nil - File.open(cache_file) { |f| data = Marshal.load(f) } - data - end - - # Saves a data structure in a shared memory cache. - # - # data:: - # Data structure to be saved. - # filename:: - # Name of the file belonging to the data structure. - def save_mem_share(data, filename) - @mem_share_cache[filename] = [Time::now.to_i, data] - end - - # Restores a data structure from a shared memory cache. You - # should consider these elements as "read only". If restoring - # the data structure failed for any reason, nil will be - # returned. - # - # filename:: - # Name of the file belonging to the data structure. - def restore_mem_share(filename) - get_from_memory_cache(filename, @mem_share_cache) - end - - # Copies a data structure to a memory cache. - # - # data:: - # Data structure to be copied. - # filename:: - # Name of the file belonging to the data structure. - def save_mem_copy(data, filename) - @mem_share_cache[filename] = [Time::now.to_i, Marshal.dump(data)] - end - - # Restores a data structure from a memory cache. If restoring - # the data structure failed for any reason, nil will be - # returned. - # - # filename:: - # Name of the file belonging to the data structure. - def restore_mem_copy(filename) - data = get_from_memory_cache(filename, @mem_share_cache) - data = Marshal.load(data) unless data.nil? - data - end - - private - - # Returns the "cache filename" belonging to a filename, i.e. - # the extension '.xml' in the original filename will be replaced - # by '.stor'. If filename does not have this extension, '.stor' - # will be appended. - # - # filename:: - # Filename to get "cache filename" for. - def get_cache_filename(filename) - filename.sub(/(\.xml)?$/, '.stor') - end - - # Returns a cache entry from a memory cache belonging to a - # certain filename. If no entry could be found for any reason, - # nil will be returned. - # - # filename:: - # Name of the file the cache entry belongs to. - # cache:: - # Memory cache to get entry from. - def get_from_memory_cache(filename, cache) - return nil unless cache[filename] - return nil unless cache[filename][0] > File::mtime(filename).to_i - return cache[filename][1] - end - end - - # Create a "global" cache. - @@cache = Cache.new - - # Creates and intializes a new XmlSimple object. - # - # defaults:: - # Default values for options. - def initialize(defaults = nil) - unless defaults.nil? || defaults.instance_of?(Hash) - raise ArgumentError, "Options have to be a Hash." - end - @default_options = normalize_option_names(defaults, KNOWN_OPTIONS['in'] & KNOWN_OPTIONS['out']) - @options = Hash.new - @_var_values = nil - end - - # Converts an XML document in the same way as the Perl module XML::Simple. - # - # string:: - # XML source. Could be one of the following: - # - # - nil: Tries to load and parse '.xml'. - # - filename: Tries to load and parse filename. - # - IO object: Reads from object until EOF is detected and parses result. - # - XML string: Parses string. - # - # options:: - # Options to be used. - def xml_in(string = nil, options = nil) - handle_options('in', options) - - # If no XML string or filename was supplied look for scriptname.xml. - if string.nil? - string = File::basename($0) - string.sub!(/\.[^.]+$/, '') - string += '.xml' - - directory = File::dirname($0) - @options['searchpath'].unshift(directory) unless directory.nil? - end - - if string.instance_of?(String) - if string =~ /<.*?>/m - @doc = parse(string) - elsif string == '-' - @doc = parse($stdin.readlines.to_s) - else - filename = find_xml_file(string, @options['searchpath']) - - if @options.has_key?('cache') - @options['cache'].each { |scheme| - case(scheme) - when 'storable' - content = @@cache.restore_storable(filename) - when 'mem_share' - content = @@cache.restore_mem_share(filename) - when 'mem_copy' - content = @@cache.restore_mem_copy(filename) - else - raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." - end - return content if content - } - end - - @doc = load_xml_file(filename) - end - elsif string.kind_of?(IO) - @doc = parse(string.readlines.to_s) - else - raise ArgumentError, "Could not parse object of type: <#{string.type}>." - end - - result = collapse(@doc.root) - result = @options['keeproot'] ? merge({}, @doc.root.name, result) : result - put_into_cache(result, filename) - result - end - - # This is the functional version of the instance method xml_in. - def XmlSimple.xml_in(string = nil, options = nil) - xml_simple = XmlSimple.new - xml_simple.xml_in(string, options) - end - - # Converts a data structure into an XML document. - # - # ref:: - # Reference to data structure to be converted into XML. - # options:: - # Options to be used. - def xml_out(ref, options = nil) - handle_options('out', options) - if ref.instance_of?(Array) - ref = { @options['anonymoustag'] => ref } - end - - if @options['keeproot'] - keys = ref.keys - if keys.size == 1 - ref = ref[keys[0]] - @options['rootname'] = keys[0] - end - elsif @options['rootname'] == '' - if ref.instance_of?(Hash) - refsave = ref - ref = {} - refsave.each { |key, value| - if !scalar(value) - ref[key] = value - else - ref[key] = [ value.to_s ] - end - } - end - end - - @ancestors = [] - xml = value_to_xml(ref, @options['rootname'], '') - @ancestors = nil - - if @options['xmldeclaration'] - xml = @options['xmldeclaration'] + "\n" + xml - end - - if @options.has_key?('outputfile') - if @options['outputfile'].kind_of?(IO) - return @options['outputfile'].write(xml) - else - File.open(@options['outputfile'], "w") { |file| file.write(xml) } - end - end - xml - end - - # This is the functional version of the instance method xml_out. - def XmlSimple.xml_out(hash, options = nil) - xml_simple = XmlSimple.new - xml_simple.xml_out(hash, options) - end - - private - - # Declare options that are valid for xml_in and xml_out. - KNOWN_OPTIONS = { - 'in' => %w( - keyattr keeproot forcecontent contentkey noattr - searchpath forcearray suppressempty anonymoustag - cache grouptags normalisespace normalizespace - variables varattr - ), - 'out' => %w( - keyattr keeproot contentkey noattr rootname - xmldeclaration outputfile noescape suppressempty - anonymoustag indent grouptags noindent - ) - } - - # Define some reasonable defaults. - DEF_KEY_ATTRIBUTES = [] - DEF_ROOT_NAME = 'opt' - DEF_CONTENT_KEY = 'content' - DEF_XML_DECLARATION = "" - DEF_ANONYMOUS_TAG = 'anon' - DEF_FORCE_ARRAY = true - DEF_INDENTATION = ' ' - - # Normalizes option names in a hash, i.e., turns all - # characters to lower case and removes all underscores. - # Additionally, this method checks, if an unknown option - # was used and raises an according exception. - # - # options:: - # Hash to be normalized. - # known_options:: - # List of known options. - def normalize_option_names(options, known_options) - return nil if options.nil? - result = Hash.new - options.each { |key, value| - lkey = key.downcase - lkey.gsub!(/_/, '') - if !known_options.member?(lkey) - raise ArgumentError, "Unrecognised option: #{lkey}." - end - result[lkey] = value - } - result - end - - # Merges a set of options with the default options. - # - # direction:: - # 'in': If options should be handled for xml_in. - # 'out': If options should be handled for xml_out. - # options:: - # Options to be merged with the default options. - def handle_options(direction, options) - @options = options || Hash.new - - raise ArgumentError, "Options must be a Hash!" unless @options.instance_of?(Hash) - - unless KNOWN_OPTIONS.has_key?(direction) - raise ArgumentError, "Unknown direction: <#{direction}>." - end - - known_options = KNOWN_OPTIONS[direction] - @options = normalize_option_names(@options, known_options) - - unless @default_options.nil? - known_options.each { |option| - unless @options.has_key?(option) - if @default_options.has_key?(option) - @options[option] = @default_options[option] - end - end - } - end - - unless @options.has_key?('noattr') - @options['noattr'] = false - end - - if @options.has_key?('rootname') - @options['rootname'] = '' if @options['rootname'].nil? - else - @options['rootname'] = DEF_ROOT_NAME - end - - if @options.has_key?('xmldeclaration') && @options['xmldeclaration'] == true - @options['xmldeclaration'] = DEF_XML_DECLARATION - end - - if @options.has_key?('contentkey') - if @options['contentkey'] =~ /^-(.*)$/ - @options['contentkey'] = $1 - @options['collapseagain'] = true - end - else - @options['contentkey'] = DEF_CONTENT_KEY - end - - unless @options.has_key?('normalisespace') - @options['normalisespace'] = @options['normalizespace'] - end - @options['normalisespace'] = 0 if @options['normalisespace'].nil? - - if @options.has_key?('searchpath') - unless @options['searchpath'].instance_of?(Array) - @options['searchpath'] = [ @options['searchpath'] ] - end - else - @options['searchpath'] = [] - end - - if @options.has_key?('cache') && scalar(@options['cache']) - @options['cache'] = [ @options['cache'] ] - end - - @options['anonymoustag'] = DEF_ANONYMOUS_TAG unless @options.has_key?('anonymoustag') - - if !@options.has_key?('indent') || @options['indent'].nil? - @options['indent'] = DEF_INDENTATION - end - - @options['indent'] = '' if @options.has_key?('noindent') - - # Special cleanup for 'keyattr' which could be an array or - # a hash or left to default to array. - if @options.has_key?('keyattr') - if !scalar(@options['keyattr']) - # Convert keyattr => { elem => '+attr' } - # to keyattr => { elem => ['attr', '+'] } - if @options['keyattr'].instance_of?(Hash) - @options['keyattr'].each { |key, value| - if value =~ /^([-+])?(.*)$/ - @options['keyattr'][key] = [$2, $1 ? $1 : ''] - end - } - elsif !@options['keyattr'].instance_of?(Array) - raise ArgumentError, "'keyattr' must be String, Hash, or Array!" - end - else - @options['keyattr'] = [ @options['keyattr'] ] - end - else - @options['keyattr'] = DEF_KEY_ATTRIBUTES - end - - if @options.has_key?('forcearray') - if @options['forcearray'].instance_of?(Regexp) - @options['forcearray'] = [ @options['forcearray'] ] - end - - if @options['forcearray'].instance_of?(Array) - force_list = @options['forcearray'] - unless force_list.empty? - @options['forcearray'] = {} - force_list.each { |tag| - if tag.instance_of?(Regexp) - unless @options['forcearray']['_regex'].instance_of?(Array) - @options['forcearray']['_regex'] = [] - end - @options['forcearray']['_regex'] << tag - else - @options['forcearray'][tag] = true - end - } - else - @options['forcearray'] = false - end - else - @options['forcearray'] = @options['forcearray'] ? true : false - end - else - @options['forcearray'] = DEF_FORCE_ARRAY - end - - if @options.has_key?('grouptags') && !@options['grouptags'].instance_of?(Hash) - raise ArgumentError, "Illegal value for 'GroupTags' option - expected a Hash." - end - - if @options.has_key?('variables') && !@options['variables'].instance_of?(Hash) - raise ArgumentError, "Illegal value for 'Variables' option - expected a Hash." - end - - if @options.has_key?('variables') - @_var_values = @options['variables'] - elsif @options.has_key?('varattr') - @_var_values = {} - end - end - - # Actually converts an XML document element into a data structure. - # - # element:: - # The document element to be collapsed. - def collapse(element) - result = @options['noattr'] ? {} : get_attributes(element) - - if @options['normalisespace'] == 2 - result.each { |k, v| result[k] = normalise_space(v) } - end - - if element.has_elements? - element.each_element { |child| - value = collapse(child) - if empty(value) && (element.attributes.empty? || @options['noattr']) - next if @options.has_key?('suppressempty') && @options['suppressempty'] == true - end - result = merge(result, child.name, value) - } - if has_mixed_content?(element) - # normalisespace? - content = element.texts.map { |x| x.to_s } - content = content[0] if content.size == 1 - result[@options['contentkey']] = content - end - elsif element.has_text? # i.e. it has only text. - return collapse_text_node(result, element) - end - - # Turn Arrays into Hashes if key fields present. - count = fold_arrays(result) - - # Disintermediate grouped tags. - if @options.has_key?('grouptags') - result.each { |key, value| - next unless (value.instance_of?(Hash) && (value.size == 1)) - child_key, child_value = value.to_a[0] - if @options['grouptags'][key] == child_key - result[key] = child_value - end - } - end - - # Fold Hases containing a single anonymous Array up into just the Array. - if count == 1 - anonymoustag = @options['anonymoustag'] - if result.has_key?(anonymoustag) && result[anonymoustag].instance_of?(Array) - return result[anonymoustag] - end - end - - if result.empty? && @options.has_key?('suppressempty') - return @options['suppressempty'] == '' ? '' : nil - end - - result - end - - # Collapses a text node and merges it with an existing Hash, if - # possible. - # Thanks to Curtis Schofield for reporting a subtle bug. - # - # hash:: - # Hash to merge text node value with, if possible. - # element:: - # Text node to be collapsed. - def collapse_text_node(hash, element) - value = node_to_text(element) - if empty(value) && !element.has_attributes? - return {} - end - - if element.has_attributes? && !@options['noattr'] - return merge(hash, @options['contentkey'], value) - else - if @options['forcecontent'] - return merge(hash, @options['contentkey'], value) - else - return value - end - end - end - - # Folds all arrays in a Hash. - # - # hash:: - # Hash to be folded. - def fold_arrays(hash) - fold_amount = 0 - keyattr = @options['keyattr'] - if (keyattr.instance_of?(Array) || keyattr.instance_of?(Hash)) - hash.each { |key, value| - if value.instance_of?(Array) - if keyattr.instance_of?(Array) - hash[key] = fold_array(value) - else - hash[key] = fold_array_by_name(key, value) - end - fold_amount += 1 - end - } - end - fold_amount - end - - # Folds an Array to a Hash, if possible. Folding happens - # according to the content of keyattr, which has to be - # an array. - # - # array:: - # Array to be folded. - def fold_array(array) - hash = Hash.new - array.each { |x| - return array unless x.instance_of?(Hash) - key_matched = false - @options['keyattr'].each { |key| - if x.has_key?(key) - key_matched = true - value = x[key] - return array if value.instance_of?(Hash) || value.instance_of?(Array) - value = normalise_space(value) if @options['normalisespace'] == 1 - x.delete(key) - hash[value] = x - break - end - } - return array unless key_matched - } - hash = collapse_content(hash) if @options['collapseagain'] - hash - end - - # Folds an Array to a Hash, if possible. Folding happens - # according to the content of keyattr, which has to be - # a Hash. - # - # name:: - # Name of the attribute to be folded upon. - # array:: - # Array to be folded. - def fold_array_by_name(name, array) - return array unless @options['keyattr'].has_key?(name) - key, flag = @options['keyattr'][name] - - hash = Hash.new - array.each { |x| - if x.instance_of?(Hash) && x.has_key?(key) - value = x[key] - return array if value.instance_of?(Hash) || value.instance_of?(Array) - value = normalise_space(value) if @options['normalisespace'] == 1 - hash[value] = x - hash[value]["-#{key}"] = hash[value][key] if flag == '-' - hash[value].delete(key) unless flag == '+' - else - $stderr.puts("Warning: <#{name}> element has no '#{key}' attribute.") - return array - end - } - hash = collapse_content(hash) if @options['collapseagain'] - hash - end - - # Tries to collapse a Hash even more ;-) - # - # hash:: - # Hash to be collapsed again. - def collapse_content(hash) - content_key = @options['contentkey'] - hash.each_value { |value| - return hash unless value.instance_of?(Hash) && value.size == 1 && value.has_key?(content_key) - hash.each_key { |key| hash[key] = hash[key][content_key] } - } - hash - end - - # Adds a new key/value pair to an existing Hash. If the key to be added - # does already exist and the existing value associated with key is not - # an Array, it will be converted into an Array. Then the new value is - # appended to that Array. - # - # hash:: - # Hash to add key/value pair to. - # key:: - # Key to be added. - # value:: - # Value to be associated with key. - def merge(hash, key, value) - if value.instance_of?(String) - value = normalise_space(value) if @options['normalisespace'] == 2 - - # do variable substitutions - unless @_var_values.nil? || @_var_values.empty? - value.gsub!(/\$\{(\w+)\}/) { |x| get_var($1) } - end - - # look for variable definitions - if @options.has_key?('varattr') - varattr = @options['varattr'] - if hash.has_key?(varattr) - set_var(hash[varattr], value) - end - end - end - if hash.has_key?(key) - if hash[key].instance_of?(Array) - hash[key] << value - else - hash[key] = [ hash[key], value ] - end - elsif value.instance_of?(Array) # Handle anonymous arrays. - hash[key] = [ value ] - else - if force_array?(key) - hash[key] = [ value ] - else - hash[key] = value - end - end - hash - end - - # Checks, if the 'forcearray' option has to be used for - # a certain key. - def force_array?(key) - return false if key == @options['contentkey'] - return true if @options['forcearray'] == true - forcearray = @options['forcearray'] - if forcearray.instance_of?(Hash) - return true if forcearray.has_key?(key) - return false unless forcearray.has_key?('_regex') - forcearray['_regex'].each { |x| return true if key =~ x } - end - return false - end - - # Converts the attributes array of a document node into a Hash. - # Returns an empty Hash, if node has no attributes. - # - # node:: - # Document node to extract attributes from. - def get_attributes(node) - attributes = {} - node.attributes.each { |n,v| attributes[n] = v } - attributes - end - - # Determines, if a document element has mixed content. - # - # element:: - # Document element to be checked. - def has_mixed_content?(element) - if element.has_text? && element.has_elements? - return true if element.texts.join('') !~ /^\s*$/s - end - false - end - - # Called when a variable definition is encountered in the XML. - # A variable definition looks like - # value - # where attrname matches the varattr setting. - def set_var(name, value) - @_var_values[name] = value - end - - # Called during variable substitution to get the value for the - # named variable. - def get_var(name) - if @_var_values.has_key?(name) - return @_var_values[name] - else - return "${#{name}}" - end - end - - # Recurses through a data structure building up and returning an - # XML representation of that structure as a string. - # - # ref:: - # Reference to the data structure to be encoded. - # name:: - # The XML tag name to be used for this item. - # indent:: - # A string of spaces for use as the current indent level. - def value_to_xml(ref, name, indent) - named = !name.nil? && name != '' - nl = @options.has_key?('noindent') ? '' : "\n" - - if !scalar(ref) - if @ancestors.member?(ref) - raise ArgumentError, "Circular data structures not supported!" - end - @ancestors << ref - else - if named - return [indent, '<', name, '>', @options['noescape'] ? ref.to_s : escape_value(ref.to_s), '', nl].join('') - else - return ref.to_s + nl - end - end - - # Unfold hash to array if possible. - if ref.instance_of?(Hash) && !ref.empty? && !@options['keyattr'].empty? && indent != '' - ref = hash_to_array(name, ref) - end - - result = [] - if ref.instance_of?(Hash) - # Reintermediate grouped values if applicable. - if @options.has_key?('grouptags') - ref.each { |key, value| - if @options['grouptags'].has_key?(key) - ref[key] = { @options['grouptags'][key] => value } - end - } - end - - nested = [] - text_content = nil - if named - result << indent << '<' << name - end - - if !ref.empty? - ref.each { |key, value| - next if !key.nil? && key[0, 1] == '-' - if value.nil? - unless @options.has_key?('suppressempty') && @options['suppressempty'].nil? - raise ArgumentError, "Use of uninitialized value!" - end - value = {} - end - - if !scalar(value) || @options['noattr'] - nested << value_to_xml(value, key, indent + @options['indent']) - else - value = value.to_s - value = escape_value(value) unless @options['noescape'] - if key == @options['contentkey'] - text_content = value - else - result << ' ' << key << '="' << value << '"' - end - end - } - else - text_content = '' - end - - if !nested.empty? || !text_content.nil? - if named - result << '>' - if !text_content.nil? - result << text_content - nested[0].sub!(/^\s+/, '') if !nested.empty? - else - result << nl - end - if !nested.empty? - result << nested << indent - end - result << '' << nl - else - result << nested - end - else - result << ' />' << nl - end - elsif ref.instance_of?(Array) - ref.each { |value| - if scalar(value) - result << indent << '<' << name << '>' - result << (@options['noescape'] ? value.to_s : escape_value(value.to_s)) - result << '' << nl - elsif value.instance_of?(Hash) - result << value_to_xml(value, name, indent) - else - result << indent << '<' << name << '>' << nl - result << value_to_xml(value, @options['anonymoustag'], indent + @options['indent']) - result << indent << '' << nl - end - } - else - # Probably, this is obsolete. - raise ArgumentError, "Can't encode a value of type: #{ref.type}." - end - @ancestors.pop if !scalar(ref) - result.join('') - end - - # Checks, if a certain value is a "scalar" value. Whatever - # that will be in Ruby ... ;-) - # - # value:: - # Value to be checked. - def scalar(value) - return false if value.instance_of?(Hash) || value.instance_of?(Array) - return true - end - - # Attempts to unfold a hash of hashes into an array of hashes. Returns - # a reference to th array on success or the original hash, if unfolding - # is not possible. - # - # parent:: - # - # hashref:: - # Reference to the hash to be unfolded. - def hash_to_array(parent, hashref) - arrayref = [] - hashref.each { |key, value| - return hashref unless value.instance_of?(Hash) - - if @options['keyattr'].instance_of?(Hash) - return hashref unless @options['keyattr'].has_key?(parent) - arrayref << { @options['keyattr'][parent][0] => key }.update(value) - else - arrayref << { @options['keyattr'][0] => key }.update(value) - end - } - arrayref - end - - # Replaces XML markup characters by their external entities. - # - # data:: - # The string to be escaped. - def escape_value(data) - return data if data.nil? || data == '' - result = data.dup - result.gsub!('&', '&') - result.gsub!('<', '<') - result.gsub!('>', '>') - result.gsub!('"', '"') - result.gsub!("'", ''') - result - end - - # Removes leading and trailing whitespace and sequences of - # whitespaces from a string. - # - # text:: - # String to be normalised. - def normalise_space(text) - text.sub!(/^\s+/, '') - text.sub!(/\s+$/, '') - text.gsub!(/\s\s+/, ' ') - text - end - - # Checks, if an object is nil, an empty String or an empty Hash. - # Thanks to Norbert Gawor for a bugfix. - # - # value:: - # Value to be checked for emptyness. - def empty(value) - case value - when Hash - return value.empty? - when String - return value !~ /\S/m - else - return value.nil? - end - end - - # Converts a document node into a String. - # If the node could not be converted into a String - # for any reason, default will be returned. - # - # node:: - # Document node to be converted. - # default:: - # Value to be returned, if node could not be converted. - def node_to_text(node, default = nil) - if node.instance_of?(Element) - return node.texts.join('') - elsif node.instance_of?(Attribute) - return node.value.nil? ? default : node.value.strip - elsif node.instance_of?(Text) - return node.to_s.strip - else - return default - end - end - - # Parses an XML string and returns the according document. - # - # xml_string:: - # XML string to be parsed. - # - # The following exception may be raised: - # - # REXML::ParseException:: - # If the specified file is not wellformed. - def parse(xml_string) - Document.new(xml_string) - end - - # Searches in a list of paths for a certain file. Returns - # the full path to the file, if it could be found. Otherwise, - # an exception will be raised. - # - # filename:: - # Name of the file to search for. - # searchpath:: - # List of paths to search in. - def find_xml_file(file, searchpath) - filename = File::basename(file) - - if filename != file - return file if File::file?(file) - else - searchpath.each { |path| - full_path = File::join(path, filename) - return full_path if File::file?(full_path) - } - end - - if searchpath.empty? - return file if File::file?(file) - raise ArgumentError, "File does not exist: #{file}." - end - raise ArgumentError, "Could not find <#{filename}> in <#{searchpath.join(':')}>" - end - - # Loads and parses an XML configuration file. - # - # filename:: - # Name of the configuration file to be loaded. - # - # The following exceptions may be raised: - # - # Errno::ENOENT:: - # If the specified file does not exist. - # REXML::ParseException:: - # If the specified file is not wellformed. - def load_xml_file(filename) - parse(File.readlines(filename).to_s) - end - - # Caches the data belonging to a certain file. - # - # data:: - # Data to be cached. - # filename:: - # Name of file the data was read from. - def put_into_cache(data, filename) - if @options.has_key?('cache') - @options['cache'].each { |scheme| - case(scheme) - when 'storable' - @@cache.save_storable(data, filename) - when 'mem_share' - @@cache.save_mem_share(data, filename) - when 'mem_copy' - @@cache.save_mem_copy(data, filename) - else - raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." - end - } - end - end -end - -# vim:sw=2 diff --git a/actionpack/test/controller/webservice_test.rb b/actionpack/test/controller/webservice_test.rb index 9874b2092b..e60e3a84cb 100644 --- a/actionpack/test/controller/webservice_test.rb +++ b/actionpack/test/controller/webservice_test.rb @@ -148,13 +148,6 @@ class WebServiceTest < Test::Unit::TestCase assert_equal %(), @controller.params[:data] end - def test_dasherized_keys_as_yaml - ActionController::Base.param_parsers[Mime::YAML] = :yaml - process('POST', 'application/x-yaml', "---\nfirst-key:\n sub-key: ...\n", true) - assert_equal 'action, controller, first_key(sub_key), full', @controller.response.body - assert_equal "...", @controller.params[:first_key][:sub_key] - end - def test_typecast_as_yaml ActionController::Base.param_parsers[Mime::YAML] = :yaml process('POST', 'application/x-yaml', <<-YAML) diff --git a/activesupport/CHANGELOG b/activesupport/CHANGELOG index 9394fcfed5..5a5fc9d98e 100644 --- a/activesupport/CHANGELOG +++ b/activesupport/CHANGELOG @@ -1,5 +1,18 @@ *SVN* +* Added Hash.create_from_xml(string) which will create a hash from a XML string and even typecast if possible [DHH]. Example: + + Hash.create_from_xml <<-EOT + + This is a note + 2004-10-10 + + EOT + + ...would return: + + { :note => { :title => "This is a note", :created_at => Date.new(2004, 10, 10) } } + * Added Jim Weirich's excellent FlexMock class to vendor (Copyright 2003, 2004 by Jim Weirich (jim@weriichhouse.org)) -- it's not automatically required, though, so require 'flexmock' is still necessary [DHH] * Fixed that Module#alias_method_chain should work with both foo? foo! and foo at the same time #4954 [anna@wota.jp] diff --git a/activesupport/lib/active_support/core_ext/hash/conversions.rb b/activesupport/lib/active_support/core_ext/hash/conversions.rb index a3270c31ef..ac4a612e49 100644 --- a/activesupport/lib/active_support/core_ext/hash/conversions.rb +++ b/activesupport/lib/active_support/core_ext/hash/conversions.rb @@ -1,4 +1,5 @@ require 'date' +require 'xml_simple' module ActiveSupport #:nodoc: module CoreExtensions #:nodoc: @@ -20,6 +21,10 @@ module ActiveSupport #:nodoc: "binary" => Proc.new { |binary| Base64.encode64(binary) } } + def self.included(klass) + klass.extend(ClassMethods) + end + def to_xml(options = {}) options[:indent] ||= 2 options.reverse_merge!({ :builder => Builder::XmlMarkup.new(:indent => options[:indent]), @@ -70,6 +75,71 @@ module ActiveSupport #:nodoc: end end + + module ClassMethods + def create_from_xml(xml) + # TODO: Refactor this into something much cleaner that doesn't rely on XmlSimple + undasherize_keys(typecast_xml_value(XmlSimple.xml_in(xml, + 'forcearray' => false, + 'forcecontent' => true, + 'keeproot' => true, + 'contentkey' => '__content__') + )) + end + + private + def typecast_xml_value(value) + case value.class.to_s + when "Hash" + if value.has_key?("__content__") + content = translate_xml_entities(value["__content__"]) + case value["type"] + when "integer" then content.to_i + when "boolean" then content == "true" + when "datetime" then ::Time.parse(content).utc + when "date" then ::Date.parse(content) + else content + end + else + value.empty? ? nil : value.inject({}) do |h,(k,v)| + h[k] = typecast_xml_value(v) + h + end + end + when "Array" + value.map! { |i| typecast_xml_value(i) } + case value.length + when 0 then nil + when 1 then value.first + else value + end + else + raise "can't typecast #{value.inspect}" + end + end + + def translate_xml_entities(value) + value.gsub(/</, "<"). + gsub(/>/, ">"). + gsub(/"/, '"'). + gsub(/'/, "'"). + gsub(/&/, "&") + end + + def undasherize_keys(params) + case params.class.to_s + when "Hash" + params.inject({}) do |h,(k,v)| + h[k.to_s.tr("-", "_")] = undasherize_keys(v) + h + end + when "Array" + params.map { |v| undasherize_keys(v) } + else + params + end + end + end end end end diff --git a/activesupport/lib/active_support/core_ext/string/inflections.rb b/activesupport/lib/active_support/core_ext/string/inflections.rb index ebae190052..7107bc6945 100644 --- a/activesupport/lib/active_support/core_ext/string/inflections.rb +++ b/activesupport/lib/active_support/core_ext/string/inflections.rb @@ -1,4 +1,5 @@ require File.dirname(__FILE__) + '/../../inflector' unless defined? Inflector + module ActiveSupport #:nodoc: module CoreExtensions #:nodoc: module String #:nodoc: @@ -6,7 +7,6 @@ module ActiveSupport #:nodoc: # For instance, you can figure out the name of a database from the name of a class. # "ScaleScore".tableize => "scale_scores" module Inflections - # Returns the plural form of the word in the string. # # Examples diff --git a/activesupport/lib/active_support/vendor/xml_simple.rb b/activesupport/lib/active_support/vendor/xml_simple.rb new file mode 100644 index 0000000000..e6ce63a839 --- /dev/null +++ b/activesupport/lib/active_support/vendor/xml_simple.rb @@ -0,0 +1,1019 @@ +# = XmlSimple +# +# Author:: Maik Schmidt +# Copyright:: Copyright (c) 2003 Maik Schmidt +# License:: Distributes under the same terms as Ruby. +# +require 'rexml/document' + +# Easy API to maintain XML (especially configuration files). +class XmlSimple #:nodoc: + include REXML + + @@VERSION = '1.0.2' + + # A simple cache for XML documents that were already transformed + # by xml_in. + class Cache #:nodoc: + # Creates and initializes a new Cache object. + def initialize + @mem_share_cache = {} + @mem_copy_cache = {} + end + + # Saves a data structure into a file. + # + # data:: + # Data structure to be saved. + # filename:: + # Name of the file belonging to the data structure. + def save_storable(data, filename) + cache_file = get_cache_filename(filename) + File.open(cache_file, "w+") { |f| Marshal.dump(data, f) } + end + + # Restores a data structure from a file. If restoring the data + # structure failed for any reason, nil will be returned. + # + # filename:: + # Name of the file belonging to the data structure. + def restore_storable(filename) + cache_file = get_cache_filename(filename) + return nil unless File::exist?(cache_file) + return nil unless File::mtime(cache_file).to_i > File::mtime(filename).to_i + data = nil + File.open(cache_file) { |f| data = Marshal.load(f) } + data + end + + # Saves a data structure in a shared memory cache. + # + # data:: + # Data structure to be saved. + # filename:: + # Name of the file belonging to the data structure. + def save_mem_share(data, filename) + @mem_share_cache[filename] = [Time::now.to_i, data] + end + + # Restores a data structure from a shared memory cache. You + # should consider these elements as "read only". If restoring + # the data structure failed for any reason, nil will be + # returned. + # + # filename:: + # Name of the file belonging to the data structure. + def restore_mem_share(filename) + get_from_memory_cache(filename, @mem_share_cache) + end + + # Copies a data structure to a memory cache. + # + # data:: + # Data structure to be copied. + # filename:: + # Name of the file belonging to the data structure. + def save_mem_copy(data, filename) + @mem_share_cache[filename] = [Time::now.to_i, Marshal.dump(data)] + end + + # Restores a data structure from a memory cache. If restoring + # the data structure failed for any reason, nil will be + # returned. + # + # filename:: + # Name of the file belonging to the data structure. + def restore_mem_copy(filename) + data = get_from_memory_cache(filename, @mem_share_cache) + data = Marshal.load(data) unless data.nil? + data + end + + private + + # Returns the "cache filename" belonging to a filename, i.e. + # the extension '.xml' in the original filename will be replaced + # by '.stor'. If filename does not have this extension, '.stor' + # will be appended. + # + # filename:: + # Filename to get "cache filename" for. + def get_cache_filename(filename) + filename.sub(/(\.xml)?$/, '.stor') + end + + # Returns a cache entry from a memory cache belonging to a + # certain filename. If no entry could be found for any reason, + # nil will be returned. + # + # filename:: + # Name of the file the cache entry belongs to. + # cache:: + # Memory cache to get entry from. + def get_from_memory_cache(filename, cache) + return nil unless cache[filename] + return nil unless cache[filename][0] > File::mtime(filename).to_i + return cache[filename][1] + end + end + + # Create a "global" cache. + @@cache = Cache.new + + # Creates and intializes a new XmlSimple object. + # + # defaults:: + # Default values for options. + def initialize(defaults = nil) + unless defaults.nil? || defaults.instance_of?(Hash) + raise ArgumentError, "Options have to be a Hash." + end + @default_options = normalize_option_names(defaults, KNOWN_OPTIONS['in'] & KNOWN_OPTIONS['out']) + @options = Hash.new + @_var_values = nil + end + + # Converts an XML document in the same way as the Perl module XML::Simple. + # + # string:: + # XML source. Could be one of the following: + # + # - nil: Tries to load and parse '.xml'. + # - filename: Tries to load and parse filename. + # - IO object: Reads from object until EOF is detected and parses result. + # - XML string: Parses string. + # + # options:: + # Options to be used. + def xml_in(string = nil, options = nil) + handle_options('in', options) + + # If no XML string or filename was supplied look for scriptname.xml. + if string.nil? + string = File::basename($0) + string.sub!(/\.[^.]+$/, '') + string += '.xml' + + directory = File::dirname($0) + @options['searchpath'].unshift(directory) unless directory.nil? + end + + if string.instance_of?(String) + if string =~ /<.*?>/m + @doc = parse(string) + elsif string == '-' + @doc = parse($stdin.readlines.to_s) + else + filename = find_xml_file(string, @options['searchpath']) + + if @options.has_key?('cache') + @options['cache'].each { |scheme| + case(scheme) + when 'storable' + content = @@cache.restore_storable(filename) + when 'mem_share' + content = @@cache.restore_mem_share(filename) + when 'mem_copy' + content = @@cache.restore_mem_copy(filename) + else + raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." + end + return content if content + } + end + + @doc = load_xml_file(filename) + end + elsif string.kind_of?(IO) + @doc = parse(string.readlines.to_s) + else + raise ArgumentError, "Could not parse object of type: <#{string.type}>." + end + + result = collapse(@doc.root) + result = @options['keeproot'] ? merge({}, @doc.root.name, result) : result + put_into_cache(result, filename) + result + end + + # This is the functional version of the instance method xml_in. + def XmlSimple.xml_in(string = nil, options = nil) + xml_simple = XmlSimple.new + xml_simple.xml_in(string, options) + end + + # Converts a data structure into an XML document. + # + # ref:: + # Reference to data structure to be converted into XML. + # options:: + # Options to be used. + def xml_out(ref, options = nil) + handle_options('out', options) + if ref.instance_of?(Array) + ref = { @options['anonymoustag'] => ref } + end + + if @options['keeproot'] + keys = ref.keys + if keys.size == 1 + ref = ref[keys[0]] + @options['rootname'] = keys[0] + end + elsif @options['rootname'] == '' + if ref.instance_of?(Hash) + refsave = ref + ref = {} + refsave.each { |key, value| + if !scalar(value) + ref[key] = value + else + ref[key] = [ value.to_s ] + end + } + end + end + + @ancestors = [] + xml = value_to_xml(ref, @options['rootname'], '') + @ancestors = nil + + if @options['xmldeclaration'] + xml = @options['xmldeclaration'] + "\n" + xml + end + + if @options.has_key?('outputfile') + if @options['outputfile'].kind_of?(IO) + return @options['outputfile'].write(xml) + else + File.open(@options['outputfile'], "w") { |file| file.write(xml) } + end + end + xml + end + + # This is the functional version of the instance method xml_out. + def XmlSimple.xml_out(hash, options = nil) + xml_simple = XmlSimple.new + xml_simple.xml_out(hash, options) + end + + private + + # Declare options that are valid for xml_in and xml_out. + KNOWN_OPTIONS = { + 'in' => %w( + keyattr keeproot forcecontent contentkey noattr + searchpath forcearray suppressempty anonymoustag + cache grouptags normalisespace normalizespace + variables varattr + ), + 'out' => %w( + keyattr keeproot contentkey noattr rootname + xmldeclaration outputfile noescape suppressempty + anonymoustag indent grouptags noindent + ) + } + + # Define some reasonable defaults. + DEF_KEY_ATTRIBUTES = [] + DEF_ROOT_NAME = 'opt' + DEF_CONTENT_KEY = 'content' + DEF_XML_DECLARATION = "" + DEF_ANONYMOUS_TAG = 'anon' + DEF_FORCE_ARRAY = true + DEF_INDENTATION = ' ' + + # Normalizes option names in a hash, i.e., turns all + # characters to lower case and removes all underscores. + # Additionally, this method checks, if an unknown option + # was used and raises an according exception. + # + # options:: + # Hash to be normalized. + # known_options:: + # List of known options. + def normalize_option_names(options, known_options) + return nil if options.nil? + result = Hash.new + options.each { |key, value| + lkey = key.downcase + lkey.gsub!(/_/, '') + if !known_options.member?(lkey) + raise ArgumentError, "Unrecognised option: #{lkey}." + end + result[lkey] = value + } + result + end + + # Merges a set of options with the default options. + # + # direction:: + # 'in': If options should be handled for xml_in. + # 'out': If options should be handled for xml_out. + # options:: + # Options to be merged with the default options. + def handle_options(direction, options) + @options = options || Hash.new + + raise ArgumentError, "Options must be a Hash!" unless @options.instance_of?(Hash) + + unless KNOWN_OPTIONS.has_key?(direction) + raise ArgumentError, "Unknown direction: <#{direction}>." + end + + known_options = KNOWN_OPTIONS[direction] + @options = normalize_option_names(@options, known_options) + + unless @default_options.nil? + known_options.each { |option| + unless @options.has_key?(option) + if @default_options.has_key?(option) + @options[option] = @default_options[option] + end + end + } + end + + unless @options.has_key?('noattr') + @options['noattr'] = false + end + + if @options.has_key?('rootname') + @options['rootname'] = '' if @options['rootname'].nil? + else + @options['rootname'] = DEF_ROOT_NAME + end + + if @options.has_key?('xmldeclaration') && @options['xmldeclaration'] == true + @options['xmldeclaration'] = DEF_XML_DECLARATION + end + + if @options.has_key?('contentkey') + if @options['contentkey'] =~ /^-(.*)$/ + @options['contentkey'] = $1 + @options['collapseagain'] = true + end + else + @options['contentkey'] = DEF_CONTENT_KEY + end + + unless @options.has_key?('normalisespace') + @options['normalisespace'] = @options['normalizespace'] + end + @options['normalisespace'] = 0 if @options['normalisespace'].nil? + + if @options.has_key?('searchpath') + unless @options['searchpath'].instance_of?(Array) + @options['searchpath'] = [ @options['searchpath'] ] + end + else + @options['searchpath'] = [] + end + + if @options.has_key?('cache') && scalar(@options['cache']) + @options['cache'] = [ @options['cache'] ] + end + + @options['anonymoustag'] = DEF_ANONYMOUS_TAG unless @options.has_key?('anonymoustag') + + if !@options.has_key?('indent') || @options['indent'].nil? + @options['indent'] = DEF_INDENTATION + end + + @options['indent'] = '' if @options.has_key?('noindent') + + # Special cleanup for 'keyattr' which could be an array or + # a hash or left to default to array. + if @options.has_key?('keyattr') + if !scalar(@options['keyattr']) + # Convert keyattr => { elem => '+attr' } + # to keyattr => { elem => ['attr', '+'] } + if @options['keyattr'].instance_of?(Hash) + @options['keyattr'].each { |key, value| + if value =~ /^([-+])?(.*)$/ + @options['keyattr'][key] = [$2, $1 ? $1 : ''] + end + } + elsif !@options['keyattr'].instance_of?(Array) + raise ArgumentError, "'keyattr' must be String, Hash, or Array!" + end + else + @options['keyattr'] = [ @options['keyattr'] ] + end + else + @options['keyattr'] = DEF_KEY_ATTRIBUTES + end + + if @options.has_key?('forcearray') + if @options['forcearray'].instance_of?(Regexp) + @options['forcearray'] = [ @options['forcearray'] ] + end + + if @options['forcearray'].instance_of?(Array) + force_list = @options['forcearray'] + unless force_list.empty? + @options['forcearray'] = {} + force_list.each { |tag| + if tag.instance_of?(Regexp) + unless @options['forcearray']['_regex'].instance_of?(Array) + @options['forcearray']['_regex'] = [] + end + @options['forcearray']['_regex'] << tag + else + @options['forcearray'][tag] = true + end + } + else + @options['forcearray'] = false + end + else + @options['forcearray'] = @options['forcearray'] ? true : false + end + else + @options['forcearray'] = DEF_FORCE_ARRAY + end + + if @options.has_key?('grouptags') && !@options['grouptags'].instance_of?(Hash) + raise ArgumentError, "Illegal value for 'GroupTags' option - expected a Hash." + end + + if @options.has_key?('variables') && !@options['variables'].instance_of?(Hash) + raise ArgumentError, "Illegal value for 'Variables' option - expected a Hash." + end + + if @options.has_key?('variables') + @_var_values = @options['variables'] + elsif @options.has_key?('varattr') + @_var_values = {} + end + end + + # Actually converts an XML document element into a data structure. + # + # element:: + # The document element to be collapsed. + def collapse(element) + result = @options['noattr'] ? {} : get_attributes(element) + + if @options['normalisespace'] == 2 + result.each { |k, v| result[k] = normalise_space(v) } + end + + if element.has_elements? + element.each_element { |child| + value = collapse(child) + if empty(value) && (element.attributes.empty? || @options['noattr']) + next if @options.has_key?('suppressempty') && @options['suppressempty'] == true + end + result = merge(result, child.name, value) + } + if has_mixed_content?(element) + # normalisespace? + content = element.texts.map { |x| x.to_s } + content = content[0] if content.size == 1 + result[@options['contentkey']] = content + end + elsif element.has_text? # i.e. it has only text. + return collapse_text_node(result, element) + end + + # Turn Arrays into Hashes if key fields present. + count = fold_arrays(result) + + # Disintermediate grouped tags. + if @options.has_key?('grouptags') + result.each { |key, value| + next unless (value.instance_of?(Hash) && (value.size == 1)) + child_key, child_value = value.to_a[0] + if @options['grouptags'][key] == child_key + result[key] = child_value + end + } + end + + # Fold Hases containing a single anonymous Array up into just the Array. + if count == 1 + anonymoustag = @options['anonymoustag'] + if result.has_key?(anonymoustag) && result[anonymoustag].instance_of?(Array) + return result[anonymoustag] + end + end + + if result.empty? && @options.has_key?('suppressempty') + return @options['suppressempty'] == '' ? '' : nil + end + + result + end + + # Collapses a text node and merges it with an existing Hash, if + # possible. + # Thanks to Curtis Schofield for reporting a subtle bug. + # + # hash:: + # Hash to merge text node value with, if possible. + # element:: + # Text node to be collapsed. + def collapse_text_node(hash, element) + value = node_to_text(element) + if empty(value) && !element.has_attributes? + return {} + end + + if element.has_attributes? && !@options['noattr'] + return merge(hash, @options['contentkey'], value) + else + if @options['forcecontent'] + return merge(hash, @options['contentkey'], value) + else + return value + end + end + end + + # Folds all arrays in a Hash. + # + # hash:: + # Hash to be folded. + def fold_arrays(hash) + fold_amount = 0 + keyattr = @options['keyattr'] + if (keyattr.instance_of?(Array) || keyattr.instance_of?(Hash)) + hash.each { |key, value| + if value.instance_of?(Array) + if keyattr.instance_of?(Array) + hash[key] = fold_array(value) + else + hash[key] = fold_array_by_name(key, value) + end + fold_amount += 1 + end + } + end + fold_amount + end + + # Folds an Array to a Hash, if possible. Folding happens + # according to the content of keyattr, which has to be + # an array. + # + # array:: + # Array to be folded. + def fold_array(array) + hash = Hash.new + array.each { |x| + return array unless x.instance_of?(Hash) + key_matched = false + @options['keyattr'].each { |key| + if x.has_key?(key) + key_matched = true + value = x[key] + return array if value.instance_of?(Hash) || value.instance_of?(Array) + value = normalise_space(value) if @options['normalisespace'] == 1 + x.delete(key) + hash[value] = x + break + end + } + return array unless key_matched + } + hash = collapse_content(hash) if @options['collapseagain'] + hash + end + + # Folds an Array to a Hash, if possible. Folding happens + # according to the content of keyattr, which has to be + # a Hash. + # + # name:: + # Name of the attribute to be folded upon. + # array:: + # Array to be folded. + def fold_array_by_name(name, array) + return array unless @options['keyattr'].has_key?(name) + key, flag = @options['keyattr'][name] + + hash = Hash.new + array.each { |x| + if x.instance_of?(Hash) && x.has_key?(key) + value = x[key] + return array if value.instance_of?(Hash) || value.instance_of?(Array) + value = normalise_space(value) if @options['normalisespace'] == 1 + hash[value] = x + hash[value]["-#{key}"] = hash[value][key] if flag == '-' + hash[value].delete(key) unless flag == '+' + else + $stderr.puts("Warning: <#{name}> element has no '#{key}' attribute.") + return array + end + } + hash = collapse_content(hash) if @options['collapseagain'] + hash + end + + # Tries to collapse a Hash even more ;-) + # + # hash:: + # Hash to be collapsed again. + def collapse_content(hash) + content_key = @options['contentkey'] + hash.each_value { |value| + return hash unless value.instance_of?(Hash) && value.size == 1 && value.has_key?(content_key) + hash.each_key { |key| hash[key] = hash[key][content_key] } + } + hash + end + + # Adds a new key/value pair to an existing Hash. If the key to be added + # does already exist and the existing value associated with key is not + # an Array, it will be converted into an Array. Then the new value is + # appended to that Array. + # + # hash:: + # Hash to add key/value pair to. + # key:: + # Key to be added. + # value:: + # Value to be associated with key. + def merge(hash, key, value) + if value.instance_of?(String) + value = normalise_space(value) if @options['normalisespace'] == 2 + + # do variable substitutions + unless @_var_values.nil? || @_var_values.empty? + value.gsub!(/\$\{(\w+)\}/) { |x| get_var($1) } + end + + # look for variable definitions + if @options.has_key?('varattr') + varattr = @options['varattr'] + if hash.has_key?(varattr) + set_var(hash[varattr], value) + end + end + end + if hash.has_key?(key) + if hash[key].instance_of?(Array) + hash[key] << value + else + hash[key] = [ hash[key], value ] + end + elsif value.instance_of?(Array) # Handle anonymous arrays. + hash[key] = [ value ] + else + if force_array?(key) + hash[key] = [ value ] + else + hash[key] = value + end + end + hash + end + + # Checks, if the 'forcearray' option has to be used for + # a certain key. + def force_array?(key) + return false if key == @options['contentkey'] + return true if @options['forcearray'] == true + forcearray = @options['forcearray'] + if forcearray.instance_of?(Hash) + return true if forcearray.has_key?(key) + return false unless forcearray.has_key?('_regex') + forcearray['_regex'].each { |x| return true if key =~ x } + end + return false + end + + # Converts the attributes array of a document node into a Hash. + # Returns an empty Hash, if node has no attributes. + # + # node:: + # Document node to extract attributes from. + def get_attributes(node) + attributes = {} + node.attributes.each { |n,v| attributes[n] = v } + attributes + end + + # Determines, if a document element has mixed content. + # + # element:: + # Document element to be checked. + def has_mixed_content?(element) + if element.has_text? && element.has_elements? + return true if element.texts.join('') !~ /^\s*$/s + end + false + end + + # Called when a variable definition is encountered in the XML. + # A variable definition looks like + # value + # where attrname matches the varattr setting. + def set_var(name, value) + @_var_values[name] = value + end + + # Called during variable substitution to get the value for the + # named variable. + def get_var(name) + if @_var_values.has_key?(name) + return @_var_values[name] + else + return "${#{name}}" + end + end + + # Recurses through a data structure building up and returning an + # XML representation of that structure as a string. + # + # ref:: + # Reference to the data structure to be encoded. + # name:: + # The XML tag name to be used for this item. + # indent:: + # A string of spaces for use as the current indent level. + def value_to_xml(ref, name, indent) + named = !name.nil? && name != '' + nl = @options.has_key?('noindent') ? '' : "\n" + + if !scalar(ref) + if @ancestors.member?(ref) + raise ArgumentError, "Circular data structures not supported!" + end + @ancestors << ref + else + if named + return [indent, '<', name, '>', @options['noescape'] ? ref.to_s : escape_value(ref.to_s), '', nl].join('') + else + return ref.to_s + nl + end + end + + # Unfold hash to array if possible. + if ref.instance_of?(Hash) && !ref.empty? && !@options['keyattr'].empty? && indent != '' + ref = hash_to_array(name, ref) + end + + result = [] + if ref.instance_of?(Hash) + # Reintermediate grouped values if applicable. + if @options.has_key?('grouptags') + ref.each { |key, value| + if @options['grouptags'].has_key?(key) + ref[key] = { @options['grouptags'][key] => value } + end + } + end + + nested = [] + text_content = nil + if named + result << indent << '<' << name + end + + if !ref.empty? + ref.each { |key, value| + next if !key.nil? && key[0, 1] == '-' + if value.nil? + unless @options.has_key?('suppressempty') && @options['suppressempty'].nil? + raise ArgumentError, "Use of uninitialized value!" + end + value = {} + end + + if !scalar(value) || @options['noattr'] + nested << value_to_xml(value, key, indent + @options['indent']) + else + value = value.to_s + value = escape_value(value) unless @options['noescape'] + if key == @options['contentkey'] + text_content = value + else + result << ' ' << key << '="' << value << '"' + end + end + } + else + text_content = '' + end + + if !nested.empty? || !text_content.nil? + if named + result << '>' + if !text_content.nil? + result << text_content + nested[0].sub!(/^\s+/, '') if !nested.empty? + else + result << nl + end + if !nested.empty? + result << nested << indent + end + result << '' << nl + else + result << nested + end + else + result << ' />' << nl + end + elsif ref.instance_of?(Array) + ref.each { |value| + if scalar(value) + result << indent << '<' << name << '>' + result << (@options['noescape'] ? value.to_s : escape_value(value.to_s)) + result << '' << nl + elsif value.instance_of?(Hash) + result << value_to_xml(value, name, indent) + else + result << indent << '<' << name << '>' << nl + result << value_to_xml(value, @options['anonymoustag'], indent + @options['indent']) + result << indent << '' << nl + end + } + else + # Probably, this is obsolete. + raise ArgumentError, "Can't encode a value of type: #{ref.type}." + end + @ancestors.pop if !scalar(ref) + result.join('') + end + + # Checks, if a certain value is a "scalar" value. Whatever + # that will be in Ruby ... ;-) + # + # value:: + # Value to be checked. + def scalar(value) + return false if value.instance_of?(Hash) || value.instance_of?(Array) + return true + end + + # Attempts to unfold a hash of hashes into an array of hashes. Returns + # a reference to th array on success or the original hash, if unfolding + # is not possible. + # + # parent:: + # + # hashref:: + # Reference to the hash to be unfolded. + def hash_to_array(parent, hashref) + arrayref = [] + hashref.each { |key, value| + return hashref unless value.instance_of?(Hash) + + if @options['keyattr'].instance_of?(Hash) + return hashref unless @options['keyattr'].has_key?(parent) + arrayref << { @options['keyattr'][parent][0] => key }.update(value) + else + arrayref << { @options['keyattr'][0] => key }.update(value) + end + } + arrayref + end + + # Replaces XML markup characters by their external entities. + # + # data:: + # The string to be escaped. + def escape_value(data) + return data if data.nil? || data == '' + result = data.dup + result.gsub!('&', '&') + result.gsub!('<', '<') + result.gsub!('>', '>') + result.gsub!('"', '"') + result.gsub!("'", ''') + result + end + + # Removes leading and trailing whitespace and sequences of + # whitespaces from a string. + # + # text:: + # String to be normalised. + def normalise_space(text) + text.sub!(/^\s+/, '') + text.sub!(/\s+$/, '') + text.gsub!(/\s\s+/, ' ') + text + end + + # Checks, if an object is nil, an empty String or an empty Hash. + # Thanks to Norbert Gawor for a bugfix. + # + # value:: + # Value to be checked for emptyness. + def empty(value) + case value + when Hash + return value.empty? + when String + return value !~ /\S/m + else + return value.nil? + end + end + + # Converts a document node into a String. + # If the node could not be converted into a String + # for any reason, default will be returned. + # + # node:: + # Document node to be converted. + # default:: + # Value to be returned, if node could not be converted. + def node_to_text(node, default = nil) + if node.instance_of?(Element) + return node.texts.join('') + elsif node.instance_of?(Attribute) + return node.value.nil? ? default : node.value.strip + elsif node.instance_of?(Text) + return node.to_s.strip + else + return default + end + end + + # Parses an XML string and returns the according document. + # + # xml_string:: + # XML string to be parsed. + # + # The following exception may be raised: + # + # REXML::ParseException:: + # If the specified file is not wellformed. + def parse(xml_string) + Document.new(xml_string) + end + + # Searches in a list of paths for a certain file. Returns + # the full path to the file, if it could be found. Otherwise, + # an exception will be raised. + # + # filename:: + # Name of the file to search for. + # searchpath:: + # List of paths to search in. + def find_xml_file(file, searchpath) + filename = File::basename(file) + + if filename != file + return file if File::file?(file) + else + searchpath.each { |path| + full_path = File::join(path, filename) + return full_path if File::file?(full_path) + } + end + + if searchpath.empty? + return file if File::file?(file) + raise ArgumentError, "File does not exist: #{file}." + end + raise ArgumentError, "Could not find <#{filename}> in <#{searchpath.join(':')}>" + end + + # Loads and parses an XML configuration file. + # + # filename:: + # Name of the configuration file to be loaded. + # + # The following exceptions may be raised: + # + # Errno::ENOENT:: + # If the specified file does not exist. + # REXML::ParseException:: + # If the specified file is not wellformed. + def load_xml_file(filename) + parse(File.readlines(filename).to_s) + end + + # Caches the data belonging to a certain file. + # + # data:: + # Data to be cached. + # filename:: + # Name of file the data was read from. + def put_into_cache(data, filename) + if @options.has_key?('cache') + @options['cache'].each { |scheme| + case(scheme) + when 'storable' + @@cache.save_storable(data, filename) + when 'mem_share' + @@cache.save_mem_share(data, filename) + when 'mem_copy' + @@cache.save_mem_copy(data, filename) + else + raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." + end + } + end + end +end + +# vim:sw=2 diff --git a/activesupport/test/core_ext/hash_ext_test.rb b/activesupport/test/core_ext/hash_ext_test.rb index f8ed54c881..65050de15a 100644 --- a/activesupport/test/core_ext/hash_ext_test.rb +++ b/activesupport/test/core_ext/hash_ext_test.rb @@ -280,4 +280,81 @@ class HashToXmlTest < Test::Unit::TestCase assert xml.include?(%(
)) end -end + def test_single_record_from_xml + topic_xml = <<-EOT + + The First Topic + David + 1 + false + 0 + 2003-07-16 + 2003-07-16T09:28:00+0000 + Have a nice day + david@loudthinking.com + + + EOT + + expected_topic_hash = { + :title => "The First Topic", + :author_name => "David", + :id => 1, + :approved => false, + :replies_count => 0, + :written_on => Date.new(2003, 7, 16), + :viewed_at => Time.utc(2003, 7, 16, 9, 28), + :content => "Have a nice day", + :author_email_address => "david@loudthinking.com", + :parent_id => nil + }.stringify_keys + + assert_equal expected_topic_hash, Hash.create_from_xml(topic_xml)["topic"] + end + + def test_multiple_records_from_xml + topics_xml = <<-EOT + + + The First Topic + David + 1 + false + 0 + 2003-07-16 + 2003-07-16T09:28:00+0000 + Have a nice day + david@loudthinking.com + + + + The Second Topic + Jason + 1 + false + 0 + 2003-07-16 + 2003-07-16T09:28:00+0000 + Have a nice day + david@loudthinking.com + + + + EOT + + expected_topic_hash = { + :title => "The First Topic", + :author_name => "David", + :id => 1, + :approved => false, + :replies_count => 0, + :written_on => Date.new(2003, 7, 16), + :viewed_at => Time.utc(2003, 7, 16, 9, 28), + :content => "Have a nice day", + :author_email_address => "david@loudthinking.com", + :parent_id => nil + }.stringify_keys + + assert_equal expected_topic_hash, Hash.create_from_xml(topics_xml)["topics"]["topic"].first + end +end \ No newline at end of file -- cgit v1.2.3