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.
@@ -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'