dynamini 1.8.2 → 1.9.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,5 @@
1
+ require 'rspec/core/rake_task'
2
+
3
+ RSpec::Core::RakeTask.new('spec')
4
+
5
+ task default: :spec
@@ -0,0 +1,29 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'dynamini'
3
+ s.version = '1.9.1'
4
+ s.date = '2015-12-14'
5
+ s.summary = 'DynamoDB interface'
6
+ s.description = 'Lightweight DynamoDB interface gem designed as
7
+ a drop-in replacement for ActiveRecord.
8
+ Built & maintained by the team at yroo.com.'
9
+ s.authors = ['Greg Ward', 'David McHoull', 'Alishan Ladhani', 'Emily Fan',
10
+ 'Justine Jones', 'Gillian Chesnais', 'Scott Chu', 'Jeff Li']
11
+ s.email = 'dev@retailcommon.com'
12
+ s.homepage = 'https://github.com/47colborne/dynamini'
13
+ s.platform = Gem::Platform::RUBY
14
+ s.license = 'MIT'
15
+
16
+ s.files = `git ls-files -z`.split("\x0")
17
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
19
+ s.require_paths = ['lib']
20
+
21
+ s.add_dependency('activemodel', ['>= 3', '< 5.0'])
22
+ s.add_dependency('aws-sdk', '~> 2')
23
+
24
+ s.add_development_dependency 'rspec', '~> 3'
25
+ s.add_development_dependency 'pry', '~> 0'
26
+ s.add_development_dependency 'fuubar', '~> 2'
27
+ s.add_development_dependency 'guard-rspec'
28
+ s.add_development_dependency 'guard-shell'
29
+ end
@@ -1,7 +1,10 @@
1
+ require_relative 'batch_operations'
2
+
1
3
  module Dynamini
2
4
  # Core db interface class.
3
5
  class Base
4
6
  include ActiveModel::Validations
7
+ extend Dynamini::BatchOperations
5
8
 
6
9
  attr_reader :attributes
7
10
 
@@ -12,8 +15,6 @@ module Dynamini
12
15
  updated_at: { format: :time, options: {} }
13
16
  }
14
17
 
15
- BATCH_SIZE = 25
16
-
17
18
  GETTER_PROCS = {
18
19
  integer: proc { |v| v.to_i },
19
20
  date: proc { |v| v.is_a?(Date) ? v : Time.at(v).to_date },
@@ -35,7 +36,7 @@ module Dynamini
35
36
  }
36
37
 
37
38
  class << self
38
- attr_writer :batch_write_queue, :in_memory
39
+ attr_writer :in_memory
39
40
  attr_reader :range_key
40
41
 
41
42
  def table_name
@@ -69,10 +70,6 @@ module Dynamini
69
70
  @in_memory || false
70
71
  end
71
72
 
72
- def batch_write_queue
73
- @batch_write_queue ||= []
74
- end
75
-
76
73
  def client
77
74
  if in_memory
78
75
  @client ||= Dynamini::TestClient.new(hash_key, range_key)
@@ -121,18 +118,6 @@ module Dynamini
121
118
  end
122
119
  end
123
120
 
124
- def batch_find(ids = [])
125
- return [] if ids.length < 1
126
- objects = []
127
- fail StandardError, 'Batch is limited to 100 items' if ids.length > 100
128
- key_structure = ids.map { |i| { hash_key => i.to_s } }
129
- response = dynamo_batch_get(key_structure)
130
- response.responses[table_name].each do |item|
131
- objects << new(item.symbolize_keys, false)
132
- end
133
- objects
134
- end
135
-
136
121
  def query(args = {})
137
122
  fail ArgumentError, 'You must provide a :hash_key.' unless args[:hash_key]
138
123
  fail TypeError, 'Your range key must be handled as an integer, float, date, or time.' unless self.range_is_numeric?
@@ -145,23 +130,6 @@ module Dynamini
145
130
  objects
146
131
  end
147
132
 
148
- def enqueue_for_save(attributes, options = {})
149
- model = new(attributes, true)
150
- model.generate_timestamps! unless options[:skip_timestamps]
151
- if model.valid?
152
- batch_write_queue << model
153
- flush_queue! if batch_write_queue.length == BATCH_SIZE
154
- return true
155
- end
156
- false
157
- end
158
-
159
- def flush_queue!
160
- response = dynamo_batch_save(batch_write_queue)
161
- self.batch_write_queue = []
162
- response
163
- end
164
-
165
133
  end
166
134
 
167
135
  #### Instance Methods
@@ -305,27 +273,6 @@ module Dynamini
305
273
  )
306
274
  end
307
275
 
308
- def self.dynamo_batch_get(key_struct)
309
- client.batch_get_item(
310
- request_items: {
311
- table_name => { keys: key_struct }
312
- }
313
- )
314
- end
315
-
316
- def self.dynamo_batch_save(model_array)
317
- put_requests = []
318
- model_array.each do |model|
319
- put_requests << { put_request: {
320
- item: model.attributes.reject{ |_k, v| v.blank? }.stringify_keys
321
- } }
322
- end
323
- request_options = { request_items: {
324
- "#{table_name}" => put_requests }
325
- }
326
- client.batch_write_item(request_options)
327
- end
328
-
329
276
  def self.dynamo_query(args)
330
277
  expression_attribute_values = self.build_expression_attribute_values(args)
331
278
  key_condition_expression = self.build_key_condition_expression(args)
@@ -0,0 +1,51 @@
1
+ module Dynamini
2
+ module BatchOperations
3
+
4
+ def import(models)
5
+ # Max batch size is 25, per Dynamo BatchWriteItem docs
6
+
7
+ models.each_slice(25) do |batch|
8
+ batch.each do |model|
9
+ model.send(:generate_timestamps!)
10
+ end
11
+ dynamo_batch_save(batch)
12
+ end
13
+ end
14
+
15
+ def batch_find(ids = [])
16
+ return [] if ids.length < 1
17
+ objects = []
18
+ fail StandardError, 'Batch is limited to 100 items' if ids.length > 100
19
+ key_structure = ids.map { |i| {hash_key => i.to_s} }
20
+ response = dynamo_batch_get(key_structure)
21
+ response.responses[table_name].each do |item|
22
+ objects << new(item.symbolize_keys, false)
23
+ end
24
+ objects
25
+ end
26
+
27
+ def dynamo_batch_save(model_array)
28
+ put_requests = model_array.map do |model|
29
+ {
30
+ put_request: {
31
+ item: model.attributes.reject { |_k, v| v.blank? }.stringify_keys
32
+ }
33
+ }
34
+ end
35
+ request_options = {
36
+ request_items: {table_name => put_requests}
37
+ }
38
+ client.batch_write_item(request_options)
39
+ end
40
+
41
+ private
42
+
43
+ def dynamo_batch_get(key_struct)
44
+ client.batch_get_item(
45
+ request_items: {
46
+ table_name => {keys: key_struct}
47
+ }
48
+ )
49
+ end
50
+ end
51
+ end
@@ -75,12 +75,13 @@ module Dynamini
75
75
  OpenStruct.new(responses: responses)
76
76
  end
77
77
 
78
+ #FIXME Add range key support
78
79
  def batch_write_item(request_options)
79
80
  request_options[:request_items].each do |k, v|
80
81
  @data[k] ||= {}
81
82
  v.each do |request_hash|
82
83
  item = request_hash[:put_request][:item]
83
- key = item[hash_key_attr]
84
+ key = item[hash_key_attr.to_s]
84
85
  @data[k][key] = item
85
86
  end
86
87
  end
@@ -0,0 +1,908 @@
1
+ require 'spec_helper'
2
+
3
+ describe Dynamini::Base do
4
+ let(:model_attributes) {
5
+ {
6
+ name: 'Widget',
7
+ price: 9.99,
8
+ id: 'abcd1234',
9
+ hash_key: '009'
10
+ }
11
+ }
12
+
13
+ subject(:model) { Dynamini::Base.new(model_attributes) }
14
+
15
+ class TestClassWithRange < Dynamini::Base
16
+ set_hash_key :foo
17
+ set_range_key :bar
18
+ self.in_memory = true
19
+ handle :bar, :integer
20
+ end
21
+
22
+ before do
23
+ model.save
24
+ end
25
+
26
+ describe '.set_table_name' do
27
+ before do
28
+ class TestClass < Dynamini::Base
29
+ end
30
+ end
31
+ it 'should' do
32
+ expect(TestClass.table_name).to eq('test_classes')
33
+ end
34
+ end
35
+
36
+ describe '#configure' do
37
+ before do
38
+ Dynamini.configure do |config|
39
+ config.region = 'eu-west-1'
40
+ end
41
+ end
42
+
43
+ it 'returns the configured variables' do
44
+ expect(Dynamini.configuration.region).to eq('eu-west-1')
45
+ end
46
+ end
47
+
48
+ describe '.client' do
49
+ it 'should not reinstantiate the client' do
50
+ expect(Dynamini::TestClient).to_not receive(:new)
51
+ Dynamini::Base.client
52
+ end
53
+ end
54
+
55
+ describe 'operations' do
56
+
57
+ describe '.handle' do
58
+
59
+ class HandledClass < Dynamini::Base;
60
+ end
61
+
62
+ context 'when reading the handled attirubte' do
63
+ before { HandledClass.handle :price, :integer, default: 9 }
64
+ it 'should return the proper format' do
65
+ object = HandledClass.new(price: "1")
66
+ expect(object.price).to eq(1)
67
+ end
68
+ it 'should return the default value if not assigned' do
69
+ object = HandledClass.new
70
+ expect(object.price).to eq(9)
71
+ end
72
+ it 'should return an array with formated item if handled' do
73
+ object = HandledClass.new(price: ["1", "2"])
74
+ expect(object.price).to eq([1, 2])
75
+ end
76
+ end
77
+
78
+ context 'when writing the handled attribute' do
79
+ before { HandledClass.handle :price, :float, default: 9 }
80
+ it 'should convert the value to handled format' do
81
+ object = HandledClass.new(price: "1")
82
+ expect(object.attributes[:price]).to eq(1.0)
83
+ end
84
+ end
85
+
86
+ end
87
+
88
+ describe '.new' do
89
+ let(:dirty_model) { Dynamini::Base.new(model_attributes) }
90
+
91
+ it 'should append all initial attrs to @changed, including hash_key' do
92
+ expect(dirty_model.changed).to eq(model_attributes.keys.map(&:to_s).delete_if { |k, v| k == 'id' })
93
+ end
94
+
95
+ it 'should not include the primary key in the changes' do
96
+ expect(dirty_model.changes[:id]).to be_nil
97
+ end
98
+ end
99
+
100
+ describe '.create' do
101
+ it 'should save the item' do
102
+ other_model_attributes = model_attributes
103
+ other_model_attributes[:id] = 'xyzzy'
104
+ Dynamini::Base.create(other_model_attributes)
105
+ expect(Dynamini::Base.find(other_model_attributes[:id])).to_not be_nil
106
+ end
107
+
108
+ it 'should return an instance of the model' do
109
+ expect(Dynamini::Base.create(model_attributes)).to be_a(Dynamini::Base)
110
+ end
111
+
112
+ context 'when creating a subclass' do
113
+ class Foo < Dynamini::Base
114
+ end
115
+
116
+ it 'should return the object as an instance of the subclass' do
117
+ expect(Foo.create(value: '1')).to be_a Foo
118
+ end
119
+ end
120
+ end
121
+
122
+ describe '.find' do
123
+
124
+ it 'should return a model with the retrieved attributes' do
125
+ found = Dynamini::Base.find('abcd1234')
126
+ expect(found.price).to eq(9.99)
127
+ expect(found.name).to eq('Widget')
128
+ expect(found.hash_key).to eq('009')
129
+ end
130
+
131
+ context 'when the object does not exist' do
132
+ it 'should raise an error' do
133
+ expect { Dynamini::Base.find('f') }.to raise_error 'Item not found.'
134
+ end
135
+
136
+ end
137
+
138
+ context 'when retrieving a subclass' do
139
+ class Foo < Dynamini::Base
140
+ self.in_memory = true
141
+ end
142
+
143
+ it 'should return the object as an instance of the subclass' do
144
+ Foo.create(id: '1')
145
+ expect(Foo.find('1')).to be_a Foo
146
+ end
147
+ end
148
+ end
149
+
150
+ describe '.query' do
151
+ before do
152
+ 4.times do |i|
153
+ TestClassWithRange.create(foo: 'foo', bar: i + 1)
154
+ end
155
+ end
156
+ context 'start value provided' do
157
+ it 'should return records with a range key greater than or equal to the start value' do
158
+ records = TestClassWithRange.query(hash_key: 'foo', start: 2)
159
+ expect(records.length).to eq 3
160
+ expect(records.first.bar).to eq 2
161
+ expect(records.last.bar).to eq 4
162
+ end
163
+ end
164
+ context 'end value provided' do
165
+ it 'should return records with a range key less than or equal to the start value' do
166
+ records = TestClassWithRange.query(hash_key: 'foo', end: 2)
167
+ expect(records.length).to eq 2
168
+ expect(records.first.bar).to eq 1
169
+ expect(records.last.bar).to eq 2
170
+ end
171
+ end
172
+ context 'start and end values provided' do
173
+ it 'should return records between the two values inclusive' do
174
+ records = TestClassWithRange.query(hash_key: 'foo', start: 1, end: 3)
175
+ expect(records.length).to eq 3
176
+ expect(records.first.bar).to eq 1
177
+ expect(records.last.bar).to eq 3
178
+ end
179
+ end
180
+ context 'neither value provided' do
181
+ it 'should return all records belonging to that hash key' do
182
+ records = TestClassWithRange.query(hash_key: 'foo')
183
+ expect(records.length).to eq 4
184
+ expect(records.first.bar).to eq 1
185
+ expect(records.last.bar).to eq 4
186
+ end
187
+ end
188
+
189
+ context 'a non-numeric range field' do
190
+ it 'should raise an error' do
191
+ class TestClassWithStringRange < Dynamini::Base
192
+ self.in_memory = true
193
+ set_hash_key :group
194
+ set_range_key :user_name
195
+ end
196
+ expect { TestClassWithStringRange.query(hash_key: 'registered', start: 'a') }.to raise_error TypeError
197
+ end
198
+ end
199
+
200
+ context 'hash key does not exist' do
201
+ it 'should return an empty array' do
202
+ expect(TestClassWithRange.query(hash_key: 'non-existent key')).to eq([])
203
+ end
204
+ end
205
+ end
206
+
207
+ describe '.increment!' do
208
+ context 'when incrementing a nil value' do
209
+ it 'should save' do
210
+ expect(model.class.client).to receive(:update_item).with(
211
+ table_name: 'bases',
212
+ key: {id: model_attributes[:id]},
213
+ attribute_updates: hash_including(
214
+ "foo" => {
215
+ value: 5,
216
+ action: 'ADD'
217
+ }
218
+ )
219
+ )
220
+ model.increment!(foo: 5)
221
+ end
222
+ it 'should update the value' do
223
+ model.increment!(foo: 5)
224
+ expect(Dynamini::Base.find('abcd1234').foo.to_i).to eq 5
225
+ end
226
+ end
227
+ context 'when incrementing a numeric value' do
228
+ it 'should save' do
229
+ expect(model).to receive(:read_attribute).and_return(9.99)
230
+ expect(model.class.client).to receive(:update_item).with(
231
+ table_name: 'bases',
232
+ key: {id: model_attributes[:id]},
233
+ attribute_updates: hash_including(
234
+ "price" => {
235
+ value: 5,
236
+ action: 'ADD'
237
+ }
238
+ )
239
+ )
240
+ model.increment!(price: 5)
241
+
242
+ end
243
+ it 'should sum the values' do
244
+ expect(model).to receive(:read_attribute).and_return(9.99)
245
+ model.increment!(price: 5)
246
+ expect(Dynamini::Base.find('abcd1234').price).to eq 14.99
247
+ end
248
+ end
249
+ context 'when incrementing a non-numeric value' do
250
+ it 'should raise an error and not save' do
251
+ expect(model).to receive(:read_attribute).and_return('hello')
252
+ expect { model.increment!(price: 5) }.to raise_error(StandardError)
253
+ end
254
+ end
255
+ context 'when incrementing with a non-numeric value' do
256
+ it 'should raise an error and not save' do
257
+ expect { model.increment!(foo: 'bar') }.to raise_error(StandardError)
258
+ end
259
+ end
260
+ context 'when incrementing multiple values' do
261
+ it 'should create/sum both values' do
262
+ allow(model).to receive(:read_attribute).and_return(9.99)
263
+ model.increment!(price: 5, baz: 6)
264
+ found_model = Dynamini::Base.find('abcd1234')
265
+ expect(found_model.price).to eq 14.99
266
+ expect(found_model.baz).to eq 6
267
+ end
268
+ end
269
+ context 'when incrementing a new record' do
270
+ it 'should save the record and init the values and timestamps' do
271
+ Dynamini::Base.new(id: 1, foo: 'bar').increment!(baz: 1)
272
+ found_model = Dynamini::Base.find(1)
273
+ expect(found_model.baz).to eq 1
274
+ expect(found_model.created_at).to_not be_nil
275
+ expect(found_model.updated_at).to_not be_nil
276
+ end
277
+ end
278
+ end
279
+
280
+ describe '.find_or_new' do
281
+ context 'when a record with the given key exists' do
282
+ it 'should return that record' do
283
+ existing_record = Dynamini::Base.find_or_new(model.id)
284
+ expect(existing_record.new_record?).to eq(false)
285
+ expect(existing_record.id).to eq(model.id)
286
+ end
287
+
288
+ it 'should return the record for table with range key' do
289
+ existing_record = TestClassWithRange.create!(foo: 1, bar: 123)
290
+ expect(TestClassWithRange.find_or_new(existing_record.foo, existing_record.bar).new_record?).to eq(false)
291
+ expect(existing_record.foo).to eq(1)
292
+ expect(existing_record.bar).to eq(123)
293
+ end
294
+
295
+ end
296
+ context 'when the key cannot be found' do
297
+ it 'should initialize a new object with that key' do
298
+ expect(Dynamini::Base.find_or_new('foo').new_record?).to be_truthy
299
+ end
300
+
301
+ it 'should initialize a new object with hash key and range key' do
302
+ new_record = TestClassWithRange.find_or_new(1, 6)
303
+ expect(new_record.new_record?).to be_truthy
304
+ expect(new_record.foo).to eq(1)
305
+ expect(new_record.bar).to eq(6)
306
+ end
307
+ end
308
+ end
309
+
310
+ describe '#==' do
311
+ let(:model_a) { Dynamini::Base.new(model_attributes).tap {
312
+ |model| model.send(:clear_changes)
313
+ } }
314
+ let(:model_attributes_d) { {
315
+ name: 'Widget',
316
+ price: 9.99,
317
+ hash_key: '007'
318
+ } }
319
+
320
+ context 'when the object is reflexive ( a = a )' do
321
+ it 'it should return true' do
322
+ expect(model_a.==(model_a)).to be_truthy
323
+ end
324
+ end
325
+
326
+ context 'when the object is symmetric ( if a = b then b = a )' do
327
+ it 'it should return true' do
328
+ model_b = model_a
329
+ expect(model_a.==(model_b)).to be_truthy
330
+ end
331
+ end
332
+
333
+ context 'when the object is transitive (if a = b and b = c then a = c)' do
334
+ it 'it should return true' do
335
+ model_b = model_a
336
+ model_c = model_b
337
+ expect(model_a.==(model_c)).to be_truthy
338
+ end
339
+ end
340
+
341
+ context 'when the object attributes are different' do
342
+ it 'should return false' do
343
+ model_d = Dynamini::Base.new(model_attributes_d).tap {
344
+ |model| model.send(:clear_changes)
345
+ }
346
+ expect(model_a.==(model_d)).to be_falsey
347
+ end
348
+ end
349
+ end
350
+
351
+ describe '#assign_attributes' do
352
+ it 'should return nil' do
353
+ expect(model.assign_attributes(price: '5')).to be_nil
354
+ end
355
+
356
+ it 'should update the attributes of the model' do
357
+ model.assign_attributes(price: '5')
358
+ expect(model.attributes[:price]).to eq('5')
359
+ end
360
+
361
+ it 'should append changed attributes to @changed' do
362
+ model.assign_attributes(name: 'Widget', price: '5')
363
+ expect(model.changed).to eq ['price']
364
+ end
365
+ end
366
+
367
+ describe '#update_attribute' do
368
+
369
+ it 'should update the attribute and save the object' do
370
+ expect(model).to receive(:save!)
371
+ model.update_attribute(:name, 'Widget 2.0')
372
+ expect(model.name).to eq('Widget 2.0')
373
+ end
374
+ end
375
+
376
+ describe '#update_attributes' do
377
+ it 'should update multiple attributes and save the object' do
378
+ expect(model).to receive(:save!)
379
+ model.update_attributes(name: 'Widget 2.0', price: '12.00')
380
+ expect(model.attributes).to include(name: 'Widget 2.0', price: '12.00')
381
+ end
382
+ end
383
+
384
+ describe '#save' do
385
+
386
+ context 'when passing validation' do
387
+ it 'should return true' do
388
+ expect(model.save).to eq true
389
+ end
390
+
391
+ context 'something has changed' do
392
+ it 'should call update_item with the changed attributes' do
393
+ expect(model.class.client).to receive(:update_item).with(
394
+ table_name: 'bases',
395
+ key: {id: model_attributes[:id]},
396
+ attribute_updates: hash_including(
397
+ "price" => {
398
+ value: '5',
399
+ action: 'PUT'
400
+ }
401
+ )
402
+ )
403
+ model.price = '5'
404
+ model.save
405
+ end
406
+
407
+ it 'should not return any changes after saving' do
408
+ model.price = 5
409
+ model.save
410
+ expect(model.changed).to be_empty
411
+ end
412
+ end
413
+
414
+ context 'when a blank field has been added' do
415
+ it 'should suppress any blank keys' do
416
+ expect(model.class.client).to receive(:update_item).with(
417
+ table_name: 'bases',
418
+ key: {id: model_attributes[:id]},
419
+ attribute_updates: hash_not_including(
420
+ foo: {
421
+ value: '',
422
+ action: 'PUT'
423
+ }
424
+ )
425
+ )
426
+ model.foo = ''
427
+ model.bar = 4
428
+ model.save
429
+ end
430
+ end
431
+ end
432
+
433
+ context 'when failing validation' do
434
+ before do
435
+ allow(model).to receive(:valid?).and_return(false)
436
+ model.price = 5
437
+ end
438
+
439
+ it 'should return false' do
440
+ expect(model.save).to eq false
441
+ end
442
+
443
+ it 'should not trigger an update' do
444
+ expect(model.class.client).not_to receive(:update_item)
445
+ model.save
446
+ end
447
+ end
448
+
449
+ context 'nothing has changed' do
450
+ it 'should not trigger an update' do
451
+ expect(model.class.client).not_to receive(:update_item)
452
+ model.save
453
+ end
454
+ end
455
+
456
+ context 'when validation is ignored' do
457
+ it 'should trigger an update' do
458
+ allow(model).to receive(:valid?).and_return(false)
459
+ model.price = 5
460
+ expect(model.save!(validate: false)).to eq true
461
+ end
462
+ end
463
+ end
464
+
465
+ describe '#delete' do
466
+ context 'when the item exists in the DB' do
467
+ it 'should delete the item and return the item' do
468
+ expect(model.delete).to eq(model)
469
+ expect { Dynamini::Base.find(model.id) }.to raise_error ('Item not found.')
470
+ end
471
+ end
472
+ context 'when the item does not exist in the DB' do
473
+ it 'should return the item' do
474
+ expect(model.delete).to eq(model)
475
+ end
476
+ end
477
+ end
478
+ end
479
+
480
+ describe '#touch' do
481
+ it 'should only send the updated time timestamp to the client' do
482
+ allow(Time).to receive(:now).and_return 1
483
+ expect(model.class.client).to receive(:update_item).with(
484
+ table_name: 'bases',
485
+ key: {id: model_attributes[:id]},
486
+ attribute_updates: {
487
+ updated_at: {
488
+ value: 1,
489
+ action: 'PUT'
490
+ }
491
+ }
492
+ )
493
+ model.touch
494
+ end
495
+
496
+ it 'should raise an error when called on a new record' do
497
+ new_model = Dynamini::Base.new(id: '3456')
498
+ expect { new_model.touch }.to raise_error StandardError
499
+ end
500
+ end
501
+
502
+ describe '#save!' do
503
+
504
+ context 'hash key only' do
505
+ class TestValidation < Dynamini::Base
506
+ set_hash_key :bar
507
+ validates_presence_of :foo
508
+ self.in_memory = true
509
+ end
510
+
511
+ it 'should raise its failed validation errors' do
512
+ model = TestValidation.new(bar: 'baz')
513
+ expect { model.save! }.to raise_error StandardError
514
+ end
515
+
516
+ it 'should not validate if validate: false is passed' do
517
+ model = TestValidation.new(bar: 'baz')
518
+ expect(model.save!(validate: false)).to eq true
519
+ end
520
+ end
521
+ end
522
+
523
+ describe '.create!' do
524
+ class TestValidation < Dynamini::Base
525
+ set_hash_key :bar
526
+ validates_presence_of :foo
527
+ end
528
+
529
+ it 'should raise its failed validation errors' do
530
+ expect { TestValidation.create!(bar: 'baz') }.to raise_error StandardError
531
+ end
532
+ end
533
+
534
+ describe '#trigger_save' do
535
+ class TestHashRangeTable < Dynamini::Base
536
+ set_hash_key :bar
537
+ set_range_key :abc
538
+ end
539
+
540
+ TestHashRangeTable.in_memory = true
541
+
542
+ let(:time) { Time.now }
543
+ before do
544
+ allow(Time).to receive(:now).and_return(time)
545
+ end
546
+ context 'new record' do
547
+ it 'should set created and updated time to current time for hash key only table' do
548
+ new_model = Dynamini::Base.create(id: '6789')
549
+ # stringify to handle floating point rounding issue
550
+ expect(new_model.created_at.to_s).to eq(time.to_s)
551
+ expect(new_model.updated_at.to_s).to eq(time.to_s)
552
+ expect(new_model.id).to eq('6789')
553
+ end
554
+
555
+ # create fake dynamini child class for testing range key
556
+
557
+ it 'should set created and updated time to current time for hash and range key table' do
558
+ new_model = TestHashRangeTable.create!(bar: '6789', abc: '1234')
559
+
560
+ # stringify to handle floating point rounding issue
561
+ expect(new_model.created_at.to_s).to eq(time.to_s)
562
+ expect(new_model.updated_at.to_s).to eq(time.to_s)
563
+ expect(new_model.bar).to eq('6789')
564
+ expect(new_model.abc).to eq('1234')
565
+ end
566
+
567
+ end
568
+ context 'existing record' do
569
+ it 'should set updated time but not created time' do
570
+ existing_model = Dynamini::Base.new({name: 'foo'}, false)
571
+ existing_model.price = 5
572
+ existing_model.save
573
+ expect(existing_model.updated_at.to_s).to eq(time.to_s)
574
+ expect(existing_model.created_at.to_s).to_not eq(time.to_s)
575
+ end
576
+ it 'should not update created_at again' do
577
+ object = Dynamini::Base.new(name: 'foo')
578
+ object.save
579
+ created_at = object.created_at
580
+ object.name = "bar"
581
+ object.save
582
+ expect(object.created_at).to eq created_at
583
+ end
584
+ it 'should preserve previously saved attributes' do
585
+ model.foo = '1'
586
+ model.save
587
+ model.bar = 2
588
+ model.save
589
+ expect(model.foo).to eq '1'
590
+ end
591
+ end
592
+ context 'when suppressing timestamps' do
593
+ it 'should not set either timestamp' do
594
+ existing_model = Dynamini::Base.new({name: 'foo'}, false)
595
+ existing_model.price = 5
596
+
597
+ existing_model.save(skip_timestamps: true)
598
+
599
+ expect(existing_model.updated_at.to_s).to_not eq(time.to_s)
600
+ expect(existing_model.created_at.to_s).to_not eq(time.to_s)
601
+ end
602
+ end
603
+ end
604
+
605
+ describe 'table config' do
606
+ class TestModel < Dynamini::Base
607
+ set_hash_key :email
608
+ set_table_name 'people'
609
+
610
+ end
611
+
612
+ it 'should override the primary_key' do
613
+ expect(TestModel.hash_key).to eq :email
614
+ end
615
+
616
+ it 'should override the table_name' do
617
+ expect(TestModel.table_name).to eq 'people'
618
+ end
619
+ end
620
+
621
+ describe 'custom column handling' do
622
+ class HandleModel < Dynamini::Base
623
+ handle :price, :float, default: 10
624
+ handle :start_date, :time
625
+ handle :int_list, :integer
626
+ handle :sym_list, :symbol
627
+ end
628
+
629
+ let(:handle_model) { HandleModel.new }
630
+
631
+ it 'should create getters and setters' do
632
+ expect(handle_model).to_not receive(:method_missing)
633
+ handle_model.price = 1
634
+ handle_model.price
635
+ end
636
+
637
+ it 'should retrieve price as a float' do
638
+ handle_model.price = '5.2'
639
+ expect(handle_model.price).to be_a(Float)
640
+ end
641
+
642
+ it 'should default price to 0 if not set' do
643
+ expect(handle_model.price).to eq 10
644
+ end
645
+
646
+ it 'should store times as floats' do
647
+ handle_model.start_date = Time.now
648
+ expect(handle_model.attributes[:start_date]).to be_a(Float)
649
+ expect(handle_model.attributes[:start_date] > 1_000_000_000).to be_truthy
650
+ expect(handle_model.start_date).to be_a(Time)
651
+ end
652
+
653
+ it 'should reject bad data' do
654
+ expect { handle_model.int_list = {a: 1} }.to raise_error NoMethodError
655
+ end
656
+
657
+ it 'should save casted arrays' do
658
+ handle_model.int_list = [12, 24, 48]
659
+ expect(handle_model.int_list).to eq([12, 24, 48])
660
+ end
661
+
662
+ it 'should retrieve casted arrays' do
663
+ handle_model.sym_list = ['foo', 'bar', 'baz']
664
+ expect(handle_model.sym_list).to eq([:foo, :bar, :baz])
665
+ end
666
+ end
667
+
668
+ describe 'attributes' do
669
+ describe '#attributes' do
670
+ it 'should return all attributes of the object' do
671
+ expect(model.attributes).to include model_attributes
672
+ end
673
+ end
674
+
675
+ describe '.exists?' do
676
+
677
+ context 'with hash key' do
678
+ context 'the item exists' do
679
+ it 'should return true' do
680
+ expect(Dynamini::Base.exists?(model_attributes[:id])).to be_truthy
681
+ end
682
+ end
683
+
684
+ context 'the item does not exist' do
685
+ it 'should return false' do
686
+ expect(Dynamini::Base.exists?('nonexistent id')).to eq(false)
687
+ end
688
+ end
689
+ end
690
+
691
+ context 'with hash key and range key' do
692
+
693
+ it 'should return true if item exists' do
694
+ TestClassWithRange.create!(foo: 'abc', bar: 123)
695
+
696
+ expect(TestClassWithRange.exists?('abc', 123)).to eq(true)
697
+ end
698
+
699
+ it 'should return false if the item does not exist' do
700
+ TestClassWithRange.create!(foo: 'abc', bar: 123)
701
+
702
+ expect(TestClassWithRange.exists?('abc', 'nonexistent range key')).to eq(false)
703
+ end
704
+
705
+ end
706
+ end
707
+
708
+
709
+ describe '#new_record?' do
710
+ it 'should return true for a new record' do
711
+ expect(Dynamini::Base.new).to be_truthy
712
+ end
713
+ it 'should return false for a retrieved record' do
714
+ expect(Dynamini::Base.find('abcd1234').new_record?).to be_falsey
715
+ end
716
+ it 'should return false after a new record is saved' do
717
+ expect(model.new_record?).to be_falsey
718
+ end
719
+ end
720
+
721
+ describe 'reader method' do
722
+ it { is_expected.to respond_to(:price) }
723
+ it { is_expected.not_to respond_to(:foo) }
724
+
725
+ context 'existing attribute' do
726
+ it 'should return the attribute' do
727
+ expect(model.price).to eq(9.99)
728
+ end
729
+ end
730
+
731
+ context 'new attribute' do
732
+ before { model.description = 'test model' }
733
+ it 'should return the attribute' do
734
+ expect(model.description).to eq('test model')
735
+ end
736
+ end
737
+
738
+ context 'nonexistent attribute' do
739
+ it 'should return nil' do
740
+ expect(subject.foo).to be_nil
741
+ end
742
+ end
743
+
744
+ context 'attribute set to nil' do
745
+ before { model.price = nil }
746
+ it 'should return nil' do
747
+ expect(model.price).to be_nil
748
+ end
749
+ end
750
+ end
751
+
752
+ describe 'writer method' do
753
+ it { is_expected.to respond_to(:baz=) }
754
+
755
+ context 'existing attribute' do
756
+ before { model.price = '1' }
757
+ it 'should overwrite the attribute' do
758
+ expect(model.price).to eq('1')
759
+ end
760
+ end
761
+ context 'new attribute' do
762
+ before { model.foo = 'bar' }
763
+ it 'should write to the attribute' do
764
+ expect(model.foo).to eq('bar')
765
+ end
766
+ end
767
+ end
768
+
769
+ describe '#__was' do
770
+
771
+ context 'nonexistent attribute' do
772
+ it 'should raise an error' do
773
+ expect { Dynamini::Base.new.thing_was }.to raise_error ArgumentError
774
+ end
775
+ end
776
+
777
+ context 'after saving' do
778
+ it 'should clear all _was values' do
779
+ model = Dynamini::Base.new
780
+ model.new_val = 'new'
781
+ model.save
782
+ expect(model.new_val_was).to eq('new')
783
+ end
784
+ end
785
+
786
+ context 'new record' do
787
+
788
+ subject(:model) { Dynamini::Base.new(baz: 'baz') }
789
+ it { is_expected.to respond_to(:baz_was) }
790
+
791
+ context 'handled attribute with default' do
792
+ it 'should return the default value' do
793
+ Dynamini::Base.handle(:num, :integer, default: 2)
794
+ expect(model.num_was).to eq(2)
795
+ end
796
+ end
797
+
798
+ context 'handled attribute with no default' do
799
+ it 'should return nil' do
800
+ Dynamini::Base.handle(:num, :integer)
801
+ expect(model.num_was).to be_nil
802
+ end
803
+ end
804
+
805
+ context 'newly assigned attribute' do
806
+ it 'should return nil' do
807
+ model.new_attribute = 'hello'
808
+ expect(model.new_attribute_was).to be_nil
809
+ end
810
+ end
811
+ end
812
+
813
+ context 'previously saved record' do
814
+ subject(:model) { Dynamini::Base.new({baz: 'baz', nil_val: nil}, false) }
815
+ context 'unchanged attribute' do
816
+ it 'should return the current value' do
817
+ expect(model.baz_was).to eq('baz')
818
+ end
819
+ end
820
+
821
+ context 'newly assigned attribute or attribute changed from explicit nil' do
822
+ it 'should return nil' do
823
+ model.nil_val = 'no longer nil'
824
+ model.new_val = 'new'
825
+ expect(model.nil_val_was).to be_nil
826
+ expect(model.new_val_was).to be_nil
827
+ end
828
+ end
829
+
830
+ context 'attribute changed from value to value' do
831
+ it 'should return the old value' do
832
+ model.baz = 'baz2'
833
+ expect(model.baz_was).to eq('baz')
834
+ end
835
+ end
836
+ end
837
+ end
838
+
839
+ describe '#changes' do
840
+ it 'should not return the hash key or range key' do
841
+ Dynamini::Base.set_range_key(:range_key)
842
+ model.instance_variable_set(:@changes, {id: 'test_hash_key', range_key: "test_range_key"})
843
+ expect(model.changes).to eq({})
844
+ Dynamini::Base.set_range_key(nil)
845
+ end
846
+
847
+ context 'no change detected' do
848
+ it 'should return an empty hash' do
849
+ expect(model.changes).to eq({})
850
+ end
851
+ end
852
+
853
+ context 'attribute changed' do
854
+ before { model.price = 1 }
855
+ it 'should include the changed attribute' do
856
+ expect(model.changes['price']).to eq([9.99, 1])
857
+ end
858
+ end
859
+
860
+ context 'attribute created' do
861
+ before { model.foo = 'bar' }
862
+ it 'should include the created attribute' do
863
+ expect(model.changes['foo']).to eq([nil, 'bar'])
864
+ end
865
+ end
866
+
867
+ context 'attribute changed twice' do
868
+ before do
869
+ model.foo = 'bar'
870
+ model.foo = 'baz'
871
+ end
872
+ it 'should only include one copy of the changed attribute' do
873
+ expect(model.changes['foo']).to eq(['bar', 'baz'])
874
+ end
875
+ end
876
+ end
877
+
878
+ describe '#changed' do
879
+ it 'should stringify the keys of changes' do
880
+ allow(model).to receive(:changes).and_return({'price' => [1, 2], 'name' => ['a', 'b']})
881
+ expect(model.changed).to eq(['price', 'name'])
882
+ end
883
+ end
884
+
885
+ describe '#key' do
886
+ context 'when using hash key only' do
887
+
888
+ before do
889
+ class TestClass < Dynamini::Base
890
+ set_hash_key :foo
891
+ self.in_memory = true
892
+ end
893
+ end
894
+
895
+ it 'should return an hash containing only the hash_key name and value' do
896
+ expect(TestClass.new(foo: 2).send(:key)).to eq(foo: 2)
897
+ end
898
+ end
899
+ context 'when using both hash_key and range_key' do
900
+ it 'should return an hash containing only the hash_key name and value' do
901
+ key_hash = TestClassWithRange.new(foo: 2, bar: 2015).send(:key)
902
+ expect(key_hash).to eq(foo: 2, bar: 2015)
903
+ end
904
+ end
905
+ end
906
+ end
907
+ end
908
+