dynamini 2.8.1 → 2.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +17 -20
- data/README.md +57 -25
- data/dynamini.gemspec +1 -1
- data/lib/dynamini/batch_operations.rb +43 -7
- data/lib/dynamini/test_client.rb +50 -0
- data/spec/dynamini/batch_operations_spec.rb +85 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e6bc03a0efcc9da7a30ed72044a0e7733cae9dd6
|
4
|
+
data.tar.gz: 3f0d131c9dec2a3f1e8af932a6daf6e513eb9b62
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bc65042442a651365977bd108350e7ba3d516009060696f33834ffad426903fa86e01b99e5ae9627c3b62b0d3dddbda7ca5ec2a77ac3e35bd2aa5ba96dc6d966
|
7
|
+
data.tar.gz: de6f8de47956820198714c9887238fd4eef803eae0dcd42907b310c57a5a9dcdc59408625011472b2fbfc4128d1efcadb8e7883fa2b183d5e2ef44da1a281f10
|
data/Gemfile.lock
CHANGED
@@ -1,30 +1,29 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
dynamini (2.
|
4
|
+
dynamini (2.9.0)
|
5
5
|
activemodel (>= 3, < 5.0)
|
6
6
|
aws-sdk (~> 2)
|
7
7
|
|
8
8
|
GEM
|
9
9
|
remote: https://rubygems.org/
|
10
10
|
specs:
|
11
|
-
activemodel (4.2.
|
12
|
-
activesupport (= 4.2.
|
11
|
+
activemodel (4.2.5.1)
|
12
|
+
activesupport (= 4.2.5.1)
|
13
13
|
builder (~> 3.1)
|
14
|
-
activesupport (4.2.
|
14
|
+
activesupport (4.2.5.1)
|
15
15
|
i18n (~> 0.7)
|
16
|
+
json (~> 1.7, >= 1.7.7)
|
16
17
|
minitest (~> 5.1)
|
17
18
|
thread_safe (~> 0.3, >= 0.3.4)
|
18
19
|
tzinfo (~> 1.1)
|
19
|
-
aws-sdk (2.
|
20
|
-
aws-sdk-resources (= 2.
|
21
|
-
aws-sdk-core (2.
|
22
|
-
aws-sigv4 (~> 1.0)
|
20
|
+
aws-sdk (2.2.22)
|
21
|
+
aws-sdk-resources (= 2.2.22)
|
22
|
+
aws-sdk-core (2.2.22)
|
23
23
|
jmespath (~> 1.0)
|
24
|
-
aws-sdk-resources (2.
|
25
|
-
aws-sdk-core (= 2.
|
26
|
-
|
27
|
-
builder (3.2.3)
|
24
|
+
aws-sdk-resources (2.2.22)
|
25
|
+
aws-sdk-core (= 2.2.22)
|
26
|
+
builder (3.2.2)
|
28
27
|
coderay (1.1.0)
|
29
28
|
diff-lcs (1.2.5)
|
30
29
|
ffi (1.9.10)
|
@@ -49,14 +48,15 @@ GEM
|
|
49
48
|
guard-shell (0.7.1)
|
50
49
|
guard (>= 2.0.0)
|
51
50
|
guard-compat (~> 1.0)
|
52
|
-
i18n (0.
|
53
|
-
jmespath (1.3
|
51
|
+
i18n (0.7.0)
|
52
|
+
jmespath (1.1.3)
|
53
|
+
json (1.8.3)
|
54
54
|
listen (3.0.3)
|
55
55
|
rb-fsevent (>= 0.9.3)
|
56
56
|
rb-inotify (>= 0.9)
|
57
57
|
lumberjack (1.0.9)
|
58
58
|
method_source (0.8.2)
|
59
|
-
minitest (5.
|
59
|
+
minitest (5.8.4)
|
60
60
|
nenv (0.2.0)
|
61
61
|
notiffany (0.0.8)
|
62
62
|
nenv (~> 0.1)
|
@@ -85,8 +85,8 @@ GEM
|
|
85
85
|
shellany (0.0.1)
|
86
86
|
slop (3.6.0)
|
87
87
|
thor (0.19.1)
|
88
|
-
thread_safe (0.3.
|
89
|
-
tzinfo (1.2.
|
88
|
+
thread_safe (0.3.5)
|
89
|
+
tzinfo (1.2.2)
|
90
90
|
thread_safe (~> 0.1)
|
91
91
|
|
92
92
|
PLATFORMS
|
@@ -99,6 +99,3 @@ DEPENDENCIES
|
|
99
99
|
guard-shell
|
100
100
|
pry (~> 0)
|
101
101
|
rspec (~> 3)
|
102
|
-
|
103
|
-
BUNDLED WITH
|
104
|
-
1.16.0.pre.2
|
data/README.md
CHANGED
@@ -156,6 +156,7 @@ Query takes the following arguments:
|
|
156
156
|
* :end (optional)
|
157
157
|
* :limit (optional)
|
158
158
|
* :scan_index_forward (optional - set to false to sort by range key in desc order)
|
159
|
+
* :index_name (to query a secondary index - see below)
|
159
160
|
|
160
161
|
Here's how you'd use it to find daily temperature data for a given city, selecting for specific date ranges:
|
161
162
|
|
@@ -197,9 +198,64 @@ DailyWeather.query(hash_key: "Toronto", limit: 2)
|
|
197
198
|
DailyWeather.query(hash_key: "Toronto", scan_index_forward: false)
|
198
199
|
> [C, B, A]
|
199
200
|
```
|
201
|
+
|
202
|
+
## Table Scans
|
203
|
+
Table scanning is a very expensive operation, and should not be undertaken without a good understanding of the read/write costs. As such, Dynamini doesn't implement the traditional ActiveRecord collection methods like .all or .where. Instead, you can .scan, which has an interface much closer to DynamoDB's native SDK method.
|
204
|
+
|
205
|
+
The following options are supported:
|
206
|
+
|
207
|
+
* consistent_read (default: false)
|
208
|
+
* start_key (hash key of first desired item, default will scan from beginning)
|
209
|
+
* index_name (if scanning a secondary index - see below)
|
210
|
+
* limit
|
211
|
+
|
212
|
+
These two options are to support paralellization of table scanning, see: http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html
|
213
|
+
* segment
|
214
|
+
* total_segments
|
215
|
+
|
216
|
+
```ruby
|
217
|
+
products_page_one = Product.scan(limit: 100)
|
218
|
+
products_page_one.found # [product, product...]
|
219
|
+
page_two = Product.scan(start_key: products_page_one.last_evaluated_key)
|
220
|
+
```
|
221
|
+
|
222
|
+
## Secondary Indices
|
223
|
+
To define a secondary index (so that you can .scan it or .query it), you can list them at the top of your Dynamini subclass. The index names have to match the names you've set up through the DynamoDB console. If your secondary index uses a range key, specify it here as well.
|
224
|
+
|
225
|
+
```ruby
|
226
|
+
class Comment < Dynamini::Base
|
227
|
+
set_hash_key :id
|
228
|
+
set_range_key :comment_date
|
229
|
+
set_secondary_index :score_index
|
230
|
+
set_secondary_index :popularity_index, range_key: :popularity
|
231
|
+
end
|
232
|
+
```
|
233
|
+
For more information on how and why to use secondary indices, see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/SecondaryIndexes.html
|
234
|
+
## Testing
|
235
|
+
We've included an optional in-memory test client, so you don't necessarily have to connect to a real Dynamo instance when running tests. You could also use this in your development environment if you don't have a real Dynamo instance yet, but the data saved to it won't persist through a server restart.
|
236
|
+
|
237
|
+
To activate this feature, just require the testing module:
|
238
|
+
```ruby
|
239
|
+
require 'dynamini/testing'
|
240
|
+
```
|
241
|
+
This module replaces all API calls Dynamini makes to AWS DynamoDB with calls to Dynamini::TestClient.
|
242
|
+
|
243
|
+
The test client will not reset its database unless you tell it to, like so:
|
244
|
+
```ruby
|
245
|
+
Vehicle.client.reset
|
246
|
+
```
|
247
|
+
|
248
|
+
So, for instance, to get Rspec working with your test suite the way your ActiveRecord model behaved, add these lines to your spec_helper.rb:
|
249
|
+
```ruby
|
250
|
+
require 'dynamini/testing'
|
251
|
+
|
252
|
+
config.after(:each) {
|
253
|
+
Vehicle.client.reset # Large test suites will be very slow and unpredictable otherwise!
|
254
|
+
}
|
255
|
+
```
|
200
256
|
|
201
257
|
## Batch Saving
|
202
|
-
|
258
|
+
Dynamini implements DynamoDB's batch write operation, mapped to the .import method you might be used to from ActiveRecord. http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html
|
203
259
|
|
204
260
|
```ruby
|
205
261
|
class Product < Dynamini::Base
|
@@ -229,30 +285,6 @@ Product.find('qwerty').updated_at
|
|
229
285
|
|
230
286
|
````
|
231
287
|
|
232
|
-
|
233
|
-
## Testing
|
234
|
-
We've included an optional in-memory test client, so you don't necessarily have to connect to a real Dynamo instance when running tests. You could also use this in your development environment if you don't have a real Dynamo instance yet, but the data saved to it won't persist through a server restart.
|
235
|
-
|
236
|
-
To activate this feature, just require the testing module:
|
237
|
-
```ruby
|
238
|
-
require 'dynamini/testing'
|
239
|
-
```
|
240
|
-
This module replaces all API calls Dynamini makes to AWS DynamoDB with calls to Dynamini::TestClient.
|
241
|
-
|
242
|
-
The test client will not reset its database unless you tell it to, like so:
|
243
|
-
```ruby
|
244
|
-
Vehicle.client.reset
|
245
|
-
```
|
246
|
-
|
247
|
-
So, for instance, to get Rspec working with your test suite the way your ActiveRecord model behaved, add these lines to your spec_helper.rb:
|
248
|
-
```ruby
|
249
|
-
require 'dynamini/testing'
|
250
|
-
|
251
|
-
config.after(:each) {
|
252
|
-
Vehicle.client.reset # Large test suites will be very slow and unpredictable otherwise!
|
253
|
-
}
|
254
|
-
```
|
255
|
-
|
256
288
|
## Things to remember
|
257
289
|
* Since DynamoDB is schemaless, your model will respond to any method that looks like a reader, meaning model.foo will return nil.
|
258
290
|
* You can also write any arbitrary attribute to your model.
|
data/dynamini.gemspec
CHANGED
@@ -33,28 +33,64 @@ module Dynamini
|
|
33
33
|
client.batch_write_item(options)
|
34
34
|
end
|
35
35
|
|
36
|
+
def scan(options = {})
|
37
|
+
validate_scan_options(options)
|
38
|
+
response = dynamo_scan(options)
|
39
|
+
if options[:index_name]
|
40
|
+
last_evaluated_key = response.last_evaluated_key[secondary_index[options[:index_name]][:hash_key_name].to_s]
|
41
|
+
else
|
42
|
+
last_evaluated_key = response.last_evaluated_key[hash_key.to_s]
|
43
|
+
end
|
44
|
+
OpenStruct.new(
|
45
|
+
last_evaluated_key: last_evaluated_key,
|
46
|
+
items: response.items.map { |i| new(i.symbolize_keys, false) }
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
36
50
|
private
|
37
51
|
|
38
52
|
def dynamo_batch_get(key_struct)
|
39
53
|
client.batch_get_item(
|
40
|
-
|
41
|
-
|
42
|
-
|
54
|
+
request_items: {
|
55
|
+
table_name => {keys: key_struct}
|
56
|
+
}
|
43
57
|
)
|
44
58
|
end
|
45
59
|
|
46
60
|
def dynamo_batch_save(model_array)
|
47
61
|
put_requests = model_array.map do |model|
|
48
62
|
{
|
49
|
-
|
50
|
-
|
51
|
-
|
63
|
+
put_request: {
|
64
|
+
item: model.attributes.reject { |_k, v| v.blank? }.stringify_keys
|
65
|
+
}
|
52
66
|
}
|
53
67
|
end
|
54
68
|
request_options = {
|
55
|
-
|
69
|
+
request_items: {table_name => put_requests}
|
56
70
|
}
|
57
71
|
client.batch_write_item(request_options)
|
58
72
|
end
|
73
|
+
|
74
|
+
def dynamo_scan(options)
|
75
|
+
response = client.scan(
|
76
|
+
consistent_read: options[:consistent_read],
|
77
|
+
exclusive_start_key: options[:exclusive_start_key],
|
78
|
+
secondary_index_name: options[:index_name],
|
79
|
+
limit: options[:limit],
|
80
|
+
segment: options[:segment],
|
81
|
+
total_segments: options[:total_segments],
|
82
|
+
table_name: table_name
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
def validate_scan_options(options)
|
87
|
+
if options[:total_segments] && !options[:segment]
|
88
|
+
raise ArgumentError, 'Must specify segment if specifying total_segments'
|
89
|
+
elsif options[:segment] && !options[:total_segments]
|
90
|
+
raise ArgumentError, 'Must specify total_segments if specifying segment'
|
91
|
+
elsif options[:index_name] && !self.secondary_index[options[:index_name]]
|
92
|
+
raise ArgumentError, "Secondary index of #{options[:index_name]} does not exist"
|
93
|
+
end
|
94
|
+
end
|
59
95
|
end
|
60
96
|
end
|
data/lib/dynamini/test_client.rb
CHANGED
@@ -84,6 +84,56 @@ module Dynamini
|
|
84
84
|
OpenStruct.new(responses: responses)
|
85
85
|
end
|
86
86
|
|
87
|
+
def scan(args = {})
|
88
|
+
records = get_table(args[:table_name]).values
|
89
|
+
|
90
|
+
sort_scanned_records!(records, args[:secondary_index_name]) if args[:secondary_index_name]
|
91
|
+
start_index = index_of_start_key(args, records)
|
92
|
+
items = limit_scanned_records(args[:limit], records, start_index)
|
93
|
+
last_evaluated_key = get_last_evaluated_key(args[:secondary_index_name], items)
|
94
|
+
OpenStruct.new(items: items, last_evaluated_key: last_evaluated_key)
|
95
|
+
end
|
96
|
+
|
97
|
+
def sort_scanned_records!(records, secondary_index_name)
|
98
|
+
index = secondary_index[secondary_index_name]
|
99
|
+
records.sort! do |a, b|
|
100
|
+
a[get_secondary_hash_key(index)] <=> b[get_secondary_hash_key(index)]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def index_of_start_key(args, records)
|
105
|
+
if args[:exclusive_start_key]
|
106
|
+
sec_index = secondary_index[args[:secondary_index_name]]
|
107
|
+
start_index = records.index do |r|
|
108
|
+
if sec_index
|
109
|
+
r[get_secondary_hash_key(sec_index)] == args[:exclusive_start_key]
|
110
|
+
else
|
111
|
+
r[hash_key_attr] == args[:exclusive_start_key]
|
112
|
+
end
|
113
|
+
end
|
114
|
+
start_index || -1
|
115
|
+
else
|
116
|
+
0
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def limit_scanned_records(limit, records, start_index)
|
121
|
+
end_index = limit ? start_index + limit - 1 : -1
|
122
|
+
records[start_index..end_index]
|
123
|
+
end
|
124
|
+
|
125
|
+
def get_last_evaluated_key(secondary_index_name, items)
|
126
|
+
# TODO should last evaluated key be present if the args[:limit] was reached?
|
127
|
+
# if items.length > records.length
|
128
|
+
index = secondary_index[secondary_index_name]
|
129
|
+
if index
|
130
|
+
{ get_secondary_hash_key(index).to_s => items.last[get_secondary_hash_key(index)] }
|
131
|
+
else
|
132
|
+
{ hash_key_attr.to_s => items.last[hash_key_attr] }
|
133
|
+
end
|
134
|
+
# end
|
135
|
+
end
|
136
|
+
|
87
137
|
# TODO add range key support for delete, not currently implemented batch_operations.batch_delete
|
88
138
|
def batch_write_item(request_options)
|
89
139
|
request_options[:request_items].each do |table_name, requests|
|
@@ -126,5 +126,90 @@ describe Dynamini::BatchOperations do
|
|
126
126
|
expect{ Dynamini::Base.find('7890') }.to raise_error(Dynamini::RecordNotFound)
|
127
127
|
end
|
128
128
|
end
|
129
|
+
|
130
|
+
class SecBase < Dynamini::Base
|
131
|
+
set_hash_key :id
|
132
|
+
set_secondary_index :sec, hash_key: :sec
|
133
|
+
set_secondary_index :rev, hash_key: :sec
|
134
|
+
end
|
135
|
+
|
136
|
+
describe '.scan' do
|
137
|
+
|
138
|
+
before do
|
139
|
+
SecBase.create(id: '123', sec: 'D')
|
140
|
+
SecBase.create(id: '124', sec: 'C')
|
141
|
+
SecBase.create(id: '125', sec: 'B')
|
142
|
+
SecBase.create(id: '126', sec: 'A')
|
143
|
+
end
|
144
|
+
|
145
|
+
context 'scanning the primary key' do
|
146
|
+
context 'with an exclusive_start_key' do
|
147
|
+
context 'with a limit' do
|
148
|
+
it 'retrieves the correct items' do
|
149
|
+
response = SecBase.scan(exclusive_start_key: '124', limit: 1)
|
150
|
+
expect(response.items.map { |i| i.id }).to eq(['124'])
|
151
|
+
expect(response.last_evaluated_key).to eq('124')
|
152
|
+
end
|
153
|
+
end
|
154
|
+
context 'without a limit' do
|
155
|
+
it 'retrieves the correct items' do
|
156
|
+
response = SecBase.scan(exclusive_start_key: '124')
|
157
|
+
expect(response.items.map { |i| i.id }).to eq(%w(124 125 126))
|
158
|
+
expect(response.last_evaluated_key).to eq('126')
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
context 'without an exclusive_start_key' do
|
163
|
+
context 'with a limit' do
|
164
|
+
it 'retrieves the correct items' do
|
165
|
+
response = SecBase.scan(limit: 2)
|
166
|
+
expect(response.items.map { |i| i.id }).to eq(%w(123 124))
|
167
|
+
expect(response.last_evaluated_key).to eq('124')
|
168
|
+
end
|
169
|
+
end
|
170
|
+
context 'without a limit' do
|
171
|
+
it 'retrieves the correct items' do
|
172
|
+
response = SecBase.scan
|
173
|
+
expect(response.items.map { |i| i.id }).to eq(%w(123 124 125 126))
|
174
|
+
expect(response.last_evaluated_key).to eq('126')
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
context 'scanning a secondary index' do
|
180
|
+
context 'with an exclusive_start_key' do
|
181
|
+
context 'with a limit' do
|
182
|
+
it 'retrieves the correct items' do
|
183
|
+
response = SecBase.scan(index_name: 'sec', exclusive_start_key: 'B', limit: 2)
|
184
|
+
expect(response.items.map { |i| i.sec }).to eq(%w(B C))
|
185
|
+
expect(response.last_evaluated_key).to eq('C')
|
186
|
+
end
|
187
|
+
end
|
188
|
+
context 'without a limit' do
|
189
|
+
it 'retrieves the correct items' do
|
190
|
+
response = SecBase.scan(index_name: 'sec', exclusive_start_key: 'B')
|
191
|
+
expect(response.items.map { |i| i.sec }).to eq(%w(B C D))
|
192
|
+
expect(response.last_evaluated_key).to eq('D')
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
context 'without an exclusive_start_key' do
|
197
|
+
context 'with a limit' do
|
198
|
+
it 'retrieves the correct items' do
|
199
|
+
response = SecBase.scan(index_name: 'sec', limit: 3)
|
200
|
+
expect(response.items.map { |i| i.sec }).to eq(%w(A B C))
|
201
|
+
expect(response.last_evaluated_key).to eq('C')
|
202
|
+
end
|
203
|
+
end
|
204
|
+
context 'without a limit' do
|
205
|
+
it 'retrieves the correct items' do
|
206
|
+
response = SecBase.scan(index_name: 'sec')
|
207
|
+
expect(response.items.map { |i| i.sec }).to eq(%w(A B C D))
|
208
|
+
expect(response.last_evaluated_key).to eq('D')
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
129
214
|
end
|
130
215
|
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dynamini
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.9.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg Ward
|
@@ -15,7 +15,7 @@ authors:
|
|
15
15
|
autorequire:
|
16
16
|
bindir: bin
|
17
17
|
cert_chain: []
|
18
|
-
date: 2017-09-
|
18
|
+
date: 2017-09-12 00:00:00.000000000 Z
|
19
19
|
dependencies:
|
20
20
|
- !ruby/object:Gem::Dependency
|
21
21
|
name: activemodel
|