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
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
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
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]
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 symbol
s or just a symbol
. For example,
normalize :email, :name, with: %i[strip downcase]
normalize :first_name, with: :strip
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
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
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
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
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.
Top comments (2)
Nice! Thanks.
I hope it helps!