Metaprogramming in Ruby: Advanced Level
Tags: ruby, rails, programming
Published: 2023-06-30 19:15:47

_This post is the third in a series focused on the application of Ruby metaprogramming. If you’re just starting to learn about metaprogramming, “[Metaprogramming in Ruby: Beginner Level](https://www.thegnar.com/blog/metaprogramming-in-ruby-beginner-level)” is a great place to get started. For those seeking a more practical example of metaprogramming, check out “[Metaprogramming in Ruby: Intermediate Level](https://www.thegnar.com/blog/metaprogramming-in-ruby-intermediate-level)”. In this article, we’ll discuss how a few popular Ruby gems make use of metaprogramming to solve everyday programming problems._

When you go out for a walk, do you bring a paddle with you? Of course not! A paddle would be pointless. It’s only when you’re up the creek that a paddle is invaluable. 

So too it goes with metaprogramming.

Welcome to the final installment of our _Metaprogramming in Ruby_ series. Up to this point, we’ve been going through relatively simple, handcrafted Ruby metaprogramming examples. In the [beginner level post](https://www.thegnar.com/blog/metaprogramming-in-ruby-beginner-level), we discussed how to use Ruby `define_method` and `send` to dynamically create methods. And in the [intermediate level post](https://www.thegnar.com/blog/metaprogramming-in-ruby-intermediate-level), we put those concepts into practice with a slightly more practical example.

This time, we’re going to look at how metaprogramming is used in the wild by diving into the code underlying popular Ruby gems. The libraries we’ll be looking at are:

* [devise](https://github.com/heartcombo/devise): An authentication library designed for Rails
* [factory_bot](https://github.com/thoughtbot/factory_bot): A fixtures replacement
* [rspec](https://github.com/rspec): A testing library


## Ruby metaprogramming in the `devise` gem

Of the gems we’re analyzing today, this is probably the most straightforward example of metaprogramming in Ruby. 

When initially setting up devise in your project, you generally run `rails generate devise MODEL`, where `MODEL` is the name of your devise model. So if you wanted your users to have a model name of `User` , you’d run `rails generate devise User`. 

To make this example a little clearer, let’s assume you run `rails generate devise Gnarnian`, which will make your devise model `Gnarnian` (that’s what we call ourselves at Gnar).

After migrating, you’ll get access to a slew of helpers created by the generator, including some nifty url and path helpers like `new_gnarnian_session_path`, `gnarnian_session_path`, and `destroy_gnarnian_session_path`. 

But that’s not all! As you add third-party sign in methods to your `app/models/gnarnian.rb` file, you additionally get access to _those_ path helpers! For instance, you can add `:omniauthable, omniauth_providers: [:google_oauth2]` to your devise modules to set up Google sign in; that should provide you with `user_google_oauth2_omniauth_authorize_path` for redirecting users to Google sign in.

At its core, this is made possible by [lib/devise/controllers/url_helpers.rb](https://github.com/heartcombo/devise/blob/ec0674523e7909579a5a008f16fb9fe0c3a71712/lib/devise/controllers/url_helpers.rb), which uses `define_method` to create the path helpers, like so:

```ruby
# lib/devise/controllers/url_helpers.rb

def self.generate_helpers!(routes = nil)
  routes ||= begin
    mappings = Devise.mappings.values.map(&:used_helpers).flatten.uniq
    Devise::URL_HELPERS.slice(*mappings)
  end

  routes.each do |module_name, actions|
    [:path, :url].each do |path_or_url|
      actions.each do |action|
        action = action ? "#{action}_" : ""
        method = :"#{action}#{module_name}_#{path_or_url}"

        define_method method do |resource_or_scope, *args|
          scope = Devise::Mapping.find_scope!(resource_or_scope)
          router_name = Devise.mappings[scope].router_name
          context = router_name ? send(router_name) : _devise_route_context
          context.send("#{action}#{scope}_#{module_name}_#{path_or_url}", *args)
        end
      end
    end
  end
end
```

## Ruby metaprogramming in the `factory_bot` gem

Factory Bot is considered by many to be a must-have tool for testing Rails projects. The `factory_bot` gem actually uses metaprogramming concepts in a couple of places, but here we’ll focus on the use of traits, callbacks, and the `evaluator` argument.

For the sake of example, let’s say you have `articles` and `authors` tables. Each `Article` belongs to a specific `Author` and each `Author` can have many `Articles`. You could create a basic factory like so:

```ruby
# spec/factories/authors.rb

FactoryBot.define do
  factory :article do
    title { "MyString" }
    body { "MyText" }
    published { false }
    author
  end
end
```

But Factory Bot also provides callbacks that allow you to execute code for a given strategy. So if you’d like to run code after calling `create`, then you could write something like `after(:create) do <something>`.

Now let’s assume an `Author` has written `Books` that you need to access in your tests; to generate `Books` written by your `Author`, you could make something like:

```ruby
# spec/factories/authors.rb

FactoryBot.define do
  factory :author do
    transient do
      book_names { ['The Color of Magic'] }
    end

    name { 'Terry Pratchett' }
  end

  trait :has_books do
    after(:create) do |author, evaluator|
      Array(evaluator.book_names).each do |book_name|
        create(:book, name: book_name, author: )
      end
    end
  end
end
```

Notice that you can use `evaluator` to access transient properties. So in your test, you could write `create(:author, :has_books, book_names: ['Equal Rites', 'Mort'])`, which would create two books with the provided names and the same `Author`.

This flexible and powerful behavior is facilitated by the Ruby `define_method` and `method_missing` blocks found in [factory_bot/lib/factory_bot/evaluator.rb](https://github.com/thoughtbot/factory_bot/blob/4a37cb64090d6354a719e8c5ef73653f5d242017/lib/factory_bot/evaluator.rb).

```ruby
# lib/factory_bot/evaluator.rb (selected snippets)

def method_missing(method_name, *args, &block)
  if @instance.respond_to?(method_name)
    @instance.send(method_name, *args, &block)
  else
    SyntaxRunner.new.send(method_name, *args, &block)
  end
end

def self.define_attribute(name, &block)
  if instance_methods(false).include?(name) || private_instance_methods(false).include?(name)
    undef_method(name)
  end

  define_method(name) do
    if @cached_attributes.key?(name)
      @cached_attributes[name]
    else
      @cached_attributes[name] = instance_exec(&block)
    end
  end
end
```

For more detailed examples, check out the [getting started docs](https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#with-callbacks)!


## Ruby metaprogramming in the `rspec-core` gem

At long last, we’ve arrived at our final Ruby metaprogramming example: the 800-lb gorilla that is `rspec` and its many associated gems. Of the three examples discussed here, `rspec` uses metaprogramming most extensively. This makes some intuitive sense given that we’re talking about a full-fledged testing framework.

That being said, the code and documentation for `rspec` is generally clear and descriptive, making it a fantastic repository for learning about metaprogramming techniques. In [rspec-core/lib/rspec/core/dsl.rb](https://github.com/rspec/rspec-core/blob/8caecca0b9b299ccbaa5c7ea5dd885ab42cd57d3/lib/rspec/core/dsl.rb), for example, the implementation is spelled out in code comments.

Let’s create an example for illustration:

```ruby
RSpec.describe "posts/index", type: :view do
  it "renders a list of posts" do
    # ...something
  end
end

# Creates RSpec::ExampleGroups::PostsIndex
# $ RSpec::ExampleGroups::PostsIndex.describe
# $  => RSpec::ExampleGroups::ArticlesIndex::Anonymous
# $ RSpec::ExampleGroups::PostsIndex.examples
# $  => [#<RSpec::Core::Example "renders a list of posts">]
```

For each spec, RSpec will construct the `RSpec::ExampleGroups::Example` subclass. Note as well that `RSpec::ExampleGroups::Examples::Anonymous.describe` will increment the id appended to the end of `RSpec::ExampleGroups::Examples::Anonymous` (e.g., `Anonymous_2`, `Anonymous_3`, `Anonymous_4`, etc.).

When you create your spec file with `RSpec.describe`, behind the scenes your arguments are being passed to `dsl.rb`:

```ruby
# rspec-core/lib/rspec/core/dsl.rb
# In this case, `args` are `["posts/index", {:type=>:view}]`
# and `example_group_block` is the block of code representing your spec 

(class << RSpec; self; end).__send__(:define_method, name) do |*args, &example_group_block|
  group = RSpec::Core::ExampleGroup.__send__(name, *args, &example_group_block)
  RSpec.world.record(group)
  group
end
```

Those arguments are then passed to `RSpec::Core::ExampleGroup`, which determines if the block is top-level and, if so, defines a new `ExampleGroup` subclass.

```ruby
# rspec-core/lib/rspec/core/example_group.rb (selected snippets)
# In this case, `name` is `:describe`

def self.define_example_group_method(name, metadata={})
  idempotently_define_singleton_method(name) do |*args, &example_group_block|
    thread_data = RSpec::Support.thread_local_data
    top_level   = self == ExampleGroup

    registration_collection =
      if top_level
        if thread_data[:in_example_group]
          raise "Creating an isolated context from within a context is " \
                "not allowed. Change `RSpec.#{name}` to `#{name}` or " \
                "move this to a top-level scope."
        end

        thread_data[:in_example_group] = true
        RSpec.world.example_groups
      else
        children
      end

    begin
      description = args.shift
      combined_metadata = metadata.dup
      combined_metadata.merge!(args.pop) if args.last.is_a? Hash
      args << combined_metadata

      subclass(self, description, args, registration_collection, &example_group_block)
    ensure
      thread_data.delete(:in_example_group) if top_level
    end
  end

  RSpec::Core::DSL.expose_example_group_alias(name)
end

def self.subclass(parent, description, args, registration_collection, &example_group_block)
  subclass = Class.new(parent)
  subclass.set_it_up(description, args, registration_collection, &example_group_block)
  subclass.module_exec(&example_group_block) if example_group_block

  # The LetDefinitions module must be included _after_ other modules
  # to ensure that it takes precedence when there are name collisions.
  # Thus, we delay including it until after the example group block
  # has been eval'd.
  MemoizedHelpers.define_helpers_on(subclass)

  subclass
end
```

The returned subclass, in this case, would be `RSpec::ExampleGroups::PostsIndex`. Calling `subclass.module_exec` will step through the `example_group_block` and define `Example`s (i.e., tests) for the `RSpec::ExampleGroups::PostsIndex` subclass:

```ruby
# rspec-core/lib/rspec/core/example_group.rb
# In this case, `name` is `:it`
# and `all_args` is `renders a list of posts`

def self.define_example_method(name, extra_options={})
  idempotently_define_singleton_method(name) do |*all_args, &block|
    desc, *args = *all_args

    options = Metadata.build_hash_from(args)
    options.update(:skip => RSpec::Core::Pending::NOT_YET_IMPLEMENTED) unless block
    options.update(extra_options)

    RSpec::Core::Example.new(self, desc, options, block)
  end
end
```

Now that the `ExampleGroups` subclass (i.e., the spec) and its `Examples` (i.e., the tests within the spec) have been defined, the runner is called to execute the tests!

```ruby
# rspec-core/lib/rspec/core/runner.rb
# If we're running only this spec, `example_groups` would be
# a single-member array containing `RSpec::ExampleGroups::PostsIndex`

def run_specs(example_groups)
  examples_count = @world.example_count(example_groups)
  examples_passed = @configuration.reporter.report(examples_count) do |reporter|
    @configuration.with_suite_hooks do
      if examples_count == 0 && @configuration.fail_if_no_examples
        return @configuration.failure_exit_code
      end

      example_groups.map { |g| g.run(reporter) }.all?
    end
  end

  exit_code(examples_passed)
end
```

## Is metaprogramming in Ruby pointless?

If you’ve read all three installments in this series, then firstly – thank you! We hope we adequately addressed your Ruby metaprogramming questions, or at least inspired curiosity.

Secondly, you’ve probably noticed we reiterated _ad nauseum_ that metaprogramming is a “break glass in case of emergency” kind of tool.

So let’s wrap this up by addressing the elephant in the room one last time: is Ruby metaprogramming useful to the average developer? Our hope is that these three articles have proven to you that the answer should be an emphatic, “YES”....

…followed by a cautiously muttered, “under the right circumstances and for specific use-cases.”

_Learn more about how The Gnar [builds Ruby on Rails applications](https://www.thegnar.com/software-development/ruby-on-rails-development)._