differential 1.0.1

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,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