dynamoid 3.2.0 → 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
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