Using Rails Concerns to apply the same validations for columns that have different names

Using Rails Concerns to apply the same validations for columns that have different names
Photo by KE ATLAS / Unsplash

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.

  1. First we include a generic validation to included block:
    validate :validate_tax_code
  2. 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
  3. 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 like product.tax_code, but since the column names are different we need to make use of the read_attribute column.
  4. 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.