synamoid 1.2.1

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.
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