Rails Hash to Struct - A monkey patch
Tags: ruby, rails
Published: 2022-09-24 16:02:06


The Hobby Developer(me!) has been busy refactoring a few my sites. I've mentioned in a posts about using serialized fields in a model [Rails - Using the Attributes API to manage serialized preferences](https://dev.to/salex/rails-using-the-attributes-api-to-manage-serialized-preferences-1cof). Most were just adding a serialize attribute in a model `serialize :settings, ActiveSupport::HashWithIndifferentAccess`. Rails 7.0.4 kind of mucked that up. I had been using HashWithIndifferentAccess because I like using symbols in a hash versus 'strings'. Rails serializes hashes using YAML. YAML had a security bug and Rails fixed it by requiring you to explicitly define what will be serialized. That took a little while to get right, but in the talk about the bug, they basically said: 'why not just use JSON'.

That's a little of what I've been refactoring. I'm still trying to figure out change a few attributes from HashWithIndifferentAccess to JSON. I'm afraid its going to be something like:

* Take the server down
* Remove `serialize :settings, ActiveSupport::HashWithIndifferentAccess`
* Deploy conversion version parse the YMAL and save as JSON
* Add `serialize :settings, JSON` and redeploy.

It will Probably take 15 minutes, but I want to think about it a little more.

What I've been doing recently is converting some of these setting/preference Hashes to Struct (originally OpenStruct - but abandoned that). Again, it's just a personal preference, I prefer `settings.acct_placeholders` than `settings['acct_placeholders']`.I originally did this using a Monkey Patch I stuck in config/initializers.

```ruby
Hash.class_eval do
  def to_struct
    Struct.new(*keys.map(&:to_sym)).new(*values)
  end
end
```

Probably not a good idea, but it worked for a simple hash, but not a nested hash. In reading a little more about Monkey Patching in [Monkey patching in Rails](http://dixpac.github.io/monkey-patching-in-rails/) and [3 Ways to Monkey-Patch Without Making a Mess](https://www.justinweiss.com/articles/3-ways-to-monkey-patch-without-making-a-mess/), I decided to do it the Rails way using modules.

I added a folder to /lib `core_extensions` and two sub-folder `hash` and `array`. In the subfolders and added my monkey patches.

* core_extensions
  * hash
    * as_struct.rb
    * to_struct.rb
  * array
    * test_array.rb

I just added the array as a proof of concept.
```ruby
# just a proof of concept
module CoreExtensions
  module Array
    def test_array
      puts "test_array"
      self
    end
  end
end
```

To get these patches to work, you have to load the patches, so in config/initializers I added `monkey_patches.rb`
```ruby
# config/initializers/money_patches.rb
# Require all Ruby files in the core_extensions directory by class
Dir[Rails.root.join('lib', 'core_extensions/*', '*.rb')].each { |f| require f }

# Apply the monkey patches
Array.include CoreExtensions::Array
Hash.include CoreExtensions::Hash
```

For the `hash.to_struct` patch I ended up with two patches: .to_struct and .as_struct. This is a spinoff and Rails .to_json and .as_json. One (.as_json) sanitizes a hash and the other does the conversion.

```ruby
# /lib/core_extensins/as_struct.rb
# convert Hash to Struct on a single level
module CoreExtensions
  module Hash
    def as_struct
      Struct.new(*keys.map(&:to_sym)).new(*values)
    end
  end
end
```
```ruby
# /lib/core_extensins/to_struct.rb
# convert Hash to a nested Struct 
module CoreExtensions
  module Hash
    def to_struct
      hash_to_struct(self)
    end

    private

    def hash_to_struct(ahash)
      struct = ahash.as_struct # convert to struct
      struct.members.each do |m|
        if struct[m].is_a? Hash
          struct[m] = hash_to_struct(struct[m]) # nested hash, recursive call
        elsif struct[m].is_a? Array 
          # look for hashes in an array and convert to struct
          struct[m].each_index do |i|
            # normal use, an array of hashes
            struct[m][i] = hash_to_struct(struct[m][i]) if struct[m][i].is_a? Hash
            # convoluded use, an array that may contain hash(es)
            struct[m][i] = hash_in_array(struct[m][i]) if struct[m][i].is_a? Array
          end
        end
      end
      struct 
    end

    def hash_in_array(arr)
      arr.each_index do |ii|
        arr[ii] = hash_to_struct(arr[ii]) if arr[ii].is_a? Hash 
      end
      arr
    end 
  end
end
```

So if I define a convoluted nested Hash (I wouldn't do this... but again proof of concept)
```ruby
h = {
  game:{id:1,date:'2022-09-11',player:6},
  players:[{name:'Joe',quota:21},{name:'Harry',quota:26},{name:'Pete',quota:14},
    {name:'don',quota:21},{name:'sally',quota:26},{name:'red',quota:14}],
  teams:[['joe','don',team:{a:1,b:2,c:3}],['harry','sally',lost:{skins:2,par3:9}],['pete','red']]}
```
and call `s = h.to_struct`, I get a convoluted Struct:
```ruby
<struct                                               
 game=<struct  id=1, date="2022-09-11", player=6>,    
 players=                                              
  [<struct  name="Joe", quota=21>,                    
   <struct  name="Harry", quota=26>,                  
   <struct  name="Pete", quota=14>,                   
   <struct  name="don", quota=21>,                    
   <struct  name="sally", quota=26>,                  
   <struct  name="red", quota=14>],                   
 teams=                                                
  [["joe", "don", <struct  team=<struct  a=1, b=2, c=3>>],
   ["harry", "sally", <struct  lost=<struct  skins=2, par3=9>>],
   ["pete", "red"]]>     
```

So
```ruby
  # s.game returns 
  <struct  id=1, date="2022-09-11", player=6>
  # s.game.date return
  "2022-09-11"
```

That it!

Again, I'm just a hobbyist and my Ruby skill are not deep, but a lot better that what I knew 12 years ago.

Any comments?