14 January 2021
Photo by Glenn Carstens-Peters on Unsplash
ActiveModel::Validations
provides #validation_context
which gets set when using #valid?
or #save
on an active record. Here is the on: option documentation
When triggered by an explicit context, validations are run for that context, as well as any validations without a context.
Here is part of the rails implementation:
# activerecord/lib/active_record/validations.rb
def save(**options)
# -- ARTICLE NOTE -- super refers to ActiveRecord::Persistence#save
perform_validations(options) ? super : false
end
def perform_validations(options = {})
options[:validate] == false || valid?(options[:context])
end
def valid?(context = nil)
# -- ARTICLE NOTE -- super refers to ActiveModel::Validation#valid?
context ||= default_validation_context
output = super(context)
errors.empty? && output
end
def default_validation_context
new_record? ? :create : :update
end
When no context is explicitly provided validation_context
is set to :create
(when record is new) or :update
(when record is persisted).
#valid?
validations are run for explicit contexts and any validation without a context:create
or :update
based on whether a model is persisted or notLet's say you have a Movie
class that has a uniqueness validation scoped by :publication_year
like so:
class Movie < ApplicationRecord
attribute :title, :string
attribute :producer, :string
attribute :publication_year, :integer
validate :producer, presence: true
validate :title, uniqueness: { scope: :publication_year }
end
A new requirement comes in and Movies can now be created in batches with a CSV but with a new uniqueness validation. Records must be unique scoped by :producer
AND :publication_year
BUT only during a batch upload.
How do you tackle this problem?
First you take a deep breathe and challenge the requirement. Making validations consistent across the application is always the best approach when possible. It is easier to understand, to change and to maintain. But sometimes requirements are immovable and you need to implement them.
How do you tackle this problem?
Rule 1: Default contexts are :create
or :update
based on whether a model is persisted or not.
Knowing this, you can write your model this way without altering the behaviour of the validations.
class Movie < ApplicationRecord
attribute :title, :string
attribute :producer, :string
attribute :publication_year, :integer
validate :producer, presence: true
with_options on: [:create, :update] do
validate :title, uniqueness: { scope: :publication_year }
end
end
Rule 2: When calling #valid?
validations are run for explicit contexts and any validation without a context.
You can now introduce a new context specific for the batch upload (:uploaded
). This will discard the [:create, :update]
validation context block and run the [:uploaded]
validation context instead.
class Movie < ApplicationRecord
attribute :title, :string
attribute :producer, :string
attribute :publication_year, :integer
validate :producer, presence: true
with_options on: [:create, :update] do
validate :title, uniqueness: { scope: :publication_year }
end
with_options on: [:uploaded] do
validate :title, uniqueness: { scope: [:producer, :publication_year] }
end
end
You can now override any default validations previously used in the application without rewriting contexts everywhere. You won't need to update all the @movie.save
, @movie.create
, @movie.valid?
references while still skipping that default validation with a new context when doing an upload like so @movie.valid?(:uploaded)
or @movie.save(context: :uploaded)
.
It's worth mentioning that when models are simple, you can consider having multiple models for the same database table like so:
class Movie::Base < ApplicationRecord
self.table_name = "movies"
attribute :title, :string
attribute :producer, :string
attribute :publication_year, :integer
validate :producer, presence: true
end
class CSVUpload::Movie < Movie::Base
validate :title, uniqueness: { scope: [:producer, :publication_year] }
end
class Movie < Movie::Base
validate :title, uniqueness: { scope: :publication_year }
end
# Then use each class where required
Movie.create(movie_params) # Use in standard MoviesController#create
CSVUpload::Movie.create(movie_params) # Use in CSV Batch upload namespace
Naming might need to change for this option but the idea remains.
I'm sure there are other methods, you can contact me if there is an easier one that I'm not aware of. I like Method 1. It is pragmatic, keeps one class and is easy to understand by explicitly defining validations at the class level.