Using OrderedOption to access hash values as methods

How to Access Hash Values with Methods Using OrderedOptions

Have you ever wanted to create a hash where you could access the values like methods on an object? The OrderedOptions class in Rails lets you do just that. This post shows you how. We'll also explore how Rails implements this feature using Ruby's metaprogramming features.

5 min read

Using the ActiveSupport::OrderedOptions class instead of a regular Hash, you can access the Hash values just like the methods on an object. It's especially useful for configuration-like objects. Rails makes abundant use of this class to configure its sub-frameworks like ActionView and ActiveStorage.

Consider a simple Hash in Ruby.

config = {
  api_key: 'my-api-key',
  api_secret: 'my-api-secret'
}

config[:api_key]    # 'my-api-key'
config[:api_secret] # 'my-api-secret'

This is fine, but wouldn't it be nice, if you could simply call the methods api_key and api_secret on the config object?

Using OrderedOptions, you can write:

require "active_support/ordered_options"

config = ActiveSupport::OrderedOptions.new

# set the values
config.api_key = "my-api-key"
config.api_secret = "my-api-secret"

# access the values
config.api_key  # => 'my-api-key'
config.api_secret # => 'my-api-secret'

# Use the bang-version to raise an error when the value is blank
config.password! # => raises KeyError: :password is blank

Pretty nice, right?

That said, I don't think it'd be wise to replace all your existing Hashes with instances of OrderedOption. However, if you're building some sort of internal API or providing configuration access to your internal library, it makes perfect sense to make your users' lives easier by letting them use method calls. This is exactly what Rails does.

For example, you must have configured various Rails sub-frameworks as follows:

config.action_controller.perform_caching = true

# OR

config.active_storage.service = :local

Behind the scenes, Rails implements config.active_storage and config.action_controller as instances of OrderedOptions, so you could just call methods like perform_caching and service on them.

# activestorage/lib/active_storage/engine.rb

module ActiveStorage
  class Engine < Rails::Engine
    config.active_storage = ActiveSupport::OrderedOptions.new
    config.active_storage.paths = ActiveSupport::OrderedOptions.new
  end
end

Sweet. I love the efforts Rails takes to provide a clean API and to make developers' lives easier.

How OrderedOptions is Implemented

Behind the scenes, Rails implements this feature using metaprogramming in Ruby. Let's take a look at its source code. Specifically, we'll explore what happens when you call a method on an OrderedOptions object.

module ActiveSupport
  class OrderedOptions < Hash            # (1)
  
    def method_missing(name, *args)      # (2)
      name_string = +name.to_s           # (3)
      if name_string.chomp!("=")         # (4)
        self[name_string] = args.first
      else
        bangs = name_string.chomp!("!")  # (5)

        if bangs
          self[name_string].presence || raise(KeyError.new(":#{name_string} is blank"))
        else
          self[name_string]
        end
      end
    end
  end
end

There're quite a few interesting things going on here, which I've marked with numbers in the comments. Let's explore each.

  1. OrderedOptions inherits from Hash, so all the standard methods you expect on a Hash are available on OrderedOptions. In addition, this class overrides a few of Hash's methods such as dig, providing its custom implementation.
  2. Notice that this feature is implemented using the method_missing method, which gets called whenever the Ruby interpreter cannot find a method you called on an object. It's basically a catch-all for missing methods. Since you can't know in advance what keys you'll have on the objects, Rails uses this method to intercept all method calls and provide dynamic behavior.
  3. Note the + operator behind name. It creates a mutable copy from a frozen string, so you can modify it in place. See the notes section below for a code example showing how it works.
  4. It uses the chomp!("=") method to remove the trailing = character. This is done to figure out if the method call is a getter or setter. If it's a setter, it will simply assign the provided argument (the value) to the key.
  5. If it's a getter method, then it checks if it ends with a bang (!). If it does, then it tries to find the key and will raise an error if the key doesn't exist. Otherwise, it returns the value without raising an error.

For more details on metaprogramming in Ruby, read my notes from the Metaprogramming Ruby 2 book.

Metaprogramming in Ruby
Metaprogramming in Ruby enables you to produce elegant, clean, and beautiful programs as well as unreadable, complex code that’s not maintainable. This book teaches you the powerful metaprogramming concepts in Ruby, and how to use them judiciously.

Fun fact: Today I learned that if you pass an argument to the chomp! method, it will remove the trailing occurrence of that argument. So far, I had only used it to remove the newlines from the terminal inputs.

irb(main):001:0> name = "value="
"value="
irb(main):002:0> name.chomp
"value="
irb(main):003:0> name.chomp("=")
"value"
irb(main):004:0> name.chomp!("=")
"value"
irb(main):005:0> "hello=world".chomp("=")
"hello=world"

Nice!

Real-World Usage

Propshaft is a new library that delivers assets for Rails. It uses OrderedOptions to define the config.assets settings, instead of creating a new configuration object. You can read the complete source on Github.

module Propshaft
  class Railtie < ::Rails::Railtie
    config.assets = ActiveSupport::OrderedOptions.new
  
    config.assets.paths          = []
    config.assets.excluded_paths = []
    config.assets.version        = "1"
    config.assets.prefix         = "/assets"
  end
end

Once again, having this option doesn’t mean you have to replace all your hashes with instances of OrderedOptions. It’s better to use them with configuration-like objects, especially when you're building internal APIs or gems, which often results in more readable code for your users.

Notes

  • What's the + operator doing behind the string?

It creates a mutable copy of the string from a frozen string. This lets you modify it in place. The following example will help you understand it better.

irb(main):013:0> name = "foo!".freeze
"foo!"
irb(main):014:0> name.chomp!("!")
(irb):14:in `chomp!': can't modify frozen String: "foo!" (FrozenError)
irb(main):015:0> unfrozen_name = +name
"foo!"
irb(main):016:0> unfrozen_name.chomp!("!")
"foo"
  • How does OrderedOptions differ from Ruby's OpenStruct? When would you use one over the other?

That's a great question: I actually didn't think about the difference between OrderedOptions and OpenStruct until after someone on Reddit raised a similar question on my post. An interesting discussion followed on that thread that you can look into.

I've also asked a question on the Rails forum. Hopefully, someone will provide more details soon.

Meanwhile, my understanding after a little research I did yesterday is that OpenStruct has performance drawbacks. Here're some posts you may find helpful.

  1. Alternatives for Ruby's OpenStruct
  2. How OpenStruct Can Kill Performance

That's a wrap. Let me know what you think about this approach.


I hope you liked this article and you learned something new.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.