# frozen_string_literal: true
module ActiveRecord # :nodoc:
module Normalization
extend ActiveSupport::Concern
included do
class_attribute :normalized_attributes, default: Set.new
before_validation :normalize_changed_in_place_attributes
end
# Normalizes a specified attribute using its declared normalizations.
#
# ==== Examples
#
# class User < ActiveRecord::Base
# normalizes :email, with: -> email { email.strip.downcase }
# end
#
# legacy_user = User.find(1)
# legacy_user.email # => " CRUISE-CONTROL@EXAMPLE.COM\n"
# legacy_user.normalize_attribute(:email)
# legacy_user.email # => "cruise-control@example.com"
# legacy_user.save
def normalize_attribute(name)
# Treat the value as a new, unnormalized value.
self[name] = self[name]
end
module ClassMethods
# Declares a normalization for one or more attributes. The normalization
# is applied when the attribute is assigned or updated, and the normalized
# value will be persisted to the database. The normalization is also
# applied to the corresponding keyword argument of query methods. This
# allows a record to be created and later queried using unnormalized
# values.
#
# However, to prevent confusion, the normalization will not be applied
# when the attribute is fetched from the database. This means that if a
# record was persisted before the normalization was declared, the record's
# attribute will not be normalized until either it is assigned a new
# value, or it is explicitly migrated via Normalization#normalize_attribute.
#
# Because the normalization may be applied multiple times, it should be
# _idempotent_. In other words, applying the normalization more than once
# should have the same result as applying it only once.
#
# By default, the normalization will not be applied to +nil+ values. This
# behavior can be changed with the +:apply_to_nil+ option.
#
# Be aware that if your app was created before Rails 7.1, and your app
# marshals instances of the targeted model (for example, when caching),
# then you should set ActiveRecord.marshalling_format_version to +7.1+ or
# higher via either config.load_defaults 7.1 or
# config.active_record.marshalling_format_version = 7.1.
# Otherwise, +Marshal+ may attempt to serialize the normalization +Proc+
# and raise +TypeError+.
#
# ==== 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+.
#
# ==== Examples
#
# class User < ActiveRecord::Base
# normalizes :email, with: -> email { email.strip.downcase }
# normalizes :phone, with: -> phone { phone.delete("^0-9").delete_prefix("1") }
# end
#
# user = User.create(email: " CRUISE-CONTROL@EXAMPLE.COM\n")
# user.email # => "cruise-control@example.com"
#
# user = User.find_by(email: "\tCRUISE-CONTROL@EXAMPLE.COM ")
# user.email # => "cruise-control@example.com"
# user.email_before_type_cast # => "cruise-control@example.com"
#
# User.where(email: "\tCRUISE-CONTROL@EXAMPLE.COM ").count # => 1
# User.where(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]).count # => 0
#
# User.exists?(email: "\tCRUISE-CONTROL@EXAMPLE.COM ") # => true
# User.exists?(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]) # => false
#
# User.normalize_value_for(:phone, "+1 (555) 867-5309") # => "5558675309"
def normalizes(*names, with:, apply_to_nil: false)
names.each do |name|
attribute(name) do |cast_type|
NormalizedValueType.new(cast_type: cast_type, normalizer: with, normalize_nil: apply_to_nil)
end
end
self.normalized_attributes += names.map(&:to_sym)
end
# Normalizes a given +value+ using normalizations declared for +name+.
#
# ==== Examples
#
# class User < ActiveRecord::Base
# normalizes :email, with: -> email { email.strip.downcase }
# end
#
# User.normalize_value_for(:email, " CRUISE-CONTROL@EXAMPLE.COM\n")
# # => "cruise-control@example.com"
def normalize_value_for(name, value)
type_for_attribute(name).cast(value)
end
end
private
def normalize_changed_in_place_attributes
self.class.normalized_attributes.each do |name|
normalize_attribute(name) if attribute_changed_in_place?(name)
end
end
class NormalizedValueType < DelegateClass(ActiveModel::Type::Value) # :nodoc:
include ActiveModel::Type::SerializeCastValue
attr_reader :cast_type, :normalizer, :normalize_nil
alias :normalize_nil? :normalize_nil
def initialize(cast_type:, normalizer:, normalize_nil:)
@cast_type = cast_type
@normalizer = normalizer
@normalize_nil = normalize_nil
super(cast_type)
end
def cast(value)
normalize(super(value))
end
def serialize(value)
serialize_cast_value(cast(value))
end
def serialize_cast_value(value)
ActiveModel::Type::SerializeCastValue.serialize(cast_type, value)
end
def ==(other)
self.class == other.class &&
normalize_nil? == other.normalize_nil? &&
normalizer == other.normalizer &&
cast_type == other.cast_type
end
alias eql? ==
def hash
[self.class, cast_type, normalizer, normalize_nil?].hash
end
def inspect
Kernel.instance_method(:inspect).bind_call(self)
end
private
def normalize(value)
normalizer.call(value) unless value.nil? && !normalize_nil?
end
end
end
end