Normalizing ActiveRecord attributes with a custom DSL

abeidahmed

Abeid Ahmed

Posted on August 26, 2021

Normalizing ActiveRecord attributes with a custom DSL

Lately, I've been reading through some Rails code that I've written for my side-projects. After reading through 4-5 projects, I noticed a common pattern that could be extracted and reused throughout the application. The pattern goes something likes this,

class User < ApplicationRecord
  before_validation :normalize_fields

  private

  def normalize_fields
    self.email = email.to_s.downcase.strip
    self.name = name.to_s.strip
    # more normalization
  end
end
Enter fullscreen mode Exit fullscreen mode

I'm sure you've seen something like this or most probably you've written some field normalizers yourself.

In my case, I wasn't just normalizing the field in the User model, but through Account, Membership, Contact models, and so on. I decided to do myself a favor and took out some time off of my schedule and made up my mind to solve this issue once and for all.

My first instinct was to use a custom Ruby DSL.

DSLs are small languages, focused on a particular aspect of a software system - Martin Fowler.

I tried to look for some inspiration and stumbled across the has_secure_token method. It taps into the before_create callback and performs some action, which is analogous to what I was doing. The only difference was to use the before_validation callback in our case and perform some normalizations.

The normalize method

The next thing to figure out was to design the API. This was pretty simple as I knew exactly what I wanted to achieve. I also looked at the normalize gem and I came up with the following syntax,

class User < ApplicationRecord
  normalize :email, with: %i[strip downcase]
  normalize :name, with: :strip
end
Enter fullscreen mode Exit fullscreen mode

Now, the only thing left is to write some code.

# app/models/concerns/normalize.rb

module Normalize
  extend ActiveSupport::Concern

  class_methods do
    def normalize(*args)
      options = args.extract_options!

      before_validation do
        args.each { |field| send("#{field}=", normalized_value(field, options[:with])) }
      end
    end
  end

  private

  def normalized_value(field, normalizers)
    if normalizers.is_a?(Array)
      normalizers.inject(send(field).to_s, :try)
    else
      send(field).to_s.send(normalizers)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Firstly, we need to define a class method called normalize. We extract the options from the normalize method and then we're left only with the fields that we want to normalize on. For example,

normalize :email, :name, with: %i[strip downcase]
Enter fullscreen mode Exit fullscreen mode

If we extract the options, we're left with an array of [:email, :name], on which we can loop through and then apply the normalizers.

Secondly, the with option can be an array of symbols or just a symbol. For example,

normalize :email, :name, with: %i[strip downcase]
normalize :first_name, with: :strip
Enter fullscreen mode Exit fullscreen mode

On the normalized_value method, we check for this case and apply the normalizers conditionally.

I then included this concern in the ApplicationRecord and started changing all of the previous occurrences with the newer syntax. I was pretty happy with what I achieved until I came across a code where I was doing something like,

class Account < ApplicationRecord
  before_validation :normalize_cname

  private

  def normalize_cname
    self.cname = cname.to_s.downcase.gsub(/\Ahttps?:\/\//, "")
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, this isn't possible with the normalize method with what we have right now.

Designing for resilience

I thought to myself that it would be perfect if we could pass a block to the normalize method and call the block in the normalized_value method. Something like this would work perfectly.

class Account < ApplicationRecord
  normalize :cname, with: %i[downcase] do |cname|
    cname.gsub(/\Ahttps?:\/\//, "")
  end
end
Enter fullscreen mode Exit fullscreen mode

In my opinion, passing in a block would be useful in many cases. Not only can we use methods like gsub, we can also use our methods for more power.

class Account < ApplicationRecord
  normalize :cname do |cname|
    some_method(cname)
  end

  def self.some_method(cname)
    # some logic
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's make some changes to the concern.

module Normalize
  extend ActiveSupport::Concern

  class_methods do
    def normalize(*args, &block)
      options = args.extract_options!
      normalizers = [block, options[:with]].flatten.compact

      before_validation do
        args.each { |field| send("#{field}=", normalized_value(field, normalizers)) }
      end
    end
  end

  private

  def normalized_value(field, normalizers)
    value = send(field).to_s

    normalizers.each do |normalizer|
      value = if normalizer.respond_to?(:call)
                normalizer.call(value)
              elsif value.respond_to?(normalizer)
                value.send(normalizer)
              end
    end

    value
  end
end
Enter fullscreen mode Exit fullscreen mode

This is the final implementation of the normalize method. Instead of checking if the with option is an array, we now check if it's a block.

I'm pretty satisfied with the implementation and the only way to find if it fits all use cases is to use it on different code bases. I'm still on the lookout for more extractions from my existing projects and I'll try to share with you all if I find them useful.

References

πŸ’– πŸ’ͺ πŸ™… 🚩
abeidahmed
Abeid Ahmed

Posted on August 26, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related