Inclusion Validations Ensuring That an Attribute Value Is One of a Fixed Set of Values
In this demonstration, I will show how to add an inclusion validation to an existing model class. In general, model validations are used to ensure that only valid data are saved to the database. The pre-defined inclusion validation helper is used specifically to ensure that an attribute is one of a set of pre-determined values.
General Steps
In general, the steps for adding an inclusion validation to a model class are as follows.
-
Step 1 Add a Validation Declaration to the Model Class. This step adds a declaration for the validation to the model class. The declaration specifies the type of validation via a validation helper, the attribute(s) to which the validation will apply, and any other arguments needed to specify the precise constraints of the validation (e.g., size limits or text patterns that must be matched).
-
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 the Inclusion of a Genre in a Review Model Object
To demonstrate the steps for adding a inclusion 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 genre
attribute can only be one of the preset approved values.
Step 1 Add a Validation Declaration to the Model Class
To add a inclusion validation for the genre
attribute of the Review
model class, we add a validates
declaration to the class definition (found in app/models/review.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
#
class Review < ApplicationRecord
validates :genre, inclusion: { in: ['Action', 'Science Fiction', 'Drama', 'Horror', 'Comedy', 'Musical'] }
end
Breaking down this line of code, the call to validates
adds the specified validation to the model class. The :genre
argument specifies that the validation will apply to the genre
attribute of the class. The inclusion: { in: ['Action', 'Science Fiction', 'Drama', 'Horror', 'Comedy', 'Musical'] }
argument specifies that the inclusion validation helper will be applied to the attribute. This validation uses in:
to specify an array of allowed values and ensures that the attribute is only one of those values.
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 inclusion 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 inclusion validation will detect a genre
value that is not in the list.
We first set the genre
to Romance
, like this:
r.genre = 'Romance'
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:
=> ["Genre is not included in the list"]
Note that the error message indicates that it was the unsanctioned genre that caused the save
method to fail.
Satisfied that our inclusion 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 inclusion 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 "genre must be from approved list" 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 ("genre must be from approved list"
) 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 "genre must be from approved list" do
review_one = reviews(:one)
end
end
Substep Manipulate the fixture object. Because we want to ensure that a review with an unapproved genre
attribute cannot be saved to the database, we set the genre
of the retrieved fixture object to 'Romance'
, like this:
class ReviewTest < ActiveSupport::TestCase
…
test "genre must be from approved list" do
review_one = reviews(:one)
review_one.genre = 'Romance'
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 "genre must be from approved list" do
review_one = reviews(:one)
review_one.genre = 'Romance'
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_genre_must_be_from_approved_list = 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_genre_must_be_from_approved_list
, 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 inclusion validation to a model class to ensure that one of its attributes can only have values from an approved list saved to the database.