dynamoid 3.7.1 → 3.9.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +246 -244
  3. data/LICENSE.txt +2 -2
  4. data/README.md +134 -55
  5. data/SECURITY.md +17 -0
  6. data/dynamoid.gemspec +66 -0
  7. data/lib/dynamoid/adapter.rb +7 -9
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/batch_get_item.rb +2 -2
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/execute_statement.rb +62 -0
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +29 -15
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb +3 -0
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +73 -59
  13. data/lib/dynamoid/associations/single_association.rb +28 -9
  14. data/lib/dynamoid/components.rb +2 -3
  15. data/lib/dynamoid/criteria/chain.rb +13 -9
  16. data/lib/dynamoid/criteria.rb +6 -7
  17. data/lib/dynamoid/dirty.rb +60 -63
  18. data/lib/dynamoid/document.rb +42 -12
  19. data/lib/dynamoid/errors.rb +2 -0
  20. data/lib/dynamoid/fields.rb +19 -37
  21. data/lib/dynamoid/finders.rb +9 -4
  22. data/lib/dynamoid/indexes.rb +45 -40
  23. data/lib/dynamoid/loadable.rb +6 -1
  24. data/lib/dynamoid/log/formatter.rb +19 -4
  25. data/lib/dynamoid/persistence/import.rb +4 -1
  26. data/lib/dynamoid/persistence/inc.rb +66 -0
  27. data/lib/dynamoid/persistence/save.rb +52 -5
  28. data/lib/dynamoid/persistence/update_fields.rb +1 -1
  29. data/lib/dynamoid/persistence/update_validations.rb +1 -1
  30. data/lib/dynamoid/persistence/upsert.rb +1 -1
  31. data/lib/dynamoid/persistence.rb +149 -61
  32. data/lib/dynamoid/undumping.rb +18 -0
  33. data/lib/dynamoid/validations.rb +6 -0
  34. data/lib/dynamoid/version.rb +1 -1
  35. data/lib/dynamoid.rb +1 -0
  36. metadata +30 -50
@@ -17,12 +17,6 @@ module Dynamoid
17
17
  end
18
18
 
19
19
  module ClassMethods
20
- # @private
21
- def table(options = {})
22
- self.options = options
23
- super if defined? super
24
- end
25
-
26
20
  def attr_readonly(*read_only_attributes)
27
21
  self.read_only_attributes.concat read_only_attributes.map(&:to_s)
28
22
  end
@@ -77,10 +71,10 @@ module Dynamoid
77
71
 
78
72
  # Return the count of items for this class.
79
73
  #
80
- # It returns aproximate value based on DynamoDB statistic. DynamoDB
81
- # updates it periodicaly so the value can be no accurate.
74
+ # It returns approximate value based on DynamoDB statistic. DynamoDB
75
+ # updates it periodically so the value can be no accurate.
82
76
  #
83
- # It's a reletivly cheap operation and doesn't read all the items in a
77
+ # It's a reletively cheap operation and doesn't read all the items in a
84
78
  # table. It makes just one HTTP request to DynamoDB.
85
79
  #
86
80
  # @return [Integer] items count in a table
@@ -166,6 +160,22 @@ module Dynamoid
166
160
  end
167
161
  end
168
162
 
163
+ attr_accessor :abstract_class
164
+
165
+ def abstract_class?
166
+ defined?(@abstract_class) && @abstract_class == true
167
+ end
168
+
169
+ def sti_name
170
+ name
171
+ end
172
+
173
+ def sti_class_for(type_name)
174
+ type_name.constantize
175
+ rescue NameError
176
+ raise Errors::SubclassNotFound, "STI subclass does not found. Subclass: '#{type_name}'"
177
+ end
178
+
169
179
  # @private
170
180
  def deep_subclasses
171
181
  subclasses + subclasses.map(&:deep_subclasses).flatten
@@ -173,7 +183,7 @@ module Dynamoid
173
183
 
174
184
  # @private
175
185
  def choose_right_class(attrs)
176
- attrs[inheritance_field] ? attrs[inheritance_field].constantize : self
186
+ attrs[inheritance_field] ? sti_class_for(attrs[inheritance_field]) : self
177
187
  end
178
188
  end
179
189
 
@@ -212,7 +222,7 @@ module Dynamoid
212
222
  load(attrs_with_defaults.merge(attrs_virtual))
213
223
 
214
224
  if block
215
- block.call(self)
225
+ yield(self)
216
226
  end
217
227
  end
218
228
  end
@@ -220,7 +230,7 @@ module Dynamoid
220
230
  # Check equality of two models.
221
231
  #
222
232
  # A model is equal to another model only if their primary keys (hash key
223
- # and optionaly range key) are equal.
233
+ # and optionally range key) are equal.
224
234
  #
225
235
  # @return [true|false]
226
236
  # @since 0.2.0
@@ -284,6 +294,26 @@ module Dynamoid
284
294
  end
285
295
  end
286
296
 
297
+ def inspect
298
+ # attributes order is:
299
+ # - partition key
300
+ # - sort key
301
+ # - user defined attributes
302
+ # - timestamps - created_at/updated_at
303
+ names = [self.class.hash_key]
304
+ names << self.class.range_key if self.class.range_key
305
+ names += self.class.attributes.keys - names - %i[created_at updated_at]
306
+ names << :created_at if self.class.attributes.key?(:created_at)
307
+ names << :updated_at if self.class.attributes.key?(:updated_at)
308
+
309
+ inspection = names.map do |name|
310
+ value = read_attribute(name)
311
+ "#{name}: #{value.inspect}"
312
+ end.join(', ')
313
+
314
+ "#<#{self.class.name} #{inspection}>"
315
+ end
316
+
287
317
  private
288
318
 
289
319
  def dumped_range_value
@@ -77,5 +77,7 @@ module Dynamoid
77
77
  class UnsupportedKeyType < Error; end
78
78
 
79
79
  class UnknownAttribute < Error; end
80
+
81
+ class SubclassNotFound < Error; end
80
82
  end
81
83
  end
@@ -8,17 +8,6 @@ module Dynamoid
8
8
  module Fields
9
9
  extend ActiveSupport::Concern
10
10
 
11
- # @private
12
- # Types allowed in indexes:
13
- PERMITTED_KEY_TYPES = %i[
14
- number
15
- integer
16
- string
17
- date
18
- datetime
19
- serialized
20
- ].freeze
21
-
22
11
  # Initialize the attributes we know the class has, in addition to our magic attributes: id, created_at, and updated_at.
23
12
  included do
24
13
  class_attribute :attributes, instance_accessor: false
@@ -220,20 +209,26 @@ module Dynamoid
220
209
  #
221
210
  # @since 0.4.0
222
211
  def table(options)
212
+ self.options = options
213
+
223
214
  # a default 'id' column is created when Dynamoid::Document is included
224
215
  unless attributes.key? hash_key
225
216
  remove_field :id
226
217
  field(hash_key)
227
218
  end
228
219
 
220
+ # The created_at/updated_at fields are declared in the `included` callback first.
221
+ # At that moment the only known setting is `Dynamoid::Config.timestamps`.
222
+ # Now `options[:timestamps]` may override the global setting for a model.
223
+ # So we need to make decision again and declare the fields or rollback thier declaration.
224
+ #
225
+ # Do not replace with `#timestamps_enabled?`.
229
226
  if options[:timestamps] && !Dynamoid::Config.timestamps
230
- # Timestamp fields weren't declared in `included` hook because they
231
- # are disabled globaly
227
+ # The fields weren't declared in `included` callback because they are disabled globaly
232
228
  field :created_at, :datetime
233
229
  field :updated_at, :datetime
234
230
  elsif options[:timestamps] == false && Dynamoid::Config.timestamps
235
- # Timestamp fields were declared in `included` hook but they are
236
- # disabled for a table
231
+ # The fields were declared in `included` callback but they are disabled for a table
237
232
  remove_field :created_at
238
233
  remove_field :updated_at
239
234
  end
@@ -290,25 +285,28 @@ module Dynamoid
290
285
  #
291
286
  # @param name [Symbol] the name of the field
292
287
  # @param value [Object] the value to assign to that field
288
+ # @return [Dynamoid::Document] self
293
289
  #
294
290
  # @since 0.2.0
295
291
  def write_attribute(name, value)
296
292
  name = name.to_sym
293
+ old_value = read_attribute(name)
297
294
 
298
295
  unless attribute_is_present_on_model?(name)
299
- raise Dynamoid::Errors::UnknownAttribute.new("Attribute #{name} is not part of the model")
296
+ raise Dynamoid::Errors::UnknownAttribute, "Attribute #{name} is not part of the model"
300
297
  end
301
298
 
302
299
  if association = @associations[name]
303
300
  association.reset
304
301
  end
305
302
 
306
- attribute_will_change!(name) # Dirty API
307
-
308
303
  @attributes_before_type_cast[name] = value
309
304
 
310
305
  value_casted = TypeCasting.cast_field(value, self.class.attributes[name])
306
+ attribute_will_change!(name) if old_value != value_casted # Dirty API
307
+
311
308
  attributes[name] = value_casted
309
+ self
312
310
  end
313
311
  alias []= write_attribute
314
312
 
@@ -356,23 +354,6 @@ module Dynamoid
356
354
 
357
355
  private
358
356
 
359
- # Automatically called during the created callback to set the created_at time.
360
- #
361
- # @since 0.2.0
362
- def set_created_at
363
- self.created_at ||= DateTime.now.in_time_zone(Time.zone) if self.class.timestamps_enabled?
364
- end
365
-
366
- # Automatically called during the save callback to set the updated_at time.
367
- #
368
- # @since 0.2.0
369
- def set_updated_at
370
- # @_touch_record=false means explicit disabling
371
- if self.class.timestamps_enabled? && !updated_at_changed? && @_touch_record != false
372
- self.updated_at = DateTime.now.in_time_zone(Time.zone)
373
- end
374
- end
375
-
376
357
  def set_expires_field
377
358
  options = self.class.options[:expires]
378
359
 
@@ -388,11 +369,12 @@ module Dynamoid
388
369
 
389
370
  def set_inheritance_field
390
371
  # actually it does only following logic:
391
- # self.type ||= self.class.name if self.class.attributes[:type]
372
+ # self.type ||= self.class.sti_name if self.class.attributes[:type]
373
+ return if self.class.abstract_class?
392
374
 
393
375
  type = self.class.inheritance_field
394
376
  if self.class.attributes[type] && send(type).nil?
395
- send("#{type}=", self.class.name)
377
+ send("#{type}=", self.class.sti_name)
396
378
  end
397
379
  end
398
380
 
@@ -118,7 +118,7 @@ module Dynamoid
118
118
 
119
119
  # @private
120
120
  def _find_all(ids, options = {})
121
- raise Errors::MissingRangeKey if range_key && ids.any? { |pk, sk| sk.nil? }
121
+ raise Errors::MissingRangeKey if range_key && ids.any? { |_pk, sk| sk.nil? }
122
122
 
123
123
  if range_key
124
124
  ids = ids.map do |pk, sk|
@@ -151,7 +151,9 @@ module Dynamoid
151
151
  end
152
152
 
153
153
  if items.size == ids.size || !options[:raise_error]
154
- items ? items.map { |i| from_database(i) } : []
154
+ models = items ? items.map { |i| from_database(i) } : []
155
+ models.each { |m| m.run_callbacks :find }
156
+ models
155
157
  else
156
158
  ids_list = range_key ? ids.map { |pk, sk| "(#{pk},#{sk})" } : ids.map(&:to_s)
157
159
  message = "Couldn't find all #{name.pluralize} with primary keys [#{ids_list.join(', ')}] "
@@ -173,7 +175,9 @@ module Dynamoid
173
175
  end
174
176
 
175
177
  if item = Dynamoid.adapter.read(table_name, id, options.slice(:range_key, :consistent_read))
176
- from_database(item)
178
+ model = from_database(item)
179
+ model.run_callbacks :find
180
+ model
177
181
  elsif options[:raise_error]
178
182
  primary_key = range_key ? "(#{id},#{options[:range_key]})" : id
179
183
  message = "Couldn't find #{name} with primary key #{primary_key}"
@@ -294,7 +298,8 @@ module Dynamoid
294
298
  # @private
295
299
  # @since 0.2.0
296
300
  def method_missing(method, *args)
297
- if method =~ /find/
301
+ # Cannot use Symbol#start_with? because it was introduced in Ruby 2.7, but we support Ruby >= 2.3
302
+ if method.to_s.start_with?('find')
298
303
  ActiveSupport::Deprecation.warn("[Dynamoid] .#{method} is deprecated! Call .where instead of")
299
304
 
300
305
  finder = method.to_s.split('_by_').first
@@ -4,6 +4,15 @@ module Dynamoid
4
4
  module Indexes
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ # @private
8
+ # @see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey
9
+ # Types allowed in indexes
10
+ PERMITTED_KEY_DYNAMODB_TYPES = %i[
11
+ string
12
+ binary
13
+ number
14
+ ].freeze
15
+
7
16
  included do
8
17
  class_attribute :local_secondary_indexes, instance_accessor: false
9
18
  class_attribute :global_secondary_indexes, instance_accessor: false
@@ -20,17 +29,17 @@ module Dynamoid
20
29
  #
21
30
  # field :category
22
31
  #
23
- # global_secondary_indexes hash_key: :category
32
+ # global_secondary_index hash_key: :category
24
33
  # end
25
34
  #
26
35
  # The full example with all the options being specified:
27
36
  #
28
- # global_secondary_indexes hash_key: :category,
29
- # range_key: :created_at,
30
- # name: 'posts_category_created_at_index',
31
- # projected_attributes: :all,
32
- # read_capacity: 100,
33
- # write_capacity: 20
37
+ # global_secondary_index hash_key: :category,
38
+ # range_key: :created_at,
39
+ # name: 'posts_category_created_at_index',
40
+ # projected_attributes: :all,
41
+ # read_capacity: 100,
42
+ # write_capacity: 20
34
43
  #
35
44
  # Global secondary index should be declared after fields for mentioned
36
45
  # hash key and optional range key are declared (with method +field+)
@@ -86,14 +95,14 @@ module Dynamoid
86
95
  # range :created_at, :datetime
87
96
  # field :author_id
88
97
  #
89
- # local_secondary_indexes hash_key: :author_id
98
+ # local_secondary_index range_key: :author_id
90
99
  # end
91
100
  #
92
101
  # The full example with all the options being specified:
93
102
  #
94
- # local_secondary_indexes range_key: :created_at,
95
- # name: 'posts_created_at_index',
96
- # projected_attributes: :all
103
+ # local_secondary_index range_key: :created_at,
104
+ # name: 'posts_created_at_index',
105
+ # projected_attributes: :all
97
106
  #
98
107
  # Local secondary index should be declared after fields for mentioned
99
108
  # hash key and optional range key are declared (with method +field+) as
@@ -160,10 +169,9 @@ module Dynamoid
160
169
  # @return [Dynamoid::Indexes::Index, nil] index object or nil if it isn't found
161
170
  def find_index_by_name(name)
162
171
  string_name = name.to_s
163
- indexes.each_value.detect{ |i| i.name.to_s == string_name }
172
+ indexes.each_value.detect { |i| i.name.to_s == string_name }
164
173
  end
165
174
 
166
-
167
175
  # Returns true iff the provided hash[,range] key combo is a local
168
176
  # secondary index.
169
177
  #
@@ -290,39 +298,36 @@ module Dynamoid
290
298
  end
291
299
  end
292
300
 
301
+ def validate_hash_key
302
+ validate_index_key(:hash_key, @hash_key)
303
+ end
304
+
293
305
  def validate_range_key
294
- if @range_key.present?
295
- range_field_attributes = @dynamoid_class.attributes[@range_key]
296
- if range_field_attributes.present?
297
- range_key_type = range_field_attributes[:type]
298
- if Dynamoid::Fields::PERMITTED_KEY_TYPES.include?(range_key_type)
299
- @range_key_schema = {
300
- @range_key => PrimaryKeyTypeMapping.dynamodb_type(range_key_type, range_field_attributes)
301
- }
302
- else
303
- errors.add(:range_key, 'Index :range_key is not a valid key type')
304
- end
305
- else
306
- errors.add(:range_key, "No such field #{@range_key} defined on table")
307
- end
308
- end
306
+ validate_index_key(:range_key, @range_key)
309
307
  end
310
308
 
311
- def validate_hash_key
312
- hash_field_attributes = @dynamoid_class.attributes[@hash_key]
313
- if hash_field_attributes.present?
314
- hash_field_type = hash_field_attributes[:type]
315
- if Dynamoid::Fields::PERMITTED_KEY_TYPES.include?(hash_field_type)
316
- @hash_key_schema = {
317
- @hash_key => PrimaryKeyTypeMapping.dynamodb_type(hash_field_type, hash_field_attributes)
318
- }
319
- else
320
- errors.add(:hash_key, 'Index :hash_key is not a valid key type')
321
- end
309
+ def validate_index_key(key_param, key_val)
310
+ return if key_val.blank?
311
+
312
+ key_field_attributes = @dynamoid_class.attributes[key_val]
313
+ if key_field_attributes.blank?
314
+ errors.add(key_param, "No such field #{key_val} defined on table")
315
+ return
316
+ end
317
+
318
+ key_dynamodb_type = dynamodb_type(key_field_attributes[:type], key_field_attributes)
319
+ if PERMITTED_KEY_DYNAMODB_TYPES.include?(key_dynamodb_type)
320
+ send("#{key_param}_schema=", { key_val => key_dynamodb_type })
322
321
  else
323
- errors.add(:hash_key, "No such field #{@hash_key} defined on table")
322
+ errors.add(key_param, "Index :#{key_param} is not a valid key type")
324
323
  end
325
324
  end
325
+
326
+ def dynamodb_type(field_type, options)
327
+ PrimaryKeyTypeMapping.dynamodb_type(field_type, options)
328
+ rescue Errors::UnsupportedKeyType
329
+ field_type
330
+ end
326
331
  end
327
332
  end
328
333
  end
@@ -8,12 +8,14 @@ module Dynamoid
8
8
  attrs.each do |key, value|
9
9
  send("#{key}=", value) if respond_to?("#{key}=")
10
10
  end
11
+
12
+ self
11
13
  end
12
14
 
13
15
  # Reload an object from the database -- if you suspect the object has changed in the data store and you need those
14
16
  # changes to be reflected immediately, you would call this method. This is a consistent read.
15
17
  #
16
- # @return [Dynamoid::Document] the document this method was called on
18
+ # @return [Dynamoid::Document] self
17
19
  #
18
20
  # @since 0.2.0
19
21
  def reload
@@ -24,7 +26,10 @@ module Dynamoid
24
26
  end
25
27
 
26
28
  self.attributes = self.class.find(hash_key, **options).attributes
29
+
27
30
  @associations.values.each(&:reset)
31
+ @new_record = false
32
+
28
33
  self
29
34
  end
30
35
  end
@@ -1,10 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Dynamoid
2
4
  module Log
5
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Log/Formatter.html
6
+ # https://docs.aws.amazon.com/sdk-for-ruby/v2/api/Seahorse/Client/Response.html
7
+ # https://aws.amazon.com/ru/blogs/developer/logging-requests/
3
8
  module Formatter
4
-
5
- # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Log/Formatter.html
6
- # https://docs.aws.amazon.com/sdk-for-ruby/v2/api/Seahorse/Client/Response.html
7
- # https://aws.amazon.com/ru/blogs/developer/logging-requests/
8
9
  class Debug
9
10
  def format(response)
10
11
  bold = "\x1b[1m"
@@ -21,6 +22,20 @@ module Dynamoid
21
22
  ].join("\n")
22
23
  end
23
24
  end
25
+
26
+ class Compact
27
+ def format(response)
28
+ bold = "\x1b[1m"
29
+ reset = "\x1b[0m"
30
+
31
+ [
32
+ response.context.operation.name,
33
+ bold,
34
+ response.context.http_request.body.string,
35
+ reset
36
+ ].join(' ')
37
+ end
38
+ end
24
39
  end
25
40
  end
26
41
  end
@@ -24,7 +24,10 @@ module Dynamoid
24
24
  import_with_backoff(models)
25
25
  end
26
26
 
27
- models.each { |d| d.new_record = false }
27
+ models.each do |m|
28
+ m.new_record = false
29
+ m.clear_changes_information
30
+ end
28
31
  models
29
32
  end
30
33
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dynamoid
4
+ module Persistence
5
+ # @private
6
+ class Inc
7
+ def self.call(model_class, hash_key, range_key = nil, counters)
8
+ new(model_class, hash_key, range_key, counters).call
9
+ end
10
+
11
+ # rubocop:disable Style/OptionalArguments
12
+ def initialize(model_class, hash_key, range_key = nil, counters)
13
+ @model_class = model_class
14
+ @hash_key = hash_key
15
+ @range_key = range_key
16
+ @counters = counters
17
+ end
18
+ # rubocop:enable Style/OptionalArguments
19
+
20
+ def call
21
+ touch = @counters.delete(:touch)
22
+
23
+ Dynamoid.adapter.update_item(@model_class.table_name, @hash_key, update_item_options) do |t|
24
+ @counters.each do |name, value|
25
+ t.add(name => cast_and_dump_attribute_value(name, value))
26
+ end
27
+
28
+ if touch
29
+ value = DateTime.now.in_time_zone(Time.zone)
30
+
31
+ timestamp_attributes_to_touch(touch).each do |name|
32
+ t.set(name => cast_and_dump_attribute_value(name, value))
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def update_item_options
41
+ if @model_class.range_key
42
+ range_key_options = @model_class.attributes[@model_class.range_key]
43
+ value_casted = TypeCasting.cast_field(@range_key, range_key_options)
44
+ value_dumped = Dumping.dump_field(value_casted, range_key_options)
45
+ { range_key: value_dumped }
46
+ else
47
+ {}
48
+ end
49
+ end
50
+
51
+ def cast_and_dump_attribute_value(name, value)
52
+ value_casted = TypeCasting.cast_field(value, @model_class.attributes[name])
53
+ Dumping.dump_field(value_casted, @model_class.attributes[name])
54
+ end
55
+
56
+ def timestamp_attributes_to_touch(touch)
57
+ return [] unless touch
58
+
59
+ names = []
60
+ names << :updated_at if @model_class.timestamps_enabled?
61
+ names += Array.wrap(touch) if touch != true
62
+ names
63
+ end
64
+ end
65
+ end
66
+ end
@@ -4,24 +4,44 @@ module Dynamoid
4
4
  module Persistence
5
5
  # @private
6
6
  class Save
7
- def self.call(model)
8
- new(model).call
7
+ def self.call(model, **options)
8
+ new(model, **options).call
9
9
  end
10
10
 
11
- def initialize(model)
11
+ def initialize(model, touch: nil)
12
12
  @model = model
13
+ @touch = touch # touch=false means explicit disabling of updating the `updated_at` attribute
13
14
  end
14
15
 
15
16
  def call
16
17
  @model.hash_key = SecureRandom.uuid if @model.hash_key.blank?
17
18
 
19
+ return true unless @model.changed?
20
+
21
+ @model.created_at ||= DateTime.now.in_time_zone(Time.zone) if @model.class.timestamps_enabled?
22
+
23
+ if @model.class.timestamps_enabled? && !@model.updated_at_changed? && !(@touch == false && @model.persisted?)
24
+ @model.updated_at = DateTime.now.in_time_zone(Time.zone)
25
+ end
26
+
18
27
  # Add an optimistic locking check if the lock_version column exists
19
28
  if @model.class.attributes[:lock_version]
20
29
  @model.lock_version = (@model.lock_version || 0) + 1
21
30
  end
22
31
 
23
- attributes_dumped = Dumping.dump_attributes(@model.attributes, @model.class.attributes)
24
- Dynamoid.adapter.write(@model.class.table_name, attributes_dumped, conditions_for_write)
32
+ if @model.new_record?
33
+ attributes_dumped = Dumping.dump_attributes(@model.attributes, @model.class.attributes)
34
+ Dynamoid.adapter.write(@model.class.table_name, attributes_dumped, conditions_for_write)
35
+ else
36
+ attributes_to_persist = @model.attributes.slice(*@model.changed.map(&:to_sym))
37
+
38
+ Dynamoid.adapter.update_item(@model.class.table_name, @model.hash_key, options_to_update_item) do |t|
39
+ attributes_to_persist.each do |name, value|
40
+ value_dumped = Dumping.dump_field(value, @model.class.attributes[name])
41
+ t.set(name => value_dumped)
42
+ end
43
+ end
44
+ end
25
45
 
26
46
  @model.new_record = false
27
47
  true
@@ -59,6 +79,33 @@ module Dynamoid
59
79
 
60
80
  conditions
61
81
  end
82
+
83
+ def options_to_update_item
84
+ options = {}
85
+
86
+ if @model.class.range_key
87
+ value_dumped = Dumping.dump_field(@model.range_value, @model.class.attributes[@model.class.range_key])
88
+ options[:range_key] = value_dumped
89
+ end
90
+
91
+ conditions = {}
92
+ conditions[:if_exists] ||= {}
93
+ conditions[:if_exists][@model.class.hash_key] = @model.hash_key
94
+
95
+ # Add an optimistic locking check if the lock_version column exists
96
+ if @model.class.attributes[:lock_version]
97
+ # Uses the original lock_version value from Dirty API
98
+ # in case user changed 'lock_version' manually
99
+ if @model.changes[:lock_version][0]
100
+ conditions[:if] ||= {}
101
+ conditions[:if][:lock_version] = @model.changes[:lock_version][0]
102
+ end
103
+ end
104
+
105
+ options[:conditions] = conditions
106
+
107
+ options
108
+ end
62
109
  end
63
110
  end
64
111
  end
@@ -19,7 +19,7 @@ module Dynamoid
19
19
  def call
20
20
  UpdateValidations.validate_attributes_exist(@model_class, @attributes)
21
21
 
22
- if Dynamoid::Config.timestamps
22
+ if @model_class.timestamps_enabled?
23
23
  @attributes[:updated_at] ||= DateTime.now.in_time_zone(Time.zone)
24
24
  end
25
25
 
@@ -9,7 +9,7 @@ module Dynamoid
9
9
 
10
10
  attributes.each do |attr_name, _|
11
11
  unless model_attributes.include?(attr_name)
12
- raise Dynamoid::Errors::UnknownAttribute.new("Attribute #{attr_name} does not exist in #{model_class}")
12
+ raise Dynamoid::Errors::UnknownAttribute, "Attribute #{attr_name} does not exist in #{model_class}"
13
13
  end
14
14
  end
15
15
  end
@@ -19,7 +19,7 @@ module Dynamoid
19
19
  def call
20
20
  UpdateValidations.validate_attributes_exist(@model_class, @attributes)
21
21
 
22
- if Dynamoid::Config.timestamps
22
+ if @model_class.timestamps_enabled?
23
23
  @attributes[:updated_at] ||= DateTime.now.in_time_zone(Time.zone)
24
24
  end
25
25