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.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/.rspec +2 -0
- data/.travis.yml +11 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +101 -0
- data/Guardfile +79 -0
- data/LICENSE +22 -0
- data/README.md +214 -0
- data/Rakefile +5 -0
- data/dynamini.gemspec +29 -0
- data/lib/dynamini/base.rb +4 -57
- data/lib/dynamini/batch_operations.rb +51 -0
- data/lib/dynamini/test_client.rb +2 -1
- data/spec/dynamini/base_spec.rb +908 -0
- data/spec/dynamini/batch_operations_spec.rb +77 -0
- data/spec/dynamini/test_client_spec.rb +187 -0
- data/spec/spec_helper.rb +15 -0
- metadata +23 -3
data/Rakefile
ADDED
data/dynamini.gemspec
ADDED
@@ -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
|
data/lib/dynamini/base.rb
CHANGED
@@ -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 :
|
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
|
data/lib/dynamini/test_client.rb
CHANGED
@@ -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
|
+
|