dynamoid 3.2.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -1
  3. data/README.md +580 -241
  4. data/lib/dynamoid.rb +2 -0
  5. data/lib/dynamoid/adapter.rb +15 -15
  6. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +82 -102
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/batch_get_item.rb +108 -0
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/create_table.rb +29 -16
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +3 -2
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/backoff.rb +2 -2
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb +2 -3
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/start_key.rb +2 -2
  13. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +15 -6
  14. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +15 -5
  15. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/table.rb +1 -0
  16. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/until_past_table_status.rb +5 -3
  17. data/lib/dynamoid/application_time_zone.rb +1 -0
  18. data/lib/dynamoid/associations.rb +182 -19
  19. data/lib/dynamoid/associations/association.rb +4 -2
  20. data/lib/dynamoid/associations/belongs_to.rb +2 -1
  21. data/lib/dynamoid/associations/has_and_belongs_to_many.rb +2 -1
  22. data/lib/dynamoid/associations/has_many.rb +2 -1
  23. data/lib/dynamoid/associations/has_one.rb +2 -1
  24. data/lib/dynamoid/associations/many_association.rb +65 -22
  25. data/lib/dynamoid/associations/single_association.rb +28 -1
  26. data/lib/dynamoid/components.rb +8 -3
  27. data/lib/dynamoid/config.rb +16 -3
  28. data/lib/dynamoid/config/backoff_strategies/constant_backoff.rb +1 -0
  29. data/lib/dynamoid/config/backoff_strategies/exponential_backoff.rb +1 -0
  30. data/lib/dynamoid/config/options.rb +1 -0
  31. data/lib/dynamoid/criteria.rb +2 -1
  32. data/lib/dynamoid/criteria/chain.rb +418 -46
  33. data/lib/dynamoid/criteria/ignored_conditions_detector.rb +3 -3
  34. data/lib/dynamoid/criteria/key_fields_detector.rb +109 -32
  35. data/lib/dynamoid/criteria/nonexistent_fields_detector.rb +3 -2
  36. data/lib/dynamoid/criteria/overwritten_conditions_detector.rb +1 -1
  37. data/lib/dynamoid/dirty.rb +239 -32
  38. data/lib/dynamoid/document.rb +130 -251
  39. data/lib/dynamoid/dumping.rb +9 -0
  40. data/lib/dynamoid/dynamodb_time_zone.rb +1 -0
  41. data/lib/dynamoid/fields.rb +246 -20
  42. data/lib/dynamoid/finders.rb +69 -32
  43. data/lib/dynamoid/identity_map.rb +6 -0
  44. data/lib/dynamoid/indexes.rb +76 -17
  45. data/lib/dynamoid/loadable.rb +31 -0
  46. data/lib/dynamoid/log/formatter.rb +26 -0
  47. data/lib/dynamoid/middleware/identity_map.rb +1 -0
  48. data/lib/dynamoid/persistence.rb +592 -122
  49. data/lib/dynamoid/persistence/import.rb +73 -0
  50. data/lib/dynamoid/persistence/save.rb +64 -0
  51. data/lib/dynamoid/persistence/update_fields.rb +63 -0
  52. data/lib/dynamoid/persistence/upsert.rb +60 -0
  53. data/lib/dynamoid/primary_key_type_mapping.rb +1 -0
  54. data/lib/dynamoid/railtie.rb +1 -0
  55. data/lib/dynamoid/tasks.rb +3 -1
  56. data/lib/dynamoid/tasks/database.rb +1 -0
  57. data/lib/dynamoid/type_casting.rb +12 -2
  58. data/lib/dynamoid/undumping.rb +8 -0
  59. data/lib/dynamoid/validations.rb +2 -0
  60. data/lib/dynamoid/version.rb +1 -1
  61. metadata +49 -71
  62. data/.coveralls.yml +0 -1
  63. data/.document +0 -5
  64. data/.gitignore +0 -74
  65. data/.rspec +0 -2
  66. data/.rubocop.yml +0 -71
  67. data/.rubocop_todo.yml +0 -55
  68. data/.travis.yml +0 -41
  69. data/Appraisals +0 -28
  70. data/Gemfile +0 -8
  71. data/Rakefile +0 -46
  72. data/Vagrantfile +0 -29
  73. data/docker-compose.yml +0 -7
  74. data/dynamoid.gemspec +0 -57
  75. data/gemfiles/rails_4_2.gemfile +0 -11
  76. data/gemfiles/rails_5_0.gemfile +0 -10
  77. data/gemfiles/rails_5_1.gemfile +0 -10
  78. data/gemfiles/rails_5_2.gemfile +0 -10
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Dynamoid
4
4
  module Criteria
5
+ # @private
5
6
  class IgnoredConditionsDetector
6
7
  def initialize(conditions)
7
8
  @conditions = conditions
@@ -24,8 +25,8 @@ module Dynamoid
24
25
  def ignored_keys
25
26
  @conditions.keys
26
27
  .group_by(&method(:key_to_field))
27
- .select { |field, ary| ary.size > 1 }
28
- .flat_map { |field, ary| ary[0 .. -2] }
28
+ .select { |_, ary| ary.size > 1 }
29
+ .flat_map { |_, ary| ary[0..-2] }
29
30
  end
30
31
 
31
32
  def key_to_field(key)
@@ -38,4 +39,3 @@ module Dynamoid
38
39
  end
39
40
  end
40
41
  end
41
-
@@ -1,59 +1,136 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  module Criteria
5
+ # @private
5
6
  class KeyFieldsDetector
6
- attr_reader :hash_key, :range_key, :index_name
7
+ class Query
8
+ def initialize(query_hash)
9
+ @query_hash = query_hash
10
+ @fields_with_operator = query_hash.keys.map(&:to_s)
11
+ @fields = query_hash.keys.map(&:to_s).map { |s| s.split('.').first }
12
+ end
13
+
14
+ def contain_only?(field_names)
15
+ (@fields - field_names.map(&:to_s)).blank?
16
+ end
17
+
18
+ def contain?(field_name)
19
+ @fields.include?(field_name.to_s)
20
+ end
21
+
22
+ def contain_with_eq_operator?(field_name)
23
+ @fields_with_operator.include?(field_name.to_s)
24
+ end
25
+ end
7
26
 
8
27
  def initialize(query, source)
9
28
  @query = query
10
29
  @source = source
30
+ @query = Query.new(query)
31
+ @result = find_keys_in_query
32
+ end
11
33
 
12
- detect_keys
34
+ def non_key_present?
35
+ !@query.contain_only?([hash_key, range_key].compact)
13
36
  end
14
37
 
15
38
  def key_present?
16
- @hash_key.present?
39
+ @result.present?
40
+ end
41
+
42
+ def hash_key
43
+ @result && @result[:hash_key]
44
+ end
45
+
46
+ def range_key
47
+ @result && @result[:range_key]
48
+ end
49
+
50
+ def index_name
51
+ @result && @result[:index_name]
17
52
  end
18
53
 
19
54
  private
20
55
 
21
- def detect_keys
22
- query_keys = @query.keys.collect { |k| k.to_s.split('.').first }
56
+ def find_keys_in_query
57
+ match_table_and_sort_key ||
58
+ match_local_secondary_index ||
59
+ match_global_secondary_index_and_sort_key ||
60
+ match_table ||
61
+ match_global_secondary_index
62
+ end
63
+
64
+ # Use table's default range key
65
+ def match_table_and_sort_key
66
+ return unless @query.contain_with_eq_operator?(@source.hash_key)
67
+ return unless @source.range_key
23
68
 
24
- # See if querying based on table hash key
25
- if @query.keys.map(&:to_s).include?(@source.hash_key.to_s)
26
- @hash_key = @source.hash_key
69
+ if @query.contain?(@source.range_key)
70
+ {
71
+ hash_key: @source.hash_key,
72
+ range_key: @source.range_key
73
+ }
74
+ end
75
+ end
27
76
 
28
- # Use table's default range key
29
- if query_keys.include?(@source.range_key.to_s)
30
- @range_key = @source.range_key
31
- return
32
- end
77
+ # See if can use any local secondary index range key
78
+ # Chooses the first LSI found that can be utilized for the query
79
+ def match_local_secondary_index
80
+ return unless @query.contain_with_eq_operator?(@source.hash_key)
33
81
 
34
- # See if can use any local secondary index range key
35
- # Chooses the first LSI found that can be utilized for the query
36
- @source.local_secondary_indexes.each do |_, lsi|
37
- next unless query_keys.include?(lsi.range_key.to_s)
82
+ lsi = @source.local_secondary_indexes.values.find do |i|
83
+ @query.contain?(i.range_key)
84
+ end
85
+
86
+ if lsi.present?
87
+ {
88
+ hash_key: @source.hash_key,
89
+ range_key: lsi.range_key,
90
+ index_name: lsi.name,
91
+ }
92
+ end
93
+ end
38
94
 
39
- @range_key = lsi.range_key
40
- @index_name = lsi.name
41
- end
95
+ # See if can use any global secondary index
96
+ # Chooses the first GSI found that can be utilized for the query
97
+ # GSI with range key involved into query conditions has higher priority
98
+ # But only do so if projects ALL attributes otherwise we won't
99
+ # get back full data
100
+ def match_global_secondary_index_and_sort_key
101
+ gsi = @source.global_secondary_indexes.values.find do |i|
102
+ @query.contain_with_eq_operator?(i.hash_key) && i.projected_attributes == :all &&
103
+ @query.contain?(i.range_key)
104
+ end
42
105
 
43
- return
106
+ if gsi.present?
107
+ {
108
+ hash_key: gsi.hash_key,
109
+ range_key: gsi.range_key,
110
+ index_name: gsi.name,
111
+ }
44
112
  end
113
+ end
114
+
115
+ def match_table
116
+ return unless @query.contain_with_eq_operator?(@source.hash_key)
45
117
 
46
- # See if can use any global secondary index
47
- # Chooses the first GSI found that can be utilized for the query
48
- # But only do so if projects ALL attributes otherwise we won't
49
- # get back full data
50
- @source.global_secondary_indexes.each do |_, gsi|
51
- next unless @query.keys.map(&:to_s).include?(gsi.hash_key.to_s) && gsi.projected_attributes == :all
52
- next if @range_key.present? && !query_keys.include?(gsi.range_key.to_s)
118
+ {
119
+ hash_key: @source.hash_key,
120
+ }
121
+ end
122
+
123
+ def match_global_secondary_index
124
+ gsi = @source.global_secondary_indexes.values.find do |i|
125
+ @query.contain_with_eq_operator?(i.hash_key) && i.projected_attributes == :all
126
+ end
53
127
 
54
- @hash_key = gsi.hash_key
55
- @range_key = gsi.range_key
56
- @index_name = gsi.name
128
+ if gsi.present?
129
+ {
130
+ hash_key: gsi.hash_key,
131
+ range_key: gsi.range_key,
132
+ index_name: gsi.name,
133
+ }
57
134
  end
58
135
  end
59
136
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Dynamoid
4
4
  module Criteria
5
+ # @private
5
6
  class NonexistentFieldsDetector
6
7
  def initialize(conditions, source)
7
8
  @conditions = conditions
@@ -19,8 +20,8 @@ module Dynamoid
19
20
  fields_list = @nonexistent_fields.map { |s| "`#{s}`" }.join(', ')
20
21
  count = @nonexistent_fields.size
21
22
 
22
- "where conditions contain nonexistent" \
23
- " field #{ 'name'.pluralize(count) } #{ fields_list }"
23
+ 'where conditions contain nonexistent' \
24
+ " field #{'name'.pluralize(count)} #{fields_list}"
24
25
  end
25
26
 
26
27
  private
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Dynamoid
4
4
  module Criteria
5
+ # @private
5
6
  class OverwrittenConditionsDetector
6
7
  def initialize(conditions, conditions_new)
7
8
  @conditions = conditions
@@ -37,4 +38,3 @@ module Dynamoid
37
38
  end
38
39
  end
39
40
  end
40
-
@@ -1,67 +1,274 @@
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
7
18
 
19
+ included do
20
+ attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
21
+ attribute_method_suffix '_previously_changed?', '_previous_change'
22
+ attribute_method_affix prefix: 'restore_', suffix: '!'
23
+ end
24
+
25
+ # @private
8
26
  module ClassMethods
27
+ def update_fields(*)
28
+ if model = super
29
+ model.send(:clear_changes_information)
30
+ end
31
+ model
32
+ end
33
+
34
+ def upsert(*)
35
+ if model = super
36
+ model.send(:clear_changes_information)
37
+ end
38
+ model
39
+ end
40
+
9
41
  def from_database(*)
10
- super.tap { |d| d.send(:clear_changes_information) }
42
+ super.tap do |m|
43
+ m.send(:clear_changes_information)
44
+ end
11
45
  end
12
46
  end
13
47
 
48
+ # @private
14
49
  def save(*)
15
- clear_changes { super }
50
+ if status = super
51
+ changes_applied
52
+ end
53
+ status
16
54
  end
17
55
 
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
56
+ # @private
57
+ def save!(*)
58
+ super.tap do
59
+ changes_applied
60
+ end
22
61
  end
23
62
 
24
- def reload
25
- super.tap { clear_changes }
63
+ # @private
64
+ def update(*)
65
+ super.tap do
66
+ clear_changes_information
67
+ end
26
68
  end
27
69
 
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
70
+ # @private
71
+ def update!(*)
72
+ super.tap do
73
+ clear_changes_information
74
+ end
75
+ end
76
+
77
+ # @private
78
+ def reload(*)
79
+ super.tap do
80
+ clear_changes_information
35
81
  end
36
82
  end
37
83
 
38
- def write_attribute(name, value)
39
- attribute_will_change!(name) unless read_attribute(name) == value
40
- super
84
+ # Returns +true+ if any attribute have unsaved changes, +false+ otherwise.
85
+ #
86
+ # person.changed? # => false
87
+ # person.name = 'Bob'
88
+ # person.changed? # => true
89
+ #
90
+ # @return [true|false]
91
+ def changed?
92
+ changed_attributes.present?
93
+ end
94
+
95
+ # Returns an array with names of the attributes with unsaved changes.
96
+ #
97
+ # person = Person.new
98
+ # person.changed # => []
99
+ # person.name = 'Bob'
100
+ # person.changed # => ["name"]
101
+ #
102
+ # @return [Array[String]]
103
+ def changed
104
+ changed_attributes.keys
105
+ end
106
+
107
+ # Returns a hash of changed attributes indicating their original
108
+ # and new values like <tt>attr => [original value, new value]</tt>.
109
+ #
110
+ # person.changes # => {}
111
+ # person.name = 'Bob'
112
+ # person.changes # => { "name" => ["Bill", "Bob"] }
113
+ #
114
+ # @return [ActiveSupport::HashWithIndifferentAccess]
115
+ def changes
116
+ ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
41
117
  end
42
118
 
43
- protected
119
+ # Returns a hash of attributes that were changed before the model was saved.
120
+ #
121
+ # person.name # => "Bob"
122
+ # person.name = 'Robert'
123
+ # person.save
124
+ # person.previous_changes # => {"name" => ["Bob", "Robert"]}
125
+ #
126
+ # @return [ActiveSupport::HashWithIndifferentAccess]
127
+ def previous_changes
128
+ @previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new
129
+ end
44
130
 
45
- def attribute_method?(attr)
46
- super || self.class.attributes.key?(attr.to_sym)
131
+ # Returns a hash of the attributes with unsaved changes indicating their original
132
+ # values like <tt>attr => original value</tt>.
133
+ #
134
+ # person.name # => "Bob"
135
+ # person.name = 'Robert'
136
+ # person.changed_attributes # => {"name" => "Bob"}
137
+ #
138
+ # @return [ActiveSupport::HashWithIndifferentAccess]
139
+ def changed_attributes
140
+ @changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new
47
141
  end
48
142
 
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
143
+ # Handle <tt>*_changed?</tt> for +method_missing+.
144
+ #
145
+ # person.attribute_changed?(:name) # => true
146
+ # person.attribute_changed?(:name, from: 'Alice')
147
+ # person.attribute_changed?(:name, to: 'Bob')
148
+ # person.attribute_changed?(:name, from: 'Alice', to: 'Bod')
149
+ #
150
+ # @private
151
+ # @param attr [Symbol] attribute name
152
+ # @param options [Hash] conditions on +from+ and +to+ value (optional)
153
+ # @option options [Symbol] :from previous attribute value
154
+ # @option options [Symbol] :to current attribute value
155
+ def attribute_changed?(attr, options = {})
156
+ result = changes_include?(attr)
157
+ result &&= options[:to] == __send__(attr) if options.key?(:to)
158
+ result &&= options[:from] == changed_attributes[attr] if options.key?(:from)
159
+ result
160
+ end
53
161
 
54
- def mutations_from_database
55
- @mutations_from_database ||= ActiveModel::NullMutationTracker.instance
162
+ # Handle <tt>*_was</tt> for +method_missing+.
163
+ #
164
+ # person = Person.create(name: 'Alice')
165
+ # person.name = 'Bob'
166
+ # person.attribute_was(:name) # => "Alice"
167
+ #
168
+ # @private
169
+ # @param attr [Symbol] attribute name
170
+ def attribute_was(attr)
171
+ attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr)
172
+ end
173
+
174
+ # Restore all previous data of the provided attributes.
175
+ #
176
+ # @param attributes [Array[Symbol]] a list of attribute names
177
+ def restore_attributes(attributes = changed)
178
+ attributes.each { |attr| restore_attribute! attr }
179
+ end
180
+
181
+ # Handles <tt>*_previously_changed?</tt> for +method_missing+.
182
+ #
183
+ # person = Person.create(name: 'Alice')
184
+ # person.name = 'Bob'
185
+ # person.save
186
+ # person.attribute_changed?(:name) # => true
187
+ #
188
+ # @private
189
+ # @param attr [Symbol] attribute name
190
+ # @return [true|false]
191
+ def attribute_previously_changed?(attr)
192
+ previous_changes_include?(attr)
193
+ end
194
+
195
+ # Handles <tt>*_previous_change</tt> for +method_missing+.
196
+ #
197
+ # person = Person.create(name: 'Alice')
198
+ # person.name = 'Bob'
199
+ # person.save
200
+ # person.attribute_previously_changed(:name) # => ["Alice", "Bob"]
201
+ #
202
+ # @private
203
+ # @param attr [Symbol]
204
+ # @return [Array]
205
+ def attribute_previous_change(attr)
206
+ previous_changes[attr] if attribute_previously_changed?(attr)
207
+ end
208
+
209
+ private
210
+
211
+ def changes_include?(attr_name)
212
+ attributes_changed_by_setter.include?(attr_name)
213
+ end
214
+ alias attribute_changed_by_setter? changes_include?
215
+
216
+ # Removes current changes and makes them accessible through +previous_changes+.
217
+ def changes_applied # :doc:
218
+ @previously_changed = changes
219
+ @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
220
+ end
221
+
222
+ # Clear all dirty data: current changes and previous changes.
223
+ def clear_changes_information # :doc:
224
+ @previously_changed = ActiveSupport::HashWithIndifferentAccess.new
225
+ @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
226
+ end
227
+
228
+ # Handle <tt>*_change</tt> for +method_missing+.
229
+ def attribute_change(attr)
230
+ [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr)
231
+ end
232
+
233
+ # Handle <tt>*_will_change!</tt> for +method_missing+.
234
+ def attribute_will_change!(attr)
235
+ return if attribute_changed?(attr)
236
+
237
+ begin
238
+ value = __send__(attr)
239
+ value = value.duplicable? ? value.clone : value
240
+ rescue TypeError, NoMethodError
56
241
  end
57
242
 
58
- def forget_attribute_assignments; end
243
+ set_attribute_was(attr, value)
59
244
  end
60
245
 
61
- if ActiveModel::VERSION::STRING < '4.2.0'
62
- def clear_changes_information
63
- changed_attributes.clear
246
+ # Handle <tt>restore_*!</tt> for +method_missing+.
247
+ def restore_attribute!(attr)
248
+ if attribute_changed?(attr)
249
+ __send__("#{attr}=", changed_attributes[attr])
250
+ clear_attribute_changes([attr])
64
251
  end
65
252
  end
253
+
254
+ # Returns +true+ if attr_name were changed before the model was saved,
255
+ # +false+ otherwise.
256
+ def previous_changes_include?(attr_name)
257
+ previous_changes.include?(attr_name)
258
+ end
259
+
260
+ # This is necessary because `changed_attributes` might be overridden in
261
+ # other implemntations (e.g. in `ActiveRecord`)
262
+ alias attributes_changed_by_setter changed_attributes
263
+
264
+ # Force an attribute to have a particular "before" value
265
+ def set_attribute_was(attr, old_value)
266
+ attributes_changed_by_setter[attr] = old_value
267
+ end
268
+
269
+ # Remove changes information for the provided attributes.
270
+ def clear_attribute_changes(attributes)
271
+ attributes_changed_by_setter.except!(*attributes)
272
+ end
66
273
  end
67
274
  end