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.
OrderedOptions
inherits fromHash
, so all the standard methods you expect on aHash
are available onOrderedOptions
. In addition, this class overrides a few of Hash's methods such asdig
, providing its custom implementation.- 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. - Note the
+
operator behindname
. 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. - 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. - 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.
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'sOpenStruct
? 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.
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.