lotus-dynamodb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,190 @@
1
+ require 'aws-sdk'
2
+ require 'lotus/model/adapters/abstract'
3
+ require 'lotus/model/adapters/implementation'
4
+ require 'lotus/model/adapters/dynamodb/coercer'
5
+ require 'lotus/model/adapters/dynamodb/collection'
6
+ require 'lotus/model/adapters/dynamodb/command'
7
+ require 'lotus/model/adapters/dynamodb/query'
8
+
9
+ module Lotus
10
+ module Model
11
+ module Adapters
12
+ # Adapter for Amazon DynamoDB.
13
+ #
14
+ # @api private
15
+ # @since 0.1.0
16
+ class DynamodbAdapter < Abstract
17
+ include Implementation
18
+
19
+ # Initialize the adapter.
20
+ #
21
+ # It takes advantage of AWS::DynamoDB::Client to perform all operations.
22
+ #
23
+ # @param mapper [Object] the database mapper
24
+ #
25
+ # @return [Lotus::Model::Adapters::DynamodbAdapter]
26
+ #
27
+ # @see Lotus::Model::Mapper
28
+ # @see Lotus::Dynamodb::API_VERSION
29
+ # @see http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/DynamoDB/Client/V20120810.html
30
+ #
31
+ # @api private
32
+ # @since 0.1.0
33
+ def initialize(mapper)
34
+ super
35
+
36
+ @client = AWS::DynamoDB::Client.new(
37
+ api_version: Lotus::Dynamodb::API_VERSION
38
+ )
39
+ @collections = {}
40
+ end
41
+
42
+ # Creates a record in the database for the given entity.
43
+ # It assigns the `id` attribute, in case of success.
44
+ #
45
+ # @param collection [Symbol] the target collection (it must be mapped)
46
+ # @param entity [#id=] the entity to create
47
+ #
48
+ # @return [Object] the entity
49
+ #
50
+ # @api private
51
+ # @since 0.1.0
52
+ def create(collection, entity)
53
+ entity.id = command(collection).create(entity)
54
+ entity
55
+ end
56
+
57
+ # Updates a record in the database corresponding to the given entity.
58
+ #
59
+ # @param collection [Symbol] the target collection (it must be mapped)
60
+ # @param entity [#id] the entity to update
61
+ #
62
+ # @return [Object] the entity
63
+ #
64
+ # @api private
65
+ # @since 0.1.0
66
+ def update(collection, entity)
67
+ command(collection).update(entity)
68
+ end
69
+
70
+ # Deletes a record in the database corresponding to the given entity.
71
+ #
72
+ # @param collection [Symbol] the target collection (it must be mapped)
73
+ # @param entity [#id] the entity to delete
74
+ #
75
+ # @api private
76
+ # @since 0.1.0
77
+ def delete(collection, entity)
78
+ command(collection).delete(entity)
79
+ end
80
+
81
+ # Deletes all the records from the given collection.
82
+ #
83
+ # This works terribly slow at the moment, and this is only useful for
84
+ # testing small collections. Consider re-creating table from scratch.
85
+ #
86
+ # @param collection [Symbol] the target collection (it must be mapped)
87
+ #
88
+ # @api private
89
+ # @since 0.1.0
90
+ def clear(collection)
91
+ command(collection).clear
92
+ end
93
+
94
+ # Returns an unique record from the given collection, with the given
95
+ # id.
96
+ #
97
+ # @param collection [Symbol] the target collection (it must be mapped)
98
+ # @param key [Array] the identity of the object
99
+ #
100
+ # @return [Object] the entity
101
+ #
102
+ # @api private
103
+ # @since 0.1.0
104
+ def find(collection, *key)
105
+ command(collection).get(key)
106
+ end
107
+
108
+ # This method is not implemented. DynamoDB does not allow
109
+ # table-wide sorting.
110
+ #
111
+ # @see http://stackoverflow.com/a/17495069
112
+ #
113
+ # @param collection [Symbol] the target collection (it must be mapped)
114
+ #
115
+ # @raise [NotImplementedError]
116
+ #
117
+ # @since 0.1.0
118
+ def first(collection)
119
+ raise NotImplementedError
120
+ end
121
+
122
+ # This method is not implemented. DynamoDB does not allow
123
+ # table-wide sorting.
124
+ #
125
+ # @see http://stackoverflow.com/a/17495069
126
+ #
127
+ # @param collection [Symbol] the target collection (it must be mapped)
128
+ #
129
+ # @raise [NotImplementedError]
130
+ #
131
+ # @since 0.1.0
132
+ def last(collection)
133
+ raise NotImplementedError
134
+ end
135
+
136
+ # Fabricates a command for the given query.
137
+ #
138
+ # @param collection [Symbol] the target collection (it must be mapped)
139
+ #
140
+ # @return [Lotus::Model::Adapters::Dynamodb::Command]
141
+ #
142
+ # @see Lotus::Model::Adapters::Dynamodb::Command
143
+ #
144
+ # @api private
145
+ # @since 0.1.0
146
+ def command(collection)
147
+ Dynamodb::Command.new(_collection(collection), _mapped_collection(collection))
148
+ end
149
+
150
+ # Fabricates a query
151
+ #
152
+ # @param collection [Symbol] the target collection (it must be mapped)
153
+ # @param context [Object]
154
+ # @param blk [Proc] a block of code to be executed in the context of
155
+ # the query.
156
+ #
157
+ # @return [Lotus::Model::Adapters::Dynamodb::Query]
158
+ #
159
+ # @see Lotus::Model::Adapters::Dynamodb::Query
160
+ #
161
+ # @api private
162
+ # @since 0.1.0
163
+ def query(collection, context = nil, &blk)
164
+ Dynamodb::Query.new(_collection(collection), _mapped_collection(collection), &blk)
165
+ end
166
+
167
+ private
168
+
169
+ # Returns a collection from the given name.
170
+ #
171
+ # @param name [Symbol] a name of the collection (it must be mapped)
172
+ #
173
+ # @return [Lotus::Model::Adapters::Dynamodb::Collection]
174
+ #
175
+ # @see Lotus::Model::Adapters::Dynamodb::Collection
176
+ #
177
+ # @api private
178
+ # @since 0.1.0
179
+ def _collection(name)
180
+ @collections[name] ||= Dynamodb::Collection.new(
181
+ @client,
182
+ Lotus::Model::Adapters::Dynamodb::Coercer.new(_mapped_collection(name)),
183
+ name,
184
+ _identity(name),
185
+ )
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,3 @@
1
+ require 'lotus/dynamodb/config'
2
+ require 'lotus/model'
3
+ require 'lotus/model/adapters/dynamodb_adapter'
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'lotus/dynamodb/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'lotus-dynamodb'
8
+ spec.version = Lotus::Dynamodb::VERSION
9
+ spec.authors = ['Dmitry Krasnoukhov']
10
+ spec.email = ['dmitry@krasnoukhov.com']
11
+ spec.summary = spec.description = %q{Amazon DynamoDB adapter for Lotus::Model}
12
+ spec.homepage = 'https://github.com/krasnoukhov/lotus-dynamodb'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_runtime_dependency 'lotus-model', '~> 0.1'
21
+ spec.add_runtime_dependency 'aws-sdk', '~> 1.0'
22
+ spec.add_runtime_dependency 'multi_json', '~> 1.10'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.5'
25
+ spec.add_development_dependency 'minitest', '~> 5'
26
+ spec.add_development_dependency 'minitest-line', '~> 0.6'
27
+ spec.add_development_dependency 'rake', '~> 10'
28
+ spec.add_development_dependency 'fake_dynamo', '~> 0.2'
29
+ spec.add_development_dependency 'foreman', '~> 0.67'
30
+ end
data/test/fixtures.rb ADDED
@@ -0,0 +1,75 @@
1
+ DB = AWS::DynamoDB::Client.new(api_version: Lotus::Dynamodb::API_VERSION)
2
+
3
+ begin
4
+ DB.create_table(
5
+ table_name: "test_users",
6
+ attribute_definitions: [
7
+ { attribute_name: "id", attribute_type: "S" },
8
+ ],
9
+ key_schema: [
10
+ { attribute_name: "id", key_type: "HASH" },
11
+ ],
12
+ provisioned_throughput: {
13
+ read_capacity_units: 10,
14
+ write_capacity_units: 10,
15
+ },
16
+ )
17
+
18
+ DB.create_table(
19
+ table_name: "test_devices",
20
+ attribute_definitions: [
21
+ { attribute_name: "uuid", attribute_type: "S" },
22
+ { attribute_name: "created_at", attribute_type: "N" },
23
+ ],
24
+ key_schema: [
25
+ { attribute_name: "uuid", key_type: "HASH" },
26
+ { attribute_name: "created_at", key_type: "RANGE" },
27
+ ],
28
+ provisioned_throughput: {
29
+ read_capacity_units: 10,
30
+ write_capacity_units: 10,
31
+ },
32
+ )
33
+
34
+ DB.create_table(
35
+ table_name: "test_purchases",
36
+ attribute_definitions: [
37
+ { attribute_name: "region", attribute_type: "S" },
38
+ { attribute_name: "created_at", attribute_type: "N" },
39
+ { attribute_name: "subtotal", attribute_type: "N" },
40
+ { attribute_name: "uuid", attribute_type: "S" },
41
+ ],
42
+ key_schema: [
43
+ { attribute_name: "region", key_type: "HASH" },
44
+ { attribute_name: "created_at", key_type: "RANGE" },
45
+ ],
46
+ local_secondary_indexes: [{
47
+ index_name: "by_subtotal",
48
+ key_schema: [
49
+ { attribute_name: "region", key_type: "HASH" },
50
+ { attribute_name: "subtotal", key_type: "RANGE" },
51
+ ],
52
+ projection: {
53
+ projection_type: "ALL",
54
+ },
55
+ }],
56
+ global_secondary_indexes: [{
57
+ index_name: "by_uuid",
58
+ key_schema: [
59
+ { attribute_name: "uuid", key_type: "HASH" },
60
+ ],
61
+ projection: {
62
+ projection_type: "ALL",
63
+ },
64
+ provisioned_throughput: {
65
+ read_capacity_units: 10,
66
+ write_capacity_units: 10,
67
+ },
68
+ }],
69
+ provisioned_throughput: {
70
+ read_capacity_units: 10,
71
+ write_capacity_units: 10,
72
+ },
73
+ )
74
+ rescue AWS::DynamoDB::Errors::ResourceInUseException
75
+ end
@@ -0,0 +1,269 @@
1
+ require 'test_helper'
2
+ require 'multi_json'
3
+
4
+ describe Lotus::Model::Adapters::Dynamodb::Coercer do
5
+ before do
6
+ MockEntity = Struct.new(:id) do
7
+ include Lotus::Entity
8
+ end
9
+
10
+ class MockCollection
11
+ def attributes
12
+ { id: [Time, :id] }
13
+ end
14
+
15
+ def identity
16
+ :id
17
+ end
18
+
19
+ def entity
20
+ MockEntity
21
+ end
22
+ end
23
+
24
+ @coercer = Lotus::Model::Adapters::Dynamodb::Coercer.new(MockCollection.new)
25
+ end
26
+
27
+ after do
28
+ Object.send(:remove_const, :MockEntity)
29
+ end
30
+
31
+ describe '#from_*' do
32
+ describe 'skipped' do
33
+ describe 'Float' do
34
+ let(:subject) { 1.5 }
35
+
36
+ it 'remains unchanged' do
37
+ @coercer.from_float(subject).class.must_equal Float
38
+ @coercer.from_float(subject).must_equal subject
39
+ end
40
+ end
41
+
42
+ describe 'Integer' do
43
+ let(:subject) { 2 }
44
+
45
+ it 'remains unchanged' do
46
+ @coercer.from_integer(subject).class.must_equal Fixnum
47
+ @coercer.from_integer(subject).must_equal subject
48
+ end
49
+ end
50
+
51
+ describe 'Set' do
52
+ let(:subject) { Set.new([1, 2, 3]) }
53
+
54
+ it 'remains unchanged' do
55
+ @coercer.from_set(subject).class.must_equal Set
56
+ @coercer.from_set(subject).must_equal subject
57
+ end
58
+ end
59
+
60
+ describe 'String' do
61
+ let(:subject) { "omg" }
62
+
63
+ it 'remains unchanged' do
64
+ @coercer.from_string(subject).class.must_equal String
65
+ @coercer.from_string(subject).must_equal subject
66
+ end
67
+ end
68
+ end
69
+
70
+ describe 'supported' do
71
+ describe 'AWS::DynamoDB::Binary' do
72
+ let(:subject) { AWS::DynamoDB::Binary.new("HUUUGE") }
73
+
74
+ it 'coerces' do
75
+ @coercer.from_aws_dynamodb_binary(subject).class.must_equal \
76
+ AWS::DynamoDB::Binary
77
+ @coercer.from_aws_dynamodb_binary(subject).must_equal \
78
+ AWS::DynamoDB::Binary.new(subject)
79
+ end
80
+ end
81
+
82
+ describe 'Array' do
83
+ let(:subject) { ["omg"] }
84
+
85
+ it 'coerces' do
86
+ @coercer.from_array(subject).class.must_equal String
87
+ @coercer.from_array(subject).must_equal MultiJson.dump(subject)
88
+ end
89
+ end
90
+
91
+ describe 'Boolean' do
92
+ it 'coerces' do
93
+ @coercer.from_boolean(true).class.must_equal Fixnum
94
+ @coercer.from_boolean(true).must_equal 1
95
+ @coercer.from_boolean(false).class.must_equal Fixnum
96
+ @coercer.from_boolean(false).must_equal 0
97
+ end
98
+ end
99
+
100
+ describe 'Date' do
101
+ let(:subject) { Date.new(2014) }
102
+
103
+ it 'coerces' do
104
+ @coercer.from_date(subject).class.must_equal Fixnum
105
+ @coercer.from_date(subject).must_equal subject.to_time.to_i
106
+ end
107
+ end
108
+
109
+ describe 'DateTime' do
110
+ let(:subject) { DateTime.new(2014) }
111
+
112
+ it 'coerces' do
113
+ @coercer.from_datetime(subject).class.must_equal Float
114
+ @coercer.from_datetime(subject).must_equal subject.to_time.to_f
115
+ end
116
+ end
117
+
118
+ describe 'Hash' do
119
+ let(:subject) { { omg: "lol" } }
120
+
121
+ it 'coerces' do
122
+ @coercer.from_hash(subject).class.must_equal String
123
+ @coercer.from_hash(subject).must_equal MultiJson.dump(subject)
124
+ end
125
+ end
126
+
127
+ describe 'Time' do
128
+ let(:subject) { Time.at(0) }
129
+
130
+ it 'coerces' do
131
+ @coercer.from_time(subject).class.must_equal Float
132
+ @coercer.from_time(subject).must_equal subject.to_f
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ describe '#to_*' do
139
+ describe 'skipped' do
140
+ describe 'Float' do
141
+ let(:subject) { 1.5 }
142
+
143
+ it 'remains unchanged' do
144
+ @coercer.to_float(subject).must_equal subject
145
+ end
146
+ end
147
+
148
+ describe 'Integer' do
149
+ let(:subject) { 2 }
150
+
151
+ it 'remains unchanged' do
152
+ @coercer.to_integer(subject).must_equal subject
153
+ end
154
+ end
155
+
156
+ describe 'Set' do
157
+ let(:subject) { Set.new([1, 2, 3]) }
158
+
159
+ it 'remains unchanged' do
160
+ @coercer.to_set(subject).class.must_equal Set
161
+ @coercer.to_set(subject).must_equal subject
162
+ end
163
+ end
164
+
165
+ describe 'String' do
166
+ let(:subject) { "omg" }
167
+
168
+ it 'remains unchanged' do
169
+ @coercer.to_string(subject).must_equal subject
170
+ end
171
+ end
172
+ end
173
+
174
+ describe 'supported' do
175
+ describe 'AWS::DynamoDB::Binary' do
176
+ let(:subject) { "HUUUGE" }
177
+
178
+ it 'coerces' do
179
+ @coercer.to_aws_dynamodb_binary(subject).class.must_equal \
180
+ AWS::DynamoDB::Binary
181
+ @coercer.to_aws_dynamodb_binary(subject).must_equal \
182
+ AWS::DynamoDB::Binary.new(subject)
183
+ end
184
+ end
185
+
186
+ describe 'Array' do
187
+ let(:subject) { MultiJson.dump(["omg"]) }
188
+
189
+ it 'coerces' do
190
+ @coercer.to_array(subject).class.must_equal Array
191
+ @coercer.to_array(subject).must_equal MultiJson.load(subject)
192
+ end
193
+ end
194
+
195
+ describe 'Boolean' do
196
+ it 'coerces' do
197
+ @coercer.to_boolean(1).class.must_equal TrueClass
198
+ @coercer.to_boolean(1).must_equal true
199
+ @coercer.to_boolean(0).class.must_equal FalseClass
200
+ @coercer.to_boolean(0).must_equal false
201
+ end
202
+ end
203
+
204
+ describe 'Date' do
205
+ let(:subject) { Date.new(2014) }
206
+
207
+ it 'coerces' do
208
+ @coercer.to_date(subject.to_time.to_i).class.must_equal Date
209
+ @coercer.to_date(subject.to_time.to_i).must_equal subject
210
+ end
211
+ end
212
+
213
+ describe 'DateTime' do
214
+ let(:subject) { DateTime.new(2014) }
215
+
216
+ it 'coerces' do
217
+ @coercer.to_datetime(subject.to_time.to_f).class.must_equal DateTime
218
+ @coercer.to_datetime(subject.to_time.to_f).must_equal subject
219
+ end
220
+ end
221
+
222
+ describe 'Hash' do
223
+ let(:subject) { MultiJson.dump({ omg: "lol" }) }
224
+
225
+ it 'coerces' do
226
+ @coercer.to_hash(subject).class.must_equal Hash
227
+ @coercer.to_hash(subject).must_equal MultiJson.load(subject)
228
+ end
229
+ end
230
+
231
+ describe 'Time' do
232
+ let(:subject) { Time.at(0) }
233
+
234
+ it 'coerces' do
235
+ @coercer.to_time(subject.to_f).class.must_equal Time
236
+ @coercer.to_time(subject.to_f).must_equal subject
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ describe '#deserialize_*' do
243
+ it 'deserializes id' do
244
+ @coercer.deserialize_id(0.0).must_equal Time.at(0)
245
+ end
246
+ end
247
+
248
+ describe '#serialize_*' do
249
+ it 'serializes id' do
250
+ @coercer.serialize_id(Time.at(0)).must_equal 0.0
251
+ end
252
+ end
253
+
254
+ describe '#to_record' do
255
+ let(:subject) { MockEntity.new(id: Time.at(0)) }
256
+
257
+ it 'serializes entity' do
258
+ @coercer.to_record(subject).must_equal ({ id: 0.0 })
259
+ end
260
+ end
261
+
262
+ describe '#from_record' do
263
+ let(:subject) { { id: 1.0 } }
264
+
265
+ it 'deserializes entity' do
266
+ @coercer.from_record(subject).must_equal MockEntity.new(id: Time.at(1))
267
+ end
268
+ end
269
+ end