Skip to content

Commit d4c31bd

Browse files
Add ActiveRecord::Base::normalizes
`ActiveRecord::Base::normalizes` 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. For example: ```ruby class User < ActiveRecord::Base normalizes :email, with: -> email { email.strip.downcase } end user = User.create(email: " [email protected]\n") user.email # => "[email protected]" user = User.find_by(email: "\[email protected] ") user.email # => "[email protected]" user.email_before_type_cast # => "[email protected]" User.exists?(email: "\[email protected] ") # => true User.exists?(["email = ?", "\[email protected] "]) # => false ```
1 parent 47eaf88 commit d4c31bd

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)