dynamoid 1.1.0 → 1.2.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.
@@ -17,7 +17,7 @@ module Dynamoid
17
17
 
18
18
  def tables
19
19
  if !@tables_.value
20
- @tables_.swap{|value, args| benchmark('Cache Tables') {list_tables}}
20
+ @tables_.swap{|value, args| benchmark('Cache Tables') { list_tables } }
21
21
  end
22
22
  @tables_.value
23
23
  end
@@ -131,7 +131,16 @@ module Dynamoid
131
131
  end
132
132
  end
133
133
 
134
- [:batch_get_item, :delete_item, :delete_table, :get_item, :list_tables, :put_item].each do |m|
134
+ # @since 0.2.0
135
+ def delete_table(table_name, *args)
136
+ if tables.include?(table_name)
137
+ benchmark('Delete Table') { adapter.delete_table(table_name, *args) }
138
+ idx = tables.index(table_name)
139
+ tables.delete_at(idx)
140
+ end
141
+ end
142
+
143
+ [:batch_get_item, :delete_item, :get_item, :list_tables, :put_item, :truncate].each do |m|
135
144
  # Method delegation with benchmark to the underlying adapter. Faster than relying on method_missing.
136
145
  #
137
146
  # @since 0.2.0
@@ -3,6 +3,33 @@ module Dynamoid
3
3
 
4
4
  # The AwsSdkV2 adapter provides support for the aws-sdk version 2 for ruby.
5
5
  class AwsSdkV2
6
+ EQ = "EQ".freeze
7
+ RANGE_MAP = {
8
+ range_greater_than: 'GT',
9
+ range_less_than: 'LT',
10
+ range_gte: 'GE',
11
+ range_lte: 'LE',
12
+ range_begins_with: 'BEGINS_WITH',
13
+ range_between: 'BETWEEN',
14
+ range_eq: 'EQ'
15
+ }
16
+ HASH_KEY = "HASH".freeze
17
+ RANGE_KEY = "RANGE".freeze
18
+ STRING_TYPE = "S".freeze
19
+ NUM_TYPE = "N".freeze
20
+ BINARY_TYPE = "B".freeze
21
+ TABLE_STATUSES = {
22
+ creating: "CREATING",
23
+ updating: "UPDATING",
24
+ deleting: "DELETING",
25
+ active: "ACTIVE"
26
+ }.freeze
27
+ PARSE_TABLE_STATUS = ->(resp, lookup = :table) {
28
+ # lookup is table for describe_table API
29
+ # lookup is table_description for create_table API
30
+ # because Amazon, damnit.
31
+ resp.send(lookup).table_status
32
+ }
6
33
  attr_reader :table_cache
7
34
 
8
35
  # Establish the connection to DynamoDB.
@@ -100,41 +127,91 @@ module Dynamoid
100
127
  # @param [String] table_name the name of the table to create
101
128
  # @param [Symbol] key the table's primary key (defaults to :id)
102
129
  # @param [Hash] options provide a range key here if the table has a composite key
103
- #
130
+ # @option options [Array<Dynamoid::Indexes::Index>] local_secondary_indexes
131
+ # @option options [Array<Dynamoid::Indexes::Index>] global_secondary_indexes
132
+ # @option options [Symbol] hash_key_type The type of the hash key
133
+ # @option options [Boolean] sync Wait for table status to be ACTIVE?
104
134
  # @since 1.0.0
105
135
  def create_table(table_name, key = :id, options = {})
106
136
  Dynamoid.logger.info "Creating #{table_name} table. This could take a while."
107
137
  read_capacity = options[:read_capacity] || Dynamoid::Config.read_capacity
108
138
  write_capacity = options[:write_capacity] || Dynamoid::Config.write_capacity
109
- range_key = options[:range_key]
110
139
 
111
- key_schema = [
112
- { attribute_name: key.to_s, key_type: HASH_KEY }
113
- ]
114
- key_schema << {
115
- attribute_name: range_key.keys.first.to_s, key_type: RANGE_KEY
116
- } if(range_key)
117
-
118
- #TODO: Provide support for number and binary hash key
119
- attribute_definitions = [
120
- { attribute_name: key.to_s, attribute_type: 'S' }
121
- ]
122
- attribute_definitions << {
123
- attribute_name: range_key.keys.first.to_s, attribute_type: api_type(range_key.values.first)
124
- } if(range_key)
125
-
126
- client.create_table(table_name: table_name,
140
+ secondary_indexes = options.slice(
141
+ :local_secondary_indexes,
142
+ :global_secondary_indexes
143
+ )
144
+ ls_indexes = options[:local_secondary_indexes]
145
+ gs_indexes = options[:global_secondary_indexes]
146
+
147
+ key_schema = {
148
+ :hash_key_schema => { key => (options[:hash_key_type] || :string) },
149
+ :range_key_schema => options[:range_key]
150
+ }
151
+ attribute_definitions = build_all_attribute_definitions(
152
+ key_schema,
153
+ secondary_indexes
154
+ )
155
+ key_schema = aws_key_schema(
156
+ key_schema[:hash_key_schema],
157
+ key_schema[:range_key_schema]
158
+ )
159
+
160
+ client_opts = {
161
+ table_name: table_name,
127
162
  provisioned_throughput: {
128
163
  read_capacity_units: read_capacity,
129
164
  write_capacity_units: write_capacity
130
165
  },
131
166
  key_schema: key_schema,
132
167
  attribute_definitions: attribute_definitions
133
- )
168
+ }
169
+
170
+ if ls_indexes.present?
171
+ client_opts[:local_secondary_indexes] = ls_indexes.map do |index|
172
+ index_to_aws_hash(index)
173
+ end
174
+ end
175
+
176
+ if gs_indexes.present?
177
+ client_opts[:global_secondary_indexes] = gs_indexes.map do |index|
178
+ index_to_aws_hash(index)
179
+ end
180
+ end
181
+ resp = client.create_table(client_opts)
182
+ options[:sync] = true if !options.has_key?(:sync) && ls_indexes.present? || gs_indexes.present?
183
+ until_past_table_status(table_name) if options[:sync] &&
184
+ (status = PARSE_TABLE_STATUS.call(resp, :table_description)) &&
185
+ status != TABLE_STATUSES[:creating]
186
+ # Response to original create_table, which, if options[:sync]
187
+ # may have an outdated table_description.table_status of "CREATING"
188
+ resp
134
189
  rescue Aws::DynamoDB::Errors::ResourceInUseException => e
135
190
  Dynamoid.logger.error "Table #{table_name} cannot be created as it already exists"
136
191
  end
137
192
 
193
+ # Create a table on DynamoDB *synchronously*.
194
+ # This usually takes a long time to complete.
195
+ # CreateTable is normally an asynchronous operation.
196
+ # You can optionally define secondary indexes on the new table,
197
+ # as part of the CreateTable operation.
198
+ # If you want to create multiple tables with secondary indexes on them,
199
+ # you must create the tables sequentially.
200
+ # Only one table with secondary indexes can be
201
+ # in the CREATING state at any given time.
202
+ # See: http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#create_table-instance_method
203
+ #
204
+ # @param [String] table_name the name of the table to create
205
+ # @param [Symbol] key the table's primary key (defaults to :id)
206
+ # @param [Hash] options provide a range key here if the table has a composite key
207
+ # @option options [Array<Dynamoid::Indexes::Index>] local_secondary_indexes
208
+ # @option options [Array<Dynamoid::Indexes::Index>] global_secondary_indexes
209
+ # @option options [Symbol] hash_key_type The type of the hash key
210
+ # @since 1.2.0
211
+ def create_table_synchronously(table_name, key = :id, options = {})
212
+ create_table(table_name, key, options.merge(sync: true))
213
+ end
214
+
138
215
  # Removes an item from DynamoDB.
139
216
  #
140
217
  # @param [String] table_name the name of the table
@@ -160,11 +237,22 @@ module Dynamoid
160
237
  # Deletes an entire table from DynamoDB.
161
238
  #
162
239
  # @param [String] table_name the name of the table to destroy
240
+ # @option options [Boolean] sync Wait for table status check to raise ResourceNotFoundException
163
241
  #
164
242
  # @since 1.0.0
165
- def delete_table(table_name)
166
- client.delete_table(table_name: table_name)
167
- table_cache.clear
243
+ def delete_table(table_name, options = {})
244
+ resp = client.delete_table(table_name: table_name)
245
+ until_past_table_status(table_name, :deleting) if options[:sync] &&
246
+ (status = PARSE_TABLE_STATUS.call(resp, :table_description)) &&
247
+ status != TABLE_STATUSES[:deleting]
248
+ table_cache.delete(table_name)
249
+ rescue Aws::DynamoDB::Errors::ResourceInUseException => e
250
+ Dynamoid.logger.error "Table #{table_name} cannot be deleted as it is in use"
251
+ raise e
252
+ end
253
+
254
+ def delete_table_synchronously(table_name, options = {})
255
+ delete_table(table_name, options.merge(sync: true))
168
256
  end
169
257
 
170
258
  # @todo Add a DescribeTable method.
@@ -275,14 +363,21 @@ module Dynamoid
275
363
  # @todo Provide support for various other options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#query-instance_method
276
364
  def query(table_name, opts = {})
277
365
  table = describe_table(table_name)
278
- hk = table.hash_key.to_s
279
- rng = table.range_key.to_s
280
- q = opts.slice(:consistent_read, :scan_index_forward, :limit, :select)
366
+ hk = (opts[:hash_key].present? ? opts[:hash_key] : table.hash_key).to_s
367
+ rng = (opts[:range_key].present? ? opts[:range_key] : table.range_key).to_s
368
+ q = opts.slice(
369
+ :consistent_read,
370
+ :scan_index_forward,
371
+ :limit,
372
+ :select,
373
+ :index_name
374
+ )
281
375
 
282
376
  opts.delete(:consistent_read)
283
377
  opts.delete(:scan_index_forward)
284
378
  opts.delete(:limit)
285
379
  opts.delete(:select)
380
+ opts.delete(:index_name)
286
381
 
287
382
  opts.delete(:next_token).tap do |token|
288
383
  break unless token
@@ -325,17 +420,6 @@ module Dynamoid
325
420
  }
326
421
  end
327
422
 
328
- EQ = "EQ".freeze
329
-
330
- RANGE_MAP = {
331
- range_greater_than: 'GT',
332
- range_less_than: 'LT',
333
- range_gte: 'GE',
334
- range_lte: 'LE',
335
- range_begins_with: 'BEGINS_WITH',
336
- range_between: 'BETWEEN'
337
- }
338
-
339
423
  # Scan the DynamoDB table. This is usually a very slow operation as it naively filters all data on
340
424
  # the DynamoDB servers.
341
425
  #
@@ -378,7 +462,6 @@ module Dynamoid
378
462
  end
379
463
  end
380
464
 
381
-
382
465
  #
383
466
  # Truncates all records in the given table
384
467
  #
@@ -391,7 +474,8 @@ module Dynamoid
391
474
  rk = table.range_key
392
475
 
393
476
  scan(table_name, {}, {}).each do |attributes|
394
- opts = {range_key: attributes[rk.to_sym] } if rk
477
+ opts = {}
478
+ opts[:range_key] = attributes[rk.to_sym] if rk
395
479
  delete_item(table_name, attributes[hk], opts)
396
480
  end
397
481
  end
@@ -402,18 +486,55 @@ module Dynamoid
402
486
 
403
487
  protected
404
488
 
405
- STRING_TYPE = "S".freeze
406
- NUM_TYPE = "N".freeze
407
- BOOLEAN_TYPE = "B".freeze
489
+ def check_table_status?(counter, resp, expect_status)
490
+ status = PARSE_TABLE_STATUS.call(resp)
491
+ again = counter < Dynamoid::Config.sync_retry_max_times &&
492
+ status == TABLE_STATUSES[expect_status]
493
+ {again: again, status: status, counter: counter}
494
+ end
495
+
496
+ def until_past_table_status(table_name, status = :creating)
497
+ counter = 0
498
+ resp = nil
499
+ begin
500
+ check = {again: true}
501
+ while check[:again]
502
+ sleep Dynamoid::Config.sync_retry_wait_seconds
503
+ resp = client.describe_table({ table_name: table_name })
504
+ check = check_table_status?(counter, resp, status)
505
+ Dynamoid.logger.info "Checked table status for #{table_name} (check #{check.inspect})"
506
+ counter += 1
507
+ end
508
+ # If you issue a DescribeTable request immediately after a CreateTable
509
+ # request, DynamoDB might return a ResourceNotFoundException.
510
+ # This is because DescribeTable uses an eventually consistent query,
511
+ # and the metadata for your table might not be available at that moment.
512
+ # Wait for a few seconds, and then try the DescribeTable request again.
513
+ # See: http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#describe_table-instance_method
514
+ rescue Aws::DynamoDB::Errors::ResourceNotFoundException => e
515
+ case status
516
+ when :creating then
517
+ if counter >= Dynamoid::Config.sync_retry_max_times
518
+ Dynamoid.logger.warn "Waiting on table metadata for #{table_name} (check #{counter})"
519
+ retry # start over at first line of begin, does not reset counter
520
+ else
521
+ Dynamoid.logger.error "Exhausted max retries (Dynamoid::Config.sync_retry_max_times) waiting on table metadata for #{table_name} (check #{counter})"
522
+ raise e
523
+ end
524
+ else
525
+ # When deleting a table, "not found" is the goal.
526
+ Dynamoid.logger.info "Checked table status for #{table_name}: Not Found (check #{check.inspect})"
527
+ end
528
+ end
529
+ end
408
530
 
409
531
  #Converts from symbol to the API string for the given data type
410
532
  # E.g. :number -> 'N'
411
533
  def api_type(type)
412
534
  case(type)
413
- when :string then STRING_TYPE
414
- when :number then NUM_TYPE
415
- when :datetime then NUM_TYPE
416
- when :boolean then BOOLEAN_TYPE
535
+ when :string then STRING_TYPE
536
+ when :number then NUM_TYPE
537
+ when :binary then BINARY_TYPE
417
538
  else raise "Unknown type: #{type}"
418
539
  end
419
540
  end
@@ -445,9 +566,6 @@ module Dynamoid
445
566
  expected
446
567
  end
447
568
 
448
- HASH_KEY = "HASH".freeze
449
- RANGE_KEY = "RANGE".freeze
450
-
451
569
  #
452
570
  # New, semi-arbitrary API to get data on the table
453
571
  #
@@ -466,6 +584,153 @@ module Dynamoid
466
584
  end
467
585
  end
468
586
 
587
+ # Converts a Dynamoid::Indexes::Index to an AWS API-compatible hash.
588
+ # This resulting hash is of the form:
589
+ #
590
+ # {
591
+ # index_name: String
592
+ # keys: {
593
+ # hash_key: aws_key_schema (hash)
594
+ # range_key: aws_key_schema (hash)
595
+ # }
596
+ # projection: {
597
+ # projection_type: (ALL, KEYS_ONLY, INCLUDE) String
598
+ # non_key_attributes: (optional) Array
599
+ # }
600
+ # provisioned_throughput: {
601
+ # read_capacity_units: Integer
602
+ # write_capacity_units: Integer
603
+ # }
604
+ # }
605
+ #
606
+ # @param [Dynamoid::Indexes::Index] index the index.
607
+ # @return [Hash] hash representing an AWS Index definition.
608
+ def index_to_aws_hash(index)
609
+ key_schema = aws_key_schema(index.hash_key_schema, index.range_key_schema)
610
+
611
+ hash = {
612
+ :index_name => index.name,
613
+ :key_schema => key_schema,
614
+ :projection => {
615
+ :projection_type => index.projection_type.to_s.upcase
616
+ }
617
+ }
618
+
619
+ # If the projection type is include, specify the non key attributes
620
+ if index.projection_type == :include
621
+ hash[:projection][:non_key_attributes] = index.projected_attributes
622
+ end
623
+
624
+ # Only global secondary indexes have a separate throughput.
625
+ if index.type == :global_secondary
626
+ hash[:provisioned_throughput] = {
627
+ :read_capacity_units => index.read_capacity,
628
+ :write_capacity_units => index.write_capacity
629
+ }
630
+ end
631
+ hash
632
+ end
633
+
634
+ # Converts hash_key_schema and range_key_schema to aws_key_schema
635
+ # @param [Hash] hash_key_schema eg: {:id => :string}
636
+ # @param [Hash] range_key_schema eg: {:created_at => :number}
637
+ # @return [Array]
638
+ def aws_key_schema(hash_key_schema, range_key_schema)
639
+ schema = [{
640
+ attribute_name: hash_key_schema.keys.first.to_s,
641
+ key_type: HASH_KEY
642
+ }]
643
+
644
+ if range_key_schema.present?
645
+ schema << {
646
+ attribute_name: range_key_schema.keys.first.to_s,
647
+ key_type: RANGE_KEY
648
+ }
649
+ end
650
+ schema
651
+ end
652
+
653
+ # Builds aws attributes definitions based off of primary hash/range and
654
+ # secondary indexes
655
+ #
656
+ # @param key_data
657
+ # @option key_data [Hash] hash_key_schema - eg: {:id => :string}
658
+ # @option key_data [Hash] range_key_schema - eg: {:created_at => :number}
659
+ # @param [Hash] secondary_indexes
660
+ # @option secondary_indexes [Array<Dynamoid::Indexes::Index>] :local_secondary_indexes
661
+ # @option secondary_indexes [Array<Dynamoid::Indexes::Index>] :global_secondary_indexes
662
+ def build_all_attribute_definitions(key_schema, secondary_indexes = {})
663
+ ls_indexes = secondary_indexes[:local_secondary_indexes]
664
+ gs_indexes = secondary_indexes[:global_secondary_indexes]
665
+
666
+ attribute_definitions = []
667
+
668
+ attribute_definitions << build_attribute_definitions(
669
+ key_schema[:hash_key_schema],
670
+ key_schema[:range_key_schema]
671
+ )
672
+
673
+ if ls_indexes.present?
674
+ ls_indexes.map do |index|
675
+ attribute_definitions << build_attribute_definitions(
676
+ index.hash_key_schema,
677
+ index.range_key_schema
678
+ )
679
+ end
680
+ end
681
+
682
+ if gs_indexes.present?
683
+ gs_indexes.map do |index|
684
+ attribute_definitions << build_attribute_definitions(
685
+ index.hash_key_schema,
686
+ index.range_key_schema
687
+ )
688
+ end
689
+ end
690
+
691
+ attribute_definitions.flatten!
692
+ # uniq these definitions because range keys might be common between
693
+ # primary and secondary indexes
694
+ attribute_definitions.uniq!
695
+ attribute_definitions
696
+ end
697
+
698
+
699
+ # Builds an attribute definitions based on hash key and range key
700
+ # @params [Hash] hash_key_schema - eg: {:id => :string}
701
+ # @params [Hash] range_key_schema - eg: {:created_at => :datetime}
702
+ # @return [Array]
703
+ def build_attribute_definitions(hash_key_schema, range_key_schema = nil)
704
+ attrs = []
705
+
706
+ attrs << attribute_definition_element(
707
+ hash_key_schema.keys.first,
708
+ hash_key_schema.values.first
709
+ )
710
+
711
+ if range_key_schema.present?
712
+ attrs << attribute_definition_element(
713
+ range_key_schema.keys.first,
714
+ range_key_schema.values.first
715
+ )
716
+ end
717
+
718
+ attrs
719
+ end
720
+
721
+ # Builds an aws attribute definition based on name and dynamoid type
722
+ # @params [Symbol] name - eg: :id
723
+ # @params [Symbol] dynamoid_type - eg: :string
724
+ # @return [Hash]
725
+ def attribute_definition_element(name, dynamoid_type)
726
+ aws_type = api_type(dynamoid_type)
727
+
728
+ {
729
+ :attribute_name => name.to_s,
730
+ :attribute_type => aws_type
731
+ }
732
+ end
733
+
469
734
  #
470
735
  # Represents a table. Exposes data from the "DescribeTable" API call, and also
471
736
  # provides methods for coercing values to the proper types based on the table's schema data