dossier-segmenter 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +20 -0
- data/.rspec +2 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +29 -0
- data/Rakefile +7 -0
- data/app/views/dossier/reports/cute_animals.html.haml +57 -0
- data/dossier-segmenter.gemspec +30 -0
- data/lib/dossier/segment.rb +54 -0
- data/lib/dossier/segment/chain.rb +31 -0
- data/lib/dossier/segment/definition.rb +73 -0
- data/lib/dossier/segment/rows.rb +39 -0
- data/lib/dossier/segment/summary.rb +50 -0
- data/lib/dossier/segmenter.rb +104 -0
- data/lib/dossier/segmenter/engine.rb +6 -0
- data/lib/dossier/segmenter/report.rb +34 -0
- data/lib/dossier/segmenter/version.rb +5 -0
- data/script/rails +5 -0
- data/spec/dossier/segment/chain_spec.rb +43 -0
- data/spec/dossier/segment/definition_spec.rb +84 -0
- data/spec/dossier/segment/rows_spec.rb +10 -0
- data/spec/dossier/segment/summary_spec.rb +51 -0
- data/spec/dossier/segment_spec.rb +121 -0
- data/spec/dossier/segmenter/report_spec.rb +23 -0
- data/spec/dossier/segmenter_spec.rb +171 -0
- data/spec/dummy/app/reports/cute_animals_report.rb +41 -0
- data/spec/dummy/config.ru +2 -0
- data/spec/dummy/config/application.rb +27 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/features/segmented_report_spec.rb +28 -0
- data/spec/spec_helper.rb +20 -0
- metadata +201 -0
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'dossier'
|
2
|
+
require 'dossier/segmenter/engine'
|
3
|
+
require 'dossier/segmenter/version'
|
4
|
+
|
5
|
+
module Dossier
|
6
|
+
class Segmenter
|
7
|
+
attr_accessor :report
|
8
|
+
|
9
|
+
class << self
|
10
|
+
attr_accessor :report_class
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.segments
|
14
|
+
@segment_chain ||= Segment::Chain.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.segment(name, options = {}, &block)
|
18
|
+
segments << Segment::Definition.new(self, name, options)
|
19
|
+
instance_eval(&block) if block_given?
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.skip_headers
|
23
|
+
segments.map(&:columns).flatten
|
24
|
+
end
|
25
|
+
|
26
|
+
delegate :skip_headers, to: "self.class"
|
27
|
+
|
28
|
+
def initialize(report)
|
29
|
+
self.report = report
|
30
|
+
extend(segment_chain.first.segment_module) if report.segmented?
|
31
|
+
end
|
32
|
+
|
33
|
+
def headers
|
34
|
+
@headers ||= report_results.headers.reject { |header| header.in?(skip_headers) }
|
35
|
+
end
|
36
|
+
|
37
|
+
def data
|
38
|
+
@data ||= report_results.rows.inject(Hash.new { [] }) { |acc, row|
|
39
|
+
acc.tap { |hash| hash[key_path_for(row)] += [row] }
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
def segment_chain
|
44
|
+
self.class.segments
|
45
|
+
end
|
46
|
+
|
47
|
+
def segmenter
|
48
|
+
self
|
49
|
+
end
|
50
|
+
|
51
|
+
def segment_options_for(segment)
|
52
|
+
position = segment.key_path.split('.').count
|
53
|
+
data.keys.map { |key|
|
54
|
+
key.split('.')
|
55
|
+
}.inject({}) { |acc, key|
|
56
|
+
acc.tap { |hash|
|
57
|
+
hash[key.first(position + 1)] ||= data[key.join('.')].first
|
58
|
+
}
|
59
|
+
}.select { |key, value|
|
60
|
+
key.first(position) == segment.key_path.split('.')
|
61
|
+
}.values.map { |row|
|
62
|
+
Hash[report_results.headers.zip(row)]
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
66
|
+
def key_path
|
67
|
+
String.new
|
68
|
+
end
|
69
|
+
|
70
|
+
def inspect
|
71
|
+
"#<#{self.class.name}>"
|
72
|
+
end
|
73
|
+
|
74
|
+
def header_index_map
|
75
|
+
@header_index_map ||= Hash[skip_headers.map { |h| [h, report_results.headers.index(h)] }]
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def report_results
|
81
|
+
report.raw_results
|
82
|
+
end
|
83
|
+
|
84
|
+
def key_path_for(row)
|
85
|
+
group_by_indexes.map { |i| row.at(i) }.join('.')
|
86
|
+
end
|
87
|
+
|
88
|
+
def segment_options
|
89
|
+
data unless defined?(@data)
|
90
|
+
@segment_options
|
91
|
+
end
|
92
|
+
|
93
|
+
def group_by_indexes
|
94
|
+
@group_by_indexes ||= header_index_map.values_at(*segment_chain.map(&:group_by).map(&:to_s))
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
require "dossier/segment"
|
100
|
+
require "dossier/segment/chain"
|
101
|
+
require "dossier/segment/definition"
|
102
|
+
require "dossier/segment/rows"
|
103
|
+
require "dossier/segment/summary"
|
104
|
+
require "dossier/segmenter/report"
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Dossier
|
2
|
+
class Segmenter
|
3
|
+
module Report
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
def segment_parent
|
7
|
+
nil
|
8
|
+
end
|
9
|
+
|
10
|
+
def segmenter
|
11
|
+
@segmenter ||= self.class.segmenter_class.new(self)
|
12
|
+
end
|
13
|
+
|
14
|
+
def segmented?
|
15
|
+
self.class.segmenter_class.segments.any?
|
16
|
+
end
|
17
|
+
|
18
|
+
module ClassMethods
|
19
|
+
def segmenter_class
|
20
|
+
const_get(:Segmenter)
|
21
|
+
end
|
22
|
+
|
23
|
+
def inherited(base)
|
24
|
+
segmenter_class = Class.new(Dossier::Segmenter)
|
25
|
+
segmenter_class.report_class = base
|
26
|
+
base.const_set(:Segmenter, segmenter_class)
|
27
|
+
super
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
Dossier::Report.send :include, self
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/script/rails
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Dossier::Segment::Chain do
|
4
|
+
let(:report_class) { Class.new(Dossier::Report) }
|
5
|
+
let(:segmenter_class) { report_class.segmenter_class }
|
6
|
+
let(:definition) { Dossier::Segment::Definition.new(segmenter_class, :foo) }
|
7
|
+
let(:next) { Dossier::Segment::Definition.new(segmenter_class, :bar) }
|
8
|
+
let(:chain) { described_class.new.tap { |c| c << definition } }
|
9
|
+
|
10
|
+
it "allows #at access of definitions" do
|
11
|
+
expect(chain.at 0).to eq definition
|
12
|
+
end
|
13
|
+
|
14
|
+
it "aliases [] to at" do
|
15
|
+
expect(chain[0]).to eq definition
|
16
|
+
end
|
17
|
+
|
18
|
+
it "conforms to enumerable" do
|
19
|
+
chain.each { |d| expect(d).to eq definition }
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "appending to the chain" do
|
23
|
+
before :each do
|
24
|
+
chain << self.next
|
25
|
+
end
|
26
|
+
|
27
|
+
it "uses <<" do
|
28
|
+
expect(chain.at 1).to eq self.next
|
29
|
+
end
|
30
|
+
|
31
|
+
it "allows accessing the end" do
|
32
|
+
expect(chain.last).to eq self.next
|
33
|
+
end
|
34
|
+
|
35
|
+
it "assigns the previous segment the given segment as next" do
|
36
|
+
expect(chain.first.next).to eq self.next
|
37
|
+
end
|
38
|
+
|
39
|
+
it "assigns the next segment the previous segment as prev" do
|
40
|
+
expect(chain.last.prev).to eq chain.first
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Dossier::Segment::Definition do
|
4
|
+
let(:report_class) { Class.new(Dossier::Report) }
|
5
|
+
let(:segmenter_class) { report_class.segmenter_class }
|
6
|
+
let(:definition) { described_class.new(segmenter_class, :foo) }
|
7
|
+
|
8
|
+
describe "attributes" do
|
9
|
+
it "has a segmenter_class" do
|
10
|
+
expect(definition.segmenter_class).to eq segmenter_class
|
11
|
+
end
|
12
|
+
it "has a name" do
|
13
|
+
expect(definition.name).to eq :foo
|
14
|
+
end
|
15
|
+
|
16
|
+
it "uses the name as the group by if one isn't provided" do
|
17
|
+
expect(definition.group_by).to eq :foo
|
18
|
+
end
|
19
|
+
|
20
|
+
it "uses the name as the display name if one isn't provided" do
|
21
|
+
expect(definition.display_name).to eq :foo
|
22
|
+
end
|
23
|
+
|
24
|
+
it "has a group_by" do
|
25
|
+
definition = described_class.new(segmenter_class, :foo, group_by: :bar)
|
26
|
+
expect(definition.group_by).to eq :bar
|
27
|
+
end
|
28
|
+
|
29
|
+
it "has a display_name" do
|
30
|
+
definition = described_class.new(segmenter_class, :foo, display_name: :baz)
|
31
|
+
expect(definition.display_name).to eq :baz
|
32
|
+
end
|
33
|
+
|
34
|
+
it "has a segment class name" do
|
35
|
+
expect(definition.segment_class_name).to eq 'Foo'
|
36
|
+
end
|
37
|
+
|
38
|
+
it "has a plural name" do
|
39
|
+
expect(definition.plural_name).to eq 'foos'
|
40
|
+
end
|
41
|
+
|
42
|
+
describe "for columns" do
|
43
|
+
it "knows to use the name" do
|
44
|
+
definition = described_class.new(segmenter_class, :foo)
|
45
|
+
expect(definition.columns).to eq %w[foo]
|
46
|
+
end
|
47
|
+
|
48
|
+
it "knows to use group_by if provided" do
|
49
|
+
definition = described_class.new(segmenter_class, :foo, group_by: :bar)
|
50
|
+
expect(definition.columns).to eq %w[bar foo]
|
51
|
+
end
|
52
|
+
|
53
|
+
it "knows to use display_name if not a proc" do
|
54
|
+
definition = described_class.new(segmenter_class, :foo, display_name: :baz)
|
55
|
+
expect(definition.columns).to eq %w[foo baz]
|
56
|
+
end
|
57
|
+
|
58
|
+
it "knows not to use display_name if not a proc" do
|
59
|
+
definition = described_class.new(segmenter_class, :foo, display_name: ->(row) {})
|
60
|
+
expect(definition.columns).to eq %w[foo]
|
61
|
+
end
|
62
|
+
|
63
|
+
it "knows not to use the name if group_by and display_name are set" do
|
64
|
+
definition = described_class.new(segmenter_class, :foo, group_by: :bar, display_name: :baz)
|
65
|
+
expect(definition.columns).to eq %w[bar baz]
|
66
|
+
end
|
67
|
+
|
68
|
+
it "knows not to use the display_name if group_by is present and display_name is a proc" do
|
69
|
+
definition = described_class.new(segmenter_class, :foo, group_by: :foo_id, display_name: ->(row) {})
|
70
|
+
expect(definition.columns).to eq %w[foo_id]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
describe "segment subclass" do
|
76
|
+
it "is defined in the report class namespace as a subclass of Dossier::Segment" do
|
77
|
+
expect(definition.segment_subclass.superclass).to eq Dossier::Segment
|
78
|
+
end
|
79
|
+
|
80
|
+
it "sets up the segment subclasses name constant properly" do
|
81
|
+
expect(CuteAnimalsReport::Family).to be_a Class
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Dossier::Segment::Summary do
|
4
|
+
|
5
|
+
let(:summary) { described_class.new(headers) }
|
6
|
+
let(:headers) { %w[animal weight cuteness fanciness] }
|
7
|
+
let(:rows) { [
|
8
|
+
['Cats', 15.0, 97, true],
|
9
|
+
['Dogs', 88.2, 45, false] # dogs aren't very fancy animals...
|
10
|
+
] }
|
11
|
+
|
12
|
+
before(:each) {
|
13
|
+
rows.each { |row| summary << row }
|
14
|
+
}
|
15
|
+
|
16
|
+
it "counts all the objects it has summarized" do
|
17
|
+
expect(summary.count).to eq 2
|
18
|
+
end
|
19
|
+
|
20
|
+
it "allows appending multiple rows" do
|
21
|
+
summary << rows[0] << rows[1]
|
22
|
+
expect(summary.count).to eq 4
|
23
|
+
end
|
24
|
+
|
25
|
+
describe "summing" do
|
26
|
+
it "handles whole numbers" do
|
27
|
+
expect(summary.sum :cuteness).to eq 142
|
28
|
+
end
|
29
|
+
|
30
|
+
it "handles decimal places" do
|
31
|
+
expect(summary.sum :weight).to eq 103.2
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "averaging" do
|
36
|
+
it "handles whole numbers" do
|
37
|
+
expect(summary.average :cuteness).to eq 71
|
38
|
+
end
|
39
|
+
|
40
|
+
it "handles decimal places" do
|
41
|
+
expect(summary.average :weight).to eq 51.6
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "operating on a header that doesn't exist" do
|
46
|
+
it "raises an helpful error" do
|
47
|
+
expect { summary.sum :fluffikins }.to raise_error(Dossier::Segment::Summary::HeaderError, /no such header 'fluffikins'/i)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Dossier::Segment do
|
4
|
+
let(:report_class) { Class.new(Dossier::Report) }
|
5
|
+
let!(:segmenter_class) {
|
6
|
+
report_class.segmenter_class.tap { |sc|
|
7
|
+
sc.segment :foo
|
8
|
+
}
|
9
|
+
}
|
10
|
+
let(:definition) { segmenter_class.segments.first }
|
11
|
+
let(:report) { report_class.new }
|
12
|
+
let(:segmenter) { report.segmenter }
|
13
|
+
let(:segment) { described_class.new(segmenter, definition) }
|
14
|
+
|
15
|
+
describe "attributes" do
|
16
|
+
it "takes the segment instance" do
|
17
|
+
expect(segment.segmenter).to eq segmenter
|
18
|
+
end
|
19
|
+
|
20
|
+
it "takes its definition" do
|
21
|
+
expect(segment.definition).to eq definition
|
22
|
+
end
|
23
|
+
|
24
|
+
it "has access to the report" do
|
25
|
+
expect(segment.report).to eq report
|
26
|
+
end
|
27
|
+
|
28
|
+
it "delegates headers to its segmenter" do
|
29
|
+
segment.segmenter.should_receive(:headers)
|
30
|
+
segment.headers
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
describe CuteAnimalsReport do
|
36
|
+
describe "key paths" do
|
37
|
+
let(:report) { CuteAnimalsReport.new }
|
38
|
+
let!(:segmenter) { report.segmenter }
|
39
|
+
let(:families) { segmenter.families }
|
40
|
+
let(:domestics) { families.map(&:domestics).flatten }
|
41
|
+
let(:groups) { domestics.map(&:groups).flatten }
|
42
|
+
let(:rows) { groups.map(&:rows).map(&:to_a) }
|
43
|
+
let(:rows) { groups.inject([]) { |a,g| a += g.rows.to_a } }
|
44
|
+
|
45
|
+
describe "select the correct amount of segments" do
|
46
|
+
|
47
|
+
it "has the right amount of families" do
|
48
|
+
expect(families.count).to eq 2
|
49
|
+
end
|
50
|
+
|
51
|
+
it "has the right amount of domestics" do
|
52
|
+
expect(domestics.count).to eq 4
|
53
|
+
end
|
54
|
+
|
55
|
+
it "has the right amount of groups" do
|
56
|
+
expect(groups.count).to eq 6
|
57
|
+
end
|
58
|
+
|
59
|
+
it "has the right amount of rows" do
|
60
|
+
expect(rows.count).to eq CuteAnimalsReport::ROWS.call.count
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe "summarizing" do
|
65
|
+
before(:each) { rows }
|
66
|
+
|
67
|
+
it "properly sums all rows under family" do
|
68
|
+
expect(families.first.summary.sum(:gifs).to_s).to eq '4154.0'
|
69
|
+
end
|
70
|
+
|
71
|
+
it "properly sums all rows under domestic" do
|
72
|
+
expect(domestics.first.summary.sum(:gifs).to_s).to eq '2755.0'
|
73
|
+
end
|
74
|
+
|
75
|
+
it "property sums all the rows under group" do
|
76
|
+
expect(groups.first.summary.sum(:gifs).to_s).to eq '1364.0'
|
77
|
+
end
|
78
|
+
|
79
|
+
describe "segment chains" do
|
80
|
+
let(:family_definition) { segmenter.segment_chain[0] }
|
81
|
+
let(:domestic_definition) { segmenter.segment_chain[1] }
|
82
|
+
let(:group_definition) { segmenter.segment_chain[2] }
|
83
|
+
let(:family) { described_class.new(segmenter, family_definition, {family: 'feline'}) }
|
84
|
+
let(:domestic) { described_class.new(segmenter, domestic_definition, {domestic: true} ).tap { |s| s.parent = family } }
|
85
|
+
let(:group) { described_class.new(segmenter, group_definition, {group_id: 25} ).tap { |s| s.parent = domestic } }
|
86
|
+
|
87
|
+
it "has all three elements in the chain from the bottom" do
|
88
|
+
expect(group.chain).to eq [group, domestic, family]
|
89
|
+
end
|
90
|
+
|
91
|
+
it "has two elements in the chain from the middle" do
|
92
|
+
expect(domestic.chain).to eq [domestic, family]
|
93
|
+
end
|
94
|
+
|
95
|
+
it "has one element in the chain from the top" do
|
96
|
+
expect(family.chain).to eq [family]
|
97
|
+
end
|
98
|
+
|
99
|
+
it "does not replicate elements in the chain when accessed again" do
|
100
|
+
family.chain
|
101
|
+
expect(family.chain.length).to eq 1
|
102
|
+
end
|
103
|
+
|
104
|
+
describe "key paths" do
|
105
|
+
it "works from the bottom" do
|
106
|
+
expect(group.key_path).to eq 'feline.true.25'
|
107
|
+
end
|
108
|
+
|
109
|
+
it "works from the middle" do
|
110
|
+
expect(domestic.key_path).to eq 'feline.true'
|
111
|
+
end
|
112
|
+
|
113
|
+
it "works from the top" do
|
114
|
+
expect(family.key_path).to eq 'feline'
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|