aws-record 1.0.3 → 1.1.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.
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