Skip to content

Commit faeb555

Browse files
committed
Reimplement Rails 4.2 compatible Dirty interface
1 parent 04929eb commit faeb555

File tree

4 files changed

+527
-75
lines changed

4 files changed

+527
-75
lines changed

lib/dynamoid/components.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,24 @@ module Components
1717
after_initialize :set_inheritance_field
1818
end
1919

20-
include ActiveModel::AttributeMethods
20+
include ActiveModel::AttributeMethods # Actually it will be inclided in Dirty module again
2121
include ActiveModel::Conversion
2222
include ActiveModel::MassAssignmentSecurity if defined?(ActiveModel::MassAssignmentSecurity)
2323
include ActiveModel::Naming
2424
include ActiveModel::Observing if defined?(ActiveModel::Observing)
2525
include ActiveModel::Serializers::JSON
2626
include ActiveModel::Serializers::Xml if defined?(ActiveModel::Serializers::Xml)
27+
include Dynamoid::Persistence
2728
include Dynamoid::Loadable
29+
# Dirty module should be included after Persistence and Loadable
30+
# because it overrides some methods declared in these modules
31+
include Dynamoid::Dirty
2832
include Dynamoid::Fields
2933
include Dynamoid::Indexes
30-
include Dynamoid::Persistence
3134
include Dynamoid::Finders
3235
include Dynamoid::Associations
3336
include Dynamoid::Criteria
3437
include Dynamoid::Validations
3538
include Dynamoid::IdentityMap
36-
include Dynamoid::Dirty
3739
end
3840
end

lib/dynamoid/dirty.rb

Lines changed: 169 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,202 @@
11
# frozen_string_literal: true
22

33
module Dynamoid
4+
# Support interface of Rails' ActiveModel::Dirty module
5+
#
6+
# The reason why not just include ActiveModel::Dirty -
7+
# ActiveModel::Dirty conflicts either with @attributes or
8+
# #attributes in different Rails versions.
9+
#
10+
# Separate implementation (or copy-pasting) is the best way to
11+
# avoid endless monkey-patching
12+
#
13+
# Documentation:
14+
# https://api.rubyonrails.org/v4.2/classes/ActiveModel/Dirty.html
415
module Dirty
516
extend ActiveSupport::Concern
6-
include ActiveModel::Dirty
17+
include ActiveModel::AttributeMethods
18+
19+
included do
20+
attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
21+
attribute_method_affix prefix: 'restore_', suffix: '!'
22+
end
723

824
module ClassMethods
25+
def update_fields(*)
26+
if model = super
27+
model.send(:clear_changes_information)
28+
end
29+
model
30+
end
31+
32+
def upsert(*)
33+
if model = super
34+
model.send(:clear_changes_information)
35+
end
36+
model
37+
end
38+
939
def from_database(*)
10-
super.tap { |d| d.send(:clear_changes_information) }
40+
super.tap do |m|
41+
m.send(:clear_changes_information)
42+
end
1143
end
1244
end
1345

1446
def save(*)
15-
clear_changes { super }
47+
if status = super
48+
changes_applied
49+
end
50+
status
1651
end
1752

18-
def update!(*)
19-
ret = super
20-
clear_changes # update! completely reloads all fields on the class, so any extant changes are wiped out
21-
ret
53+
def save!(*)
54+
super.tap do
55+
changes_applied
56+
end
2257
end
2358

24-
def reload
25-
super.tap { clear_changes }
59+
def update(*)
60+
super.tap do
61+
clear_changes_information
62+
end
2663
end
2764

28-
def clear_changes
29-
previous = changes
30-
(block_given? ? yield : true).tap do |result|
31-
unless result == false # failed validation; nil is OK.
32-
@previously_changed = previous
33-
clear_changes_information
34-
end
65+
def update!(*)
66+
super.tap do
67+
clear_changes_information
68+
end
69+
end
70+
71+
def reload(*)
72+
super.tap do
73+
clear_changes_information
3574
end
3675
end
3776

38-
def write_attribute(name, value)
39-
attribute_will_change!(name) unless read_attribute(name) == value
40-
super
77+
# Returns +true+ if any attribute have unsaved changes, +false+ otherwise.
78+
#
79+
# person.changed? # => false
80+
# person.name = 'bob'
81+
# person.changed? # => true
82+
def changed?
83+
changed_attributes.present?
4184
end
4285

43-
protected
86+
# Returns an array with the name of the attributes with unsaved changes.
87+
#
88+
# person.changed # => []
89+
# person.name = 'bob'
90+
# person.changed # => ["name"]
91+
def changed
92+
changed_attributes.keys
93+
end
4494

45-
def attribute_method?(attr)
46-
super || self.class.attributes.key?(attr.to_sym)
95+
# Returns a hash of changed attributes indicating their original
96+
# and new values like <tt>attr => [original value, new value]</tt>.
97+
#
98+
# person.changes # => {}
99+
# person.name = 'bob'
100+
# person.changes # => { "name" => ["bill", "bob"] }
101+
def changes
102+
ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
47103
end
48104

49-
if ActiveModel::VERSION::STRING >= '5.2.0'
50-
# The ActiveModel::Dirty API was changed
51-
# https://github.com/rails/rails/commit/c3675f50d2e59b7fc173d7b332860c4b1a24a726#diff-aaddd42c7feb0834b1b5c66af69814d3
52-
# So we just try to disable new functionality
105+
# Returns a hash of attributes that were changed before the model was saved.
106+
#
107+
# person.name # => "bob"
108+
# person.name = 'robert'
109+
# person.save
110+
# person.previous_changes # => {"name" => ["bob", "robert"]}
111+
def previous_changes
112+
@previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new
113+
end
53114

54-
def mutations_from_database
55-
@mutations_from_database ||= ActiveModel::NullMutationTracker.instance
56-
end
115+
# Returns a hash of the attributes with unsaved changes indicating their original
116+
# values like <tt>attr => original value</tt>.
117+
#
118+
# person.name # => "bob"
119+
# person.name = 'robert'
120+
# person.changed_attributes # => {"name" => "bob"}
121+
def changed_attributes
122+
@changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new
123+
end
57124

58-
def forget_attribute_assignments; end
125+
# Handle <tt>*_changed?</tt> for +method_missing+.
126+
def attribute_changed?(attr, options = {}) #:nodoc:
127+
result = changes_include?(attr)
128+
result &&= options[:to] == __send__(attr) if options.key?(:to)
129+
result &&= options[:from] == changed_attributes[attr] if options.key?(:from)
130+
result
59131
end
60132

61-
if ActiveModel::VERSION::STRING < '4.2.0'
62-
def clear_changes_information
63-
changed_attributes.clear
64-
end
133+
# Handle <tt>*_was</tt> for +method_missing+.
134+
def attribute_was(attr) # :nodoc:
135+
attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr)
65136
end
137+
138+
# Restore all previous data of the provided attributes.
139+
def restore_attributes(attributes = changed)
140+
attributes.each { |attr| restore_attribute! attr }
141+
end
142+
143+
private
144+
145+
def changes_include?(attr_name)
146+
attributes_changed_by_setter.include?(attr_name)
147+
end
148+
alias attribute_changed_by_setter? changes_include?
149+
150+
# Removes current changes and makes them accessible through +previous_changes+.
151+
def changes_applied # :doc:
152+
@previously_changed = changes
153+
@changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
154+
end
155+
156+
# Clear all dirty data: current changes and previous changes.
157+
def clear_changes_information # :doc:
158+
@previously_changed = ActiveSupport::HashWithIndifferentAccess.new
159+
@changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
160+
end
161+
162+
# Handle <tt>*_change</tt> for +method_missing+.
163+
def attribute_change(attr)
164+
[changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
165+
end
166+
167+
# Handle <tt>*_will_change!</tt> for +method_missing+.
168+
def attribute_will_change!(attr)
169+
return if attribute_changed?(attr)
170+
171+
begin
172+
value = __send__(attr)
173+
value = value.duplicable? ? value.clone : value
174+
rescue TypeError, NoMethodError
175+
end
176+
177+
set_attribute_was(attr, value)
178+
end
179+
180+
# Handle <tt>restore_*!</tt> for +method_missing+.
181+
def restore_attribute!(attr)
182+
if attribute_changed?(attr)
183+
__send__("#{attr}=", changed_attributes[attr])
184+
clear_attribute_changes([attr])
185+
end
186+
end
187+
188+
# This is necessary because `changed_attributes` might be overridden in
189+
# other implemntations (e.g. in `ActiveRecord`)
190+
alias_method :attributes_changed_by_setter, :changed_attributes # :nodoc:
191+
192+
# Force an attribute to have a particular "before" value
193+
def set_attribute_was(attr, old_value)
194+
attributes_changed_by_setter[attr] = old_value
195+
end
196+
197+
# Remove changes information for the provided attributes.
198+
def clear_attribute_changes(attributes) # :doc:
199+
attributes_changed_by_setter.except!(*attributes)
200+
end
66201
end
67202
end

lib/dynamoid/fields.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def field(name, type = :string, options = {})
5252
end
5353
self.attributes = attributes.merge(name => { type: type }.merge(options))
5454

55+
define_attribute_method(name) # Dirty API
56+
5557
generated_methods.module_eval do
5658
define_method(named) { read_attribute(named) }
5759
define_method("#{named}?") do
@@ -85,6 +87,10 @@ def remove_field(field)
8587
field = field.to_sym
8688
attributes.delete(field) || raise('No such field')
8789

90+
# Dirty API
91+
undefine_attribute_methods
92+
define_attribute_methods attributes.keys
93+
8894
generated_methods.module_eval do
8995
remove_method field
9096
remove_method :"#{field}="
@@ -121,6 +127,8 @@ def write_attribute(name, value)
121127
association.reset
122128
end
123129

130+
attribute_will_change!(name) # Dirty API
131+
124132
@attributes_before_type_cast[name] = value
125133

126134
value_casted = TypeCasting.cast_field(value, self.class.attributes[name])

0 commit comments

Comments
 (0)