tags:

views:

254

answers:

5

I've written a method to turn a hash (nested if necessary) of values into a chain that can be used with eval to dynamically return values from an object.

E.g. passed a hash like { :user => { :club => :title }}, it will return "user.club.title", which I can then eval. (The point of this is to write a method for views that will allow me to dump the contents of objects rapidly, by passing in the object and a list of attributes that I want to display, e.g.: item_row(@user, :name, :email, { :club => :title })

Here's what I've got. It works, but I know it can be improved. Curious to see how you'd improve it.

# hash = { :user => { :club => :title }}
# we want to end up with user.club.title
def hash_to_eval_chain(hash)
  raise "Hash cannot contain multiple key-value pairs unless they are nested" if hash.size > 1
  hash.each_pair do |key, value|
    chain = key.to_s + "."
    if value.is_a? String or value.is_a? Symbol
      chain += value.to_s
    elsif value.is_a? Hash
      chain += hash_to_eval_chain(value)
    else
      raise "Only strings, symbols, and hashes are allowed as values in the hash."
    end
    # returning from inside the each_pair block only makes sense because we only ever accept hashes
    # with a single key-value pair
    return chain
  end
end

puts hash_to_eval_chain({ :club => :title }) # => club.title

puts hash_to_eval_chain({ :user => { :club => :title }}) # => user.club.title

puts hash_to_eval_chain({ :user => { :club => { :owners => :name }}}) # => user.club.owners.name

puts ({ :user => { :club => { :owners => :name }}}).to_s # => userclubownersname (close, but lacks the periods)
A: 

I think Ruby 1.9 has Hash#flatten(level), that allows you to flatten a hash recursively (it looks like you have to define the level of recursion). Then just join the resulting array with "."

EDIT: Just got to 1.9 to try this -- it only works when an array is nested in a hash value, not more hashes. Sorry, this won't work for you.

Ben
A: 

This is good but not great; see below for a much-simplified method.

def hash_to_eval_chain(hsh)
  make_eval_chain(hsh).join "."
end

private
def make_eval_chain(obj)
  if obj.is_a? Hash
    raise "Hash cannot contain multiple key-value pairs unless they are nested" if obj.size > 1
    return make_eval_chain(obj.to_a.flatten)
  elsif obj.is_a? Array
    return [ obj.first, *make_eval_chain(obj.last) ] if obj.last.is_a? Hash
    return obj if [String, Symbol, Array].include? obj.class
    raise "Only strings, symbols, and hashes are allowed as values in the hash."
  else
    raise "Expected Hash, received #{obj.class}"
  end
end

# Usage:
# irb> hash_to_eval_chain { :one => { :two=> { :three => { :four=> :five } } } }
# => "one.two.three.four.five"

I couldn't find an elegant way to roll it all into one function, so I hope two will suffice.

Basically I realized that {:one => {:two => :three}}.to_a.flatten returns [:one, {:two => :three}] and said A-ha!, this is classic car/cdr-style recursion.

BETTER METHOD:

You know what, I was making that WAY harder than necessary. This is better (inspired by Facets' Hash#recursively method):

def hash_to_eval_chain(hsh)
  make_eval_chain(hsh).flatten.join "."
end

private
def make_eval_chain(obj)
  if obj.is_a? Hash
    return obj.map {|key, val| [key, *make_eval_chain(val)] } if obj.is_a?(Hash) && obj.size <= 1
    raise "Hash cannot contain multiple key-value pairs unless they are nested"
  else
    return obj if [String, Symbol, Array].any? { |klass| obj.is_a? klass }
    raise "Only strings, symbols, and hashes are allowed as values in the Hash."
  end
end

# Usage:
# irb> hash_to_eval_chain { :one => { :two=> { :three => { :four=> :five } } } }
# => "one.two.three.four.five"
Jordan
+3  A: 

<codegolfing mode=on>

def hash_to_arr(h)
    arr = []
    while h.kind_of?(Hash)
            # FIXME : check h.size ?
            k = h.keys[0]
            arr.push(k)
            h = h[k]
    end
    arr.push h
end

puts hash_to_arr(:club).join('.') #=> "club"
puts hash_to_arr(:club => :title).join('.') #=> "club.title"
puts hash_to_arr(:user => {:club => :title}).join('.') #=> "user.club.title"
puts hash_to_arr(:user => {:club => {:owners => :name}}).join('.') #=> "user.club.owners.name"
  • Call .join('.') to get the string.
  • No checks for other types than Hash, I expect them to repond nicely on #to_s when called by Array#join('.').
  • No recursive calls
  • Shorter code

The biggest change is to avoid iteration since we are interested in 1 element hashes. Btw, an array like [:club, :title, :owners] would maybe more straightforward for your usage.

Cheers,
   zimbatm

zimbatm
Nice! I rather enjoyed solving it recursively, but your solution is elegant in its own right.
Jordan
So elegant I decided to know off a few chars. See my second answer. :) Fun!
Jordan
A very nice solution.
adriandz
+2  A: 

zimbatm's code-golfy answer so inspired me that I decided to improve on it.

def hash_to_arr(hash)
  arr = []
  arr[arr.size], hash = hash.to_a[0] while hash.kind_of?(Hash)
  arr << hash
end

# > h = { :one => { :two=> { :three => { :four=> :five } } } }
# > hash_to_arr(h).join "."
# => "one.two.three.four.five"

Or, if you want it super-golfy, it's 69 64 chars:

def f(h)a=[];a[a.size],h=h.to_a[0]while h.kind_of? Hash;a<<h end
Jordan
Hehe, I see you're having fun golfing
zimbatm
Thanks for the answer - your time is much appreciated.
adriandz
+2  A: 

Inspired by some of the other answers, here's a way to do it using recursive send() rather than eval():

def extract(obj, hash)
  k, v = hash.to_a[0]
  v.is_a?(Hash) ? extract(obj.send(k), v) : obj.send(k).send(v)
end

In the case mentioned in the question, extract(@user, {:club => :title}) would result in @user.send(:club).send(:title).

EDIT: as mentioned by zimbatm, an array like [:club, :title, :owner] might be cleaner. If you used that instead (and are running in an environment that supports Symbol#to_proc), you could just do:

def extract2(obj, method_array)
  method_array.inject(obj, &:send)
end
Greg Campbell
Oh, very good. I didn't see the poster's bit about evaling the returned string--in light of that, your `send` version has less of a smell about it.
Jordan
You could shorten the second line to *k, v = hash.to_a[0]*, btw.
Jordan
Good point - done.
Greg Campbell
Oh, and you have an extra end-paren on the third line in your extract() call.
Jordan
Truly elegant, and top marks for a response that went beyond what I asked for and instead addressed the root issue. I'm going to study both of your suggested approaches.
adriandz