Skip to content

Commit e2cfbab

Browse files
committed
Fix model saving and detect attributes in-place changes
1 parent ce6c382 commit e2cfbab

File tree

4 files changed

+427
-17
lines changed

4 files changed

+427
-17
lines changed

lib/dynamoid/dirty.rb

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ def upsert(*)
3636
end
3737
end
3838

39-
def from_database(*)
40-
super.tap(&:clear_changes_information)
39+
def from_database(attributes_from_database)
40+
super.tap do |model|
41+
model.clear_changes_information
42+
model.assign_attributes_from_database(DeepDupper.dup_attributes(model.attributes, model.class))
43+
end
4144
end
4245
end
4346

@@ -108,7 +111,7 @@ def changed
108111
#
109112
# @return [ActiveSupport::HashWithIndifferentAccess]
110113
def changes
111-
ActiveSupport::HashWithIndifferentAccess[changed.map { |name| [name, attribute_change(name)] }]
114+
ActiveSupport::HashWithIndifferentAccess[changed_attributes.map { |name, old_value| [name, [old_value, read_attribute(name)]] }]
112115
end
113116

114117
# Returns a hash of attributes that were changed before the model was saved.
@@ -132,26 +135,31 @@ def previous_changes
132135
#
133136
# @return [ActiveSupport::HashWithIndifferentAccess]
134137
def changed_attributes
135-
@changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new
138+
attributes_changed_by_setter.merge(attributes_changed_in_place)
136139
end
137140

138141
# Clear all dirty data: current changes and previous changes.
139142
def clear_changes_information
140143
@previously_changed = ActiveSupport::HashWithIndifferentAccess.new
141-
@changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
144+
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
145+
@attributes_from_database = HashWithIndifferentAccess.new(DeepDupper.dup_attributes(@attributes, self.class))
142146
end
143147

144148
# Clears dirty data and moves +changes+ to +previous_changes+.
145149
def changes_applied
146150
@previously_changed = changes
147-
@changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
151+
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
152+
@attributes_from_database = HashWithIndifferentAccess.new(DeepDupper.dup_attributes(@attributes, self.class))
148153
end
149154

150155
# Remove changes information for the provided attributes.
151156
#
152157
# @param attributes [Array[String]] - a list of attributes to clear changes for
153158
def clear_attribute_changes(names)
154159
attributes_changed_by_setter.except!(*names)
160+
161+
slice = HashWithIndifferentAccess.new(@attributes).slice(*names)
162+
attributes_from_database.merge!(DeepDupper.dup_attributes(slice, self.class))
155163
end
156164

157165
# Handle <tt>*_changed?</tt> for +method_missing+.
@@ -220,12 +228,16 @@ def attribute_previous_change(name)
220228
previous_changes[name] if attribute_previously_changed?(name)
221229
end
222230

231+
# @private
232+
def assign_attributes_from_database(attributes_from_database)
233+
@attributes_from_database = HashWithIndifferentAccess.new(attributes_from_database)
234+
end
235+
223236
private
224237

225238
def changes_include?(name)
226-
attributes_changed_by_setter.include?(name)
239+
attribute_changed_by_setter?(name) || attribute_changed_in_place?(name)
227240
end
228-
alias attribute_changed_by_setter? changes_include?
229241

230242
# Handle <tt>*_change</tt> for +method_missing+.
231243
def attribute_change(name)
@@ -259,13 +271,76 @@ def previous_changes_include?(name)
259271
previous_changes.include?(name)
260272
end
261273

262-
# This is necessary because `changed_attributes` might be overridden in
263-
# other implemntations (e.g. in `ActiveRecord`)
264-
alias attributes_changed_by_setter changed_attributes
274+
def attributes_changed_by_setter
275+
@attributes_changed_by_setter ||= ActiveSupport::HashWithIndifferentAccess.new
276+
end
277+
278+
def attribute_changed_by_setter?(name)
279+
attributes_changed_by_setter.include?(name)
280+
end
281+
282+
def attributes_from_database
283+
@attributes_from_database ||= ActiveSupport::HashWithIndifferentAccess.new
284+
end
265285

266286
# Force an attribute to have a particular "before" value
267287
def set_attribute_was(name, old_value)
268288
attributes_changed_by_setter[name] = old_value
269289
end
290+
291+
def attributes_changed_in_place
292+
attributes_from_database.select do |name, _|
293+
attribute_changed_in_place?(name)
294+
end
295+
end
296+
297+
def attribute_changed_in_place?(name)
298+
return false if attribute_changed_by_setter?(name)
299+
300+
value_from_database = attributes_from_database[name]
301+
return false if value_from_database.nil?
302+
303+
value = read_attribute(name)
304+
value != value_from_database
305+
end
306+
307+
module DeepDupper
308+
def self.dup_attributes(attributes, klass)
309+
attributes.map do |name, value|
310+
type_options = klass.attributes[name.to_sym]
311+
value_duplicate = dup_attribute(value, type_options)
312+
[name, value_duplicate]
313+
end.to_h
314+
end
315+
316+
def self.dup_attribute(value, type_options)
317+
type, of = type_options.values_at(:type, :of)
318+
319+
case value
320+
when NilClass, TrueClass, FalseClass, Numeric, Symbol, IO
321+
# till Ruby 2.4 these immutable objects could not be duplicated
322+
# IO objects cannot be duplicated - is used for binary fields
323+
value
324+
when String
325+
value.dup
326+
when Array
327+
if of.is_a? Class # custom type
328+
value.map { |e| dup_attribute(e, type: of) }
329+
else
330+
value.deep_dup
331+
end
332+
when Set
333+
Set.new(value.map { |e| dup_attribute(e, type: of) })
334+
when Hash
335+
value.deep_dup
336+
else
337+
if type.is_a? Class # custom type
338+
Marshal.load(Marshal.dump(value)) # dup instance variables
339+
else
340+
value.dup # date, datetime
341+
end
342+
end
343+
end
344+
end
270345
end
271346
end

lib/dynamoid/undumping.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ def process(value)
238238
class SerializedUndumper < Base
239239
# We must use YAML.safe_load in Ruby 3.1 to handle serialized Set class
240240
minimum_ruby_version = ->(version) { Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(version) }
241-
# Once we drop support for Rubies older than 2.6 we can remove this conditional (with major version bump)!
241+
242+
# Once we drop support for Rubies older than 2.6 we can remove this condition (with major version bump)!
242243
# YAML_SAFE_LOAD = minimum_ruby_version.call("2.6")
243244
# But we don't want to change behavior for Ruby <= 3.0 that has been using the gem, without a major version bump
244245
YAML_SAFE_LOAD = minimum_ruby_version.call('3.1')

0 commit comments

Comments
 (0)