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

Posted by Tobias L. Maier on March 24, 2024

This article reviews the new normalizes feature in Ruby on Rails 7.1 and shows how to increase reusability by creating normalizer modules.

Introduction

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

Review

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.

Increasing reusability

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.