differential 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,62 @@
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 Differential
11
+ module Calculator
12
+ # A Report has 0 or more Group objects and a Group has 0 or more Item objects.
13
+ # Report -> Group -> Item
14
+ # A Group is, as the name implies, a grouping of items. It is up to the consumer application
15
+ # to define how to group (i.e. group based on this attribute's value or
16
+ # group based on these two attributes' values.)
17
+ class Group
18
+ include ::Differential::Calculator::HasTotals
19
+
20
+ attr_reader :id
21
+
22
+ def initialize(id)
23
+ raise ArgumentError, 'id is required' unless id
24
+
25
+ @id = id
26
+ end
27
+
28
+ def items
29
+ items_by_id.values
30
+ end
31
+
32
+ def add(record, side)
33
+ raise ArgumentError, 'record is required' unless record
34
+ raise ArgumentError, 'side is required' unless side
35
+ raise ArgumentError, "mismatch: #{record.group_id} != #{id}" if id != record.group_id
36
+
37
+ totals.add(record.value, side)
38
+
39
+ upsert_item(record, side)
40
+
41
+ self
42
+ end
43
+
44
+ private
45
+
46
+ def upsert_item(record, side)
47
+ item_id = record.id
48
+
49
+ # Create a new item if one does not exist
50
+ items_by_id[item_id] = Item.new(item_id) unless items_by_id.key?(item_id)
51
+
52
+ items_by_id[item_id].add(record, side)
53
+
54
+ nil
55
+ end
56
+
57
+ def items_by_id
58
+ @items_by_id ||= {}
59
+ end
60
+ end
61
+ end
62
+ 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
+ module Differential
11
+ module Calculator
12
+ # There are multiple classes that all need calculation support (The Total class.)
13
+ # Instead of using inheritance, those classes can use this mix-in for composition.
14
+ module HasTotals
15
+ extend Forwardable
16
+
17
+ def_delegators :totals, :a_sigma, :b_sigma, :delta
18
+
19
+ def totals
20
+ @totals ||= ::Differential::Calculator::Totals.new
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
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 Differential
11
+ module Calculator
12
+ # Consider this as being line-level and is the lowest point of calculation.
13
+ # Ultimately a Report object will turn all added Record objects into Item objects (placed
14
+ # in Group objects.)
15
+ class Item
16
+ include ::Differential::Calculator::HasTotals
17
+ include ::Differential::Calculator::Side
18
+
19
+ attr_reader :id, :a_records, :b_records
20
+
21
+ def initialize(id)
22
+ raise ArgumentError, 'id is required' unless id
23
+
24
+ @id = id
25
+ @a_records = []
26
+ @b_records = []
27
+ end
28
+
29
+ def add(record, side)
30
+ raise ArgumentError, 'record is required' unless record
31
+ raise ArgumentError, 'side is required' unless side
32
+ raise ArgumentError, "mismatch: #{record.id} != #{id}" if id != record.id
33
+
34
+ totals.add(record.value, side)
35
+
36
+ account_for_record(record, side)
37
+
38
+ self
39
+ end
40
+
41
+ private
42
+
43
+ def account_for_record(record, side)
44
+ case side
45
+ when A
46
+ @a_records << record
47
+ when B
48
+ @b_records << record
49
+ else
50
+ raise ArgumentError, "unknown side: #{side}"
51
+ end
52
+
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
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 Differential
11
+ module Calculator
12
+ # This class is responsible for building an entire report. Usage:
13
+ # - Instantiate a Reader.
14
+ # - Instantiate a Report.
15
+ # - Feed in dataset(s) into the Reader to generate Record objects.
16
+ # - Feed in Record objects, generated by a Reader, by calling Report#add.
17
+ # The Report object will keep running sums and deltas of all added records.
18
+ class Report
19
+ include HasTotals
20
+
21
+ def groups
22
+ groups_by_id.values
23
+ end
24
+
25
+ def add(record, side)
26
+ raise ArgumentError, 'record is required' unless record
27
+ raise ArgumentError, 'side is required' unless side
28
+
29
+ totals.add(record.value, side)
30
+
31
+ upsert_group(record, side)
32
+
33
+ self
34
+ end
35
+
36
+ private
37
+
38
+ def upsert_group(record, side)
39
+ group_id = record.group_id
40
+
41
+ # Create a new group if one does not exist
42
+ groups_by_id[group_id] = Group.new(group_id) unless groups_by_id.key?(group_id)
43
+
44
+ groups_by_id[group_id].add(record, side)
45
+
46
+ nil
47
+ end
48
+
49
+ def groups_by_id
50
+ @groups_by_id ||= {}
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,19 @@
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 Differential
11
+ module Calculator
12
+ # Differential can currently only compute calculations for two datasets, represented as:
13
+ # A and B. Ultimately, how you define what A and B are up to the consuming application.
14
+ module Side
15
+ A = :a
16
+ B = :b
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,44 @@
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 Differential
11
+ module Calculator
12
+ # Value object that can capture basic calculations:
13
+ # - a_sigma is the sum of data set A's values.
14
+ # - b_sigma is the sum of data set B's values.
15
+ # - delta is the difference: b_sigma - a_sigma.
16
+ class Totals
17
+ include ::Differential::Calculator::Side
18
+
19
+ attr_reader :a_sigma, :b_sigma
20
+
21
+ def initialize
22
+ @a_sigma = 0
23
+ @b_sigma = 0
24
+ end
25
+
26
+ def delta
27
+ b_sigma - a_sigma
28
+ end
29
+
30
+ def add(value, side)
31
+ case side
32
+ when A
33
+ @a_sigma += value
34
+ when B
35
+ @b_sigma += value
36
+ else
37
+ raise ArgumentError, "unknown side: #{side}"
38
+ end
39
+
40
+ self
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
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 'forwardable'
11
+
12
+ require_relative 'calculator/calculator'
13
+ require_relative 'parser/parser'
14
+
15
+ # This module will serve as the top-level entry point for consumers.
16
+ # You can stick with the API provided here unless you know the internals behind this point.
17
+ module Differential
18
+ extend ::Differential::Calculator::Side
19
+
20
+ class << self
21
+ def calculate(dataset_a: [], dataset_b: [], reader_config: {})
22
+ reader = ::Differential::Parser::Reader.new(reader_config)
23
+ report = ::Differential::Calculator::Report.new
24
+
25
+ reader.each(dataset_a) { |record| report.add(record, A) }
26
+ reader.each(dataset_b) { |record| report.add(record, B) }
27
+
28
+ report
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
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 'reader'
11
+ require_relative 'record'
@@ -0,0 +1,73 @@
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 Differential
11
+ module Parser
12
+ # This class is used to parse incoming datasets.
13
+ # Usage:
14
+ # Instantiate new object with configuration options.
15
+ # Call read to parse individual hash objects into Record objects.
16
+ class Reader
17
+ attr_reader :record_id_key,
18
+ :value_key,
19
+ :group_id_key
20
+
21
+ # Params:
22
+ # +record_id_key+:: The hash key(s) to use to uniquely identify a record (required)
23
+ # +value_key+:: The hash key used to extract the value of the record.
24
+ # +group_id_key+:: The hash key(s) to use to identify which group the record belongs to.
25
+ def initialize(record_id_key:, value_key:, group_id_key: nil)
26
+ raise ArgumentError, 'record_id_key is required' unless record_id_key
27
+ raise ArgumentError, 'value_key is required' unless value_key
28
+
29
+ @record_id_key = record_id_key
30
+ @value_key = value_key
31
+ @group_id_key = group_id_key
32
+ end
33
+
34
+ def each(hashes)
35
+ return enum_for(:each) unless block_given?
36
+
37
+ hashes.each do |hash|
38
+ record = read(hash)
39
+ yield record
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def read(hash)
46
+ id = make_record_id(hash)
47
+ group_id = make_group_id(hash)
48
+ value = hash[value_key]
49
+
50
+ ::Differential::Parser::Record.new(id: id,
51
+ group_id: group_id,
52
+ value: value,
53
+ data: hash)
54
+ end
55
+
56
+ def make_record_id(hash)
57
+ record_id_key_array.map { |k| hash[k] }.join(':')
58
+ end
59
+
60
+ def make_group_id(hash)
61
+ group_id_key_array.map { |k| hash[k] }.join(':')
62
+ end
63
+
64
+ def record_id_key_array
65
+ @record_id_key_array ||= Array(record_id_key)
66
+ end
67
+
68
+ def group_id_key_array
69
+ @group_id_key_array ||= Array(group_id_key)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,25 @@
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 Differential
11
+ module Parser
12
+ # Represents a parsed record object. This is ultimately what a Reader creates and
13
+ # is serves as input into the Calculator module.
14
+ class Record
15
+ attr_reader :id, :group_id, :value, :data
16
+
17
+ def initialize(id:, group_id:, value:, data:)
18
+ @id = id
19
+ @group_id = group_id
20
+ @value = value
21
+ @data = data
22
+ end
23
+ end
24
+ end
25
+ 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 Differential
11
+ VERSION = '1.0.1'
12
+ end
@@ -0,0 +1,137 @@
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 ::Differential::Calculator::Report do
13
+ it 'should initialize correctly' do
14
+ report = ::Differential::Calculator::Report.new
15
+
16
+ expect(report.a_sigma).to eq(0)
17
+ expect(report.b_sigma).to eq(0)
18
+ expect(report.delta).to eq(0)
19
+ end
20
+
21
+ let(:group_1_plus8) do
22
+ ::Differential::Parser::Record.new(id: '1', group_id: '1', value: 8, data: { id: '1' })
23
+ end
24
+
25
+ let(:group_1_minus9) do
26
+ ::Differential::Parser::Record.new(id: '1', group_id: '1', value: -9, data: { id: '1' })
27
+ end
28
+
29
+ let(:group_2_plus3) do
30
+ ::Differential::Parser::Record.new(id: '2', group_id: '2', value: 3, data: { id: '1' })
31
+ end
32
+
33
+ let(:group_2_minus2) do
34
+ ::Differential::Parser::Record.new(id: '3', group_id: '2', value: -2, data: { id: '1' })
35
+ end
36
+
37
+ context 'when totaling sigma and deltas at report level' do
38
+ it 'should compute sigma & delta correctly' do
39
+ report = ::Differential::Calculator::Report.new
40
+
41
+ report.add(group_1_plus8, ::Differential::Calculator::Side::A)
42
+
43
+ expect(report.a_sigma).to eq(group_1_plus8.value)
44
+ expect(report.b_sigma).to eq(0)
45
+ expect(report.delta).to eq(-group_1_plus8.value)
46
+
47
+ report.add(group_1_minus9, ::Differential::Calculator::Side::B)
48
+
49
+ expect(report.a_sigma).to eq(group_1_plus8.value)
50
+ expect(report.b_sigma).to eq(group_1_minus9.value)
51
+ expect(report.delta).to eq(group_1_minus9.value - group_1_plus8.value)
52
+ end
53
+ end
54
+
55
+ context 'when totaling sigma and deltas at group level' do
56
+ it 'should compute sigma & delta correctly' do
57
+ report = ::Differential::Calculator::Report.new
58
+
59
+ report.add(group_1_plus8, ::Differential::Calculator::Side::A)
60
+
61
+ group1 = report.groups.first
62
+
63
+ expect(report.groups.length).to eq(1)
64
+ expect(group1.id).to eq(group_1_plus8.group_id)
65
+ expect(group1.a_sigma).to eq(group_1_plus8.value)
66
+ expect(group1.b_sigma).to eq(0)
67
+ expect(group1.delta).to eq(-group_1_plus8.value)
68
+
69
+ report.add(group_1_minus9, ::Differential::Calculator::Side::B)
70
+
71
+ group1 = report.groups.first
72
+
73
+ expect(report.groups.length).to eq(1)
74
+ expect(group1.id).to eq(group_1_plus8.group_id)
75
+ expect(group1.a_sigma).to eq(group_1_plus8.value)
76
+ expect(group1.b_sigma).to eq(group_1_minus9.value)
77
+ expect(group1.delta).to eq(group_1_minus9.value - group_1_plus8.value)
78
+
79
+ report.add(group_2_plus3, ::Differential::Calculator::Side::A)
80
+
81
+ group2 = report.groups.last
82
+
83
+ expect(report.groups.length).to eq(2)
84
+ expect(group2.id).to eq(group_2_plus3.group_id)
85
+ expect(group2.a_sigma).to eq(group_2_plus3.value)
86
+ expect(group2.b_sigma).to eq(0)
87
+ expect(group2.delta).to eq(-group_2_plus3.value)
88
+
89
+ report.add(group_2_minus2, ::Differential::Calculator::Side::B)
90
+
91
+ group2 = report.groups.last
92
+
93
+ expect(report.groups.length).to eq(2)
94
+ expect(group2.id).to eq(group_2_plus3.group_id)
95
+ expect(group2.a_sigma).to eq(group_2_plus3.value)
96
+ expect(group2.b_sigma).to eq(group_2_minus2.value)
97
+ expect(group2.delta).to eq(group_2_minus2.value - group_2_plus3.value)
98
+ end
99
+ end
100
+
101
+ context 'when totaling sigma and deltas at item level' do
102
+ it 'should compute sigma & delta correctly' do
103
+ report = ::Differential::Calculator::Report.new
104
+
105
+ report.add(group_1_plus8, ::Differential::Calculator::Side::A)
106
+ report.add(group_1_minus9, ::Differential::Calculator::Side::B)
107
+ report.add(group_2_plus3, ::Differential::Calculator::Side::A)
108
+ report.add(group_2_minus2, ::Differential::Calculator::Side::B)
109
+
110
+ group1 = report.groups.first
111
+ group2 = report.groups.last
112
+
113
+ expect(group1.items.length).to eq(1)
114
+ expect(group2.items.length).to eq(2)
115
+
116
+ group1_item1 = group1.items.first
117
+
118
+ expect(group1_item1.id).to eq(group_1_plus8.id)
119
+ expect(group1_item1.a_sigma).to eq(group_1_plus8.value)
120
+ expect(group1_item1.b_sigma).to eq(group_1_minus9.value)
121
+ expect(group1_item1.delta).to eq(group_1_minus9.value - group_1_plus8.value)
122
+
123
+ group2_item1 = group2.items.first
124
+ group2_item2 = group2.items.last
125
+
126
+ expect(group2_item1.id).to eq(group_2_plus3.id)
127
+ expect(group2_item1.a_sigma).to eq(group_2_plus3.value)
128
+ expect(group2_item1.b_sigma).to eq(0)
129
+ expect(group2_item1.delta).to eq(-group_2_plus3.value)
130
+
131
+ expect(group2_item2.id).to eq(group_2_minus2.id)
132
+ expect(group2_item2.a_sigma).to eq(0)
133
+ expect(group2_item2.b_sigma).to eq(group_2_minus2.value)
134
+ expect(group2_item2.delta).to eq(group_2_minus2.value)
135
+ end
136
+ end
137
+ end