Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

WIP Refactor xml conversion to hash #8471

Merged
merged 1 commit into from

6 participants

@kytrinyx

Hash.from_xml was very complicated, and so we decided to take a stab at
refactoring it. This is a work in progress that we want to get feedback on.

Basically, instead of defining a huge method on Hash, we made a utility
object for actually doing the conversion. This let us break up the big
method into a bunch of smaller ones, so that the process overall is
easier to understand.

However, some of the names kinda suck. Still working on that. But we
wanted to get some feedback on if this kind of change is generally
considered to be better or not before putting more effort into
breaking it up. We think that a bunch of small methods are easier to understand
than one massive one.

Things to work on:

  • Names. {process,convert} is awkward. And there's also typecast/to.
  • Might be another utility object as well, as half of the class is for refining a Hash that's getting converted.
  • We may have uncovered either missing test cases or extraneous conditions in a few of the if statements. We've left some comments. Tests pass if you remove them, but we didn't want to before confirming that they aren't actually needed.
  • Changes to documentation, including # :nodoc: where appropriate

:heart:

...pport/lib/active_support/core_ext/hash/conversions.rb
((81 lines not shown))
end
end
- def unrename_keys(params)
- case params
- when Hash
- Hash[params.map { |k,v| [k.to_s.tr('-', '_'), unrename_keys(v)] } ]
+ def typecast_to_hash(value)
+ send("convert_#{value.class.to_s.downcase}", value)
@rafaelfranca Owner

I think that is better to use a case statement here instead of call send based in the class name.

@steveklabnik Collaborator

Really? Hm. I guess it's not the worst, and there's only 4 of them...

@rafaelfranca Owner

I don't know, seems weird to me.

case value
when Array
  convert_array(value)
when Hash
  convert_hash(value)
else
  raise
end

This reads better to me. It is cleaner. It make clear what are the supported types of value without need to read the whole class.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...pport/lib/active_support/core_ext/hash/conversions.rb
((89 lines not shown))
+ send("convert_#{value.class.to_s.downcase}", value)
+ rescue NoMethodError
+ raise "can't typecast #{value.class.name} - #{value.inspect}"
+ end
+
+ def convert_array(value)
+ value.map! { |i| typecast_to_hash(i) }
+ value.length > 1 ? value : value.first
+ end
+
+ def convert_string(value)
+ value
+ end
+
+ def convert_hash(value)
+ if array?(value)
@rafaelfranca Owner

Those methods names seems weird. array?(value) means value === Array to me. But I can't think nothing better now

@steveklabnik Collaborator

Yeah, so we tried to encapsulate what the conversion was trying to get at. So like #hash? for example is the thing that determines if the node is a hash.

Everything could kinda use better names.

In this context it kinda seems ok, since it's checking the type attribute for array, I guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...pport/lib/active_support/core_ext/hash/conversions.rb
((102 lines not shown))
+
+ def convert_hash(value)
+ if array?(value)
+ process_array(value)
+ elsif content?(value)
+ process_content(value)
+ elsif string?(value)
+ process_string(value)
+ elsif hash?(value)
+ process_hash(value)
+ end
+ end
+
+ def process_array(value)
+ _, entries = Array.wrap(value.detect { |k,v| not v.is_a?(String) })
+ if entries.nil? || (c = value['__content__'] && c.blank?)
@rafaelfranca Owner
if entries.nil? || (value['__content__'].blank?)
@steveklabnik Collaborator

Totally. This is the original code, we just moved it around, so I've missed some easy stuff like this.

@steveklabnik Collaborator

.blank? fails the tests, because the left half checks that it's not nil or false.

@steveklabnik Collaborator

had to do .try(:empty?) to handle the case properly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@rafaelfranca

I like the idea to have a specialized object to do the conversion. :+1: from my side

...pport/lib/active_support/core_ext/hash/conversions.rb
((105 lines not shown))
+ process_array(value)
+ elsif content?(value)
+ process_content(value)
+ elsif string?(value)
+ process_string(value)
+ elsif hash?(value)
+ process_hash(value)
+ end
+ end
+
+ def process_array(value)
+ _, entries = Array.wrap(value.detect { |k,v| not v.is_a?(String) })
+ if entries.nil? || (c = value['__content__'] && c.blank?)
+ []
+ else
+ case entries # something weird with classes not matching here. maybe singleton methods breaking is_a?

This comment could :fire:

@steveklabnik Collaborator

yeah it was originally there. I think that maybe the code was changed and not the comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@frodsan frodsan commented on the diff
...pport/lib/active_support/core_ext/hash/conversions.rb
@@ -102,71 +102,129 @@ class << self
# hash = Hash.from_xml(xml)
# # => {"hash"=>{"foo"=>1, "bar"=>2}}
def from_xml(xml)
- typecast_xml_value(unrename_keys(ActiveSupport::XmlMini.parse(xml)))
+ ActiveSupport::XMLConverter.new(xml).to_h
+ end
+ end
+end
+
+module ActiveSupport
+ class XMLConverter
@frodsan
frodsan added a note

# :nodoc:

@steveklabnik Collaborator

Totes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...pport/lib/active_support/core_ext/hash/conversions.rb
((166 lines not shown))
+
+ # blank or nil parsed values are represented by nil
+ def nothing?(value)
+ value.blank? || value['nil'] == 'true'
+ end
+
+ # If the type is the only element which makes it then
+ # this still makes the value nil, except if type is
+ # a XML node(where type['value'] is a Hash)
+ def garbage?(value)
+ # I have suspicion about the last term
+ value['type'] && !value['type'].is_a?(::Hash) && value.size == 1
+ end
+
+ def content?(value)
+ value['type'] == 'file' || value['__content__']
@steveklabnik Collaborator

Changing this is the line that broke the test. ha!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@steveklabnik
Collaborator

We've updated this pull request to handle all of the feedback.

We re-checked that we didn't break anything when making these changes, and rolled back a few of the more aggressive changes we made previously.

We're thinking that, since most people don't actually use this code, in the future, it might be a good candidate to extract to a plugin.

@kytrinyx

Should we put the XMLConverter class in its own file?

@steveklabnik
Collaborator

Since it's probably not going to be used on its own, this is fine. Having it in its own file would be nice if it needed to be autoloaded, but it won't.

I'm going to squash and then :shipit:.

Steve Klabnik + Katrina Owen Refactor Hash.from_xml.
Three basic refactors in this PR:

* We extracted the logic into a method object. We now don't define a tone of extraneous methods on Hash, even if they were private.
* Extracted blocks of the case statement into methods that do the work. This makes the logic more clear.
* Extracted complicated if clauses into their own query methods. They often have two or three terms, this makes it much easier to see what they _do_.

We took care not to refactor too much as to not break anything, and put comments where we suspect tests are missing.

We think ActiveSupport::XMLMini might be a good candidate to move to a plugin in the future.
b02ebe7
@steveklabnik steveklabnik merged commit 10c0a3b into rails:master
@kytrinyx kytrinyx deleted the unknown repository branch
@rizwanreza

Love this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Dec 21, 2012
  1. Refactor Hash.from_xml.

    Steve Klabnik + Katrina Owen authored
    Three basic refactors in this PR:
    
    * We extracted the logic into a method object. We now don't define a tone of extraneous methods on Hash, even if they were private.
    * Extracted blocks of the case statement into methods that do the work. This makes the logic more clear.
    * Extracted complicated if clauses into their own query methods. They often have two or three terms, this makes it much easier to see what they _do_.
    
    We took care not to refactor too much as to not break anything, and put comments where we suspect tests are missing.
    
    We think ActiveSupport::XMLMini might be a good candidate to move to a plugin in the future.
This page is out of date. Refresh to see the latest.
View
150 activesupport/lib/active_support/core_ext/hash/conversions.rb
@@ -102,55 +102,41 @@ class << self
# hash = Hash.from_xml(xml)
# # => {"hash"=>{"foo"=>1, "bar"=>2}}
def from_xml(xml)
- typecast_xml_value(unrename_keys(ActiveSupport::XmlMini.parse(xml)))
+ ActiveSupport::XMLConverter.new(xml).to_h
+ end
+
+ end
+end
+
+module ActiveSupport
+ class XMLConverter # :nodoc:
@frodsan
frodsan added a note

# :nodoc:

@steveklabnik Collaborator

Totes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ def initialize(xml)
+ @xml = normalize_keys(XmlMini.parse(xml))
+ end
+
+ def to_h
+ deep_to_h(@xml)
end
private
- def typecast_xml_value(value)
- case value
+
+ def normalize_keys(params)
+ case params
when Hash
- if value['type'] == 'array'
- _, entries = Array.wrap(value.detect { |k,v| not v.is_a?(String) })
- if entries.nil? || (c = value['__content__'] && c.blank?)
- []
- else
- case entries # something weird with classes not matching here. maybe singleton methods breaking is_a?
- when Array
- entries.collect { |v| typecast_xml_value(v) }
- when Hash
- [typecast_xml_value(entries)]
- else
- raise "can't typecast #{entries.inspect}"
- end
- end
- elsif value['type'] == 'file' ||
- (value['__content__'] && (value.keys.size == 1 || value['__content__'].present?))
- content = value['__content__']
- if parser = ActiveSupport::XmlMini::PARSING[value['type']]
- parser.arity == 1 ? parser.call(content) : parser.call(content, value)
- else
- content
- end
- elsif value['type'] == 'string' && value['nil'] != 'true'
- ''
- # blank or nil parsed values are represented by nil
- elsif value.blank? || value['nil'] == 'true'
- nil
- # If the type is the only element which makes it then
- # this still makes the value nil, except if type is
- # a XML node(where type['value'] is a Hash)
- elsif value['type'] && value.size == 1 && !value['type'].is_a?(::Hash)
- nil
- else
- xml_value = Hash[value.map { |k,v| [k, typecast_xml_value(v)] }]
+ Hash[params.map { |k,v| [k.to_s.tr('-', '_'), normalize_keys(v)] } ]
+ when Array
+ params.map { |v| normalize_keys(v) }
+ else
+ params
+ end
+ end
- # Turn { files: { file: #<StringIO> } } into { files: #<StringIO> } so it is compatible with
- # how multipart uploaded files from HTML appear
- xml_value['file'].is_a?(StringIO) ? xml_value['file'] : xml_value
- end
+ def deep_to_h(value)
+ case value
+ when Hash
+ process_hash(value)
when Array
- value.map! { |i| typecast_xml_value(i) }
- value.length > 1 ? value : value.first
+ process_array(value)
when String
value
else
@@ -158,15 +144,79 @@ def typecast_xml_value(value)
end
end
- def unrename_keys(params)
- case params
- when Hash
- Hash[params.map { |k,v| [k.to_s.tr('-', '_'), unrename_keys(v)] } ]
- when Array
- params.map { |v| unrename_keys(v) }
+ def process_hash(value)
+ if become_array?(value)
+ _, entries = Array.wrap(value.detect { |k,v| not v.is_a?(String) })
+ if entries.nil? || value['__content__'].try(:empty?)
+ []
else
- params
+ case entries
+ when Array
+ entries.collect { |v| deep_to_h(v) }
+ when Hash
+ [deep_to_h(entries)]
+ else
+ raise "can't typecast #{entries.inspect}"
+ end
+ end
+ elsif become_content?(value)
+ process_content(value)
+
+ elsif become_empty_string?(value)
+ ''
+ elsif become_hash?(value)
+ xml_value = Hash[value.map { |k,v| [k, deep_to_h(v)] }]
+
+ # Turn { files: { file: #<StringIO> } } into { files: #<StringIO> } so it is compatible with
+ # how multipart uploaded files from HTML appear
+ xml_value['file'].is_a?(StringIO) ? xml_value['file'] : xml_value
end
end
+
+ def become_content?(value)
+ value['type'] == 'file' || (value['__content__'] && (value.keys.size == 1 || value['__content__'].present?))
+ end
+
+ def become_array?(value)
+ value['type'] == 'array'
+ end
+
+ def become_empty_string?(value)
+ # {"string" => true}
+ # No tests fail when the second term is removed.
+ value['type'] == 'string' && value['nil'] != 'true'
+ end
+
+ def become_hash?(value)
+ !nothing?(value) && !garbage?(value)
+ end
+
+ def nothing?(value)
+ # blank or nil parsed values are represented by nil
+ value.blank? || value['nil'] == 'true'
+ end
+
+ def garbage?(value)
+ # If the type is the only element which makes it then
+ # this still makes the value nil, except if type is
+ # a XML node(where type['value'] is a Hash)
+ value['type'] && !value['type'].is_a?(::Hash) && value.size == 1
+ end
+
+ def process_content(value)
+ content = value['__content__']
+ if parser = ActiveSupport::XmlMini::PARSING[value['type']]
+ parser.arity == 1 ? parser.call(content) : parser.call(content, value)
+ else
+ content
+ end
+ end
+
+ def process_array(value)
+ value.map! { |i| deep_to_h(i) }
+ value.length > 1 ? value : value.first
+ end
+
end
end
+
View
2  activesupport/test/core_ext/hash_ext_test.rb
@@ -1330,7 +1330,7 @@ def test_kernel_method_names_to_xml
def test_empty_string_works_for_typecast_xml_value
assert_nothing_raised do
- Hash.__send__(:typecast_xml_value, "")
+ ActiveSupport::XMLConverter.new("").to_h
end
end
Something went wrong with that request. Please try again.