LogoDTreeLabs

Format values of attributes on the ActiveRecord model

Gaurav SinghBy Gaurav Singh in RailsActiveRecord on March 2, 2022

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.

  1. Override the attribute getter or setter method on the model.
  2. Extend ActiveRecord with a module which implements the formatter method
  3. 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:

  1. format the phone number using PhoneLib - a phone number formatting library.
  2. 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.

  1. 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"
  1. 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.

  1. ActiveRecord::Attributes
  2. Introduction to Rails 5 Attributes

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:

  1. PhoneLib Gem

Please connect with us on Twitter for your feedback and suggestions.