airspace 1.0.0
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 +7 -0
- data/.editorconfig +8 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +11 -0
- data/.ruby-version +1 -0
- data/.travis.yml +22 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +104 -0
- data/Guardfile +16 -0
- data/LICENSE +7 -0
- data/README.md +220 -0
- data/airspace.gemspec +33 -0
- data/bin/console +15 -0
- data/lib/airspace.rb +10 -0
- data/lib/airspace/airspace.rb +50 -0
- data/lib/airspace/chunker.rb +47 -0
- data/lib/airspace/dataset.rb +79 -0
- data/lib/airspace/has_metadata.rb +24 -0
- data/lib/airspace/info_keys.rb +17 -0
- data/lib/airspace/key.rb +35 -0
- data/lib/airspace/metadata.rb +44 -0
- data/lib/airspace/reader.rb +92 -0
- data/lib/airspace/serializer.rb +42 -0
- data/lib/airspace/store.rb +115 -0
- data/lib/airspace/version.rb +12 -0
- data/spec/airspace/airspace_spec.rb +177 -0
- data/spec/airspace/chunker_spec.rb +111 -0
- data/spec/airspace/store_spec.rb +21 -0
- data/spec/spec_helper.rb +33 -0
- metadata +190 -0
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Airspace
|
11
|
+
# The Store is the data access layer that knows how to persist and retrieve all data.
|
12
|
+
# There really should never be a need to interact directly with the store, it merely
|
13
|
+
# acts as an intermediary between the Redis client and the Dataset/Reader.
|
14
|
+
class Store
|
15
|
+
attr_reader :client
|
16
|
+
|
17
|
+
def initialize(client)
|
18
|
+
raise ArgumentError unless client
|
19
|
+
|
20
|
+
@client = client
|
21
|
+
end
|
22
|
+
|
23
|
+
def exist?(key)
|
24
|
+
client.exists(key.root)
|
25
|
+
end
|
26
|
+
|
27
|
+
def persist(key, info_hash, chunks, expires_in_seconds)
|
28
|
+
options = make_options(expires_in_seconds)
|
29
|
+
|
30
|
+
multi_pipeline do
|
31
|
+
client.set(key.root, info_hash.to_json, options)
|
32
|
+
|
33
|
+
chunks.each_with_index do |chunk, index|
|
34
|
+
chunk_key = key.chunk(index)
|
35
|
+
client.set(chunk_key, chunk.to_json, options)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
nil
|
40
|
+
end
|
41
|
+
|
42
|
+
def retrieve(key)
|
43
|
+
return nil unless exist?(key)
|
44
|
+
|
45
|
+
data = client.get(key)
|
46
|
+
|
47
|
+
JSON.parse(data)
|
48
|
+
end
|
49
|
+
|
50
|
+
def delete(key, chunk_count)
|
51
|
+
return false unless exist?(key)
|
52
|
+
|
53
|
+
multi_pipeline do
|
54
|
+
client.del(key.root)
|
55
|
+
|
56
|
+
(0...chunk_count).each do |index|
|
57
|
+
chunk_key = key.chunk(index)
|
58
|
+
client.del(chunk_key)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
true
|
63
|
+
end
|
64
|
+
|
65
|
+
def chunks(key, chunk_count)
|
66
|
+
futures = chunk_futures(key, chunk_count)
|
67
|
+
all_chunks = []
|
68
|
+
|
69
|
+
futures.each do |chunk_future|
|
70
|
+
cached_chunk = chunk_future.value
|
71
|
+
all_chunks += JSON.parse(cached_chunk)
|
72
|
+
end
|
73
|
+
|
74
|
+
all_chunks
|
75
|
+
end
|
76
|
+
|
77
|
+
def chunk(key, chunk_index)
|
78
|
+
chunk_key = key.chunk(chunk_index)
|
79
|
+
cached_chunk = client.get(chunk_key)
|
80
|
+
|
81
|
+
return [] unless cached_chunk
|
82
|
+
|
83
|
+
JSON.parse(cached_chunk)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def multi_pipeline
|
89
|
+
client.multi do
|
90
|
+
client.pipelined do
|
91
|
+
yield
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def make_options(expires_in_seconds)
|
97
|
+
{}.tap do |o|
|
98
|
+
o[:ex] = expires_in_seconds if expires_in_seconds
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def chunk_futures(key, chunk_count)
|
103
|
+
futures = []
|
104
|
+
|
105
|
+
client.pipelined do
|
106
|
+
(0...chunk_count).each do |index|
|
107
|
+
chunk_key = key.chunk(index)
|
108
|
+
futures << client.get(chunk_key)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
futures
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
module Airspace
|
11
|
+
VERSION = '1.0.0'
|
12
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
require './spec/spec_helper'
|
11
|
+
|
12
|
+
class CustomSerializer < ::Airspace::Serializer
|
13
|
+
def serialize_data(obj)
|
14
|
+
obj = obj.map { |k, v| [k.to_sym, v] }.to_h
|
15
|
+
|
16
|
+
json_serialize(
|
17
|
+
[
|
18
|
+
obj[:movie_name],
|
19
|
+
obj[:release_date].to_s,
|
20
|
+
obj[:rating]
|
21
|
+
]
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
def deserialize_data(json)
|
26
|
+
array = json_deserialize(json)
|
27
|
+
|
28
|
+
{
|
29
|
+
movie_name: array[0],
|
30
|
+
release_date: Date.parse(array[1]),
|
31
|
+
rating: array[2]
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
def serialize_row(obj)
|
36
|
+
obj = obj.map { |k, v| [k.to_sym, v] }.to_h
|
37
|
+
|
38
|
+
json_serialize([obj[:id], obj[:name]])
|
39
|
+
end
|
40
|
+
|
41
|
+
def deserialize_row(json)
|
42
|
+
array = json_deserialize(json)
|
43
|
+
|
44
|
+
{
|
45
|
+
id: array[0],
|
46
|
+
name: array[1]
|
47
|
+
}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# This is mainly to ensure CI has a proper Redis installation and instance.
|
52
|
+
describe ::Airspace do
|
53
|
+
let(:client) { Redis.new }
|
54
|
+
|
55
|
+
let(:data) do
|
56
|
+
{
|
57
|
+
'movie_name' => 'Avengers',
|
58
|
+
'release_date' => Date.new(2012, 5, 5),
|
59
|
+
'rating' => 'PG-13'
|
60
|
+
}
|
61
|
+
end
|
62
|
+
|
63
|
+
let(:data_hash) do
|
64
|
+
{
|
65
|
+
'movie_name' => 'Avengers',
|
66
|
+
'release_date' => '2012-05-05',
|
67
|
+
'rating' => 'PG-13'
|
68
|
+
}
|
69
|
+
end
|
70
|
+
|
71
|
+
let(:rows) do
|
72
|
+
[
|
73
|
+
{ 'id' => 1, 'name' => 'Iron Man' },
|
74
|
+
{ 'id' => 2, 'name' => 'Hulk' },
|
75
|
+
{ 'id' => 3, 'name' => 'Thor' },
|
76
|
+
{ 'id' => 4, 'name' => 'Spiderman' },
|
77
|
+
{ 'id' => 5, 'name' => 'Captain America' }
|
78
|
+
]
|
79
|
+
end
|
80
|
+
|
81
|
+
let(:pages) do
|
82
|
+
rows.each_slice(2).to_a
|
83
|
+
end
|
84
|
+
|
85
|
+
let(:symbolized_pages) do
|
86
|
+
pages.map { |page| page.map { |row| row.map { |k, v| [k.to_sym, v] }.to_h } }
|
87
|
+
end
|
88
|
+
|
89
|
+
describe '#get, #set, #del' do
|
90
|
+
it 'should use passed in ID' do
|
91
|
+
id = 'marvel'
|
92
|
+
options = { prefix: TEST_PREFIX }
|
93
|
+
|
94
|
+
::Airspace.set(client, data: data, id: id, pages: pages, options: options)
|
95
|
+
|
96
|
+
actual_reader = ::Airspace.get(client, id, options: options)
|
97
|
+
expect(actual_reader.data).to eq(data_hash)
|
98
|
+
expect(actual_reader.page_count).to eq(pages.length)
|
99
|
+
|
100
|
+
actual_pages = actual_reader.pages
|
101
|
+
expect(actual_pages).to eq(pages)
|
102
|
+
|
103
|
+
actual_page1 = actual_reader.page(1)
|
104
|
+
expect(actual_page1).to eq(pages[0])
|
105
|
+
|
106
|
+
actual_page2 = actual_reader.page(2)
|
107
|
+
expect(actual_page2).to eq(pages[1])
|
108
|
+
|
109
|
+
actual_page3 = actual_reader.page(3)
|
110
|
+
expect(actual_page3).to eq(pages[2])
|
111
|
+
|
112
|
+
deleted = ::Airspace.del(client, id, options: options)
|
113
|
+
expect(deleted).to be true
|
114
|
+
|
115
|
+
reader = ::Airspace.get(client, id, options: options)
|
116
|
+
expect(reader).to be nil
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'should auto-assign ID' do
|
120
|
+
options = { prefix: TEST_PREFIX }
|
121
|
+
id = ::Airspace.set(client, data: data, pages: pages, options: options)
|
122
|
+
|
123
|
+
actual_reader = ::Airspace.get(client, id, options: options)
|
124
|
+
expect(actual_reader.data).to eq(data_hash)
|
125
|
+
expect(actual_reader.page_count).to eq(pages.length)
|
126
|
+
|
127
|
+
actual_pages = actual_reader.pages
|
128
|
+
expect(actual_pages).to eq(pages)
|
129
|
+
|
130
|
+
actual_page1 = actual_reader.page(1)
|
131
|
+
expect(actual_page1).to eq(pages[0])
|
132
|
+
|
133
|
+
actual_page2 = actual_reader.page(2)
|
134
|
+
expect(actual_page2).to eq(pages[1])
|
135
|
+
|
136
|
+
actual_page3 = actual_reader.page(3)
|
137
|
+
expect(actual_page3).to eq(pages[2])
|
138
|
+
|
139
|
+
deleted = ::Airspace.del(client, id, options: options)
|
140
|
+
expect(deleted).to be true
|
141
|
+
|
142
|
+
reader = ::Airspace.get(client, id, options: options)
|
143
|
+
expect(reader).to be nil
|
144
|
+
end
|
145
|
+
|
146
|
+
it 'should suppport custom serialization' do
|
147
|
+
options = {
|
148
|
+
prefix: TEST_PREFIX,
|
149
|
+
serializer: CustomSerializer.new
|
150
|
+
}
|
151
|
+
|
152
|
+
id = ::Airspace.set(client, data: data, pages: pages, options: options)
|
153
|
+
|
154
|
+
actual_reader = ::Airspace.get(client, id, options: options)
|
155
|
+
expect(actual_reader.data).to eq(data.map { |k, v| [k.to_sym, v] }.to_h)
|
156
|
+
expect(actual_reader.page_count).to eq(pages.length)
|
157
|
+
|
158
|
+
actual_pages = actual_reader.pages
|
159
|
+
expect(actual_pages).to eq(symbolized_pages)
|
160
|
+
|
161
|
+
actual_page1 = actual_reader.page(1)
|
162
|
+
expect(actual_page1).to eq(symbolized_pages[0])
|
163
|
+
|
164
|
+
actual_page2 = actual_reader.page(2)
|
165
|
+
expect(actual_page2).to eq(symbolized_pages[1])
|
166
|
+
|
167
|
+
actual_page3 = actual_reader.page(3)
|
168
|
+
expect(actual_page3).to eq(symbolized_pages[2])
|
169
|
+
|
170
|
+
deleted = ::Airspace.del(client, id, options: options)
|
171
|
+
expect(deleted).to be true
|
172
|
+
|
173
|
+
reader = ::Airspace.get(client, id, options: options)
|
174
|
+
expect(reader).to be nil
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
require './spec/spec_helper'
|
11
|
+
|
12
|
+
describe ::Airspace::Chunker do
|
13
|
+
subject { ::Airspace::Chunker }
|
14
|
+
|
15
|
+
describe 'initialization' do
|
16
|
+
it 'should raise ArgumentError with a negative pages_per_chunk' do
|
17
|
+
expect { subject.new(-1) }.to raise_error(ArgumentError)
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'should raise ArgumentError with a zero pages_per_chunk' do
|
21
|
+
expect { subject.new(0) }.to raise_error(ArgumentError)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe '#count with pages_per_chunk = 5' do
|
26
|
+
it { expect(subject.new(5).count(0)).to eq(0) }
|
27
|
+
it { expect(subject.new(5).count(1)).to eq(1) }
|
28
|
+
it { expect(subject.new(5).count(2)).to eq(1) }
|
29
|
+
it { expect(subject.new(5).count(3)).to eq(1) }
|
30
|
+
it { expect(subject.new(5).count(4)).to eq(1) }
|
31
|
+
it { expect(subject.new(5).count(5)).to eq(1) }
|
32
|
+
it { expect(subject.new(5).count(6)).to eq(2) }
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '#count with pages_per_chunk = 1' do
|
36
|
+
let(:pages_per_chunk) { 1 }
|
37
|
+
it { expect(subject.new(pages_per_chunk).count(0)).to eq(0) }
|
38
|
+
it { expect(subject.new(pages_per_chunk).count(1)).to eq(1) }
|
39
|
+
it { expect(subject.new(pages_per_chunk).count(2)).to eq(2) }
|
40
|
+
it { expect(subject.new(pages_per_chunk).count(3)).to eq(3) }
|
41
|
+
it { expect(subject.new(pages_per_chunk).count(4)).to eq(4) }
|
42
|
+
it { expect(subject.new(pages_per_chunk).count(5)).to eq(5) }
|
43
|
+
it { expect(subject.new(pages_per_chunk).count(6)).to eq(6) }
|
44
|
+
end
|
45
|
+
|
46
|
+
describe '#count with pages_per_chunk = 9' do
|
47
|
+
let(:pages_per_chunk) { 9 }
|
48
|
+
it { expect(subject.new(pages_per_chunk).count(0)).to eq(0) }
|
49
|
+
it { expect(subject.new(pages_per_chunk).count(1)).to eq(1) }
|
50
|
+
it { expect(subject.new(pages_per_chunk).count(2)).to eq(1) }
|
51
|
+
it { expect(subject.new(pages_per_chunk).count(3)).to eq(1) }
|
52
|
+
it { expect(subject.new(pages_per_chunk).count(4)).to eq(1) }
|
53
|
+
it { expect(subject.new(pages_per_chunk).count(5)).to eq(1) }
|
54
|
+
it { expect(subject.new(pages_per_chunk).count(6)).to eq(1) }
|
55
|
+
end
|
56
|
+
|
57
|
+
it '#each with page_size = 0' do
|
58
|
+
chunker = subject.new(5)
|
59
|
+
|
60
|
+
actual = chunker.each(0).with_object([]) { |chunk, array| array << chunk }
|
61
|
+
|
62
|
+
expected = []
|
63
|
+
|
64
|
+
expect(actual).to eq(expected)
|
65
|
+
end
|
66
|
+
|
67
|
+
it '#each with page_size = 6' do
|
68
|
+
chunker = subject.new(5)
|
69
|
+
|
70
|
+
actual = chunker.each(6).with_object([]) { |chunk, array| array << chunk }
|
71
|
+
|
72
|
+
expected = [
|
73
|
+
::Airspace::Chunker::Chunk.new(0, 0, 4),
|
74
|
+
::Airspace::Chunker::Chunk.new(1, 5, 9)
|
75
|
+
]
|
76
|
+
|
77
|
+
expect(actual).to eq(expected)
|
78
|
+
end
|
79
|
+
|
80
|
+
it '#each with page_size = 13' do
|
81
|
+
chunker = subject.new(5)
|
82
|
+
|
83
|
+
actual = chunker.each(13).with_object([]) { |chunk, array| array << chunk }
|
84
|
+
|
85
|
+
expected = [
|
86
|
+
::Airspace::Chunker::Chunk.new(0, 0, 4),
|
87
|
+
::Airspace::Chunker::Chunk.new(1, 5, 9),
|
88
|
+
::Airspace::Chunker::Chunk.new(2, 10, 14)
|
89
|
+
]
|
90
|
+
|
91
|
+
expect(actual).to eq(expected)
|
92
|
+
end
|
93
|
+
|
94
|
+
describe '.locate' do
|
95
|
+
it { expect(subject.new(5).locate(0)).to eq(::Airspace::Chunker::Location.new(0, 0)) }
|
96
|
+
it { expect(subject.new(5).locate(1)).to eq(::Airspace::Chunker::Location.new(0, 1)) }
|
97
|
+
it { expect(subject.new(5).locate(2)).to eq(::Airspace::Chunker::Location.new(0, 2)) }
|
98
|
+
it { expect(subject.new(5).locate(3)).to eq(::Airspace::Chunker::Location.new(0, 3)) }
|
99
|
+
it { expect(subject.new(5).locate(4)).to eq(::Airspace::Chunker::Location.new(0, 4)) }
|
100
|
+
it { expect(subject.new(5).locate(5)).to eq(::Airspace::Chunker::Location.new(1, 0)) }
|
101
|
+
it { expect(subject.new(5).locate(6)).to eq(::Airspace::Chunker::Location.new(1, 1)) }
|
102
|
+
|
103
|
+
it 'should raise ArgumentError with a negative pages_per_chunk' do
|
104
|
+
expect { subject.new(-1).locate(0) }.to raise_error(ArgumentError)
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'should raise ArgumentError with a zero pages_per_chunk' do
|
108
|
+
expect { subject.new(0).locate(0) }.to raise_error(ArgumentError)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
require './spec/spec_helper'
|
11
|
+
|
12
|
+
# This is mainly to ensure CI has a proper Redis installation and instance.
|
13
|
+
describe ::Airspace::Store do
|
14
|
+
let(:client) { Redis.new }
|
15
|
+
|
16
|
+
describe 'initialization' do
|
17
|
+
it 'should raise ArgumentError with a null client' do
|
18
|
+
expect { ::Airspace::Store.new(nil) }.to raise_error(ArgumentError)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# Copyright (c) 2019-present, Blue Marble Payroll, LLC
|
5
|
+
#
|
6
|
+
# This source code is licensed under the MIT license found in the
|
7
|
+
# LICENSE file in the root directory of this source tree.
|
8
|
+
#
|
9
|
+
|
10
|
+
require 'database_cleaner'
|
11
|
+
require 'simplecov'
|
12
|
+
require 'simplecov-console'
|
13
|
+
require 'redis'
|
14
|
+
require 'pry'
|
15
|
+
|
16
|
+
TEST_PREFIX = 'airspace_test'
|
17
|
+
|
18
|
+
RSpec.configure do |config|
|
19
|
+
config.before(:suite) do
|
20
|
+
DatabaseCleaner[:redis].strategy = :truncation, { only: ['airspace_test:*'] }
|
21
|
+
end
|
22
|
+
|
23
|
+
config.around(:each) do |example|
|
24
|
+
DatabaseCleaner[:redis].cleaning do
|
25
|
+
example.run
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
SimpleCov.formatter = SimpleCov::Formatter::Console
|
31
|
+
SimpleCov.start
|
32
|
+
|
33
|
+
require './lib/airspace'
|