aws-record 1.0.3 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1ad49e4e5f6ac03a577a97f00f2312c37dfbe3ed
4
- data.tar.gz: f5bf877eb339d5206f24f3c26e78adefdd08ff30
3
+ metadata.gz: 707c0dda4d338ec8c39441124f050922181ca6a5
4
+ data.tar.gz: 945f67f08fa5230b7c3f7dac7d95138a1017ee9c
5
5
  SHA512:
6
- metadata.gz: e816d83098562cf009da4c44520605260f22440d6f384fd55c94e30e47b789fa1106cda1b50c4a47fc314ee9b83c254e6e781edb464f46035fb63e42ab347c28
7
- data.tar.gz: 6c501354f1de3358c4ee475ad8c8dd017d5e6a9b748af57b570114781d1b614a3f4ba27b9ea3944b2a0c36559ee76586ad2cfb92ae91feb46a9f1a46674aec04
6
+ metadata.gz: f58be26c4c9c1215a293fbbcc1484ca4993d305edeac7e16182df127a9fe80620135024039190eb9894d3ed5f58554a02cf93cd091b73c9b133c7c739d4a90ba
7
+ data.tar.gz: 056e886c4d5146019525d9aa5eb25e2f4e195dfee69abf9105be266210ca6e20daee3f0b5a3be66ab390d30d5eb7506e83ac0cd0842e62242fd602ce3f0d5ce0
@@ -28,6 +28,7 @@ module Aws
28
28
  autoload :ModelAttributes, 'aws-record/record/model_attributes'
29
29
  autoload :Query, 'aws-record/record/query'
30
30
  autoload :SecondaryIndexes, 'aws-record/record/secondary_indexes'
31
+ autoload :TableConfig, 'aws-record/record/table_config'
31
32
  autoload :TableMigration, 'aws-record/record/table_migration'
32
33
  autoload :VERSION, 'aws-record/record/version'
33
34
 
@@ -53,6 +53,7 @@ module Aws
53
53
  # not exist.
54
54
  class TableDoesNotExist < RuntimeError; end
55
55
 
56
+ class MissingRequiredConfiguration < RuntimeError; end
56
57
  end
57
58
  end
58
59
  end
@@ -89,14 +89,17 @@ module Aws
89
89
  private
90
90
  def _migration_format_indexes(indexes)
91
91
  return nil if indexes.empty?
92
- indexes.collect do |name, opts|
92
+ mfi = indexes.collect do |name, opts|
93
93
  h = { index_name: name }
94
94
  h[:key_schema] = _si_key_schema(opts)
95
- opts.delete(:hash_key)
96
- opts.delete(:range_key)
95
+ hk = opts.delete(:hash_key)
96
+ rk = opts.delete(:range_key)
97
97
  h = h.merge(opts)
98
+ opts[:hash_key] = hk if hk
99
+ opts[:range_key] = rk if rk
98
100
  h
99
101
  end
102
+ mfi
100
103
  end
101
104
 
102
105
  def _si_key_schema(opts)
@@ -0,0 +1,514 @@
1
+ # Copyright 2015-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may not
4
+ # use this file except in compliance with the License. A copy of the License is
5
+ # located at
6
+ #
7
+ # http://aws.amazon.com/apache2.0/
8
+ #
9
+ # or in the "license" file accompanying this file. This file is distributed on
10
+ # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11
+ # or implied. See the License for the specific language governing permissions
12
+ # and limitations under the License.
13
+
14
+ module Aws
15
+ module Record
16
+
17
+ # +Aws::Record::TableConfig+ provides a DSL for describing and modifying
18
+ # the remote configuration of your DynamoDB tables. A table configuration
19
+ # object can perform intelligent comparisons and incremental migrations
20
+ # versus the current remote configuration, if the table exists, or do a full
21
+ # create if it does not. In this manner, table configuration becomes fully
22
+ # declarative.
23
+ #
24
+ # @example A basic model with configuration.
25
+ # class Model
26
+ # include Aws::Record
27
+ # string_attr :uuid, hash_key: true
28
+ # end
29
+ #
30
+ # table_config = Aws::Record::TableConfig.define do |t|
31
+ # t.model_class Model
32
+ # t.read_capacity_units 10
33
+ # t.write_capacity_units 5
34
+ # end
35
+ #
36
+ # @example Running a conditional migration on a basic model.
37
+ # table_config = Aws::Record::TableConfig.define do |t|
38
+ # t.model_class Model
39
+ # t.read_capacity_units 10
40
+ # t.write_capacity_units 5
41
+ # end
42
+ #
43
+ # table_config.migrate! unless table_config.compatible?
44
+ #
45
+ # @example A model with a global secondary index.
46
+ # class Forum
47
+ # include Aws::Record
48
+ # string_attr :forum_uuid, hash_key: true
49
+ # integer_attr :post_id, range_key: true
50
+ # string_attr :post_title
51
+ # string_attr :post_body
52
+ # string_attr :author_username
53
+ # datetime_attr :created_date
54
+ # datetime_attr :updated_date
55
+ # string_set_attr :tags
56
+ # map_attr :metadata, default_value: {}
57
+ #
58
+ # global_secondary_index(
59
+ # :title,
60
+ # hash_key: :forum_uuid,
61
+ # range_key: :post_title,
62
+ # projection_type: "ALL"
63
+ # )
64
+ # end
65
+ #
66
+ # table_config = Aws::Record::TableConfig.define do |t|
67
+ # t.model_class Forum
68
+ # t.read_capacity_units 10
69
+ # t.write_capacity_units 5
70
+ #
71
+ # t.global_secondary_index(:title) do |i|
72
+ # i.read_capacity_units 5
73
+ # i.write_capacity_units 5
74
+ # end
75
+ # end
76
+ #
77
+ class TableConfig
78
+
79
+ attr_accessor :client
80
+
81
+ class << self
82
+
83
+ # Creates a new table configuration, using a DSL in the provided block.
84
+ # The DSL has the following methods:
85
+ # * +#model_class+ A class name reference to the +Aws::Record+ model
86
+ # class.
87
+ # * +#read_capacity_units+ Sets the read capacity units for the table.
88
+ # * +#write_capacity_units+ Sets the write capacity units for the table.
89
+ # * +#global_secondary_index(index_symbol, &block)+ Defines a global
90
+ # secondary index with capacity attributes in a block:
91
+ # * +#read_capacity_units+ Sets the read capacity units for the
92
+ # index.
93
+ # * +#write_capacity_units+ Sets the write capacity units for the
94
+ # index.
95
+ #
96
+ # @example Defining a migration with a GSI.
97
+ # class Forum
98
+ # include Aws::Record
99
+ # string_attr :forum_uuid, hash_key: true
100
+ # integer_attr :post_id, range_key: true
101
+ # string_attr :post_title
102
+ # string_attr :post_body
103
+ # string_attr :author_username
104
+ # datetime_attr :created_date
105
+ # datetime_attr :updated_date
106
+ # string_set_attr :tags
107
+ # map_attr :metadata, default_value: {}
108
+ #
109
+ # global_secondary_index(
110
+ # :title,
111
+ # hash_key: :forum_uuid,
112
+ # range_key: :post_title,
113
+ # projection_type: "ALL"
114
+ # )
115
+ # end
116
+ #
117
+ # table_config = Aws::Record::TableConfig.define do |t|
118
+ # t.model_class Forum
119
+ # t.read_capacity_units 10
120
+ # t.write_capacity_units 5
121
+ #
122
+ # t.global_secondary_index(:title) do |i|
123
+ # i.read_capacity_units 5
124
+ # i.write_capacity_units 5
125
+ # end
126
+ # end
127
+ def define(&block)
128
+ cfg = TableConfig.new
129
+ cfg.instance_eval(&block)
130
+ cfg.configure_client
131
+ cfg
132
+ end
133
+ end
134
+
135
+ # @api private
136
+ def initialize
137
+ @client_options = {}
138
+ @global_secondary_indexes = {}
139
+ end
140
+
141
+ # @api private
142
+ def model_class(model)
143
+ @model_class = model
144
+ end
145
+
146
+ # @api private
147
+ def read_capacity_units(units)
148
+ @read_capacity_units = units
149
+ end
150
+
151
+ # @api private
152
+ def write_capacity_units(units)
153
+ @write_capacity_units = units
154
+ end
155
+
156
+ # @api private
157
+ def global_secondary_index(name, &block)
158
+ gsi = GlobalSecondaryIndex.new
159
+ gsi.instance_eval(&block)
160
+ @global_secondary_indexes[name] = gsi
161
+ end
162
+
163
+ # @api private
164
+ def client_options(opts)
165
+ @client_options = opts
166
+ end
167
+
168
+ # @api private
169
+ def configure_client
170
+ @client = Aws::DynamoDB::Client.new(@client_options)
171
+ end
172
+
173
+ # Performs a migration, if needed, against the remote table. If
174
+ # +#compatible?+ would return true, the remote table already has the same
175
+ # throughput, key schema, attribute definitions, and global secondary
176
+ # indexes, so no further API calls are made. Otherwise, a DynamoDB table
177
+ # will be created or updated to match your declared configuration.
178
+ def migrate!
179
+ _validate_required_configuration
180
+ begin
181
+ resp = @client.describe_table(table_name: @model_class.table_name)
182
+ if _compatible_check(resp)
183
+ nil
184
+ else
185
+ # Gotcha: You need separate migrations for indexes and throughput
186
+ unless _throughput_equal(resp)
187
+ @client.update_table(
188
+ table_name: @model_class.table_name,
189
+ provisioned_throughput: {
190
+ read_capacity_units: @read_capacity_units,
191
+ write_capacity_units: @write_capacity_units
192
+ }
193
+ )
194
+ @client.wait_until(
195
+ :table_exists,
196
+ table_name: @model_class.table_name
197
+ )
198
+ end
199
+ unless _gsi_superset(resp)
200
+ @client.update_table(_update_index_opts(resp))
201
+ @client.wait_until(
202
+ :table_exists,
203
+ table_name: @model_class.table_name
204
+ )
205
+ end
206
+ end
207
+ rescue DynamoDB::Errors::ResourceNotFoundException
208
+ # Code Smell: Exception as control flow.
209
+ # Can I use SDK ability to skip raising an exception for this?
210
+ @client.create_table(_create_table_opts)
211
+ @client.wait_until(:table_exists, table_name: @model_class.table_name)
212
+ end
213
+ end
214
+
215
+ # Checks the remote table for compatibility. Similar to +#exact_match?+,
216
+ # this will return +false+ if the remote table does not exist. It also
217
+ # checks the keys, declared global secondary indexes, declared attribute
218
+ # definitions, and throughput for exact matches. However, if the remote
219
+ # end has additional attribute definitions and global secondary indexes
220
+ # not defined in your config, will still return +true+. This allows for a
221
+ # check that is friendly to single table inheritance use cases.
222
+ #
223
+ # @return [Boolean] true if remote is compatible, false otherwise.
224
+ def compatible?
225
+ begin
226
+ resp = @client.describe_table(table_name: @model_class.table_name)
227
+ _compatible_check(resp)
228
+ rescue DynamoDB::Errors::ResourceNotFoundException
229
+ false
230
+ end
231
+ end
232
+
233
+ # Checks against the remote table's configuration. If the remote table
234
+ # does not exist, guaranteed +false+. Otherwise, will check if the remote
235
+ # throughput, keys, attribute definitions, and global secondary indexes
236
+ # are exactly equal to your declared configuration.
237
+ #
238
+ # @return [Boolean] true if remote is an exact match, false otherwise.
239
+ def exact_match?
240
+ begin
241
+ resp = @client.describe_table(table_name: @model_class.table_name)
242
+ _throughput_equal(resp) &&
243
+ _keys_equal(resp) &&
244
+ _ad_equal(resp) &&
245
+ _gsi_equal(resp)
246
+ rescue DynamoDB::Errors::ResourceNotFoundException
247
+ false
248
+ end
249
+ end
250
+
251
+ private
252
+ def _compatible_check(resp)
253
+ _throughput_equal(resp) &&
254
+ _keys_equal(resp) &&
255
+ _ad_superset(resp) &&
256
+ _gsi_superset(resp)
257
+ end
258
+
259
+ def _create_table_opts
260
+ opts = {
261
+ table_name: @model_class.table_name,
262
+ provisioned_throughput: {
263
+ read_capacity_units: @read_capacity_units,
264
+ write_capacity_units: @write_capacity_units
265
+ }
266
+ }
267
+ opts[:key_schema] = _key_schema
268
+ opts[:attribute_definitions] = _attribute_definitions
269
+ gsi = _global_secondary_indexes
270
+ unless gsi.empty?
271
+ opts[:global_secondary_indexes] = gsi
272
+ end
273
+ opts
274
+ end
275
+
276
+ def _update_index_opts(resp)
277
+ gsi_updates, attribute_definitions = _gsi_updates(resp)
278
+ opts = {
279
+ table_name: @model_class.table_name,
280
+ global_secondary_index_updates: gsi_updates
281
+ }
282
+ unless attribute_definitions.empty?
283
+ opts[:attribute_definitions] = attribute_definitions
284
+ end
285
+ opts
286
+ end
287
+
288
+ def _gsi_updates(resp)
289
+ gsi_updates = []
290
+ attributes_referenced = Set.new
291
+ remote_gsis = resp.table.global_secondary_indexes
292
+ local_gsis = _global_secondary_indexes
293
+ remote_idx, local_idx = _gsi_index_names(remote_gsis, local_gsis)
294
+ create_candidates = local_idx - remote_idx
295
+ update_candidates = local_idx.intersection(remote_idx)
296
+ create_candidates.each do |index_name|
297
+ gsi = @model_class.global_secondary_indexes_for_migration.find do |i|
298
+ i[:index_name].to_s == index_name
299
+ end
300
+ gsi[:key_schema].each do |k|
301
+ attributes_referenced.add(k[:attribute_name])
302
+ end
303
+ # This may be a problem, check if I can maintain symbols.
304
+ lgsi = @global_secondary_indexes[index_name.to_sym]
305
+ gsi[:provisioned_throughput] = lgsi.provisioned_throughput
306
+ gsi_updates << {
307
+ create: gsi
308
+ }
309
+ end
310
+ update_candidates.each do |index_name|
311
+ # This may be a problem, check if I can maintain symbols.
312
+ lgsi = @global_secondary_indexes[index_name.to_sym]
313
+ gsi_updates << {
314
+ update: {
315
+ index_name: index_name,
316
+ provisioned_throughput: lgsi.provisioned_throughput
317
+ }
318
+ }
319
+ end
320
+ attribute_definitions = _attribute_definitions
321
+ incremental_attributes = attributes_referenced.map do |attr_name|
322
+ attribute_definitions.find do |ad|
323
+ ad[:attribute_name] == attr_name
324
+ end
325
+ end
326
+ [gsi_updates, incremental_attributes]
327
+ end
328
+
329
+ def _key_schema
330
+ _keys.map do |type, attr|
331
+ {
332
+ attribute_name: attr.database_name,
333
+ key_type: type == :hash ? "HASH" : "RANGE"
334
+ }
335
+ end
336
+ end
337
+
338
+ def _attribute_definitions
339
+ attribute_definitions = _keys.map do |type, attr|
340
+ {
341
+ attribute_name: attr.database_name,
342
+ attribute_type: attr.dynamodb_type
343
+ }
344
+ end
345
+ @model_class.global_secondary_indexes.each do |_, attributes|
346
+ gsi_keys = [attributes[:hash_key]]
347
+ gsi_keys << attributes[:range_key] if attributes[:range_key]
348
+ gsi_keys.each do |name|
349
+ attribute = @model_class.attributes.attribute_for(name)
350
+ exists = attribute_definitions.any? do |ad|
351
+ ad[:attribute_name] == attribute.database_name
352
+ end
353
+ unless exists
354
+ attribute_definitions << {
355
+ attribute_name: attribute.database_name,
356
+ attribute_type: attribute.dynamodb_type
357
+ }
358
+ end
359
+ end
360
+ end
361
+ attribute_definitions
362
+ end
363
+
364
+ def _keys
365
+ @model_class.keys.inject({}) do |acc, (type, name)|
366
+ acc[type] = @model_class.attributes.attribute_for(name)
367
+ acc
368
+ end
369
+ end
370
+
371
+ def _throughput_equal(resp)
372
+ expected = resp.table.provisioned_throughput.to_h
373
+ actual = {
374
+ read_capacity_units: @read_capacity_units,
375
+ write_capacity_units: @write_capacity_units
376
+ }
377
+ actual.all? do |k,v|
378
+ expected[k] == v
379
+ end
380
+ end
381
+
382
+ def _keys_equal(resp)
383
+ remote_key_schema = resp.table.key_schema.map { |i| i.to_h }
384
+ _array_unsorted_eql(remote_key_schema, _key_schema)
385
+ end
386
+
387
+ def _ad_equal(resp)
388
+ remote_ad = resp.table.attribute_definitions.map { |i| i.to_h }
389
+ _array_unsorted_eql(remote_ad, _attribute_definitions)
390
+ end
391
+
392
+ def _ad_superset(resp)
393
+ remote_ad = resp.table.attribute_definitions.map { |i| i.to_h }
394
+ _attribute_definitions.all? do |attribute_definition|
395
+ remote_ad.include?(attribute_definition)
396
+ end
397
+ end
398
+
399
+ def _gsi_superset(resp)
400
+ remote_gsis = resp.table.global_secondary_indexes
401
+ local_gsis = _global_secondary_indexes
402
+ remote_idx, local_idx = _gsi_index_names(remote_gsis, local_gsis)
403
+ if local_idx.subset?(remote_idx)
404
+ _gsi_set_compare(remote_gsis, local_gsis)
405
+ else
406
+ # If we have any local indexes not on the remote table,
407
+ # guaranteed false.
408
+ false
409
+ end
410
+ end
411
+
412
+ def _gsi_equal(resp)
413
+ remote_gsis = resp.table.global_secondary_indexes
414
+ local_gsis = _global_secondary_indexes
415
+ remote_idx, local_idx = _gsi_index_names(remote_gsis, local_gsis)
416
+ if local_idx == remote_idx
417
+ _gsi_set_compare(remote_gsis, local_gsis)
418
+ else
419
+ false
420
+ end
421
+ end
422
+
423
+ def _gsi_set_compare(remote_gsis, local_gsis)
424
+ local_gsis.all? do |lgsi|
425
+ rgsi = remote_gsis.find do |r|
426
+ r.index_name == lgsi[:index_name].to_s
427
+ end
428
+
429
+ remote_key_schema = rgsi.key_schema.map { |i| i.to_h }
430
+ ks_match = _array_unsorted_eql(remote_key_schema, lgsi[:key_schema])
431
+
432
+ rpt = rgsi.provisioned_throughput.to_h
433
+ lpt = lgsi[:provisioned_throughput]
434
+ pt_match = lpt.all? do |k,v|
435
+ rpt[k] == v
436
+ end
437
+
438
+ rp = rgsi.projection.to_h
439
+ lp = lgsi[:projection]
440
+ rp[:non_key_attributes].sort! if rp[:non_key_attributes]
441
+ lp[:non_key_attributes].sort! if lp[:non_key_attributes]
442
+ p_match = rp == lp
443
+
444
+ ks_match && pt_match && p_match
445
+ end
446
+ end
447
+
448
+ def _gsi_index_names(remote, local)
449
+ remote_index_names = Set.new
450
+ local_index_names = Set.new
451
+ if remote
452
+ remote.each do |gsi|
453
+ remote_index_names.add(gsi.index_name)
454
+ end
455
+ end
456
+ if local
457
+ local.each do |gsi|
458
+ local_index_names.add(gsi[:index_name].to_s)
459
+ end
460
+ end
461
+ [remote_index_names, local_index_names]
462
+ end
463
+
464
+ def _global_secondary_indexes
465
+ gsis = []
466
+ model_gsis = @model_class.global_secondary_indexes_for_migration
467
+ gsi_config = @global_secondary_indexes
468
+ if model_gsis
469
+ model_gsis.each do |mgsi|
470
+ config = gsi_config[mgsi[:index_name]]
471
+ # Validate throughput exists? Validate each throughput is in model?
472
+ gsis << mgsi.merge(
473
+ provisioned_throughput: config.provisioned_throughput
474
+ )
475
+ end
476
+ end
477
+ gsis
478
+ end
479
+
480
+ def _array_unsorted_eql(a, b)
481
+ a.all? { |x| b.include?(x) } && b.all? { |x| a.include?(x) }
482
+ end
483
+
484
+ def _validate_required_configuration
485
+ missing_config = []
486
+ missing_config << 'model_class' unless @model_class
487
+ missing_config << 'read_capacity_units' unless @read_capacity_units
488
+ missing_config << 'write_capacity_units' unless @write_capacity_units
489
+ unless missing_config.empty?
490
+ msg = missing_config.join(', ')
491
+ raise Errors::MissingRequiredConfiguration, 'Missing: ' + msg
492
+ end
493
+ end
494
+
495
+ # @api private
496
+ class GlobalSecondaryIndex
497
+ attr_reader :provisioned_throughput
498
+
499
+ def initialize
500
+ @provisioned_throughput = {}
501
+ end
502
+
503
+ def read_capacity_units(units)
504
+ @provisioned_throughput[:read_capacity_units] = units
505
+ end
506
+
507
+ def write_capacity_units(units)
508
+ @provisioned_throughput[:write_capacity_units] = units
509
+ end
510
+ end
511
+
512
+ end
513
+ end
514
+ end
@@ -13,6 +13,6 @@
13
13
 
14
14
  module Aws
15
15
  module Record
16
- VERSION = '1.0.3'
16
+ VERSION = '1.1.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: 1.0.3
4
+ version: 1.1.0
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: 2016-12-19 00:00:00.000000000 Z
11
+ date: 2017-04-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-resources
@@ -54,6 +54,7 @@ files:
54
54
  - lib/aws-record/record/model_attributes.rb
55
55
  - lib/aws-record/record/query.rb
56
56
  - lib/aws-record/record/secondary_indexes.rb
57
+ - lib/aws-record/record/table_config.rb
57
58
  - lib/aws-record/record/table_migration.rb
58
59
  - lib/aws-record/record/version.rb
59
60
  homepage: http://github.com/aws/aws-sdk-ruby-record