Shorten validation code in Rails Application
In any application, there are chances that we keep repeating the same validation logic over the time. Specifically when those kind of validations are common type like email validation, required validation... or when it belong to business domain
Rails can help you tackle this problem easily. It is bundled with a lot of "tools" that help you to DRY (Don't Repeat Yourself) your code. Today, I will share with you one of its good practices using Validator classess
Validation target
In a typical pattern, Rails application validation logic usually locates in 2 places: model classes and parameter classes (which represents data sent from client side).
This post shows you a practice in parameter class. However, the same strategy can apply into model classes as well.
Typical parameter class
For example we have a simplified parameter class named StaffForm
, to contains data from client side, sent via APIs. There are 3 properties inside, including: email
, join_date
, quit_date
. Each of them need some kind of validations. In the real world, there may be more, but not necessary to this post :)
In general, below is how it usually looks like:
- Include necessary module:
ActiveModel::Model
orActiveModel::Validations
- Validation logic
- Input data initialization
class StaffForm
include ActiveModel::Model
validate :join_date_before_quit_date
validate :email_must_belong_company
def initialize(params = {})
@params = params
end
private
def join_date_before_quit_date
errors.add(:quit_date, 'Quit date must be after join date.') if quit_date <= join_date
end
def email_must_belong_company
errors.add(:email, 'This email domain is not supported') unless email.match?(/[a-z0-9]+@[a-z]+.company.com/)
end
def join_date
@params[:join_date]
end
def quit_date
@params[:quit_date]
end
def email
@params[:email]
end
end
In code above, we include all of validation logic into the class so for the partially similar paramters, we need to write the same code again.
class PartTimeStaffForm
include ActiveModel::Model
validate :join_date_before_quit_date
validate :email_must_belong_company
def initialize(params = {})
@params = params
end
private
def join_date_before_quit_date
errors.add(:quit_date, 'Quit date must be after join date.') if quit_date <= join_date
end
# here is the different --------------
def email_must_belong_company
return if email.match?(/[a-z0-9]+@parttime.company.com/) || email.match?(/[a-z0-9]+@seasonal.company.com/)
errors.add(:email, 'This email domain is not supported')
end
def join_date
@params[:join_date]
end
def quit_date
@params[:quit_date]
end
def email
@params[:email]
end
end
EachValidator class
The idea is to separate validation logic out, divide into single responsible validators, make it easier to reuse. For individual property, we use EachValidator class.
Any class inherited from ActiveModel::EachValidator
and has "Validator" postfix in name can be used with "validates" method.
class CompanyEmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
record.errors.add(:email, 'This email domain is not supported') unless email.match?(/[a-z0-9]+@[a-z]+.company.com/)
end
end
Then in parameter class, we remove the corresponding validation logic and use our new validator with validates
method. The "company_email" value in company_email: true
is a part of our new validator name
class StaffForm
include ActiveModel::Model
validate :join_date_before_quit_date
validates :email, company_email: true # <- change here
def initialize(params = {})
@params = params
end
private
def join_date_before_quit_date
errors.add(:quit_date, 'Quit date must be after join date.') if quit_date <= join_date
end
def join_date
@params[:join_date]
end
def quit_date
@params[:quit_date]
end
def email
@params[:email]
end
end
But it's not done yet. In case of part time employee, the subdomain of email address is limited to "parttime.company.com" or "seasonal.company.com". So instead create a new validator, we can use option
to make it more flexible
class CompanyEmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if options[:subdomain].blank?
record.errors.add(:email, 'This email domain is not supported') unless value.match?(/[a-z0-9]+@[a-z]+.company.com/)
else
return if options[:subdomain].any? { |s| value.match?(/[a-z0-9]+@#{s}.company.com/) }
record.errors.add(:email, 'This email domain is not supported')
end
end
end
class StaffForm
...
validates :email, company_email: true
...
end
class PartTimeStaffForm
...
validates :email, company_email: { subdomain: ['parttime', 'seasonal'] }
...
end
validates_with
Our code now is much more concise with above approach but there is still a problem. When a validation involves many properties, it doesn't seem to be make sense to use validates
. Obviously, you can get any value of the parameter class's instance with record
variable in validate_each
method. However it's hard to understand and maintain as well because of the validation belongs to no property. For this case, validates_with
is the perfect choice.
A validator which is used with validates_with
must inherit ActiveModel::Validator
and declare validate
method. This method accepts a parameter class instance and pass it via "record" variable.
class ContractValidator < ActiveModel::Validator
def validate(record)
record.errors.add(:quit_date, 'Quit date must be after join date.') if record.quit_date <= record.join_date
end
end
Next we remove the old validation logic in parameter class and call validates_with
method instead. However, as we want to access join_date
and quit_date
in ContractValidator
, so we need to expose those 2 publicly. So the final version looks like this
class StaffForm
include ActiveModel::Model
validates_with ContractValidator
validates :email, company_email: true
def initialize(params = {})
@params = params
end
def join_date
@params[:join_date]
end
def quit_date
@params[:quit_date]
end
private
def email
@params[:email]
end
end
class PartTimeStaffForm
include ActiveModel::Model
validates_with ContractValidator
validates :email, company_email: { subdomain: ['parttime', 'seasonal'] }
def initialize(params = {})
@params = params
end
def join_date
@params[:join_date]
end
def quit_date
@params[:quit_date]
end
private
def email
@params[:email]
end
end
Thank you! :)