Skip to content

Commit 599c9af

Browse files
committed
Migrate ActiveRecord::Normalization to Active Model
Follow-up to [rails#53954][] Re-submission of [rails#53887][] Closes [rails#53793][] Follow-up to [rails#43945][] To support this behavior, the bulk of the implementation is moved to the new `ActiveModel::Normalization` module. Any "persistence"-related language, methods, and test coverage has been excised. The single implementation change is related to reading and writing attributes: ```diff def normalize_attribute(name) # Treat the value as a new, unnormalized value. - self[name] = self[name] + send(:"#{name}=", send(name)) end ``` This can be undone if a change like [rails#53886][] lands. [rails#53954]: rails#53954 [rails#53887]: rails#53887 [rails#53793]: rails#53793 [rails#43945]: rails#43945 [rails#53886]: rails#53886
1 parent 0396f46 commit 599c9af

File tree

9 files changed

+322
-220
lines changed

9 files changed

+322
-220
lines changed

activemodel/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,20 @@
1+
* Backport `ActiveRecord::Normalization` to `ActiveModel::Attributes::Normalization`
2+
3+
```ruby
4+
class User
5+
include ActiveModel::Attributes
6+
include ActiveModel::Attributes::Normalization
7+
8+
attribute :email, :string
9+
10+
normalizes :email, with: -> email { email.strip.downcase }
11+
end
12+
13+
user = User.new
14+
user.email = " [email protected]\n"
15+
user.email # => "[email protected]"
16+
```
17+
18+
*Sean Doyle*
119

220
Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activemodel/CHANGELOG.md) for previous changes.

activemodel/lib/active_model.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ module ActiveModel
5656
autoload :Validations
5757
autoload :Validator
5858

59+
module Attributes
60+
extend ActiveSupport::Autoload
61+
62+
autoload :Normalization
63+
end
64+
5965
eager_autoload do
6066
autoload :Errors
6167
autoload :Error
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveModel
4+
module Attributes
5+
module Normalization
6+
extend ActiveSupport::Concern
7+
8+
included do
9+
include ActiveModel::Dirty
10+
include ActiveModel::Validations::Callbacks
11+
12+
class_attribute :normalized_attributes, default: Set.new
13+
14+
before_validation :normalize_changed_in_place_attributes
15+
end
16+
17+
# Normalizes a specified attribute using its declared normalizations.
18+
#
19+
# ==== Examples
20+
#
21+
# class User
22+
# include ActiveModel::Attributes
23+
# include ActiveModel::Attributes::Normalization
24+
#
25+
# attribute :email, :string
26+
#
27+
# normalizes :email, with: -> email { email.strip.downcase }
28+
# end
29+
#
30+
# legacy_user = User.load_from_legacy_data(...)
31+
# legacy_user.email # => " [email protected]\n"
32+
# legacy_user.normalize_attribute(:email)
33+
# legacy_user.email # => "[email protected]"
34+
#
35+
# ==== Behavior with Active Record
36+
#
37+
# To prevent confusion, normalization will not be applied
38+
# when the attribute is fetched from the database. This means that if a
39+
# record was persisted before the normalization was declared, the record's
40+
# attribute will not be normalized until either it is assigned a new
41+
# value, or it is explicitly migrated via Normalization#normalize_attribute.
42+
#
43+
# Be aware that if your app was created before Rails 7.1, and your app
44+
# marshals instances of the targeted model (for example, when caching),
45+
# then you should set ActiveRecord.marshalling_format_version to +7.1+ or
46+
# higher via either <tt>config.load_defaults 7.1</tt> or
47+
# <tt>config.active_record.marshalling_format_version = 7.1</tt>.
48+
# Otherwise, +Marshal+ may attempt to serialize the normalization +Proc+
49+
# and raise +TypeError+.
50+
#
51+
# class User < ActiveRecord::Base
52+
# normalizes :email, with: -> email { email.strip.downcase }
53+
# normalizes :phone, with: -> phone { phone.delete("^0-9").delete_prefix("1") }
54+
# end
55+
#
56+
# user = User.create(email: " [email protected]\n")
57+
# user.email # => "[email protected]"
58+
#
59+
# user = User.find_by(email: "\[email protected] ")
60+
# user.email # => "[email protected]"
61+
# user.email_before_type_cast # => "[email protected]"
62+
#
63+
# User.where(email: "\[email protected] ").count # => 1
64+
# User.where(["email = ?", "\[email protected] "]).count # => 0
65+
#
66+
# User.exists?(email: "\[email protected] ") # => true
67+
# User.exists?(["email = ?", "\[email protected] "]) # => false
68+
#
69+
# User.normalize_value_for(:phone, "+1 (555) 867-5309") # => "5558675309"
70+
def normalize_attribute(name)
71+
# Treat the value as a new, unnormalized value.
72+
send(:"#{name}=", send(name))
73+
end
74+
75+
module ClassMethods
76+
# Declares a normalization for one or more attributes. The normalization
77+
# is applied when the attribute is assigned or validated.
78+
#
79+
# Because the normalization may be applied multiple times, it should be
80+
# _idempotent_. In other words, applying the normalization more than once
81+
# should have the same result as applying it only once.
82+
#
83+
# By default, the normalization will not be applied to +nil+ values. This
84+
# behavior can be changed with the +:apply_to_nil+ option.
85+
#
86+
# ==== Options
87+
#
88+
# * +:with+ - Any callable object that accepts the attribute's value as
89+
# its sole argument, and returns it normalized.
90+
# * +:apply_to_nil+ - Whether to apply the normalization to +nil+ values.
91+
# Defaults to +false+.
92+
#
93+
# ==== Examples
94+
#
95+
# class User
96+
# include ActiveModel::Attributes
97+
# include ActiveModel::Attributes::Normalization
98+
#
99+
# attribute :email, :string
100+
# attribute :phone, :string
101+
#
102+
# normalizes :email, with: -> email { email.strip.downcase }
103+
# normalizes :phone, with: -> phone { phone.delete("^0-9").delete_prefix("1") }
104+
# end
105+
#
106+
# user = User.new
107+
# user.email = " [email protected]\n"
108+
# user.email # => "[email protected]"
109+
#
110+
# User.normalize_value_for(:phone, "+1 (555) 867-5309") # => "5558675309"
111+
def normalizes(*names, with:, apply_to_nil: false)
112+
decorate_attributes(names) do |name, cast_type|
113+
NormalizedValueType.new(cast_type: cast_type, normalizer: with, normalize_nil: apply_to_nil)
114+
end
115+
116+
self.normalized_attributes += names.map(&:to_sym)
117+
end
118+
119+
# Normalizes a given +value+ using normalizations declared for +name+.
120+
#
121+
# ==== Examples
122+
#
123+
# class User
124+
# include ActiveModel::Attributes
125+
# include ActiveModel::Attributes::Normalization
126+
#
127+
# attribute :email, :string
128+
#
129+
# normalizes :email, with: -> email { email.strip.downcase }
130+
# end
131+
#
132+
# User.normalize_value_for(:email, " [email protected]\n")
133+
134+
def normalize_value_for(name, value)
135+
type_for_attribute(name).cast(value)
136+
end
137+
end
138+
139+
private
140+
def normalize_changed_in_place_attributes
141+
self.class.normalized_attributes.each do |name|
142+
normalize_attribute(name) if attribute_changed_in_place?(name)
143+
end
144+
end
145+
146+
class NormalizedValueType < DelegateClass(ActiveModel::Type::Value) # :nodoc:
147+
include ActiveModel::Type::SerializeCastValue
148+
149+
attr_reader :cast_type, :normalizer, :normalize_nil
150+
alias :normalize_nil? :normalize_nil
151+
152+
def initialize(cast_type:, normalizer:, normalize_nil:)
153+
@cast_type = cast_type
154+
@normalizer = normalizer
155+
@normalize_nil = normalize_nil
156+
super(cast_type)
157+
end
158+
159+
def cast(value)
160+
normalize(super(value))
161+
end
162+
163+
def serialize(value)
164+
serialize_cast_value(cast(value))
165+
end
166+
167+
def serialize_cast_value(value)
168+
ActiveModel::Type::SerializeCastValue.serialize(cast_type, value)
169+
end
170+
171+
def ==(other)
172+
self.class == other.class &&
173+
normalize_nil? == other.normalize_nil? &&
174+
normalizer == other.normalizer &&
175+
cast_type == other.cast_type
176+
end
177+
alias eql? ==
178+
179+
def hash
180+
[self.class, cast_type, normalizer, normalize_nil?].hash
181+
end
182+
183+
define_method(:inspect, Kernel.instance_method(:inspect))
184+
185+
private
186+
def normalize(value)
187+
normalizer.call(value) unless value.nil? && !normalize_nil?
188+
end
189+
end
190+
end
191+
end
192+
end
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
5+
class NormalizedAttributeTest < ActiveModel::TestCase
6+
class Aircraft
7+
include ActiveModel::API
8+
include ActiveModel::Attributes
9+
include ActiveModel::Attributes::Normalization
10+
11+
attribute :manufactured_at, :datetime, default: -> { Time.current }
12+
attribute :name, :string
13+
attribute :wheels_count, :integer, default: 0
14+
attribute :wheels_owned_at, :datetime
15+
end
16+
17+
class NormalizedAircraft < Aircraft
18+
normalizes :name, with: -> name { name.presence&.titlecase }
19+
normalizes :manufactured_at, with: -> time { time.noon }
20+
21+
attr_accessor :validated_name
22+
validate { self.validated_name = name.dup }
23+
end
24+
25+
setup do
26+
@time = Time.utc(1999, 12, 31, 12, 34, 56)
27+
@aircraft = NormalizedAircraft.new(name: "fly HIGH", manufactured_at: @time)
28+
end
29+
30+
test "normalizes value from validation" do
31+
@aircraft.validate!
32+
33+
assert_equal "Fly High", @aircraft.name
34+
end
35+
36+
test "normalizes value from assignment" do
37+
@aircraft.name = "fly HIGHER"
38+
assert_equal "Fly Higher", @aircraft.name
39+
end
40+
41+
test "normalizes changed-in-place value before validation" do
42+
@aircraft.name.downcase!
43+
assert_equal "fly high", @aircraft.name
44+
45+
@aircraft.valid?
46+
assert_equal "Fly High", @aircraft.validated_name
47+
end
48+
49+
test "normalizes value on demand" do
50+
@aircraft.name.downcase!
51+
assert_equal "fly high", @aircraft.name
52+
53+
@aircraft.normalize_attribute(:name)
54+
assert_equal "Fly High", @aircraft.name
55+
end
56+
57+
test "normalizes value without model" do
58+
assert_equal "Titlecase Me", NormalizedAircraft.normalize_value_for(:name, "titlecase ME")
59+
end
60+
61+
test "casts value when no normalization is declared" do
62+
assert_equal 6, NormalizedAircraft.normalize_value_for(:wheels_count, "6")
63+
end
64+
65+
test "casts value before applying normalization" do
66+
@aircraft.manufactured_at = @time.to_s
67+
assert_equal @time.noon, @aircraft.manufactured_at
68+
end
69+
70+
test "ignores nil by default" do
71+
assert_nil NormalizedAircraft.normalize_value_for(:name, nil)
72+
end
73+
74+
test "normalizes nil if apply_to_nil" do
75+
including_nil = Class.new(Aircraft) do
76+
normalizes :name, with: -> name { name&.titlecase || "Untitled" }, apply_to_nil: true
77+
end
78+
79+
assert_equal "Untitled", including_nil.normalize_value_for(:name, nil)
80+
end
81+
82+
test "can stack normalizations" do
83+
titlecase_then_reverse = Class.new(NormalizedAircraft) do
84+
normalizes :name, with: -> name { name.reverse }
85+
end
86+
87+
assert_equal "esreveR nehT esaceltiT", titlecase_then_reverse.normalize_value_for(:name, "titlecase THEN reverse")
88+
assert_equal "Only Titlecase", NormalizedAircraft.normalize_value_for(:name, "ONLY titlecase")
89+
end
90+
91+
test "minimizes number of times normalization is applied" do
92+
count_applied = Class.new(Aircraft) do
93+
normalizes :name, with: -> name { name.succ }
94+
end
95+
96+
aircraft = count_applied.new(name: "0")
97+
assert_equal "1", aircraft.name
98+
99+
aircraft.name = "0"
100+
assert_equal "1", aircraft.name
101+
102+
aircraft.name.replace("0")
103+
assert_equal "0", aircraft.name
104+
end
105+
end

activerecord/lib/active_record.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ module ActiveRecord
6262
autoload :ModelSchema
6363
autoload :NestedAttributes
6464
autoload :NoTouching
65-
autoload :Normalization
6665
autoload :Persistence
6766
autoload :QueryCache
6867
autoload :QueryLogs

activerecord/lib/active_record/attributes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module ActiveRecord
77
module Attributes
88
extend ActiveSupport::Concern
99
include ActiveModel::AttributeRegistration
10+
include ActiveModel::Attributes::Normalization
1011

1112
# = Active Record \Attributes
1213
module ClassMethods

activerecord/lib/active_record/base.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,6 @@ class Base
328328
include TokenFor
329329
include SignedId
330330
include Suppressor
331-
include Normalization
332331
include Marshalling::Methods
333332

334333
self.param_delimiter = "_"

0 commit comments

Comments
 (0)