Model Validation and using FactoryBot
Part 10 of building a Rails 7 application

I am a web developer who has been in the industry since 1995. My current tech stack preference is Ruby on Rails with JavaScript. Originally from Australia but now living in Scotland.
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:
https://guides.rubyonrails.org/active_record_validations.html
https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html
https://github.com/thoughtbot/factory_bot




