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

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:
{name}
{name}?
{name}=
mark_as_{name}!
unmark_as_{name}!
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.