dynamoid 2.2.0 → 3.0.0

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