Let’s say that for an interview you were asked to write a simplified version of the ActiveRecord validates macro. The object that implements the validation should be able to return correct values for the methods valid? and invalid? and it should also return any errors by responding to an errors method. Optionally, calling full_messages on errors should give you the full messages just like ActiveRecord gives us.
In order for this to work, every validation on every attribute needs to return a truthy value; otherwise, it would be invalid. How would you do it? Before we get to code writing, it’s important to have an idea of what the high-level design would look like.
Based on our acceptance criteria, we know that we want to be able to write validates followed by any number of attributes in our class and that should be validated against something. So let’s say that we had a character class for a video game and we wanted to ensure that any character that we create has a name and an archetype (think fighter, mage, thief, etc.) as well as the number of hit points they have.
The name and archetype are mandatory and should be a string for the object to be valid. Additionally, their hit points have to be a number. Given that, we could write something like this:
validates :name, :archetype, with: ->(value) { value.is_a?(String) && !value.empty? }
validates :hit_points, with: /\d+/
If we were to use something like the above we know what we have to validate the attribute(s) by either an Proc or anonymous method and a regular expression. From this point validates appears to be a bit magical but it’s actually just a class method.
If we were using Rails we might be able to use a concern here but since we don’t have Rails we’ll do it the old school way. We’ll create a module called Validations and start with the boilerplate:
module Validations
def self.included(base)
base.extend ClassMethods
base.include InstanceMethods
end
module ClassMethods
def foo
'class method foo'
end
end
module InstanceMethods
def bar
'instance method bar'
end
end
end
In our Character class we can include the Validations module. This allows methods that are added to the ClassMethods module to be added to the class itself and methods under the InstanceMethods module will be added to instances of whatever class it is mixed in to. This means that Character.foo should return the string class method foo and a new instance of a Character will return the string ‘instance method bar’ when the bar method is called on it.
Ok, so what’s next? How do we register validations? How do we see errors? How do we reset errors? How can we find out what validations are defined on the class?
For storing validations, this gets a little complex. We want to store these validations on every instance of the class (and by extension its subclasses). We can do this by adding class instance variables like so:
module Validations
def self.included(base)
base.extend ClassMethods
base.include InstanceMethods
base.instance_variable_set(:@validations, [])
def base.inherited(subclass)
super
subclass.instance_variable_set(:@validations, @validations.dup)
end
end
module ClassMethods
def validations
@validations ||= []
end
end
module InstanceMethods
def bar
'instance method bar'
end
end
end
Now we have a way to register our validations on our class and its subclasses, we can start writing up the validates method itself.
⚠️ A note on thread safety. It’s important to understand that class instance variables in Ruby are not thread safe. Their use here should be generally okay because classes are loaded and configured before threads start and validations are defined once and then only read by request threads.
module Validations
def self.included(base)
base.extend ClassMethods
base.include InstanceMethods
base.instance_variable_set(:@validations, [])
end
def base.inherited(subclass)
super
subclass.instance_variable_set(:@validations, @validations.dup)
end
module ClassMethods
def validations
@validations ||= []
end
def validates(*attributes, with:, message: nil)
validator =
case with
when Proc then with
when Regexp then ->(value) { value.to_s.match?(with) }
else raise ArgumentError, "with: must be a Proc or a Regexp"
end
attributes.each { |attribute| validations << [attribute.to_sym, validator, message] }
end
end
module InstanceMethods
def bar
'instance method bar'
end
end
end
Let’s break down what’s going on here. For each usage of validates in our class we will store a reference to it in a validator variable. When it’s a Proc then we’ll simply store the Proc. When it’s a Regexp then we wrap that in a Proc that when executed checks to see if there is a match to the regular expression. Finally, for every attribute we record the validation by adding an array to our validations array that is comprised of our attribute (as a symbol), the validator, and if there is a message.
From here let’s define how to store any errors that come up. This is fairly straightforward:
module InstanceMethods
def errors
@errors ||= []
end
end
That’s it. As I said, it’s straightforward for now but we’ll add a little bit for flavor here later on.
Our next step is to add a couple of private methods that are called when we call the valid? method on our object. Let’s start with clearing errors:
module InstanceMethods
def errors
@errors ||= []
end
private
def reset_errors
errors.clear
end
end
The next private method is actually running our validations:
module InstanceMethods
def errors
@errors ||= []
end
private
def reset_errors
errors.clear
end
def run_validations
self.class.validations.each do |attribute, validator, message|
value = public_send(attribute)
next if validator.call(value)
errors << [attribute, message || 'is invalid']
end
end
end
When we call the run_validations method we will loop through all of the validations registered, get the value of the attribute by using public_send and then calling our Proc passing in the value. This calls our anonymous method and if it’s true then we just move on to the next one. If it’s false we add something to our errors array.
Now let’s connect these private methods to our valid? and invalid? methods:
module InstanceMethods
def errors
@errors ||= []
end
def valid?
reset_errors
run_validations
errors.empty?
end
def invalid? = !valid?
private
def reset_errors
errors.clear
end
def run_validations
self.class.validations.each do |attribute, validator, message|
value = public_send(attribute)
next if validator.call(value)
errors << [attribute, message || 'is invalid']
end
end
end
Now when we call the valid? method we will reset our errors and then run all of our validations. The method invalid? just checks to see if valid? is not true.
There is, as Steve Jobs used to say, ‘one more thing’. We can make the interface for our errors a little bit nicer and more Rails-y like:
module Validations
def self.included(base)
base.extend ClassMethods
base.include InstanceMethods
base.instance_variable_set(:@validations, [])
def base.inherited(subclass)
super
subclass.instance_variable_set(:@validations, @validations.dup)
end
end
module ClassMethods
def validations
@validations ||= []
end
def validates(*attributes, with:, message: nil)
validator =
case with
when Proc then with
when Regexp then ->(value) { value.to_s.match?(with) }
else raise ArgumentError, "with: must be a Proc or a Regexp"
end
attributes.each { |attribute| validations << [attribute.to_sym, validator, message] }
end
end
module InstanceMethods
def errors
@errors ||= Errors.new
end
def valid?
reset_errors
run_validations
errors.empty?
end
def invalid? = !valid?
private
def reset_errors
errors.clear
end
def run_validations
self.class.validations.each do |attribute, validator, message|
value = public_send(attribute)
next if validator.call(value)
errors.add(attribute, message || 'is invalid')
end
end
end
class Errors
def initialize
@h = Hash.new { |h, k| h[k] = [] }
end
def add(attribute, message) = (@h[attribute] << message)
def [](attribute) = @h[attribute]
def each(&block) = @h.each(&block)
def empty? = @h.all? { |_, v| v.empty? }
def any? = !empty?
def clear = @h.clear
def full_messages = @h.flat_map { |attribute, messages| messages.map { |message| "#{attribute.capitalize} #{message}"} }
end
end
This allows us to have a nicer interface when adding new errors, accessing the errors, and seeing full messages on the errors (which was an optional requirement).
Our implementation of this is now complete and I hope that demystifies how macros work in Rails and how you might be able to build your own when the time comes.