tree_branch 1.0.0

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