article illustration

Meta-Programming In Ruby: A Simple DSL for Boolean Timestamps

Marc Chamberlin
Marc Chamberlin
03 Jun 2024 5 min

Meta-programming is one of my most favorite concepts in Ruby. It’s a programming technique that allows a program to manipulate itself. Ruby already provides a lot of sharp knives, but with meta-programming, you can create your own.

In this article, we will explore some of the basics of metaprogramming in Ruby and see how we can use it to create a simple DSL (Domain Specific Language) for a common Ruby on Rails usecase.

DSL? What’s That?

A DSL is a small language that is designed to solve a specific problem. It’s a way to make your code more expressive and easier to read. The most popular example of a DSL in Web development is HTML. HTML is a language that is designed to describe the structure of a web page with XML-like tags.

DSL can be even more specific. One of the reason of Rails’ success is its model definition syntax. When you define a model in Rails, you use a DSL that looks like this:

class Article < ApplicationRecord
  belongs_to :author
  has_many :comments
  validates :title, presence: true
end

belongs_to, has_many and validates are actually methods which really look like keywords in a natural language. This not only makes the code easier to read and understand but also creates many methods behind the scenes that interact with the database. Rails also scans the database schema to create methods (like getters and setters) based on the columns of the table.

Here are few examples of what Rails does behind the scene when you define a model:

article = author.articles.create(title: "Hello, World!")
article.title # => "Hello, World!"
article.title = "Yet Another Article"
article.title_changed? # => true
article.title_was # => "Hello, World!"

You don’t need meta-programming to create a DSL, but you can create powerfull DSL with it. Let’s see how to some basic meta-programming techniques in Ruby with a real-world example.

Creating A DSL For Boolean Timestamps

Real-World Example

In the previous article, we saw how to save booleans in the database using a timestamp column. Here is a User model with a confirmed_at column with multiple convenience methods to handle the confirmation status of a user:

class User < ApplicationRecord
  # Class methods to fetch only confirmed users
  def self.confirmed
    where.not(confirmed_at: nil)
  end

  # Instance methods to check if a user is confirmed
  def confirmed?
    self.confirmed_at.present?
  end

  # Setter for confirmation status
  def confirmed=(value)
    self.confirmed_at = if value
      Time.now
    else
      nil
    end
  end

  # Instance method to confirm a user in database
  def mark_as_confirmed!
    update!(confirmed_at: Time.now)
  end

  # Instance method to unconfirm a user in database
  def unmark_as_confirmed!
    update!(confirmed_at: nil)
  end
end

The Boolean Timestamp DSL

Let’s find a place to put our code. Ruby on Rails introduced the concerns directory to store shared code between models. Rails concerns, essentially Ruby mixins with syntactic sugar, provide an ideal location for our code. Let’s create a new concern called boolean_timestamp.rb:

$ touch app/models/concerns/boolean_timestamp.rb

Here is the content of the file:

module BooleanTimestamp
  extend ActiveSupport::Concern

  class_methods do
    def boolean_timestamp(name)
      # FIXME
    end
  end
end

It will then be included in the User model like this:

class User < ApplicationRecord
  include BooleanTimestamp

  boolean_timestamp :confirmed
end

We start to see the DSL taking shape.

I know you like creating methods, so I created a method that creates methods

This is where the ~magic~ meta-programming happens. We are given a name and we need to create a bunch of methods based on it:

For that, we will use the define_method method which as the name suggests, defines a method dynamically. Let’s see quickly how it works:

class Animal
  def shouts(sound)
    self.class.define_method(sound) do
      sound.capitalize + "!!!"
    end
  end
end

dog = Animal.new
dog.shouts("woof")
dog.woof # => "Woof!!!"
dog.barf # => undefined method `barf' for #<Animal:0x00005575bd8c61c0> (NoMethodError)
dog.shouts("barf")
dog.barf # => "Barf!!!"

define_method is called on the class and creates a new instance method. It takes a block that will be the body of the method. The method is then available on all instances of the class.

We know how to create methods dynamically but we’re missing the final touch: calling existing methods with a dynamic name. We can do that by using the send method. send is a method that calls a method on an object based on its name.

dog = Animal.new
my_dynamic_method = "woof"
dog.shouts(my_dynamic_method)
dog.send(my_dynamic_method) # => "Woof!!!"

Back To Our Concern

Here is the final implementation of the boolean_timestamp method:

module BooleanTimestamp
  extend ActiveSupport::Concern

  class_methods do
    def boolean_timestamp(name)

      # Define the getter
      define_method(name) do
        self.send("#{name}_at").present?
      end

      # Define the setter
      define_method("#{name}=") do |value|
        self.send("#{name}_at=", value ? Time.now : nil)
      end

      # Define the question mark method
      define_method("#{name}?") do
        self.send(name)
      end

      # Define the mark method
      define_method("mark_as_#{name}!") do
        self.update!("#{name}_at": Time.now)
      end

      # Define the unmark method
      define_method("unmark_as_#{name}!") do
        self.update!("#{name}_at": nil)
      end
    end
  end
end

The Limit Of Meta-Programming

Our simple DSL is simple but very useful to handle boolean timestamps. Let’s have a look at a basic User model:

class User < ApplicationRecord
  include BooleanTimestamp

  boolean_timestamp :email_confirmed
  boolean_timestamp :newsletter_subscribed
  boolean_timestamp :soft_deleted
  boolean_timestamp :tos_accepted
end

Those 4 lines of code encapsulate a lot of logic. It’s a good example of how meta-programming can make your code more expressive.

However, all the generated methods names are not written anywhere in the code. If you happen to find in the code a line like user.mark_as_soft_deleted! and you wan’t to know more about it, you won’t be able to find the definition of the method in the User model. You won’t be able to search the term mark_as_soft_deleted! in your codebase and if you’re using an IDE, you won’t be able to jump to the definition of the method.

Thankfully, our simple DSL is simple enough to understand what’s going on just by reading the method names. I’m pretty sure you can guess what soft_deleted? does but you might not be completely sure about unmark_as_soft_deleted!. So what about very complex DSLs? Like the one written by your colleague who left the company 3 years ago (or the one you wrote 3 years ago)? If you have to understand your colleague’s deep meta-programming magic every time, then it’s not worth it.

Conclusion

My rule of thumbs are usually YAGNI, KISS and DRY (in that order). Despite being one of the DRYest techniques, meta-programming seldom passes the YAGNI test and almost always fails the KISS test.

To let you undeerstand where I draw the line: I’ve used and reused my BooleanTimestamp DSL in multiple projects but without the mark_as and unmark_as methods.

← Back to Blog