fake_dynamo 0.0.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/.gitignore +17 -0
- data/.rspec +1 -0
- data/Gemfile +11 -0
- data/Guardfile +19 -0
- data/LICENSE +22 -0
- data/README.md +4 -0
- data/Rakefile +2 -0
- data/bin/fake_dynamo +17 -0
- data/fake_dynamo.gemspec +18 -0
- data/lib/fake_dynamo.rb +20 -0
- data/lib/fake_dynamo/api.yml +734 -0
- data/lib/fake_dynamo/attribute.rb +54 -0
- data/lib/fake_dynamo/db.rb +113 -0
- data/lib/fake_dynamo/exceptions.rb +57 -0
- data/lib/fake_dynamo/filter.rb +110 -0
- data/lib/fake_dynamo/item.rb +102 -0
- data/lib/fake_dynamo/key.rb +71 -0
- data/lib/fake_dynamo/key_schema.rb +26 -0
- data/lib/fake_dynamo/server.rb +43 -0
- data/lib/fake_dynamo/storage.rb +71 -0
- data/lib/fake_dynamo/table.rb +362 -0
- data/lib/fake_dynamo/validation.rb +155 -0
- data/lib/fake_dynamo/version.rb +3 -0
- data/spec/fake_dynamo/db_spec.rb +257 -0
- data/spec/fake_dynamo/filter_spec.rb +122 -0
- data/spec/fake_dynamo/item_spec.rb +97 -0
- data/spec/fake_dynamo/server_spec.rb +47 -0
- data/spec/fake_dynamo/table_spec.rb +435 -0
- data/spec/fake_dynamo/validation_spec.rb +63 -0
- data/spec/spec_helper.rb +28 -0
- metadata +105 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
module FakeDynamo
|
2
|
+
class KeySchema
|
3
|
+
|
4
|
+
attr_accessor :hash_key, :range_key
|
5
|
+
|
6
|
+
def initialize(data)
|
7
|
+
extract_values(data)
|
8
|
+
end
|
9
|
+
|
10
|
+
def description
|
11
|
+
description = { 'HashKeyElement' => hash_key.description }
|
12
|
+
if range_key
|
13
|
+
description['RangeKeyElement'] = range_key.description
|
14
|
+
end
|
15
|
+
description
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
def extract_values(data)
|
20
|
+
@hash_key = Attribute.from_data(data['HashKeyElement'])
|
21
|
+
if range_key_element = data['RangeKeyElement']
|
22
|
+
@range_key = Attribute.from_data(range_key_element)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
|
3
|
+
module FakeDynamo
|
4
|
+
class Server < Sinatra::Base
|
5
|
+
|
6
|
+
set :show_exceptions, false
|
7
|
+
|
8
|
+
post '/' do
|
9
|
+
status = 200
|
10
|
+
content_type 'application/x-amz-json-1.0'
|
11
|
+
begin
|
12
|
+
data = JSON.parse(request.body.read)
|
13
|
+
operation = extract_operation(request.env)
|
14
|
+
puts "operation #{operation}"
|
15
|
+
puts "data"
|
16
|
+
pp data
|
17
|
+
response = db.process(operation, data)
|
18
|
+
storage.persist(operation, data)
|
19
|
+
rescue FakeDynamo::Error => e
|
20
|
+
response, status = e.response, e.status
|
21
|
+
end
|
22
|
+
puts "response"
|
23
|
+
pp response
|
24
|
+
[status, response.to_json]
|
25
|
+
end
|
26
|
+
|
27
|
+
def db
|
28
|
+
DB.instance
|
29
|
+
end
|
30
|
+
|
31
|
+
def storage
|
32
|
+
Storage.instance
|
33
|
+
end
|
34
|
+
|
35
|
+
def extract_operation(env)
|
36
|
+
if env['HTTP_X_AMZ_TARGET'] =~ /DynamoDB_\d+\.([a-zA-z]+)/
|
37
|
+
$1
|
38
|
+
else
|
39
|
+
raise UnknownOperationException
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module FakeDynamo
|
2
|
+
class Storage
|
3
|
+
|
4
|
+
class << self
|
5
|
+
def instance
|
6
|
+
@storage ||= Storage.new
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
init_db
|
12
|
+
end
|
13
|
+
|
14
|
+
def write_commands
|
15
|
+
%w[CreateTable DeleteItem DeleteTable PutItem UpdateItem UpdateTable]
|
16
|
+
end
|
17
|
+
|
18
|
+
def write_command?(command)
|
19
|
+
write_commands.include?(command)
|
20
|
+
end
|
21
|
+
|
22
|
+
def db_path
|
23
|
+
'/usr/local/var/fake_dynamo/db.fdb'
|
24
|
+
end
|
25
|
+
|
26
|
+
def init_db
|
27
|
+
return if File.exists? db_path
|
28
|
+
FileUtils.mkdir_p(File.dirname(db_path))
|
29
|
+
FileUtils.touch(db_path)
|
30
|
+
end
|
31
|
+
|
32
|
+
def delete_db
|
33
|
+
return unless File.exists? db_path
|
34
|
+
FileUtils.rm(db_path)
|
35
|
+
end
|
36
|
+
|
37
|
+
def db
|
38
|
+
DB.instance
|
39
|
+
end
|
40
|
+
|
41
|
+
def db_aof
|
42
|
+
@aof ||= File.new(db_path, 'a')
|
43
|
+
end
|
44
|
+
|
45
|
+
def shutdown
|
46
|
+
puts "shutting down fake_dynamo ..."
|
47
|
+
@aof.close if @aof
|
48
|
+
end
|
49
|
+
|
50
|
+
def persist(operation, data)
|
51
|
+
return unless write_command?(operation)
|
52
|
+
db_aof.puts(operation)
|
53
|
+
data = data.to_json
|
54
|
+
db_aof.puts(data.size + 1)
|
55
|
+
db_aof.puts(data)
|
56
|
+
end
|
57
|
+
|
58
|
+
def load_aof
|
59
|
+
file = File.new(db_path, 'r')
|
60
|
+
puts "Loading fake_dynamo data ..."
|
61
|
+
loop do
|
62
|
+
operation = file.readline.chomp
|
63
|
+
size = Integer(file.readline.chomp)
|
64
|
+
data = file.read(size)
|
65
|
+
db.process(operation, JSON.parse(data))
|
66
|
+
end
|
67
|
+
rescue EOFError
|
68
|
+
file.close
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,362 @@
|
|
1
|
+
module FakeDynamo
|
2
|
+
class Table
|
3
|
+
include Validation
|
4
|
+
include Filter
|
5
|
+
|
6
|
+
attr_accessor :creation_date_time, :read_capacity_units, :write_capacity_units,
|
7
|
+
:name, :status, :key_schema, :items, :size_bytes, :last_increased_time,
|
8
|
+
:last_decreased_time
|
9
|
+
|
10
|
+
def initialize(data)
|
11
|
+
extract_values(data)
|
12
|
+
init
|
13
|
+
end
|
14
|
+
|
15
|
+
def description
|
16
|
+
{
|
17
|
+
'TableDescription' => {
|
18
|
+
'CreationDateTime' => creation_date_time,
|
19
|
+
'KeySchema' => key_schema.description,
|
20
|
+
'ProvisionedThroughput' => {
|
21
|
+
'ReadCapacityUnits' => read_capacity_units,
|
22
|
+
'WriteCapacityUnits' => write_capacity_units
|
23
|
+
},
|
24
|
+
'TableName' => name,
|
25
|
+
'TableStatus' => status
|
26
|
+
}
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def size_description
|
31
|
+
{ 'ItemCount' => items.count,
|
32
|
+
'TableSizeBytes' => size_bytes }
|
33
|
+
end
|
34
|
+
|
35
|
+
def describe_table
|
36
|
+
{ 'Table' => description['TableDescription'] }.merge(size_description)
|
37
|
+
end
|
38
|
+
|
39
|
+
def activate
|
40
|
+
@status = 'ACTIVE'
|
41
|
+
end
|
42
|
+
|
43
|
+
def delete
|
44
|
+
@status = 'DELETING'
|
45
|
+
description
|
46
|
+
end
|
47
|
+
|
48
|
+
def update(read_capacity_units, write_capacity_units)
|
49
|
+
if @read_capacity_units > read_capacity_units
|
50
|
+
@last_decreased_time = Time.now.to_i
|
51
|
+
elsif @read_capacity_units < read_capacity_units
|
52
|
+
@last_increased_time = Time.now.to_i
|
53
|
+
end
|
54
|
+
|
55
|
+
if @write_capacity_units > write_capacity_units
|
56
|
+
@last_decreased_time = Time.now.to_i
|
57
|
+
elsif @write_capacity_units < write_capacity_units
|
58
|
+
@last_increased_time = Time.now.to_i
|
59
|
+
end
|
60
|
+
|
61
|
+
@read_capacity_units, @write_capacity_units = read_capacity_units, write_capacity_units
|
62
|
+
|
63
|
+
response = description.merge(size_description)
|
64
|
+
|
65
|
+
if last_increased_time
|
66
|
+
response['TableDescription']['ProvisionedThroughput']['LastIncreaseDateTime'] = @last_increased_time
|
67
|
+
end
|
68
|
+
|
69
|
+
if last_decreased_time
|
70
|
+
response['TableDescription']['ProvisionedThroughput']['LastDecreaseDateTime'] = @last_decreased_time
|
71
|
+
end
|
72
|
+
|
73
|
+
response['TableDescription']['TableStatus'] = 'UPDATING'
|
74
|
+
response
|
75
|
+
end
|
76
|
+
|
77
|
+
def put_item(data)
|
78
|
+
item = Item.from_data(data['Item'], key_schema)
|
79
|
+
old_item = @items[item.key]
|
80
|
+
check_conditions(old_item, data['Expected'])
|
81
|
+
@items[item.key] = item
|
82
|
+
|
83
|
+
consumed_capacity.merge(return_values(data, old_item))
|
84
|
+
end
|
85
|
+
|
86
|
+
def get_item(data)
|
87
|
+
response = consumed_capacity
|
88
|
+
if item_hash = get_raw_item(data['Key'], data['AttributesToGet'])
|
89
|
+
response.merge!('Item' => item_hash)
|
90
|
+
end
|
91
|
+
response
|
92
|
+
end
|
93
|
+
|
94
|
+
def get_raw_item(key_data, attributes_to_get)
|
95
|
+
key = Key.from_data(key_data, key_schema)
|
96
|
+
item = @items[key]
|
97
|
+
|
98
|
+
if item
|
99
|
+
filter_attributes(item, attributes_to_get)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def filter_attributes(item, attributes_to_get)
|
104
|
+
hash = item.as_hash
|
105
|
+
if attributes_to_get
|
106
|
+
hash.select! do |attribute, value|
|
107
|
+
attributes_to_get.include? attribute
|
108
|
+
end
|
109
|
+
end
|
110
|
+
hash
|
111
|
+
end
|
112
|
+
|
113
|
+
def delete_item(data)
|
114
|
+
key = Key.from_data(data['Key'], key_schema)
|
115
|
+
item = @items[key]
|
116
|
+
check_conditions(item, data['Expected'])
|
117
|
+
|
118
|
+
@items.delete(key) if item
|
119
|
+
consumed_capacity.merge(return_values(data, item))
|
120
|
+
end
|
121
|
+
|
122
|
+
def update_item(data)
|
123
|
+
key = Key.from_data(data['Key'], key_schema)
|
124
|
+
item = @items[key]
|
125
|
+
check_conditions(item, data['Expected'])
|
126
|
+
|
127
|
+
unless item
|
128
|
+
if create_item?(data)
|
129
|
+
item = @items[key] = Item.from_key(key)
|
130
|
+
else
|
131
|
+
return consumed_capacity
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
old_hash = item.as_hash
|
136
|
+
data['AttributeUpdates'].each do |name, update_data|
|
137
|
+
item.update(name, update_data)
|
138
|
+
end
|
139
|
+
|
140
|
+
consumed_capacity.merge(return_values(data, old_hash, item))
|
141
|
+
end
|
142
|
+
|
143
|
+
def query(data)
|
144
|
+
unless key_schema.range_key
|
145
|
+
raise ValidationException, "Query can be performed only on a table with a HASH,RANGE key schema"
|
146
|
+
end
|
147
|
+
|
148
|
+
count_and_attributes_to_get_present?(data)
|
149
|
+
validate_limit(data)
|
150
|
+
|
151
|
+
hash_attribute = Attribute.from_hash(key_schema.hash_key.name, data['HashKeyValue'])
|
152
|
+
matched_items = get_items_by_hash_key(hash_attribute)
|
153
|
+
|
154
|
+
|
155
|
+
forward = data.has_key?('ScanIndexForward') ? data['ScanIndexForward'] : true
|
156
|
+
|
157
|
+
if forward
|
158
|
+
matched_items.sort! { |a, b| a.key.range <=> b.key.range }
|
159
|
+
else
|
160
|
+
matched_items.sort! { |a, b| b.key.range <=> a.key.range }
|
161
|
+
end
|
162
|
+
|
163
|
+
matched_items = drop_till_start(matched_items, data['ExclusiveStartKey'])
|
164
|
+
|
165
|
+
if data['RangeKeyCondition']
|
166
|
+
conditions = {key_schema.range_key.name => data['RangeKeyCondition']}
|
167
|
+
else
|
168
|
+
conditions = {}
|
169
|
+
end
|
170
|
+
|
171
|
+
result, last_evaluated_item, _ = filter(matched_items, conditions, data['Limit'], true)
|
172
|
+
|
173
|
+
response = {
|
174
|
+
'Count' => result.size,
|
175
|
+
'ConsumedCapacityUnits' => 1 }
|
176
|
+
|
177
|
+
unless data['Count']
|
178
|
+
response['Items'] = result.map { |r| filter_attributes(r, data['AttributesToGet']) }
|
179
|
+
end
|
180
|
+
|
181
|
+
if last_evaluated_item
|
182
|
+
response['LastEvaluatedKey'] = last_evaluated_item.key.as_key_hash
|
183
|
+
end
|
184
|
+
response
|
185
|
+
end
|
186
|
+
|
187
|
+
def scan(data)
|
188
|
+
count_and_attributes_to_get_present?(data)
|
189
|
+
validate_limit(data)
|
190
|
+
conditions = data['ScanFilter'] || {}
|
191
|
+
all_items = drop_till_start(items.values, data['ExclusiveStartKey'])
|
192
|
+
result, last_evaluated_item, scaned_count = filter(all_items, conditions, data['Limit'], false)
|
193
|
+
response = {
|
194
|
+
'Count' => result.size,
|
195
|
+
'ScannedCount' => scaned_count,
|
196
|
+
'ConsumedCapacityUnits' => 1 }
|
197
|
+
|
198
|
+
unless data['Count']
|
199
|
+
response['Items'] = result.map { |r| filter_attributes(r, data['AttributesToGet']) }
|
200
|
+
end
|
201
|
+
|
202
|
+
if last_evaluated_item
|
203
|
+
response['LastEvaluatedKey'] = last_evaluated_item.key.as_key_hash
|
204
|
+
end
|
205
|
+
|
206
|
+
response
|
207
|
+
end
|
208
|
+
|
209
|
+
def count_and_attributes_to_get_present?(data)
|
210
|
+
if data['Count'] and data['AttributesToGet']
|
211
|
+
raise ValidationException, "Cannot specify the AttributesToGet when choosing to get only the Count"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def validate_limit(data)
|
216
|
+
if data['Limit'] and data['Limit'] <= 0
|
217
|
+
raise ValidationException, "Limit failed to satisfy constraint: Member must have value greater than or equal to 1"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def drop_till_start(all_items, start_key_hash)
|
222
|
+
if start_key_hash
|
223
|
+
all_items.drop_while { |i| i.key.as_key_hash != start_key_hash }.drop(1)
|
224
|
+
else
|
225
|
+
all_items
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
def filter(items, conditions, limit, fail_on_type_mismatch)
|
230
|
+
limit ||= -1
|
231
|
+
result = []
|
232
|
+
last_evaluated_item = nil
|
233
|
+
scaned_count = 0
|
234
|
+
items.each do |item|
|
235
|
+
select = true
|
236
|
+
conditions.each do |attribute_name, condition|
|
237
|
+
value = condition['AttributeValueList']
|
238
|
+
comparison_op = condition['ComparisonOperator']
|
239
|
+
unless self.send("#{comparison_op.downcase}_filter", value, item[attribute_name], fail_on_type_mismatch)
|
240
|
+
select = false
|
241
|
+
break
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
if select
|
246
|
+
result << item
|
247
|
+
if (limit -= 1) == 0
|
248
|
+
last_evaluated_item = item
|
249
|
+
break
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
scaned_count += 1
|
254
|
+
end
|
255
|
+
[result, last_evaluated_item, scaned_count]
|
256
|
+
end
|
257
|
+
|
258
|
+
def get_items_by_hash_key(hash_key)
|
259
|
+
items.values.select do |i|
|
260
|
+
i.key.primary == hash_key
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
def create_item?(data)
|
265
|
+
data['AttributeUpdates'].any? do |name, update_data|
|
266
|
+
action = update_data['Action']
|
267
|
+
['PUT', 'ADD', nil].include? action
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
def updated_attributes(data)
|
272
|
+
data['AttributeUpdates'].map { |name, _| name }
|
273
|
+
end
|
274
|
+
|
275
|
+
def return_values(data, old_item, new_item={})
|
276
|
+
old_item ||= {}
|
277
|
+
old_hash = old_item.kind_of?(Item) ? old_item.as_hash : old_item
|
278
|
+
|
279
|
+
new_item ||= {}
|
280
|
+
new_hash = new_item.kind_of?(Item) ? new_item.as_hash : new_item
|
281
|
+
|
282
|
+
|
283
|
+
return_value = data['ReturnValues']
|
284
|
+
result = case return_value
|
285
|
+
when 'ALL_OLD'
|
286
|
+
old_hash
|
287
|
+
when 'ALL_NEW'
|
288
|
+
new_hash
|
289
|
+
when 'UPDATED_OLD'
|
290
|
+
updated = updated_attributes(data)
|
291
|
+
old_hash.select { |name, _| updated.include? name }
|
292
|
+
when 'UPDATED_NEW'
|
293
|
+
updated = updated_attributes(data)
|
294
|
+
new_hash.select { |name, _| updated.include? name }
|
295
|
+
when 'NONE', nil
|
296
|
+
{}
|
297
|
+
else
|
298
|
+
raise 'unknown return value'
|
299
|
+
end
|
300
|
+
|
301
|
+
unless result.empty?
|
302
|
+
{ 'Attributes' => result }
|
303
|
+
else
|
304
|
+
{}
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def consumed_capacity
|
309
|
+
{ 'ConsumedCapacityUnits' => 1 }
|
310
|
+
end
|
311
|
+
|
312
|
+
def check_conditions(old_item, conditions)
|
313
|
+
return unless conditions
|
314
|
+
|
315
|
+
conditions.each do |name, predicate|
|
316
|
+
exist = predicate['Exists']
|
317
|
+
value = predicate['Value']
|
318
|
+
|
319
|
+
if not value
|
320
|
+
if exist.nil?
|
321
|
+
raise ValidationException, "'Exists' is set to null. 'Exists' must be set to false when no Attribute value is specified"
|
322
|
+
elsif exist
|
323
|
+
raise ValidationException, "'Exists' is set to true. 'Exists' must be set to false when no Attribute value is specified"
|
324
|
+
elsif !exist # false
|
325
|
+
if old_item and old_item[name]
|
326
|
+
raise ConditionalCheckFailedException
|
327
|
+
end
|
328
|
+
end
|
329
|
+
else
|
330
|
+
expected_attr = Attribute.from_hash(name, value)
|
331
|
+
|
332
|
+
if exist.nil? or exist
|
333
|
+
raise ConditionalCheckFailedException unless (old_item and old_item[name] == expected_attr)
|
334
|
+
elsif !exist # false
|
335
|
+
raise ValidationException, "Cannot expect an attribute to have a specified value while expecting it to not exist"
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
|
342
|
+
private
|
343
|
+
def init
|
344
|
+
@creation_date_time = Time.now.to_i
|
345
|
+
@status = 'CREATING'
|
346
|
+
@items = {}
|
347
|
+
@size_bytes = 0
|
348
|
+
end
|
349
|
+
|
350
|
+
def extract_values(data)
|
351
|
+
@name = data['TableName']
|
352
|
+
@key_schema = KeySchema.new(data['KeySchema'])
|
353
|
+
set_throughput(data['ProvisionedThroughput'])
|
354
|
+
end
|
355
|
+
|
356
|
+
def set_throughput(throughput)
|
357
|
+
@read_capacity_units = throughput['ReadCapacityUnits']
|
358
|
+
@write_capacity_units = throughput['WriteCapacityUnits']
|
359
|
+
end
|
360
|
+
|
361
|
+
end
|
362
|
+
end
|