active_reporting 0.0.1 → 0.1.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.
- checksums.yaml +4 -4
- data/.codeclimate.yml +15 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +25 -0
- data/.travis.yml +13 -2
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +1 -1
- data/README.md +301 -5
- data/Rakefile +5 -5
- data/active_reporting.gemspec +20 -13
- data/bin/console +3 -3
- data/gemfiles/4.0.gemfile +5 -0
- data/gemfiles/5.0.gemfile +5 -0
- data/lib/active_reporting.rb +39 -2
- data/lib/active_reporting/active_record_adaptor.rb +21 -0
- data/lib/active_reporting/configuration.rb +80 -0
- data/lib/active_reporting/dimension.rb +59 -0
- data/lib/active_reporting/dimension_filter.rb +30 -0
- data/lib/active_reporting/fact_model.rb +170 -0
- data/lib/active_reporting/metric.rb +49 -0
- data/lib/active_reporting/report.rb +149 -0
- data/lib/active_reporting/reporting_dimension.rb +102 -0
- data/lib/active_reporting/version.rb +1 -1
- metadata +78 -8
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
module ActiveReporting
|
3
|
+
AGGREGATES = %i(count sum max min avg).freeze
|
4
|
+
|
5
|
+
class Metric
|
6
|
+
extend Forwardable
|
7
|
+
def_delegators :@fact_model, :model
|
8
|
+
attr_reader :fact_model, :name, :dimensions, :dimension_filter, :aggregate, :metric_filter, :order_by_dimension
|
9
|
+
|
10
|
+
def initialize(
|
11
|
+
name,
|
12
|
+
fact_model:,
|
13
|
+
aggregate: :count,
|
14
|
+
dimensions: [],
|
15
|
+
dimension_filter: {},
|
16
|
+
metric_filter: {},
|
17
|
+
order_by_dimension: {}
|
18
|
+
)
|
19
|
+
@name = name.to_sym
|
20
|
+
@fact_model = fact_model
|
21
|
+
@dimension_filter = dimension_filter
|
22
|
+
@aggregate = determin_aggregate(aggregate.to_sym)
|
23
|
+
@metric_filter = metric_filter
|
24
|
+
@dimensions = ReportingDimension.build_from_dimensions(@fact_model, Array(dimensions))
|
25
|
+
@order_by_dimension = order_by_dimension
|
26
|
+
check_dimension_filter
|
27
|
+
end
|
28
|
+
|
29
|
+
# Builds an ActiveReporting::Report object based on the metric
|
30
|
+
#
|
31
|
+
# @return [ActiveReporting::Report]
|
32
|
+
def report
|
33
|
+
Report.new(self)
|
34
|
+
end
|
35
|
+
|
36
|
+
private ####################################################################
|
37
|
+
|
38
|
+
def check_dimension_filter
|
39
|
+
@dimension_filter.each do |name, _|
|
40
|
+
@fact_model.find_dimension_filter(name)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def determin_aggregate(agg)
|
45
|
+
raise UnknownAggregate, "Unknown aggregate '#{agg}'" unless AGGREGATES.include?(agg)
|
46
|
+
@aggregate = agg
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
module ActiveReporting
|
3
|
+
class Report
|
4
|
+
AGGREGATE_FUNCTION_OPERATORS = {
|
5
|
+
eq: '=',
|
6
|
+
gt: '>',
|
7
|
+
gte: '>=',
|
8
|
+
lt: '<',
|
9
|
+
lte: '<='
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
extend Forwardable
|
13
|
+
def_delegators :@metric, :fact_model, :model
|
14
|
+
|
15
|
+
def initialize(metric, dimension_identifiers: true, dimension_filter: {}, dimensions: [], metric_filter: {})
|
16
|
+
@metric = metric.is_a?(Metric) ? metric : ActiveReporting.fetch_metric(metric)
|
17
|
+
raise UnknownMetric, "Unknown metric #{metric}" if @metric.nil?
|
18
|
+
|
19
|
+
@dimension_identifiers = dimension_identifiers
|
20
|
+
local_dimensions = ReportingDimension.build_from_dimensions(fact_model, Array(dimensions))
|
21
|
+
@dimensions = (@metric.dimensions + local_dimensions).uniq
|
22
|
+
@metric_filter = @metric.metric_filter.merge(metric_filter)
|
23
|
+
@ordering = @metric.order_by_dimension
|
24
|
+
partition_dimension_filters dimension_filter
|
25
|
+
end
|
26
|
+
|
27
|
+
# Builds and executes a query, returning the raw result
|
28
|
+
#
|
29
|
+
# @return [Array]
|
30
|
+
def run
|
31
|
+
@run ||= build_data
|
32
|
+
end
|
33
|
+
|
34
|
+
private ######################################################################
|
35
|
+
|
36
|
+
def build_data
|
37
|
+
@data = model.connection.execute(statement.to_sql).to_a
|
38
|
+
apply_dimension_callbacks
|
39
|
+
@data
|
40
|
+
end
|
41
|
+
|
42
|
+
def partition_dimension_filters(user_dimension_filter)
|
43
|
+
@dimension_filters = { ransack: {}, scope: {}, lambda: {} }
|
44
|
+
user_dimension_filter.merge(@metric.dimension_filter).each do |key, value|
|
45
|
+
dm = fact_model.find_dimension_filter(key.to_sym)
|
46
|
+
@dimension_filters[dm.type][dm] = value
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def statement
|
51
|
+
parts = {
|
52
|
+
select: select_statement,
|
53
|
+
joins: dimension_joins,
|
54
|
+
group: group_by_statement,
|
55
|
+
having: having_statement,
|
56
|
+
order: order_by_statement
|
57
|
+
}
|
58
|
+
|
59
|
+
statement = ([model] + parts.keys).inject do |chain, method|
|
60
|
+
chain.public_send(method, parts[method])
|
61
|
+
end
|
62
|
+
|
63
|
+
statement = process_scope_dimension_filter(statement)
|
64
|
+
statement = process_lambda_dimension_filter(statement)
|
65
|
+
statement = process_ransack_dimension_filter(statement)
|
66
|
+
|
67
|
+
statement
|
68
|
+
end
|
69
|
+
|
70
|
+
def select_statement
|
71
|
+
ss = ["#{select_aggregate} AS #{@metric.name}"]
|
72
|
+
ss += @dimensions.map { |d| d.select_statement(with_identifier: @dimension_identifiers) }
|
73
|
+
ss.flatten
|
74
|
+
end
|
75
|
+
|
76
|
+
def select_aggregate
|
77
|
+
case @metric.aggregate
|
78
|
+
when :count
|
79
|
+
'COUNT(*)'
|
80
|
+
else
|
81
|
+
"#{@metric.aggregate.to_s.upcase}(#{@metric.measure})"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def dimension_joins
|
86
|
+
@dimensions.select { |d| d.type == :standard }.map { |d| d.name.to_sym }
|
87
|
+
end
|
88
|
+
|
89
|
+
def group_by_statement
|
90
|
+
@dimensions.map { |d| d.group_by_statement(with_identifier: @dimension_identifiers) }
|
91
|
+
end
|
92
|
+
|
93
|
+
def process_scope_dimension_filter(chain)
|
94
|
+
@dimension_filters[:scope].each do |dm, args|
|
95
|
+
chain = if [true, 'true'].include?(args)
|
96
|
+
chain.public_send(dm.name)
|
97
|
+
else
|
98
|
+
chain.public_send(dm.name, args)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
chain
|
102
|
+
end
|
103
|
+
|
104
|
+
def process_lambda_dimension_filter(chain)
|
105
|
+
@dimension_filters[:lambda].each do |df, args|
|
106
|
+
chain = if [true, 'true'].include?(args)
|
107
|
+
chain.scoping { model.instance_exec(&df.body) }
|
108
|
+
else
|
109
|
+
chain.scoping { model.instance_exec(args, &df.body) }
|
110
|
+
end
|
111
|
+
end
|
112
|
+
chain
|
113
|
+
end
|
114
|
+
|
115
|
+
def process_ransack_dimension_filter(chain)
|
116
|
+
ransack_hash = {}
|
117
|
+
@dimension_filters[:ransack].each do |dm, value|
|
118
|
+
ransack_hash[dm.name] = value
|
119
|
+
end
|
120
|
+
chain = chain.ransack(ransack_hash).result if ransack_hash.present?
|
121
|
+
chain
|
122
|
+
end
|
123
|
+
|
124
|
+
def having_statement
|
125
|
+
@metric_filter.map do |operator, value|
|
126
|
+
"#{select_aggregate} #{AGGREGATE_FUNCTION_OPERATORS[operator]} #{value.to_f}"
|
127
|
+
end.join(' AND ')
|
128
|
+
end
|
129
|
+
|
130
|
+
def order_by_statement
|
131
|
+
[].tap do |o|
|
132
|
+
@ordering.each do |dimension_key, direction|
|
133
|
+
dim = @dimensions.detect { |d| d.name.to_sym == dimension_key.to_sym }
|
134
|
+
o << dim.order_by_statement(direction: direction) if dim
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def apply_dimension_callbacks
|
140
|
+
@dimensions.each do |dimension|
|
141
|
+
callback = dimension.label_callback
|
142
|
+
next unless callback
|
143
|
+
@data.each do |hash|
|
144
|
+
hash[dimension.name.to_s] = callback.call(hash[dimension.name.to_s])
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
module ActiveReporting
|
3
|
+
class ReportingDimension
|
4
|
+
extend Forwardable
|
5
|
+
def_delegators :@dimension, :name, :type, :klass, :association, :model, :hierarchical?
|
6
|
+
|
7
|
+
def self.build_from_dimensions(fact_model, dimensions)
|
8
|
+
Array(dimensions).map do |dim|
|
9
|
+
dimension_name, label = dim.is_a?(Hash) ? Array(dim).flatten : [dim, nil]
|
10
|
+
found_dimension = fact_model.dimensions[dimension_name.to_sym]
|
11
|
+
if found_dimension.nil?
|
12
|
+
raise UnknownDimension, "Dimension '#{dim}' not found on fact model '#{fact_model}'"
|
13
|
+
end
|
14
|
+
new(found_dimension, label: label)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(dimension, label: nil)
|
19
|
+
@dimension = dimension
|
20
|
+
determine_label(label)
|
21
|
+
end
|
22
|
+
|
23
|
+
# The foreign key to use in queries
|
24
|
+
#
|
25
|
+
# @return [String]
|
26
|
+
def foreign_key
|
27
|
+
association ? association.foreign_key : name
|
28
|
+
end
|
29
|
+
|
30
|
+
# Fragments of a select statement for queries that use the dimension
|
31
|
+
#
|
32
|
+
# @return [Array]
|
33
|
+
def select_statement(with_identifier: true)
|
34
|
+
return [degenerate_fragment] if type == :degenerate
|
35
|
+
|
36
|
+
ss = ["#{label_fragment} AS #{name}"]
|
37
|
+
ss << "#{identifier_fragment} AS #{name}_identifier" if with_identifier
|
38
|
+
ss
|
39
|
+
end
|
40
|
+
|
41
|
+
# Fragments of a group by clause for queries that use the dimension
|
42
|
+
#
|
43
|
+
# @return [Array]
|
44
|
+
def group_by_statement(with_identifier: true)
|
45
|
+
return [degenerate_fragment] if type == :degenerate
|
46
|
+
|
47
|
+
group = [label_fragment]
|
48
|
+
group << identifier_fragment if with_identifier
|
49
|
+
group
|
50
|
+
end
|
51
|
+
|
52
|
+
# Fragment of an order by clause for queries that sort by the dimension
|
53
|
+
#
|
54
|
+
# @return [String]
|
55
|
+
def order_by_statement(direction:)
|
56
|
+
direction = direction.to_s.upcase
|
57
|
+
raise "Ording direction should be 'asc' or 'desc'" unless %w(ASC DESC).include?(direction)
|
58
|
+
return "#{degenerate_fragment} #{direction}" if type == :degenerate
|
59
|
+
"#{label_fragment} #{direction}"
|
60
|
+
end
|
61
|
+
|
62
|
+
# Looks up the dimension label callback for the label
|
63
|
+
#
|
64
|
+
# @return [Lambda, NilClass]
|
65
|
+
def label_callback
|
66
|
+
klass.fact_model.dimension_label_callbacks[@label]
|
67
|
+
end
|
68
|
+
|
69
|
+
private ####################################################################
|
70
|
+
|
71
|
+
def determine_label(label)
|
72
|
+
@label = label.to_sym if label.present? && validate_hierarchical_label(label)
|
73
|
+
@label ||= dimension_fact_model.dimension_label || Configuration.default_dimension_label
|
74
|
+
end
|
75
|
+
|
76
|
+
def validate_hierarchical_label(hierarchical_label)
|
77
|
+
if !hierarchical?
|
78
|
+
raise InvalidDimensionLabel, "#{name} must be hierarchical to use label #{hierarchical_label}"
|
79
|
+
end
|
80
|
+
unless dimension_fact_model.hierarchical_levels.include?(hierarchical_label.to_sym)
|
81
|
+
raise InvalidDimensionLabel, "#{hierarchical_label} is not a hierarchical label in #{name}"
|
82
|
+
end
|
83
|
+
true
|
84
|
+
end
|
85
|
+
|
86
|
+
def degenerate_fragment
|
87
|
+
"#{model.quoted_table_name}.#{name}"
|
88
|
+
end
|
89
|
+
|
90
|
+
def identifier_fragment
|
91
|
+
"#{klass.quoted_table_name}.#{klass.primary_key}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def label_fragment
|
95
|
+
"#{klass.quoted_table_name}.#{@label}"
|
96
|
+
end
|
97
|
+
|
98
|
+
def dimension_fact_model
|
99
|
+
@dimension_fact_model ||= klass.fact_model
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
metadata
CHANGED
@@ -1,29 +1,57 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_reporting
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tony Drake
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2017-04-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 4.2.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 4.2.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activesupport
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 4.2.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 4.2.0
|
13
41
|
- !ruby/object:Gem::Dependency
|
14
42
|
name: bundler
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
16
44
|
requirements:
|
17
|
-
- - "
|
45
|
+
- - ">="
|
18
46
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
47
|
+
version: '0'
|
20
48
|
type: :development
|
21
49
|
prerelease: false
|
22
50
|
version_requirements: !ruby/object:Gem::Requirement
|
23
51
|
requirements:
|
24
|
-
- - "
|
52
|
+
- - ">="
|
25
53
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
54
|
+
version: '0'
|
27
55
|
- !ruby/object:Gem::Dependency
|
28
56
|
name: rake
|
29
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,6 +80,34 @@ dependencies:
|
|
52
80
|
- - "~>"
|
53
81
|
- !ruby/object:Gem::Version
|
54
82
|
version: '5.0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: ransack
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: sqlite3
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
55
111
|
description:
|
56
112
|
email:
|
57
113
|
- t27duck@gmail.com
|
@@ -59,8 +115,12 @@ executables: []
|
|
59
115
|
extensions: []
|
60
116
|
extra_rdoc_files: []
|
61
117
|
files:
|
118
|
+
- ".codeclimate.yml"
|
62
119
|
- ".gitignore"
|
120
|
+
- ".rubocop.yml"
|
63
121
|
- ".travis.yml"
|
122
|
+
- CHANGELOG.md
|
123
|
+
- CODE_OF_CONDUCT.md
|
64
124
|
- Gemfile
|
65
125
|
- LICENSE.txt
|
66
126
|
- README.md
|
@@ -68,7 +128,17 @@ files:
|
|
68
128
|
- active_reporting.gemspec
|
69
129
|
- bin/console
|
70
130
|
- bin/setup
|
131
|
+
- gemfiles/4.0.gemfile
|
132
|
+
- gemfiles/5.0.gemfile
|
71
133
|
- lib/active_reporting.rb
|
134
|
+
- lib/active_reporting/active_record_adaptor.rb
|
135
|
+
- lib/active_reporting/configuration.rb
|
136
|
+
- lib/active_reporting/dimension.rb
|
137
|
+
- lib/active_reporting/dimension_filter.rb
|
138
|
+
- lib/active_reporting/fact_model.rb
|
139
|
+
- lib/active_reporting/metric.rb
|
140
|
+
- lib/active_reporting/report.rb
|
141
|
+
- lib/active_reporting/reporting_dimension.rb
|
72
142
|
- lib/active_reporting/version.rb
|
73
143
|
homepage: https://github.com/t27duck/active_reporting
|
74
144
|
licenses:
|
@@ -90,8 +160,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
160
|
version: '0'
|
91
161
|
requirements: []
|
92
162
|
rubyforge_project:
|
93
|
-
rubygems_version: 2.
|
163
|
+
rubygems_version: 2.6.11
|
94
164
|
signing_key:
|
95
165
|
specification_version: 4
|
96
|
-
summary: OLAP-like functionality
|
166
|
+
summary: Add relational OLAP-like functionality for ActiveRecord
|
97
167
|
test_files: []
|