lotus-dynamodb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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