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