Photo by Scott Webb on Unsplash
Model Validation and using FactoryBot
Part 10 of building a Rails 7 application
Maintaining the data integrity of the models I am creating can be done through the use of the built in validation tools provided by Rails. These validations will prevent records being saved if they fail the rules I define. However I believe these rules should also be further enforced by applying constraints and indexes at the database level as well.
So let's take a look at a couple examples of validations I want to apply.
class Brand < ApplicationRecord
has_many :brand_aliases, dependent: :destroy
has_associated_audits
audited
validates :name, presence: true, uniqueness: true
validates :count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
end
A Brand must have a name
and that must not be a duplicate of any other Brand name
. The count
attribute must also be a number that is equal to or greater than zero, but it can be nil if there is no count.
class Product < ApplicationRecord
has_many :product_duplicates, dependent: :destroy
has_associated_audits
audited
validates :product_id, presence: true, uniqueness: true
validates :buy_list_count, numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
end
A Product must have a product_id
and it must be unique within all other Products. The buy_list_count
field must be an integer greater than zero if it is not nil.
To enforce these in the database as well I will need the following changes made to the schema:
class AddIntegrityConstraints < ActiveRecord::Migration[7.0]
def change
change_column_null :brands, :name, false
change_column_null :products, :product_id, false
...
add_check_constraint :products, 'buy_list_count >= 0', name: 'buy_list_count_check'
...
add_index :brands, :name, unique: true
add_index :products, :product_id, unique: true
...
end
end
Now let's see how these work in practice. First try not setting a name for the brand.
> b = Brand.new
=> #<Brand:0x00007fe3ec2e6990 id: nil, name: nil, canonical: false, count: nil, created_at: nil, updated_at: nil>
> b.valid?
Brand Exists? (2.5ms) SELECT 1 AS one FROM "brands" WHERE "brands"."name" IS NULL LIMIT $1 [["LIMIT", 1]]
=> false
> b.errors.full_messages
=> ["Name can't be blank"]
Then see what happens if attempting to use a Brand name that already exists.
> b = Brand.new
=> #<Brand:0x00007fe3ec48bae8 id: nil, name: nil, canonical: false, count: nil, created_at: nil, updated_at: nil>
> b.name = 'Heinz'
=> "Heinz"
> b.valid?
Brand Exists? (0.3ms) SELECT 1 AS one FROM "brands" WHERE "brands"."name" = $1 LIMIT $2 [["name", "Heinz"], ["LIMIT", 1]]
=> false
> b.errors.full_messages
=> ["Name has already been taken"]
How about a Product with a negative buy_list_count
:
> p = Product.new
> p.buy_list_count = -1
=> -1
> p.valid?
Product Exists? (0.5ms) SELECT 1 AS one FROM "products" WHERE "products"."product_id" IS NULL LIMIT $1 [["LIMIT", 1]]
=> false
> p.errors.full_messages
=> ["Buy list count must be greater than or equal to 0"]
The status enum on Task does not allow anything other than one of the values in the enumeration. As a reminder of what that enum looked like:
app\models\task.rb
class Task < ApplicationRecord
audited
enum status: {
pending: 'pending',
processing: 'processing',
complete: 'complete',
error: 'error'
}
...
end
And what happens when I try some different values:
> t = Task.new
> t.status = 'processed'
activerecord-7.0.3/lib/active_record/enum.rb:157:in `assert_valid_value': 'processed' is not a valid status (ArgumentError)
> t.status = 'processing'
=> "processing"
In addition to validations there will be some manipulation I want to apply to some attributes prior to them being saved, for example I want certain attributes to only ever be stored as lower case.
Here is an example of one of these before validation callbacks.
class ItemSellPack < ApplicationRecord
has_many :item_sell_pack_aliases, dependent: :destroy
has_associated_audits
audited
before_validation :clean
validates :name, presence: true, uniqueness: true
protected
def clean
# Allowed characters are a-z and space
assign_attributes(name: name&.downcase&.tr('^a-z ', ' ')&.squeeze(' ')&.strip)
end
end
In this example the ItemSellPack name
may only have the lowercase letters a-z or a space. All other extraneous whitespace is removed.
To help me test whether my validations and callbacks are working I plan on using a gem called FactoryBot. This allows me to easily build models in various states, simulating good and bad data integrity. The advantage of FactoryBot over fixtures is that I can avoid performing any database activity at all, thus improving the performance of my test suite. I can also easily name and reuse these models elsewhere in the test suite.
Installing FactoryBot requires adding the gem to the Gemfile. I am using factory_bot_rails
Gemfile
group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem 'capybara'
gem 'factory_bot_rails'
gem 'selenium-webdriver'
gem 'simplecov', require: false
gem 'webdrivers'
end
Then add the FactoryBot methods to the test helper so they are available in the test cases.
test\test_helper.rb
class ActiveSupport::TestCase
...
# Add more helper methods to be used by all tests here...
include FactoryBot::Syntax::Methods
end
Now I can start defining the factories. Note that because alias
is a ruby keyword it needs to be defined differently using add_attribute
.
test\factories\item_sell_pack_alias.rb
FactoryBot.define do
factory :item_sell_pack_alias do
association :item_sell_pack
add_attribute(:alias) { 'ctn' }
confirmed { true }
end
end
And finally I can write some test cases for my validation and clean up callbacks:
require 'test_helper'
class ItemSellPackTest < ActiveSupport::TestCase
test 'name presence validation' do
item_sell_pack = build(:item_sell_pack, name: nil, canonical: nil)
assert_not(item_sell_pack.save, 'Saved the item sell pack without required attributes')
end
test 'name uniqueness validation' do
# NOTE: there is an ItemSellPack with a name of 'carton' already in the fixtures
item_sell_pack = build(:item_sell_pack, name: 'carton', canonical: nil)
assert_not(item_sell_pack.save, 'Saved the item sell pack using a duplicate name')
end
test 'cleaning' do
item_sell_pack = build(:item_sell_pack, name: " 5 Btl \n")
item_sell_pack.valid?
assert_equal('btl', item_sell_pack.name, 'Item sell pack contains illegal whitespace')
end
end
Useful links:
guides.rubyonrails.org/active_record_valida..