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: