dynamoid 3.13.0 → 3.14.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -4
  3. data/README.md +60 -1584
  4. data/lib/dynamoid/associations/association.rb +10 -2
  5. data/lib/dynamoid/associations/belongs_to.rb +3 -1
  6. data/lib/dynamoid/associations/has_and_belongs_to_many.rb +0 -1
  7. data/lib/dynamoid/associations/has_many.rb +0 -1
  8. data/lib/dynamoid/associations/has_one.rb +1 -2
  9. data/lib/dynamoid/associations/many_association.rb +1 -0
  10. data/lib/dynamoid/associations.rb +64 -48
  11. data/lib/dynamoid/components.rb +1 -0
  12. data/lib/dynamoid/criteria/chain.rb +43 -30
  13. data/lib/dynamoid/dirty.rb +13 -3
  14. data/lib/dynamoid/document.rb +29 -21
  15. data/lib/dynamoid/fields.rb +55 -41
  16. data/lib/dynamoid/finders.rb +1 -1
  17. data/lib/dynamoid/indexes.rb +29 -21
  18. data/lib/dynamoid/persistence/inc.rb +15 -7
  19. data/lib/dynamoid/persistence/save.rb +21 -14
  20. data/lib/dynamoid/persistence.rb +88 -54
  21. data/lib/dynamoid/transactions/mutation/base.rb +76 -0
  22. data/lib/dynamoid/transactions/mutation/builders/delete_request_builder.rb +44 -0
  23. data/lib/dynamoid/transactions/mutation/builders/update_request_builder.rb +139 -0
  24. data/lib/dynamoid/transactions/mutation/create.rb +52 -0
  25. data/lib/dynamoid/transactions/mutation/delete_with_instance.rb +80 -0
  26. data/lib/dynamoid/transactions/mutation/delete_with_primary_key.rb +62 -0
  27. data/lib/dynamoid/transactions/mutation/destroy.rb +96 -0
  28. data/lib/dynamoid/transactions/mutation/import.rb +84 -0
  29. data/lib/dynamoid/transactions/mutation/inc.rb +101 -0
  30. data/lib/dynamoid/transactions/mutation/increment.rb +63 -0
  31. data/lib/dynamoid/transactions/mutation/save.rb +194 -0
  32. data/lib/dynamoid/transactions/mutation/touch.rb +99 -0
  33. data/lib/dynamoid/transactions/mutation/update_attributes.rb +49 -0
  34. data/lib/dynamoid/transactions/mutation/update_fields.rb +185 -0
  35. data/lib/dynamoid/transactions/mutation/upsert.rb +94 -0
  36. data/lib/dynamoid/transactions/mutation.rb +935 -0
  37. data/lib/dynamoid/transactions/retrieval/find.rb +140 -0
  38. data/lib/dynamoid/transactions/retrieval.rb +150 -0
  39. data/lib/dynamoid/transactions.rb +63 -0
  40. data/lib/dynamoid/version.rb +1 -1
  41. data/lib/dynamoid.rb +1 -2
  42. metadata +23 -17
  43. data/lib/dynamoid/transaction_read/find.rb +0 -137
  44. data/lib/dynamoid/transaction_read.rb +0 -146
  45. data/lib/dynamoid/transaction_write/base.rb +0 -59
  46. data/lib/dynamoid/transaction_write/create.rb +0 -49
  47. data/lib/dynamoid/transaction_write/delete_with_instance.rb +0 -65
  48. data/lib/dynamoid/transaction_write/delete_with_primary_key.rb +0 -64
  49. data/lib/dynamoid/transaction_write/destroy.rb +0 -84
  50. data/lib/dynamoid/transaction_write/item_updater.rb +0 -60
  51. data/lib/dynamoid/transaction_write/save.rb +0 -177
  52. data/lib/dynamoid/transaction_write/update_attributes.rb +0 -46
  53. data/lib/dynamoid/transaction_write/update_fields.rb +0 -247
  54. data/lib/dynamoid/transaction_write/upsert.rb +0 -113
  55. data/lib/dynamoid/transaction_write.rb +0 -673
@@ -0,0 +1,935 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mutation/create'
4
+ require_relative 'mutation/delete_with_primary_key'
5
+ require_relative 'mutation/delete_with_instance'
6
+ require_relative 'mutation/destroy'
7
+ require_relative 'mutation/save'
8
+ require_relative 'mutation/update_fields'
9
+ require_relative 'mutation/update_attributes'
10
+ require_relative 'mutation/upsert'
11
+ require_relative 'mutation/inc'
12
+ require_relative 'mutation/touch'
13
+ require_relative 'mutation/increment'
14
+ require_relative 'mutation/import'
15
+
16
+ module Dynamoid
17
+ module Transactions
18
+ # The +Mutation+ class provides a way to perform multiple modifying operations
19
+ # atomically in a transaction—either all operations succeed, or all fail.
20
+ #
21
+ # The persistence methods are designed to mirror their non-transactional
22
+ # counterparts like +.create+, +#save+, and +#delete+:
23
+ #
24
+ # ```
25
+ # user = User.new(name: 'John')
26
+ # payment = Payment.find(1)
27
+ #
28
+ # Dynamoid::Transactions::Mutation.execute do |t|
29
+ # t.save! user
30
+ # t.create! Account, name: 'A'
31
+ # t.delete payment
32
+ # end
33
+ # ```
34
+ #
35
+ # The primary difference is that these methods are called on a transaction
36
+ # instance, and the model (or class) must be passed as an argument.
37
+ #
38
+ # For example, +user.save!+ becomes +t.save!(user)+, +Account.create!(name: 'A')+
39
+ # becomes +t.create!(Account, name: 'A')+, and +payment.delete+ becomes
40
+ # +t.delete(payment)+.
41
+ #
42
+ # Transactions can also be used without a block by manually instantiating
43
+ # and committing:
44
+ #
45
+ # t = Dynamoid::Transactions::Mutation.new
46
+ # t.save! user
47
+ # t.create! Account, name: 'A'
48
+ # t.delete payment
49
+ # t.commit
50
+ #
51
+ # Some persistence methods (like +.update+ and +.update!+) are intentionally
52
+ # unavailable because they perform multiple underlying operations and cannot
53
+ # be implemented atomically.
54
+ #
55
+ # ### DynamoDB Transactions
56
+ #
57
+ # Unlike many databases, DynamoDB transactions are executed in batch.
58
+ # In Dynamoid, no changes are persisted when a method like +#save+ is called.
59
+ # All changes are queued and sent to DynamoDB at the end of the transaction.
60
+ #
61
+ # This uses the DynamoDB +TransactWriteItems+ operation (see
62
+ # [documentation](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html)).
63
+ #
64
+ # ### Callbacks
65
+ #
66
+ # Transactional methods support +before_+, +after_+, and +around_+ callbacks
67
+ # to the same extent as their non-transactional counterparts.
68
+ #
69
+ # **Important difference:** Callbacks (even +after_+ ones) run immediately
70
+ # when the method is called, *before* changes are actually persisted to
71
+ # DynamoDB. Consequently, code in an +after_+ callback will not yet see the
72
+ # updated data in DynamoDB.
73
+ #
74
+ # If a callback aborts the operation or a model is invalid, that specific
75
+ # action is skipped, but the transaction itself may still commit successfully.
76
+ #
77
+ # ### Transaction Rollback
78
+ #
79
+ # A transaction is automatically rolled back on the DynamoDB side if:
80
+ # - Another operation is currently updating the same item.
81
+ # - Provisioned capacity is insufficient.
82
+ # - Item or transaction size limits are exceeded (e.g., item > 400 KB or total > 4 MB).
83
+ # - There is a user error (e.g., invalid data format).
84
+ #
85
+ # Since no changes are persisted until the +#commit+ call, a transaction can
86
+ # be aborted by simply raising an exception within the block.
87
+ #
88
+ # Raising +Dynamoid::Errors::Rollback+ will interrupt the transaction without
89
+ # propagating the exception further:
90
+ #
91
+ # ```
92
+ # Dynamoid::Transactions::Mutation.execute do |t|
93
+ # t.save! user
94
+ # t.create! Account, name: 'A'
95
+ #
96
+ # raise Dynamoid::Errors::Rollback if user.is_admin?
97
+ # end
98
+ # ```
99
+ #
100
+ # When a transaction is successfully committed or rolled back, the corresponding
101
+ # +#after_commit+ or +#after_rollback+ callbacks are run for each involved model.
102
+ class Mutation
103
+ def self.execute
104
+ transaction = new
105
+
106
+ begin
107
+ yield transaction
108
+ rescue StandardError => e
109
+ transaction.rollback
110
+
111
+ unless e.is_a?(Dynamoid::Errors::Rollback)
112
+ raise e
113
+ end
114
+ else
115
+ transaction.commit
116
+ end
117
+ end
118
+
119
+ def initialize
120
+ @actions = []
121
+ end
122
+
123
+ # Persist all the changes.
124
+ #
125
+ # transaction = Dynamoid::Transactions::Mutation.new
126
+ # # ...
127
+ # transaction.commit
128
+ def commit
129
+ actions_to_commit = @actions.reject(&:aborted?).reject(&:skipped?)
130
+ return if actions_to_commit.empty?
131
+
132
+ action_requests = actions_to_commit.flat_map(&:action_requests)
133
+ Dynamoid.adapter.transact_write_items(action_requests)
134
+ actions_to_commit.each(&:on_commit)
135
+
136
+ nil
137
+ rescue Aws::Errors::ServiceError
138
+ run_on_rollback_callbacks
139
+ raise
140
+ end
141
+
142
+ def rollback
143
+ run_on_rollback_callbacks
144
+ end
145
+
146
+ # Update the +updated_at+ timestamp and optionally other specified
147
+ # attributes.
148
+ #
149
+ # Runs callbacks.
150
+ #
151
+ # ```
152
+ # Dynamoid::Transactions::Mutation.execute do |t|
153
+ # t.touch(user)
154
+ # end
155
+ # ```
156
+ #
157
+ # If attribute names are passed, they are updated along with updated_at
158
+ # attribute:
159
+ #
160
+ # t.touch(user, :viewed_at)
161
+ # t.touch(user, :viewed_at, :accessed_at)
162
+ #
163
+ # t.touch(user, time: 2.days.ago)
164
+ #
165
+ # Raises +Dynamoid::Errors::Error+ if a model is new or destroyed.
166
+ #
167
+ # @param model [Dynamoid::Document] a model
168
+ # @param names [Array<Symbol>] (optional) attribute names to update
169
+ # @param time [Time] datetime value that can be used instead of the current time (optional)
170
+ # @return [Dynamoid::Document] self
171
+ def touch(model, *names, time: nil)
172
+ action = Touch.new(model, *names, time: time)
173
+ register_action action
174
+ end
175
+
176
+ # Create new model or persist changes in already existing one.
177
+ #
178
+ # Run the validation and callbacks. Returns +true+ if saving is successful
179
+ # and +false+ otherwise.
180
+ #
181
+ # ```
182
+ # user = User.new
183
+ #
184
+ # Dynamoid::Transactions::Mutation.execute do |t|
185
+ # t.save!(user)
186
+ # end
187
+ # ```
188
+ #
189
+ # Validation can be skipped with +validate: false+ option:
190
+ #
191
+ # ```
192
+ # user = User.new(age: -1)
193
+ #
194
+ # Dynamoid::Transactions::Mutation.execute do |t|
195
+ # t.save!(user, validate: false)
196
+ # end
197
+ # ```
198
+ #
199
+ # +save!+ by default sets timestamps attributes - +created_at+ and
200
+ # +updated_at+ when creates new model and updates +updated_at+ attribute
201
+ # when updates already existing one.
202
+ #
203
+ # If a model is new and hash key (+id+ by default) is not assigned yet
204
+ # it was assigned implicitly with random UUID value.
205
+ #
206
+ # When a model is not persisted - its id should have unique value.
207
+ # Otherwise a transaction will be rolled back.
208
+ #
209
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a model is already persisted
210
+ # and a partition key has value +nil+ and raises
211
+ # +Dynamoid::Errors::MissingRangeKey+ if a sort key is required but has
212
+ # value +nil+.
213
+ #
214
+ # There are the following differences between transactional and
215
+ # non-transactional +#save!+:
216
+ # - transactional +#save!+ doesn't raise +Dynamoid::Errors::StaleObjectError+
217
+ # when optimistic concurrency control detects a conflict. A generic
218
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
219
+ # - transactional +#save!+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
220
+ # at saving new model when primary key is already used. A generic
221
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
222
+ # - transactional +save!+ doesn't raise
223
+ # +Dynamoid::Errors::StaleObjectError+ when a model that is being updated
224
+ # was concurrently deleted
225
+ # - a table isn't created lazily if it doesn't exist yet
226
+ #
227
+ # @param model [Dynamoid::Document] a model
228
+ # @param options [Hash] (optional)
229
+ # @option options [true|false] :validate validate a model or not - +true+ by default (optional)
230
+ # @option options [true|false] :touch update tiemstamps fields or not - +true+ by default (optional)
231
+ # @return [true|false] Whether saving successful or not
232
+ def save!(model, **options)
233
+ action = Save.new(model, **options, raise_error: true)
234
+ register_action action
235
+ end
236
+
237
+ # Create new model or persist changes in already existing one.
238
+ #
239
+ # Run the validation and callbacks. Raise
240
+ # +Dynamoid::Errors::DocumentNotValid+ unless this object is valid.
241
+ #
242
+ # ```
243
+ # user = User.new
244
+ #
245
+ # Dynamoid::Transactions::Mutation.execute do |t|
246
+ # t.save(user)
247
+ # end
248
+ # ```
249
+ #
250
+ # Validation can be skipped with +validate: false+ option:
251
+ #
252
+ # ```
253
+ # user = User.new(age: -1)
254
+ #
255
+ # Dynamoid::Transactions::Mutation.execute do |t|
256
+ # t.save(user, validate: false)
257
+ # end
258
+ # ```
259
+ #
260
+ # +save+ by default sets timestamps attributes - +created_at+ and
261
+ # +updated_at+ when creates new model and updates +updated_at+ attribute
262
+ # when updates already existing one.
263
+ #
264
+ # If a model is new and hash key (+id+ by default) is not assigned yet
265
+ # it was assigned implicitly with random UUID value.
266
+ #
267
+ # When a model is not persisted - its id should have unique value.
268
+ # Otherwise a transaction will be rolled back.
269
+ #
270
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a model is already persisted
271
+ # and a partition key has value +nil+ and raises
272
+ # +Dynamoid::Errors::MissingRangeKey+ if a sort key is required but has
273
+ # value +nil+.
274
+ #
275
+ # There are the following differences between transactional and
276
+ # non-transactional +#save+:
277
+ # - transactional +#save+ doesn't raise +Dynamoid::Errors::StaleObjectError+
278
+ # when optimistic concurrency control detects a conflict. A generic
279
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
280
+ # - transactional +#save+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
281
+ # at saving new model when primary key is already used. A generic
282
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
283
+ # - transactional +save+ doesn't raise +Dynamoid::Errors::StaleObjectError+
284
+ # when a model that is being updated was concurrently deleted
285
+ # - a table isn't created lazily if it doesn't exist yet
286
+ #
287
+ # @param model [Dynamoid::Document] a model
288
+ # @param options [Hash] (optional)
289
+ # @option options [true|false] :validate validate a model or not - +true+ by default (optional)
290
+ # @option options [true|false] :touch update tiemstamps fields or not - +true+ by default (optional)
291
+ # @return [true|false] Whether saving successful or not
292
+ def save(model, **options)
293
+ action = Save.new(model, **options, raise_error: false)
294
+ register_action action
295
+ end
296
+
297
+ # Create a model.
298
+ #
299
+ # ```
300
+ # Dynamoid::Transactions::Mutation.execute do |t|
301
+ # t.create!(User, name: 'A')
302
+ # end
303
+ # ```
304
+ #
305
+ # Accepts both Hash and Array of Hashes and can create several models.
306
+ #
307
+ # ```
308
+ # Dynamoid::Transactions::Mutation.execute do |t|
309
+ # t.create!(User, [{name: 'A'}, {name: 'B'}, {name: 'C'}])
310
+ # end
311
+ # ```
312
+ #
313
+ # Instantiates a model and pass it into an optional block to set other
314
+ # attributes.
315
+ #
316
+ # ```
317
+ # Dynamoid::Transactions::Mutation.execute do |t|
318
+ # t.create!(User, name: 'A') do |user|
319
+ # user.initialize_roles
320
+ # end
321
+ # end
322
+ # ```
323
+ #
324
+ # Validates model and runs callbacks.
325
+ #
326
+ # Raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is required but
327
+ # not specified or has value +nil+.
328
+ #
329
+ # There are the following differences between transactional and
330
+ # non-transactional +#create+:
331
+ # - transactional +#create!+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
332
+ # at saving new model when primary key is already used. A generic
333
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
334
+ # - a table isn't created lazily if it doesn't exist yet
335
+ #
336
+ # @param model_class [Class] a model class which should be instantiated
337
+ # @param attributes [Hash|Array<Hash>] attributes of a model
338
+ # @param block [Proc] a block to process a model after initialization
339
+ # @return [Dynamoid::Document] a model that was instantiated but not yet persisted
340
+ def create!(model_class, attributes = {}, &block)
341
+ if attributes.is_a? Array
342
+ attributes.map do |attr|
343
+ action = Create.new(model_class, attr, raise_error: true, &block)
344
+ register_action action
345
+ end
346
+ else
347
+ action = Create.new(model_class, attributes, raise_error: true, &block)
348
+ register_action action
349
+ end
350
+ end
351
+
352
+ # Create a model.
353
+ #
354
+ # ```
355
+ # Dynamoid::Transactions::Mutation.execute do |t|
356
+ # t.create(User, name: 'A')
357
+ # end
358
+ # ```
359
+ #
360
+ # Accepts both Hash and Array of Hashes and can create several models.
361
+ #
362
+ # ```
363
+ # Dynamoid::Transactions::Mutation.execute do |t|
364
+ # t.create(User, [{name: 'A'}, {name: 'B'}, {name: 'C'}])
365
+ # end
366
+ # ```
367
+ #
368
+ # Instantiates a model and pass it into an optional block to set other
369
+ # attributes.
370
+ #
371
+ # ```
372
+ # Dynamoid::Transactions::Mutation.execute do |t|
373
+ # t.create(User, name: 'A') do |user|
374
+ # user.initialize_roles
375
+ # end
376
+ # end
377
+ # ```
378
+ #
379
+ # Validates model and runs callbacks.
380
+ #
381
+ # Raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is required but
382
+ # not specified or has value +nil+.
383
+ #
384
+ # There are the following differences between transactional and
385
+ # non-transactional +#create+:
386
+ # - transactional +#create+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
387
+ # at saving new model when primary key is already used. A generic
388
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
389
+ # - a table isn't created lazily if it doesn't exist yet
390
+ #
391
+ # @param model_class [Class] a model class which should be instantiated
392
+ # @param attributes [Hash|Array<Hash>] attributes of a model
393
+ # @param block [Proc] a block to process a model after initialization
394
+ # @return [Dynamoid::Document] a model that was instantiated but not yet persisted
395
+ def create(model_class, attributes = {}, &block)
396
+ if attributes.is_a? Array
397
+ attributes.map do |attr|
398
+ action = Create.new(model_class, attr, raise_error: false, &block)
399
+ register_action action
400
+ end
401
+ else
402
+ action = Create.new(model_class, attributes, raise_error: false, &block)
403
+ register_action action
404
+ end
405
+ end
406
+
407
+ # Update an existing document or create a new one.
408
+ #
409
+ # If a document with specified hash and range keys doesn't exist it
410
+ # creates a new document with specified attributes. Doesn't run
411
+ # validations and callbacks.
412
+ #
413
+ # ```
414
+ # Dynamoid::Transactions::Mutation.execute do |t|
415
+ # t.upsert(User, '1', age: 26)
416
+ # end
417
+ # ```
418
+ #
419
+ # If range key is declared for a model it should be passed as well:
420
+ #
421
+ # ```
422
+ # Dynamoid::Transactions::Mutation.execute do |t|
423
+ # t.upsert(User, '1', 'Tylor', age: 26)
424
+ # end
425
+ # ```
426
+ #
427
+ # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
428
+ # attributes is not declared in the model class.
429
+ #
430
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
431
+ # +nil+ and +Dynamoid::Errors::MissingRangeKey+ if a sort key is required
432
+ # but has value +nil+.
433
+ #
434
+ # There are the following differences between transactional and
435
+ # non-transactional +#upsert+:
436
+ # - transactional +#upsert+ doesn't support conditions (that's +if+ and
437
+ # +unless_exists+ options)
438
+ # - transactional +#upsert+ doesn't return a document that was updated or
439
+ # created
440
+ #
441
+ # @param model_class [Class] a model class
442
+ # @param hash_key [Scalar value] hash key value
443
+ # @param range_key [Scalar value] range key value (optional)
444
+ # @param attributes [Hash]
445
+ # @return [nil]
446
+ def upsert(model_class, hash_key, range_key = nil, attributes) # rubocop:disable Style/OptionalArguments
447
+ action = Upsert.new(model_class, hash_key, range_key, attributes)
448
+ register_action action
449
+ end
450
+
451
+ # Update document.
452
+ #
453
+ # Doesn't run validations and callbacks.
454
+ #
455
+ # ```
456
+ # Dynamoid::Transactions::Mutation.execute do |t|
457
+ # t.update_fields(User, '1', age: 26)
458
+ # end
459
+ # ```
460
+ #
461
+ # If range key is declared for a model it should be passed as well:
462
+ #
463
+ # ```
464
+ # Dynamoid::Transactions::Mutation.execute do |t|
465
+ # t.update_fields(User, '1', 'Tylor', age: 26)
466
+ # end
467
+ # ```
468
+ #
469
+ # Updates can also be performed in a block.
470
+ #
471
+ # ```
472
+ # Dynamoid::Transactions::Mutation.execute do |t|
473
+ # t.update_fields(User, 1) do |u|
474
+ # u.add(article_count: 1)
475
+ # u.delete(favorite_colors: 'green')
476
+ # u.set(age: 27, last_name: 'Tylor')
477
+ # end
478
+ # end
479
+ # ```
480
+ #
481
+ # Operation +add+ just adds a value for numeric attributes and join
482
+ # collections if attribute is a set.
483
+ #
484
+ # ```
485
+ # t.update_fields(User, 1) do |u|
486
+ # u.add(age: 1, followers_count: 5)
487
+ # u.add(hobbies: ['skying', 'climbing'])
488
+ # end
489
+ # ```
490
+ #
491
+ # Operation +delete+ is applied to collection attribute types and
492
+ # substructs one collection from another.
493
+ #
494
+ # ```
495
+ # t.update_fields(User, 1) do |u|
496
+ # u.delete(hobbies: ['skying'])
497
+ # end
498
+ # ```
499
+ #
500
+ # Operation +set+ just changes an attribute value:
501
+ #
502
+ # ```
503
+ # t.update_fields(User, 1) do |u|
504
+ # u.set(age: 21)
505
+ # end
506
+ # ```
507
+ #
508
+ # Operation +remove+ removes one or more attributes from an item.
509
+ #
510
+ # ```
511
+ # t.update_fields(User, 1) do |u|
512
+ # u.remove(:age)
513
+ # end
514
+ # ```
515
+ #
516
+ # All the operations work like +ADD+, +DELETE+, +REMOVE+, and +SET+ actions supported
517
+ # by +UpdateExpression+
518
+ # {parameter}[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.UpdateExpressions.html]
519
+ # of +UpdateItem+ operation.
520
+ #
521
+ # It's atomic operations. So adding or deleting elements in a collection
522
+ # or incrementing or decrementing a numeric field is atomic and does not
523
+ # interfere with other write requests.
524
+ #
525
+ # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
526
+ # attributes is not declared in the model class.
527
+ #
528
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
529
+ # +nil+ and +Dynamoid::Errors::MissingRangeKey+ if a sort key is required
530
+ # but has value +nil+.
531
+ #
532
+ # There are the following differences between transactional and
533
+ # non-transactional +#update_fields+:
534
+ # - transactional +#update_fields+ doesn't support conditions (that's +if+
535
+ # and +unless_exists+ options)
536
+ # - transactional +#update_fields+ doesn't return a document that was
537
+ # updated or created
538
+ #
539
+ # @param model_class [Class] a model class
540
+ # @param hash_key [Scalar value] hash key value
541
+ # @param range_key [Scalar value] range key value (optional)
542
+ # @param attributes [Hash]
543
+ # @return [nil]
544
+ def update_fields(model_class, hash_key, range_key = nil, attributes = nil, &block)
545
+ # given no attributes, but there may be a block
546
+ if range_key.is_a?(Hash) && !attributes
547
+ attributes = range_key
548
+ range_key = nil
549
+ end
550
+
551
+ action = UpdateFields.new(model_class, hash_key, range_key, attributes, &block)
552
+ register_action action
553
+ end
554
+
555
+ # Increment numeric attributes.
556
+ #
557
+ # Doesn't run validations and callbacks.
558
+ #
559
+ # ```
560
+ # Dynamoid::Transactions::Mutation.execute do |t|
561
+ # t.inc(User, '1', age: 1)
562
+ # end
563
+ # ```
564
+ #
565
+ # If range key is declared for a model it should be passed as well:
566
+ #
567
+ # ```
568
+ # Dynamoid::Transactions::Mutation.execute do |t|
569
+ # t.inc(User, '1', 'Tylor', age: 1)
570
+ # end
571
+ # ```
572
+ #
573
+ # It also supports +touch+ option to update +updated_at+ attribute and
574
+ # optionally other specified attributes.
575
+ #
576
+ # ```
577
+ # Dynamoid::Transactions::Mutation.execute do |t|
578
+ # t.inc(User, '1', age: 1, touch: true)
579
+ # end
580
+ # ```
581
+ #
582
+ # If attribute names are passed, they are updated along with updated_at
583
+ # attribute:
584
+ #
585
+ # t.inc(User, '1', age: 2, touch: :viewed_at)
586
+ # t.inc(User, '1', age: 2, touch: [:viewed_at, :accessed_at])
587
+ #
588
+ # It's an atomic operation. So incrementing or decrementing a numeric
589
+ # field is atomic and does not interfere with other write requests.
590
+ #
591
+ # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
592
+ # attributes is not declared in the model class.
593
+ #
594
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
595
+ # +nil+ and +Dynamoid::Errors::MissingRangeKey+ if a sort key is required
596
+ # but has value +nil+.
597
+ #
598
+ # @param model_class [Class] a model class
599
+ # @param hash_key [Scalar value] hash key value
600
+ # @param range_key [Scalar value] range key value (optional)
601
+ # @param counters [Hash]
602
+ # @return [nil]
603
+ def inc(model_class, hash_key, range_key = nil, counters = nil)
604
+ if range_key.is_a?(Hash) && !counters
605
+ counters = range_key
606
+ range_key = nil
607
+ end
608
+
609
+ action = Inc.new(model_class, hash_key, range_key, counters)
610
+ register_action action
611
+ end
612
+
613
+ # Change numeric attribute value and save a model.
614
+ #
615
+ # Initializes attribute to zero if +nil+ and adds the specified value (by
616
+ # default is 1). Only makes sense for number-based attributes.
617
+ #
618
+ # ```
619
+ # Dynamoid::Transactions::Mutation.execute do |t|
620
+ # t.increment!(user, :followers_count)
621
+ # t.increment!(user, :followers_count, 2)
622
+ # end
623
+ # ```
624
+ #
625
+ # Only `attribute` is saved. The model itself is not saved. So any other
626
+ # modified attributes will still be dirty. Validations and callbacks are
627
+ # skipped.
628
+ #
629
+ # When `:touch` option is passed the timestamp columns are updating. If
630
+ # attribute names are passed, they are updated along with updated_at
631
+ # attribute:
632
+ #
633
+ # t.increment!(user, :followers_count, touch: true)
634
+ # t.increment!(user, :followers_count, touch: :viewed_at)
635
+ # t.increment!(user, :followers_count, touch: [:viewed_at, :accessed_at])
636
+ #
637
+ # @param model [Dynamoid::Document] a model
638
+ # @param attribute [Symbol] attribute name
639
+ # @param by [Numeric] value to add (optional)
640
+ # @param touch [true | Symbol | Array<Symbol>] to update update_at attribute and optionally the specified ones
641
+ # @return [Dynamoid::Document] self
642
+ def increment!(model, attribute, by = 1, touch: nil)
643
+ action = Increment.new(model, attribute, by, touch: touch)
644
+ register_action action
645
+ end
646
+
647
+ # Change numeric attribute value and save a model.
648
+ #
649
+ # Initializes attribute to zero if +nil+ and subtracts the specified value
650
+ # (by default is 1). Only makes sense for number-based attributes.
651
+ #
652
+ # Runs callbacks.
653
+ #
654
+ # ```
655
+ # Dynamoid::Transactions::Mutation.execute do |t|
656
+ # t.decrement!(user, :followers_count)
657
+ # t.decrement!(user, :followers_count, 2)
658
+ # end
659
+ # ```
660
+ #
661
+ # Only `attribute` is saved. The model itself is not saved. So any other
662
+ # modified attributes will still be dirty. Validations are skipped.
663
+ #
664
+ # When `:touch` option is passed the timestamp columns are updating. If
665
+ # attribute names are passed, they are updated along with updated_at
666
+ # attribute:
667
+ #
668
+ # t.decrement!(user, :followers_count, touch: true)
669
+ # t.decrement!(user, :followers_count, touch: :viewed_at)
670
+ # t.decrement!(user, :followers_count, touch: [:viewed_at, :accessed_at])
671
+ #
672
+ # @param model [Dynamoid::Document] a model
673
+ # @param attribute [Symbol] attribute name
674
+ # @param by [Numeric] value to subtract (optional)
675
+ # @param touch [true | Symbol | Array<Symbol>] to update update_at attribute and optionally the specified ones
676
+ # @return [Dynamoid::Document] self
677
+ def decrement!(model, attribute, by = 1, touch: nil)
678
+ increment!(model, attribute, -by, touch: touch)
679
+ end
680
+
681
+ # Update multiple attributes at once.
682
+ #
683
+ # ```
684
+ # Dynamoid::Transactions::Mutation.execute do |t|
685
+ # t.update_attributes(user, age: 27, last_name: 'Tylor')
686
+ # end
687
+ # ```
688
+ #
689
+ # Returns +true+ if saving is successful and +false+
690
+ # otherwise.
691
+ #
692
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
693
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
694
+ # required but has value +nil+.
695
+ #
696
+ # There are the following differences between transactional and
697
+ # non-transactional +#update_attributes+:
698
+ # - transactional +#update_attributes+ doesn't raise
699
+ # +Dynamoid::Errors::StaleObjectError+ when optimistic concurrency
700
+ # control detects a conflict. A generic
701
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised
702
+ # instead.
703
+ # - transactional +update_attributes+ doesn't raise
704
+ # +Dynamoid::Errors::StaleObjectError+ when a model that is being updated
705
+ # was concurrently deleted
706
+ # - a table isn't created lazily if it doesn't exist yet
707
+ #
708
+ # @param model [Dynamoid::Document] a model
709
+ # @param attributes [Hash] a hash of attributes to update
710
+ # @return [true|false] Whether updating successful or not
711
+ def update_attributes(model, attributes)
712
+ action = UpdateAttributes.new(model, attributes, raise_error: false)
713
+ register_action action
714
+ end
715
+
716
+ # Update multiple attributes at once.
717
+ #
718
+ # Returns +true+ if saving is successful and +false+
719
+ # otherwise.
720
+ #
721
+ # ```
722
+ # Dynamoid::Transactions::Mutation.execute do |t|
723
+ # t.update_attributes(user, age: 27, last_name: 'Tylor')
724
+ # end
725
+ # ```
726
+ #
727
+ # Raises a +Dynamoid::Errors::DocumentNotValid+ exception if some vaidation
728
+ # fails.
729
+ #
730
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
731
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
732
+ # required but has value +nil+.
733
+ #
734
+ # There are the following differences between transactional and
735
+ # non-transactional +#update_attributes!+:
736
+ # - transactional +#update_attributes!+ doesn't raise
737
+ # +Dynamoid::Errors::StaleObjectError+ when optimistic concurrency
738
+ # control detects a conflict. A generic
739
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised
740
+ # instead.
741
+ # - transactional +update_attributes!+ doesn't raise
742
+ # +Dynamoid::Errors::StaleObjectError+ when a model that is being updated
743
+ # was concurrently deleted
744
+ # - a table isn't created lazily if it doesn't exist yet
745
+ #
746
+ # @param model [Dynamoid::Document] a model
747
+ # @param attributes [Hash] a hash of attributes to update
748
+ def update_attributes!(model, attributes)
749
+ action = UpdateAttributes.new(model, attributes, raise_error: true)
750
+ register_action action
751
+ end
752
+
753
+ # Update a single attribute, saving the object afterwards.
754
+ #
755
+ # ```
756
+ # Dynamoid::Transactions::Mutation.execute do |t|
757
+ # t.update_attribute(user, :last_name, 'Tylor')
758
+ # end
759
+ # ```
760
+ #
761
+ # Validation is skipped.
762
+ #
763
+ # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
764
+ # attributes is not on the model
765
+ #
766
+ # @param model [Dynamoid::Document] a model
767
+ # @param attribute [Symbol] attribute name to update
768
+ # @param value [Object] the value to assign it
769
+ # @return [Dynamoid::Document] self
770
+ def update_attribute(model, attribute, value)
771
+ model.write_attribute(attribute, value)
772
+ save(model, validate: false)
773
+ end
774
+
775
+ # Update a single attribute, saving the object afterwards.
776
+ #
777
+ # ```
778
+ # Dynamoid::Transactions::Mutation.execute do |t|
779
+ # t.update_attribute!(user, :last_name, 'Tylor')
780
+ # end
781
+ # ```
782
+ #
783
+ # Validation is skipped.
784
+ #
785
+ # If any of the `before_*` callbacks throws `:abort` the action is
786
+ # cancelled and `update_attribute!` raises
787
+ # Dynamoid::Errors::RecordNotSaved.
788
+ #
789
+ # Raises a +Dynamoid::Errors::UnknownAttribute+ exception if any of the
790
+ # attributes is not on the model
791
+ #
792
+ # @param model [Dynamoid::Document] a model
793
+ # @param attribute [Symbol] attribute name to update
794
+ # @param value [Object] the value to assign it
795
+ # @return [Dynamoid::Document] self
796
+ def update_attribute!(model, attribute, value)
797
+ model.write_attribute(attribute, value)
798
+ save!(model, validate: false)
799
+ model
800
+ end
801
+
802
+ # Delete a model.
803
+ #
804
+ # Can be called either with a model:
805
+ #
806
+ # ```
807
+ # Dynamoid::Transactions::Mutation.execute do |t|
808
+ # t.delete(user)
809
+ # end
810
+ # ```
811
+ #
812
+ # or with a primary key:
813
+ #
814
+ # ```
815
+ # Dynamoid::Transactions::Mutation.execute do |t|
816
+ # t.delete(User, user_id)
817
+ # end
818
+ # ```
819
+ #
820
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
821
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
822
+ # required but has value +nil+.
823
+ #
824
+ # There are the following differences between transactional and
825
+ # non-transactional +#delete+: TBD
826
+ # - transactional +#delete+ doesn't raise
827
+ # +Dynamoid::Errors::StaleObjectError+ when optimistic concurrency
828
+ # control detects a conflict. A generic
829
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised
830
+ # instead.
831
+ # - transactional +#delete+ doesn't disassociate a model from associated ones
832
+ # if there is any
833
+ #
834
+ # @param model_or_model_class [Class|Dynamoid::Document] either model or model class
835
+ # @param hash_key [Scalar value] hash key value
836
+ # @param range_key [Scalar value] range key value (optional)
837
+ # @return [Dynamoid::Document] self
838
+ def delete(model_or_model_class, hash_key = nil, range_key = nil)
839
+ action = if model_or_model_class.is_a? Class
840
+ DeleteWithPrimaryKey.new(model_or_model_class, hash_key, range_key)
841
+ else
842
+ DeleteWithInstance.new(model_or_model_class)
843
+ end
844
+ register_action action
845
+ end
846
+
847
+ # Delete a model.
848
+ #
849
+ # Runs callbacks.
850
+ #
851
+ # Raises +Dynamoid::Errors::RecordNotDestroyed+ exception if model deleting
852
+ # failed (e.g. aborted by a callback).
853
+ #
854
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
855
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
856
+ # required but has value +nil+.
857
+ #
858
+ # There are the following differences between transactional and
859
+ # non-transactional +#destroy!+:
860
+ # - transactional +#destroy!+ doesn't raise
861
+ # +Dynamoid::Errors::StaleObjectError+ when optimistic concurrency
862
+ # control detects a conflict. A generic
863
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised
864
+ # instead.
865
+ # - transactional +#destroy!+ doesn't disassociate a model from associated ones
866
+ # if there are association declared in the model class
867
+ #
868
+ # @param model [Dynamoid::Document] a model
869
+ # @return [Dynamoid::Document|false] returns self if destoying is succefull, +false+ otherwise
870
+ def destroy!(model)
871
+ action = Destroy.new(model, raise_error: true)
872
+ register_action action
873
+ end
874
+
875
+ # Delete a model.
876
+ #
877
+ # Runs callbacks.
878
+ #
879
+ # Raises +Dynamoid::Errors::MissingHashKey+ if a partition key has value
880
+ # +nil+ and raises +Dynamoid::Errors::MissingRangeKey+ if a sort key is
881
+ # required but has value +nil+.
882
+ #
883
+ # There are the following differences between transactional and
884
+ # non-transactional +#destroy+:
885
+ # - transactional +#destroy+ doesn't raise
886
+ # +Dynamoid::Errors::StaleObjectError+ when optimistic concurrency
887
+ # control detects a conflict. A generic
888
+ # +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised
889
+ # instead.
890
+ # - transactional +#destroy+ doesn't disassociate a model from associated ones
891
+ # if there are association declared in the model class
892
+ #
893
+ # @param model [Dynamoid::Document] a model
894
+ # @return [Dynamoid::Document] self
895
+ def destroy(model)
896
+ action = Destroy.new(model, raise_error: false)
897
+ register_action action
898
+ end
899
+
900
+ # Create multiple models from an array of attribute hashes.
901
+ #
902
+ # Validations and callbacks are skipped.
903
+ #
904
+ # ```
905
+ # Dynamoid::Transactions::Mutation.execute do |t|
906
+ # t.import(User, [{ name: 'A' }, { name: 'B' }])
907
+ # end
908
+ # ```
909
+ #
910
+ # Since DynamoDB limits the total number of actions per transaction,
911
+ # each model created via +#import+ consumes one action from this limit.
912
+ #
913
+ # @param model_class [Class] a model class
914
+ # @param array_of_attributes [Array<Hash>] attributes of models
915
+ # @return [Array<Dynamoid::Document>] created models
916
+ def import(model_class, array_of_attributes)
917
+ action = Import.new(model_class, array_of_attributes)
918
+ register_action action
919
+ end
920
+
921
+ private
922
+
923
+ def register_action(action)
924
+ @actions << action
925
+ action.on_registration
926
+ action.observable_by_user_result
927
+ end
928
+
929
+ def run_on_rollback_callbacks
930
+ actions_to_commit = @actions.reject(&:aborted?).reject(&:skipped?)
931
+ actions_to_commit.each(&:on_rollback)
932
+ end
933
+ end
934
+ end
935
+ end