airspace 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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'