How to implement Rails validations from scratch in Ruby

Hi guys!
This time I am going to show you how to implement your own Rails validations completely from the ground up. I will talk about validations briefly, I will show you a bit of Rails source code and then show you how you can implement your own Rails validations.

In Rails, every model inherits from ApplicationRecord (before Rails 5.0, it was ActiveRecord::Base) which means that you can use validations in every Rails model. How does this work? If you look at ActiveRecord::Base class you will notice that it includes and extends lots of stuff (probably the most experienced senior developers don’t know all these things). One of the lines says:

include Validations

More importantly, Active Record includes the majority of its validations from ActiveModel::Validations. Validates method is implemented there! Let’s take a look at the code

def validates(*attributes)
  defaults = attributes.extract_options!.dup
  validations = defaults.slice!(*_validates_default_keys)

  raise ArgumentError, "You need to supply at least one attribute" if attributes.empty?
  raise ArgumentError, "You need to supply at least one validation" if validations.empty?

  defaults[:attributes] = attributes

  validations.each do |key, options|
    next unless options
    key = "#{key.to_s.camelize}Validator"

    begin
      validator = key.include?("::".freeze) ? key.constantize : const_get(key)
    rescue NameError
      raise ArgumentError, "Unknown validator: '#{key}'"
    end

    validates_with(validator, defaults.merge(_parse_validates_options(options)))
  end
end

Validates method takes a splat argument (an array of attributes) and they extract options using Rails built-in method #extract_options!(click source code). What is does is it accesses the last element of the array if it’s a Hash, otherwise they return an empty Hash. Next, they raise an ArgumentError in case validates method is not given any attributes. Next, they take the last attribute (which is either ‘presence’ or ‘length’ or even your own validator) and make it a constant, for example ‘PresenceValidator’ or ‘LengthValidator’ (whatever is given as the last argument to validates method). Additionaly, ActiveRecord validations implement such methods as #save, #save!, #valid?, etc.

My goal here is to get from here:

class User < ApplicationRecord
  validates :email, :phone, presence: true
  validates :phone, length: { is: 9 }
end

to here:

class User < MyOwnActiveRecord
  validates :email, :phone, presence: true
  validates :phone, length: { is: 9 }
end

I want to be Test Driven so I will write some tests first before everything. I will use Rspec. In Gemfile I add this:

source 'https://rubygems.org'

gem 'rspec', '3.7.0'

Run

bundle install

Create a folder structure:

mkdir lib spec

#test/user_spec.rb

require 'rspec'
require_relative '../lib/my_own_active_record'

class User < MyOwnActiveRecord
  attr_accessor :email, :phone
  def initialize(attributes = {})
    super()
    @email = attributes[:email]
    @phone = attributes[:phone]
  end

  validates :email, :phone, presence: true
end

describe "Validation" do
  context "Invalid user" do
    let(:user) { User.new }

    it "is not valid without email or phone number" do
      expect(user.save).to eq(false)
    end

    it "returns the correct error messages when attributes are not given" do
      user.save
      expect(user.errors.full_messages).to eq(
        ["Email can't be blank",
         "Phone can't be blank"
        ])
    end
  end

  context "Valid user" do
    let(:user) { User.new(email: "abc@1.com", phone: 123456789) }

    it "is valid with email or phone number" do
      expect(user.save).to eq(true)
    end

    it "returns an empty list of errors" do
      user.save
      expect(user.errors.full_messages).to eq([])
    end
  end
end

I require a file my_own_active_record.rb which I am going to create in a second. Then, I create a simple User model that inherits from MyOwnActiveRecord class which I am going to implement in a moment. I create two contexts: “unhappy path” and “happy path”. The unhappy path checks the behaviour of the application when an invalid user is created and the happy path checks the scenario when a valid user is created.

#lib/my_own_active_record.rb.


class MyOwnActiveRecord
  attr_reader :errors

  @@validators = []
  def self.validators
    @@validators
  end

  def initialize
    @errors = Errors.new
  end

  def save(validates: true)
    return true if !validates

    validate!
    errors.size == 0
  end

  def validate!
    self.class.validators.each do |validator|
      validator.call(self)
    end
  end

  def self.validates(*attributes)
    raise ArgumentError, "You need to supply at least one attribute" if attributes.empty?
    validator_class = self.extract_validator_class!(attributes)
    @@validators.push(validator_class.new(attributes))
  end

  private

  def self.extract_validator_class!(attributes)
    validator_param = attributes.last
    validator_type = validator_param.keys.first.to_s
    validator_class_name = "#{validator_type.capitalize}Validator"
    Object.const_get(validator_class_name)
  end
end

The key point is that #validates methods adds a validator class to the model. Next, #save method triggers validate! method. As in Rails, save method accepts an optional hash argument thanks to which the validation process on save can be skipped by passing validate: false. validate! method invokes #call method. Let’s implement presence validator now.

class PresenceValidator
  attr_reader :attributes
  def initialize(attributes)
    @attributes = attributes
  end
  def call(object)
    attributes[0..-2].each do |attribute|
      value = object.send(attribute)
      object.errors.add(attribute.to_s, "can't be blank") if value.nil?
    end
  end
end

The #call method iterates over all model’s attributes except for the last one (the last one will be something like ‘presence: true’ which we don’t want ot vaidate) and adds an attribute and an error message if an attribute is nil. Let’s implement errors now.

class Errors
  def initialize
    @errors = []
  end

  def add(attribute, message)
    @errors.push(Error.new(attribute, message))
  end

  def full_messages
    @errors.map do |error|
      "#{error.attribute.capitalize} #{error.message}"
    end
  end

  def size
    @errors.size
  end
end

#add method creates a new Error object and ads it to the list of errors. #size method returns the number of errors. Let’s create Error class.

class Error
  attr_reader :attribute, :message
  def initialize(attribute, message)
    @attribute = attribute
    @message = message
  end
end

The tests are all passing.

I can now add another validator, say LengthValidator and validate phone number’s length. I am going to add new test first. The full tests code is this:

require 'rspec'
require_relative '../lib/my_own_active_record'

class User < MyOwnActiveRecord
  attr_accessor :email, :phone
  def initialize(attributes = {})
    super()
    @email = attributes[:email]
    @phone = attributes[:phone]
  end

  validates :email, :phone, presence: true
  validates :phone, length: { is: 9 }
end

describe "Validation" do
  context "Invalid user" do
    let(:user) { User.new }

    it "is not valid without email or phone number" do
      expect(user.save).to eq(false)
    end

    it "returns the correct error messages when attributes are not given" do
      user.save
      expect(user.errors.full_messages).to eq(
        ["Email can't be blank",
         "Phone can't be blank",
         "Phone is the wrong length (should be 9) characters"
        ])
    end

    it "returns the correct error messages when phone number is not the right length" do
      user = User.new(email: "test@tes.com", phone: 12345)
      user.save
      expect(user.errors.full_messages).to eq(["Phone is the wrong length (should be 9) characters"])
    end
  end

  context "Valid user" do
    let(:user) { User.new(email: "abc@1.com", phone: 123456789) }

    it "is valid with email or phone number" do
      expect(user.save).to eq(true)
    end

    it "returns an empty list of errors" do
      user.save
      expect(user.errors.full_messages).to eq([])
    end
  end
end

#lib/length_validator.rb

class LengthValidator
  attr_reader :attributes
  def initialize(attributes)
    @attributes = attributes
  end
  def call(object)
    allowed_length = attributes.last[:length].values.first
    attributes[0..-2].each do |attribute|
      value = object.send(attribute)
      object.errors.add(attribute.to_s, "is the wrong length (should be #{allowed_length}) characters") unless value.to_s.length == allowed_length
    end
  end
end

What I do here is also access the last attribute in the array and add errors if the length of the validated attribute (in this case ‘phone’) is not the allowed length, which is specified in ‘length: { is: 9 } hash.

That’s it!

I know this is not the perfect solution mainly becuase there are too many options in Rails validations but at least I implement basic functionality. If you have any thoughts and ideas share them with me, I am happy to here them.
I have also made a video and posted in on Youtube so if you are intrested check out the video here: https://www.youtube.com/watch?v=jajT9d9-GhU&feature=youtu.be

Thanx for reading and watching. I’ll see you in the next one.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

w

Connecting to %s