Rails Introduces ActiveModel::Attributes::Normalization

When we work with user input, we want to sanitaize the data shared by user. Sometimes we want to format the data before saving it to the database(eg: downcase emails).

Normalization allows us to keep the data clean, consistent and predictable. We can format the data before saving it to the database or further processing.

For example, imagine a user signing up with [email protected] and another with [email protected]. Without downcasing these emails, even though they’re the same but system treats them as different emails. We can normalize the email format before saving it to the database.

Before

Rails 7.1 introduced ActiveRecord::Base::Normalization, we can specify unique normalization rules for model attributes. When an attribute is assigned or changed, normalization is applied, and the normalised value is stored in the database.

The normalizes(*names, with:, apply_to_nil: false) method takes name of the attribute and a block that defines the normalization logic.

options

  • :with -> Any callable object that accepts the attribute’s value as its sole argument, and returns it normalized.

  • :apply_to_nil -> Whether to apply the normalization to nil values. Defaults to false.

class User < ActiveRecord::Base
  normalizes :name, with: -> name { name.strip.titlecase }
end
user = User.create(name: " JOHN DOE \n")
user.name # => "John Doe"

# This demonstrates that the name attribute is successfully normalized during the creation of a new user.

By default, normalization skips nil values. Otherwise below code throws NoMethodError from nil.strip.

user = User.create(name: nil)
user.name # => nil

To normalize nil value, we can enable it using :apply_to_nil option.

class User < ActiveRecord::Base
  normalizes :name, with: -> name { name.strip.titlecase || 'Untitled' }, apply_to_nil: true
end

user = User.create(name: nil)
user.name # => 'Untitled'

With normalize_value_for, we can apply the normalization rule manually.

User.normalize_value_for(:name, " JOHN DOE\n")
# => "John Doe"

We can find record by normalized value. This allows a record to be created and later queried using unnormalized values but it won’t work with SQL fragments. Since SQL does not automatically apply the normalization rules defined in our model, it searches for an exact match.

# Finding a user by a differently formatted name:
user = User.find_by(name: "\tJOHN DOE ")
user.name # => "John Doe"
user.name_before_type_cast # => "John Doe"


# Counting users based on a differently formatted name:
User.where(name: "\t JOHN DOE ").count # => 1
User.where(["name = ?", "\tJOHN DOE "]).count # => 0


# Checking for the existence of a user with a differently formatted name:
User.exists?(name: "\tJOHN DOE ") # => true
User.exists?(["name = ?", "\tJOHN DOE "]) # => false

The Normalization is not automatically applied when fetching data from the database to prevent unintended alterations to pre-existing records. To apply normalization to pre-existing data, we can either assign a new value to the attribute or explicitly migrate it using the Normalization#normalize_attribute method.

legacy_user = User.find(1)
legacy_user.name # => " JOHN DOE\n"
legacy_user.normalize_attribute(:name)
legacy_user.name # => "John Doe"
legacy_user.save

The key disadvantage with ActiveRecord::Base::Normalization is that it works only for ActiveRecord, but not for ActiveModel based classes. This means it cannot used with form objects or other classes that include ActiveModel::Model.

For example:

  • Value Objects need consistent formatting (phone numbers, ISBNs)
  • Form Objects require input sanitization
  • API Resource objects need to normalize data from external sources

After

Rails migrated ActiveRecord::Normalization to ActiveModel. Now we can use Normalization with both ActiveModel and ActiveRecord.

class User
  include ActiveModel::Attributes
  include ActiveModel::Attributes::Normalization

  attribute :name, :string
  attribute :phone, :string

  normalizes :name, with: -> name { name.strip.titlecase }
  normalizes :phone, with: -> phone { phone.delete("^0-9").delete_prefix("1") }
end

user = User.create(name: " JOHN DOE \n")
user.name # => "John Doe"

User.normalize_value_for(:phone, "+1 (555) 867-5309") # => "5558675309"

With ActiveRecord it works same like before with all the previous normalization features as ActiveRecord includes ActiveModel::Attributes::Normalization under the hood.

class User < ActiveRecord::Base
  normalizes :name, with: -> name { name.strip.titlecase }
end

user = User.create(name: " JOHN DOE \n")
user.name # => "John Doe"

Refer

Need help on your Ruby on Rails or React project?

Join Our Newsletter