tree_branch 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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 TreeBranch
11
+ # This class understands how to take a tree, digest it given a context, set of comparators, and a
12
+ # block, then returns a new tree structure.
13
+ class Processor
14
+ def process(node, context: nil, comparators: [], &block)
15
+ return nil if at_least_one_comparator_returns_false?(node.data, context, comparators)
16
+
17
+ valid_children = process_children(node.children, context, comparators, &block)
18
+
19
+ if block_given?
20
+ yield(node.data, valid_children, context)
21
+ else
22
+ ::TreeBranch::Node.new(node.data)
23
+ .add(valid_children)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def at_least_one_comparator_returns_false?(data, context, comparators)
30
+ Array(comparators).any? { |c| execute_comparator(c, data, context) == false }
31
+ end
32
+
33
+ def process_children(children, context, comparators, &block)
34
+ children.map do |node|
35
+ process(node, context: context, comparators: comparators, &block)
36
+ end.compact
37
+ end
38
+
39
+ def execute_comparator(comparator, data, context)
40
+ if comparator.is_a?(Proc)
41
+ comparator.call(OpenStruct.new(data), OpenStruct.new(context))
42
+ else
43
+ comparator.new(data: data, context: context).valid?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 TreeBranch
11
+ # A basic subclass of Node that makes the data element a deterministic and comparable OpenStruct
12
+ # object.
13
+ class SimpleNode < Node
14
+ acts_as_hashable
15
+
16
+ def initialize(data: {}, children: [])
17
+ @data = OpenStruct.new(data)
18
+ @children = self.class.array(children)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 'acts_as_hashable'
11
+ require 'ostruct'
12
+
13
+ require_relative 'comparator'
14
+ require_relative 'node'
15
+ require_relative 'simple_node'
16
+ require_relative 'processor'
17
+
18
+ # Top-level namespace of the library. The methods contained here should be considered the
19
+ # main public API.
20
+ module TreeBranch
21
+ class << self
22
+ def process(node: {}, context: {}, comparators: [], &block)
23
+ ::TreeBranch::Processor.new
24
+ .process(
25
+ normalize_node(node),
26
+ context: context,
27
+ comparators: comparators,
28
+ &block
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def normalize_node(node)
35
+ node.is_a?(::TreeBranch::Node) ? node : ::TreeBranch::SimpleNode.make(node, nullable: false)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 TreeBranch
11
+ VERSION = '1.0.0'
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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_relative 'tree_branch/tree_branch'
@@ -0,0 +1,13 @@
1
+ :data:
2
+ name: Matt
3
+ :dob: '1920-01-04'
4
+ state: IL
5
+ :children:
6
+ - :data:
7
+ :name: Nick
8
+ dob: '1930-04-04'
9
+ :state: WI
10
+ - :data:
11
+ :name: Sam
12
+ dob: '1940-09-12'
13
+ :state: AK
@@ -0,0 +1,9 @@
1
+ :data:
2
+ name: Matt
3
+ :dob: '1920-01-04'
4
+ state: IL
5
+ :children:
6
+ - :data:
7
+ :name: Sam
8
+ dob: '1940-09-12'
9
+ :state: AK
@@ -0,0 +1,18 @@
1
+ :data:
2
+ name: Matt
3
+ :dob: '1920-01-04'
4
+ state: IL
5
+ :children:
6
+ - :data:
7
+ :name: Nick
8
+ dob: '1930-04-04'
9
+ :state: WI
10
+ :children:
11
+ - :data:
12
+ name: Katie
13
+ :dob: '1820-02-05'
14
+ :state: 'CA'
15
+ - :data:
16
+ :name: Sam
17
+ dob: '1940-09-12'
18
+ :state: AK
@@ -0,0 +1,22 @@
1
+ :data:
2
+ name: Matt
3
+ :dob: '1920-01-04'
4
+ state: IL
5
+ injected: Mattcakes!!
6
+ :children:
7
+ - :data:
8
+ :name: Nick
9
+ dob: '1930-04-04'
10
+ :state: WI
11
+ injected: Nickcakes!!
12
+ :children:
13
+ - :data:
14
+ name: Katie
15
+ :dob: '1820-02-05'
16
+ :state: CA
17
+ injected: Katiecakes!!
18
+ - :data:
19
+ :name: Sam
20
+ dob: '1940-09-12'
21
+ :state: AK
22
+ injected: Samcakes!!
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 'date'
11
+ require 'yaml'
12
+
13
+ require './lib/tree_branch'
14
+
15
+ def fixture_path(filename)
16
+ File.join('spec', 'fixtures', filename)
17
+ end
18
+
19
+ def fixture(filename)
20
+ YAML.load_file(fixture_path(filename))
21
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 ::TreeBranch::Comparator do
13
+ let(:data_hash) do
14
+ {
15
+ 'name': 'Matt',
16
+ dob: '1920-01-04',
17
+ 'state' => 'IL'
18
+ }
19
+ end
20
+
21
+ let(:context_hash) do
22
+ {
23
+ letters: %w[M S]
24
+ }
25
+ end
26
+
27
+ it 'should initialize from hashes correctly' do
28
+ comparator = ::TreeBranch::Comparator.new(data: data_hash, context: context_hash)
29
+
30
+ expect(comparator.data['name']).to eq(data_hash['name'])
31
+ expect(comparator.data[:dob]).to eq(data_hash[:dob])
32
+ expect(comparator.data['state']).to eq(data_hash['state'])
33
+ expect(comparator.context[:letters]).to eq(context_hash[:letters])
34
+ end
35
+
36
+ it 'should initialize from OpenStruct objects correctly' do
37
+ data = OpenStruct.new(data_hash)
38
+ context = OpenStruct.new(context_hash)
39
+
40
+ comparator = ::TreeBranch::Comparator.new(data: data, context: context)
41
+
42
+ expect(comparator.data.name).to eq(data.name)
43
+ expect(comparator.data.dob).to eq(data.dob)
44
+ expect(comparator.data.state).to eq(data.state)
45
+ expect(comparator.context.letters).to eq(context.letters)
46
+ end
47
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 ::TreeBranch::Node do
13
+ let(:node_hash) { fixture('node.yml') }
14
+
15
+ let(:node) { ::TreeBranch::SimpleNode.make(node_hash) }
16
+
17
+ it 'should initialize and equality compare correctly' do
18
+ expected_data = OpenStruct.new(node_hash[:data])
19
+ expected_children = ::TreeBranch::SimpleNode.array(node_hash[:children])
20
+
21
+ expect(node.data).to eq(expected_data)
22
+ expect(node.children).to eq(expected_children)
23
+ end
24
+ end
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2018-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 ::TreeBranch do
13
+ # We will use this spec to also test ::TreeBranch::Node#process since ::TreeBranch#process
14
+ # fully delegates to that method.
15
+ describe '#process' do
16
+ let(:node_hash) { fixture('node.yml') }
17
+
18
+ let(:node_hash_with_injected) { fixture('node_with_injected.yml') }
19
+
20
+ let(:node) { ::TreeBranch::SimpleNode.make(node_hash) }
21
+
22
+ let(:node_with_injected) { ::TreeBranch::SimpleNode.make(node_hash_with_injected) }
23
+
24
+ let(:born_after1915_hash) { fixture('born_after1915.yml') }
25
+
26
+ let(:born_after1915) { ::TreeBranch::SimpleNode.make(born_after1915_hash) }
27
+
28
+ let(:born_after1915_starts_with_m_or_s_hash) do
29
+ fixture('born_after1915_with_m_or_s.yml')
30
+ end
31
+
32
+ let(:born_after1915_starts_with_m_or_s) do
33
+ ::TreeBranch::SimpleNode.make(born_after1915_starts_with_m_or_s_hash)
34
+ end
35
+
36
+ class BornAfter1915 < ::TreeBranch::Comparator
37
+ def valid?
38
+ Date.parse(data.dob).year > 1915
39
+ end
40
+ end
41
+
42
+ class NameStartsWith < ::TreeBranch::Comparator
43
+ def valid?
44
+ context[:letters].include?(data.name.to_s[0])
45
+ end
46
+ end
47
+
48
+ let(:name_starts_with_lambda) do
49
+ lambda do |data, context|
50
+ context.letters.include?(data.name.to_s[0])
51
+ end
52
+ end
53
+
54
+ it 'should return everything when no comparators are given' do
55
+ expect(::TreeBranch.process(node: node_hash)).to eq(node)
56
+ end
57
+
58
+ it 'should return nil when no comparators pass for top level node' do
59
+ # The base comparator class returns false by default
60
+ expect(::TreeBranch.process(node: node_hash, comparators: ::TreeBranch::Comparator)).to be nil
61
+ end
62
+
63
+ it 'should return valid nodes with one comparator' do
64
+ actual = ::TreeBranch.process(node: node_hash, comparators: BornAfter1915)
65
+
66
+ expect(actual).to eq(born_after1915)
67
+ end
68
+
69
+ it 'should return valid nodes with two class comparators' do
70
+ input = {
71
+ node: node_hash,
72
+ context: { letters: %w[M S] },
73
+ comparators: [BornAfter1915, NameStartsWith]
74
+ }
75
+
76
+ expect(::TreeBranch.process(input)).to eq(born_after1915_starts_with_m_or_s)
77
+ end
78
+
79
+ it 'should return valid nodes with one class comparator and one lambda comparator' do
80
+ input = {
81
+ node: node_hash,
82
+ context: { letters: %w[M S] },
83
+ comparators: [BornAfter1915, name_starts_with_lambda]
84
+ }
85
+
86
+ expect(::TreeBranch.process(input)).to eq(born_after1915_starts_with_m_or_s)
87
+ end
88
+
89
+ it 'should execute the block after node processing' do
90
+ outside_variable = '!!'
91
+
92
+ input = {
93
+ node: node,
94
+ context: OpenStruct.new(suffix: 'cakes')
95
+ }
96
+
97
+ processed = ::TreeBranch.process(input) do |data, children, context|
98
+ local_node = ::TreeBranch::SimpleNode.new(data: data, children: children)
99
+
100
+ local_node.data.injected = "#{local_node.data.name}#{context.suffix}#{outside_variable}"
101
+
102
+ local_node
103
+ end
104
+
105
+ expect(processed).to eq(node_with_injected)
106
+ end
107
+ end
108
+
109
+ describe 'README Examples' do
110
+ class StateComparator < ::TreeBranch::Comparator
111
+ STATE_OPS = {
112
+ none: %i[open],
113
+ passive: %i[open save close print],
114
+ active: %i[open save close print cut copy paste]
115
+ }.freeze
116
+
117
+ def valid?
118
+ data.command.nil? || Array(STATE_OPS[context[:state]]).include?(data.command)
119
+ end
120
+ end
121
+
122
+ let(:menu) do
123
+ {
124
+ data: { name: 'Menu' },
125
+ children: [
126
+ {
127
+ data: { name: 'File' },
128
+ children: [
129
+ { data: { name: 'Open', command: :open } },
130
+ { data: { name: 'Save', command: :save, right: :write } },
131
+ { data: { name: 'Close', command: :close } },
132
+ {
133
+ data: { name: 'Print', command: :print },
134
+ children: [
135
+ { data: { name: 'Print' } },
136
+ { data: { name: 'Print Preview' } }
137
+ ]
138
+ }
139
+ ]
140
+ },
141
+ {
142
+ data: { name: 'Edit' },
143
+ children: [
144
+ { data: { name: 'Cut', command: :cut } },
145
+ { data: { name: 'Copy', command: :copy } },
146
+ { data: { name: 'Paste', command: :paste } }
147
+ ]
148
+ }
149
+ ]
150
+ }
151
+ end
152
+
153
+ it 'should compute state: none' do
154
+ no_file_menu = ::TreeBranch.process(
155
+ node: menu,
156
+ comparators: StateComparator,
157
+ context: { state: :none }
158
+ ) do |data, children, _context|
159
+ ::TreeBranch::SimpleNode.new(data: data, children: children)
160
+ end
161
+
162
+ expected = ::TreeBranch::SimpleNode.new(
163
+ data: { name: 'Menu' },
164
+ children: [
165
+ {
166
+ data: { name: 'File' },
167
+ children: [
168
+ { data: { name: 'Open', command: :open } }
169
+ ]
170
+ },
171
+ {
172
+ data: { name: 'Edit' }
173
+ }
174
+ ]
175
+ )
176
+
177
+ expect(no_file_menu).to eq(expected)
178
+ end
179
+
180
+ it 'should compute state: passive' do
181
+ no_file_menu = ::TreeBranch.process(
182
+ node: menu,
183
+ comparators: StateComparator,
184
+ context: { state: :passive }
185
+ ) do |data, children, _context|
186
+ ::TreeBranch::SimpleNode.new(data: data, children: children)
187
+ end
188
+
189
+ expected = ::TreeBranch::SimpleNode.new(
190
+ data: { name: 'Menu' },
191
+ children: [
192
+ {
193
+ data: { name: 'File' },
194
+ children: [
195
+ { data: { name: 'Open', command: :open } },
196
+ { data: { name: 'Save', command: :save, right: :write } },
197
+ { data: { name: 'Close', command: :close } },
198
+ {
199
+ data: { name: 'Print', command: :print },
200
+ children: [
201
+ { data: { name: 'Print' } },
202
+ { data: { name: 'Print Preview' } }
203
+ ]
204
+ }
205
+ ]
206
+ },
207
+ {
208
+ data: { name: 'Edit' }
209
+ }
210
+ ]
211
+ )
212
+
213
+ expect(no_file_menu).to eq(expected)
214
+ end
215
+
216
+ it 'should compute state: active' do
217
+ no_file_menu = ::TreeBranch.process(
218
+ node: menu,
219
+ comparators: StateComparator,
220
+ context: { state: :active }
221
+ ) do |data, children, _context|
222
+ ::TreeBranch::SimpleNode.new(data: data, children: children)
223
+ end
224
+
225
+ expected = ::TreeBranch::SimpleNode.new(
226
+ data: { name: 'Menu' },
227
+ children: [
228
+ {
229
+ data: { name: 'File' },
230
+ children: [
231
+ { data: { name: 'Open', command: :open } },
232
+ { data: { name: 'Save', command: :save, right: :write } },
233
+ { data: { name: 'Close', command: :close } },
234
+ {
235
+ data: { name: 'Print', command: :print },
236
+ children: [
237
+ { data: { name: 'Print' } },
238
+ { data: { name: 'Print Preview' } }
239
+ ]
240
+ }
241
+ ]
242
+ },
243
+ {
244
+ data: { name: 'Edit' },
245
+ children: [
246
+ { data: { name: 'Cut', command: :cut } },
247
+ { data: { name: 'Copy', command: :copy } },
248
+ { data: { name: 'Paste', command: :paste } }
249
+ ]
250
+ }
251
+ ]
252
+ )
253
+
254
+ expect(no_file_menu).to eq(expected)
255
+ end
256
+
257
+ class AuthorizationComparator < ::TreeBranch::Comparator
258
+ def valid?
259
+ data.right.nil? || Array(context[:rights]).include?(data.right)
260
+ end
261
+ end
262
+
263
+ it 'should compute state: passive for read-only authorization' do
264
+ no_file_menu = ::TreeBranch.process(
265
+ node: menu,
266
+ comparators: [StateComparator, AuthorizationComparator],
267
+ context: { state: :passive }
268
+ ) do |data, children, _context|
269
+ ::TreeBranch::SimpleNode.new(data: data, children: children)
270
+ end
271
+
272
+ expected = ::TreeBranch::SimpleNode.new(
273
+ data: { name: 'Menu' },
274
+ children: [
275
+ {
276
+ data: { name: 'File' },
277
+ children: [
278
+ { data: { name: 'Open', command: :open } },
279
+ { data: { name: 'Close', command: :close } },
280
+ {
281
+ data: { name: 'Print', command: :print },
282
+ children: [
283
+ { data: { name: 'Print' } },
284
+ { data: { name: 'Print Preview' } }
285
+ ]
286
+ }
287
+ ]
288
+ },
289
+ {
290
+ data: { name: 'Edit' }
291
+ }
292
+ ]
293
+ )
294
+
295
+ expect(no_file_menu).to eq(expected)
296
+ end
297
+
298
+ it 'should compute state: passive for read/write authorization' do
299
+ no_file_menu = ::TreeBranch.process(
300
+ node: menu,
301
+ comparators: [StateComparator, AuthorizationComparator],
302
+ context: { state: :passive, rights: :write }
303
+ ) do |data, children, _context|
304
+ ::TreeBranch::SimpleNode.new(data: data, children: children)
305
+ end
306
+
307
+ expected = ::TreeBranch::SimpleNode.new(
308
+ data: { name: 'Menu' },
309
+ children: [
310
+ {
311
+ data: { name: 'File' },
312
+ children: [
313
+ { data: { name: 'Open', command: :open } },
314
+ { data: { name: 'Save', command: :save, right: :write } },
315
+ { data: { name: 'Close', command: :close } },
316
+ {
317
+ data: { name: 'Print', command: :print },
318
+ children: [
319
+ { data: { name: 'Print' } },
320
+ { data: { name: 'Print Preview' } }
321
+ ]
322
+ }
323
+ ]
324
+ },
325
+ {
326
+ data: { name: 'Edit' }
327
+ }
328
+ ]
329
+ )
330
+
331
+ expect(no_file_menu).to eq(expected)
332
+ end
333
+
334
+ let(:auth_comparator) do
335
+ lambda do |data, context|
336
+ data.right.nil? || Array(context.rights).include?(data.right)
337
+ end
338
+ end
339
+
340
+ class MenuItem
341
+ acts_as_hashable
342
+
343
+ attr_reader :menu_items, :name
344
+
345
+ def initialize(name: '', menu_items: [])
346
+ @name = name
347
+ @menu_items = self.class.array(menu_items)
348
+ end
349
+
350
+ def eql?(other)
351
+ name == other.name && menu_items == other.menu_items
352
+ end
353
+
354
+ def ==(other)
355
+ eql?(other)
356
+ end
357
+ end
358
+
359
+ it 'should compute state: passive for read/write authorization and return MenuItem(s)' do
360
+ passive_read_write_menu =
361
+ ::TreeBranch.process(
362
+ node: menu,
363
+ comparators: [StateComparator, auth_comparator],
364
+ context: { state: :passive, rights: :write }
365
+ ) { |data, children, _context| MenuItem.new(name: data.name, menu_items: children) }
366
+
367
+ expected = MenuItem.new(
368
+ name: 'Menu',
369
+ menu_items: [
370
+ {
371
+ name: 'File',
372
+ menu_items: [
373
+ { name: 'Open' },
374
+ { name: 'Save' },
375
+ { name: 'Close' },
376
+ {
377
+ name: 'Print',
378
+ menu_items: [
379
+ { name: 'Print' },
380
+ { name: 'Print Preview' }
381
+ ]
382
+ }
383
+ ]
384
+ },
385
+ {
386
+ name: 'Edit'
387
+ }
388
+ ]
389
+ )
390
+
391
+ expect(passive_read_write_menu).to eq(expected)
392
+ end
393
+ end
394
+ end