DEV Community

Cover image for Normalizing ActiveRecord attributes with a custom DSL
Abeid Ahmed
Abeid Ahmed

Posted on

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

Top comments (2)

Collapse
 
alissonviegas profile image
Alisson Viegas

Nice! Thanks.

Collapse
 
abeidahmed profile image
Abeid Ahmed

I hope it helps!