dynamoid 3.10.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -1
  3. data/README.md +268 -8
  4. data/dynamoid.gemspec +4 -4
  5. data/lib/dynamoid/adapter.rb +1 -1
  6. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb +53 -18
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +5 -4
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb +9 -7
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +1 -1
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +1 -1
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb +31 -0
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +17 -5
  13. data/lib/dynamoid/components.rb +1 -0
  14. data/lib/dynamoid/config.rb +3 -0
  15. data/lib/dynamoid/criteria/chain.rb +74 -21
  16. data/lib/dynamoid/criteria/where_conditions.rb +13 -6
  17. data/lib/dynamoid/dirty.rb +97 -11
  18. data/lib/dynamoid/dumping.rb +39 -17
  19. data/lib/dynamoid/errors.rb +30 -3
  20. data/lib/dynamoid/fields.rb +13 -3
  21. data/lib/dynamoid/finders.rb +44 -23
  22. data/lib/dynamoid/loadable.rb +1 -0
  23. data/lib/dynamoid/persistence/inc.rb +35 -19
  24. data/lib/dynamoid/persistence/item_updater_with_casting_and_dumping.rb +36 -0
  25. data/lib/dynamoid/persistence/item_updater_with_dumping.rb +33 -0
  26. data/lib/dynamoid/persistence/save.rb +29 -14
  27. data/lib/dynamoid/persistence/update_fields.rb +23 -8
  28. data/lib/dynamoid/persistence/update_validations.rb +3 -3
  29. data/lib/dynamoid/persistence/upsert.rb +22 -8
  30. data/lib/dynamoid/persistence.rb +184 -28
  31. data/lib/dynamoid/transaction_read/find.rb +137 -0
  32. data/lib/dynamoid/transaction_read.rb +146 -0
  33. data/lib/dynamoid/transaction_write/base.rb +47 -0
  34. data/lib/dynamoid/transaction_write/create.rb +49 -0
  35. data/lib/dynamoid/transaction_write/delete_with_instance.rb +65 -0
  36. data/lib/dynamoid/transaction_write/delete_with_primary_key.rb +64 -0
  37. data/lib/dynamoid/transaction_write/destroy.rb +84 -0
  38. data/lib/dynamoid/transaction_write/item_updater.rb +55 -0
  39. data/lib/dynamoid/transaction_write/save.rb +169 -0
  40. data/lib/dynamoid/transaction_write/update_attributes.rb +46 -0
  41. data/lib/dynamoid/transaction_write/update_fields.rb +239 -0
  42. data/lib/dynamoid/transaction_write/upsert.rb +106 -0
  43. data/lib/dynamoid/transaction_write.rb +673 -0
  44. data/lib/dynamoid/type_casting.rb +3 -1
  45. data/lib/dynamoid/undumping.rb +13 -2
  46. data/lib/dynamoid/validations.rb +8 -5
  47. data/lib/dynamoid/version.rb +1 -1
  48. data/lib/dynamoid.rb +8 -0
  49. metadata +21 -5
@@ -10,6 +10,7 @@ require 'dynamoid/persistence/upsert'
10
10
  require 'dynamoid/persistence/save'
11
11
  require 'dynamoid/persistence/inc'
12
12
  require 'dynamoid/persistence/update_validations'
13
+ require 'dynamoid/persistence/item_updater_with_dumping'
13
14
 
14
15
  # encoding: utf-8
15
16
  module Dynamoid
@@ -18,8 +19,9 @@ module Dynamoid
18
19
  module Persistence
19
20
  extend ActiveSupport::Concern
20
21
 
21
- attr_accessor :new_record
22
+ attr_accessor :new_record, :destroyed
22
23
  alias new_record? new_record
24
+ alias destroyed? destroyed
23
25
 
24
26
  # @private
25
27
  UNIX_EPOCH_DATE = Date.new(1970, 1, 1).freeze
@@ -166,7 +168,7 @@ module Dynamoid
166
168
 
167
169
  # Create a model.
168
170
  #
169
- # Initializes a new model and immediately saves it to DynamoDB.
171
+ # Initializes a new model and immediately saves it into DynamoDB.
170
172
  #
171
173
  # User.create(first_name: 'Mark', last_name: 'Tyler')
172
174
  #
@@ -174,7 +176,8 @@ module Dynamoid
174
176
  #
175
177
  # User.create([{ first_name: 'Alice' }, { first_name: 'Bob' }])
176
178
  #
177
- # 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.
178
181
  #
179
182
  # User.create(first_name: 'Mark') do |u|
180
183
  # u.age = 21
@@ -182,7 +185,10 @@ module Dynamoid
182
185
  #
183
186
  # Validates model and runs callbacks.
184
187
  #
185
- # @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
186
192
  # @param block [Proc] Block to process a document after initialization
187
193
  # @return [Dynamoid::Document] The created document
188
194
  # @since 0.2.0
@@ -196,12 +202,31 @@ module Dynamoid
196
202
 
197
203
  # Create a model.
198
204
  #
199
- # 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
+ #
200
209
  # Raises an exception +Dynamoid::Errors::DocumentNotValid+ if validation
201
- # 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
202
213
  # models.
203
214
  #
204
- # @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.
205
230
  # @param block [Proc] Block to process a document after initialization
206
231
  # @return [Dynamoid::Document] The created document
207
232
  # @since 0.2.0
@@ -292,10 +317,16 @@ module Dynamoid
292
317
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
293
318
  # attributes is not on the model
294
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
+ #
295
324
  # @param hash_key_value [Scalar value] hash key
296
325
  # @param range_key_value [Scalar value] range key (optional)
297
326
  # @param attrs [Hash]
298
327
  # @param conditions [Hash] (optional)
328
+ # @option conditions [Hash] :if conditions on attribute values
329
+ # @option conditions [Hash] :unless_exists conditions on attributes presence
299
330
  # @return [Dynamoid::Document|nil] Updated document
300
331
  def update_fields(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
301
332
  optional_params = [range_key_value, attrs, conditions].compact
@@ -348,12 +379,18 @@ module Dynamoid
348
379
  # an updated document back with one HTTP request.
349
380
  #
350
381
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
351
- # 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+.
352
387
  #
353
388
  # @param hash_key_value [Scalar value] hash key
354
389
  # @param range_key_value [Scalar value] range key (optional)
355
390
  # @param attrs [Hash]
356
391
  # @param conditions [Hash] (optional)
392
+ # @option conditions [Hash] :if conditions on attribute values
393
+ # @option conditions [Hash] :unless_exists conditions on attributes presence
357
394
  # @return [Dynamoid::Document|nil] Updated document
358
395
  def upsert(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
359
396
  optional_params = [range_key_value, attrs, conditions].compact
@@ -404,9 +441,10 @@ module Dynamoid
404
441
  # @param hash_key_value [Scalar value] hash key
405
442
  # @param range_key_value [Scalar value] range key (optional)
406
443
  # @param counters [Hash] value to increase by
407
- # @option counters [true | Symbol | Array[Symbol]] :touch to update update_at attribute and optionally the specified ones
444
+ # @option counters [true | Symbol | Array<Symbol>] :touch to update update_at attribute and optionally the specified ones
408
445
  # @return [Model class] self
409
446
  def inc(hash_key_value, range_key_value = nil, counters)
447
+ # It's similar to Rails' #update_counters.
410
448
  Inc.call(self, hash_key_value, range_key_value, counters)
411
449
  self
412
450
  end
@@ -490,7 +528,7 @@ module Dynamoid
490
528
  #
491
529
  # +save+ by default sets timestamps attributes - +created_at+ and
492
530
  # +updated_at+ when creates new model and updates +updated_at+ attribute
493
- # when update already existing one.
531
+ # when updates already existing one.
494
532
  #
495
533
  # Changing +updated_at+ attribute at updating a model can be skipped with
496
534
  # +touch: false+ option:
@@ -500,7 +538,8 @@ module Dynamoid
500
538
  # If a model is new and hash key (+id+ by default) is not assigned yet
501
539
  # it was assigned implicitly with random UUID value.
502
540
  #
503
- # 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.
504
543
  #
505
544
  # +save+ method call raises +Dynamoid::Errors::RecordNotUnique+ exception
506
545
  # if primary key (hash key + optional range key) already exists in a
@@ -511,6 +550,11 @@ module Dynamoid
511
550
  # already changed concurrently and +lock_version+ was consequently
512
551
  # increased.
513
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
+ #
514
558
  # When a table is not created yet the first +save+ method call will create
515
559
  # a table. It's useful in test environment to avoid explicit table
516
560
  # creation.
@@ -534,8 +578,89 @@ module Dynamoid
534
578
  end
535
579
  end
536
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
642
+
643
+ create_or_update = new_record? ? :create : :update
644
+ aborted = true
645
+
646
+ run_callbacks(:save) do
647
+ run_callbacks(create_or_update) do
648
+ aborted = false
649
+ Save.call(self, touch: options[:touch])
650
+ end
651
+ end
652
+
653
+ if aborted
654
+ raise Dynamoid::Errors::RecordNotSaved, self
655
+ end
656
+
657
+ self
658
+ end
659
+
537
660
  # Update multiple attributes at once, saving the object once the updates
538
- # are complete. Returns +true+ if saving is successful and +false+
661
+ # are complete.
662
+ #
663
+ # Returns +true+ if saving is successful and +false+
539
664
  # otherwise.
540
665
  #
541
666
  # user.update_attributes(age: 27, last_name: 'Tylor')
@@ -543,6 +668,10 @@ module Dynamoid
543
668
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
544
669
  # attributes is not on the model
545
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
+ #
546
675
  # @param attributes [Hash] a hash of attributes to update
547
676
  # @return [true|false] Whether updating successful or not
548
677
  # @since 0.2.0
@@ -562,6 +691,10 @@ module Dynamoid
562
691
  # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
563
692
  # attributes is not on the model
564
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
+ #
565
698
  # @param attributes [Hash] a hash of attributes to update
566
699
  def update_attributes!(attributes)
567
700
  attributes.each { |attribute, value| write_attribute(attribute, value) }
@@ -586,9 +719,6 @@ module Dynamoid
586
719
  # @since 0.2.0
587
720
  def update_attribute(attribute, value)
588
721
  # final implementation is in the Dynamoid::Validation module
589
- write_attribute(attribute, value)
590
- save
591
- self
592
722
  end
593
723
 
594
724
  # Update a model.
@@ -599,8 +729,7 @@ module Dynamoid
599
729
  # attributes. Supports following operations: +add+, +delete+ and +set+.
600
730
  #
601
731
  # Operation +add+ just adds a value for numeric attributes and join
602
- # collections if attribute is a collection (one of +array+, +set+ or
603
- # +map+).
732
+ # collections if attribute is a set.
604
733
  #
605
734
  # user.update! do |t|
606
735
  # t.add(age: 1, followers_count: 5)
@@ -625,7 +754,7 @@ module Dynamoid
625
754
  # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.AttributeUpdates.html]
626
755
  # of +UpdateItem+ operation.
627
756
  #
628
- # 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
629
758
  # or incrementing or decrementing a numeric field is atomic and does not
630
759
  # interfere with other write requests.
631
760
  #
@@ -665,21 +794,33 @@ module Dynamoid
665
794
 
666
795
  begin
667
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
+
668
806
  update_item_options = options.merge(conditions: conditions)
669
807
 
670
- new_attrs = Dynamoid.adapter.update_item(table_name, hash_key, update_item_options) do |t|
671
- 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]
672
812
 
673
813
  if self.class.timestamps_enabled?
674
- time_now = DateTime.now.in_time_zone(Time.zone)
675
- time_now_dumped = Dumping.dump_field(time_now, self.class.attributes[:updated_at])
676
- t.set(updated_at: time_now_dumped)
814
+ item_updater.set(updated_at: DateTime.now.in_time_zone(Time.zone))
677
815
  end
678
816
 
679
817
  yield t
680
818
  end
681
819
  load(Undumping.undump_attributes(new_attrs, self.class.attributes))
682
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.
683
824
  raise Dynamoid::Errors::StaleObjectError.new(self, 'update')
684
825
  end
685
826
  end
@@ -695,8 +836,7 @@ module Dynamoid
695
836
  # attributes. Supports following operations: +add+, +delete+ and +set+.
696
837
  #
697
838
  # Operation +add+ just adds a value for numeric attributes and join
698
- # collections if attribute is a collection (one of +array+, +set+ or
699
- # +map+).
839
+ # collections if attribute is a set.
700
840
  #
701
841
  # user.update do |t|
702
842
  # t.add(age: 1, followers_count: 5)
@@ -804,7 +944,7 @@ module Dynamoid
804
944
  #
805
945
  # @param attribute [Symbol] attribute name
806
946
  # @param by [Numeric] value to add (optional)
807
- # @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
947
+ # @param touch [true | Symbol | Array<Symbol>] to update update_at attribute and optionally the specified ones
808
948
  # @return [Dynamoid::Document] self
809
949
  def increment!(attribute, by = 1, touch: nil)
810
950
  increment(attribute, by)
@@ -855,7 +995,7 @@ module Dynamoid
855
995
  #
856
996
  # @param attribute [Symbol] attribute name
857
997
  # @param by [Numeric] value to subtract (optional)
858
- # @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
998
+ # @param touch [true | Symbol | Array<Symbol>] to update update_at attribute and optionally the specified ones
859
999
  # @return [Dynamoid::Document] self
860
1000
  def decrement!(attribute, by = 1, touch: nil)
861
1001
  increment!(attribute, -by, touch: touch)
@@ -870,6 +1010,10 @@ module Dynamoid
870
1010
  #
871
1011
  # Returns +self+ if deleted successfully and +false+ otherwise.
872
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
+ #
873
1017
  # @return [Dynamoid::Document|false] whether deleted successfully
874
1018
  # @since 0.2.0
875
1019
  def destroy
@@ -891,6 +1035,10 @@ module Dynamoid
891
1035
  #
892
1036
  # Raises +Dynamoid::Errors::RecordNotDestroyed+ exception if model deleting
893
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+.
894
1042
  def destroy!
895
1043
  destroy || (raise Dynamoid::Errors::RecordNotDestroyed, self)
896
1044
  end
@@ -903,10 +1051,18 @@ module Dynamoid
903
1051
  # Raises +Dynamoid::Errors::StaleObjectError+ exception if cannot delete a
904
1052
  # model.
905
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
+ #
906
1058
  # @return [Dynamoid::Document] self
907
1059
  # @since 0.2.0
908
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
+
909
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])
910
1066
 
911
1067
  # Add an optimistic locking check if the lock_version column exists
912
1068
  if self.class.attributes[:lock_version]
@@ -922,7 +1078,7 @@ module Dynamoid
922
1078
 
923
1079
  @destroyed = true
924
1080
 
925
- Dynamoid.adapter.delete(self.class.table_name, hash_key, options)
1081
+ Dynamoid.adapter.delete(self.class.table_name, partition_key_dumped, options)
926
1082
 
927
1083
  self.class.associations.each_key do |name|
928
1084
  send(name).disassociate_source
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dynamoid
4
+ class TransactionRead
5
+ class Find
6
+ attr_reader :model_class
7
+
8
+ def initialize(model_class, *ids, **options)
9
+ @model_class = model_class
10
+ @ids = ids
11
+ @options = options
12
+ end
13
+
14
+ def on_registration
15
+ validate_primary_key!
16
+ end
17
+
18
+ def observable_by_user_result
19
+ nil
20
+ end
21
+
22
+ def action_request
23
+ if single_key_given?
24
+ action_request_for_single_key
25
+ else
26
+ action_request_for_multiple_keys
27
+ end
28
+ end
29
+
30
+ def process_responses(responses)
31
+ models = responses.map do |response|
32
+ if response.item
33
+ @model_class.from_database(response.item)
34
+ elsif @options[:raise_error] == false
35
+ nil
36
+ else
37
+ message = build_record_not_found_exception_message(responses)
38
+ raise Dynamoid::Errors::RecordNotFound, message
39
+ end
40
+ end
41
+
42
+ unless single_key_given?
43
+ models.compact!
44
+ end
45
+
46
+ models.each { |m| m&.run_callbacks :find }
47
+ models
48
+ end
49
+
50
+ private
51
+
52
+ def single_key_given?
53
+ @ids.size == 1 && !@ids[0].is_a?(Array)
54
+ end
55
+
56
+ def validate_primary_key!
57
+ if single_key_given?
58
+ partition_key = @ids[0]
59
+ sort_key = @options[:range_key]
60
+
61
+ raise Dynamoid::Errors::MissingHashKey if partition_key.nil?
62
+ raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key && sort_key.nil?
63
+ else
64
+ ids = @ids.flatten(1)
65
+
66
+ raise Dynamoid::Errors::MissingHashKey if ids.any? { |pk, _sk| pk.nil? }
67
+ raise Errors::MissingRangeKey if @model_class.range_key && ids.any? { |_pk, sk| sk.nil? }
68
+ end
69
+ end
70
+
71
+ def action_request_for_single_key
72
+ partition_key = @ids[0]
73
+
74
+ key = { @model_class.hash_key => cast_and_dump_attribute(@model_class.hash_key, partition_key) }
75
+
76
+ if @model_class.range_key
77
+ sort_key = @options[:range_key]
78
+ key[@model_class.range_key] = cast_and_dump_attribute(@model_class.range_key, sort_key)
79
+ end
80
+
81
+ {
82
+ get: {
83
+ key: key,
84
+ table_name: @model_class.table_name
85
+ }
86
+ }
87
+ end
88
+
89
+ def action_request_for_multiple_keys
90
+ @ids.flatten(1).map do |id|
91
+ if @model_class.range_key
92
+ # expect [hash-key, range-key] pair
93
+ pk, sk = id
94
+ pk_dumped = cast_and_dump_attribute(@model_class.hash_key, pk)
95
+ sk_dumped = cast_and_dump_attribute(@model_class.range_key, sk)
96
+
97
+ key = { @model_class.hash_key => pk_dumped, @model_class.range_key => sk_dumped }
98
+ else
99
+ pk_dumped = cast_and_dump_attribute(@model_class.hash_key, id)
100
+
101
+ key = { @model_class.hash_key => pk_dumped }
102
+ end
103
+
104
+ {
105
+ get: {
106
+ key: key,
107
+ table_name: @model_class.table_name
108
+ }
109
+ }
110
+ end
111
+ end
112
+
113
+ def cast_and_dump_attribute(name, value)
114
+ attribute_options = @model_class.attributes[name]
115
+ casted_value = TypeCasting.cast_field(value, attribute_options)
116
+ Dumping.dump_field(casted_value, attribute_options)
117
+ end
118
+
119
+ def build_record_not_found_exception_message(responses)
120
+ items = responses.map(&:item)
121
+ ids = @ids.flatten(1)
122
+
123
+ if single_key_given?
124
+ id = ids[0]
125
+ primary_key = @model_class.range_key ? "(#{id.inspect},#{@options[:range_key].inspect})" : id.inspect
126
+ message = "Couldn't find #{@model_class.name} with primary key #{primary_key}"
127
+ else
128
+ ids_list = @model_class.range_key ? ids.map { |pk, sk| "(#{pk.inspect},#{sk.inspect})" } : ids.map(&:inspect)
129
+ message = "Couldn't find all #{@model_class.name.pluralize} with primary keys [#{ids_list.join(', ')}] "
130
+ message += "(found #{items.compact.size} results, but was looking for #{items.size})"
131
+ end
132
+
133
+ message
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dynamoid/transaction_read/find'
4
+
5
+ module Dynamoid
6
+ # The class +TransactionRead+ provides means to perform multiple reading
7
+ # operations in transaction, that is atomically, so that either all of them
8
+ # succeed, or all of them fail.
9
+ #
10
+ # The reading methods are supposed to be as close as possible to their
11
+ # non-transactional counterparts:
12
+ #
13
+ # user_id = params[:user_id]
14
+ # payment = params[:payment_id]
15
+ #
16
+ # models = Dynamoid::TransactionRead.execute do |t|
17
+ # t.find User, user_id
18
+ # t.find Payment, payment_id
19
+ # end
20
+ #
21
+ # The only difference is that the methods are called on a transaction
22
+ # instance and a model or a model class should be specified. So +User.find+
23
+ # becomes +t.find(user_id)+ and +Payment.find(payment_id)+ becomes +t.find
24
+ # Payment, payment_id+.
25
+ #
26
+ # A transaction can be used without a block. This way a transaction instance
27
+ # should be instantiated and committed manually with +#commit+ method:
28
+ #
29
+ # t = Dynamoid::TransactionRead.new
30
+ #
31
+ # t.find user_id
32
+ # t.find payment_id
33
+ #
34
+ # models = t.commit
35
+ #
36
+ #
37
+ # ### DynamoDB's transactions
38
+ #
39
+ # The main difference between DynamoDB transactions and a common interface is
40
+ # that DynamoDB's transactions are executed in batch. So in Dynamoid no
41
+ # data actually loaded when some transactional method (e.g+ `#find+) is
42
+ # called. All the changes are loaded at the end.
43
+ #
44
+ # A +TransactGetItems+ DynamoDB operation is used (see
45
+ # [documentation](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html)
46
+ # for details).
47
+ class TransactionRead
48
+ def self.execute
49
+ transaction = new
50
+
51
+ begin
52
+ yield transaction
53
+ transaction.commit
54
+ end
55
+ end
56
+
57
+ def initialize
58
+ @actions = []
59
+ end
60
+
61
+ # Load all the models.
62
+ #
63
+ # transaction = Dynamoid::TransactionRead.new
64
+ # # ...
65
+ # transaction.commit
66
+ def commit
67
+ return [] if @actions.empty?
68
+
69
+ # some actions may produce multiple requests
70
+ action_request_groups = @actions.map(&:action_request).map do |action_request|
71
+ action_request.is_a?(Array) ? action_request : [action_request]
72
+ end
73
+ action_requests = action_request_groups.flatten(1)
74
+
75
+ return [] if action_requests.empty?
76
+
77
+ response = Dynamoid.adapter.transact_read_items(action_requests)
78
+
79
+ responses = response.responses.dup
80
+ @actions.zip(action_request_groups).map do |action, action_requests|
81
+ action_responses = responses.shift(action_requests.size)
82
+ action.process_responses(action_responses)
83
+ end.flatten
84
+ end
85
+
86
+ # Find one or many objects, specified by one id or an array of ids.
87
+ #
88
+ # By default it raises +RecordNotFound+ exception if at least one model
89
+ # isn't found. This behavior can be changed with +raise_error+ option. If
90
+ # specified +raise_error: false+ option then +find+ will not raise the
91
+ # exception.
92
+ #
93
+ # When a document schema includes range key it should always be specified
94
+ # in +find+ method call. In case it's missing +MissingRangeKey+ exception
95
+ # will be raised.
96
+ #
97
+ # Please note that there are the following differences between
98
+ # transactional and non-transactional +find+:
99
+ # - transactional +find+ preserves order of models in result when given multiple ids
100
+ # - transactional +find+ doesn't return results immediately, a single
101
+ # collection with results of all the +find+ calls is returned instead
102
+ # - +:consistent_read+ option isn't supported
103
+ # - transactional +find+ is subject to limitations of the
104
+ # [TransactGetItems](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html)
105
+ # DynamoDB operation, e.g. the whole read transaction can load only up to 100 models.
106
+ #
107
+ # @param [Object|Array] ids a single primary key or an array of primary keys
108
+ # @param [Hash] options optional parameters of the operation
109
+ # @option options [Object] :range_key sort key of a model; required when a single partition key is given and a sort key is declared for a model
110
+ # @option options [true|false] :raise_error whether to raise a +RecordNotFound+ exception; specify explicitly +raise_error: false+ to suppress the exception; default is +true+
111
+ # @return [nil]
112
+ #
113
+ # @example Find by partition key
114
+ # Dynamoid::TransactionRead.execute do |t|
115
+ # t.find Document, 101
116
+ # end
117
+ #
118
+ # @example Find by partition key and sort key
119
+ # Dynamoid::TransactionRead.execute do |t|
120
+ # t.find Document, 101, range_key: 'archived'
121
+ # end
122
+ #
123
+ # @example Find several documents by partition key
124
+ # Dynamoid::TransactionRead.execute do |t|
125
+ # t.find Document, 101, 102, 103
126
+ # t.find Document, [101, 102, 103]
127
+ # end
128
+ #
129
+ # @example Find several documents by partition key and sort key
130
+ # Dynamoid::TransactionRead.execute do |t|
131
+ # t.find Document, [[101, 'archived'], [102, 'new'], [103, 'deleted']]
132
+ # end
133
+ def find(model_class, *ids, **options)
134
+ action = Dynamoid::TransactionRead::Find.new(model_class, *ids, **options)
135
+ register_action action
136
+ end
137
+
138
+ private
139
+
140
+ def register_action(action)
141
+ @actions << action
142
+ action.on_registration
143
+ action.observable_by_user_result
144
+ end
145
+ end
146
+ end