dynamoid 2.2.0 → 3.0.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +53 -0
  3. data/.rubocop_todo.yml +55 -0
  4. data/.travis.yml +5 -27
  5. data/Appraisals +17 -15
  6. data/CHANGELOG.md +26 -3
  7. data/Gemfile +4 -2
  8. data/README.md +95 -77
  9. data/Rakefile +17 -17
  10. data/Vagrantfile +5 -3
  11. data/dynamoid.gemspec +39 -45
  12. data/gemfiles/rails_4_2.gemfile +7 -5
  13. data/gemfiles/rails_5_0.gemfile +6 -4
  14. data/gemfiles/rails_5_1.gemfile +6 -4
  15. data/gemfiles/rails_5_2.gemfile +6 -4
  16. data/lib/dynamoid.rb +11 -4
  17. data/lib/dynamoid/adapter.rb +21 -27
  18. data/lib/dynamoid/adapter_plugin/{aws_sdk_v2.rb → aws_sdk_v3.rb} +118 -113
  19. data/lib/dynamoid/application_time_zone.rb +27 -0
  20. data/lib/dynamoid/associations.rb +3 -6
  21. data/lib/dynamoid/associations/association.rb +3 -6
  22. data/lib/dynamoid/associations/belongs_to.rb +4 -5
  23. data/lib/dynamoid/associations/has_and_belongs_to_many.rb +2 -3
  24. data/lib/dynamoid/associations/has_many.rb +2 -3
  25. data/lib/dynamoid/associations/has_one.rb +2 -3
  26. data/lib/dynamoid/associations/many_association.rb +8 -9
  27. data/lib/dynamoid/associations/single_association.rb +3 -3
  28. data/lib/dynamoid/components.rb +2 -2
  29. data/lib/dynamoid/config.rb +9 -5
  30. data/lib/dynamoid/config/backoff_strategies/constant_backoff.rb +4 -2
  31. data/lib/dynamoid/config/backoff_strategies/exponential_backoff.rb +3 -1
  32. data/lib/dynamoid/config/options.rb +4 -4
  33. data/lib/dynamoid/criteria.rb +3 -5
  34. data/lib/dynamoid/criteria/chain.rb +42 -49
  35. data/lib/dynamoid/dirty.rb +5 -4
  36. data/lib/dynamoid/document.rb +142 -36
  37. data/lib/dynamoid/dumping.rb +167 -0
  38. data/lib/dynamoid/dynamodb_time_zone.rb +16 -0
  39. data/lib/dynamoid/errors.rb +7 -6
  40. data/lib/dynamoid/fields.rb +24 -23
  41. data/lib/dynamoid/finders.rb +101 -59
  42. data/lib/dynamoid/identity_map.rb +5 -11
  43. data/lib/dynamoid/indexes.rb +45 -46
  44. data/lib/dynamoid/middleware/identity_map.rb +2 -0
  45. data/lib/dynamoid/persistence.rb +67 -307
  46. data/lib/dynamoid/primary_key_type_mapping.rb +34 -0
  47. data/lib/dynamoid/railtie.rb +3 -1
  48. data/lib/dynamoid/tasks/database.rake +11 -11
  49. data/lib/dynamoid/tasks/database.rb +4 -3
  50. data/lib/dynamoid/type_casting.rb +193 -0
  51. data/lib/dynamoid/undumping.rb +188 -0
  52. data/lib/dynamoid/validations.rb +4 -7
  53. data/lib/dynamoid/version.rb +3 -1
  54. metadata +59 -53
  55. data/gemfiles/rails_4_0.gemfile +0 -9
  56. data/gemfiles/rails_4_1.gemfile +0 -9
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Dynamoid
2
4
  module Dirty
3
5
  extend ActiveSupport::Concern
@@ -34,14 +36,14 @@ module Dynamoid
34
36
  end
35
37
 
36
38
  def write_attribute(name, value)
37
- attribute_will_change!(name) unless self.read_attribute(name) == value
39
+ attribute_will_change!(name) unless read_attribute(name) == value
38
40
  super
39
41
  end
40
42
 
41
43
  protected
42
44
 
43
45
  def attribute_method?(attr)
44
- super || self.class.attributes.has_key?(attr.to_sym)
46
+ super || self.class.attributes.key?(attr.to_sym)
45
47
  end
46
48
 
47
49
  if ActiveModel::VERSION::STRING >= '5.2.0'
@@ -53,8 +55,7 @@ module Dynamoid
53
55
  @mutations_from_database ||= ActiveModel::NullMutationTracker.instance
54
56
  end
55
57
 
56
- def forget_attribute_assignments
57
- end
58
+ def forget_attribute_assignments; end
58
59
  end
59
60
 
60
61
  if ActiveModel::VERSION::STRING < '4.2.0'
@@ -1,6 +1,6 @@
1
- # encoding: utf-8
2
- module Dynamoid #:nodoc:
1
+ # frozen_string_literal: true
3
2
 
3
+ module Dynamoid #:nodoc:
4
4
  # This is the base module for all domain objects that need to be persisted to
5
5
  # the database as documents.
6
6
  module Document
@@ -114,35 +114,66 @@ module Dynamoid #:nodoc:
114
114
  # @since 0.2.0
115
115
  def exists?(id_or_conditions = {})
116
116
  case id_or_conditions
117
- when Hash then where(id_or_conditions).first.present?
118
- else !! find_by_id(id_or_conditions)
119
- end
120
- end
121
-
122
- def update(hash_key, range_key_value=nil, attrs)
123
- if range_key.present?
124
- range_key_value = dump_field(range_key_value, attributes[self.range_key])
117
+ when Hash then where(id_or_conditions).first.present?
125
118
  else
126
- range_key_value = nil
119
+ begin
120
+ find(id_or_conditions)
121
+ true
122
+ rescue Dynamoid::Errors::RecordNotFound
123
+ false
124
+ end
127
125
  end
126
+ end
128
127
 
128
+ # Update document with provided values.
129
+ # Instantiates document and saves changes. Runs validations and callbacks.
130
+ #
131
+ # @param [Scalar value] partition key
132
+ # @param [Scalar value] sort key, optional
133
+ # @param [Hash] attributes
134
+ #
135
+ # @return [Dynamoid::Doument] updated document
136
+ #
137
+ # @example Update document
138
+ # Post.update(101, read: true)
139
+ def update(hash_key, range_key_value = nil, attrs)
129
140
  model = find(hash_key, range_key: range_key_value, consistent_read: true)
130
141
  model.update_attributes(attrs)
131
142
  model
132
143
  end
133
144
 
134
- def update_fields(hash_key_value, range_key_value=nil, attrs={}, conditions={})
145
+ # Update document.
146
+ # Uses efficient low-level `UpdateItem` API call.
147
+ # Changes attibutes and loads new document version with one API call.
148
+ # Doesn't run validations and callbacks. Can make conditional update.
149
+ # If a document doesn't exist or specified conditions failed - returns `nil`
150
+ #
151
+ # @param [Scalar value] partition key
152
+ # @param [Scalar value] sort key (optional)
153
+ # @param [Hash] attributes
154
+ # @param [Hash] conditions
155
+ #
156
+ # @return [Dynamoid::Document/nil] updated document
157
+ #
158
+ # @example Update document
159
+ # Post.update_fields(101, read: true)
160
+ #
161
+ # @example Update document with condition
162
+ # Post.update_fields(101, { read: true }, if: { version: 1 })
163
+ def update_fields(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
135
164
  optional_params = [range_key_value, attrs, conditions].compact
136
165
  if optional_params.first.is_a?(Hash)
137
166
  range_key_value = nil
138
- attrs, conditions = optional_params[0 .. 1]
167
+ attrs, conditions = optional_params[0..1]
139
168
  else
140
169
  range_key_value = optional_params.first
141
- attrs, conditions = optional_params[1 .. 2]
170
+ attrs, conditions = optional_params[1..2]
142
171
  end
143
172
 
144
173
  options = if range_key
145
- { range_key: dump_field(range_key_value, attributes[range_key]) }
174
+ value_casted = TypeCasting.cast_field(range_key_value, attributes[range_key])
175
+ value_dumped = Dumping.dump_field(value_casted, attributes[range_key])
176
+ { range_key: value_dumped }
146
177
  else
147
178
  {}
148
179
  end
@@ -150,42 +181,81 @@ module Dynamoid #:nodoc:
150
181
  (conditions[:if_exists] ||= {})[hash_key] = hash_key_value
151
182
  options[:conditions] = conditions
152
183
 
184
+ attrs = attrs.symbolize_keys
185
+ if Dynamoid::Config.timestamps
186
+ attrs[:updated_at] ||= DateTime.now.in_time_zone(Time.zone)
187
+ end
188
+
153
189
  begin
154
190
  new_attrs = Dynamoid.adapter.update_item(table_name, hash_key_value, options) do |t|
155
- attrs.symbolize_keys.each do |k, v|
156
- t.set k => dump_field(v, attributes[k])
191
+ attrs.each do |k, v|
192
+ value_casted = TypeCasting.cast_field(v, attributes[k])
193
+ value_dumped = Dumping.dump_field(value_casted, attributes[k])
194
+ t.set(k => value_dumped)
157
195
  end
158
196
  end
159
- new(new_attrs)
197
+ attrs_undumped = Undumping.undump_attributes(new_attrs, attributes)
198
+ new(attrs_undumped)
160
199
  rescue Dynamoid::Errors::ConditionalCheckFailedException
161
200
  end
162
201
  end
163
202
 
164
- def upsert(hash_key_value, range_key_value=nil, attrs={}, conditions={})
203
+
204
+ # Update existing document or create new one.
205
+ # Similar to `.update_fields`. The only diffirence is creating new document.
206
+ #
207
+ # Uses efficient low-level `UpdateItem` API call.
208
+ # Changes attibutes and loads new document version with one API call.
209
+ # Doesn't run validations and callbacks. Can make conditional update.
210
+ # If specified conditions failed - returns `nil`
211
+ #
212
+ # @param [Scalar value] partition key
213
+ # @param [Scalar value] sort key (optional)
214
+ # @param [Hash] attributes
215
+ # @param [Hash] conditions
216
+ #
217
+ # @return [Dynamoid::Document/nil] updated document
218
+ #
219
+ # @example Update document
220
+ # Post.update(101, read: true)
221
+ #
222
+ # @example Update document
223
+ # Post.upsert(101, read: true)
224
+ def upsert(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
165
225
  optional_params = [range_key_value, attrs, conditions].compact
166
226
  if optional_params.first.is_a?(Hash)
167
227
  range_key_value = nil
168
- attrs, conditions = optional_params[0 .. 1]
228
+ attrs, conditions = optional_params[0..1]
169
229
  else
170
230
  range_key_value = optional_params.first
171
- attrs, conditions = optional_params[1 .. 2]
231
+ attrs, conditions = optional_params[1..2]
172
232
  end
173
233
 
174
234
  options = if range_key
175
- { range_key: dump_field(range_key_value, attributes[range_key]) }
235
+ value_casted = TypeCasting.cast_field(range_key_value, attributes[range_key])
236
+ value_dumped = Dumping.dump_field(value_casted, attributes[range_key])
237
+ { range_key: value_dumped }
176
238
  else
177
239
  {}
178
240
  end
179
241
 
180
242
  options[:conditions] = conditions
181
243
 
244
+ attrs = attrs.symbolize_keys
245
+ if Dynamoid::Config.timestamps
246
+ attrs[:updated_at] ||= DateTime.now.in_time_zone(Time.zone)
247
+ end
248
+
182
249
  begin
183
250
  new_attrs = Dynamoid.adapter.update_item(table_name, hash_key_value, options) do |t|
184
- attrs.symbolize_keys.each do |k, v|
185
- t.set k => dump_field(v, attributes[k])
251
+ attrs.each do |k, v|
252
+ value_casted = TypeCasting.cast_field(v, attributes[k])
253
+ value_dumped = Dumping.dump_field(value_casted, attributes[k])
254
+ t.set(k => value_dumped)
186
255
  end
187
256
  end
188
- new(new_attrs)
257
+ attrs_undumped = Undumping.undump_attributes(new_attrs, attributes)
258
+ new(attrs_undumped)
189
259
  rescue Dynamoid::Errors::ConditionalCheckFailedException
190
260
  end
191
261
  end
@@ -212,13 +282,30 @@ module Dynamoid #:nodoc:
212
282
  @attributes ||= {}
213
283
  @associations ||= {}
214
284
 
215
- load(attrs)
285
+ self.class.attributes.each do |_, options|
286
+ if options[:type].is_a?(Class) && options[:default]
287
+ raise 'Dynamoid class-type fields do not support default values'
288
+ end
289
+ end
290
+
291
+ attrs_with_defaults = {}
292
+ self.class.attributes.each do |attribute, options|
293
+ attrs_with_defaults[attribute] = if attrs.key?(attribute)
294
+ attrs[attribute]
295
+ elsif options.key?(:default)
296
+ evaluate_default_value(options[:default])
297
+ end
298
+ end
299
+
300
+ attrs_virtual = attrs.slice(*(attrs.keys - self.class.attributes.keys))
301
+
302
+ load(attrs_with_defaults.merge(attrs_virtual))
216
303
  end
217
304
  end
218
305
 
219
306
  def load(attrs)
220
- self.class.undump(attrs).each do |key, value|
221
- send("#{key}=", value) if self.respond_to?("#{key}=")
307
+ attrs.each do |key, value|
308
+ send("#{key}=", value) if respond_to?("#{key}=")
222
309
  end
223
310
  end
224
311
 
@@ -230,7 +317,7 @@ module Dynamoid #:nodoc:
230
317
  super
231
318
  else
232
319
  return false if other.nil?
233
- other.is_a?(Dynamoid::Document) && self.hash_key == other.hash_key && self.range_value == other.range_value
320
+ other.is_a?(Dynamoid::Document) && hash_key == other.hash_key && range_value == other.range_value
234
321
  end
235
322
  end
236
323
 
@@ -249,8 +336,13 @@ module Dynamoid #:nodoc:
249
336
  #
250
337
  # @since 0.2.0
251
338
  def reload
252
- range_key_value = range_value ? dumped_range_value : nil
253
- self.attributes = self.class.find(hash_key, range_key: range_key_value, consistent_read: true).attributes
339
+ options = { consistent_read: true }
340
+
341
+ if self.class.range_key
342
+ options[:range_key] = range_value
343
+ end
344
+
345
+ self.attributes = self.class.find(hash_key, options).attributes
254
346
  @associations.values.each(&:reset)
255
347
  self
256
348
  end
@@ -259,30 +351,44 @@ module Dynamoid #:nodoc:
259
351
  #
260
352
  # @since 0.4.0
261
353
  def hash_key
262
- self.send(self.class.hash_key)
354
+ send(self.class.hash_key)
263
355
  end
264
356
 
265
357
  # Assign an object's hash key, regardless of what it might be called to the object.
266
358
  #
267
359
  # @since 0.4.0
268
360
  def hash_key=(value)
269
- self.send("#{self.class.hash_key}=", value)
361
+ send("#{self.class.hash_key}=", value)
270
362
  end
271
363
 
272
364
  def range_value
273
365
  if range_key = self.class.range_key
274
- self.send(range_key)
366
+ send(range_key)
275
367
  end
276
368
  end
277
369
 
278
370
  def range_value=(value)
279
- self.send("#{self.class.range_key}=", value)
371
+ send("#{self.class.range_key}=", value)
280
372
  end
281
373
 
282
374
  private
283
375
 
284
376
  def dumped_range_value
285
- dump_field(range_value, self.class.attributes[self.class.range_key])
377
+ Dumping.dump_field(range_value, self.class.attributes[self.class.range_key])
378
+ end
379
+
380
+ # Evaluates the default value given, this is used by undump
381
+ # when determining the value of the default given for a field options.
382
+ #
383
+ # @param [Object] :value the attribute's default value
384
+ def evaluate_default_value(val)
385
+ if val.respond_to?(:call)
386
+ val.call
387
+ elsif val.duplicable?
388
+ val.dup
389
+ else
390
+ val
391
+ end
286
392
  end
287
393
  end
288
394
  end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dynamoid
4
+ module Dumping
5
+ def self.dump_attributes(attributes, attributes_options)
6
+ {}.tap do |h|
7
+ attributes.each do |attribute, value|
8
+ h[attribute] = dump_field(value, attributes_options[attribute])
9
+ end
10
+ end
11
+ end
12
+
13
+ def self.dump_field(value, options)
14
+ dumper = field_dumper(options)
15
+
16
+ if dumper.nil?
17
+ raise ArgumentError, "Unknown type #{options[:type]}"
18
+ end
19
+
20
+ dumper.process(value)
21
+ end
22
+
23
+ def self.field_dumper(options)
24
+ dumper_class = case options[:type]
25
+ when :string then StringDumper
26
+ when :integer then IntegerDumper
27
+ when :number then NumberDumper
28
+ when :set then SetDumper
29
+ when :array then ArrayDumper
30
+ when :datetime then DateTimeDumper
31
+ when :date then DateDumper
32
+ when :serialized then SerializedDumper
33
+ when :raw then RawDumper
34
+ when :boolean then BooleanDumper
35
+ when Class then CustomTypeDumper
36
+ end
37
+
38
+ if dumper_class.present?
39
+ dumper_class.new(options)
40
+ end
41
+ end
42
+
43
+ class Base
44
+ def initialize(options)
45
+ @options = options
46
+ end
47
+
48
+ def process(value)
49
+ value
50
+ end
51
+ end
52
+
53
+ # string -> string
54
+ class StringDumper < Base
55
+ end
56
+
57
+ # integer -> number
58
+ class IntegerDumper < Base
59
+ end
60
+
61
+ # number -> number
62
+ class NumberDumper < Base
63
+ end
64
+
65
+ # set -> set
66
+ class SetDumper < Base
67
+ end
68
+
69
+ # array -> array
70
+ class ArrayDumper < Base
71
+ end
72
+
73
+ # datetime -> integer/string
74
+ class DateTimeDumper < Base
75
+ def process(value)
76
+ !value.nil? ? format_datetime(value, @options) : nil
77
+ end
78
+
79
+ private
80
+
81
+ def format_datetime(value, options)
82
+ use_string_format = if options[:store_as_string].nil?
83
+ Dynamoid.config.store_datetime_as_string
84
+ else
85
+ options[:store_as_string]
86
+ end
87
+
88
+ if use_string_format
89
+ value_in_time_zone = Dynamoid::DynamodbTimeZone.in_time_zone(value)
90
+ value_in_time_zone.iso8601
91
+ else
92
+ unless value.respond_to?(:to_i) && value.respond_to?(:nsec)
93
+ value = value.to_time
94
+ end
95
+ BigDecimal(format('%d.%09d', value.to_i, value.nsec))
96
+ end
97
+ end
98
+ end
99
+
100
+ # date -> integer/string
101
+ class DateDumper < Base
102
+ def process(value)
103
+ !value.nil? ? format_date(value, @options) : nil
104
+ end
105
+
106
+ private
107
+
108
+ def format_date(value, options)
109
+ use_string_format = if options[:store_as_string].nil?
110
+ Dynamoid.config.store_date_as_string
111
+ else
112
+ options[:store_as_string]
113
+ end
114
+
115
+ if use_string_format
116
+ value.to_date.iso8601
117
+ else
118
+ (value.to_date - Dynamoid::Persistence::UNIX_EPOCH_DATE).to_i
119
+ end
120
+ end
121
+ end
122
+
123
+ # any standard Ruby object -> self
124
+ class RawDumper < Base
125
+ end
126
+
127
+ # object -> string
128
+ class SerializedDumper < Base
129
+ def process(value)
130
+ @options[:serializer] ? @options[:serializer].dump(value) : value.to_yaml
131
+ end
132
+ end
133
+
134
+ # True/False -> True/False/string
135
+ class BooleanDumper < Base
136
+ def process(value)
137
+ unless value.nil?
138
+ store_as_boolean = if @options[:store_as_native_boolean].nil?
139
+ Dynamoid.config.store_boolean_as_native
140
+ else
141
+ @options[:store_as_native_boolean]
142
+ end
143
+ if store_as_boolean
144
+ !!value
145
+ else
146
+ value.to_s[0] # => "f" or "t"
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ # any object -> string
153
+ class CustomTypeDumper < Base
154
+ def process(value)
155
+ field_class = @options[:type]
156
+
157
+ if value.respond_to?(:dynamoid_dump)
158
+ value.dynamoid_dump
159
+ elsif field_class.respond_to?(:dynamoid_dump)
160
+ field_class.dynamoid_dump(value)
161
+ else
162
+ raise ArgumentError, "Neither #{field_class} nor #{value} supports serialization for Dynamoid."
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end