dynamoid 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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