Mastering Rails Validations: Objectify

… and check why 5600+ Rails engineers read also this

Mastering Rails Validations: Objectify

In my previous blogpost I showed you how Rails validations might become context dependent and a few ways how to handle such situation. However none of them were perfect because our object had to become context-aware. The alternative solution that I would like to show you now is to extract the validations rules outside, making our validated object lighter.

Not so far from our comfort zone

For start we are gonna use the trick with SimpleDelegator (we use it sometimes in our Fearless Refactoring: Rails Controllers book as an intermediary step).

class UserEditedByAdminValidator < SimpleDelegator
  include ActiveModel::Validations

  validates_length_of :slug, minimum: 1
end
user = User.find(1)
user.attributes = {slug: "summertime-blues"}

validator = UserEditedByAdminValidator.new(user)
if validator.valid?
  user.save!(validate: false)
else
  puts validator.errors.full_messages
end

So now you have external validator that you can use in one context and you can easily create another validator that would validate different business rules when used in another context.

The context in your system can be almost everything. Sometimes the difference is just create vs update. Sometimes it is in save as draft vs publish as ready. And sometimes it based on the user role like admin vs moderator.

One step further

But let’s go one step further and drop the nice DSL-alike methods such as validates_length_of that Rails used to bought us and that we all love, to see what’s beneath them.

class UserEditedByAdminValidator < SimpleDelegator
  include ActiveModel::Validations

  validates_with LengthValidator, attributes: [:slug], minimum: 1
end

The DSL-methods from ActiveModel::Validations::HelperMethods are just tiny wrappers for a slightly more object oriented validators. And they just convert first argument to Array value of attributes key in a Hash.

Almost there

When you dig deeper you can see that one of validates_with responsibilities is to actually finally create an instance of validation rule.

class UserEditedByAdminValidator < SimpleDelegator
  include ActiveModel::Validations

  validate LengthValidator.new(attributes: [:slug], minimum: 1)
end

Let’s create an instance of such rule ourselves and give it a name.

Rule as an object

We are going to do it by simply assigning it to a constant. That is one, really global name, I guess :)

SlugMustHaveAtLeastOneCharacter =
  ActiveModel::Validations::LengthValidator.new(
    attributes: [:slug],
    minimum: 1
  )

class UserEditedByAdminValidator < SimpleDelegator
  include ActiveModel::Validations

  validate SlugMustHaveAtLeastOneCharacter
end

Now you can share some of those rules in different validators for different contexts.

Reusable rules, my way

The rules:

SlugMustStartWithU =
  ActiveModel::Validations::FormatValidator.new(
    attributes: [:slug],
    with: /\Au/
  )

SlugMustHaveAtLeastOneCharacter =
  ActiveModel::Validations::LengthValidator.new(
    attributes: [:slug],
    minimum: 1
  )

SlugMustHaveAtLeastThreeCharacters  =
  ActiveModel::Validations::LengthValidator.new(
    attributes: [:slug],
    minimum: 3
  )

Validators that are using them:

class UserEditedByAdminValidator < SimpleDelegator
  include ActiveModel::Validations

  validate SlugMustStartWithU
  validate SlugMustHaveAtLeastOneCharacter
end

class UserEditedByUserValidator < SimpleDelegator
  include ActiveModel::Validations

  validate SlugMustStartWithU
  validate SlugMustHaveAtLeastThreeCharacters
end

or the highway

I could not find an easy way to register multiple instances of validation rules. So below is a bit hacky (although valid) way to work around the problem.

It gives us a nice ability to group common rules in Array and add or subtract other rules.

Rules definitions:


format_validator = ActiveModel::Validations::FormatValidator
length_validator = ActiveModel::Validations::LengthValidator

class SlugMustStartWithU < format_validator
  def initialize(*)
    super(attributes: [:slug], with: /\Au/)
  end
end

class SlugMustEndWithZ < format_validator
  def initialize(*)
    super(attributes: [:slug], with: /z\Z/)
  end
end

class SlugMustHaveAtLeastOneCharacter < length_validator
  def initialize(*)
    super(attributes: [:slug], minimum: 1)
  end
end

class SlugMustHaveAtLeastThreeCharacters < length_validator
  def initialize(*)
    super(attributes: [:slug], minimum: 5)
  end
end

Validators using the rules:

CommonValidations = [SlugMustStartWithU, SlugMustEndWithZ]

class UserEditedByAdminValidator < SimpleDelegator
  include ActiveModel::Validations

  validates_with *(CommonValidations +
    [SlugMustHaveAtLeastOneCharacter]
  )
end

class UserEditedByUserValidator < SimpleDelegator
  include ActiveModel::Validations

  validates_with *(CommonValidations +
    [SlugMustHaveAtLeastThreeCharacters]
  )
end

Cooperation with rails forms

The previous examples won’t cooperate nicely with Rails features expecting list of errors validations on the validated object, because as I showed in first example, the #errors that are filled are defined on the validator object.

validator = UserEditedByAdminValidator.new(user)
unless validator.valid?
  puts validator.errors.full_messages
end

But you can easily overwrite the #errors that come from including ActiveModel::Validations, by delegating them to the validated object, which in our case is #user.

class UserEditedByAdminValidator
  include ActiveModel::Validations

  delegate :slug, :errors, to: :user

  def initialize(user)
    @user = user
  end

  validates_with *(CommonValidations +
    [SlugMustHaveAtLeastOneCharacter]
  )

  private
  attr_reader :user
end

What next?

That was a brief introduction to the more object oriented aspects of rails validations. Subscribe to our newsletter below if you don’t want to miss our next blogpost that are going to be about problems with refactoring in rails, active record aggregates, another part on validations problems and service objects. We have plenty of ideas for our next posts.

You might also want to read some of our other popular blogposts ActiveRecord-related:

You might also like