168

I have a string which looks like a hash:

"{ :key_a => { :key_1a => 'value_1a', :key_2a => 'value_2a' }, :key_b => { :key_1b => 'value_1b' } }"

How do I get a Hash out of it? like:

{ :key_a => { :key_1a => 'value_1a', :key_2a => 'value_2a' }, :key_b => { :key_1b => 'value_1b' } }

The string can have any depth of nesting. It has all the properties how a valid Hash is typed in Ruby.

Waseem
  • 8,232
  • 9
  • 43
  • 54

16 Answers16

210

For different string, you can do it without using dangerous eval method:

hash_as_string = "{\"0\"=>{\"answer\"=>\"1\", \"value\"=>\"No\"}, \"1\"=>{\"answer\"=>\"2\", \"value\"=>\"Yes\"}, \"2\"=>{\"answer\"=>\"3\", \"value\"=>\"No\"}, \"3\"=>{\"answer\"=>\"4\", \"value\"=>\"1\"}, \"4\"=>{\"value\"=>\"2\"}, \"5\"=>{\"value\"=>\"3\"}, \"6\"=>{\"value\"=>\"4\"}}"
JSON.parse hash_as_string.gsub('=>', ':')
zolter
  • 7,070
  • 3
  • 37
  • 51
  • 3
    This answer should be selected for avoiding using eval. – Michael_Zhang Sep 04 '18 at 21:57
  • 7
    you should also replace nils, f.e. `JSON.parse(hash_as_string.gsub("=>", ":").gsub(":nil,", ":null,"))` – Yo Ludke Nov 09 '18 at 12:06
  • 1
    @YoLudke's reply is a good idea, but it only substitutes nils that are followed by a comma, so it will break if your hash ends with a nil value. Using word boundaries is more flexible: `JSON.parse(hash_as_string.gsub("=>", ":").gsub(/\bnil\b/, "null"))`. – take May 23 '22 at 13:00
155

Quick and dirty method would be

eval("{ :key_a => { :key_1a => 'value_1a', :key_2a => 'value_2a' }, :key_b => { :key_1b => 'value_1b' } }") 

But it has severe security implications.
It executes whatever it is passed, you must be 110% sure (as in, at least no user input anywhere along the way) it would contain only properly formed hashes or unexpected bugs/horrible creatures from outer space might start popping up.

The Whiz of Oz
  • 6,763
  • 9
  • 48
  • 85
Toms Mikoss
  • 9,097
  • 10
  • 29
  • 41
  • 17
    I have a light saber with me. I can take care of those creatures and bugs. :) – Waseem Nov 03 '09 at 14:35
  • 12
    USING EVAL can be dangerous here, according to my teacher. Eval takes any ruby code and runs it. The danger here is analogous to SQL injection danger. Gsub is preferable. – boulder_ruby Jul 20 '12 at 17:37
  • 10
    Example string showing why David's teacher is correct: '{:surprise => "#{system \"rm -rf * \"}"}' – A. Wilson Jul 30 '12 at 18:09
  • 15
    I cannot emphasize the DANGER of using EVAL here enough! This is absolutely forbidden if user input can ever wind its way into your string. – Dave Collins Jan 11 '13 at 13:15
  • Even if you think you'll never open this up more publicly, someone else might. We all (should) know how code gets used in ways you wouldn't have expected. It's like putting extremely heavy things on a high shelf, making it top heavy. You should just never create this form of danger. – Steve Sether Jun 21 '19 at 16:15
  • @Waseem A lightsaber won't protect you from viruses, malware, hackers, etc. – Sapphire_Brick Sep 13 '19 at 15:22
86

The string created by calling Hash#inspect can be turned back into a hash by calling eval on it. However, this requires the same to be true of all of the objects in the hash.

If I start with the hash {:a => Object.new}, then its string representation is "{:a=>#<Object:0x7f66b65cf4d0>}", and I can't use eval to turn it back into a hash because #<Object:0x7f66b65cf4d0> isn't valid Ruby syntax.

However, if all that's in the hash is strings, symbols, numbers, and arrays, it should work, because those have string representations that are valid Ruby syntax.

Ken Bloom
  • 57,498
  • 14
  • 111
  • 168
  • "if all that's in the hash is strings, symbols, and numbers,". This says a lot. So I can check the validity of a string to be `eval`uated as a hash by making sure that the above statement is valid for that string. – Waseem Nov 03 '09 at 14:38
  • 1
    Yes, but in order to do that you either need a full Ruby parser, or you need to know where the string came from in the first place and know that it can only generate strings, symbols, and numbers. (See also Toms Mikoss's answer about trusting the contents of the string.) – Ken Bloom Nov 03 '09 at 14:43
  • 20
    Be carefule where you use this. Using `eval` at the wrong place is a huge security hole. Anything inside the string, will be evaluated. So imagine if in an API someone injected `rm -fr` – Pithikos Apr 05 '16 at 14:23
33

I had the same problem. I was storing a hash in Redis. When retrieving that hash, it was a string. I didn't want to call eval(str) because of security concerns. My solution was to save the hash as a json string instead of a ruby hash string. If you have the option, using json is easier.

  redis.set(key, ruby_hash.to_json)
  JSON.parse(redis.get(key))

TL;DR: use to_json and JSON.parse

Jared Menard
  • 2,654
  • 1
  • 21
  • 22
  • 3
    This is the best answer by far. ```to_json``` and ```JSON.parse``` – port5432 Sep 02 '17 at 07:23
  • 3
    To whoever downvoted me. Why? I had the same issue, trying to convert a string representation of a ruby hash into an actual hash object. I realized that I was trying to solve the wrong problem. I realized that solving the question asked here was error prone and insecure. I realized that I needed to store my data differently and use a format that is designed to safely serialize and deserialize objects. TL;DR: I had the same question as OP, and realized that the answer was to ask a different question. Also, if you down-vote me, please provide feedback so we can all learn together. – Jared Menard Sep 22 '17 at 18:52
  • 3
    Downvoting without an explanatory comment is the cancer of Stack Overflow. – port5432 Sep 23 '17 at 06:25
  • 1
    yes downvoting should require an explanation and show who downvotes. – Nick Res Nov 21 '17 at 17:06
  • 2
    To make this answer even more applicable to the OP's question, if your string representation of a hash is called 'strungout' you should be able to do hashit = JSON.parse(strungout.to_json) and then select your items inside hashit via hashit['keyname'] as normal. – cixelsyd Jan 15 '18 at 20:28
  • 1
    I also use this way – hiphapis Apr 22 '20 at 09:34
  • 1
    this works just perfect, thanks – Vinirdishtith Rana Dec 13 '21 at 21:32
  • 1
    I agree that this is a much better option than eval. Thanks! – vkozyrev Jul 29 '22 at 05:25
27

Maybe YAML.load ?

silent
  • 3,843
  • 23
  • 29
  • 1
    (load method supports strings) – silent Nov 03 '09 at 14:41
  • 5
    That requires a totally different string representation, but it much, much safer. (And the string representation is just as easy to generate -- just call #to_yaml, rather than #inspect) – Ken Bloom Nov 03 '09 at 14:46
  • Wow. I had no idea it was so easy to parse strings w/ yaml. It takes my chain of linux bash commands that generate data and intelligently turns it into a ruby Hash w/o any string format massaging. – labyrinth Aug 29 '17 at 20:56
  • This and to_yaml solves my problem since I have some control of the way the string is generated. Thanks! – mlabarca Feb 26 '20 at 13:51
26

The solutions so far cover some cases but miss some (see below). Here's my attempt at a more thorough (safe) conversion. I know of one corner case which this solution doesn't handle which is single character symbols made up of odd, but allowed characters. For example {:> => :<} is a valid ruby hash.

I put this code up on github as well. This code starts with a test string to exercise all the conversions

require 'json'

# Example ruby hash string which exercises all of the permutations of position and type
# See http://json.org/
ruby_hash_text='{"alpha"=>{"first second > third"=>"first second > third", "after comma > foo"=>:symbolvalue, "another after comma > foo"=>10}, "bravo"=>{:symbol=>:symbolvalue, :aftercomma=>10, :anotheraftercomma=>"first second > third"}, "charlie"=>{1=>10, 2=>"first second > third", 3=>:symbolvalue}, "delta"=>["first second > third", "after comma > foo"], "echo"=>[:symbol, :aftercomma], "foxtrot"=>[1, 2]}'

puts ruby_hash_text

# Transform object string symbols to quoted strings
ruby_hash_text.gsub!(/([{,]\s*):([^>\s]+)\s*=>/, '\1"\2"=>')

# Transform object string numbers to quoted strings
ruby_hash_text.gsub!(/([{,]\s*)([0-9]+\.?[0-9]*)\s*=>/, '\1"\2"=>')

# Transform object value symbols to quotes strings
ruby_hash_text.gsub!(/([{,]\s*)(".+?"|[0-9]+\.?[0-9]*)\s*=>\s*:([^,}\s]+\s*)/, '\1\2=>"\3"')

# Transform array value symbols to quotes strings
ruby_hash_text.gsub!(/([\[,]\s*):([^,\]\s]+)/, '\1"\2"')

# Transform object string object value delimiter to colon delimiter
ruby_hash_text.gsub!(/([{,]\s*)(".+?"|[0-9]+\.?[0-9]*)\s*=>/, '\1\2:')

puts ruby_hash_text

puts JSON.parse(ruby_hash_text)

Here are some notes on the other solutions here

Community
  • 1
  • 1
gene_wood
  • 1,960
  • 4
  • 26
  • 39
  • Very cool solution. You could add a gsub of all `:nil` to `:null` to handle that particular weirdness. – SteveTurczyn May 06 '16 at 13:30
  • 1
    This solution also has the bonus of working on multi-level hashes recursively, since it leverages JSON#parse. I had some trouble with nesting on other solutions. – Patrick Read Dec 13 '16 at 15:50
  • Found the regexes a little hard to follow, so created a gist for this with some test cases. https://gist.github.com/akagr/0339fb80f1b268a48a43ffbd1606cb3b Thanks for answer! – Akash Apr 01 '21 at 12:25
25

This short little snippet will do it, but I can't see it working with a nested hash. I think it's pretty cute though

STRING.gsub(/[{}:]/,'').split(', ').map{|h| h1,h2 = h.split('=>'); {h1 => h2}}.reduce(:merge)

Steps 1. I eliminate the '{','}' and the ':' 2. I split upon the string wherever it finds a ',' 3. I split each of the substrings that were created with the split, whenever it finds a '=>'. Then, I create a hash with the two sides of the hash I just split apart. 4. I am left with an array of hashes which I then merge together.

EXAMPLE INPUT: "{:user_id=>11, :blog_id=>2, :comment_id=>1}" RESULT OUTPUT: {"user_id"=>"11", "blog_id"=>"2", "comment_id"=>"1"}

hrdwdmrbl
  • 4,814
  • 2
  • 32
  • 41
12

works in rails 4.1 and support symbols without quotes {:a => 'b'}

just add this to initializers folder:

class String
  def to_hash_object
    JSON.parse(self.gsub(/:([a-zA-z]+)/,'"\\1"').gsub('=>', ': ')).symbolize_keys
  end
end
Eugene
  • 991
  • 10
  • 10
12

I prefer to abuse ActiveSupport::JSON. Their approach is to convert the hash to yaml and then load it. Unfortunately the conversion to yaml isn't simple and you'd probably want to borrow it from AS if you don't have AS in your project already.

We also have to convert any symbols into regular string-keys as symbols aren't appropriate in JSON.

However, its unable to handle hashes that have a date string in them (our date strings end up not being surrounded by strings, which is where the big issue comes in):

string = '{'last_request_at' : 2011-12-28 23:00:00 UTC }' ActiveSupport::JSON.decode(string.gsub(/:([a-zA-z])/,'\\1').gsub('=>', ' : '))

Would result in an invalid JSON string error when it tries to parse the date value.

Would love any suggestions on how to handle this case

c.apolzon
  • 406
  • 4
  • 6
  • 2
    Thanks for the pointer to .decode, it worked great for me. I needed to convert a JSON response to test it. Here's the code I used: `ActiveSupport::JSON.decode(response.body, symbolize_keys: true)` – Andrew Philips Dec 03 '13 at 05:01
4

Please consider this solution. Library+spec:

File: lib/ext/hash/from_string.rb:

require "json"

module Ext
  module Hash
    module ClassMethods
      # Build a new object from string representation.
      #
      #   from_string('{"name"=>"Joe"}')
      #
      # @param s [String]
      # @return [Hash]
      def from_string(s)
        s.gsub!(/(?<!\\)"=>nil/, '":null')
        s.gsub!(/(?<!\\)"=>/, '":')
        JSON.parse(s)
      end
    end
  end
end

class Hash    #:nodoc:
  extend Ext::Hash::ClassMethods
end

File: spec/lib/ext/hash/from_string_spec.rb:

require "ext/hash/from_string"

describe "Hash.from_string" do
  it "generally works" do
    [
      # Basic cases.
      ['{"x"=>"y"}', {"x" => "y"}],
      ['{"is"=>true}', {"is" => true}],
      ['{"is"=>false}', {"is" => false}],
      ['{"is"=>nil}', {"is" => nil}],
      ['{"a"=>{"b"=>"c","ar":[1,2]}}', {"a" => {"b" => "c", "ar" => [1, 2]}}],
      ['{"id"=>34030, "users"=>[14105]}', {"id" => 34030, "users" => [14105]}],

      # Tricky cases.
      ['{"data"=>"{\"x\"=>\"y\"}"}', {"data" => "{\"x\"=>\"y\"}"}],   # Value is a `Hash#inspect` string which must be preserved.
    ].each do |input, expected|
      output = Hash.from_string(input)
      expect([input, output]).to eq [input, expected]
    end
  end # it
end
Alex Fortuna
  • 1,223
  • 12
  • 16
  • 1
    `it "generally works"` but not necessarily? I would be more verbose in those tests. `it "converts strings to object" { expect('...').to eql ... }` `it "supports nested objects" { expect('...').to eql ... }` – Lex Jun 14 '18 at 01:30
  • Hey @Lex, what method does is described in its RubyDoc comment. The test better not re-state it, it'll create unnecessary details as passive text. Thus, "generally works" is a nice formula to state that stuff, well, generally works. Cheers! – Alex Fortuna Jun 14 '18 at 12:57
  • Yeah, at the end of the day whatever works. Any tests are better than no tests. Personally I'm a fan of explicit descriptions, but thats just a preference. – Lex Jun 14 '18 at 22:28
3

Here is a method using whitequark/parser which is safer than both gsub and eval methods.

It makes the following assumptions about the data:

  1. Hash keys are assumed to be a string, symbol, or integer.
  2. Hash values are assumed to be a string, symbol, integer, boolean, nil, array, or a hash.
# frozen_string_literal: true

require 'parser/current'

class HashParser
  # Type error is used to handle unexpected types when parsing stringified hashes.
  class TypeError < ::StandardError
    attr_reader :message, :type

    def initialize(message, type)
      @message = message
      @type = type
    end
  end

  def hash_from_s(str_hash)
    ast = Parser::CurrentRuby.parse(str_hash)

    unless ast.type == :hash
      puts "expected data to be a hash but got #{ast.type}"
      return
    end

    parse_hash(ast)
  rescue Parser::SyntaxError => e
    puts "error parsing hash: #{e.message}"
  rescue TypeError => e
    puts "unexpected type (#{e.type}) encountered while parsing: #{e.message}"
  end

  private

  def parse_hash(hash)
    out = {}
    hash.children.each do |node|
      unless node.type == :pair
        raise TypeError.new("expected child of hash to be a `pair`", node.type)
      end

      key, value = node.children

      key = parse_key(key)
      value = parse_value(value)

      out[key] = value
    end

    out
  end

  def parse_key(key)
    case key.type
    when :sym, :str, :int
      key.children.first
    else
      raise TypeError.new("expected key to be either symbol, string, or integer", key.type)
    end
  end

  def parse_value(value)
    case value.type
    when :sym, :str, :int
      value.children.first
    when :true
      true
    when :false
      false
    when :nil
      nil
    when :array
      value.children.map { |c| parse_value(c) }
    when :hash
      parse_hash(value)
    else
      raise TypeError.new("value of a pair was an unexpected type", value.type)
    end
  end
end

and here are some rspec tests verifying that it works as expected:

# frozen_string_literal: true

require 'spec_helper'

RSpec.describe HashParser do
  describe '#hash_from_s' do
    subject { described_class.new.hash_from_s(input) }

    context 'when input contains forbidden types' do
      where(:input) do
        [
          'def foo; "bar"; end',
          '`cat somefile`',
          'exec("cat /etc/passwd")',
          '{:key=>Env.fetch("SOME_VAR")}',
          '{:key=>{:another_key=>Env.fetch("SOME_VAR")}}',
          '{"key"=>"value: #{send}"}'
        ]
      end

      with_them do
        it 'returns nil' do
          expect(subject).to be_nil
        end
      end
    end

    context 'when input cannot be parsed' do
      let(:input) { "{" }

      it 'returns nil' do
        expect(subject).to be_nil
      end
    end

    context 'with valid input' do
      using RSpec::Parameterized::TableSyntax

      where(:input, :expected) do
        '{}'                          | {}
        '{"bool"=>true}'              | { 'bool' => true }
        '{"bool"=>false}'             | { 'bool' => false }
        '{"nil"=>nil}'                | { 'nil' => nil }
        '{"array"=>[1, "foo", nil]}'  | { 'array' => [1, "foo", nil] }
        '{foo: :bar}'                 | { foo: :bar }
        '{foo: {bar: "bin"}}'         | { foo: { bar: "bin" } }
      end

      with_them do
        specify { expect(subject).to eq(expected) }
      end
    end
  end
end
Brian Williams
  • 226
  • 1
  • 2
2

I built a gem hash_parser that first checks if a hash is safe or not using ruby_parser gem. Only then, it applies the eval.

You can use it as

require 'hash_parser'

# this executes successfully
a = "{ :key_a => { :key_1a => 'value_1a', :key_2a => 'value_2a' }, 
       :key_b => { :key_1b => 'value_1b' } }"
p HashParser.new.safe_load(a)

# this throws a HashParser::BadHash exception
a = "{ :key_a => system('ls') }"
p HashParser.new.safe_load(a)

The tests in https://github.com/bibstha/ruby_hash_parser/blob/master/test/test_hash_parser.rb give you more examples of the things I've tested to make sure eval is safe.

Bibek Shrestha
  • 32,848
  • 7
  • 31
  • 34
2

This method works for one level deep hash


  def convert_to_hash(str)
    return unless str.is_a?(String)

    hash_arg = str.gsub(/[^'"\w\d]/, ' ').squish.split.map { |x| x.gsub(/['"]/, '') }
    Hash[*hash_arg]
  end

example


> convert_to_hash("{ :key_a => 'value_a', :key_b => 'value_b', :key_c => '' }")
=> {"key_a"=>"value_a", "key_b"=>"value_b", "key_c"=>""}


Shiva
  • 11,485
  • 2
  • 67
  • 84
1

Ran across a similar issue that needed to use the eval().

My situation, I was pulling some data from an API and writing it to a file locally. Then being able to pull the data from the file and use the Hash.

I used IO.read() to read the contents of the file into a variable. In this case IO.read() creates it as a String.

Then used eval() to convert the string into a Hash.

read_handler = IO.read("Path/To/File.json")

puts read_handler.kind_of?(String) # Returns TRUE

a = eval(read_handler)

puts a.kind_of?(Hash) # Returns TRUE

puts a["Enter Hash Here"] # Returns Key => Values

puts a["Enter Hash Here"].length # Returns number of key value pairs

puts a["Enter Hash Here"]["Enter Key Here"] # Returns associated value

Also just to mention that IO is an ancestor of File. So you can also use File.read instead if you wanted.

TomG
  • 1,019
  • 1
  • 8
  • 6
1

I had a similar issue when trying to convert a string to a hash in Ruby.

The result from my computations was this:

{
 "coord":{"lon":24.7535,"lat":59.437},
 "weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],
 "base":"stations",
 "main":{"temp":283.34,"feels_like":281.8,"temp_min":282.33,"temp_max":283.34,"pressure":1021,"humidity":53},
 "visibility":10000,
 "wind":{"speed":3.09,"deg":310},
 "clouds":{"all":75},
 "dt":1652808506,
 "sys":{"type":1,"id":1330,"country":"EE","sunrise":1652751796,"sunset":1652813502},
 "timezone":10800,"id":588409,"name":"Tallinn","cod":200
 }

I checked the type value and confirmed that it was of the String type using the command below:

result = 
{
 "coord":{"lon":24.7535,"lat":59.437},
 "weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],
 "base":"stations",
 "main":{"temp":283.34,"feels_like":281.8,"temp_min":282.33,"temp_max":283.34,"pressure":1021,"humidity":53},
 "visibility":10000,
 "wind":{"speed":3.09,"deg":310},
 "clouds":{"all":75},
 "dt":1652808506,
 "sys":{"type":1,"id":1330,"country":"EE","sunrise":1652751796,"sunset":1652813502},
 "timezone":10800,"id":588409,"name":"Tallinn","cod":200
 }

puts result.instance_of? String
puts result.instance_of? Hash

Here's how I solved it:

All I had to do was run the command below to convert it from a String to a Hash:

result_new = JSON.parse(result, symbolize_names: true)

And then checked the type value again using the commands below:

puts result_new.instance_of? String
puts result_new.instance_of? Hash

This time it returned true for the Hash

Promise Preston
  • 24,334
  • 12
  • 145
  • 143
1

I came to this question after writing a one-liner for this purpose, so I share my code in case it helps somebody. Works for a string with only one level depth and possible empty values (but not nil), like:

"{ :key_a => 'value_a', :key_b => 'value_b', :key_c => '' }"

The code is:

the_string = '...'
the_hash = Hash.new
the_string[1..-2].split(/, /).each {|entry| entryMap=entry.split(/=>/); value_str = entryMap[1]; the_hash[entryMap[0].strip[1..-1].to_sym] = value_str.nil? ? "" : value_str.strip[1..-2]}
Pablo
  • 2,834
  • 5
  • 25
  • 45