dynamoid 0.7.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +2 -24
  3. data/README.markdown +89 -73
  4. data/Rakefile +10 -36
  5. data/dynamoid.gemspec +56 -191
  6. data/lib/dynamoid.rb +6 -4
  7. data/lib/dynamoid/adapter.rb +64 -150
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v2.rb +579 -0
  9. data/lib/dynamoid/components.rb +0 -1
  10. data/lib/dynamoid/config.rb +2 -5
  11. data/lib/dynamoid/criteria.rb +1 -1
  12. data/lib/dynamoid/criteria/chain.rb +27 -140
  13. data/lib/dynamoid/document.rb +2 -2
  14. data/lib/dynamoid/errors.rb +30 -9
  15. data/lib/dynamoid/fields.rb +15 -3
  16. data/lib/dynamoid/finders.rb +7 -6
  17. data/lib/dynamoid/identity_map.rb +1 -5
  18. data/lib/dynamoid/persistence.rb +108 -93
  19. metadata +56 -229
  20. data/.document +0 -5
  21. data/.rspec +0 -1
  22. data/.travis.yml +0 -7
  23. data/Gemfile.lock +0 -81
  24. data/Gemfile_activemodel4 +0 -24
  25. data/Gemfile_activemodel4.lock +0 -88
  26. data/VERSION +0 -1
  27. data/doc/.nojekyll +0 -0
  28. data/doc/Dynamoid.html +0 -328
  29. data/doc/Dynamoid/Adapter.html +0 -1872
  30. data/doc/Dynamoid/Adapter/AwsSdk.html +0 -2101
  31. data/doc/Dynamoid/Adapter/Local.html +0 -1574
  32. data/doc/Dynamoid/Associations.html +0 -138
  33. data/doc/Dynamoid/Associations/Association.html +0 -847
  34. data/doc/Dynamoid/Associations/BelongsTo.html +0 -161
  35. data/doc/Dynamoid/Associations/ClassMethods.html +0 -766
  36. data/doc/Dynamoid/Associations/HasAndBelongsToMany.html +0 -167
  37. data/doc/Dynamoid/Associations/HasMany.html +0 -167
  38. data/doc/Dynamoid/Associations/HasOne.html +0 -161
  39. data/doc/Dynamoid/Associations/ManyAssociation.html +0 -1684
  40. data/doc/Dynamoid/Associations/SingleAssociation.html +0 -627
  41. data/doc/Dynamoid/Components.html +0 -242
  42. data/doc/Dynamoid/Config.html +0 -412
  43. data/doc/Dynamoid/Config/Options.html +0 -638
  44. data/doc/Dynamoid/Criteria.html +0 -138
  45. data/doc/Dynamoid/Criteria/Chain.html +0 -1471
  46. data/doc/Dynamoid/Criteria/ClassMethods.html +0 -105
  47. data/doc/Dynamoid/Dirty.html +0 -424
  48. data/doc/Dynamoid/Dirty/ClassMethods.html +0 -174
  49. data/doc/Dynamoid/Document.html +0 -1033
  50. data/doc/Dynamoid/Document/ClassMethods.html +0 -1116
  51. data/doc/Dynamoid/Errors.html +0 -125
  52. data/doc/Dynamoid/Errors/ConditionalCheckFailedException.html +0 -141
  53. data/doc/Dynamoid/Errors/DocumentNotValid.html +0 -221
  54. data/doc/Dynamoid/Errors/Error.html +0 -137
  55. data/doc/Dynamoid/Errors/InvalidField.html +0 -141
  56. data/doc/Dynamoid/Errors/InvalidQuery.html +0 -131
  57. data/doc/Dynamoid/Errors/MissingRangeKey.html +0 -141
  58. data/doc/Dynamoid/Fields.html +0 -686
  59. data/doc/Dynamoid/Fields/ClassMethods.html +0 -438
  60. data/doc/Dynamoid/Finders.html +0 -135
  61. data/doc/Dynamoid/Finders/ClassMethods.html +0 -943
  62. data/doc/Dynamoid/IdentityMap.html +0 -492
  63. data/doc/Dynamoid/IdentityMap/ClassMethods.html +0 -534
  64. data/doc/Dynamoid/Indexes.html +0 -321
  65. data/doc/Dynamoid/Indexes/ClassMethods.html +0 -369
  66. data/doc/Dynamoid/Indexes/Index.html +0 -1142
  67. data/doc/Dynamoid/Middleware.html +0 -115
  68. data/doc/Dynamoid/Middleware/IdentityMap.html +0 -264
  69. data/doc/Dynamoid/Persistence.html +0 -892
  70. data/doc/Dynamoid/Persistence/ClassMethods.html +0 -836
  71. data/doc/Dynamoid/Validations.html +0 -415
  72. data/doc/_index.html +0 -506
  73. data/doc/class_list.html +0 -53
  74. data/doc/css/common.css +0 -1
  75. data/doc/css/full_list.css +0 -57
  76. data/doc/css/style.css +0 -338
  77. data/doc/file.LICENSE.html +0 -73
  78. data/doc/file.README.html +0 -416
  79. data/doc/file_list.html +0 -58
  80. data/doc/frames.html +0 -28
  81. data/doc/index.html +0 -416
  82. data/doc/js/app.js +0 -214
  83. data/doc/js/full_list.js +0 -178
  84. data/doc/js/jquery.js +0 -4
  85. data/doc/method_list.html +0 -1144
  86. data/doc/top-level-namespace.html +0 -112
  87. data/lib/dynamoid/adapter/aws_sdk.rb +0 -287
  88. data/lib/dynamoid/indexes.rb +0 -69
  89. data/lib/dynamoid/indexes/index.rb +0 -103
  90. data/spec/app/models/address.rb +0 -13
  91. data/spec/app/models/camel_case.rb +0 -34
  92. data/spec/app/models/car.rb +0 -6
  93. data/spec/app/models/magazine.rb +0 -11
  94. data/spec/app/models/message.rb +0 -9
  95. data/spec/app/models/nuclear_submarine.rb +0 -5
  96. data/spec/app/models/sponsor.rb +0 -8
  97. data/spec/app/models/subscription.rb +0 -12
  98. data/spec/app/models/tweet.rb +0 -12
  99. data/spec/app/models/user.rb +0 -26
  100. data/spec/app/models/vehicle.rb +0 -7
  101. data/spec/dynamoid/adapter/aws_sdk_spec.rb +0 -376
  102. data/spec/dynamoid/adapter_spec.rb +0 -155
  103. data/spec/dynamoid/associations/association_spec.rb +0 -194
  104. data/spec/dynamoid/associations/belongs_to_spec.rb +0 -71
  105. data/spec/dynamoid/associations/has_and_belongs_to_many_spec.rb +0 -47
  106. data/spec/dynamoid/associations/has_many_spec.rb +0 -42
  107. data/spec/dynamoid/associations/has_one_spec.rb +0 -45
  108. data/spec/dynamoid/associations_spec.rb +0 -16
  109. data/spec/dynamoid/config_spec.rb +0 -27
  110. data/spec/dynamoid/criteria/chain_spec.rb +0 -210
  111. data/spec/dynamoid/criteria_spec.rb +0 -75
  112. data/spec/dynamoid/dirty_spec.rb +0 -57
  113. data/spec/dynamoid/document_spec.rb +0 -180
  114. data/spec/dynamoid/fields_spec.rb +0 -156
  115. data/spec/dynamoid/finders_spec.rb +0 -147
  116. data/spec/dynamoid/identity_map_spec.rb +0 -45
  117. data/spec/dynamoid/indexes/index_spec.rb +0 -104
  118. data/spec/dynamoid/indexes_spec.rb +0 -25
  119. data/spec/dynamoid/persistence_spec.rb +0 -301
  120. data/spec/dynamoid/validations_spec.rb +0 -36
  121. data/spec/dynamoid_spec.rb +0 -9
  122. data/spec/spec_helper.rb +0 -55
  123. data/spec/support/with_partitioning.rb +0 -15
@@ -1,6 +1,7 @@
1
1
  require "delegate"
2
2
  require "time"
3
3
  require "securerandom"
4
+ require "active_support"
4
5
  require "active_support/core_ext"
5
6
  require 'active_support/json'
6
7
  require "active_support/inflector"
@@ -10,7 +11,6 @@ require "active_model"
10
11
 
11
12
  require 'dynamoid/errors'
12
13
  require 'dynamoid/fields'
13
- require 'dynamoid/indexes'
14
14
  require 'dynamoid/associations'
15
15
  require 'dynamoid/persistence'
16
16
  require 'dynamoid/dirty'
@@ -29,10 +29,9 @@ module Dynamoid
29
29
  extend self
30
30
 
31
31
  MAX_ITEM_SIZE = 65_536
32
-
32
+
33
33
  def configure
34
34
  block_given? ? yield(Dynamoid::Config) : Dynamoid::Config
35
- Dynamoid::Adapter.reconnect!
36
35
  end
37
36
  alias :config :configure
38
37
 
@@ -43,5 +42,8 @@ module Dynamoid
43
42
  def included_models
44
43
  @included_models ||= []
45
44
  end
46
-
45
+
46
+ def adapter
47
+ @adapter ||= Adapter.new
48
+ end
47
49
  end
@@ -1,28 +1,42 @@
1
+ # require only 'concurrent/atom' once this issue is resolved:
2
+ # https://github.com/ruby-concurrency/concurrent-ruby/pull/377
3
+ require 'concurrent'
4
+
1
5
  # encoding: utf-8
2
6
  module Dynamoid
3
7
 
4
- # Adapter provides a generic, write-through class that abstracts variations in the underlying connections to provide a uniform response
5
- # to Dynamoid.
6
- module Adapter
7
- extend self
8
- attr_accessor :tables
8
+ # Adapter's value-add:
9
+ # 1) For the rest of Dynamoid, the gateway to DynamoDB.
10
+ # 2) Allows switching `config.adapter` to ease development of a new adapter.
11
+ # 3) Caches the list of tables Dynamoid knows about.
12
+ class Adapter
13
+ def initialize
14
+ @adapter_ = Concurrent::Atom.new(nil)
15
+ @tables_ = Concurrent::Atom.new(nil)
16
+ end
9
17
 
10
- # The actual adapter currently in use: presently AwsSdk.
18
+ def tables
19
+ if !@tables_.value
20
+ @tables_.swap{|value, args| benchmark('Cache Tables') {list_tables}}
21
+ end
22
+ @tables_.value
23
+ end
24
+
25
+ # The actual adapter currently in use.
11
26
  #
12
27
  # @since 0.2.0
13
28
  def adapter
14
- reconnect! unless @adapter
15
- @adapter
29
+ if !@adapter_.value
30
+ adapter = self.class.adapter_plugin_class.new
31
+ adapter.connect! if adapter.respond_to?(:connect!)
32
+ @adapter_.compare_and_set(nil, adapter)
33
+ clear_cache!
34
+ end
35
+ @adapter_.value
16
36
  end
17
37
 
18
- # Establishes a connection to the underyling adapter and caches all its tables for speedier future lookups. Issued when the adapter is first called.
19
- #
20
- # @since 0.2.0
21
- def reconnect!
22
- require "dynamoid/adapter/#{Dynamoid::Config.adapter}" unless Dynamoid::Adapter.const_defined?(Dynamoid::Config.adapter.camelcase)
23
- @adapter = Dynamoid::Adapter.const_get(Dynamoid::Config.adapter.camelcase)
24
- @adapter.connect! if @adapter.respond_to?(:connect!)
25
- self.tables = benchmark('Cache Tables') {list_tables}
38
+ def clear_cache!
39
+ @tables_.swap{|value, args| nil}
26
40
  end
27
41
 
28
42
  # Shows how long it takes a method to run on the adapter. Useful for generating logged output.
@@ -41,7 +55,7 @@ module Dynamoid
41
55
  return result
42
56
  end
43
57
 
44
- # Write an object to the adapter. Partition it to a randomly selected key first if necessary.
58
+ # Write an object to the adapter.
45
59
  #
46
60
  # @param [String] table the name of the table to write the object to
47
61
  # @param [Object] object the object itself
@@ -51,22 +65,19 @@ module Dynamoid
51
65
  #
52
66
  # @since 0.2.0
53
67
  def write(table, object, options = nil)
54
- if Dynamoid::Config.partitioning? && object[:id]
55
- object[:id] = "#{object[:id]}.#{Random.rand(Dynamoid::Config.partition_size)}"
56
- object[:updated_at] = Time.now.to_f
57
- end
58
68
  put_item(table, object, options)
59
69
  end
60
70
 
61
- # Read one or many keys from the selected table. This method intelligently calls batch_get or get on the underlying adapter depending on
62
- # whether ids is a range or a single key: additionally, if partitioning is enabled, it batch_gets all keys in the partition space
63
- # automatically. Finally, if a range key is present, it will also interpolate that into the ids so that the batch get will acquire the
64
- # correct record.
71
+ # Read one or many keys from the selected table.
72
+ # This method intelligently calls batch_get or get on the underlying adapter
73
+ # depending on whether ids is a range or a single key.
74
+ # If a range key is present, it will also interpolate that into the ids so
75
+ # that the batch get will acquire the correct record.
65
76
  #
66
77
  # @param [String] table the name of the table to write the object to
67
78
  # @param [Array] ids to fetch, can also be a string of just one id
68
79
  # @param [Hash] options: Passed to the underlying query. The :range_key option is required whenever the table has a range key,
69
- # unless multiple ids are passed in and Dynamoid::Config.partitioning? is turned off.
80
+ # unless multiple ids are passed in.
70
81
  #
71
82
  # @since 0.2.0
72
83
  def read(table, ids, options = {})
@@ -74,25 +85,14 @@ module Dynamoid
74
85
 
75
86
  if ids.respond_to?(:each)
76
87
  ids = ids.collect{|id| range_key ? [id, range_key] : id}
77
- if Dynamoid::Config.partitioning?
78
- results = batch_get_item({table => id_with_partitions(ids)}, options)
79
- {table => result_for_partition(results[table],table)}
80
- else
81
- batch_get_item({table => ids}, options)
82
- end
88
+ batch_get_item({table => ids}, options)
83
89
  else
84
- if Dynamoid::Config.partitioning?
85
- ids = range_key ? [[ids, range_key]] : ids
86
- results = batch_get_item({table => id_with_partitions(ids)}, options)
87
- result_for_partition(results[table],table).first
88
- else
89
- options[:range_key] = range_key if range_key
90
- get_item(table, ids, options)
91
- end
90
+ options[:range_key] = range_key if range_key
91
+ get_item(table, ids, options)
92
92
  end
93
93
  end
94
94
 
95
- # Delete an item from a table. If partitioning is turned on, deletes all partitioned keys as well.
95
+ # Delete an item from a table.
96
96
  #
97
97
  # @param [String] table the name of the table to write the object to
98
98
  # @param [Array] ids to delete, can also be a string of just one id
@@ -107,19 +107,10 @@ module Dynamoid
107
107
  else
108
108
  ids = range_key ? [[ids, range_key]] : ids
109
109
  end
110
-
111
- if Dynamoid::Config.partitioning?
112
- batch_delete_item(table => id_with_partitions(ids))
113
- else
114
- batch_delete_item(table => ids)
115
- end
110
+
111
+ batch_delete_item(table => ids)
116
112
  else
117
- if Dynamoid::Config.partitioning?
118
- ids = range_key ? [[ids, range_key]] : ids
119
- batch_delete_item(table => id_with_partitions(ids))
120
- else
121
- delete_item(table, ids, options)
122
- end
113
+ delete_item(table, ids, options)
123
114
  end
124
115
  end
125
116
 
@@ -130,15 +121,17 @@ module Dynamoid
130
121
  #
131
122
  # @since 0.2.0
132
123
  def scan(table, query, opts = {})
133
- if Dynamoid::Config.partitioning?
134
- results = benchmark('Scan', table, query) {adapter.scan(table, query, opts)}
135
- result_for_partition(results,table)
136
- else
137
- benchmark('Scan', table, query) {adapter.scan(table, query, opts)}
124
+ benchmark('Scan', table, query) {adapter.scan(table, query, opts)}
125
+ end
126
+
127
+ def create_table(table_name, key, options = {})
128
+ if !tables.include?(table_name)
129
+ benchmark('Create Table') { adapter.create_table(table_name, key, options) }
130
+ tables << table_name
138
131
  end
139
132
  end
140
133
 
141
- [:batch_get_item, :create_table, :delete_item, :delete_table, :get_item, :list_tables, :put_item].each do |m|
134
+ [:batch_get_item, :delete_item, :delete_table, :get_item, :list_tables, :put_item].each do |m|
142
135
  # Method delegation with benchmark to the underlying adapter. Faster than relying on method_missing.
143
136
  #
144
137
  # @since 0.2.0
@@ -147,86 +140,17 @@ module Dynamoid
147
140
  end
148
141
  end
149
142
 
150
- # Takes a list of ids and returns them with partitioning added. If an array of arrays is passed, we assume the second key is the range key
151
- # and pass it in unchanged.
152
- #
153
- # @example Partition id 1
154
- # Dynamoid::Adapter.id_with_partitions(['1']) # ['1.0', '1.1', '1.2', ..., '1.199']
155
- # @example Partition id 1 and range_key 1.0
156
- # Dynamoid::Adapter.id_with_partitions([['1', 1.0]]) # [['1.0', 1.0], ['1.1', 1.0], ['1.2', 1.0], ..., ['1.199', 1.0]]
157
- #
158
- # @param [Array] ids array of ids to partition
159
- #
160
- # @since 0.2.0
161
- def id_with_partitions(ids)
162
- Array(ids).collect {|id| (0...Dynamoid::Config.partition_size).collect{|n| id.is_a?(Array) ? ["#{id.first}.#{n}", id.last] : "#{id}.#{n}"}}.flatten(1)
163
- end
164
-
165
- #Get original id (hash_key) and partiton number from a hash_key
166
- #
167
- # @param [String] id the id or hash_key of a record, ex. xxxxx.13
168
- #
169
- # @return [String,String] original_id and the partition number, ex original_id = xxxxx partition = 13
170
- def get_original_id_and_partition id
171
- partition = id.split('.').last
172
- id = id.split(".#{partition}").first
173
-
174
- return id, partition
175
- end
176
-
177
- # Takes an array of query results that are partitioned, find the most recently updated ones that share an id and range_key, and return only the most recently updated. Compares each result by
178
- # their id and updated_at attributes; if the updated_at is the greatest, then it must be the correct result.
179
- #
180
- # @param [Array] returned partitioned results from a query
181
- # @param [String] table_name the name of the table
182
- #
183
- # @since 0.2.0
184
- def result_for_partition(results, table_name)
185
- table = Dynamoid::Adapter::AwsSdk.get_table(table_name)
186
-
187
- if table.range_key
188
- range_key_name = table.range_key.name.to_sym
189
-
190
- final_hash = {}
191
-
192
- results.each do |record|
193
- test_record = final_hash[record[range_key_name]]
194
-
195
- if test_record.nil? || ((record[range_key_name] == test_record[range_key_name]) && (record[:updated_at] > test_record[:updated_at]))
196
- #get ride of our partition and put it in the array with the range key
197
- record[:id], partition = get_original_id_and_partition record[:id]
198
- final_hash[record[range_key_name]] = record
199
- end
200
- end
201
-
202
- return final_hash.values
203
- else
204
- {}.tap do |hash|
205
- Array(results).each do |result|
206
- next if result.nil?
207
- #Need to find the value of id with out the . and partition number
208
- id, partition = get_original_id_and_partition result[:id]
209
-
210
- if !hash[id] || (result[:updated_at] > hash[id][:updated_at])
211
- result[:id] = id
212
- hash[id] = result
213
- end
214
- end
215
- end.values
216
- end
217
- end
218
-
219
143
  # Delegate all methods that aren't defind here to the underlying adapter.
220
144
  #
221
145
  # @since 0.2.0
222
146
  def method_missing(method, *args, &block)
223
- return benchmark(method, *args) {adapter.send(method, *args, &block)} if @adapter.respond_to?(method)
147
+ return benchmark(method, *args) {adapter.send(method, *args, &block)} if adapter.respond_to?(method)
224
148
  super
225
149
  end
226
-
150
+
227
151
  # Query the DynamoDB table. This employs DynamoDB's indexes so is generally faster than scanning, but is
228
152
  # only really useful for range queries, since it can only find by one hash key at once. Only provide
229
- # one range key to the hash. If paritioning is on, will run a query for every parition and join the results
153
+ # one range key to the hash.
230
154
  #
231
155
  # @param [String] table_name the name of the table
232
156
  # @param [Hash] opts the options to query the table with
@@ -240,28 +164,18 @@ module Dynamoid
240
164
  # @return [Array] an array of all matching items
241
165
  #
242
166
  def query(table_name, opts = {})
243
-
244
- unless Dynamoid::Config.partitioning?
245
- #no paritioning? just pass to the standard query method
246
- Dynamoid::Adapter::AwsSdk.query(table_name, opts)
247
- else
248
- #get all the hash_values that could be possible
249
- ids = id_with_partitions(opts[:hash_value])
250
-
251
- #lets not overwrite with the original options
252
- modified_options = opts.clone
253
- results = []
254
-
255
- #loop and query on each of the partition ids
256
- ids.each do |id|
257
- modified_options[:hash_value] = id
167
+ adapter.query(table_name, opts)
168
+ end
258
169
 
259
- query_result = Dynamoid::Adapter::AwsSdk.query(table_name, modified_options)
260
- results += query_result.inject([]){|array, result| array += [result]} if query_result.any?
261
- end
170
+ private
262
171
 
263
- result_for_partition results, table_name
172
+ def self.adapter_plugin_class
173
+ unless Dynamoid.const_defined?(:AdapterPlugin) && Dynamoid::AdapterPlugin.const_defined?(Dynamoid::Config.adapter.camelcase)
174
+ require "dynamoid/adapter_plugin/#{Dynamoid::Config.adapter}"
264
175
  end
176
+
177
+ Dynamoid::AdapterPlugin.const_get(Dynamoid::Config.adapter.camelcase)
265
178
  end
179
+
266
180
  end
267
181
  end
@@ -0,0 +1,579 @@
1
+ module Dynamoid
2
+ module AdapterPlugin
3
+
4
+ # The AwsSdkV2 adapter provides support for the aws-sdk version 2 for ruby.
5
+ class AwsSdkV2
6
+ attr_reader :table_cache
7
+
8
+ # Establish the connection to DynamoDB.
9
+ #
10
+ # @return [Aws::DynamoDB::Client] the DynamoDB connection
11
+ def connect!
12
+ @client = if Dynamoid::Config.endpoint?
13
+ Aws::DynamoDB::Client.new(endpoint: Dynamoid::Config.endpoint)
14
+ else
15
+ Aws::DynamoDB::Client.new
16
+ end
17
+ @table_cache = {}
18
+ end
19
+
20
+ # Return the client object.
21
+ #
22
+ # @since 1.0.0
23
+ def client
24
+ @client
25
+ end
26
+
27
+ # Get many items at once from DynamoDB. More efficient than getting each item individually.
28
+ #
29
+ # @example Retrieve IDs 1 and 2 from the table testtable
30
+ # Dynamoid::Adapter::AwsSdkV2.batch_get_item({'table1' => ['1', '2']})
31
+ #
32
+ # @param [Hash] table_ids the hash of tables and IDs to retrieve
33
+ # @param [Hash] options to be passed to underlying BatchGet call
34
+ #
35
+ # @return [Hash] a hash where keys are the table names and the values are the retrieved items
36
+ #
37
+ # @since 1.0.0
38
+ #
39
+ # @todo: Provide support for passing options to underlying batch_get_item http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#batch_get_item-instance_method
40
+ def batch_get_item(table_ids, options = {})
41
+ request_items = Hash.new{|h, k| h[k] = []}
42
+ return request_items if table_ids.all?{|k, v| v.empty?}
43
+
44
+ table_ids.each do |t, ids|
45
+ next if ids.empty?
46
+ tbl = describe_table(t)
47
+ hk = tbl.hash_key.to_s
48
+ rng = tbl.range_key.to_s
49
+
50
+ keys = if rng.present?
51
+ Array(ids).map do |h,r|
52
+ { hk => h, rng => r }
53
+ end
54
+ else
55
+ Array(ids).map do |id|
56
+ { hk => id }
57
+ end
58
+ end
59
+
60
+ request_items[t] = {
61
+ keys: keys
62
+ }
63
+ end
64
+
65
+ results = client.batch_get_item(
66
+ request_items: request_items
67
+ )
68
+
69
+ ret = Hash.new([].freeze) # Default for tables where no rows are returned
70
+ results.data[:responses].each do |table, rows|
71
+ ret[table] = rows.collect { |r| result_item_to_hash(r) }
72
+ end
73
+ ret
74
+ end
75
+
76
+ # Delete many items at once from DynamoDB. More efficient than delete each item individually.
77
+ #
78
+ # @example Delete IDs 1 and 2 from the table testtable
79
+ # Dynamoid::Adapter::AwsSdk.batch_delete_item('table1' => ['1', '2'])
80
+ #or
81
+ # Dynamoid::Adapter::AwsSdkV2.batch_delete_item('table1' => [['hk1', 'rk2'], ['hk1', 'rk2']]]))
82
+ #
83
+ # @param [Hash] options the hash of tables and IDs to delete
84
+ #
85
+ # @return nil
86
+ #
87
+ # @todo: Provide support for passing options to underlying delete_item http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#delete_item-instance_method
88
+ def batch_delete_item(options)
89
+ options.each_pair do |table_name, ids|
90
+ table = describe_table(table_name)
91
+ ids.each do |id|
92
+ client.delete_item(table_name: table_name, key: key_stanza(table, *id))
93
+ end
94
+ end
95
+ nil
96
+ end
97
+
98
+ # Create a table on DynamoDB. This usually takes a long time to complete.
99
+ #
100
+ # @param [String] table_name the name of the table to create
101
+ # @param [Symbol] key the table's primary key (defaults to :id)
102
+ # @param [Hash] options provide a range key here if the table has a composite key
103
+ #
104
+ # @since 1.0.0
105
+ def create_table(table_name, key = :id, options = {})
106
+ Dynamoid.logger.info "Creating #{table_name} table. This could take a while."
107
+ read_capacity = options[:read_capacity] || Dynamoid::Config.read_capacity
108
+ write_capacity = options[:write_capacity] || Dynamoid::Config.write_capacity
109
+ range_key = options[:range_key]
110
+
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,
127
+ provisioned_throughput: {
128
+ read_capacity_units: read_capacity,
129
+ write_capacity_units: write_capacity
130
+ },
131
+ key_schema: key_schema,
132
+ attribute_definitions: attribute_definitions
133
+ )
134
+ rescue Aws::DynamoDB::Errors::ResourceInUseException => e
135
+ Dynamoid.logger.error "Table #{table_name} cannot be created as it already exists"
136
+ end
137
+
138
+ # Removes an item from DynamoDB.
139
+ #
140
+ # @param [String] table_name the name of the table
141
+ # @param [String] key the hash key of the item to delete
142
+ # @param [Hash] options provide a range key here if the table has a composite key
143
+ #
144
+ # @since 1.0.0
145
+ #
146
+ # @todo: Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#delete_item-instance_method
147
+ def delete_item(table_name, key, options = nil)
148
+ table = describe_table(table_name)
149
+ client.delete_item(table_name: table_name, key: key_stanza(table, key, options && options[:range_key]))
150
+ end
151
+
152
+ # Deletes an entire table from DynamoDB.
153
+ #
154
+ # @param [String] table_name the name of the table to destroy
155
+ #
156
+ # @since 1.0.0
157
+ def delete_table(table_name)
158
+ client.delete_table(table_name: table_name)
159
+ table_cache.clear
160
+ end
161
+
162
+ # @todo Add a DescribeTable method.
163
+
164
+ # Fetches an item from DynamoDB.
165
+ #
166
+ # @param [String] table_name the name of the table
167
+ # @param [String] key the hash key of the item to find
168
+ # @param [Hash] options provide a range key here if the table has a composite key
169
+ #
170
+ # @return [Hash] a hash representing the raw item in DynamoDB
171
+ #
172
+ # @since 1.0.0
173
+ #
174
+ # @todo Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#get_item-instance_method
175
+ def get_item(table_name, key, options = {})
176
+ table = describe_table(table_name)
177
+ range_key = options.delete(:range_key)
178
+
179
+ item = client.get_item(table_name: table_name,
180
+ key: key_stanza(table, key, range_key)
181
+ )[:item]
182
+ item ? result_item_to_hash(item) : nil
183
+ end
184
+
185
+ # Edits an existing item's attributes, or adds a new item to the table if it does not already exist. You can put, delete, or add attribute values
186
+ #
187
+ # @param [String] table_name the name of the table
188
+ # @param [String] key the hash key of the item to find
189
+ # @param [Hash] options provide a range key here if the table has a composite key
190
+ #
191
+ # @return new attributes for the record
192
+ #
193
+ # @todo Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method
194
+ def update_item(table_name, key, options = {})
195
+ range_key = options.delete(:range_key)
196
+ conditions = options.delete(:conditions)
197
+ table = describe_table(table_name)
198
+
199
+ yield(iu = ItemUpdater.new(table, key, range_key))
200
+
201
+ raise "non-empty options: #{options}" unless options.empty?
202
+ begin
203
+ result = client.update_item(table_name: table_name,
204
+ key: key_stanza(table, key, range_key),
205
+ attribute_updates: iu.to_h,
206
+ expected: expected_stanza(conditions),
207
+ return_values: "ALL_NEW"
208
+ )
209
+ result_item_to_hash(result[:attributes])
210
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
211
+ raise Dynamoid::Errors::ConditionalCheckFailedException, e
212
+ end
213
+ end
214
+
215
+ # List all tables on DynamoDB.
216
+ #
217
+ # @since 1.0.0
218
+ #
219
+ # @todo Provide limit support http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method
220
+ def list_tables
221
+ client.list_tables[:table_names]
222
+ end
223
+
224
+ # Persists an item on DynamoDB.
225
+ #
226
+ # @param [String] table_name the name of the table
227
+ # @param [Object] object a hash or Dynamoid object to persist
228
+ #
229
+ # @since 1.0.0
230
+ #
231
+ # @todo: Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#put_item-instance_method
232
+ def put_item(table_name, object, options = nil)
233
+ item = {}
234
+
235
+ object.each do |k, v|
236
+ next if v.nil? || (v.respond_to?(:empty?) && v.empty?)
237
+ item[k.to_s] = v
238
+ end
239
+
240
+ begin
241
+ client.put_item(table_name: table_name,
242
+ item: item,
243
+ expected: expected_stanza(options)
244
+ )
245
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
246
+ raise Dynamoid::Errors::ConditionalCheckFailedException, e
247
+ end
248
+ end
249
+
250
+ # Query the DynamoDB table. This employs DynamoDB's indexes so is generally faster than scanning, but is
251
+ # only really useful for range queries, since it can only find by one hash key at once. Only provide
252
+ # one range key to the hash.
253
+ #
254
+ # @param [String] table_name the name of the table
255
+ # @param [Hash] opts the options to query the table with
256
+ # @option opts [String] :hash_value the value of the hash key to find
257
+ # @option opts [Number, Number] :range_between find the range key within this range
258
+ # @option opts [Number] :range_greater_than find range keys greater than this
259
+ # @option opts [Number] :range_less_than find range keys less than this
260
+ # @option opts [Number] :range_gte find range keys greater than or equal to this
261
+ # @option opts [Number] :range_lte find range keys less than or equal to this
262
+ #
263
+ # @return [Enumerable] matching items
264
+ #
265
+ # @since 1.0.0
266
+ #
267
+ # @todo Provide support for various other options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#query-instance_method
268
+ def query(table_name, opts = {})
269
+ table = describe_table(table_name)
270
+ hk = table.hash_key.to_s
271
+ rng = table.range_key.to_s
272
+ q = opts.slice(:consistent_read, :scan_index_forward, :limit, :select)
273
+
274
+ opts.delete(:consistent_read)
275
+ opts.delete(:scan_index_forward)
276
+ opts.delete(:limit)
277
+ opts.delete(:select)
278
+
279
+ opts.delete(:next_token).tap do |token|
280
+ break unless token
281
+ q[:exclusive_start_key] = {
282
+ hk => token[:hash_key_element],
283
+ rng => token[:range_key_element]
284
+ }
285
+ end
286
+
287
+ key_conditions = {
288
+ hk => {
289
+ # TODO: Provide option for other operators like NE, IN, LE, etc
290
+ comparison_operator: EQ,
291
+ attribute_value_list: [
292
+ opts.delete(:hash_value).freeze
293
+ ]
294
+ }
295
+ }
296
+
297
+ opts.each_pair do |k, v|
298
+ # TODO: ATM, only few comparison operators are supported, provide support for all operators
299
+ next unless(op = RANGE_MAP[k])
300
+ key_conditions[rng] = {
301
+ comparison_operator: op,
302
+ attribute_value_list: [
303
+ opts.delete(k).freeze
304
+ ].flatten # Flatten as BETWEEN operator specifies array of two elements
305
+ }
306
+ end
307
+
308
+ q[:table_name] = table_name
309
+ q[:key_conditions] = key_conditions
310
+
311
+ Enumerator.new { |y|
312
+ result = client.query(q)
313
+
314
+ result.items.each { |r|
315
+ y << result_item_to_hash(r)
316
+ }
317
+ }
318
+ end
319
+
320
+ EQ = "EQ".freeze
321
+
322
+ RANGE_MAP = {
323
+ range_greater_than: 'GT',
324
+ range_less_than: 'LT',
325
+ range_gte: 'GE',
326
+ range_lte: 'LE',
327
+ range_begins_with: 'BEGINS_WITH',
328
+ range_between: 'BETWEEN'
329
+ }
330
+
331
+ # Scan the DynamoDB table. This is usually a very slow operation as it naively filters all data on
332
+ # the DynamoDB servers.
333
+ #
334
+ # @param [String] table_name the name of the table
335
+ # @param [Hash] scan_hash a hash of attributes: matching records will be returned by the scan
336
+ #
337
+ # @return [Enumerable] matching items
338
+ #
339
+ # @since 1.0.0
340
+ #
341
+ # @todo: Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#scan-instance_method
342
+ def scan(table_name, scan_hash, select_opts = {})
343
+ limit = select_opts.delete(:limit)
344
+ batch = select_opts.delete(:batch_size)
345
+
346
+ request = { table_name: table_name }
347
+ request[:limit] = batch || limit if batch || limit
348
+ request[:scan_filter] = scan_hash.reduce({}) do |memo, kvp|
349
+ memo[kvp[0].to_s] = {
350
+ attribute_value_list: [kvp[1]],
351
+ # TODO: Provide support for all comparison operators
352
+ comparison_operator: EQ
353
+ }
354
+ memo
355
+ end if scan_hash.present?
356
+
357
+ Enumerator.new do |y|
358
+ # Batch loop, pulls multiple requests until done using the start_key
359
+ loop do
360
+ results = client.scan(request)
361
+
362
+ results.data[:items].each { |row| y << result_item_to_hash(row) }
363
+
364
+ if((lk = results[:last_evaluated_key]) && batch)
365
+ request[:exclusive_start_key] = lk
366
+ else
367
+ break
368
+ end
369
+ end
370
+ end
371
+ end
372
+
373
+
374
+ #
375
+ # Truncates all records in the given table
376
+ #
377
+ # @param [String] table_name the name of the table
378
+ #
379
+ # @since 1.0.0
380
+ def truncate(table_name)
381
+ table = describe_table(table_name)
382
+ hk = table.hash_key
383
+ rk = table.range_key
384
+
385
+ scan(table_name, {}, {}).each do |attributes|
386
+ opts = {range_key: attributes[rk.to_sym] } if rk
387
+ delete_item(table_name, attributes[hk], opts)
388
+ end
389
+ end
390
+
391
+ def count(table_name)
392
+ describe_table(table_name, true).item_count
393
+ end
394
+
395
+ protected
396
+
397
+ STRING_TYPE = "S".freeze
398
+ NUM_TYPE = "N".freeze
399
+ BOOLEAN_TYPE = "B".freeze
400
+
401
+ #Converts from symbol to the API string for the given data type
402
+ # E.g. :number -> 'N'
403
+ def api_type(type)
404
+ case(type)
405
+ when :string then STRING_TYPE
406
+ when :number then NUM_TYPE
407
+ when :datetime then NUM_TYPE
408
+ when :boolean then BOOLEAN_TYPE
409
+ else raise "Unknown type: #{type}"
410
+ end
411
+ end
412
+
413
+ #
414
+ # The key hash passed on get_item, put_item, delete_item, update_item, etc
415
+ #
416
+ def key_stanza(table, hash_key, range_key = nil)
417
+ key = { table.hash_key.to_s => hash_key }
418
+ key[table.range_key.to_s] = range_key if range_key
419
+ key
420
+ end
421
+
422
+ #
423
+ # @param [Hash] conditions Conditions to enforce on operation (e.g. { :if => { :count => 5 }, :unless_exists => ['id']})
424
+ # @return an Expected stanza for the given conditions hash
425
+ #
426
+ def expected_stanza(conditions = nil)
427
+ expected = Hash.new { |h,k| h[k] = {} }
428
+ return expected unless conditions
429
+
430
+ conditions[:unless_exists].try(:each) do |col|
431
+ expected[col.to_s][:exists] = false
432
+ end
433
+ conditions[:if].try(:each) do |col,val|
434
+ expected[col.to_s][:value] = val
435
+ end
436
+
437
+ expected
438
+ end
439
+
440
+ HASH_KEY = "HASH".freeze
441
+ RANGE_KEY = "RANGE".freeze
442
+
443
+ #
444
+ # New, semi-arbitrary API to get data on the table
445
+ #
446
+ def describe_table(table_name, reload = false)
447
+ (!reload && table_cache[table_name]) || begin
448
+ table_cache[table_name] = Table.new(client.describe_table(table_name: table_name).data)
449
+ end
450
+ end
451
+
452
+ #
453
+ # Converts a hash returned by get_item, scan, etc. into a key-value hash
454
+ #
455
+ def result_item_to_hash(item)
456
+ {}.tap do |r|
457
+ item.each { |k,v| r[k.to_sym] = v }
458
+ end
459
+ end
460
+
461
+ #
462
+ # Represents a table. Exposes data from the "DescribeTable" API call, and also
463
+ # provides methods for coercing values to the proper types based on the table's schema data
464
+ #
465
+ class Table
466
+ attr_reader :schema
467
+
468
+ #
469
+ # @param [Hash] schema Data returns from a "DescribeTable" call
470
+ #
471
+ def initialize(schema)
472
+ @schema = schema[:table]
473
+ end
474
+
475
+ def range_key
476
+ @range_key ||= schema[:key_schema].find { |d| d[:key_type] == RANGE_KEY }.try(:attribute_name)
477
+ end
478
+
479
+ def range_type
480
+ range_type ||= schema[:attribute_definitions].find { |d|
481
+ d[:attribute_name] == range_key
482
+ }.try(:fetch,:attribute_type, nil)
483
+ end
484
+
485
+ def hash_key
486
+ @hash_key ||= schema[:key_schema].find { |d| d[:key_type] == HASH_KEY }.try(:attribute_name).to_sym
487
+ end
488
+
489
+ #
490
+ # Returns the API type (e.g. "N", "S") for the given column, if the schema defines it,
491
+ # nil otherwise
492
+ #
493
+ def col_type(col)
494
+ col = col.to_s
495
+ col_def = schema[:attribute_definitions].find { |d| d[:attribute_name] == col.to_s }
496
+ col_def && col_def[:attribute_type]
497
+ end
498
+
499
+ def item_count
500
+ schema[:item_count]
501
+ end
502
+ end
503
+
504
+ #
505
+ # Mimics behavior of the yielded object on DynamoDB's update_item API (high level).
506
+ #
507
+ class ItemUpdater
508
+ attr_reader :table, :key, :range_key
509
+
510
+ def initialize(table, key, range_key = nil)
511
+ @table = table; @key = key, @range_key = range_key
512
+ @additions = {}
513
+ @deletions = {}
514
+ @updates = {}
515
+ end
516
+
517
+ #
518
+ # Adds the given values to the values already stored in the corresponding columns.
519
+ # The column must contain a Set or a number.
520
+ #
521
+ # @param [Hash] vals keys of the hash are the columns to update, vals are the values to
522
+ # add. values must be a Set, Array, or Numeric
523
+ #
524
+ def add(values)
525
+ @additions.merge!(values)
526
+ end
527
+
528
+ #
529
+ # Removes values from the sets of the given columns
530
+ #
531
+ # @param [Hash] values keys of the hash are the columns, values are Arrays/Sets of items
532
+ # to remove
533
+ #
534
+ def delete(values)
535
+ @deletions.merge!(values)
536
+ end
537
+
538
+ #
539
+ # Replaces the values of one or more attributes
540
+ #
541
+ def set(values)
542
+ @updates.merge!(values)
543
+ end
544
+
545
+ #
546
+ # Returns an AttributeUpdates hash suitable for passing to the V2 Client API
547
+ #
548
+ def to_h
549
+ ret = {}
550
+
551
+ @additions.each do |k,v|
552
+ ret[k.to_s] = {
553
+ action: ADD,
554
+ value: v
555
+ }
556
+ end
557
+ @deletions.each do |k,v|
558
+ ret[k.to_s] = {
559
+ action: DELETE,
560
+ value: v
561
+ }
562
+ end
563
+ @updates.each do |k,v|
564
+ ret[k.to_s] = {
565
+ action: PUT,
566
+ value: v
567
+ }
568
+ end
569
+
570
+ ret
571
+ end
572
+
573
+ ADD = "ADD".freeze
574
+ DELETE = "DELETE".freeze
575
+ PUT = "PUT".freeze
576
+ end
577
+ end
578
+ end
579
+ end