Skip to content

Writing an ActiveRecord Style Macro in Ruby

Published: at 04:00 PM

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.


Previous Post
Wait, You Can Do That in Ruby? — One Line Methods
Next Post
On YouTube's Baffling Change to Their AppleTV App