dynamoid 3.8.0 → 3.12.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -2
  3. data/README.md +375 -64
  4. data/SECURITY.md +17 -0
  5. data/dynamoid.gemspec +65 -0
  6. data/lib/dynamoid/adapter.rb +21 -14
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/create_table.rb +2 -2
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/execute_statement.rb +62 -0
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb +113 -0
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +29 -2
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb +3 -0
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb +40 -0
  13. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +46 -61
  14. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +34 -28
  15. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb +31 -0
  16. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +132 -74
  17. data/lib/dynamoid/associations/belongs_to.rb +6 -6
  18. data/lib/dynamoid/associations.rb +1 -1
  19. data/lib/dynamoid/components.rb +3 -3
  20. data/lib/dynamoid/config/options.rb +12 -12
  21. data/lib/dynamoid/config.rb +4 -0
  22. data/lib/dynamoid/criteria/chain.rb +165 -149
  23. data/lib/dynamoid/criteria/key_fields_detector.rb +6 -7
  24. data/lib/dynamoid/criteria/nonexistent_fields_detector.rb +2 -2
  25. data/lib/dynamoid/criteria/where_conditions.rb +36 -0
  26. data/lib/dynamoid/dirty.rb +145 -59
  27. data/lib/dynamoid/document.rb +39 -3
  28. data/lib/dynamoid/dumping.rb +41 -19
  29. data/lib/dynamoid/errors.rb +32 -3
  30. data/lib/dynamoid/fields/declare.rb +6 -6
  31. data/lib/dynamoid/fields.rb +21 -29
  32. data/lib/dynamoid/finders.rb +68 -51
  33. data/lib/dynamoid/indexes.rb +7 -10
  34. data/lib/dynamoid/loadable.rb +3 -2
  35. data/lib/dynamoid/log/formatter.rb +19 -4
  36. data/lib/dynamoid/persistence/import.rb +4 -1
  37. data/lib/dynamoid/persistence/inc.rb +82 -0
  38. data/lib/dynamoid/persistence/item_updater_with_casting_and_dumping.rb +36 -0
  39. data/lib/dynamoid/persistence/item_updater_with_dumping.rb +33 -0
  40. data/lib/dynamoid/persistence/save.rb +75 -17
  41. data/lib/dynamoid/persistence/update_fields.rb +24 -9
  42. data/lib/dynamoid/persistence/update_validations.rb +3 -3
  43. data/lib/dynamoid/persistence/upsert.rb +22 -8
  44. data/lib/dynamoid/persistence.rb +308 -72
  45. data/lib/dynamoid/transaction_read/find.rb +137 -0
  46. data/lib/dynamoid/transaction_read.rb +146 -0
  47. data/lib/dynamoid/transaction_write/base.rb +47 -0
  48. data/lib/dynamoid/transaction_write/create.rb +49 -0
  49. data/lib/dynamoid/transaction_write/delete_with_instance.rb +65 -0
  50. data/lib/dynamoid/transaction_write/delete_with_primary_key.rb +64 -0
  51. data/lib/dynamoid/transaction_write/destroy.rb +84 -0
  52. data/lib/dynamoid/transaction_write/item_updater.rb +55 -0
  53. data/lib/dynamoid/transaction_write/save.rb +169 -0
  54. data/lib/dynamoid/transaction_write/update_attributes.rb +46 -0
  55. data/lib/dynamoid/transaction_write/update_fields.rb +239 -0
  56. data/lib/dynamoid/transaction_write/upsert.rb +106 -0
  57. data/lib/dynamoid/transaction_write.rb +673 -0
  58. data/lib/dynamoid/type_casting.rb +18 -15
  59. data/lib/dynamoid/undumping.rb +14 -3
  60. data/lib/dynamoid/validations.rb +8 -5
  61. data/lib/dynamoid/version.rb +1 -1
  62. data/lib/dynamoid.rb +8 -0
  63. metadata +43 -49
  64. data/lib/dynamoid/criteria/ignored_conditions_detector.rb +0 -41
  65. data/lib/dynamoid/criteria/overwritten_conditions_detector.rb +0 -40
@@ -8,7 +8,9 @@ require 'dynamoid/persistence/import'
8
8
  require 'dynamoid/persistence/update_fields'
9
9
  require 'dynamoid/persistence/upsert'
10
10
  require 'dynamoid/persistence/save'
11
+ require 'dynamoid/persistence/inc'
11
12
  require 'dynamoid/persistence/update_validations'
13
+ require 'dynamoid/persistence/item_updater_with_dumping'
12
14
 
13
15
  # encoding: utf-8
14
16
  module Dynamoid
@@ -17,8 +19,9 @@ module Dynamoid
17
19
  module Persistence
18
20
  extend ActiveSupport::Concern
19
21
 
20
- attr_accessor :new_record
22
+ attr_accessor :new_record, :destroyed
21
23
  alias new_record? new_record
24
+ alias destroyed? destroyed
22
25
 
23
26
  # @private
24
27
  UNIX_EPOCH_DATE = Date.new(1970, 1, 1).freeze
@@ -165,7 +168,7 @@ module Dynamoid
165
168
 
166
169
  # Create a model.
167
170
  #
168
- # Initializes a new model and immediately saves it to DynamoDB.
171
+ # Initializes a new model and immediately saves it into DynamoDB.
169
172
  #
170
173
  # User.create(first_name: 'Mark', last_name: 'Tyler')
171
174
  #
@@ -173,7 +176,8 @@ module Dynamoid
173
176
  #
174
177
  # User.create([{ first_name: 'Alice' }, { first_name: 'Bob' }])
175
178
  #
176
- # Creates a model and pass it into a block to set other attributes.
179
+ # Instantiates a model and pass it into an optional block to set other
180
+ # attributes.
177
181
  #
178
182
  # User.create(first_name: 'Mark') do |u|
179
183
  # u.age = 21
@@ -181,7 +185,10 @@ module Dynamoid
181
185
  #
182
186
  # Validates model and runs callbacks.
183
187
  #
184
- # @param attrs [Hash|Array[Hash]] Attributes of the models
188
+ # Raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is required
189
+ # but not specified or has value +nil+.
190
+ #
191
+ # @param attrs [Hash|Array<Hash>] Attributes of a model
185
192
  # @param block [Proc] Block to process a document after initialization
186
193
  # @return [Dynamoid::Document] The created document
187
194
  # @since 0.2.0
@@ -195,12 +202,31 @@ module Dynamoid
195
202
 
196
203
  # Create a model.
197
204
  #
198
- # Initializes a new object and immediately saves it to the Dynamoid.
205
+ # Initializes a new object and immediately saves it into DynamoDB.
206
+ #
207
+ # User.create!(first_name: 'Mark', last_name: 'Tyler')
208
+ #
199
209
  # Raises an exception +Dynamoid::Errors::DocumentNotValid+ if validation
200
- # failed. Accepts both Hash and Array of Hashes and can create several
210
+ # failed.
211
+ #
212
+ # Accepts both Hash and Array of Hashes and can create several
201
213
  # models.
202
214
  #
203
- # @param attrs [Hash|Array[Hash]] Attributes with which to create the object.
215
+ # User.create!([{ first_name: 'Alice' }, { first_name: 'Bob' }])
216
+ #
217
+ # Instantiates a model and pass it into an optional block to set other
218
+ # attributes.
219
+ #
220
+ # User.create!(first_name: 'Mark') do |u|
221
+ # u.age = 21
222
+ # end
223
+ #
224
+ # Validates model and runs callbacks.
225
+ #
226
+ # Raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is required
227
+ # but not specified or has value +nil+.
228
+ #
229
+ # @param attrs [Hash|Array<Hash>] Attributes with which to create the object.
204
230
  # @param block [Proc] Block to process a document after initialization
205
231
  # @return [Dynamoid::Document] The created document
206
232
  # @since 0.2.0
@@ -270,7 +296,7 @@ module Dynamoid
270
296
  # meets the specified conditions. Conditions can be specified as a +Hash+
271
297
  # with +:if+ key:
272
298
  #
273
- # User.update_fields('1', { age: 26 }, if: { version: 1 })
299
+ # User.update_fields('1', { age: 26 }, { if: { version: 1 } })
274
300
  #
275
301
  # Here +User+ model has an integer +version+ field and the document will
276
302
  # be updated only if the +version+ attribute currently has value 1.
@@ -278,16 +304,29 @@ module Dynamoid
278
304
  # If a document with specified hash and range keys doesn't exist or
279
305
  # conditions were specified and failed the method call returns +nil+.
280
306
  #
307
+ # To check if some attribute (or attributes) isn't stored in a DynamoDB
308
+ # item (e.g. it wasn't set explicitly) there is another condition -
309
+ # +unless_exists+:
310
+ #
311
+ # user = User.create(name: 'Tylor')
312
+ # User.update_fields(user.id, { age: 18 }, { unless_exists: [:age] })
313
+ #
281
314
  # +update_fields+ uses the +UpdateItem+ operation so it saves changes and
282
315
  # loads an updated document back with one HTTP request.
283
316
  #
284
317
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
285
318
  # attributes is not on the model
286
319
  #
320
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
321
+ # +nil+ and +Dynamoid::Errors::MissingRangeKey+ if a sort key is required
322
+ # but has value +nil+.
323
+ #
287
324
  # @param hash_key_value [Scalar value] hash key
288
325
  # @param range_key_value [Scalar value] range key (optional)
289
326
  # @param attrs [Hash]
290
327
  # @param conditions [Hash] (optional)
328
+ # @option conditions [Hash] :if conditions on attribute values
329
+ # @option conditions [Hash] :unless_exists conditions on attributes presence
291
330
  # @return [Dynamoid::Document|nil] Updated document
292
331
  def update_fields(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
293
332
  optional_params = [range_key_value, attrs, conditions].compact
@@ -322,23 +361,36 @@ module Dynamoid
322
361
  # meets the specified conditions. Conditions can be specified as a +Hash+
323
362
  # with +:if+ key:
324
363
  #
325
- # User.upsert('1', { age: 26 }, if: { version: 1 })
364
+ # User.upsert('1', { age: 26 }, { if: { version: 1 } })
326
365
  #
327
366
  # Here +User+ model has an integer +version+ field and the document will
328
367
  # be updated only if the +version+ attribute currently has value 1.
329
368
  #
369
+ # To check if some attribute (or attributes) isn't stored in a DynamoDB
370
+ # item (e.g. it wasn't set explicitly) there is another condition -
371
+ # +unless_exists+:
372
+ #
373
+ # user = User.create(name: 'Tylor')
374
+ # User.upsert(user.id, { age: 18 }, { unless_exists: [:age] })
375
+ #
330
376
  # If conditions were specified and failed the method call returns +nil+.
331
377
  #
332
378
  # +upsert+ uses the +UpdateItem+ operation so it saves changes and loads
333
379
  # an updated document back with one HTTP request.
334
380
  #
335
381
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
336
- # attributes is not on the model
382
+ # attributes is not declared in the model class.
383
+ #
384
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
385
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
386
+ # required but has value +nil+.
337
387
  #
338
388
  # @param hash_key_value [Scalar value] hash key
339
389
  # @param range_key_value [Scalar value] range key (optional)
340
390
  # @param attrs [Hash]
341
391
  # @param conditions [Hash] (optional)
392
+ # @option conditions [Hash] :if conditions on attribute values
393
+ # @option conditions [Hash] :unless_exists conditions on attributes presence
342
394
  # @return [Dynamoid::Document|nil] Updated document
343
395
  def upsert(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
344
396
  optional_params = [range_key_value, attrs, conditions].compact
@@ -378,28 +430,22 @@ module Dynamoid
378
430
  # Doesn't run validations and callbacks. Doesn't update +created_at+ and
379
431
  # +updated_at+ as well.
380
432
  #
433
+ # When `:touch` option is passed the timestamp columns are updating. If
434
+ # attribute names are passed, they are updated along with updated_at
435
+ # attribute:
436
+ #
437
+ # User.inc('1', age: 2, touch: true)
438
+ # User.inc('1', age: 2, touch: :viewed_at)
439
+ # User.inc('1', age: 2, touch: [:viewed_at, :accessed_at])
440
+ #
381
441
  # @param hash_key_value [Scalar value] hash key
382
442
  # @param range_key_value [Scalar value] range key (optional)
383
443
  # @param counters [Hash] value to increase by
444
+ # @option counters [true | Symbol | Array<Symbol>] :touch to update update_at attribute and optionally the specified ones
384
445
  # @return [Model class] self
385
446
  def inc(hash_key_value, range_key_value = nil, counters)
386
- options = if range_key
387
- value_casted = TypeCasting.cast_field(range_key_value, attributes[range_key])
388
- value_dumped = Dumping.dump_field(value_casted, attributes[range_key])
389
- { range_key: value_dumped }
390
- else
391
- {}
392
- end
393
-
394
- Dynamoid.adapter.update_item(table_name, hash_key_value, options) do |t|
395
- counters.each do |k, v|
396
- value_casted = TypeCasting.cast_field(v, attributes[k])
397
- value_dumped = Dumping.dump_field(value_casted, attributes[k])
398
-
399
- t.add(k => value_dumped)
400
- end
401
- end
402
-
447
+ # It's similar to Rails' #update_counters.
448
+ Inc.call(self, hash_key_value, range_key_value, counters)
403
449
  self
404
450
  end
405
451
  end
@@ -410,17 +456,43 @@ module Dynamoid
410
456
  #
411
457
  # post.touch
412
458
  #
413
- # Can update another field in addition with the same timestamp if it's name passed as argument.
459
+ # Can update other fields in addition with the same timestamp if their
460
+ # names passed as arguments.
414
461
  #
415
- # user.touch(:last_login_at)
462
+ # user.touch(:last_login_at, :viewed_at)
416
463
  #
417
- # @param name [Symbol] attribute name to update (optional)
464
+ # Some specific value can be used to save:
465
+ #
466
+ # user.touch(time: 1.hour.ago)
467
+ #
468
+ # No validation is performed and only +after_touch+ callback is called.
469
+ #
470
+ # The method must be used on a persisted object, otherwise
471
+ # +Dynamoid::Errors::Error+ will be thrown.
472
+ #
473
+ # @param names [*Symbol] a list of attribute names to update (optional)
474
+ # @param time [Time] datetime value that can be used instead of the current time (optional)
418
475
  # @return [Dynamoid::Document] self
419
- def touch(name = nil)
420
- now = DateTime.now
421
- self.updated_at = now
422
- attributes[name] = now if name
423
- save
476
+ def touch(*names, time: nil)
477
+ if new_record?
478
+ raise Dynamoid::Errors::Error, 'cannot touch on a new or destroyed record object'
479
+ end
480
+
481
+ time_to_assign = time || DateTime.now
482
+
483
+ self.updated_at = time_to_assign
484
+ names.each do |name|
485
+ attributes[name] = time_to_assign
486
+ end
487
+
488
+ attribute_names = names.map(&:to_sym) + [:updated_at]
489
+ attributes_with_values = attributes.slice(*attribute_names)
490
+
491
+ run_callbacks :touch do
492
+ self.class.update_fields(hash_key, range_value, attributes_with_values)
493
+ clear_attribute_changes(attribute_names.map(&:to_s))
494
+ end
495
+
424
496
  self
425
497
  end
426
498
 
@@ -456,7 +528,7 @@ module Dynamoid
456
528
  #
457
529
  # +save+ by default sets timestamps attributes - +created_at+ and
458
530
  # +updated_at+ when creates new model and updates +updated_at+ attribute
459
- # when update already existing one.
531
+ # when updates already existing one.
460
532
  #
461
533
  # Changing +updated_at+ attribute at updating a model can be skipped with
462
534
  # +touch: false+ option:
@@ -466,7 +538,8 @@ module Dynamoid
466
538
  # If a model is new and hash key (+id+ by default) is not assigned yet
467
539
  # it was assigned implicitly with random UUID value.
468
540
  #
469
- # If +lock_version+ attribute is declared it will be incremented. If it's blank then it will be initialized with 1.
541
+ # If +lock_version+ attribute is declared it will be incremented. If it's
542
+ # blank then it will be initialized with 1.
470
543
  #
471
544
  # +save+ method call raises +Dynamoid::Errors::RecordNotUnique+ exception
472
545
  # if primary key (hash key + optional range key) already exists in a
@@ -477,6 +550,11 @@ module Dynamoid
477
550
  # already changed concurrently and +lock_version+ was consequently
478
551
  # increased.
479
552
  #
553
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a model is already persisted
554
+ # and a partition key has value +nil+ and raises
555
+ # +Dynamoid::Errors::MissingRangeKey+ if a sort key is required but has
556
+ # value +nil+.
557
+ #
480
558
  # When a table is not created yet the first +save+ method call will create
481
559
  # a table. It's useful in test environment to avoid explicit table
482
560
  # creation.
@@ -487,21 +565,102 @@ module Dynamoid
487
565
  # @return [true|false] Whether saving successful or not
488
566
  # @since 0.2.0
489
567
  def save(options = {})
490
- self.class.create_table(sync: true)
568
+ if Dynamoid.config.create_table_on_save
569
+ self.class.create_table(sync: true)
570
+ end
491
571
 
492
- @_touch_record = options[:touch]
572
+ create_or_update = new_record? ? :create : :update
573
+
574
+ run_callbacks(:save) do
575
+ run_callbacks(create_or_update) do
576
+ Save.call(self, touch: options[:touch])
577
+ end
578
+ end
579
+ end
580
+
581
+ # Create new model or persist changes.
582
+ #
583
+ # Run the validation and callbacks. Raises
584
+ # +Dynamoid::Errors::DocumentNotValid+ is validation fails.
585
+ #
586
+ # user = User.create
587
+ #
588
+ # user.age = 26
589
+ # user.save! # => user
590
+ #
591
+ # Validation can be skipped with +validate: false+ option:
592
+ #
593
+ # user = User.new(age: -1)
594
+ # user.save!(validate: false) # => user
595
+ #
596
+ # +save!+ by default sets timestamps attributes - +created_at+ and
597
+ # +updated_at+ when creates new model and updates +updated_at+ attribute
598
+ # when updates already existing one.
599
+ #
600
+ # Changing +updated_at+ attribute at updating a model can be skipped with
601
+ # +touch: false+ option:
602
+ #
603
+ # user.save!(touch: false)
604
+ #
605
+ # If a model is new and hash key (+id+ by default) is not assigned yet
606
+ # it was assigned implicitly with random UUID value.
607
+ #
608
+ # If +lock_version+ attribute is declared it will be incremented. If it's
609
+ # blank then it will be initialized with 1.
610
+ #
611
+ # +save!+ method call raises +Dynamoid::Errors::RecordNotUnique+ exception
612
+ # if primary key (hash key + optional range key) already exists in a
613
+ # table.
614
+ #
615
+ # +save!+ method call raises +Dynamoid::Errors::StaleObjectError+ exception
616
+ # if there is +lock_version+ attribute and the document in a table was
617
+ # already changed concurrently and +lock_version+ was consequently
618
+ # increased.
619
+ #
620
+ # +save!+ method call raises +Dynamoid::Errors::RecordNotSaved+ exception
621
+ # if some callback aborted execution.
622
+ #
623
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a model is already persisted
624
+ # and a partition key has value +nil+ and raises
625
+ # +Dynamoid::Errors::MissingRangeKey+ if a sort key is required but has
626
+ # value +nil+.
627
+ #
628
+ # When a table is not created yet the first +save!+ method call will create
629
+ # a table. It's useful in test environment to avoid explicit table
630
+ # creation.
631
+ #
632
+ # @param options [Hash] (optional)
633
+ # @option options [true|false] :validate validate a model or not - +true+ by default (optional)
634
+ # @option options [true|false] :touch update tiemstamps fields or not - +true+ by default (optional)
635
+ # @return [true|false] Whether saving successful or not
636
+ def save!(options = {})
637
+ # validation is handled in the Validation module
638
+
639
+ if Dynamoid.config.create_table_on_save
640
+ self.class.create_table(sync: true)
641
+ end
493
642
 
494
643
  create_or_update = new_record? ? :create : :update
644
+ aborted = true
495
645
 
496
- run_callbacks(create_or_update) do
497
- run_callbacks(:save) do
498
- Save.call(self)
646
+ run_callbacks(:save) do
647
+ run_callbacks(create_or_update) do
648
+ aborted = false
649
+ Save.call(self, touch: options[:touch])
499
650
  end
500
651
  end
652
+
653
+ if aborted
654
+ raise Dynamoid::Errors::RecordNotSaved, self
655
+ end
656
+
657
+ self
501
658
  end
502
659
 
503
660
  # Update multiple attributes at once, saving the object once the updates
504
- # are complete. Returns +true+ if saving is successful and +false+
661
+ # are complete.
662
+ #
663
+ # Returns +true+ if saving is successful and +false+
505
664
  # otherwise.
506
665
  #
507
666
  # user.update_attributes(age: 27, last_name: 'Tylor')
@@ -509,6 +668,10 @@ module Dynamoid
509
668
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
510
669
  # attributes is not on the model
511
670
  #
671
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
672
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
673
+ # required but has value +nil+.
674
+ #
512
675
  # @param attributes [Hash] a hash of attributes to update
513
676
  # @return [true|false] Whether updating successful or not
514
677
  # @since 0.2.0
@@ -528,6 +691,10 @@ module Dynamoid
528
691
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
529
692
  # attributes is not on the model
530
693
  #
694
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
695
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
696
+ # required but has value +nil+.
697
+ #
531
698
  # @param attributes [Hash] a hash of attributes to update
532
699
  def update_attributes!(attributes)
533
700
  attributes.each { |attribute, value| write_attribute(attribute, value) }
@@ -540,6 +707,8 @@ module Dynamoid
540
707
  #
541
708
  # user.update_attribute(:last_name, 'Tylor')
542
709
  #
710
+ # Validation is skipped.
711
+ #
543
712
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
544
713
  # attributes is not on the model
545
714
  #
@@ -550,21 +719,17 @@ module Dynamoid
550
719
  # @since 0.2.0
551
720
  def update_attribute(attribute, value)
552
721
  # final implementation is in the Dynamoid::Validation module
553
- write_attribute(attribute, value)
554
- save
555
- self
556
722
  end
557
723
 
558
724
  # Update a model.
559
725
  #
560
- # Runs validation and callbacks. Reloads all attribute values.
726
+ # Doesn't run validation. Runs only +update+ callbacks. Reloads all attribute values.
561
727
  #
562
728
  # Accepts mandatory block in order to specify operations which will modify
563
729
  # attributes. Supports following operations: +add+, +delete+ and +set+.
564
730
  #
565
731
  # Operation +add+ just adds a value for numeric attributes and join
566
- # collections if attribute is a collection (one of +array+, +set+ or
567
- # +map+).
732
+ # collections if attribute is a set.
568
733
  #
569
734
  # user.update! do |t|
570
735
  # t.add(age: 1, followers_count: 5)
@@ -589,7 +754,7 @@ module Dynamoid
589
754
  # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.AttributeUpdates.html]
590
755
  # of +UpdateItem+ operation.
591
756
  #
592
- # It's an atomic operation. So adding or deleting elements in a collection
757
+ # It's atomic operations. So adding or deleting elements in a collection
593
758
  # or incrementing or decrementing a numeric field is atomic and does not
594
759
  # interfere with other write requests.
595
760
  #
@@ -599,6 +764,15 @@ module Dynamoid
599
764
  # t.add(age: 1)
600
765
  # end
601
766
  #
767
+ # To check if some attribute (or attributes) isn't stored in a DynamoDB
768
+ # item (e.g. it wasn't set explicitly) there is another condition -
769
+ # +unless_exists+:
770
+ #
771
+ # user = User.create(name: 'Tylor')
772
+ # user.update!(unless_exists: [:age]) do |t|
773
+ # t.set(age: 18)
774
+ # end
775
+ #
602
776
  # If a document doesn't meet conditions it raises
603
777
  # +Dynamoid::Errors::StaleObjectError+ exception.
604
778
  #
@@ -620,21 +794,33 @@ module Dynamoid
620
794
 
621
795
  begin
622
796
  table_name = self.class.table_name
797
+ partition_key_dumped = Dumping.dump_field(hash_key, self.class.attributes[self.class.hash_key])
798
+ conditions = conditions.dup
799
+ conditions[:if] ||= {}
800
+ conditions[:if][self.class.hash_key] = partition_key_dumped
801
+ if self.class.range_key
802
+ sort_key_dumped = Dumping.dump_field(range_value, self.class.attributes[self.class.range_key])
803
+ conditions[:if][self.class.range_key] = sort_key_dumped
804
+ end
805
+
623
806
  update_item_options = options.merge(conditions: conditions)
624
807
 
625
- new_attrs = Dynamoid.adapter.update_item(table_name, hash_key, update_item_options) do |t|
626
- t.add(lock_version: 1) if self.class.attributes[:lock_version]
808
+ new_attrs = Dynamoid.adapter.update_item(table_name, partition_key_dumped, update_item_options) do |t|
809
+ item_updater = ItemUpdaterWithDumping.new(self.class, t)
810
+
811
+ item_updater.add(lock_version: 1) if self.class.attributes[:lock_version]
627
812
 
628
813
  if self.class.timestamps_enabled?
629
- time_now = DateTime.now.in_time_zone(Time.zone)
630
- time_now_dumped = Dumping.dump_field(time_now, self.class.attributes[:updated_at])
631
- t.set(updated_at: time_now_dumped)
814
+ item_updater.set(updated_at: DateTime.now.in_time_zone(Time.zone))
632
815
  end
633
816
 
634
817
  yield t
635
818
  end
636
819
  load(Undumping.undump_attributes(new_attrs, self.class.attributes))
637
820
  rescue Dynamoid::Errors::ConditionalCheckFailedException
821
+ # exception may be raised either because of failed user provided conditions
822
+ # or because of conditions on partition and sort keys. We cannot
823
+ # distinguish these two cases.
638
824
  raise Dynamoid::Errors::StaleObjectError.new(self, 'update')
639
825
  end
640
826
  end
@@ -644,14 +830,13 @@ module Dynamoid
644
830
 
645
831
  # Update a model.
646
832
  #
647
- # Runs validation and callbacks. Reloads all attribute values.
833
+ # Doesn't run validation. Runs only +update+ callbacks. Reloads all attribute values.
648
834
  #
649
835
  # Accepts mandatory block in order to specify operations which will modify
650
836
  # attributes. Supports following operations: +add+, +delete+ and +set+.
651
837
  #
652
838
  # Operation +add+ just adds a value for numeric attributes and join
653
- # collections if attribute is a collection (one of +array+, +set+ or
654
- # +map+).
839
+ # collections if attribute is a set.
655
840
  #
656
841
  # user.update do |t|
657
842
  # t.add(age: 1, followers_count: 5)
@@ -695,6 +880,15 @@ module Dynamoid
695
880
  # t.add(age: 1)
696
881
  # end
697
882
  #
883
+ # To check if some attribute (or attributes) isn't stored in a DynamoDB
884
+ # item (e.g. it wasn't set explicitly) there is another condition -
885
+ # +unless_exists+:
886
+ #
887
+ # user = User.create(name: 'Tylor')
888
+ # user.update(unless_exists: [:age]) do |t|
889
+ # t.set(age: 18)
890
+ # end
891
+ #
698
892
  # If a document doesn't meet conditions it just returns +false+. Otherwise it returns +true+.
699
893
  #
700
894
  # It will increment the +lock_version+ attribute if a table has the column,
@@ -736,14 +930,32 @@ module Dynamoid
736
930
  # user.increment!(:followers_count)
737
931
  # user.increment!(:followers_count, 2)
738
932
  #
739
- # Returns +true+ if a model was saved and +false+ otherwise.
933
+ # Only `attribute` is saved. The model itself is not saved. So any other
934
+ # modified attributes will still be dirty. Validations and callbacks are
935
+ # skipped.
936
+ #
937
+ # When `:touch` option is passed the timestamp columns are updating. If
938
+ # attribute names are passed, they are updated along with updated_at
939
+ # attribute:
940
+ #
941
+ # user.increment!(:followers_count, touch: true)
942
+ # user.increment!(:followers_count, touch: :viewed_at)
943
+ # user.increment!(:followers_count, touch: [:viewed_at, :accessed_at])
740
944
  #
741
945
  # @param attribute [Symbol] attribute name
742
946
  # @param by [Numeric] value to add (optional)
743
- # @return [true|false] whether saved model successfully
744
- def increment!(attribute, by = 1)
947
+ # @param touch [true | Symbol | Array<Symbol>] to update update_at attribute and optionally the specified ones
948
+ # @return [Dynamoid::Document] self
949
+ def increment!(attribute, by = 1, touch: nil)
745
950
  increment(attribute, by)
746
- save
951
+ change = read_attribute(attribute) - (attribute_was(attribute) || 0)
952
+
953
+ run_callbacks :touch do
954
+ self.class.inc(hash_key, range_value, attribute => change, touch: touch)
955
+ clear_attribute_changes(attribute)
956
+ end
957
+
958
+ self
747
959
  end
748
960
 
749
961
  # Change numeric attribute value.
@@ -758,9 +970,7 @@ module Dynamoid
758
970
  # @param by [Numeric] value to subtract (optional)
759
971
  # @return [Dynamoid::Document] self
760
972
  def decrement(attribute, by = 1)
761
- self[attribute] ||= 0
762
- self[attribute] -= by
763
- self
973
+ increment(attribute, -by)
764
974
  end
765
975
 
766
976
  # Change numeric attribute value and save a model.
@@ -771,14 +981,24 @@ module Dynamoid
771
981
  # user.decrement!(:followers_count)
772
982
  # user.decrement!(:followers_count, 2)
773
983
  #
774
- # Returns +true+ if a model was saved and +false+ otherwise.
984
+ # Only `attribute` is saved. The model itself is not saved. So any other
985
+ # modified attributes will still be dirty. Validations and callbacks are
986
+ # skipped.
987
+ #
988
+ # When `:touch` option is passed the timestamp columns are updating. If
989
+ # attribute names are passed, they are updated along with updated_at
990
+ # attribute:
991
+ #
992
+ # user.decrement!(:followers_count, touch: true)
993
+ # user.decrement!(:followers_count, touch: :viewed_at)
994
+ # user.decrement!(:followers_count, touch: [:viewed_at, :accessed_at])
775
995
  #
776
996
  # @param attribute [Symbol] attribute name
777
997
  # @param by [Numeric] value to subtract (optional)
778
- # @return [true|false] whether saved model successfully
779
- def decrement!(attribute, by = 1)
780
- decrement(attribute, by)
781
- save
998
+ # @param touch [true | Symbol | Array<Symbol>] to update update_at attribute and optionally the specified ones
999
+ # @return [Dynamoid::Document] self
1000
+ def decrement!(attribute, by = 1, touch: nil)
1001
+ increment!(attribute, -by, touch: touch)
782
1002
  end
783
1003
 
784
1004
  # Delete a model.
@@ -790,6 +1010,10 @@ module Dynamoid
790
1010
  #
791
1011
  # Returns +self+ if deleted successfully and +false+ otherwise.
792
1012
  #
1013
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
1014
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
1015
+ # required but has value +nil+.
1016
+ #
793
1017
  # @return [Dynamoid::Document|false] whether deleted successfully
794
1018
  # @since 0.2.0
795
1019
  def destroy
@@ -811,6 +1035,10 @@ module Dynamoid
811
1035
  #
812
1036
  # Raises +Dynamoid::Errors::RecordNotDestroyed+ exception if model deleting
813
1037
  # failed.
1038
+ #
1039
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
1040
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
1041
+ # required but has value +nil+.
814
1042
  def destroy!
815
1043
  destroy || (raise Dynamoid::Errors::RecordNotDestroyed, self)
816
1044
  end
@@ -823,10 +1051,18 @@ module Dynamoid
823
1051
  # Raises +Dynamoid::Errors::StaleObjectError+ exception if cannot delete a
824
1052
  # model.
825
1053
  #
1054
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
1055
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
1056
+ # required but has value +nil+.
1057
+ #
826
1058
  # @return [Dynamoid::Document] self
827
1059
  # @since 0.2.0
828
1060
  def delete
1061
+ raise Dynamoid::Errors::MissingHashKey if hash_key.nil?
1062
+ raise Dynamoid::Errors::MissingRangeKey if self.class.range_key? && range_value.nil?
1063
+
829
1064
  options = range_key ? { range_key: Dumping.dump_field(read_attribute(range_key), self.class.attributes[range_key]) } : {}
1065
+ partition_key_dumped = Dumping.dump_field(hash_key, self.class.attributes[self.class.hash_key])
830
1066
 
831
1067
  # Add an optimistic locking check if the lock_version column exists
832
1068
  if self.class.attributes[:lock_version]
@@ -842,9 +1078,9 @@ module Dynamoid
842
1078
 
843
1079
  @destroyed = true
844
1080
 
845
- Dynamoid.adapter.delete(self.class.table_name, hash_key, options)
1081
+ Dynamoid.adapter.delete(self.class.table_name, partition_key_dumped, options)
846
1082
 
847
- self.class.associations.each do |name, options|
1083
+ self.class.associations.each_key do |name|
848
1084
  send(name).disassociate_source
849
1085
  end
850
1086