Skip to content

Commit d48a356

Browse files
Merge pull request rails#53887 from seanpdoyle/active-model-normalization
Migrate `ActiveRecord::Normalization` to Active Model
2 parents 527483b + b035626 commit d48a356

File tree

6 files changed

+301
-158
lines changed

6 files changed

+301
-158
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: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
def normalize_attribute(name)
35+
# Treat the value as a new, unnormalized value.
36+
send(:"#{name}=", send(name))
37+
end
38+
39+
module ClassMethods
40+
# Declares a normalization for one or more attributes. The normalization
41+
# is applied when the attribute is assigned or validated.
42+
#
43+
# Because the normalization may be applied multiple times, it should be
44+
# _idempotent_. In other words, applying the normalization more than once
45+
# should have the same result as applying it only once.
46+
#
47+
# By default, the normalization will not be applied to +nil+ values. This
48+
# behavior can be changed with the +:apply_to_nil+ option.
49+
#
50+
# ==== Options
51+
#
52+
# * +:with+ - Any callable object that accepts the attribute's value as
53+
# its sole argument, and returns it normalized.
54+
# * +:apply_to_nil+ - Whether to apply the normalization to +nil+ values.
55+
# Defaults to +false+.
56+
#
57+
# ==== Examples
58+
#
59+
# class User
60+
# include ActiveModel::Attributes
61+
# include ActiveModel::Attributes::Normalization
62+
#
63+
# attribute :email, :string
64+
# attribute :phone, :string
65+
#
66+
# normalizes :email, with: -> email { email.strip.downcase }
67+
# normalizes :phone, with: -> phone { phone.delete("^0-9").delete_prefix("1") }
68+
# end
69+
#
70+
# user = User.new
71+
# user.email = " [email protected]\n"
72+
# user.email # => "[email protected]"
73+
#
74+
# User.normalize_value_for(:phone, "+1 (555) 867-5309") # => "5558675309"
75+
def normalizes(*names, with:, apply_to_nil: false)
76+
decorate_attributes(names) do |name, cast_type|
77+
NormalizedValueType.new(cast_type: cast_type, normalizer: with, normalize_nil: apply_to_nil)
78+
end
79+
80+
self.normalized_attributes += names.map(&:to_sym)
81+
end
82+
83+
# Normalizes a given +value+ using normalizations declared for +name+.
84+
#
85+
# ==== Examples
86+
#
87+
# class User
88+
# include ActiveModel::Attributes
89+
# include ActiveModel::Attributes::Normalization
90+
#
91+
# attribute :email, :string
92+
#
93+
# normalizes :email, with: -> email { email.strip.downcase }
94+
# end
95+
#
96+
# User.normalize_value_for(:email, " [email protected]\n")
97+
98+
def normalize_value_for(name, value)
99+
type_for_attribute(name).cast(value)
100+
end
101+
end
102+
103+
private
104+
def normalize_changed_in_place_attributes
105+
self.class.normalized_attributes.each do |name|
106+
normalize_attribute(name) if attribute_changed_in_place?(name)
107+
end
108+
end
109+
110+
class NormalizedValueType < DelegateClass(ActiveModel::Type::Value) # :nodoc:
111+
include ActiveModel::Type::SerializeCastValue
112+
113+
attr_reader :cast_type, :normalizer, :normalize_nil
114+
alias :normalize_nil? :normalize_nil
115+
116+
def initialize(cast_type:, normalizer:, normalize_nil:)
117+
@cast_type = cast_type
118+
@normalizer = normalizer
119+
@normalize_nil = normalize_nil
120+
super(cast_type)
121+
end
122+
123+
def cast(value)
124+
normalize(super(value))
125+
end
126+
127+
def serialize(value)
128+
serialize_cast_value(cast(value))
129+
end
130+
131+
def serialize_cast_value(value)
132+
ActiveModel::Type::SerializeCastValue.serialize(cast_type, value)
133+
end
134+
135+
def ==(other)
136+
self.class == other.class &&
137+
normalize_nil? == other.normalize_nil? &&
138+
normalizer == other.normalizer &&
139+
cast_type == other.cast_type
140+
end
141+
alias eql? ==
142+
143+
def hash
144+
[self.class, cast_type, normalizer, normalize_nil?].hash
145+
end
146+
147+
define_method(:inspect, Kernel.instance_method(:inspect))
148+
149+
private
150+
def normalize(value)
151+
normalizer.call(value) unless value.nil? && !normalize_nil?
152+
end
153+
end
154+
end
155+
end
156+
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

0 commit comments

Comments
 (0)