|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
3 | 3 | 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 |
4 | 15 | module Dirty |
5 | 16 | 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 |
7 | 23 |
|
8 | 24 | 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 | + |
9 | 39 | 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 |
11 | 43 | end |
12 | 44 | end |
13 | 45 |
|
14 | 46 | def save(*) |
15 | | - clear_changes { super } |
| 47 | + if status = super |
| 48 | + changes_applied |
| 49 | + end |
| 50 | + status |
16 | 51 | end |
17 | 52 |
|
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 |
22 | 57 | end |
23 | 58 |
|
24 | | - def reload |
25 | | - super.tap { clear_changes } |
| 59 | + def update(*) |
| 60 | + super.tap do |
| 61 | + clear_changes_information |
| 62 | + end |
26 | 63 | end |
27 | 64 |
|
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 |
35 | 74 | end |
36 | 75 | end |
37 | 76 |
|
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? |
41 | 84 | end |
42 | 85 |
|
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 |
44 | 94 |
|
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)] }] |
47 | 103 | end |
48 | 104 |
|
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 |
53 | 114 |
|
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 |
57 | 124 |
|
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 |
59 | 131 | end |
60 | 132 |
|
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) |
65 | 136 | 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 |
66 | 201 | end |
67 | 202 | end |
0 commit comments