Skip to content

Commit 52146de

Browse files
Merge pull request rails#43945 from jonathanhefner/active_record-normalizes
Add `ActiveRecord::Base::normalizes`
2 parents 47eaf88 + d4c31bd commit 52146de

File tree

5 files changed

+292
-0
lines changed

5 files changed

+292
-0
lines changed

activerecord/CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
* Add `ActiveRecord::Base::normalizes` to declare attribute normalizations.
2+
3+
A normalization is applied when the attribute is assigned or updated, and
4+
the normalized value will be persisted to the database. The normalization
5+
is also applied to the corresponding keyword argument of finder methods.
6+
This allows a record to be created and later queried using unnormalized
7+
values. For example:
8+
9+
```ruby
10+
class User < ActiveRecord::Base
11+
normalizes :email, with: -> email { email.strip.downcase }
12+
end
13+
14+
user = User.create(email: " [email protected]\n")
15+
user.email # => "[email protected]"
16+
17+
user = User.find_by(email: "\t[email protected] ")
18+
user.email # => "[email protected]"
19+
user.email_before_type_cast # => "[email protected]"
20+
21+
User.exists?(email: "\t[email protected] ") # => true
22+
User.exists?(["email = ?", "\t[email protected] "]) # => false
23+
```
24+
25+
*Jonathan Hefner*
26+
127
* Hide changes to before_committed! callback behaviour behind flag.
228

329
In #46525, behavior around before_committed! callbacks was changed so that callbacks

activerecord/lib/active_record.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ module ActiveRecord
5656
autoload :Migrator, "active_record/migration"
5757
autoload :ModelSchema
5858
autoload :NestedAttributes
59+
autoload :Normalization
5960
autoload :NoTouching
6061
autoload :Persistence
6162
autoload :QueryCache

activerecord/lib/active_record/base.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ class Base
329329
include TokenFor
330330
include SignedId
331331
include Suppressor
332+
include Normalization
332333
end
333334

334335
ActiveSupport.run_load_hooks(:active_record, Base)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecord # :nodoc:
4+
module Normalization
5+
extend ActiveSupport::Concern
6+
7+
included do
8+
class_attribute :normalized_attributes, default: Set.new
9+
10+
before_validation :normalize_changed_in_place_attributes
11+
end
12+
13+
# Normalizes a specified attribute using its declared normalizations.
14+
#
15+
# ==== Examples
16+
#
17+
# class User < ActiveRecord::Base
18+
# normalizes :email, with: -> email { email.strip.downcase }
19+
# end
20+
#
21+
# legacy_user = User.find(1)
22+
# legacy_user.email # => " [email protected]\n"
23+
# legacy_user.normalize_attribute(:email)
24+
# legacy_user.email # => "[email protected]"
25+
# legacy_user.save
26+
def normalize_attribute(name)
27+
# Treat the value as a new, unnormalized value.
28+
self[name] = self[name]
29+
end
30+
31+
module ClassMethods
32+
# Declares a normalization for one or more attributes. The normalization
33+
# is applied when the attribute is assigned or updated, and the normalized
34+
# value will be persisted to the database. The normalization is also
35+
# applied to the corresponding keyword argument of finder methods. This
36+
# allows a record to be created and later queried using unnormalized
37+
# values.
38+
#
39+
# However, to prevent confusion, the normalization will not be applied
40+
# when the attribute is fetched from the database. This means that if a
41+
# record was persisted before the normalization was declared, the record's
42+
# attribute will not be normalized until either it is assigned a new
43+
# value, or it is explicitly migrated via Normalization#normalize_attribute.
44+
#
45+
# Because the normalization may be applied multiple times, it should be
46+
# _idempotent_. In other words, applying the normalization more than once
47+
# should have the same result as applying it only once.
48+
#
49+
# By default, the normalization will not be applied to +nil+ values. This
50+
# behavior can be changed with the +:apply_to_nil+ option.
51+
#
52+
# ==== Options
53+
#
54+
# * +:with+ - The normalization to apply.
55+
# * +:apply_to_nil+ - Whether to apply the normalization to +nil+ values.
56+
# Defaults to +false+.
57+
#
58+
# ==== Examples
59+
#
60+
# class User < ActiveRecord::Base
61+
# normalizes :email, with: -> email { email.strip.downcase }
62+
# normalizes :phone, with: -> phone { phone.delete("^0-9").delete_prefix("1") }
63+
# end
64+
#
65+
# user = User.create(email: " [email protected]\n")
66+
# user.email # => "[email protected]"
67+
#
68+
# user = User.find_by(email: "\[email protected] ")
69+
# user.email # => "[email protected]"
70+
# user.email_before_type_cast # => "[email protected]"
71+
#
72+
# User.exists?(email: "\[email protected] ") # => true
73+
# User.exists?(["email = ?", "\[email protected] "]) # => false
74+
#
75+
# User.normalize(:phone, "+1 (555) 867-5309") # => "5558675309"
76+
def normalizes(*names, with:, apply_to_nil: false)
77+
names.each do |name|
78+
attribute(name) do |cast_type|
79+
NormalizedValueType.new(cast_type: cast_type, normalizer: with, normalize_nil: apply_to_nil)
80+
end
81+
end
82+
83+
self.normalized_attributes += names.map(&:to_sym)
84+
end
85+
86+
# Normalizes a given +value+ using normalizations declared for +name+.
87+
#
88+
# ==== Examples
89+
#
90+
# class User < ActiveRecord::Base
91+
# normalizes :email, with: -> email { email.strip.downcase }
92+
# end
93+
#
94+
# User.normalize(:email, " [email protected]\n")
95+
96+
def normalize(name, value)
97+
type_for_attribute(name).cast(value)
98+
end
99+
end
100+
101+
private
102+
def normalize_changed_in_place_attributes
103+
self.class.normalized_attributes.each do |name|
104+
normalize_attribute(name) if attribute_changed_in_place?(name)
105+
end
106+
end
107+
108+
class NormalizedValueType < DelegateClass(ActiveModel::Type::Value) # :nodoc:
109+
include ActiveModel::Type::SerializeCastValue
110+
111+
attr_reader :cast_type, :normalizer, :normalize_nil
112+
alias :normalize_nil? :normalize_nil
113+
114+
def initialize(cast_type:, normalizer:, normalize_nil:)
115+
@cast_type = cast_type
116+
@normalizer = normalizer
117+
@normalize_nil = normalize_nil
118+
super(cast_type)
119+
end
120+
121+
def cast(value)
122+
normalize(super(value))
123+
end
124+
125+
def serialize(value)
126+
serialize_cast_value(cast(value))
127+
end
128+
129+
def serialize_cast_value(value)
130+
ActiveModel::Type::SerializeCastValue.serialize(cast_type, value)
131+
end
132+
133+
def ==(other)
134+
self.class == other.class &&
135+
normalize_nil? == other.normalize_nil? &&
136+
normalizer == other.normalizer &&
137+
cast_type == other.cast_type
138+
end
139+
alias eql? ==
140+
141+
def hash
142+
[self.class, cast_type, normalizer, normalize_nil?].hash
143+
end
144+
145+
def inspect
146+
Kernel.instance_method(:inspect).bind_call(self)
147+
end
148+
149+
private
150+
def normalize(value)
151+
normalizer.call(value) unless value.nil? && !normalize_nil?
152+
end
153+
end
154+
end
155+
end
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/aircraft"
5+
require "active_support/core_ext/string/inflections"
6+
7+
class NormalizedAttributeTest < ActiveRecord::TestCase
8+
class NormalizedAircraft < Aircraft
9+
normalizes :name, with: -> name { name.titlecase }
10+
normalizes :manufactured_at, with: -> time { time.noon }
11+
12+
attr_accessor :validated_name
13+
validate { self.validated_name = name.dup }
14+
end
15+
16+
setup do
17+
@time = Time.utc(1999, 12, 31, 12, 34, 56)
18+
@aircraft = NormalizedAircraft.create!(name: "fly HIGH", manufactured_at: @time)
19+
end
20+
21+
test "normalizes value from create" do
22+
assert_equal "Fly High", @aircraft.name
23+
end
24+
25+
test "normalizes value from update" do
26+
@aircraft.update!(name: "fly HIGHER")
27+
assert_equal "Fly Higher", @aircraft.name
28+
end
29+
30+
test "normalizes value from assignment" do
31+
@aircraft.name = "fly HIGHER"
32+
assert_equal "Fly Higher", @aircraft.name
33+
end
34+
35+
test "normalizes changed-in-place value before validation" do
36+
@aircraft.name.downcase!
37+
assert_equal "fly high", @aircraft.name
38+
39+
@aircraft.valid?
40+
assert_equal "Fly High", @aircraft.validated_name
41+
end
42+
43+
test "normalizes value on demand" do
44+
@aircraft.name.downcase!
45+
assert_equal "fly high", @aircraft.name
46+
47+
@aircraft.normalize_attribute(:name)
48+
assert_equal "Fly High", @aircraft.name
49+
end
50+
51+
test "normalizes value without record" do
52+
assert_equal "Titlecase Me", NormalizedAircraft.normalize(:name, "titlecase ME")
53+
end
54+
55+
test "casts value before applying normalization" do
56+
@aircraft.manufactured_at = @time.to_s
57+
assert_equal @time.noon, @aircraft.manufactured_at
58+
end
59+
60+
test "ignores nil by default" do
61+
assert_nil NormalizedAircraft.normalize(:name, nil)
62+
end
63+
64+
test "normalizes nil if apply_to_nil" do
65+
including_nil = Class.new(Aircraft) do
66+
normalizes :name, with: -> name { name&.titlecase || "Untitled" }, apply_to_nil: true
67+
end
68+
69+
assert_equal "Untitled", including_nil.normalize(:name, nil)
70+
end
71+
72+
test "does not automatically normalize value from database" do
73+
from_database = NormalizedAircraft.find(Aircraft.create(name: "NOT titlecase").id)
74+
assert_equal "NOT titlecase", from_database.name
75+
end
76+
77+
test "finds record by normalized value" do
78+
assert_equal @time.noon, @aircraft.manufactured_at
79+
assert_equal @aircraft, NormalizedAircraft.find_by(manufactured_at: @time.to_s)
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 "eM esreveR nehT esaceltiT", titlecase_then_reverse.normalize(:name, "titlecase THEN reverse ME")
88+
assert_equal "Only Titlecase Me", NormalizedAircraft.normalize(:name, "ONLY titlecase ME")
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.create!(name: "0")
97+
assert_equal "1", aircraft.name
98+
99+
aircraft.name = "0"
100+
assert_equal "1", aircraft.name
101+
aircraft.save
102+
assert_equal "1", aircraft.name
103+
104+
aircraft.name.replace("0")
105+
assert_equal "0", aircraft.name
106+
aircraft.save
107+
assert_equal "1", aircraft.name
108+
end
109+
end

0 commit comments

Comments
 (0)