Using Rails Concerns to apply the same validations for columns that have different names
Using Rails Concerns are a very effective way to write DRY code.
With a single file you can add validations and methods to any model that includes
the concern.
However things can get tricky when you want the same behavior but for columns that are named different for each model.
I ran into this issue when working at a gifting company. They had products that had their own codes which were used to calculate taxes. Each product belonged to a brand and that brand could set a default code to be used as a fallback if the product tax code was empty.
class Brand < ApplicationRecord
end
app/models/brand.rb
class Product < ApplicationRecord
belongs_to :brand
def tax_code_or_brand_tax_code
tax_code.present? ? tax_code : brand.tax_code_default
end
end
app/models/product.rb
I wanted to add a set of validations for Product#tax_code and Brand#tax_code_default
In An Ideal World
If the two columns were named the same it would have been incredibly simple.
We want the tax code to start with 2 letters followed by 6-7 digits, so this regex will do the trick for us.
# frozen_string_literal: true
module TaxCodeable
extend ActiveSupport::Concern
included do
validates_format_of :tax_code, with: /^[A-Z]{2}\d{6,7}$/
end
end
app/models/concerns/tax_codeable.rb
And then just add include this concern in our models
class Brand < ApplicationRecord
include TaxCodeable
end
app/models/brand.rb
class Product < ApplicationRecord
include TaxCodeable
belongs_to :brand
def tax_code_or_brand_tax_code
tax_code.present? ? tax_code : brand.tax_code_default
end
end
app/models/product.rb
Unfortunately the two columns are not the same. One is tax_code
while the other one is tax_code_default
so we need to get more creative with this.
Setting the column names
The first that we need to do is have the concern keep track of what the different column names are.
Let's create a class method that will store this value for us.
module TaxCodeable
extend ActiveSupport::Concern
included do
class << self
def set_tax_code_column(tax_code_column)
@tax_code_column = tax_code_column
end
end
end
end
app/models/concerns/tax_codeable.rb
Then we can call the set_tax_code_column
like so:
class Brand < ApplicationRecord
include TaxCodeable
set_tax_code_column :tax_code_default
end
app/models/brand.rb
class Product < ApplicationRecord
include TaxCodeable
set_tax_code_column :tax_code
belongs_to :brand
def tax_code_or_brand_tax_code
tax_code.present? ? tax_code : brand.tax_code_default
end
end
app/models/product.rb
This will set the tax_code_column on Application Load.
Adding Validations
The whole point of this column was to add validations so let's get to it.
- First we include a generic validation to
included
block:validate :validate_tax_code
- The validations are going to run for instances but the column name is stored at the class level so this is how we obtain the column name:
tax_code_column = self.class.tax_code_column
- Then we use the
read_attribute
method to grab the value.new_tax_code = read_attribute(tax_code_column)
NOTE: Normally we'd just call the column name directly likeproduct.tax_code
, but since the column names are different we need to make use of theread_attribute
column. - Then we add the actual validation
return false if /^[A-Z]{2}\d{6,7}$/.match?(new_tax_code)
errors.add(tax_code_column, 'is invalid')
And the final result is this!
module TaxCodeable
extend ActiveSupport::Concern
included do
validate :validate_tax_code
class << self
def set_tax_code_column(tax_code_column)
@tax_code_column = tax_code_column
end
end
private
def validate_tax_code
tax_code_column = self.class.tax_code_column
new_tax_code = read_attribute(tax_code_column)
return false if /^[A-Z]{2}\d{6,7}$/.match?(new_tax_code)
errors.add(tax_code_column, 'is invalid')
end
end
end
app/models/concerns/tax_codeable.rb
So there you have it. This is a way we can use the same validation in a concern for columns that are named differently.