active_reporting 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|