dynamoid 0.7.1 → 1.0.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.
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