aws-record 2.1.1 → 2.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 3d5eae4de88bc967f473b49bf9294047a6ee6821
4
- data.tar.gz: 5c2e4404ad3b47f909d572b546f3cd70a8e0e93d
2
+ SHA256:
3
+ metadata.gz: d017802e3f3023427a79e39fe59468a66f1fe138ef3a6bd173bdcc4d4a15c05c
4
+ data.tar.gz: c9efe98e3226977a5c381d16cf0dd9ffcd77e6c99d7d4407d58c2e20908c5be2
5
5
  SHA512:
6
- metadata.gz: c261673f84aa256222fdd9c17f715ab9161c1a1a1fd23978b494c62627312d12dd314d824321ef43296f87fe92be35ab135de104b6094bb753b746fe3e7235f6
7
- data.tar.gz: 7742ea625d83be44c72602f0d55b05f7614f87971cd69bcb402f02de0e7c55057b9870a77b43b0d2adfe02f428565b7be7e770317ba9220bc8b2386528306750
6
+ metadata.gz: 0fd56a56605143708ee9f70074317452105860f86835f59653e484c0b1f700b9a7e587383fb9a9278c1eb3c0585cb6fde1024d07bf3d6bcefc1497fcd9ab5984
7
+ data.tar.gz: ba3f5889ca0954c294ca77b752ab45cb7151067c81369b3b303c86f72e32bc2f0eb7496ca6ccdfbfbf515ef6d04c1809cf32f839cc3574d35ff1f965646505a9
@@ -27,6 +27,8 @@ require_relative 'aws-record/record/secondary_indexes'
27
27
  require_relative 'aws-record/record/table_config'
28
28
  require_relative 'aws-record/record/table_migration'
29
29
  require_relative 'aws-record/record/version'
30
+ require_relative 'aws-record/record/transactions'
31
+ require_relative 'aws-record/record/buildable_search'
30
32
  require_relative 'aws-record/record/marshalers/string_marshaler'
31
33
  require_relative 'aws-record/record/marshalers/boolean_marshaler'
32
34
  require_relative 'aws-record/record/marshalers/integer_marshaler'
@@ -48,12 +48,14 @@ module Aws
48
48
  # item is nil or not set at persistence time.
49
49
  def initialize(name, options = {})
50
50
  @name = name
51
- @database_name = options[:database_attribute_name] || name.to_s
51
+ @database_name = (options[:database_attribute_name] || name).to_s
52
52
  @dynamodb_type = options[:dynamodb_type]
53
53
  @marshaler = options[:marshaler] || DefaultMarshaler
54
54
  @persist_nil = options[:persist_nil]
55
55
  dv = options[:default_value]
56
- @default_value_or_lambda = type_cast(dv) unless dv.nil?
56
+ unless dv.nil?
57
+ @default_value_or_lambda = _is_lambda?(dv) ? dv : type_cast(dv)
58
+ end
57
59
  end
58
60
 
59
61
  # Attempts to type cast a raw value into the attribute's type. This call
@@ -92,8 +94,8 @@ module Aws
92
94
 
93
95
  # @api private
94
96
  def default_value
95
- if @default_value_or_lambda.respond_to?(:call)
96
- @default_value_or_lambda.call
97
+ if _is_lambda?(@default_value_or_lambda)
98
+ type_cast(@default_value_or_lambda.call)
97
99
  else
98
100
  _deep_copy(@default_value_or_lambda)
99
101
  end
@@ -104,6 +106,10 @@ module Aws
104
106
  Marshal.load(Marshal.dump(obj))
105
107
  end
106
108
 
109
+ def _is_lambda?(obj)
110
+ obj.respond_to?(:call)
111
+ end
112
+
107
113
  end
108
114
 
109
115
  # This is an identity marshaler, which performs no changes for type casting
@@ -0,0 +1,232 @@
1
+ module Aws
2
+ module Record
3
+ class BuildableSearch
4
+ SUPPORTED_OPERATIONS = [:query, :scan]
5
+
6
+ # This should never be called directly, rather it is called by the
7
+ # #build_query or #build_scan methods of your aws-record model class.
8
+ def initialize(opts)
9
+ operation = opts[:operation]
10
+ model = opts[:model]
11
+ if SUPPORTED_OPERATIONS.include?(operation)
12
+ @operation = operation
13
+ else
14
+ raise ArgumentError.new("Unsupported operation: #{operation}")
15
+ end
16
+ @model = model
17
+ @params = {}
18
+ @next_name = "BUILDERA"
19
+ @next_value = "buildera"
20
+ end
21
+
22
+ # If you are querying or scanning on an index, you can specify it with
23
+ # this builder method. Provide the symbol of your index as defined on your
24
+ # model class.
25
+ def on_index(index)
26
+ @params[:index_name] = index
27
+ self
28
+ end
29
+
30
+ # If true, will perform your query or scan as a consistent read. If false,
31
+ # the query or scan is eventually consistent.
32
+ def consistent_read(b)
33
+ @params[:consistent_read] = b
34
+ self
35
+ end
36
+
37
+ # For the scan operation, you can split your scan into multiple segments
38
+ # to be scanned in parallel. If you wish to do this, you can use this
39
+ # builder method to provide the :total_segments of your parallel scan and
40
+ # the :segment number of this scan.
41
+ def parallel_scan(opts)
42
+ unless @operation == :scan
43
+ raise ArgumentError.new("parallel_scan is only supported for scans")
44
+ end
45
+ unless opts[:total_segments] && opts[:segment]
46
+ raise ArgumentError.new("Must specify :total_segments and :segment in a parallel scan.")
47
+ end
48
+ @params[:total_segments] = opts[:total_segments]
49
+ @params[:segment] = opts[:segment]
50
+ self
51
+ end
52
+
53
+ # For a query operation, you can use this to set if you query is in
54
+ # ascending or descending order on your range key. By default, a query is
55
+ # run in ascending order.
56
+ def scan_ascending(b)
57
+ unless @operation == :query
58
+ raise ArgumentError.new("scan_ascending is only supported for queries.")
59
+ end
60
+ @params[:scan_index_forward] = b
61
+ self
62
+ end
63
+
64
+ # If you have an exclusive start key for your query or scan, you can
65
+ # provide it with this builder method. You should not use this if you are
66
+ # querying or scanning without a set starting point, as the
67
+ # {Aws::Record::ItemCollection} class handles pagination automatically
68
+ # for you.
69
+ def exclusive_start_key(key)
70
+ @params[:exclusive_start_key] = key
71
+ self
72
+ end
73
+
74
+ # Provide a key condition expression for your query using a substitution
75
+ # expression.
76
+ #
77
+ # @example Building a simple query with a key expression:
78
+ # # Example model class
79
+ # class ExampleTable
80
+ # include Aws::Record
81
+ # string_attr :uuid, hash_key: true
82
+ # integer_attr :id, range_key: true
83
+ # string_attr :body
84
+ # end
85
+ #
86
+ # q = ExampleTable.build_query.key_expr(
87
+ # ":uuid = ? AND :id > ?", "smpl-uuid", 100
88
+ # ).complete!
89
+ # q.to_a # You can use this like any other query result in aws-record
90
+ def key_expr(statement_str, *subs)
91
+ unless @operation == :query
92
+ raise ArgumentError.new("key_expr is only supported for queries.")
93
+ end
94
+ names = @params[:expression_attribute_names]
95
+ if names.nil?
96
+ @params[:expression_attribute_names] = {}
97
+ names = @params[:expression_attribute_names]
98
+ end
99
+ values = @params[:expression_attribute_values]
100
+ if values.nil?
101
+ @params[:expression_attribute_values] = {}
102
+ values = @params[:expression_attribute_values]
103
+ end
104
+ _key_pass(statement_str, names)
105
+ _apply_values(statement_str, subs, values)
106
+ @params[:key_condition_expression] = statement_str
107
+ self
108
+ end
109
+
110
+ # Provide a filter expression for your query or scan using a substitution
111
+ # expression.
112
+ #
113
+ # @example Building a simple scan:
114
+ # # Example model class
115
+ # class ExampleTable
116
+ # include Aws::Record
117
+ # string_attr :uuid, hash_key: true
118
+ # integer_attr :id, range_key: true
119
+ # string_attr :body
120
+ # end
121
+ #
122
+ # scan = ExampleTable.build_scan.filter_expr(
123
+ # "contains(:body, ?)",
124
+ # "bacon"
125
+ # ).complete!
126
+ #
127
+ def filter_expr(statement_str, *subs)
128
+ names = @params[:expression_attribute_names]
129
+ if names.nil?
130
+ @params[:expression_attribute_names] = {}
131
+ names = @params[:expression_attribute_names]
132
+ end
133
+ values = @params[:expression_attribute_values]
134
+ if values.nil?
135
+ @params[:expression_attribute_values] = {}
136
+ values = @params[:expression_attribute_values]
137
+ end
138
+ _key_pass(statement_str, names)
139
+ _apply_values(statement_str, subs, values)
140
+ @params[:filter_expression] = statement_str
141
+ self
142
+ end
143
+
144
+ # Allows you to define a projection expression for the values returned by
145
+ # a query or scan. See
146
+ # {https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ProjectionExpressions.html the Amazon DynamoDB Developer Guide}
147
+ # for more details on projection expressions. You can use the symbols from
148
+ # your aws-record model class in a projection expression. Keys are always
149
+ # retrieved.
150
+ #
151
+ # @example Scan with a projection expression:
152
+ # # Example model class
153
+ # class ExampleTable
154
+ # include Aws::Record
155
+ # string_attr :uuid, hash_key: true
156
+ # integer_attr :id, range_key: true
157
+ # string_attr :body
158
+ # map_attr :metadata
159
+ # end
160
+ #
161
+ # scan = ExampleTable.build_scan.projection_expr(
162
+ # ":body"
163
+ # ).complete!
164
+ def projection_expr(statement_str)
165
+ names = @params[:expression_attribute_names]
166
+ if names.nil?
167
+ @params[:expression_attribute_names] = {}
168
+ names = @params[:expression_attribute_names]
169
+ end
170
+ _key_pass(statement_str, names)
171
+ @params[:projection_expression] = statement_str
172
+ self
173
+ end
174
+
175
+ # Allows you to set a page size limit on each query or scan request.
176
+ def limit(size)
177
+ @params[:limit] = size
178
+ self
179
+ end
180
+
181
+ # You must call this method at the end of any query or scan you build.
182
+ #
183
+ # @return [Aws::Record::ItemCollection] The item collection lazy
184
+ # enumerable.
185
+ def complete!
186
+ @model.send(@operation, @params)
187
+ end
188
+
189
+ private
190
+ def _key_pass(statement, names)
191
+ statement.gsub!(/:(\w+)/) do |match|
192
+ key = match.gsub!(':','').to_sym
193
+ key_name = @model.attributes.storage_name_for(key)
194
+ if key_name
195
+ sub_name = _next_name
196
+ raise "Substitution collision!" if names[sub_name]
197
+ names[sub_name] = key_name
198
+ sub_name
199
+ else
200
+ raise "No such key #{key}"
201
+ end
202
+ end
203
+ end
204
+
205
+ def _apply_values(statement, subs, values)
206
+ count = 0
207
+ statement.gsub!(/[?]/) do |match|
208
+ sub_value = _next_value
209
+ raise "Substitution collision!" if values[sub_value]
210
+ values[sub_value] = subs[count]
211
+ count += 1
212
+ sub_value
213
+ end
214
+ unless count == subs.size
215
+ raise "Expected #{count} values in the substitution set, but found #{subs.size}"
216
+ end
217
+ end
218
+
219
+ def _next_name
220
+ ret = "#" + @next_name
221
+ @next_name.next!
222
+ ret
223
+ end
224
+
225
+ def _next_value
226
+ ret = ":" + @next_value
227
+ @next_value.next!
228
+ ret
229
+ end
230
+ end
231
+ end
232
+ end
@@ -54,6 +54,14 @@ module Aws
54
54
  class TableDoesNotExist < RuntimeError; end
55
55
 
56
56
  class MissingRequiredConfiguration < RuntimeError; end
57
+
58
+ # Raised when you attempt to combine your own condition expression with
59
+ # the auto-generated condition expression from a "safe put" from saving
60
+ # a new item in a transactional write operation. The path forward until
61
+ # this case is supported is to use a plain "put" call, and to include
62
+ # the key existance check yourself in your condition expression if you
63
+ # wish to do so.
64
+ class TransactionalSaveConditionCollision < RuntimeError; end
57
65
  end
58
66
  end
59
67
  end
@@ -122,9 +122,9 @@ module Aws
122
122
 
123
123
  def populate_default_values
124
124
  @model_attributes.attributes.each do |name, attribute|
125
- unless attribute.default_value.nil?
125
+ unless (default_value = attribute.default_value).nil?
126
126
  if @data[name].nil? && @data[name].nil?
127
- @data[name] = attribute.default_value
127
+ @data[name] = default_value
128
128
  end
129
129
  end
130
130
  end
@@ -329,6 +329,112 @@ module Aws
329
329
 
330
330
  module ItemOperationsClassMethods
331
331
 
332
+ # @example Usage Example
333
+ # check_exp = Model.transact_check_expression(
334
+ # key: { uuid: "foo" },
335
+ # condition_expression: "size(#T) <= :v",
336
+ # expression_attribute_names: {
337
+ # "#T" => "body"
338
+ # },
339
+ # expression_attribute_values: {
340
+ # ":v" => 1024
341
+ # }
342
+ # )
343
+ #
344
+ # Allows you to build a "check" expression for use in transactional
345
+ # write operations.
346
+ #
347
+ # @param [Hash] opts Options matching the :condition_check contents in
348
+ # the
349
+ # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_write_items-instance_method Aws::DynamoDB::Client#transact_write_items}
350
+ # API, with the exception that keys will be marshalled for you, and
351
+ # the table name will be provided for you by the operation.
352
+ # @return [Hash] Options suitable to be used as a check expression when
353
+ # calling the +#transact_write+ operation.
354
+ def transact_check_expression(opts)
355
+ # need to transform the key, and add the table name
356
+ opts = opts.dup
357
+ key = opts.delete(:key)
358
+ check_key = {}
359
+ @keys.keys.each_value do |attr_sym|
360
+ unless key[attr_sym]
361
+ raise Errors::KeyMissing.new(
362
+ "Missing required key #{attr_sym} in #{key}"
363
+ )
364
+ end
365
+ attr_name = attributes.storage_name_for(attr_sym)
366
+ check_key[attr_name] = attributes.attribute_for(attr_sym).
367
+ serialize(key[attr_sym])
368
+ end
369
+ opts[:key] = check_key
370
+ opts[:table_name] = table_name
371
+ opts
372
+ end
373
+
374
+ def tfind_opts(opts)
375
+ opts = opts.dup
376
+ key = opts.delete(:key)
377
+ request_key = {}
378
+ @keys.keys.each_value do |attr_sym|
379
+ unless key[attr_sym]
380
+ raise Errors::KeyMissing.new(
381
+ "Missing required key #{attr_sym} in #{key}"
382
+ )
383
+ end
384
+ attr_name = attributes.storage_name_for(attr_sym)
385
+ request_key[attr_name] = attributes.attribute_for(attr_sym).
386
+ serialize(key[attr_sym])
387
+ end
388
+ # this is a :get item used by #transact_get_items, with the exception
389
+ # of :model_class which needs to be removed before passing along
390
+ opts[:key] = request_key
391
+ opts[:table_name] = table_name
392
+ {
393
+ model_class: self,
394
+ get: opts
395
+ }
396
+ end
397
+
398
+ # @example Usage Example
399
+ # class Table
400
+ # include Aws::Record
401
+ # string_attr :hk, hash_key: true
402
+ # string_attr :rk, range_key: true
403
+ # end
404
+ #
405
+ # results = Table.transact_find(
406
+ # transact_items: [
407
+ # {key: { hk: "hk1", rk: "rk1"}},
408
+ # {key: { hk: "hk2", rk: "rk2"}}
409
+ # ]
410
+ # ) # => results.responses contains nil or instances of Table
411
+ #
412
+ # Provides a way to run a transactional find across multiple DynamoDB
413
+ # items, including transactions which get items across multiple actual
414
+ # or virtual tables.
415
+ #
416
+ # @param [Hash] opts Options to pass through to
417
+ # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_get_items-instance_method Aws::DynamoDB::Client#transact_get_items},
418
+ # with the exception of the :transact_items array, which uses the
419
+ # +#tfind_opts+ operation on your model class to provide extra
420
+ # metadata used to marshal your items after retrieval.
421
+ # @option opts [Array] :transact_items A set of options describing
422
+ # instances of the model class to return.
423
+ # @return [OpenStruct] Structured like the client API response from
424
+ # +#transact_get_items+, except that the +responses+ member contains
425
+ # +Aws::Record+ items marshaled into the model class used to call
426
+ # this method. See the usage example.
427
+ def transact_find(opts)
428
+ opts = opts.dup
429
+ transact_items = opts.delete(:transact_items)
430
+ global_transact_items = transact_items.map do |topts|
431
+ tfind_opts(topts)
432
+ end
433
+ opts[:transact_items] = global_transact_items
434
+ opts[:client] = dynamodb_client
435
+ Transactions.transact_find(opts)
436
+ end
437
+
332
438
  # @example Usage Example
333
439
  # class MyModel
334
440
  # include Aws::Record
@@ -55,6 +55,8 @@ module Aws
55
55
  raw_value
56
56
  when Integer # timestamp
57
57
  ::Time.at(raw_value)
58
+ when BigDecimal
59
+ ::Time.at(raw_value)
58
60
  else # Date, DateTime, or String
59
61
  ::Time.parse(raw_value.to_s)
60
62
  end
@@ -58,7 +58,7 @@ module Aws
58
58
  if item.is_a?(Numeric)
59
59
  item
60
60
  else
61
- BigDecimal.new(item.to_s)
61
+ BigDecimal(item.to_s)
62
62
  end
63
63
  end
64
64
  end
@@ -102,6 +102,54 @@ module Aws
102
102
  scan_opts = opts.merge(table_name: table_name)
103
103
  ItemCollection.new(:scan, scan_opts, self, dynamodb_client)
104
104
  end
105
+
106
+ # This method allows you to build a query using the {Aws::Record::BuildableSearch} DSL.
107
+ #
108
+ # @example Building a simple query:
109
+ # # Example model class
110
+ # class ExampleTable
111
+ # include Aws::Record
112
+ # string_attr :uuid, hash_key: true
113
+ # integer_attr :id, range_key: true
114
+ # string_attr :body
115
+ # end
116
+ #
117
+ # q = ExampleTable.build_query.key_expr(
118
+ # ":uuid = ? AND :id > ?", "smpl-uuid", 100
119
+ # ).scan_ascending(false).complete!
120
+ # q.to_a # You can use this like any other query result in aws-record
121
+ def build_query
122
+ BuildableSearch.new(
123
+ operation: :query,
124
+ model: self
125
+ )
126
+ end
127
+
128
+ # This method allows you to build a scan using the {Aws::Record::BuildableSearch} DSL.
129
+ #
130
+ # @example Building a simple scan:
131
+ # # Example model class
132
+ # class ExampleTable
133
+ # include Aws::Record
134
+ # string_attr :uuid, hash_key: true
135
+ # integer_attr :id, range_key: true
136
+ # string_attr :body
137
+ # end
138
+ #
139
+ # segment_2_scan = ExampleTable.build_scan.filter_expr(
140
+ # "contains(:body, ?)",
141
+ # "bacon"
142
+ # ).scan_ascending(false).parallel_scan(
143
+ # total_segments: 5,
144
+ # segment: 2
145
+ # ).complete!
146
+ # segment_2_scan.to_a # You can use this like any other query result in aws-record
147
+ def build_scan
148
+ BuildableSearch.new(
149
+ operation: :scan,
150
+ model: self
151
+ )
152
+ end
105
153
  end
106
154
 
107
155
  end
@@ -33,6 +33,17 @@ module Aws
33
33
  # t.write_capacity_units 5
34
34
  # end
35
35
  #
36
+ # @example A basic model with pay per request billing.
37
+ # class Model
38
+ # include Aws::Record
39
+ # string_attr :uuid, hash_key: true
40
+ # end
41
+ #
42
+ # table_config = Aws::Record::TableConfig.define do |t|
43
+ # t.model_class Model
44
+ # t.billing_mode "PAY_PER_REQUEST"
45
+ # end
46
+ #
36
47
  # @example Running a conditional migration on a basic model.
37
48
  # table_config = Aws::Record::TableConfig.define do |t|
38
49
  # t.model_class Model
@@ -59,7 +70,9 @@ module Aws
59
70
  # :title,
60
71
  # hash_key: :forum_uuid,
61
72
  # range_key: :post_title,
62
- # projection_type: "ALL"
73
+ # projection: {
74
+ # projection_type: "ALL"
75
+ # }
63
76
  # )
64
77
  # end
65
78
  #
@@ -74,18 +87,18 @@ module Aws
74
87
  # end
75
88
  # end
76
89
  #
77
- # @example A model with a Time to Live attribute
78
- # class ExpiringTokens
79
- # string_attr :token_uuid, hash_key: true
80
- # epoch_time_attr :ttl
81
- # end
90
+ # @example A model with a Time to Live attribute
91
+ # class ExpiringTokens
92
+ # string_attr :token_uuid, hash_key: true
93
+ # epoch_time_attr :ttl
94
+ # end
82
95
  #
83
- # table_config = Aws::Record::TableConfig.define do |t|
84
- # t.model_class ExpiringTokens
85
- # t.read_capacity_units 10
86
- # t.write_capacity_units 1
87
- # t.ttl_attribute :ttl
88
- # end
96
+ # table_config = Aws::Record::TableConfig.define do |t|
97
+ # t.model_class ExpiringTokens
98
+ # t.read_capacity_units 10
99
+ # t.write_capacity_units 1
100
+ # t.ttl_attribute :ttl
101
+ # end
89
102
  #
90
103
  class TableConfig
91
104
 
@@ -106,7 +119,12 @@ module Aws
106
119
  # * +#write_capacity_units+ Sets the write capacity units for the
107
120
  # index.
108
121
  # * +#ttl_attribute+ Sets the attribute ID to be used as the TTL
109
- # attribute, and if present, TTL will be enabled for the table.
122
+ # attribute, and if present, TTL will be enabled for the table.
123
+ # * +#billing_mode+ Sets the billing mode, with the current supported
124
+ # options being "PROVISIONED" and "PAY_PER_REQUEST". If using
125
+ # "PAY_PER_REQUEST" you must not set provisioned throughput values,
126
+ # and if using "PROVISIONED" you must set provisioned throughput
127
+ # values. Default assumption is "PROVISIONED".
110
128
  #
111
129
  # @example Defining a migration with a GSI.
112
130
  # class Forum
@@ -151,6 +169,7 @@ module Aws
151
169
  def initialize
152
170
  @client_options = {}
153
171
  @global_secondary_indexes = {}
172
+ @billing_mode = "PROVISIONED" # default
154
173
  end
155
174
 
156
175
  # @api private
@@ -195,6 +214,11 @@ module Aws
195
214
  end
196
215
  end
197
216
 
217
+ # @api private
218
+ def billing_mode(mode)
219
+ @billing_mode = mode
220
+ end
221
+
198
222
  # Performs a migration, if needed, against the remote table. If
199
223
  # +#compatible?+ would return true, the remote table already has the same
200
224
  # throughput, key schema, attribute definitions, and global secondary
@@ -209,13 +233,7 @@ module Aws
209
233
  else
210
234
  # Gotcha: You need separate migrations for indexes and throughput
211
235
  unless _throughput_equal(resp)
212
- @client.update_table(
213
- table_name: @model_class.table_name,
214
- provisioned_throughput: {
215
- read_capacity_units: @read_capacity_units,
216
- write_capacity_units: @write_capacity_units
217
- }
218
- )
236
+ @client.update_table(_update_throughput_opts(resp))
219
237
  @client.wait_until(
220
238
  :table_exists,
221
239
  table_name: @model_class.table_name
@@ -327,12 +345,19 @@ module Aws
327
345
 
328
346
  def _create_table_opts
329
347
  opts = {
330
- table_name: @model_class.table_name,
331
- provisioned_throughput: {
348
+ table_name: @model_class.table_name
349
+ }
350
+ if @billing_mode == "PROVISIONED"
351
+ opts[:provisioned_throughput] = {
332
352
  read_capacity_units: @read_capacity_units,
333
353
  write_capacity_units: @write_capacity_units
334
354
  }
335
- }
355
+ elsif @billing_mode == "PAY_PER_REQUEST"
356
+ opts[:billing_mode] = @billing_mode
357
+ else
358
+ raise ArgumentError, "Unsupported billing mode #{@billing_mode}"
359
+ end
360
+
336
361
  opts[:key_schema] = _key_schema
337
362
  opts[:attribute_definitions] = _attribute_definitions
338
363
  gsi = _global_secondary_indexes
@@ -342,6 +367,54 @@ module Aws
342
367
  opts
343
368
  end
344
369
 
370
+ def _add_global_secondary_index_throughput(opts, resp_gsis)
371
+ gsis = resp_gsis.map do |g|
372
+ g.index_name
373
+ end
374
+ gsi_updates = []
375
+ gsis.each do |index_name|
376
+ lgsi = @global_secondary_indexes[index_name.to_sym]
377
+ gsi_updates << {
378
+ update: {
379
+ index_name: index_name,
380
+ provisioned_throughput: lgsi.provisioned_throughput
381
+ }
382
+ }
383
+ end
384
+ opts[:global_secondary_index_updates] = gsi_updates
385
+ true
386
+ end
387
+
388
+ def _update_throughput_opts(resp)
389
+ if @billing_mode == "PROVISIONED"
390
+ opts = {
391
+ table_name: @model_class.table_name,
392
+ provisioned_throughput: {
393
+ read_capacity_units: @read_capacity_units,
394
+ write_capacity_units: @write_capacity_units
395
+ }
396
+ }
397
+ # special case: we have global secondary indexes existing, and they
398
+ # need provisioned capacity to be set within this call
399
+ if !resp.table.billing_mode_summary.nil? &&
400
+ resp.table.billing_mode_summary.billing_mode == "PAY_PER_REQUEST"
401
+ opts[:billing_mode] = @billing_mode
402
+ if resp.table.global_secondary_indexes
403
+ resp_gsis = resp.table.global_secondary_indexes
404
+ _add_global_secondary_index_throughput(opts, resp_gsis)
405
+ end
406
+ end # else don't include billing mode
407
+ opts
408
+ elsif @billing_mode == "PAY_PER_REQUEST"
409
+ {
410
+ table_name: @model_class.table_name,
411
+ billing_mode: "PAY_PER_REQUEST"
412
+ }
413
+ else
414
+ raise ArgumentError, "Unsupported billing mode #{@billing_mode}"
415
+ end
416
+ end
417
+
345
418
  def _update_index_opts(resp)
346
419
  gsi_updates, attribute_definitions = _gsi_updates(resp)
347
420
  opts = {
@@ -369,22 +442,25 @@ module Aws
369
442
  gsi[:key_schema].each do |k|
370
443
  attributes_referenced.add(k[:attribute_name])
371
444
  end
372
- # This may be a problem, check if I can maintain symbols.
373
- lgsi = @global_secondary_indexes[index_name.to_sym]
374
- gsi[:provisioned_throughput] = lgsi.provisioned_throughput
445
+ if @billing_mode == "PROVISIONED"
446
+ lgsi = @global_secondary_indexes[index_name.to_sym]
447
+ gsi[:provisioned_throughput] = lgsi.provisioned_throughput
448
+ end
375
449
  gsi_updates << {
376
450
  create: gsi
377
451
  }
378
452
  end
379
- update_candidates.each do |index_name|
380
- # This may be a problem, check if I can maintain symbols.
381
- lgsi = @global_secondary_indexes[index_name.to_sym]
382
- gsi_updates << {
383
- update: {
384
- index_name: index_name,
385
- provisioned_throughput: lgsi.provisioned_throughput
453
+ # we don't currently update anything other than throughput
454
+ if @billing_mode == "PROVISIONED"
455
+ update_candidates.each do |index_name|
456
+ lgsi = @global_secondary_indexes[index_name.to_sym]
457
+ gsi_updates << {
458
+ update: {
459
+ index_name: index_name,
460
+ provisioned_throughput: lgsi.provisioned_throughput
461
+ }
386
462
  }
387
- }
463
+ end
388
464
  end
389
465
  attribute_definitions = _attribute_definitions
390
466
  incremental_attributes = attributes_referenced.map do |attr_name|
@@ -438,13 +514,18 @@ module Aws
438
514
  end
439
515
 
440
516
  def _throughput_equal(resp)
441
- expected = resp.table.provisioned_throughput.to_h
442
- actual = {
443
- read_capacity_units: @read_capacity_units,
444
- write_capacity_units: @write_capacity_units
445
- }
446
- actual.all? do |k,v|
447
- expected[k] == v
517
+ if @billing_mode == "PAY_PER_REQUEST"
518
+ !resp.table.billing_mode_summary.nil? &&
519
+ resp.table.billing_mode_summary.billing_mode == "PAY_PER_REQUEST"
520
+ else
521
+ expected = resp.table.provisioned_throughput.to_h
522
+ actual = {
523
+ read_capacity_units: @read_capacity_units,
524
+ write_capacity_units: @write_capacity_units
525
+ }
526
+ actual.all? do |k,v|
527
+ expected[k] == v
528
+ end
448
529
  end
449
530
  end
450
531
 
@@ -498,10 +579,17 @@ module Aws
498
579
  remote_key_schema = rgsi.key_schema.map { |i| i.to_h }
499
580
  ks_match = _array_unsorted_eql(remote_key_schema, lgsi[:key_schema])
500
581
 
582
+ # Throughput Check: Dependent on Billing Mode
501
583
  rpt = rgsi.provisioned_throughput.to_h
502
584
  lpt = lgsi[:provisioned_throughput]
503
- pt_match = lpt.all? do |k,v|
504
- rpt[k] == v
585
+ if @billing_mode == "PROVISIONED"
586
+ pt_match = lpt.all? do |k,v|
587
+ rpt[k] == v
588
+ end
589
+ elsif @billing_mode == "PAY_PER_REQUEST"
590
+ pt_match = lpt.nil? ? true : false
591
+ else
592
+ raise ArgumentError, "Unsupported billing mode #{@billing_mode}"
505
593
  end
506
594
 
507
595
  rp = rgsi.projection.to_h
@@ -537,10 +625,13 @@ module Aws
537
625
  if model_gsis
538
626
  model_gsis.each do |mgsi|
539
627
  config = gsi_config[mgsi[:index_name]]
540
- # Validate throughput exists? Validate each throughput is in model?
541
- gsis << mgsi.merge(
542
- provisioned_throughput: config.provisioned_throughput
543
- )
628
+ if @billing_mode == "PROVISIONED"
629
+ gsis << mgsi.merge(
630
+ provisioned_throughput: config.provisioned_throughput
631
+ )
632
+ else
633
+ gsis << mgsi
634
+ end
544
635
  end
545
636
  end
546
637
  gsis
@@ -553,8 +644,14 @@ module Aws
553
644
  def _validate_required_configuration
554
645
  missing_config = []
555
646
  missing_config << 'model_class' unless @model_class
556
- missing_config << 'read_capacity_units' unless @read_capacity_units
557
- missing_config << 'write_capacity_units' unless @write_capacity_units
647
+ if @billing_mode == "PROVISIONED"
648
+ missing_config << 'read_capacity_units' unless @read_capacity_units
649
+ missing_config << 'write_capacity_units' unless @write_capacity_units
650
+ else
651
+ if @read_capacity_units || @write_capacity_units
652
+ raise ArgumentError.new("Cannot have billing mode #{@billing_mode} with provisioned capacity.")
653
+ end
654
+ end
558
655
  unless missing_config.empty?
559
656
  msg = missing_config.join(', ')
560
657
  raise Errors::MissingRequiredConfiguration, 'Missing: ' + msg
@@ -0,0 +1,332 @@
1
+ module Aws
2
+ module Record
3
+ module Transactions
4
+ class << self
5
+
6
+ # @example Usage Example
7
+ # class TableOne
8
+ # include Aws::Record
9
+ # string_attr :uuid, hash_key: true
10
+ # end
11
+ #
12
+ # class TableTwo
13
+ # include Aws::Record
14
+ # string_attr :hk, hash_key: true
15
+ # string_attr :rk, range_key: true
16
+ # end
17
+ #
18
+ # results = Aws::Record::Transactions.transact_find(
19
+ # transact_items: [
20
+ # TableOne.tfind_opts(key: { uuid: "uuid1234" }),
21
+ # TableTwo.tfind_opts(key: { hk: "hk1", rk: "rk1"}),
22
+ # TableTwo.tfind_opts(key: { hk: "hk2", rk: "rk2"})
23
+ # ]
24
+ # ) # => results.responses contains nil or marshalled items
25
+ # results.responses.map { |r| r.class } # [TableOne, TableTwo, TableTwo]
26
+ #
27
+ # Provides a way to run a transactional find across multiple DynamoDB
28
+ # items, including transactions which get items across multiple actual
29
+ # or virtual tables.
30
+ #
31
+ # @param [Hash] opts Options to pass through to
32
+ # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_get_items-instance_method Aws::DynamoDB::Client#transact_get_items},
33
+ # with the exception of the :transact_items array, which uses the
34
+ # +#tfind_opts+ operation on your model class to provide extra
35
+ # metadata used to marshal your items after retrieval.
36
+ # @option opts [Array] :transact_items A set of +#tfind_opts+ results,
37
+ # such as those created by the usage example.
38
+ # @option opts [Aws::DynamoDB::Client] :client Optionally, you can pass
39
+ # in your own client to use for the transaction calls.
40
+ # @return [OpenStruct] Structured like the client API response from
41
+ # +#transact_get_items+, except that the +responses+ member contains
42
+ # +Aws::Record+ items marshaled into the classes used to call
43
+ # +#tfind_opts+ in each positional member. See the usage example.
44
+ def transact_find(opts)
45
+ opts = opts.dup
46
+ client = opts.delete(:client) || dynamodb_client
47
+ transact_items = opts.delete(:transact_items) # add nil check?
48
+ model_classes = []
49
+ client_transact_items = transact_items.map do |tfind_opts|
50
+ model_class = tfind_opts.delete(:model_class)
51
+ model_classes << model_class
52
+ tfind_opts
53
+ end
54
+ request_opts = opts
55
+ request_opts[:transact_items] = client_transact_items
56
+ client_resp = client.transact_get_items(
57
+ request_opts
58
+ )
59
+ responses = client_resp.responses
60
+ index = -1
61
+ ret = OpenStruct.new
62
+ ret.consumed_capacity = client_resp.consumed_capacity
63
+ ret.missing_items = []
64
+ ret.responses = client_resp.responses.map do |item|
65
+ index += 1
66
+ if item.nil? || item.item.nil?
67
+ missing_data = {
68
+ model_class: model_classes[index],
69
+ key: transact_items[index][:get][:key]
70
+ }
71
+ ret.missing_items << missing_data
72
+ nil
73
+ else
74
+ # need to translate the item keys
75
+ raw_item = item.item
76
+ model_class = model_classes[index]
77
+ new_item_opts = {}
78
+ raw_item.each do |db_name, value|
79
+ name = model_class.attributes.db_to_attribute_name(db_name)
80
+ new_item_opts[name] = value
81
+ end
82
+ item = model_class.new(new_item_opts)
83
+ item.clean!
84
+ item
85
+ end
86
+ end
87
+ ret
88
+ end
89
+
90
+ # @example Usage Example
91
+ # class TableOne
92
+ # include Aws::Record
93
+ # string_attr :uuid, hash_key: true
94
+ # string_attr :body
95
+ # end
96
+ #
97
+ # class TableTwo
98
+ # include Aws::Record
99
+ # string_attr :hk, hash_key: true
100
+ # string_attr :rk, range_key: true
101
+ # string_attr :body
102
+ # end
103
+ #
104
+ # check_exp = TableOne.transact_check_expression(
105
+ # key: { uuid: "foo" },
106
+ # condition_expression: "size(#T) <= :v",
107
+ # expression_attribute_names: {
108
+ # "#T" => "body"
109
+ # },
110
+ # expression_attribute_values: {
111
+ # ":v" => 1024
112
+ # }
113
+ # )
114
+ # new_item = TableTwo.new(hk: "hk1", rk: "rk1", body: "Hello!")
115
+ # update_item_1 = TableOne.find(uuid: "bar")
116
+ # update_item_1.body = "Updated the body!"
117
+ # put_item = TableOne.new(uuid: "foobar", body: "Content!")
118
+ # update_item_2 = TableTwo.find(hk: "hk2", rk: "rk2")
119
+ # update_item_2.body = "Update!"
120
+ # delete_item = TableOne.find(uuid: "to_be_deleted")
121
+ #
122
+ # Aws::Record::Transactions.transact_write(
123
+ # transact_items: [
124
+ # { check: check_exp },
125
+ # { save: new_item },
126
+ # { save: update_item_1 },
127
+ # {
128
+ # put: put_item,
129
+ # condition_expression: "attribute_not_exists(#H)",
130
+ # expression_attribute_names: { "#H" => "uuid" },
131
+ # return_values_on_condition_check_failure: "ALL_OLD"
132
+ # },
133
+ # { update: update_item_2 },
134
+ # { delete: delete_item }
135
+ # ]
136
+ # )
137
+ #
138
+ # Provides a way to pass in aws-record items into transactional writes,
139
+ # as well as adding the ability to run 'save' commands in a transaction
140
+ # while allowing aws-record to determine if a :put or :update operation
141
+ # is most appropriate. +#transact_write+ supports 5 different transact
142
+ # item modes:
143
+ # - save: Behaves much like the +#save+ operation on the item itself.
144
+ # If the keys are dirty, and thus it appears to be a new item, will
145
+ # create a :put operation with a conditional check on the item's
146
+ # existance. Note that you cannot bring your own conditional
147
+ # expression in this case. If you wish to force put or add your
148
+ # own conditional checks, use the :put operation.
149
+ # - put: Does a force put for the given item key and model.
150
+ # - update: Does an upsert for the given item.
151
+ # - delete: Deletes the given item.
152
+ # - check: Takes the result of +#transact_check_expression+,
153
+ # performing the specified check as a part of the transaction.
154
+ #
155
+ # @param [Hash] opts Options to pass through to
156
+ # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_write_items-instance_method Aws::DynamoDB::Client#transact_write_items}
157
+ # with the exception of :transact_items array, which is transformed
158
+ # to use your item to populate the :key, :table_name, :item, and/or
159
+ # :update_expression parameters as appropriate. See the usage example
160
+ # for a comprehensive set of combinations.
161
+ # @option opts [Array] :transact_items An array of hashes, accepting
162
+ # +:save+, +:put+, +:delete+, +:update+, and +:check+ as specified.
163
+ # @option opts [Aws::DynamoDB::Client] :client Optionally, you can
164
+ # specify a client to use for this transaction call. If not
165
+ # specified, the configured client for +Aws::Record::Transactions+
166
+ # is used.
167
+ def transact_write(opts)
168
+ opts = opts.dup
169
+ client = opts.delete(:client) || dynamodb_client
170
+ dirty_items = []
171
+ delete_items = []
172
+ # fetch abstraction records
173
+ transact_items = _transform_transact_write_items(
174
+ opts.delete(:transact_items),
175
+ dirty_items,
176
+ delete_items
177
+ )
178
+ opts[:transact_items] = transact_items
179
+ resp = client.transact_write_items(opts)
180
+ # mark all items clean/destroyed as needed if we didn't raise an exception
181
+ dirty_items.each { |i| i.clean! }
182
+ delete_items.each { |i| i.instance_variable_get("@data").destroyed = true }
183
+ resp
184
+ end
185
+
186
+ # Configures the Amazon DynamoDB client used by global transaction
187
+ # operations.
188
+ #
189
+ # Please note that this method is also called internally when you first
190
+ # attempt to perform an operation against the remote end, if you have
191
+ # not already configured a client. As such, please read and understand
192
+ # the documentation in the AWS SDK for Ruby V3 around
193
+ # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/#Configuration configuration}
194
+ # to ensure you understand how default configuration behavior works.
195
+ # When in doubt, call this method to ensure your client is configured
196
+ # the way you want it to be configured.
197
+ #
198
+ # @param [Hash] opts the options you wish to use to create the client.
199
+ # Note that if you include the option +:client+, all other options
200
+ # will be ignored. See the documentation for other options in the
201
+ # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#initialize-instance_method AWS SDK for Ruby V3}.
202
+ # @option opts [Aws::DynamoDB::Client] :client allows you to pass in
203
+ # your own pre-configured client.
204
+ def configure_client(opts = {})
205
+ provided_client = opts.delete(:client)
206
+ opts[:user_agent_suffix] = _user_agent(
207
+ opts.delete(:user_agent_suffix)
208
+ )
209
+ client = provided_client || Aws::DynamoDB::Client.new(opts)
210
+ @@dynamodb_client = client
211
+ end
212
+
213
+ # Gets the
214
+ # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html}
215
+ # instance that Transactions use. When called for the first time, if
216
+ # {#configure_client} has not yet been called, will configure a new
217
+ # client for you with default parameters.
218
+ #
219
+ # @return [Aws::DynamoDB::Client] the Amazon DynamoDB client instance.
220
+ def dynamodb_client
221
+ @@dynamodb_client ||= configure_client
222
+ end
223
+
224
+ private
225
+ def _transform_transact_write_items(transact_items, dirty_items, delete_items)
226
+ transact_items.map do |item|
227
+ # this code will assume users only provided one operation, and
228
+ # will fail down the line if that assumption is wrong
229
+ if save_record = item.delete(:save)
230
+ dirty_items << save_record
231
+ _transform_save_record(save_record, item)
232
+ elsif put_record = item.delete(:put)
233
+ dirty_items << put_record
234
+ _transform_put_record(put_record, item)
235
+ elsif delete_record = item.delete(:delete)
236
+ delete_items << delete_record
237
+ _transform_delete_record(delete_record, item)
238
+ elsif update_record = item.delete(:update)
239
+ dirty_items << update_record
240
+ _transform_update_record(update_record, item)
241
+ elsif check_record = item.delete(:check)
242
+ _transform_check_record(check_record, item)
243
+ else
244
+ raise ArgumentError.new(
245
+ "Invalid transact write item, must include an operation of "\
246
+ "type :save, :update, :delete, :update, or :check - #{item}"
247
+ )
248
+ end
249
+ end
250
+ end
251
+
252
+ def _transform_save_record(save_record, opts)
253
+ # determine if record is considered a new item or not
254
+ # then create a put with conditions, or an update
255
+ if save_record.send(:expect_new_item?)
256
+ safety_expression = save_record.send(:prevent_overwrite_expression)
257
+ if opts.include?(:condition_expression)
258
+ raise Errors::TransactionalSaveConditionCollision.new(
259
+ "Transactional write includes a :save operation that would "\
260
+ "result in a 'safe put' for the given item, yet a "\
261
+ "condition expression was also provided. This is not "\
262
+ "currently supported. You should rewrite this case to use "\
263
+ "a :put transaction, adding the existence check to your "\
264
+ "own condition expression if desired.\n"\
265
+ "\tItem: #{JSON.pretty_unparse(save_record.to_h)}\n"\
266
+ "\tExtra Options: #{JSON.pretty_unparse(opts)}"
267
+ )
268
+ else
269
+ opts = opts.merge(safety_expression)
270
+ _transform_put_record(save_record, opts)
271
+ end
272
+ else
273
+ _transform_update_record(save_record, opts)
274
+ end
275
+ end
276
+
277
+ def _transform_put_record(put_record, opts)
278
+ # convert to a straight put
279
+ opts[:table_name] = put_record.class.table_name
280
+ opts[:item] = put_record.send(:_build_item_for_save)
281
+ { put: opts }
282
+ end
283
+
284
+ def _transform_delete_record(delete_record, opts)
285
+ # extract the key from each record to perform a deletion
286
+ opts[:table_name] = delete_record.class.table_name
287
+ opts[:key] = delete_record.send(:key_values)
288
+ { delete: opts }
289
+ end
290
+
291
+ def _transform_update_record(update_record, opts)
292
+ # extract dirty attribute changes to perform an update
293
+ opts[:table_name] = update_record.class.table_name
294
+ dirty_changes = update_record.send(:_dirty_changes_for_update)
295
+ update_tuple = update_record.class.send(
296
+ :_build_update_expression,
297
+ dirty_changes
298
+ )
299
+ uex, exp_attr_names, exp_attr_values = update_tuple
300
+ opts[:key] = update_record.send(:key_values)
301
+ opts[:update_expression] = uex
302
+ # need to combine expression attribute names and values
303
+ if names = opts[:expression_attribute_names]
304
+ opts[:expression_attribute_names] = exp_attr_names.merge(names)
305
+ else
306
+ opts[:expression_attribute_names] = exp_attr_names
307
+ end
308
+ if values = opts[:expression_attribute_values]
309
+ opts[:expression_attribute_values] = exp_attr_values.merge(values)
310
+ else
311
+ opts[:expression_attribute_values] = exp_attr_values
312
+ end
313
+ { update: opts }
314
+ end
315
+
316
+ def _transform_check_record(check_record, opts)
317
+ # check records are a pass-through
318
+ { condition_check: opts.merge(check_record) }
319
+ end
320
+
321
+ def _user_agent(custom)
322
+ if custom
323
+ custom
324
+ else
325
+ " aws-record/#{VERSION}"
326
+ end
327
+ end
328
+
329
+ end
330
+ end
331
+ end
332
+ end
@@ -13,6 +13,6 @@
13
13
 
14
14
  module Aws
15
15
  module Record
16
- VERSION = '2.1.1'
16
+ VERSION = '2.4.0'
17
17
  end
18
18
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aws-record
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amazon Web Services
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-07-10 00:00:00.000000000 Z
11
+ date: 2020-05-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-dynamodb
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.0'
19
+ version: '1.18'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.0'
26
+ version: '1.18'
27
27
  description: Provides an object mapping abstraction for Amazon DynamoDB.
28
28
  email:
29
29
  - alexwood@amazon.com
@@ -35,6 +35,7 @@ files:
35
35
  - lib/aws-record/record.rb
36
36
  - lib/aws-record/record/attribute.rb
37
37
  - lib/aws-record/record/attributes.rb
38
+ - lib/aws-record/record/buildable_search.rb
38
39
  - lib/aws-record/record/dirty_tracking.rb
39
40
  - lib/aws-record/record/errors.rb
40
41
  - lib/aws-record/record/item_collection.rb
@@ -58,6 +59,7 @@ files:
58
59
  - lib/aws-record/record/secondary_indexes.rb
59
60
  - lib/aws-record/record/table_config.rb
60
61
  - lib/aws-record/record/table_migration.rb
62
+ - lib/aws-record/record/transactions.rb
61
63
  - lib/aws-record/record/version.rb
62
64
  homepage: http://github.com/aws/aws-sdk-ruby-record
63
65
  licenses:
@@ -78,8 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
80
  - !ruby/object:Gem::Version
79
81
  version: '0'
80
82
  requirements: []
81
- rubyforge_project:
82
- rubygems_version: 2.6.13
83
+ rubygems_version: 3.0.3
83
84
  signing_key:
84
85
  specification_version: 4
85
86
  summary: AWS Record library for Amazon DynamoDB