dynamoid 3.11.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.
@@ -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
@@ -35,10 +35,10 @@ module Dynamoid
35
35
  end
36
36
 
37
37
  def action_request
38
- key = { @model_class.hash_key => @model.hash_key }
38
+ key = { @model_class.hash_key => dump_attribute(@model_class.hash_key, @model.hash_key) }
39
39
 
40
40
  if @model_class.range_key?
41
- key[@model_class.range_key] = @model.range_value
41
+ key[@model_class.range_key] = dump_attribute(@model_class.range_key, @model.range_value)
42
42
  end
43
43
 
44
44
  {
@@ -55,6 +55,11 @@ module Dynamoid
55
55
  raise Dynamoid::Errors::MissingHashKey if @model.hash_key.nil?
56
56
  raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @model.range_value.nil?
57
57
  end
58
+
59
+ def dump_attribute(name, value)
60
+ options = @model_class.attributes[name]
61
+ Dumping.dump_field(value, options)
62
+ end
58
63
  end
59
64
  end
60
65
  end
@@ -34,10 +34,10 @@ module Dynamoid
34
34
  end
35
35
 
36
36
  def action_request
37
- key = { @model_class.hash_key => @hash_key }
37
+ key = { @model_class.hash_key => dump_attribute(@model_class.hash_key, @hash_key) }
38
38
 
39
39
  if @model_class.range_key?
40
- key[@model_class.range_key] = @range_key
40
+ key[@model_class.range_key] = dump_attribute(@model_class.range_key, @range_key)
41
41
  end
42
42
 
43
43
  {
@@ -54,6 +54,11 @@ module Dynamoid
54
54
  raise Dynamoid::Errors::MissingHashKey if @hash_key.nil?
55
55
  raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @range_key.nil?
56
56
  end
57
+
58
+ def dump_attribute(name, value)
59
+ options = @model_class.attributes[name]
60
+ Dumping.dump_field(value, options)
61
+ end
57
62
  end
58
63
  end
59
64
  end
@@ -54,10 +54,10 @@ module Dynamoid
54
54
  end
55
55
 
56
56
  def action_request
57
- key = { @model_class.hash_key => @model.hash_key }
57
+ key = { @model_class.hash_key => dump_attribute(@model_class.hash_key, @model.hash_key) }
58
58
 
59
59
  if @model_class.range_key?
60
- key[@model_class.range_key] = @model.range_value
60
+ key[@model_class.range_key] = dump_attribute(@model_class.range_key, @model.range_value)
61
61
  end
62
62
 
63
63
  {
@@ -74,6 +74,11 @@ module Dynamoid
74
74
  raise Dynamoid::Errors::MissingHashKey if @model.hash_key.nil?
75
75
  raise Dynamoid::Errors::MissingRangeKey if @model_class.range_key? && @model.range_value.nil?
76
76
  end
77
+
78
+ def dump_attribute(name, value)
79
+ options = @model_class.attributes[name]
80
+ Dumping.dump_field(value, options)
81
+ end
77
82
  end
78
83
  end
79
84
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dynamoid
4
+ class TransactionWrite
5
+ class ItemUpdater
6
+ attr_reader :attributes_to_set, :attributes_to_add, :attributes_to_delete, :attributes_to_remove
7
+
8
+ def initialize(model_class)
9
+ @model_class = model_class
10
+
11
+ @attributes_to_set = {}
12
+ @attributes_to_add = {}
13
+ @attributes_to_delete = {}
14
+ @attributes_to_remove = []
15
+ end
16
+
17
+ def empty?
18
+ [@attributes_to_set, @attributes_to_add, @attributes_to_delete, @attributes_to_remove].all?(&:empty?)
19
+ end
20
+
21
+ def set(attributes)
22
+ validate_attribute_names!(attributes.keys)
23
+ @attributes_to_set.merge!(attributes)
24
+ end
25
+
26
+ # adds to array of fields for use in REMOVE update expression
27
+ def remove(*names)
28
+ validate_attribute_names!(names)
29
+ @attributes_to_remove += names
30
+ end
31
+
32
+ # increments a number or adds to a set, starts at 0 or [] if it doesn't yet exist
33
+ def add(attributes)
34
+ validate_attribute_names!(attributes.keys)
35
+ @attributes_to_add.merge!(attributes)
36
+ end
37
+
38
+ # deletes a value or values from a set
39
+ def delete(attributes)
40
+ validate_attribute_names!(attributes.keys)
41
+ @attributes_to_delete.merge!(attributes)
42
+ end
43
+
44
+ private
45
+
46
+ def validate_attribute_names!(names)
47
+ names.each do |name|
48
+ unless @model_class.attributes[name]
49
+ raise Dynamoid::Errors::UnknownAttribute.new(@model_class, name)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -121,8 +121,8 @@ module Dynamoid
121
121
  changes_dumped = Dynamoid::Dumping.dump_attributes(changes, @model_class.attributes)
122
122
 
123
123
  # primary key to look up an item to update
124
- key = { @model_class.hash_key => @model.hash_key }
125
- key[@model_class.range_key] = @model.range_value if @model_class.range_key?
124
+ key = { @model_class.hash_key => dump_attribute(@model_class.hash_key, @model.hash_key) }
125
+ key[@model_class.range_key] = dump_attribute(@model_class.range_key, @model.range_value) if @model_class.range_key?
126
126
 
127
127
  # Build UpdateExpression and keep names and values placeholders mapping
128
128
  # in ExpressionAttributeNames and ExpressionAttributeValues.
@@ -159,6 +159,11 @@ module Dynamoid
159
159
  @model.updated_at = timestamp unless @options[:touch] == false && !@was_new_record
160
160
  @model.created_at ||= timestamp unless skip_created_at
161
161
  end
162
+
163
+ def dump_attribute(name, value)
164
+ options = @model_class.attributes[name]
165
+ Dumping.dump_field(value, options)
166
+ end
162
167
  end
163
168
  end
164
169
  end
@@ -6,18 +6,24 @@ require 'dynamoid/persistence/update_validations'
6
6
  module Dynamoid
7
7
  class TransactionWrite
8
8
  class UpdateFields < Base
9
- def initialize(model_class, hash_key, range_key, attributes)
9
+ def initialize(model_class, hash_key, range_key, attributes, &block)
10
10
  super()
11
11
 
12
12
  @model_class = model_class
13
13
  @hash_key = hash_key
14
14
  @range_key = range_key
15
- @attributes = attributes
15
+ @attributes = attributes || {}
16
+ @block = block
16
17
  end
17
18
 
18
19
  def on_registration
19
20
  validate_primary_key!
20
21
  Dynamoid::Persistence::UpdateValidations.validate_attributes_exist(@model_class, @attributes)
22
+
23
+ if @block
24
+ @item_updater = ItemUpdater.new(@model_class)
25
+ @block.call(@item_updater)
26
+ end
21
27
  end
22
28
 
23
29
  def on_commit; end
@@ -29,7 +35,7 @@ module Dynamoid
29
35
  end
30
36
 
31
37
  def skipped?
32
- @attributes.empty?
38
+ @attributes.empty? && (!@item_updater || @item_updater.empty?)
33
39
  end
34
40
 
35
41
  def observable_by_user_result
@@ -37,48 +43,55 @@ module Dynamoid
37
43
  end
38
44
 
39
45
  def action_request
46
+ builder = UpdateRequestBuilder.new(@model_class)
47
+
48
+ # primary key to look up an item to update
49
+ builder.hash_key = dump_attribute(@model_class.hash_key, @hash_key)
50
+ builder.range_key = dump_attribute(@model_class.range_key, @range_key) if @model_class.range_key?
51
+
40
52
  # changed attributes to persist
41
53
  changes = @attributes.dup
42
54
  changes = add_timestamps(changes, skip_created_at: true)
43
55
  changes_dumped = Dynamoid::Dumping.dump_attributes(changes, @model_class.attributes)
44
56
 
45
- # primary key to look up an item to update
46
- key = { @model_class.hash_key => @hash_key }
47
- key[@model_class.range_key] = @range_key if @model_class.range_key?
48
-
49
- # Build UpdateExpression and keep names and values placeholders mapping
50
- # in ExpressionAttributeNames and ExpressionAttributeValues.
51
- update_expression_statements = []
52
- expression_attribute_names = {}
53
- expression_attribute_values = {}
54
-
55
- changes_dumped.each_with_index do |(name, value), i|
56
- name_placeholder = "#_n#{i}"
57
- value_placeholder = ":_s#{i}"
58
-
59
- update_expression_statements << "#{name_placeholder} = #{value_placeholder}"
60
- expression_attribute_names[name_placeholder] = name
61
- expression_attribute_values[value_placeholder] = value
57
+ builder.set_attributes(changes_dumped)
58
+
59
+ # given a block
60
+ if @item_updater
61
+ builder.set_attributes(@item_updater.attributes_to_set)
62
+ builder.remove_attributes(@item_updater.attributes_to_remove)
63
+
64
+ @item_updater.attributes_to_add.each do |name, value|
65
+ # The ADD section in UpdateExpressions requires values to be a
66
+ # set to update a set attribute.
67
+ # Allow specifying values as any Enumerable collection (e.g. Array).
68
+ # Allow a single value not wrapped into a Set
69
+ if @model_class.attributes[name][:type] == :set
70
+ value = value.is_a?(Enumerable) ? Set.new(value) : Set[value]
71
+ end
72
+
73
+ builder.add_value(name, value)
74
+ end
75
+
76
+ @item_updater.attributes_to_delete.each do |name, value|
77
+ # The DELETE section in UpdateExpressions requires values to be a
78
+ # set to update a set attribute.
79
+ # Allow specifying values as any Enumerable collection (e.g. Array).
80
+ # Allow a single value not wrapped into a Set
81
+ value = value.is_a?(Enumerable) ? Set.new(value) : Set[value]
82
+
83
+ builder.delete_value(name, value)
84
+ end
62
85
  end
63
86
 
64
- update_expression = "SET #{update_expression_statements.join(', ')}"
65
-
66
87
  # require primary key to exist
67
88
  condition_expression = "attribute_exists(#{@model_class.hash_key})"
68
89
  if @model_class.range_key?
69
90
  condition_expression += " AND attribute_exists(#{@model_class.range_key})"
70
91
  end
92
+ builder.condition_expression = condition_expression
71
93
 
72
- {
73
- update: {
74
- key: key,
75
- table_name: @model_class.table_name,
76
- update_expression: update_expression,
77
- expression_attribute_names: expression_attribute_names,
78
- expression_attribute_values: expression_attribute_values,
79
- condition_expression: condition_expression
80
- }
81
- }
94
+ builder.request
82
95
  end
83
96
 
84
97
  private
@@ -97,6 +110,130 @@ module Dynamoid
97
110
  result[:updated_at] ||= timestamp
98
111
  result
99
112
  end
113
+
114
+ def dump_attribute(name, value)
115
+ options = @model_class.attributes[name]
116
+ Dumping.dump_field(value, options)
117
+ end
118
+
119
+ class UpdateRequestBuilder
120
+ attr_writer :hash_key, :range_key, :condition_expression
121
+
122
+ def initialize(model_class)
123
+ @model_class = model_class
124
+
125
+ @attributes_to_set = {}
126
+ @attributes_to_add = {}
127
+ @attributes_to_delete = {}
128
+ @attributes_to_remove = []
129
+ @condition_expression = nil
130
+ end
131
+
132
+ def set_attributes(attributes) # rubocop:disable Naming/AccessorMethodName
133
+ @attributes_to_set.merge!(attributes)
134
+ end
135
+
136
+ def add_value(name, value)
137
+ @attributes_to_add[name] = value
138
+ end
139
+
140
+ def delete_value(name, value)
141
+ @attributes_to_delete[name] = value
142
+ end
143
+
144
+ def remove_attributes(names)
145
+ @attributes_to_remove.concat(names)
146
+ end
147
+
148
+ def request
149
+ key = { @model_class.hash_key => @hash_key }
150
+ key[@model_class.range_key] = @range_key if @model_class.range_key?
151
+
152
+ # Build UpdateExpression and keep names and values placeholders mapping
153
+ # in ExpressionAttributeNames and ExpressionAttributeValues.
154
+ update_expression_statements = []
155
+ expression_attribute_names = {}
156
+ expression_attribute_values = {}
157
+ name_placeholder = '#_n0'
158
+ value_placeholder = ':_v0'
159
+
160
+ unless @attributes_to_set.empty?
161
+ statements = []
162
+
163
+ @attributes_to_set.each do |name, value|
164
+ statements << "#{name_placeholder} = #{value_placeholder}"
165
+
166
+ expression_attribute_names[name_placeholder] = name
167
+ expression_attribute_values[value_placeholder] = value
168
+
169
+ name_placeholder = name_placeholder.succ
170
+ value_placeholder = value_placeholder.succ
171
+ end
172
+
173
+ update_expression_statements << "SET #{statements.join(', ')}"
174
+ end
175
+
176
+ unless @attributes_to_add.empty?
177
+ statements = []
178
+
179
+ @attributes_to_add.each do |name, value|
180
+ statements << "#{name_placeholder} #{value_placeholder}"
181
+
182
+ expression_attribute_names[name_placeholder] = name
183
+ expression_attribute_values[value_placeholder] = value
184
+
185
+ name_placeholder = name_placeholder.succ
186
+ value_placeholder = value_placeholder.succ
187
+ end
188
+
189
+ update_expression_statements << "ADD #{statements.join(', ')}"
190
+ end
191
+
192
+ unless @attributes_to_delete.empty?
193
+ statements = []
194
+
195
+ @attributes_to_delete.each do |name, value|
196
+ statements << "#{name_placeholder} #{value_placeholder}"
197
+
198
+ expression_attribute_names[name_placeholder] = name
199
+ expression_attribute_values[value_placeholder] = value
200
+
201
+ name_placeholder = name_placeholder.succ
202
+ value_placeholder = value_placeholder.succ
203
+ end
204
+
205
+ update_expression_statements << "DELETE #{statements.join(', ')}"
206
+ end
207
+
208
+ unless @attributes_to_remove.empty?
209
+ name_placeholders = []
210
+
211
+ @attributes_to_remove.each do |name|
212
+ name_placeholders << name_placeholder
213
+
214
+ expression_attribute_names[name_placeholder] = name
215
+
216
+ name_placeholder = name_placeholder.succ
217
+ value_placeholder = value_placeholder.succ
218
+ end
219
+
220
+ update_expression_statements << "REMOVE #{name_placeholders.join(', ')}"
221
+ end
222
+
223
+ update_expression = update_expression_statements.join(' ')
224
+
225
+ {
226
+ update: {
227
+ key: key,
228
+ table_name: @model_class.table_name,
229
+ update_expression: update_expression,
230
+ expression_attribute_names: expression_attribute_names,
231
+ expression_attribute_values: expression_attribute_values,
232
+ condition_expression: @condition_expression
233
+ }
234
+ }
235
+ end
236
+ end
100
237
  end
101
238
  end
102
239
  end
@@ -44,8 +44,13 @@ module Dynamoid
44
44
  changes_dumped = Dynamoid::Dumping.dump_attributes(changes, @model_class.attributes)
45
45
 
46
46
  # primary key to look up an item to update
47
- key = { @model_class.hash_key => @hash_key }
48
- key[@model_class.range_key] = @range_key if @model_class.range_key?
47
+ partition_key_dumped = dump(@model_class.hash_key, @hash_key)
48
+ key = { @model_class.hash_key => partition_key_dumped }
49
+
50
+ if @model_class.range_key?
51
+ sort_key_dumped = dump(@model_class.range_key, @range_key)
52
+ key[@model_class.range_key] = sort_key_dumped
53
+ end
49
54
 
50
55
  # Build UpdateExpression and keep names and values placeholders mapping
51
56
  # in ExpressionAttributeNames and ExpressionAttributeValues.
@@ -91,6 +96,11 @@ module Dynamoid
91
96
  result[:updated_at] ||= timestamp
92
97
  result
93
98
  end
99
+
100
+ def dump(name, value)
101
+ options = @model_class.attributes[name]
102
+ Dumping.dump_field(value, options)
103
+ end
94
104
  end
95
105
  end
96
106
  end