synamoid 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,64 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ begin
4
+ Bundler.setup(:default, :development)
5
+ rescue Bundler::BundlerError => e
6
+ $stderr.puts e.message
7
+ $stderr.puts "Run `bundle install` to install missing gems"
8
+ exit e.status_code
9
+ end
10
+
11
+ require "rake"
12
+ require "rspec/core/rake_task"
13
+ RSpec::Core::RakeTask.new(:spec) do |spec|
14
+ spec.pattern = FileList["spec/**/*_spec.rb"]
15
+ end
16
+
17
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
18
+ spec.pattern = "spec/**/*_spec.rb"
19
+ spec.rcov = true
20
+ end
21
+
22
+ desc "Start DynamoDBLocal, run tests, clean up"
23
+ task :unattended_spec do |t|
24
+
25
+ if system("bin/start_dynamodblocal")
26
+ puts "DynamoDBLocal started; proceeding with specs."
27
+ else
28
+ raise "Unable to start DynamoDBLocal. Cannot run unattended specs."
29
+ end
30
+
31
+ #Cleanup
32
+ at_exit do
33
+ unless system("bin/stop_dynamodblocal")
34
+ $stderr.puts "Unable to cleanly stop DynamoDBLocal."
35
+ end
36
+ end
37
+
38
+ Rake::Task["spec"].invoke
39
+ end
40
+
41
+ require "yard"
42
+ YARD::Rake::YardocTask.new do |t|
43
+ t.files = ["lib/**/*.rb", "README", "LICENSE"] # optional
44
+ t.options = ["-m", "markdown"] # optional
45
+ end
46
+
47
+ desc "Publish documentation to gh-pages"
48
+ task :publish do
49
+ Rake::Task["yard"].invoke
50
+ `git add .`
51
+ `git commit -m 'Regenerated documentation'`
52
+ `git checkout gh-pages`
53
+ `git clean -fdx`
54
+ `git checkout master -- doc`
55
+ `cp -R doc/* .`
56
+ `git rm -rf doc/`
57
+ `git add .`
58
+ `git commit -m 'Regenerated documentation'`
59
+ `git pull`
60
+ `git push`
61
+ `git checkout master`
62
+ end
63
+
64
+ task :default => :spec
data/dynamoid.gemspec ADDED
@@ -0,0 +1,53 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "dynamoid/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "synamoid"
8
+ spec.version = Dynamoid::VERSION
9
+
10
+ # Keep in sync with README
11
+ spec.authors = [
12
+ "Josh Symonds",
13
+ "Logan Bowers",
14
+ "Craig Heneveld",
15
+ "Anatha Kumaran",
16
+ "Jason Dew",
17
+ "Luis Arias",
18
+ "Stefan Neculai",
19
+ "Philip White",
20
+ "Peeyush Kumar",
21
+ "Sumanth Ravipati",
22
+ "Pascal Corpet",
23
+ "Brian Glusman",
24
+ "Peter Boling"
25
+ ]
26
+ spec.email = ["peter.boling@gmail.com", "brian@stellaservice.com"]
27
+
28
+ spec.description = "Dynamoid is an ORM for Amazon's DynamoDB that supports offline development, associations, querying, and everything else you'd expect from an ActiveRecord-style replacement."
29
+ spec.summary = "Dynamoid is an ORM for Amazon's DynamoDB"
30
+ spec.extra_rdoc_files = [
31
+ "LICENSE.txt",
32
+ "README.md"
33
+ ]
34
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(bin|test|spec|features)/}) }
35
+ spec.homepage = "http://github.com/Dynamoid/Dynamoid"
36
+ spec.licenses = ["MIT"]
37
+ spec.bindir = "exe"
38
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
39
+ spec.require_paths = ["lib"]
40
+
41
+ spec.add_runtime_dependency(%q<activemodel>, ["~> 5"])
42
+ spec.add_runtime_dependency(%q<activemodel-serializers-xml>, ["~> 1"])
43
+ spec.add_runtime_dependency(%q<aws-sdk-resources>, ["~> 2"])
44
+ spec.add_runtime_dependency(%q<concurrent-ruby>, [">= 1.0"])
45
+ spec.add_development_dependency(%q<rake>, [">= 10"])
46
+ spec.add_development_dependency(%q<bundler>, ["~> 1.12"])
47
+ spec.add_development_dependency(%q<rspec>, [">= 3"])
48
+ spec.add_development_dependency(%q<yard>, [">= 0"])
49
+ spec.add_development_dependency(%q<github-markup>, [">= 0"])
50
+ spec.add_development_dependency(%q<pry>, [">= 0"])
51
+ spec.add_development_dependency(%q<coveralls>, [">= 0"])
52
+ spec.add_development_dependency(%q<rspec-retry>, [">= 0"])
53
+ end
data/lib/dynamoid.rb ADDED
@@ -0,0 +1,53 @@
1
+ require "delegate"
2
+ require "time"
3
+ require "securerandom"
4
+ require "active_support"
5
+ require "active_support/core_ext"
6
+ require "active_support/json"
7
+ require "active_support/inflector"
8
+ require "active_support/lazy_load_hooks"
9
+ require "active_support/time_with_zone"
10
+ require "active_model"
11
+ require 'activemodel-serializers-xml'
12
+ #gem 'active_model_serializers'
13
+
14
+ require "dynamoid/version"
15
+ require "dynamoid/errors"
16
+ require "dynamoid/fields"
17
+ require "dynamoid/indexes"
18
+ require "dynamoid/associations"
19
+ require "dynamoid/persistence"
20
+ require "dynamoid/dirty"
21
+ require "dynamoid/validations"
22
+ require "dynamoid/criteria"
23
+ require "dynamoid/finders"
24
+ require "dynamoid/identity_map"
25
+ require "dynamoid/config"
26
+ require "dynamoid/components"
27
+ require "dynamoid/document"
28
+ require "dynamoid/adapter"
29
+
30
+ require "dynamoid/middleware/identity_map"
31
+
32
+ module Dynamoid
33
+ extend self
34
+
35
+ MAX_ITEM_SIZE = 65_536
36
+
37
+ def configure
38
+ block_given? ? yield(Dynamoid::Config) : Dynamoid::Config
39
+ end
40
+ alias :config :configure
41
+
42
+ def logger
43
+ Dynamoid::Config.logger
44
+ end
45
+
46
+ def included_models
47
+ @included_models ||= []
48
+ end
49
+
50
+ def adapter
51
+ @adapter ||= Adapter.new
52
+ end
53
+ end
@@ -0,0 +1,190 @@
1
+ # require only 'concurrent/atom' once this issue is resolved:
2
+ # https://github.com/ruby-concurrency/concurrent-ruby/pull/377
3
+ require 'concurrent'
4
+
5
+ # encoding: utf-8
6
+ module Dynamoid
7
+
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
17
+
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.
26
+ #
27
+ # @since 0.2.0
28
+ def 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
36
+ end
37
+
38
+ def clear_cache!
39
+ @tables_.swap{|value, args| nil}
40
+ end
41
+
42
+ # Shows how long it takes a method to run on the adapter. Useful for generating logged output.
43
+ #
44
+ # @param [Symbol] method the name of the method to appear in the log
45
+ # @param [Array] args the arguments to the method to appear in the log
46
+ # @yield the actual code to benchmark
47
+ #
48
+ # @return the result of the yield
49
+ #
50
+ # @since 0.2.0
51
+ def benchmark(method, *args)
52
+ start = Time.now
53
+ result = yield
54
+ Dynamoid.logger.info "(#{((Time.now - start) * 1000.0).round(2)} ms) #{method.to_s.split('_').collect(&:upcase).join(' ')}#{ " - #{args.inspect}" unless args.nil? || args.empty? }"
55
+ return result
56
+ end
57
+
58
+ # Write an object to the adapter.
59
+ #
60
+ # @param [String] table the name of the table to write the object to
61
+ # @param [Object] object the object itself
62
+ # @param [Hash] options Options that are passed to the put_item call
63
+ #
64
+ # @return [Object] the persisted object
65
+ #
66
+ # @since 0.2.0
67
+ def write(table, object, options = nil)
68
+ put_item(table, object, options)
69
+ end
70
+
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.
76
+ #
77
+ # @param [String] table the name of the table to write the object to
78
+ # @param [Array] ids to fetch, can also be a string of just one id
79
+ # @param [Hash] options: Passed to the underlying query. The :range_key option is required whenever the table has a range key,
80
+ # unless multiple ids are passed in.
81
+ #
82
+ # @since 0.2.0
83
+ def read(table, ids, options = {})
84
+ range_key = options.delete(:range_key)
85
+
86
+ if ids.respond_to?(:each)
87
+ ids = ids.collect{|id| range_key ? [id, range_key] : id}
88
+ batch_get_item({table => ids}, options)
89
+ else
90
+ options[:range_key] = range_key if range_key
91
+ get_item(table, ids, options)
92
+ end
93
+ end
94
+
95
+ # Delete an item from a table.
96
+ #
97
+ # @param [String] table the name of the table to write the object to
98
+ # @param [Array] ids to delete, can also be a string of just one id
99
+ # @param [Array] range_key of the record to delete, can also be a string of just one range_key
100
+ #
101
+ def delete(table, ids, options = {})
102
+ range_key = options[:range_key] #array of range keys that matches the ids passed in
103
+ if ids.respond_to?(:each)
104
+ if range_key.respond_to?(:each)
105
+ #turn ids into array of arrays each element being hash_key, range_key
106
+ ids = ids.each_with_index.map{|id,i| [id,range_key[i]]}
107
+ else
108
+ ids = range_key ? [[ids, range_key]] : ids
109
+ end
110
+
111
+ batch_delete_item(table => ids)
112
+ else
113
+ delete_item(table, ids, options)
114
+ end
115
+ end
116
+
117
+ # Scans a table. Generally quite slow; try to avoid using scan if at all possible.
118
+ #
119
+ # @param [String] table the name of the table to write the object to
120
+ # @param [Hash] scan_hash a hash of attributes: matching records will be returned by the scan
121
+ #
122
+ # @since 0.2.0
123
+ def 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
131
+ end
132
+ end
133
+
134
+ # @since 0.2.0
135
+ def delete_table(table_name, options = {})
136
+ if tables.include?(table_name)
137
+ benchmark('Delete Table') { adapter.delete_table(table_name, options) }
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, :batch_write_item, :batch_delete_item].each do |m|
144
+ # Method delegation with benchmark to the underlying adapter. Faster than relying on method_missing.
145
+ #
146
+ # @since 0.2.0
147
+ define_method(m) do |*args|
148
+ benchmark("#{m.to_s}", args) {adapter.send(m, *args)}
149
+ end
150
+ end
151
+
152
+ # Delegate all methods that aren't defind here to the underlying adapter.
153
+ #
154
+ # @since 0.2.0
155
+ def method_missing(method, *args, &block)
156
+ return benchmark(method, *args) {adapter.send(method, *args, &block)} if adapter.respond_to?(method)
157
+ super
158
+ end
159
+
160
+ # Query the DynamoDB table. This employs DynamoDB's indexes so is generally faster than scanning, but is
161
+ # only really useful for range queries, since it can only find by one hash key at once. Only provide
162
+ # one range key to the hash.
163
+ #
164
+ # @param [String] table_name the name of the table
165
+ # @param [Hash] opts the options to query the table with
166
+ # @option opts [String] :hash_value the value of the hash key to find
167
+ # @option opts [Range] :range_value find the range key within this range
168
+ # @option opts [Number] :range_greater_than find range keys greater than this
169
+ # @option opts [Number] :range_less_than find range keys less than this
170
+ # @option opts [Number] :range_gte find range keys greater than or equal to this
171
+ # @option opts [Number] :range_lte find range keys less than or equal to this
172
+ #
173
+ # @return [Array] an array of all matching items
174
+ #
175
+ def query(table_name, opts = {})
176
+ adapter.query(table_name, opts)
177
+ end
178
+
179
+ private
180
+
181
+ def self.adapter_plugin_class
182
+ unless Dynamoid.const_defined?(:AdapterPlugin) && Dynamoid::AdapterPlugin.const_defined?(Dynamoid::Config.adapter.camelcase)
183
+ require "dynamoid/adapter_plugin/#{Dynamoid::Config.adapter}"
184
+ end
185
+
186
+ Dynamoid::AdapterPlugin.const_get(Dynamoid::Config.adapter.camelcase)
187
+ end
188
+
189
+ end
190
+ end
@@ -0,0 +1,892 @@
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
+ 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
+ }
33
+ attr_reader :table_cache
34
+
35
+ # Establish the connection to DynamoDB.
36
+ #
37
+ # @return [Aws::DynamoDB::Client] the DynamoDB connection
38
+ def connect!
39
+ @client = if Dynamoid::Config.endpoint?
40
+ Aws::DynamoDB::Client.new(endpoint: Dynamoid::Config.endpoint)
41
+ else
42
+ Aws::DynamoDB::Client.new
43
+ end
44
+ @table_cache = {}
45
+ end
46
+
47
+ # Return the client object.
48
+ #
49
+ # @since 1.0.0
50
+ def client
51
+ @client
52
+ end
53
+
54
+ # Puts or deletes multiple items in one or more tables
55
+ #
56
+ # @param [String] table_name the name of the table
57
+ # @param [Array] items to be processed
58
+ # @param [Hash] additional options
59
+ #
60
+ #See: http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#batch_write_item-instance_method
61
+ def batch_write_item table_name, objects, options = {}
62
+ request_items = []
63
+ options ||= {}
64
+ objects.each do |o|
65
+ request_items << { "put_request" => { item: o } }
66
+ end
67
+
68
+ begin
69
+ client.batch_write_item(
70
+ {
71
+ request_items: {
72
+ table_name => request_items,
73
+ },
74
+ return_consumed_capacity: "TOTAL",
75
+ return_item_collection_metrics: "SIZE"
76
+ }.merge!(options)
77
+ )
78
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
79
+ raise Dynamoid::Errors::ConditionalCheckFailedException, e
80
+ end
81
+ end
82
+
83
+ # Get many items at once from DynamoDB. More efficient than getting each item individually.
84
+ #
85
+ # @example Retrieve IDs 1 and 2 from the table testtable
86
+ # Dynamoid::AdapterPlugin::AwsSdkV2.batch_get_item({'table1' => ['1', '2']})
87
+ #
88
+ # @param [Hash] table_ids the hash of tables and IDs to retrieve
89
+ # @param [Hash] options to be passed to underlying BatchGet call
90
+ #
91
+ # @return [Hash] a hash where keys are the table names and the values are the retrieved items
92
+ #
93
+ # @since 1.0.0
94
+ #
95
+ # @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
96
+ def batch_get_item(table_ids, options = {})
97
+ request_items = Hash.new{|h, k| h[k] = []}
98
+ return request_items if table_ids.all?{|k, v| v.empty?}
99
+
100
+ table_ids.each do |t, ids|
101
+ next if ids.empty?
102
+ tbl = describe_table(t)
103
+ hk = tbl.hash_key.to_s
104
+ rng = tbl.range_key.to_s
105
+
106
+ keys = if rng.present?
107
+ Array(ids).map do |h,r|
108
+ { hk => h, rng => r }
109
+ end
110
+ else
111
+ Array(ids).map do |id|
112
+ { hk => id }
113
+ end
114
+ end
115
+
116
+ request_items[t] = {
117
+ keys: keys
118
+ }
119
+ end
120
+
121
+ results = client.batch_get_item(
122
+ request_items: request_items
123
+ )
124
+
125
+ ret = Hash.new([].freeze) # Default for tables where no rows are returned
126
+ results.data[:responses].each do |table, rows|
127
+ ret[table] = rows.collect { |r| result_item_to_hash(r) }
128
+ end
129
+ ret
130
+ end
131
+
132
+ # Delete many items at once from DynamoDB. More efficient than delete each item individually.
133
+ #
134
+ # @example Delete IDs 1 and 2 from the table testtable
135
+ # Dynamoid::AdapterPlugin::AwsSdk.batch_delete_item('table1' => ['1', '2'])
136
+ #or
137
+ # Dynamoid::AdapterPlugin::AwsSdkV2.batch_delete_item('table1' => [['hk1', 'rk2'], ['hk1', 'rk2']]]))
138
+ #
139
+ # @param [Hash] options the hash of tables and IDs to delete
140
+ #
141
+ # @return nil
142
+ #
143
+ # @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
144
+ def batch_delete_item(options)
145
+ options.each_pair do |table_name, ids|
146
+ table = describe_table(table_name)
147
+ ids.each do |id|
148
+ client.delete_item(table_name: table_name, key: key_stanza(table, *id))
149
+ end
150
+ end
151
+ nil
152
+ end
153
+
154
+ # Create a table on DynamoDB. This usually takes a long time to complete.
155
+ #
156
+ # @param [String] table_name the name of the table to create
157
+ # @param [Symbol] key the table's primary key (defaults to :id)
158
+ # @param [Hash] options provide a range key here if the table has a composite key
159
+ # @option options [Array<Dynamoid::Indexes::Index>] local_secondary_indexes
160
+ # @option options [Array<Dynamoid::Indexes::Index>] global_secondary_indexes
161
+ # @option options [Symbol] hash_key_type The type of the hash key
162
+ # @option options [Boolean] sync Wait for table status to be ACTIVE?
163
+ # @since 1.0.0
164
+ def create_table(table_name, key = :id, options = {})
165
+ Dynamoid.logger.info "Creating #{table_name} table. This could take a while."
166
+ read_capacity = options[:read_capacity] || Dynamoid::Config.read_capacity
167
+ write_capacity = options[:write_capacity] || Dynamoid::Config.write_capacity
168
+
169
+ secondary_indexes = options.slice(
170
+ :local_secondary_indexes,
171
+ :global_secondary_indexes
172
+ )
173
+ ls_indexes = options[:local_secondary_indexes]
174
+ gs_indexes = options[:global_secondary_indexes]
175
+
176
+ key_schema = {
177
+ :hash_key_schema => { key => (options[:hash_key_type] || :string) },
178
+ :range_key_schema => options[:range_key]
179
+ }
180
+ attribute_definitions = build_all_attribute_definitions(
181
+ key_schema,
182
+ secondary_indexes
183
+ )
184
+ key_schema = aws_key_schema(
185
+ key_schema[:hash_key_schema],
186
+ key_schema[:range_key_schema]
187
+ )
188
+
189
+ client_opts = {
190
+ table_name: table_name,
191
+ provisioned_throughput: {
192
+ read_capacity_units: read_capacity,
193
+ write_capacity_units: write_capacity
194
+ },
195
+ key_schema: key_schema,
196
+ attribute_definitions: attribute_definitions
197
+ }
198
+
199
+ if ls_indexes.present?
200
+ client_opts[:local_secondary_indexes] = ls_indexes.map do |index|
201
+ index_to_aws_hash(index)
202
+ end
203
+ end
204
+
205
+ if gs_indexes.present?
206
+ client_opts[:global_secondary_indexes] = gs_indexes.map do |index|
207
+ index_to_aws_hash(index)
208
+ end
209
+ end
210
+ resp = client.create_table(client_opts)
211
+ options[:sync] = true if !options.has_key?(:sync) && ls_indexes.present? || gs_indexes.present?
212
+ until_past_table_status(table_name) if options[:sync] &&
213
+ (status = PARSE_TABLE_STATUS.call(resp, :table_description)) &&
214
+ status != TABLE_STATUSES[:creating]
215
+ # Response to original create_table, which, if options[:sync]
216
+ # may have an outdated table_description.table_status of "CREATING"
217
+ resp
218
+ rescue Aws::DynamoDB::Errors::ResourceInUseException => e
219
+ Dynamoid.logger.error "Table #{table_name} cannot be created as it already exists"
220
+ end
221
+
222
+ # Create a table on DynamoDB *synchronously*.
223
+ # This usually takes a long time to complete.
224
+ # CreateTable is normally an asynchronous operation.
225
+ # You can optionally define secondary indexes on the new table,
226
+ # as part of the CreateTable operation.
227
+ # If you want to create multiple tables with secondary indexes on them,
228
+ # you must create the tables sequentially.
229
+ # Only one table with secondary indexes can be
230
+ # in the CREATING state at any given time.
231
+ # See: http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#create_table-instance_method
232
+ #
233
+ # @param [String] table_name the name of the table to create
234
+ # @param [Symbol] key the table's primary key (defaults to :id)
235
+ # @param [Hash] options provide a range key here if the table has a composite key
236
+ # @option options [Array<Dynamoid::Indexes::Index>] local_secondary_indexes
237
+ # @option options [Array<Dynamoid::Indexes::Index>] global_secondary_indexes
238
+ # @option options [Symbol] hash_key_type The type of the hash key
239
+ # @since 1.2.0
240
+ def create_table_synchronously(table_name, key = :id, options = {})
241
+ create_table(table_name, key, options.merge(sync: true))
242
+ end
243
+
244
+ # Removes an item from DynamoDB.
245
+ #
246
+ # @param [String] table_name the name of the table
247
+ # @param [String] key the hash key of the item to delete
248
+ # @param [Hash] options provide a range key here if the table has a composite key
249
+ #
250
+ # @since 1.0.0
251
+ #
252
+ # @todo: Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#delete_item-instance_method
253
+ def delete_item(table_name, key, options = {})
254
+ options ||= {}
255
+ range_key = options[:range_key]
256
+ conditions = options[:conditions]
257
+ table = describe_table(table_name)
258
+ client.delete_item(
259
+ table_name: table_name,
260
+ key: key_stanza(table, key, range_key),
261
+ expected: expected_stanza(conditions)
262
+ )
263
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
264
+ raise Dynamoid::Errors::ConditionalCheckFailedException, e
265
+ end
266
+
267
+ # Deletes an entire table from DynamoDB.
268
+ #
269
+ # @param [String] table_name the name of the table to destroy
270
+ # @option options [Boolean] sync Wait for table status check to raise ResourceNotFoundException
271
+ #
272
+ # @since 1.0.0
273
+ def delete_table(table_name, options = {})
274
+ resp = client.delete_table(table_name: table_name)
275
+ until_past_table_status(table_name, :deleting) if options[:sync] &&
276
+ (status = PARSE_TABLE_STATUS.call(resp, :table_description)) &&
277
+ status != TABLE_STATUSES[:deleting]
278
+ table_cache.delete(table_name)
279
+ rescue Aws::DynamoDB::Errors::ResourceInUseException => e
280
+ Dynamoid.logger.error "Table #{table_name} cannot be deleted as it is in use"
281
+ raise e
282
+ end
283
+
284
+ def delete_table_synchronously(table_name, options = {})
285
+ delete_table(table_name, options.merge(sync: true))
286
+ end
287
+
288
+ # @todo Add a DescribeTable method.
289
+
290
+ # Fetches an item from DynamoDB.
291
+ #
292
+ # @param [String] table_name the name of the table
293
+ # @param [String] key the hash key of the item to find
294
+ # @param [Hash] options provide a range key here if the table has a composite key
295
+ #
296
+ # @return [Hash] a hash representing the raw item in DynamoDB
297
+ #
298
+ # @since 1.0.0
299
+ #
300
+ # @todo Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#get_item-instance_method
301
+ def get_item(table_name, key, options = {})
302
+ options ||= {}
303
+ table = describe_table(table_name)
304
+ range_key = options.delete(:range_key)
305
+
306
+ item = client.get_item(table_name: table_name,
307
+ key: key_stanza(table, key, range_key)
308
+ )[:item]
309
+ item ? result_item_to_hash(item) : nil
310
+ end
311
+
312
+ # 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
313
+ #
314
+ # @param [String] table_name the name of the table
315
+ # @param [String] key the hash key of the item to find
316
+ # @param [Hash] options provide a range key here if the table has a composite key
317
+ #
318
+ # @return new attributes for the record
319
+ #
320
+ # @todo Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method
321
+ def update_item(table_name, key, options = {})
322
+ range_key = options.delete(:range_key)
323
+ conditions = options.delete(:conditions)
324
+ table = describe_table(table_name)
325
+
326
+ yield(iu = ItemUpdater.new(table, key, range_key))
327
+
328
+ raise "non-empty options: #{options}" unless options.empty?
329
+ begin
330
+ result = client.update_item(table_name: table_name,
331
+ key: key_stanza(table, key, range_key),
332
+ attribute_updates: iu.to_h,
333
+ expected: expected_stanza(conditions),
334
+ return_values: "ALL_NEW"
335
+ )
336
+ result_item_to_hash(result[:attributes])
337
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
338
+ raise Dynamoid::Errors::ConditionalCheckFailedException, e
339
+ end
340
+ end
341
+
342
+ # List all tables on DynamoDB.
343
+ #
344
+ # @since 1.0.0
345
+ #
346
+ # @todo Provide limit support http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#update_item-instance_method
347
+ def list_tables
348
+ client.list_tables[:table_names]
349
+ end
350
+
351
+ # Persists an item on DynamoDB.
352
+ #
353
+ # @param [String] table_name the name of the table
354
+ # @param [Object] object a hash or Dynamoid object to persist
355
+ #
356
+ # @since 1.0.0
357
+ #
358
+ # See: http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#put_item-instance_method
359
+ def put_item(table_name, object, options = {})
360
+ item = {}
361
+ options ||= {}
362
+
363
+ object.each do |k, v|
364
+ next if v.nil? || (v.respond_to?(:empty?) && v.empty?)
365
+ item[k.to_s] = v
366
+ end
367
+
368
+ begin
369
+ client.put_item(
370
+ {
371
+ table_name: table_name,
372
+ item: item,
373
+ expected: expected_stanza(options)
374
+ }.merge!(options)
375
+ )
376
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
377
+ raise Dynamoid::Errors::ConditionalCheckFailedException, e
378
+ end
379
+ end
380
+
381
+ # Query the DynamoDB table. This employs DynamoDB's indexes so is generally faster than scanning, but is
382
+ # only really useful for range queries, since it can only find by one hash key at once. Only provide
383
+ # one range key to the hash.
384
+ #
385
+ # @param [String] table_name the name of the table
386
+ # @param [Hash] opts the options to query the table with
387
+ # @option opts [String] :hash_value the value of the hash key to find
388
+ # @option opts [Number, Number] :range_between find the range key within this range
389
+ # @option opts [Number] :range_greater_than find range keys greater than this
390
+ # @option opts [Number] :range_less_than find range keys less than this
391
+ # @option opts [Number] :range_gte find range keys greater than or equal to this
392
+ # @option opts [Number] :range_lte find range keys less than or equal to this
393
+ #
394
+ # @return [Enumerable] matching items
395
+ #
396
+ # @since 1.0.0
397
+ #
398
+ # @todo Provide support for various other options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#query-instance_method
399
+ def query(table_name, opts = {})
400
+ table = describe_table(table_name)
401
+ hk = (opts[:hash_key].present? ? opts[:hash_key] : table.hash_key).to_s
402
+ rng = (opts[:range_key].present? ? opts[:range_key] : table.range_key).to_s
403
+ q = opts.slice(
404
+ :consistent_read,
405
+ :scan_index_forward,
406
+ :limit,
407
+ :select,
408
+ :index_name
409
+ )
410
+
411
+ opts.delete(:consistent_read)
412
+ opts.delete(:scan_index_forward)
413
+ opts.delete(:limit)
414
+ opts.delete(:select)
415
+ opts.delete(:index_name)
416
+
417
+ opts.delete(:next_token).tap do |token|
418
+ break unless token
419
+ q[:exclusive_start_key] = {
420
+ hk => token[:hash_key_element],
421
+ rng => token[:range_key_element]
422
+ }
423
+ end
424
+
425
+ key_conditions = {
426
+ hk => {
427
+ # TODO: Provide option for other operators like NE, IN, LE, etc
428
+ comparison_operator: EQ,
429
+ attribute_value_list: [
430
+ opts.delete(:hash_value).freeze
431
+ ]
432
+ }
433
+ }
434
+
435
+ opts.each_pair do |k, v|
436
+ # TODO: ATM, only few comparison operators are supported, provide support for all operators
437
+ next unless(op = RANGE_MAP[k])
438
+ key_conditions[rng] = {
439
+ comparison_operator: op,
440
+ attribute_value_list: [
441
+ opts.delete(k).freeze
442
+ ].flatten # Flatten as BETWEEN operator specifies array of two elements
443
+ }
444
+ end
445
+
446
+ q[:table_name] = table_name
447
+ q[:key_conditions] = key_conditions
448
+
449
+ Enumerator.new { |y|
450
+ loop do
451
+ results = client.query(q)
452
+ results.items.each { |row| y << result_item_to_hash(row) }
453
+
454
+ if(lk = results.last_evaluated_key)
455
+ q[:exclusive_start_key] = lk
456
+ else
457
+ break
458
+ end
459
+ end
460
+ }
461
+ end
462
+
463
+ # Scan the DynamoDB table. This is usually a very slow operation as it naively filters all data on
464
+ # the DynamoDB servers.
465
+ #
466
+ # @param [String] table_name the name of the table
467
+ # @param [Hash] scan_hash a hash of attributes: matching records will be returned by the scan
468
+ #
469
+ # @return [Enumerable] matching items
470
+ #
471
+ # @since 1.0.0
472
+ #
473
+ # @todo: Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#scan-instance_method
474
+ def scan(table_name, scan_hash, select_opts = {})
475
+ limit = select_opts.delete(:limit)
476
+ batch = select_opts.delete(:batch_size)
477
+
478
+ request = { table_name: table_name }
479
+ request[:limit] = batch || limit if batch || limit
480
+ request[:scan_filter] = scan_hash.reduce({}) do |memo, kvp|
481
+ memo[kvp[0].to_s] = {
482
+ attribute_value_list: [kvp[1]],
483
+ # TODO: Provide support for all comparison operators
484
+ comparison_operator: EQ
485
+ }
486
+ memo
487
+ end if scan_hash.present?
488
+
489
+ Enumerator.new do |y|
490
+ # Batch loop, pulls multiple requests until done using the start_key
491
+ loop do
492
+ results = client.scan(request)
493
+
494
+ results.data[:items].each { |row| y << result_item_to_hash(row) }
495
+
496
+ if((lk = results[:last_evaluated_key]) && batch)
497
+ request[:exclusive_start_key] = lk
498
+ else
499
+ break
500
+ end
501
+ end
502
+ end
503
+ end
504
+
505
+ #
506
+ # Truncates all records in the given table
507
+ #
508
+ # @param [String] table_name the name of the table
509
+ #
510
+ # @since 1.0.0
511
+ def truncate(table_name)
512
+ table = describe_table(table_name)
513
+ hk = table.hash_key
514
+ rk = table.range_key
515
+
516
+ scan(table_name, {}, {}).each do |attributes|
517
+ opts = {}
518
+ opts[:range_key] = attributes[rk.to_sym] if rk
519
+ delete_item(table_name, attributes[hk], opts)
520
+ end
521
+ end
522
+
523
+ def count(table_name)
524
+ describe_table(table_name, true).item_count
525
+ end
526
+
527
+ protected
528
+
529
+ def check_table_status?(counter, resp, expect_status)
530
+ status = PARSE_TABLE_STATUS.call(resp)
531
+ again = counter < Dynamoid::Config.sync_retry_max_times &&
532
+ status == TABLE_STATUSES[expect_status]
533
+ {again: again, status: status, counter: counter}
534
+ end
535
+
536
+ def until_past_table_status(table_name, status = :creating)
537
+ counter = 0
538
+ resp = nil
539
+ begin
540
+ check = {again: true}
541
+ while check[:again]
542
+ sleep Dynamoid::Config.sync_retry_wait_seconds
543
+ resp = client.describe_table({ table_name: table_name })
544
+ check = check_table_status?(counter, resp, status)
545
+ Dynamoid.logger.info "Checked table status for #{table_name} (check #{check.inspect})"
546
+ counter += 1
547
+ end
548
+ # If you issue a DescribeTable request immediately after a CreateTable
549
+ # request, DynamoDB might return a ResourceNotFoundException.
550
+ # This is because DescribeTable uses an eventually consistent query,
551
+ # and the metadata for your table might not be available at that moment.
552
+ # Wait for a few seconds, and then try the DescribeTable request again.
553
+ # See: http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#describe_table-instance_method
554
+ rescue Aws::DynamoDB::Errors::ResourceNotFoundException => e
555
+ case status
556
+ when :creating then
557
+ if counter >= Dynamoid::Config.sync_retry_max_times
558
+ Dynamoid.logger.warn "Waiting on table metadata for #{table_name} (check #{counter})"
559
+ retry # start over at first line of begin, does not reset counter
560
+ else
561
+ Dynamoid.logger.error "Exhausted max retries (Dynamoid::Config.sync_retry_max_times) waiting on table metadata for #{table_name} (check #{counter})"
562
+ raise e
563
+ end
564
+ else
565
+ # When deleting a table, "not found" is the goal.
566
+ Dynamoid.logger.info "Checked table status for #{table_name}: Not Found (check #{check.inspect})"
567
+ end
568
+ end
569
+ end
570
+
571
+ #Converts from symbol to the API string for the given data type
572
+ # E.g. :number -> 'N'
573
+ def api_type(type)
574
+ case(type)
575
+ when :string then STRING_TYPE
576
+ when :number then NUM_TYPE
577
+ when :binary then BINARY_TYPE
578
+ else raise "Unknown type: #{type}"
579
+ end
580
+ end
581
+
582
+ #
583
+ # The key hash passed on get_item, put_item, delete_item, update_item, etc
584
+ #
585
+ def key_stanza(table, hash_key, range_key = nil)
586
+ key = { table.hash_key.to_s => hash_key }
587
+ key[table.range_key.to_s] = range_key if range_key
588
+ key
589
+ end
590
+
591
+ #
592
+ # @param [Hash] conditions Conditions to enforce on operation (e.g. { :if => { :count => 5 }, :unless_exists => ['id']})
593
+ # @return an Expected stanza for the given conditions hash
594
+ #
595
+ def expected_stanza(conditions = nil)
596
+ expected = Hash.new { |h,k| h[k] = {} }
597
+ return expected unless conditions
598
+
599
+ conditions.delete(:unless_exists).try(:each) do |col|
600
+ expected[col.to_s][:exists] = false
601
+ end
602
+ conditions.delete(:if).try(:each) do |col,val|
603
+ expected[col.to_s][:value] = val
604
+ end
605
+
606
+ expected
607
+ end
608
+
609
+ #
610
+ # New, semi-arbitrary API to get data on the table
611
+ #
612
+ def describe_table(table_name, reload = false)
613
+ (!reload && table_cache[table_name]) || begin
614
+ table_cache[table_name] = Table.new(client.describe_table(table_name: table_name).data)
615
+ end
616
+ end
617
+
618
+ #
619
+ # Converts a hash returned by get_item, scan, etc. into a key-value hash
620
+ #
621
+ def result_item_to_hash(item)
622
+ {}.tap do |r|
623
+ item.each { |k,v| r[k.to_sym] = v }
624
+ end
625
+ end
626
+
627
+ # Converts a Dynamoid::Indexes::Index to an AWS API-compatible hash.
628
+ # This resulting hash is of the form:
629
+ #
630
+ # {
631
+ # index_name: String
632
+ # keys: {
633
+ # hash_key: aws_key_schema (hash)
634
+ # range_key: aws_key_schema (hash)
635
+ # }
636
+ # projection: {
637
+ # projection_type: (ALL, KEYS_ONLY, INCLUDE) String
638
+ # non_key_attributes: (optional) Array
639
+ # }
640
+ # provisioned_throughput: {
641
+ # read_capacity_units: Integer
642
+ # write_capacity_units: Integer
643
+ # }
644
+ # }
645
+ #
646
+ # @param [Dynamoid::Indexes::Index] index the index.
647
+ # @return [Hash] hash representing an AWS Index definition.
648
+ def index_to_aws_hash(index)
649
+ key_schema = aws_key_schema(index.hash_key_schema, index.range_key_schema)
650
+
651
+ hash = {
652
+ :index_name => index.name,
653
+ :key_schema => key_schema,
654
+ :projection => {
655
+ :projection_type => index.projection_type.to_s.upcase
656
+ }
657
+ }
658
+
659
+ # If the projection type is include, specify the non key attributes
660
+ if index.projection_type == :include
661
+ hash[:projection][:non_key_attributes] = index.projected_attributes
662
+ end
663
+
664
+ # Only global secondary indexes have a separate throughput.
665
+ if index.type == :global_secondary
666
+ hash[:provisioned_throughput] = {
667
+ :read_capacity_units => index.read_capacity,
668
+ :write_capacity_units => index.write_capacity
669
+ }
670
+ end
671
+ hash
672
+ end
673
+
674
+ # Converts hash_key_schema and range_key_schema to aws_key_schema
675
+ # @param [Hash] hash_key_schema eg: {:id => :string}
676
+ # @param [Hash] range_key_schema eg: {:created_at => :number}
677
+ # @return [Array]
678
+ def aws_key_schema(hash_key_schema, range_key_schema)
679
+ schema = [{
680
+ attribute_name: hash_key_schema.keys.first.to_s,
681
+ key_type: HASH_KEY
682
+ }]
683
+
684
+ if range_key_schema.present?
685
+ schema << {
686
+ attribute_name: range_key_schema.keys.first.to_s,
687
+ key_type: RANGE_KEY
688
+ }
689
+ end
690
+ schema
691
+ end
692
+
693
+ # Builds aws attributes definitions based off of primary hash/range and
694
+ # secondary indexes
695
+ #
696
+ # @param key_data
697
+ # @option key_data [Hash] hash_key_schema - eg: {:id => :string}
698
+ # @option key_data [Hash] range_key_schema - eg: {:created_at => :number}
699
+ # @param [Hash] secondary_indexes
700
+ # @option secondary_indexes [Array<Dynamoid::Indexes::Index>] :local_secondary_indexes
701
+ # @option secondary_indexes [Array<Dynamoid::Indexes::Index>] :global_secondary_indexes
702
+ def build_all_attribute_definitions(key_schema, secondary_indexes = {})
703
+ ls_indexes = secondary_indexes[:local_secondary_indexes]
704
+ gs_indexes = secondary_indexes[:global_secondary_indexes]
705
+
706
+ attribute_definitions = []
707
+
708
+ attribute_definitions << build_attribute_definitions(
709
+ key_schema[:hash_key_schema],
710
+ key_schema[:range_key_schema]
711
+ )
712
+
713
+ if ls_indexes.present?
714
+ ls_indexes.map do |index|
715
+ attribute_definitions << build_attribute_definitions(
716
+ index.hash_key_schema,
717
+ index.range_key_schema
718
+ )
719
+ end
720
+ end
721
+
722
+ if gs_indexes.present?
723
+ gs_indexes.map do |index|
724
+ attribute_definitions << build_attribute_definitions(
725
+ index.hash_key_schema,
726
+ index.range_key_schema
727
+ )
728
+ end
729
+ end
730
+
731
+ attribute_definitions.flatten!
732
+ # uniq these definitions because range keys might be common between
733
+ # primary and secondary indexes
734
+ attribute_definitions.uniq!
735
+ attribute_definitions
736
+ end
737
+
738
+
739
+ # Builds an attribute definitions based on hash key and range key
740
+ # @params [Hash] hash_key_schema - eg: {:id => :string}
741
+ # @params [Hash] range_key_schema - eg: {:created_at => :datetime}
742
+ # @return [Array]
743
+ def build_attribute_definitions(hash_key_schema, range_key_schema = nil)
744
+ attrs = []
745
+
746
+ attrs << attribute_definition_element(
747
+ hash_key_schema.keys.first,
748
+ hash_key_schema.values.first
749
+ )
750
+
751
+ if range_key_schema.present?
752
+ attrs << attribute_definition_element(
753
+ range_key_schema.keys.first,
754
+ range_key_schema.values.first
755
+ )
756
+ end
757
+
758
+ attrs
759
+ end
760
+
761
+ # Builds an aws attribute definition based on name and dynamoid type
762
+ # @params [Symbol] name - eg: :id
763
+ # @params [Symbol] dynamoid_type - eg: :string
764
+ # @return [Hash]
765
+ def attribute_definition_element(name, dynamoid_type)
766
+ aws_type = api_type(dynamoid_type)
767
+
768
+ {
769
+ :attribute_name => name.to_s,
770
+ :attribute_type => aws_type
771
+ }
772
+ end
773
+
774
+ #
775
+ # Represents a table. Exposes data from the "DescribeTable" API call, and also
776
+ # provides methods for coercing values to the proper types based on the table's schema data
777
+ #
778
+ class Table
779
+ attr_reader :schema
780
+
781
+ #
782
+ # @param [Hash] schema Data returns from a "DescribeTable" call
783
+ #
784
+ def initialize(schema)
785
+ @schema = schema[:table]
786
+ end
787
+
788
+ def range_key
789
+ @range_key ||= schema[:key_schema].find { |d| d[:key_type] == RANGE_KEY }.try(:attribute_name)
790
+ end
791
+
792
+ def range_type
793
+ range_type ||= schema[:attribute_definitions].find { |d|
794
+ d[:attribute_name] == range_key
795
+ }.try(:fetch,:attribute_type, nil)
796
+ end
797
+
798
+ def hash_key
799
+ @hash_key ||= schema[:key_schema].find { |d| d[:key_type] == HASH_KEY }.try(:attribute_name).to_sym
800
+ end
801
+
802
+ #
803
+ # Returns the API type (e.g. "N", "S") for the given column, if the schema defines it,
804
+ # nil otherwise
805
+ #
806
+ def col_type(col)
807
+ col = col.to_s
808
+ col_def = schema[:attribute_definitions].find { |d| d[:attribute_name] == col.to_s }
809
+ col_def && col_def[:attribute_type]
810
+ end
811
+
812
+ def item_count
813
+ schema[:item_count]
814
+ end
815
+ end
816
+
817
+ #
818
+ # Mimics behavior of the yielded object on DynamoDB's update_item API (high level).
819
+ #
820
+ class ItemUpdater
821
+ attr_reader :table, :key, :range_key
822
+
823
+ def initialize(table, key, range_key = nil)
824
+ @table = table; @key = key, @range_key = range_key
825
+ @additions = {}
826
+ @deletions = {}
827
+ @updates = {}
828
+ end
829
+
830
+ #
831
+ # Adds the given values to the values already stored in the corresponding columns.
832
+ # The column must contain a Set or a number.
833
+ #
834
+ # @param [Hash] vals keys of the hash are the columns to update, vals are the values to
835
+ # add. values must be a Set, Array, or Numeric
836
+ #
837
+ def add(values)
838
+ @additions.merge!(values)
839
+ end
840
+
841
+ #
842
+ # Removes values from the sets of the given columns
843
+ #
844
+ # @param [Hash] values keys of the hash are the columns, values are Arrays/Sets of items
845
+ # to remove
846
+ #
847
+ def delete(values)
848
+ @deletions.merge!(values)
849
+ end
850
+
851
+ #
852
+ # Replaces the values of one or more attributes
853
+ #
854
+ def set(values)
855
+ @updates.merge!(values)
856
+ end
857
+
858
+ #
859
+ # Returns an AttributeUpdates hash suitable for passing to the V2 Client API
860
+ #
861
+ def to_h
862
+ ret = {}
863
+
864
+ @additions.each do |k,v|
865
+ ret[k.to_s] = {
866
+ action: ADD,
867
+ value: v
868
+ }
869
+ end
870
+ @deletions.each do |k,v|
871
+ ret[k.to_s] = {
872
+ action: DELETE,
873
+ value: v
874
+ }
875
+ end
876
+ @updates.each do |k,v|
877
+ ret[k.to_s] = {
878
+ action: PUT,
879
+ value: v
880
+ }
881
+ end
882
+
883
+ ret
884
+ end
885
+
886
+ ADD = "ADD".freeze
887
+ DELETE = "DELETE".freeze
888
+ PUT = "PUT".freeze
889
+ end
890
+ end
891
+ end
892
+ end