Metaprogramming in Ruby: Advanced Level

  • June 30, 2023
  • Royce Threadgill
  • 9 min read

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” is a great place to get started. For those seeking a more practical example of metaprogramming, check out “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, we discussed how to use Ruby define_method and send to dynamically create methods. And in the intermediate level post, 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: An authentication library designed for Rails
  • factory_bot: A fixtures replacement
  • 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, which uses define_method to create the path helpers, like so:

# 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:

# 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:

# 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.

# 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!

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, for example, the implementation is spelled out in code comments.

Let’s create an example for illustration:

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:

# 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.

# 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 Examples (i.e., tests) for the RSpec::ExampleGroups::PostsIndex subclass:

# 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!

# 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.

Interested in building with us?