Custom Validations Creating a Custom Validation when the Predefined Helpers Won’t Do
In this demonstration, I will show how to add a custom validation to an existing model class. In general, model validations are used to ensure that only valid data are saved to the database. Although Rails offers some pre-defined helpers, sometimes an attribute’s validation helper must be created manually to fit the needed constraint.
General Steps
In general, the steps for adding a custom validation to a model class are as follows.
-
Step 1 Add a Validation Declaration and Custom Method to the Model Class. This step adds a declaration for the validation to the model class. The declaration specifies a custom validation helper which will be run on the model object before saving. The custom method is also added in this step and contains logic that is capable of finding and tagging a specific error with any of the attributes in the model.
-
Step 2 Add a Model Test for the Validation. This step adds a model test to verify that the validation was declared correctly. The substeps for adding this test follow the general steps introduced in the model tests demo.
Validating a Custom Attribute Constraint in a Review Model Object
To demonstrate the steps for adding a custom validation to a model class, we will be building upon a movie-review base app by adding a validation to the existing Review
model class (depicted in Figure 1) that ensures that a review’s review_date
comes after its release_date
.
Step 1 Add a Validation Declaration to the Model Class
To add a custom validation to the Review
model class, we follow these steps to add a validate
declaration and custom validation method to the class definition (found in app/models/review.rb
).
Substep Create custom method. In app/models/review.rb
, add a custom method to hold the validation logic with an appropriate name, like so:
# == Schema Information
#
# Table name: reviews
#
# id :bigint not null, primary key
# body :text
# genre :string
# link :string
# release_date :date
# review_date :date
# score :decimal(, )
# title :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Review < ApplicationRecord
def review_date_must_be_after_release_date
end
end
Substep Add statement to apply error to attribute unless value passes constraint. In this case, the constraint is that the object’s review_date
must come after the release_date
. Unless the constraint check passes, we want to add a statement that applies an Active Record error to the object with an appropriate message, like so:
# == Schema Information
#
# Table name: reviews
#
# id :bigint not null, primary key
# body :text
# genre :string
# link :string
# release_date :date
# review_date :date
# score :decimal(, )
# title :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Review < ApplicationRecord
def review_date_must_be_after_release_date
errors.add(:review_date, "must come after release date") unless review_date.after?(release_date)
end
end
Breaking down this line of code, we are using the add
method to add an error message for an attribute to the object’s errors
collection. In the call to add
, the :review_date
parameter indicates which attribute the error pertains to, and the "must come after release date"
parameter is the custom error message we set on that attribute. The unless
keyword is used as a modifier such that the preceding argument is executed if the following argument is not true. In this case, the error will be added if an object’s review_date
is NOT after the release_date
. We use the after
method to compare the two Date values.
Substep Add validation declaration to model class. Although we have written the validation function, we still need to tell Rails to run it when validating an object. To do this, we need to add a validate
declaration for the custom method, like so:
# == Schema Information
#
# Table name: reviews
#
# id :bigint not null, primary key
# body :text
# genre :string
# link :string
# release_date :date
# review_date :date
# score :decimal(, )
# title :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Review < ApplicationRecord
validate :review_date_must_be_after_release_date
def review_date_must_be_after_release_date
errors.add(:review_date, "must come after release date") unless review_date.after?(release_date)
end
end
Test It!
To confirm that we made this change correctly, we test it using the Rails console.
To start the console, we run this command:
rails console
As a first test of our custom model validation, we check whether we can create and save valid Review
objects in the database by entering a call to the create
class method, like this:
r = Review.create(
title: 'The Matrix',
score: 9.7,
body: 'I love the Matrix so much that I saw it eight times in theaters and had plastic surgery to look like Keanu Reeves. Now everyone asks if I\'m Keanu. My life is actually kind of sad.',
genre: 'Science Fiction',
link: 'https://www.imdb.com/title/tt0133093/',
release_date: Date.new(1999, 3, 31),
review_date: Date.new(2020, 12, 2)
)
The console output should look like this:
TRANSACTION (0.3ms) BEGIN
Review Create (1.8ms) INSERT INTO "reviews" ("body", "genre", "link", "release_date", "review_date", "score", "title", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING "id" [["body", "I love the Matrix so much that I saw it eight times in theaters and had plastic surgery to look like Keanu Reeves. Now everyone asks if I'm Keanu. My life is actually kind of sad."], ["genre", "Science Fiction"], ["link", "https://www.imdb.com/title/tt0133093/"], ["release_date", "1999-03-31"], ["review_date", "2020-12-02"], ["score", "9.7"], ["title", "The Matrix"], ["created_at", "2021-01-29 22:01:23.998414"], ["updated_at", "2021-01-29 22:01:23.998414"]]
TRANSACTION (0.3ms) COMMIT
=> #<Review id: 1, body: "I love the Matrix so much that I saw it eight time...", genre: "Science Fiction", link: "https:...
Note that the call to create
succeeded, returning a Review
object.
Also, note that we stored a reference to the returned Review
object in a variable, r
.
Next, we test that our custom validation will detect a review_date
value which is after the release_date
value.
Our test object’s release_date
is 3-31-1999
, so we first set the review_date
to a date before that date, like this:
r.review_date = Date.new(1998, 3, 31)
We then attempt to save this change to the database, like this:
r.save
The output of the command should look only like this:
=> false
The return value of false
indicates that the call to save
was unsuccessful. Note that no SQL commands are printed, because the change wasn’t saved to the database.
When commands that save model objects to the database fail, error messages are attached to the model object. To inspect the error messages in this case, we use the errors
method, like this:
r.errors.full_messages
The call should return this value:
=> ["Review date must come after release date"]
Note that the error message indicates that it was the failed constraint on the dates which caused the save
to fail.
If we wanted to do further testing, we could repeat the above steps, only modifying the release_date
attribute instead of the review_date
attribute so that the constraint is violated in a similar way. We could also make both dates the same which also violates the constraint.
When we are satisfied that our custom validation is functioning as expected, we quit the Rails console by entering this command:
exit
Step 2 Add a Model Test for the Validation
To add a model test for verifying that our custom validation works correctly, we follow the general steps from the model tests demo.
Substep Create fixtures. In the base app, the Review
model class already has this test fixture (in test/fixtures/reviews.yml
):
# == Schema Information
#
# Table name: reviews
#
# id :bigint not null, primary key
# body :text
# genre :string
# link :string
# release_date :date
# review_date :date
# score :decimal(, )
# title :string
# created_at :datetime not null
# updated_at :datetime not null
#
one:
title: The Matrix
score: 9.7
body: I love the Matrix so much that I saw it eight times in theaters and had plastic surgery to look like Keanu Reeves. Now everyone asks if I'm Keanu. My life is actually kind of sad.
genre: Science Fiction
link: https://www.imdb.com/title/tt0133093/
release_date: 1999-03-31
review_date: 2020-12-02
This fixture will be sufficient for this model test, so we can skip this substep.
Substep Add an empty model test. We add an empty model test to the ReviewTest
class (in test/models/review_test.rb
), like this:
# == Schema Information
#
# Table name: reviews
#
# id :bigint not null, primary key
# body :text
# genre :string
# link :string
# release_date :date
# review_date :date
# score :decimal(, )
# title :string
# created_at :datetime not null
# updated_at :datetime not null
#
require "test_helper"
class ReviewTest < ActiveSupport::TestCase
test "all fixtures should be valid" do
review_one = reviews(:one)
assert review_one.valid?, review_one.errors.full_messages.inspect
end
test "review_date must be after release_date" do
end
end
Note that the ReviewTest
class contained one test already ("all fixtures should be valid"
) to verify that all the fixtures are valid, and we added our new test ("review_date must be after release_date"
) beneath it.
Substep Retrieve the fixture object. To retrieve the fixture object, we add a call to the fixture-retrieving method, reviews
, like this:
class ReviewTest < ActiveSupport::TestCase
…
test "review_date must be after release_date" do
review_one = reviews(:one)
end
end
Substep Manipulate the fixture object. Because we want to ensure that a review that violates our review_date
constraint cannot be saved to the database, we set the review_date
of the retrieved fixture object to a date before the release_date
, like this:
class ReviewTest < ActiveSupport::TestCase
…
test "review_date must be after release_date" do
review_one = reviews(:one)
review_one.review_date = Date.new(1998, 3, 31)
end
end
Substep Check for expected behavior. In this case, the test succeeds if the validation catches the error, so we want to make sure the fixture is not valid. Thus, we use the assert_not
function in conjunction with the valid?
method, like this:
class ReviewTest < ActiveSupport::TestCase
…
test "review_date must be after release_date" do
review_one = reviews(:one)
review_one.review_date = Date.new(1998, 3, 31)
assert_not review_one.valid?
end
end
Test It!
To confirm that we made this change correctly, we run the test we created, like this:
rails test -v
Technically, this command runs all the tests written for the web app. It should produce output similar to this:
Running via Spring preloader in process 78139
Run options: -v --seed 38274
# Running:
ReviewTest#test_all_fixtures_should_be_valid = 0.06 s = .
ReviewTest#test_review_date_must_be_after_release_date = 0.06 s = .
Finished in 0.204849s, 9.7633 runs/s, 9.7633 assertions/s.
2 runs, 2 assertions, 0 failures, 0 errors, 0 skips
Note that the output shows that our newly created test, ReviewTest#test_review_date_must_be_after_release_date
, ran and that there were “0 failures” and “0 errors”. Also, the output shows “2 runs”, because the ReviewTest
class contains two tests, and it shows “2 assertions”, because each of those tests executed a call to one assertion.
Conclusion
Following the above steps, we have now added a custom validation to a model class to ensure that one of its attributes satisfies a special domain constraint before it can be saved to the database.