There can be multiple channels where our data can come from. It's difficult to enforce formatting checks on the source of the data, e.g. data may come from some API request or user's input. Thus, we need to format attribute values before setting them on the ActiveRecord model to maintain data integrity There are many ways we can format values before saving them to the database.
- Override the attribute getter or setter method on the model.
- Extend ActiveRecord with a module which implements the formatter method
- Use ActiveRecord::Type::Value to define a attribute on the model.
Let's take an example of the User model where we want to perform following operations before saving data to the database:
- format the phone number using PhoneLib - a phone number formatting library.
- downcase the email
class User < ApplicationRecord
validates_presence_of :phone_number
validates_presence_of :email
end
Override ActiveRecord getter or setter
There are two ways to solve this problem.
- Override the setter method to the model: We'll save the formatted data in the database. In this case, we'll lose the original value entered by the user.
class User < ApplicationRecord
validates_presence_of :phone_number
validates_presence_of :email
def phone_number=(val)
self[:phone_number] = Phonelib.parse(value.to_s).to_s
end
def email=(val)
self[:email] = val.downcase
end
end
Let's examine how these getters and setters function on a rails console.
user = User.new(phone_number: "+1(908)232-6758", email: "TEst@test.COM")
user.phone_number # => "+13478941111"
user.email # => "test@test.com"
- Override the getter method on the model: This way, we'll keep the data entered by the user in the same format as they have entered, but we'll display it in a formatted manner.
class User < ApplicationRecord
validates_presence_of :phone_number
validates_presence_of :email
def phone_number
Phonelib.parse(self[:phone_number]).to_s
end
def email
self[:email].downcase
end
end
# On rails console:
user = User.create(phone_number: "+1(908)232-6758", email: "TEst@test.COM")
user.phone_number # => "+13478941111"
user.email # => "test@test.com"
# But in database values will be saved as it is.
# phone_number: "+1(908)232-6758"
# email: "TEst@test.COM"
Extend ActiveRecord with the formatter module
We can not reuse the above approach in other models since this is specific to the model where it is defined. What if we want to extend the same formatter to other models? In that case, we need to extend the ActiveRecord formatter, so that this functionality can be reused on other models.
# lib/active_record_extend/formatter.rb
module ActiveRecordExtend
module Formatter
def format_field(*args)
attribute_name, formatter_bloc = args
define_method("#{attribute_name}=") do |value|
super(formatter_bloc[:with].call(value))
end
end
end
end
ActiveRecord::Base.extend ActiveRecordExtend::ActiveRecord
# Add the following line in the active_record_extender.rb to extend the ActiveRecord with the Formatter module
# config/intializers/active_record_extender.rb
require_relative "../../lib/active_record_extend/formatter"
We can use this on our models as given below.
class User < ApplicationRecord
# passing the proc for processing according to the attributes specification
format_field :phone_number, with: ->(val) { Phonelib.parse(value.to_s).to_s }
format_field :email, with: ->(val) { val.downcase }
validates_presence_of :phone_number
validates_presence_of :email
end
This approach is a bit complex but it seems like a more idiomatic way to implement the formatter for rails.
ActiveRecord Attributes
Rails introduced ActiveRecord Attributes in Rails 5 to solve this issue. This feature allows a developer to assert a specific type for a given attribute and an optional default value. This API coerces the value of the attribute to the defined type, which is helpful because the developer doesn't have to coerce the value explicitly as we have done in the first solution using getters and setters. Refer following artciles to know more about the Attributes in Rails.
Let's check an example of using ActiveRecord Attributes below.:
# app/types/formatted_type.rb
class FormattedType < ActiveRecord::Type::Value
attr_reader :normalizer
def initialize(normalizer:)
@normalizer = normalizer
end
def cast(value)
normalizer.call(super(value))
end
end
# config/initializers/types.rb
ActiveRecord::Type.register(:formatted_field, FormattedType)
We can use this on our models as given below.
class User < ApplicationRecord
# passing the proc for processing according to the attributes specification
attribute :phonenumber, :formatted_field, ->(val) { Phonelib.parse(value.to_s).to_s }
attribute :email, :formatted_field, ->(val) { val.downcase }
validates_presence_of :phone_number
validates_presence_of :email
end
This approach is almost similar to the Extend ActiveRecord with the formatter module. This is using the attribute API which is being used by Rails internally for the value coercion.
Currently, we have to write attribute(:column_name, :formatted_field, **attrs)
for the attribute formatting. A better way to write this can be like format_field :column_name, **attrs
. This format explains what exactly is happening with this field. We can create a new module and extend ActiveRecord with the module to add this syntactic sugar.
Let's see how this works:
# lib/types/formatted_type.rb
class FormattedType < ActiveRecord::Type::Value
attr_reader :normalizer
def initialize(normalizer:)
@normalizer = normalizer
end
def cast(value)
normalizer.call(super(value))
end
end
# lib/active_record_extend/formatter.rb
require 'formatted_type'
module ActiveRecordExtend
module Formatter
def format_field(attr_name, **options)
attribute(attr_name) do
FormattedType.new(normalizer: options[:with])
end
end
end
end
ActiveRecord::Base.extend ActiveRecordExtend::Formatter
# Add following line in the active_record_extender.rb to extend the ActiveRecord with the Formatter module
# config/intializers/active_record_extender.rb
require_relative "../../lib/active_record_extend/formatter"
Now in rails models, we can use it like following:
class User < ApplicationRecord
# passing the proc for processing according to the attributes specification
format_field :phonenumber, :with ->(val) { Phonelib.parse(value.to_s).to_s }
format_field :email, :with ->(val) { val.downcase }
validates_presence_of :phonenumber
validates_presence_of :email
end
Though this method is just a syntactic sugar on top of the Attribute method, it explicitly shows the intent. This approach is inspired from Gist.
References:
Please connect with us on Twitter for your feedback and suggestions.