How to enable reusability for ActiveRecord::Base::normalizes
Review of the new normalizes feature in Ruby on Rails 7.1 and how to increase reusability
This article reviews the new normalizes
feature in Ruby on Rails 7.1 and shows how to increase reusability by creating normalizer modules.
When working with user input, one often has to normalize it before storing it in the database.
For example, one may want to strip leading and trailing whitespace from all inputs, store NULL
/nil
instead of empty strings or convert all emails to lowercase.
Up to now, one had to do this manually or use 3rd party gems, like attribute_normalizer
or any other gem from the Active Record Value Cleanup category.
With Ruby on Rails 7.1 3rd party gems may not be necessary anymore, since it introduced normalizes
for ActiveRecord::Base
.
class User < ActiveRecord::Base
normalizes :email, with: -> email { email.strip.downcase }
end
It works not only when validating the model, but also at the setter. This is a great improvement for the rails community, since two out of the three most popular gems for normalizing attributes1 only work when validating the model, not when setting the attribute directly.
user = User.new(email: ' john.doe@EXAMPLE.com ')
user.email # => 'john.doe@example.dom'
Normalization is also applied when querying the database. To my understanding, none of the three most popular gems for normalizing attributes do this.
User.where(email: " JOHN.DOE@example.com ").count # => 1
The key disadvantage I see is that it works only for Active Record, but not for Active Model-based classes.
This means it cannot used with form objects or other classes that include ActiveModel::Model
.
(I opened a feature request for this: https://discuss.rubyonrails.org/t/proposal-activemodel-normalization/85405)
Another, comparatively minor, disadvantage is that block provided to with:
is not reusable.
The examples shown in the Ruby on Rails documentation (and above) are very simple, but in practice the normalization rules will be more complex.
As stated above, one may want to store NULL
/nil
instead of empty strings and before that strip all leading and trailing whitespace.
Another interesting use case could be to normalize the UTF-8 input2 (using the unf
gem).
And when the complexity of the lambda provided increases, one should want to test them properly.
When checking the source code of ActiveRecord::Normalization
, you can see that the with
requires any object that responds to call
.3
I created three normalizer classes or modules at app/normalizers
:
# frozen_string_literal: true
# Strips leading and trailing whitespace
module StripNormalizer
def self.call(value)
value.is_a?(String) ? value.strip : value
end
end
# frozen_string_literal: true
# Converts empty strings to nil
module BlankNormalizer
def self.call(value)
value.nil? || (value.is_a?(String) && value.blank?) ? nil : value
end
end
# frozen_string_literal: true
# Applies all normalizers in the order they are provided
class PipelineNormalizer
# @param normalizers [Array<#call>] a list of normalizers
def initialize(*normalizers)
@normalizers = normalizers.flatten
end
def call(value)
@normalizers.reduce(value) do |current_value, normalizer|
normalizer.call(current_value)
end
end
end
The first two are pretty obvious, the third one is a pipeline that applies all normalizers in the order they are provided.
Now I can use them in the model:
class User < ActiveRecord::Base
normalizes :email, with: PipelineNormalizer.new(StripNormalizer, BlankNormalizer, -> { _1&.downcase })
end
I also created test cases for the normalizers:
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe StripNormalizer do
it 'strips leading and trailing whitespace' do
expect(described_class.call(' test ')).to eq('test')
end
it 'does not alter non-strings' do
expect(described_class.call(42)).to eq(42)
end
end
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe BlankNormalizer do
it 'converts empty strings to nil' do
expect(described_class.call('')).to be_nil
end
it 'does not convert non-empty strings to nil' do
expect(described_class.call('test')).to eq('test')
end
it 'converts nil to nil' do
expect(described_class.call(nil)).to be_nil
end
end
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PipelineNormalizer do
subject(:normalizer) { described_class.new(StripNormalizer, BlankNormalizer) }
it 'applies all normalizers in the order they are provided' do
expect(normalizer.call('')).to be_nil
expect(normalizer.call(' ')).to be_nil
expect(normalizer.call(nil)).to be_nil
expect(normalizer.call(' test ')).to eq('test')
expect(normalizer.call('test')).to eq('test')
expect(normalizer.call(42)).to eq(42)
end
end
This way I can test the normalizers in isolation and reuse them in other models.
I hope this will be helpful for you, too.
strip_attributes
, attribute_normalizer
and auto_strip_attributes
gems, see https://www.ruby-toolbox.com/categories/Active_Record_Value_Cleanup ↩
See source code of ActiveRecord::Normalization::NormalizedValueType#normalize
↩