Skip to content

Commit

Permalink
Merge pull request rails#43945 from jonathanhefner/active_record-norm…
Browse files Browse the repository at this point in the history
…alizes

Add `ActiveRecord::Base::normalizes`
  • Loading branch information
jonathanhefner committed Dec 21, 2022
2 parents 47eaf88 + d4c31bd commit 52146de
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 0 deletions.
26 changes: 26 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
* Add `ActiveRecord::Base::normalizes` to declare attribute normalizations.

A 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 finder methods.
This allows a record to be created and later queried using unnormalized
values. For example:

```ruby
class User < ActiveRecord::Base
normalizes :email, with: -> email { email.strip.downcase }
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.exists?(email: "\tCRUISE-CONTROL@EXAMPLE.COM ") # => true
User.exists?(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]) # => false
```

*Jonathan Hefner*

* Hide changes to before_committed! callback behaviour behind flag.

In #46525, behavior around before_committed! callbacks was changed so that callbacks
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ module ActiveRecord
autoload :Migrator, "active_record/migration"
autoload :ModelSchema
autoload :NestedAttributes
autoload :Normalization
autoload :NoTouching
autoload :Persistence
autoload :QueryCache
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/active_record/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ class Base
include TokenFor
include SignedId
include Suppressor
include Normalization
end

ActiveSupport.run_load_hooks(:active_record, Base)
Expand Down
155 changes: 155 additions & 0 deletions activerecord/lib/active_record/normalization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 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 finder 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.
#
# ==== Options
#
# * +:with+ - The normalization to apply.
# * +: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.exists?(email: "\tCRUISE-CONTROL@EXAMPLE.COM ") # => true
# User.exists?(["email = ?", "\tCRUISE-CONTROL@EXAMPLE.COM "]) # => false
#
# User.normalize(: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(:email, " CRUISE-CONTROL@EXAMPLE.COM\n")
# # => "cruise-control@example.com"
def normalize(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
109 changes: 109 additions & 0 deletions activerecord/test/cases/normalized_attribute_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

require "cases/helper"
require "models/aircraft"
require "active_support/core_ext/string/inflections"

class NormalizedAttributeTest < ActiveRecord::TestCase
class NormalizedAircraft < Aircraft
normalizes :name, with: -> name { name.titlecase }
normalizes :manufactured_at, with: -> time { time.noon }

attr_accessor :validated_name
validate { self.validated_name = name.dup }
end

setup do
@time = Time.utc(1999, 12, 31, 12, 34, 56)
@aircraft = NormalizedAircraft.create!(name: "fly HIGH", manufactured_at: @time)
end

test "normalizes value from create" do
assert_equal "Fly High", @aircraft.name
end

test "normalizes value from update" do
@aircraft.update!(name: "fly HIGHER")
assert_equal "Fly Higher", @aircraft.name
end

test "normalizes value from assignment" do
@aircraft.name = "fly HIGHER"
assert_equal "Fly Higher", @aircraft.name
end

test "normalizes changed-in-place value before validation" do
@aircraft.name.downcase!
assert_equal "fly high", @aircraft.name

@aircraft.valid?
assert_equal "Fly High", @aircraft.validated_name
end

test "normalizes value on demand" do
@aircraft.name.downcase!
assert_equal "fly high", @aircraft.name

@aircraft.normalize_attribute(:name)
assert_equal "Fly High", @aircraft.name
end

test "normalizes value without record" do
assert_equal "Titlecase Me", NormalizedAircraft.normalize(:name, "titlecase ME")
end

test "casts value before applying normalization" do
@aircraft.manufactured_at = @time.to_s
assert_equal @time.noon, @aircraft.manufactured_at
end

test "ignores nil by default" do
assert_nil NormalizedAircraft.normalize(:name, nil)
end

test "normalizes nil if apply_to_nil" do
including_nil = Class.new(Aircraft) do
normalizes :name, with: -> name { name&.titlecase || "Untitled" }, apply_to_nil: true
end

assert_equal "Untitled", including_nil.normalize(:name, nil)
end

test "does not automatically normalize value from database" do
from_database = NormalizedAircraft.find(Aircraft.create(name: "NOT titlecase").id)
assert_equal "NOT titlecase", from_database.name
end

test "finds record by normalized value" do
assert_equal @time.noon, @aircraft.manufactured_at
assert_equal @aircraft, NormalizedAircraft.find_by(manufactured_at: @time.to_s)
end

test "can stack normalizations" do
titlecase_then_reverse = Class.new(NormalizedAircraft) do
normalizes :name, with: -> name { name.reverse }
end

assert_equal "eM esreveR nehT esaceltiT", titlecase_then_reverse.normalize(:name, "titlecase THEN reverse ME")
assert_equal "Only Titlecase Me", NormalizedAircraft.normalize(:name, "ONLY titlecase ME")
end

test "minimizes number of times normalization is applied" do
count_applied = Class.new(Aircraft) do
normalizes :name, with: -> name { name.succ }
end

aircraft = count_applied.create!(name: "0")
assert_equal "1", aircraft.name

aircraft.name = "0"
assert_equal "1", aircraft.name
aircraft.save
assert_equal "1", aircraft.name

aircraft.name.replace("0")
assert_equal "0", aircraft.name
aircraft.save
assert_equal "1", aircraft.name
end
end

0 comments on commit 52146de

Please sign in to comment.