active_reporting 0.6.2 → 1.0.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/CHANGELOG.md +81 -31
- data/README.md +139 -43
- data/lib/active_reporting/active_record_adaptor.rb +2 -10
- data/lib/active_reporting/configuration.rb +63 -45
- data/lib/active_reporting/datetime_drill.rb +88 -0
- data/lib/active_reporting/dimension.rb +10 -6
- data/lib/active_reporting/fact_model.rb +15 -10
- data/lib/active_reporting/fact_model_registry.rb +51 -0
- data/lib/active_reporting/metric.rb +7 -5
- data/lib/active_reporting/railtie.rb +9 -0
- data/lib/active_reporting/report.rb +73 -34
- data/lib/active_reporting/reporting_dimension.rb +60 -79
- data/lib/active_reporting/version.rb +1 -1
- data/lib/active_reporting.rb +43 -23
- metadata +20 -122
- data/.github/dependabot.yml +0 -11
- data/.github/workflows/tests.yml +0 -150
- data/.gitignore +0 -12
- data/.rubocop.yml +0 -29
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -17
- data/Rakefile +0 -12
- data/active_reporting.gemspec +0 -37
- data/bin/console +0 -15
- data/bin/setup +0 -8
|
@@ -2,22 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
module ActiveReporting
|
|
4
4
|
module Configuration
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
# The default label used by all dimensions if not set otherwise
|
|
6
|
+
#
|
|
7
|
+
# Default value is `:name`
|
|
8
|
+
#
|
|
9
|
+
# @return [Symbol]
|
|
10
|
+
mattr_reader :default_dimension_label, default: :name
|
|
11
|
+
|
|
12
|
+
# The default measure for all fact models
|
|
13
|
+
#
|
|
14
|
+
# Default value is `:value`
|
|
15
|
+
#
|
|
16
|
+
# @return [Symbol]
|
|
17
|
+
mattr_reader :default_measure, default: :value
|
|
18
|
+
|
|
19
|
+
# Tells if unknown dimension filters should always fallback to ransack
|
|
20
|
+
#
|
|
21
|
+
# Default value is `false`
|
|
22
|
+
#
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
mattr_reader :ransack_fallback, default: false
|
|
25
|
+
|
|
26
|
+
# Returns the name of the constant used to lookup prebuilt `ActiveReporting::Metric`
|
|
27
|
+
# objects by name. The constant should define a class method called `#lookup`
|
|
28
|
+
# which can take a string or symbol of the metric name.
|
|
29
|
+
#
|
|
30
|
+
# Default value is `::Metric`
|
|
31
|
+
#
|
|
32
|
+
# @return [String]
|
|
33
|
+
mattr_reader :metric_lookup_class, default: '::Metric'
|
|
11
34
|
|
|
12
35
|
def self.config
|
|
13
36
|
yield self
|
|
14
37
|
end
|
|
15
38
|
|
|
16
|
-
#
|
|
39
|
+
# `mattr_reader` stores values in class variables, so the custom writers below set them directly.
|
|
40
|
+
# Configuration is never included into other classes, so class variable sharing is not a concern.
|
|
41
|
+
# rubocop:disable Style/ClassVars
|
|
42
|
+
|
|
43
|
+
# Determines if ransack is available for use in the gem. Ransack is loaded the first time this is
|
|
44
|
+
# called, so it does not matter whether it is required before or after ActiveReporting.
|
|
17
45
|
#
|
|
18
|
-
#
|
|
19
|
-
def self.
|
|
20
|
-
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def self.ransack_available
|
|
48
|
+
return @@ransack_available if defined?(@@ransack_available)
|
|
49
|
+
|
|
50
|
+
@@ransack_available = begin
|
|
51
|
+
require 'ransack'
|
|
52
|
+
true
|
|
53
|
+
rescue LoadError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Overrides ransack detection
|
|
59
|
+
#
|
|
60
|
+
# @param available [Boolean]
|
|
61
|
+
def self.ransack_available=(available)
|
|
62
|
+
@@ransack_available = available
|
|
21
63
|
end
|
|
22
64
|
|
|
23
65
|
# Sets the default dimension label to be used by all dimensions
|
|
@@ -25,58 +67,34 @@ module ActiveReporting
|
|
|
25
67
|
# @param dimension_label [String, Symbol]
|
|
26
68
|
# @return [Symbol]
|
|
27
69
|
def self.default_dimension_label=(dimension_label)
|
|
28
|
-
|
|
70
|
+
@@default_dimension_label = dimension_label.to_sym
|
|
29
71
|
end
|
|
30
72
|
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# Default value is `:value``
|
|
34
|
-
def self.default_measure
|
|
35
|
-
@default_measure ||= :value
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Sets the default measture to be used by all fact models
|
|
73
|
+
# Sets the default measure to be used by all fact models
|
|
39
74
|
#
|
|
40
75
|
# @param measure [String, Symbol]
|
|
41
76
|
# @return [Symbol]
|
|
42
77
|
def self.default_measure=(measure)
|
|
43
|
-
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Tells if unkown dimension filters should always fallback to ransack
|
|
47
|
-
#
|
|
48
|
-
# Default value is `false`
|
|
49
|
-
#
|
|
50
|
-
# @return [Boolean]
|
|
51
|
-
def self.ransack_fallback
|
|
52
|
-
@ransack_fallback ||= false
|
|
78
|
+
@@default_measure = measure.to_sym
|
|
53
79
|
end
|
|
54
80
|
|
|
55
81
|
# Sets the flag to always fallback to ransack for unknown dimension filters
|
|
82
|
+
#
|
|
56
83
|
# @param fallback [Boolean]
|
|
57
84
|
# @return [Boolean]
|
|
58
85
|
def self.ransack_fallback=(fallback)
|
|
59
|
-
raise RansackNotAvailable
|
|
60
|
-
|
|
86
|
+
raise RansackNotAvailable if fallback && !ransack_available
|
|
87
|
+
|
|
88
|
+
@@ransack_fallback = fallback
|
|
61
89
|
end
|
|
62
90
|
|
|
63
|
-
# Sets the name of the constant used to lookup prebuilt `
|
|
91
|
+
# Sets the name of the constant used to lookup prebuilt `ActiveReporting::Metric`
|
|
64
92
|
# objects by name.
|
|
65
93
|
#
|
|
66
|
-
# @param klass_name [String]
|
|
94
|
+
# @param klass_name [String, Symbol, Class]
|
|
67
95
|
def self.metric_lookup_class=(klass_name)
|
|
68
|
-
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Sets the name of the constant used to lookup prebuilt `Reporting::Metric`
|
|
72
|
-
# objects by name. The constant should define a class method called `#lookup`
|
|
73
|
-
# which can take a string or symbol of the metric name.
|
|
74
|
-
#
|
|
75
|
-
# Default value is ::Metric
|
|
76
|
-
#
|
|
77
|
-
# @returns [String]
|
|
78
|
-
def self.metric_lookup_class
|
|
79
|
-
@metric_lookup_class ||= '::Metric'
|
|
96
|
+
@@metric_lookup_class = "::#{klass_name.to_s.camelize.delete_prefix('::')}"
|
|
80
97
|
end
|
|
98
|
+
# rubocop:enable Style/ClassVars
|
|
81
99
|
end
|
|
82
100
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveReporting
|
|
4
|
+
# Builds the SQL used to drill a datetime column to a given level of a datetime hierarchy
|
|
5
|
+
module DatetimeDrill
|
|
6
|
+
# Adapters that support datetime drills, keyed by the adapter class to look for in the connection's
|
|
7
|
+
# ancestry. Matching on ancestry covers adapters built on top of these, e.g. PostGIS and Trilogy.
|
|
8
|
+
ADAPTERS = {
|
|
9
|
+
'ActiveRecord::ConnectionAdapters::PostgreSQLAdapter' => :postgresql,
|
|
10
|
+
'ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter' => :mysql,
|
|
11
|
+
'ActiveRecord::ConnectionAdapters::SQLite3Adapter' => :sqlite
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The datetime drill flavor for a connection, or nil if datetime drills are not supported for it
|
|
17
|
+
#
|
|
18
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
19
|
+
# @return [Symbol, nil] :postgresql, :mysql, or :sqlite
|
|
20
|
+
def adapter_for(connection)
|
|
21
|
+
connection.class.ancestors.filter_map { |ancestor| ADAPTERS[ancestor.name] }.first
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param adapter [Symbol] as returned by `adapter_for`
|
|
25
|
+
# @param drill [Symbol, String] one of `ReportingDimension::DATETIME_HIERARCHIES`
|
|
26
|
+
# @param column [String] the quoted column to drill
|
|
27
|
+
# @return [String] SQL fragment
|
|
28
|
+
def fragment(adapter, drill, column)
|
|
29
|
+
public_send(adapter, drill, column)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def postgresql(drill, column)
|
|
33
|
+
case drill.to_sym
|
|
34
|
+
when :date
|
|
35
|
+
"DATE(#{column})"
|
|
36
|
+
else
|
|
37
|
+
"DATE_TRUNC('#{drill}', #{column})"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Mirrors Postgres' `date_trunc`, returning the truncated value as a DATETIME. Values are built from
|
|
42
|
+
# formatted strings because casting to a lower fractional precision rounds instead of truncating.
|
|
43
|
+
def mysql(drill, column)
|
|
44
|
+
year = "YEAR(#{column})"
|
|
45
|
+
sql = case drill.to_sym
|
|
46
|
+
when :microseconds then return column
|
|
47
|
+
when :milliseconds
|
|
48
|
+
return "CAST(LEFT(DATE_FORMAT(#{column}, '%Y-%m-%d %H:%i:%s.%f'), 23) AS DATETIME(3))"
|
|
49
|
+
when :second then "DATE_FORMAT(#{column}, '%Y-%m-%d %H:%i:%s')"
|
|
50
|
+
when :minute then "DATE_FORMAT(#{column}, '%Y-%m-%d %H:%i:00')"
|
|
51
|
+
when :hour then "DATE_FORMAT(#{column}, '%Y-%m-%d %H:00:00')"
|
|
52
|
+
when :day then "DATE_FORMAT(#{column}, '%Y-%m-%d 00:00:00')"
|
|
53
|
+
when :week then "DATE_SUB(DATE(#{column}), INTERVAL WEEKDAY(#{column}) DAY)"
|
|
54
|
+
when :month then "DATE_FORMAT(#{column}, '%Y-%m-01 00:00:00')"
|
|
55
|
+
when :quarter then "MAKEDATE(#{year}, 1) + INTERVAL (QUARTER(#{column}) - 1) QUARTER"
|
|
56
|
+
when :year then "MAKEDATE(#{year}, 1)"
|
|
57
|
+
when :decade then "MAKEDATE(#{year} DIV 10 * 10, 1)"
|
|
58
|
+
when :century then "MAKEDATE((#{year} - 1) DIV 100 * 100 + 1, 1)"
|
|
59
|
+
when :millennium then "MAKEDATE((#{year} - 1) DIV 1000 * 1000 + 1, 1)"
|
|
60
|
+
when :date then return "DATE(#{column})"
|
|
61
|
+
end
|
|
62
|
+
"CAST(#{sql} AS DATETIME)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Mirrors Postgres' `date_trunc`, returning the truncated datetime as 'YYYY-MM-DD HH:MM:SS' text
|
|
66
|
+
def sqlite(drill, column)
|
|
67
|
+
year = "CAST(STRFTIME('%Y', #{column}) AS INTEGER)"
|
|
68
|
+
case drill.to_sym
|
|
69
|
+
when :microseconds then column
|
|
70
|
+
# STRFTIME's %f rounds to the nearest millisecond, so truncate the stored fraction directly
|
|
71
|
+
when :milliseconds then "STRFTIME('%Y-%m-%d %H:%M:%S', #{column}) || SUBSTR(#{column} || '.000', 20, 4)"
|
|
72
|
+
when :second then "STRFTIME('%Y-%m-%d %H:%M:%S', #{column})"
|
|
73
|
+
when :minute then "STRFTIME('%Y-%m-%d %H:%M:00', #{column})"
|
|
74
|
+
when :hour then "STRFTIME('%Y-%m-%d %H:00:00', #{column})"
|
|
75
|
+
when :day then "STRFTIME('%Y-%m-%d 00:00:00', #{column})"
|
|
76
|
+
when :week then "STRFTIME('%Y-%m-%d 00:00:00', #{column}, '-6 days', 'weekday 1')"
|
|
77
|
+
when :month then "STRFTIME('%Y-%m-01 00:00:00', #{column})"
|
|
78
|
+
when :quarter
|
|
79
|
+
"PRINTF('%04d-%02d-01 00:00:00', #{year}, (CAST(STRFTIME('%m', #{column}) AS INTEGER) - 1) / 3 * 3 + 1)"
|
|
80
|
+
when :year then "STRFTIME('%Y-01-01 00:00:00', #{column})"
|
|
81
|
+
when :decade then "PRINTF('%04d-01-01 00:00:00', #{year} / 10 * 10)"
|
|
82
|
+
when :century then "PRINTF('%04d-01-01 00:00:00', (#{year} - 1) / 100 * 100 + 1)"
|
|
83
|
+
when :millennium then "PRINTF('%04d-01-01 00:00:00', (#{year} - 1) / 1000 * 1000 + 1)"
|
|
84
|
+
when :date then "DATE(#{column})"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -5,14 +5,14 @@ module ActiveReporting
|
|
|
5
5
|
TYPES = { degenerate: :degenerate, standard: :standard }.freeze
|
|
6
6
|
attr_reader :name
|
|
7
7
|
|
|
8
|
-
# @param
|
|
8
|
+
# @param fact_model [ActiveReporting::FactModel]
|
|
9
9
|
# @param name [Symbol]
|
|
10
10
|
def initialize(fact_model, name:)
|
|
11
11
|
@fact_model = fact_model
|
|
12
12
|
@name = name.to_s
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# Determines the type of the dimension
|
|
16
16
|
#
|
|
17
17
|
# A dimension type is either:
|
|
18
18
|
#
|
|
@@ -34,18 +34,20 @@ module ActiveReporting
|
|
|
34
34
|
#
|
|
35
35
|
# @return [Boolean]
|
|
36
36
|
def hierarchical?
|
|
37
|
-
@hierarchical
|
|
37
|
+
return @hierarchical if defined?(@hierarchical)
|
|
38
|
+
|
|
39
|
+
@hierarchical = !klass.fact_model.hierarchical_levels.empty?
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
# Returns either the model of the dimension's association or the model
|
|
41
43
|
# itself if the dimension lives on the fact model
|
|
42
44
|
#
|
|
43
|
-
# @return [
|
|
45
|
+
# @return [Class]
|
|
44
46
|
def klass
|
|
45
47
|
@klass ||= association ? association.klass : model
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
# Returns the fact model's
|
|
50
|
+
# Returns the fact model's ActiveRecord model
|
|
49
51
|
#
|
|
50
52
|
# @return [ActiveRecord::Base]
|
|
51
53
|
def model
|
|
@@ -56,7 +58,9 @@ module ActiveReporting
|
|
|
56
58
|
#
|
|
57
59
|
# @return [ActiveRecord::Reflection]
|
|
58
60
|
def association
|
|
59
|
-
@association
|
|
61
|
+
return @association if defined?(@association)
|
|
62
|
+
|
|
63
|
+
@association = model.reflect_on_association(@name)
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
end
|
|
@@ -7,7 +7,7 @@ module ActiveReporting
|
|
|
7
7
|
attr_writer :measure
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
# Explicitly sets which ActiveRecord model to
|
|
10
|
+
# Explicitly sets which ActiveRecord model to link to this fact model.
|
|
11
11
|
#
|
|
12
12
|
# @note You should only need to set this if the name of your fact model does not
|
|
13
13
|
# follow the pattern of [MyModel]FactModel
|
|
@@ -27,7 +27,7 @@ module ActiveReporting
|
|
|
27
27
|
else
|
|
28
28
|
model
|
|
29
29
|
end
|
|
30
|
-
|
|
30
|
+
FactModelRegistry.register(@model, self)
|
|
31
31
|
@model
|
|
32
32
|
end
|
|
33
33
|
|
|
@@ -104,7 +104,7 @@ module ActiveReporting
|
|
|
104
104
|
@dimension_label_callbacks ||= {}
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# Sets a
|
|
107
|
+
# Sets a callback for a given dimension label. The returned value of
|
|
108
108
|
# the callable body will be used as the label value when used in a report.
|
|
109
109
|
# The label's raw database value is passed to the callback.
|
|
110
110
|
#
|
|
@@ -113,12 +113,13 @@ module ActiveReporting
|
|
|
113
113
|
def self.dimension_label_callback(column, body)
|
|
114
114
|
@dimension_label_callbacks ||= {}
|
|
115
115
|
raise ArgumentError, 'Dimension label callback body must be a callable object' unless body.respond_to?(:call)
|
|
116
|
+
|
|
116
117
|
@dimension_label_callbacks[column.to_sym] = body
|
|
117
118
|
end
|
|
118
119
|
|
|
119
120
|
# Declares a dimension filter for this fact model
|
|
120
121
|
#
|
|
121
|
-
# @param name [
|
|
122
|
+
# @param name [String, Symbol] The name of the dimension filter
|
|
122
123
|
# @param lambda_or_type [Symbol, Lambda]
|
|
123
124
|
#
|
|
124
125
|
# @note If not provided, the type of the dimension filter will be a scope.
|
|
@@ -127,6 +128,10 @@ module ActiveReporting
|
|
|
127
128
|
# dimension filter is a ransack search term. Finally, you may pass in
|
|
128
129
|
# a callable object similar to defining a scope on ActiveRecord
|
|
129
130
|
#
|
|
131
|
+
# @note A report applies a scope, or a lambda without parameters, for `true` and leaves it out for
|
|
132
|
+
# `false`. A lambda with parameters is always passed the report's value. See "Dimension filter values"
|
|
133
|
+
# in the README.
|
|
134
|
+
#
|
|
130
135
|
# @example
|
|
131
136
|
# class PostFactModel < ActiveReporting::FactModel
|
|
132
137
|
# # Assumes there's an `active` scope on the model
|
|
@@ -149,6 +154,7 @@ module ActiveReporting
|
|
|
149
154
|
unless Configuration.ransack_available
|
|
150
155
|
raise RansackNotAvailable, 'Ransack not available. Please include it in your Gemfile.'
|
|
151
156
|
end
|
|
157
|
+
|
|
152
158
|
@ransack_fallback = true
|
|
153
159
|
end
|
|
154
160
|
|
|
@@ -156,7 +162,6 @@ module ActiveReporting
|
|
|
156
162
|
#
|
|
157
163
|
# @return [Boolean]
|
|
158
164
|
def self.ransack_fallback
|
|
159
|
-
@ransack_fallback ||= false
|
|
160
165
|
@ransack_fallback || Configuration.ransack_fallback
|
|
161
166
|
end
|
|
162
167
|
private_class_method :ransack_fallback
|
|
@@ -166,11 +171,11 @@ module ActiveReporting
|
|
|
166
171
|
# @param name [Symbol]
|
|
167
172
|
# @return [ActiveReporting::DimensionFilter]
|
|
168
173
|
def self.find_dimension_filter(name)
|
|
169
|
-
@dimension_filters
|
|
170
|
-
|
|
171
|
-
return
|
|
172
|
-
|
|
173
|
-
raise UnknownDimensionFilter, "Dimension filter '#{name}' not found on fact model '#{self
|
|
174
|
+
dimension_filter = @dimension_filters&.fetch(name.to_sym, nil)
|
|
175
|
+
return dimension_filter if dimension_filter
|
|
176
|
+
return DimensionFilter.build(name, :ransack) if ransack_fallback
|
|
177
|
+
|
|
178
|
+
raise UnknownDimensionFilter, "Dimension filter '#{name}' not found on fact model '#{self}'"
|
|
174
179
|
end
|
|
175
180
|
end
|
|
176
181
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent/map'
|
|
4
|
+
|
|
5
|
+
module ActiveReporting
|
|
6
|
+
# Maps ActiveRecord models to their fact models.
|
|
7
|
+
#
|
|
8
|
+
# Lookups are cached here instead of on the model classes so the cache can be cleared when Rails
|
|
9
|
+
# reloads code (see ActiveReporting::Railtie). Otherwise a model could keep returning a fact model
|
|
10
|
+
# from before the reload, or a fact model could keep pointing to a model from before the reload.
|
|
11
|
+
module FactModelRegistry
|
|
12
|
+
@fact_models = Concurrent::Map.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Returns the fact model for an ActiveRecord model: the `[MyModel]FactModel` class if one is
|
|
16
|
+
# defined, otherwise a generated fact model linked to the model.
|
|
17
|
+
#
|
|
18
|
+
# @param model [Class] an ActiveRecord model
|
|
19
|
+
# @return [Class] an ActiveReporting::FactModel subclass
|
|
20
|
+
def fetch(model)
|
|
21
|
+
@fact_models.fetch_or_store(model) { "#{model.name}FactModel".safe_constantize || generate(model) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Links an ActiveRecord model to a fact model
|
|
25
|
+
#
|
|
26
|
+
# @param model [Class] an ActiveRecord model
|
|
27
|
+
# @param fact_model [Class] an ActiveReporting::FactModel subclass
|
|
28
|
+
def register(model, fact_model)
|
|
29
|
+
@fact_models[model] = fact_model
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Forgets all looked up and registered fact models
|
|
33
|
+
def clear
|
|
34
|
+
@fact_models.clear
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Generated fact models are not assigned to a constant, so they never outlive a code reload or
|
|
40
|
+
# stop a `[MyModel]FactModel` defined later from being autoloaded.
|
|
41
|
+
def generate(model)
|
|
42
|
+
label = "#{model.name}FactModel (generated)"
|
|
43
|
+
Class.new(FactModel) do
|
|
44
|
+
define_singleton_method(:to_s) { label }
|
|
45
|
+
define_singleton_method(:inspect) { label }
|
|
46
|
+
self.model = model
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -6,6 +6,7 @@ module ActiveReporting
|
|
|
6
6
|
|
|
7
7
|
class Metric
|
|
8
8
|
extend Forwardable
|
|
9
|
+
|
|
9
10
|
def_delegators :@fact_model, :model
|
|
10
11
|
attr_reader :fact_model, :name, :dimensions, :dimension_filter, :aggregate
|
|
11
12
|
attr_reader :metric_filter, :order_by_dimension, :measure
|
|
@@ -23,8 +24,8 @@ module ActiveReporting
|
|
|
23
24
|
@name = name.to_sym
|
|
24
25
|
@fact_model = fact_model
|
|
25
26
|
@dimension_filter = dimension_filter
|
|
26
|
-
@aggregate =
|
|
27
|
-
@metric_filter = metric_filter
|
|
27
|
+
@aggregate = validate_aggregate(aggregate.to_sym)
|
|
28
|
+
@metric_filter = Report.normalize_metric_filter(metric_filter)
|
|
28
29
|
@dimensions = ReportingDimension.build_from_dimensions(@fact_model, Array(dimensions))
|
|
29
30
|
@order_by_dimension = order_by_dimension
|
|
30
31
|
@measure = measure
|
|
@@ -41,14 +42,15 @@ module ActiveReporting
|
|
|
41
42
|
private ####################################################################
|
|
42
43
|
|
|
43
44
|
def check_dimension_filter
|
|
44
|
-
@dimension_filter.
|
|
45
|
+
@dimension_filter.each_key do |name|
|
|
45
46
|
@fact_model.find_dimension_filter(name)
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
|
|
49
|
-
def
|
|
50
|
+
def validate_aggregate(agg)
|
|
50
51
|
raise UnknownAggregate, "Unknown aggregate '#{agg}'" unless AGGREGATES.include?(agg)
|
|
51
|
-
|
|
52
|
+
|
|
53
|
+
agg
|
|
52
54
|
end
|
|
53
55
|
end
|
|
54
56
|
end
|
|
@@ -4,14 +4,32 @@ require 'forwardable'
|
|
|
4
4
|
module ActiveReporting
|
|
5
5
|
class Report
|
|
6
6
|
AGGREGATE_FUNCTION_OPERATORS = {
|
|
7
|
-
eq:
|
|
8
|
-
gt:
|
|
9
|
-
gte:
|
|
10
|
-
lt:
|
|
11
|
-
lte:
|
|
7
|
+
eq: '=',
|
|
8
|
+
gt: '>',
|
|
9
|
+
gte: '>=',
|
|
10
|
+
lt: '<',
|
|
11
|
+
lte: '<='
|
|
12
12
|
}.freeze
|
|
13
13
|
|
|
14
|
+
DIMENSION_FILTER_ON_VALUES = [true, 'true'].freeze
|
|
15
|
+
DIMENSION_FILTER_OFF_VALUES = [false, 'false'].freeze
|
|
16
|
+
|
|
14
17
|
extend Forwardable
|
|
18
|
+
|
|
19
|
+
# Converts a metric filter's operators to symbols, raising if any operator is not supported
|
|
20
|
+
#
|
|
21
|
+
# @param metric_filter [Hash] operator (e.g. `:gt` or `'gt'`) to value
|
|
22
|
+
# @return [Hash]
|
|
23
|
+
def self.normalize_metric_filter(metric_filter)
|
|
24
|
+
metric_filter.to_h.transform_keys do |operator|
|
|
25
|
+
next operator.to_sym if AGGREGATE_FUNCTION_OPERATORS.key?(operator.to_s.to_sym)
|
|
26
|
+
|
|
27
|
+
raise UnknownMetricFilterOperator,
|
|
28
|
+
"Unknown metric filter operator '#{operator}'. Valid operators are: " \
|
|
29
|
+
"#{AGGREGATE_FUNCTION_OPERATORS.keys.join(', ')}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
15
33
|
def_delegators :@metric, :fact_model, :model
|
|
16
34
|
|
|
17
35
|
def initialize(metric, dimension_identifiers: true, dimension_filter: {}, dimensions: [], metric_filter: {})
|
|
@@ -21,9 +39,10 @@ module ActiveReporting
|
|
|
21
39
|
@dimension_identifiers = dimension_identifiers
|
|
22
40
|
local_dimensions = ReportingDimension.build_from_dimensions(fact_model, Array(dimensions))
|
|
23
41
|
@dimensions = (@metric.dimensions + local_dimensions).uniq
|
|
24
|
-
|
|
25
|
-
@
|
|
42
|
+
# The report's metric filter replaces the metric's, while the metric's dimension filters can't be overridden
|
|
43
|
+
@metric_filter = @metric.metric_filter.merge(Report.normalize_metric_filter(metric_filter))
|
|
26
44
|
partition_dimension_filters dimension_filter
|
|
45
|
+
@order_by = build_order_by
|
|
27
46
|
end
|
|
28
47
|
|
|
29
48
|
# Builds and executes a query, returning the raw result
|
|
@@ -36,13 +55,14 @@ module ActiveReporting
|
|
|
36
55
|
private ######################################################################
|
|
37
56
|
|
|
38
57
|
def build_data
|
|
39
|
-
@data = model.connection.
|
|
58
|
+
@data = model.connection.select_all(statement, 'ActiveReporting').to_a
|
|
40
59
|
apply_dimension_callbacks
|
|
41
60
|
@data
|
|
42
61
|
end
|
|
43
62
|
|
|
44
63
|
def partition_dimension_filters(user_dimension_filter)
|
|
45
64
|
@dimension_filters = { ransack: {}, scope: {}, lambda: {} }
|
|
65
|
+
# The metric's dimension filters are part of its definition, so they take precedence over the report's
|
|
46
66
|
user_dimension_filter.merge(@metric.dimension_filter).each do |key, value|
|
|
47
67
|
dm = fact_model.find_dimension_filter(key.to_sym)
|
|
48
68
|
@dimension_filters[dm.type][dm] = value
|
|
@@ -56,7 +76,7 @@ module ActiveReporting
|
|
|
56
76
|
left_outer_joins: dimension_joins(ReportingDimension::JOIN_METHODS[:left_outer_joins]),
|
|
57
77
|
group: group_by_statement,
|
|
58
78
|
having: having_statement,
|
|
59
|
-
order:
|
|
79
|
+
order: @order_by
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
statement = ([model] + parts.keys).inject do |chain, method|
|
|
@@ -65,13 +85,11 @@ module ActiveReporting
|
|
|
65
85
|
|
|
66
86
|
statement = process_scope_dimension_filter(statement)
|
|
67
87
|
statement = process_lambda_dimension_filter(statement)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
statement
|
|
88
|
+
process_ransack_dimension_filter(statement)
|
|
71
89
|
end
|
|
72
90
|
|
|
73
91
|
def select_statement
|
|
74
|
-
ss = ["#{select_aggregate} AS #{@metric.name}"]
|
|
92
|
+
ss = ["#{select_aggregate} AS #{model.connection.quote_column_name(@metric.name)}"]
|
|
75
93
|
ss += @dimensions.map { |d| d.select_statement(with_identifier: @dimension_identifiers) }
|
|
76
94
|
ss.flatten
|
|
77
95
|
end
|
|
@@ -81,40 +99,52 @@ module ActiveReporting
|
|
|
81
99
|
when :count
|
|
82
100
|
'COUNT(*)'
|
|
83
101
|
else
|
|
84
|
-
"#{@metric.aggregate.to_s.upcase}(#{
|
|
102
|
+
"#{@metric.aggregate.to_s.upcase}(#{measure_fragment})"
|
|
85
103
|
end
|
|
86
104
|
end
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
# The measure as a column qualified by the fact model's table, so it is never ambiguous with a column on
|
|
107
|
+
# a joined dimension. A measure given as `Arel.sql(...)` is used as is, allowing SQL expressions.
|
|
108
|
+
def measure_fragment
|
|
109
|
+
measure = @metric.measure || fact_model.measure
|
|
110
|
+
return measure if measure.is_a?(Arel::Nodes::SqlLiteral)
|
|
111
|
+
|
|
112
|
+
"#{model.quoted_table_name}.#{model.connection.quote_column_name(measure)}"
|
|
90
113
|
end
|
|
91
114
|
|
|
92
115
|
def dimension_joins(join_method)
|
|
93
|
-
@dimensions.select { |d| d.type == Dimension::TYPES[:standard] && d.join_method == join_method }
|
|
94
|
-
|
|
116
|
+
@dimensions.select { |d| d.type == Dimension::TYPES[:standard] && d.join_method == join_method }
|
|
117
|
+
.map { |d| d.name.to_sym }
|
|
95
118
|
end
|
|
96
119
|
|
|
97
120
|
def group_by_statement
|
|
98
121
|
@dimensions.map { |d| d.group_by_statement(with_identifier: @dimension_identifiers) }
|
|
99
122
|
end
|
|
100
123
|
|
|
124
|
+
# Rails defines scopes methods taking any arguments, so it can't be known if a scope takes an argument.
|
|
101
125
|
def process_scope_dimension_filter(chain)
|
|
102
|
-
@dimension_filters[:scope].each do |
|
|
103
|
-
|
|
104
|
-
|
|
126
|
+
@dimension_filters[:scope].each do |df, value|
|
|
127
|
+
next if DIMENSION_FILTER_OFF_VALUES.include?(value)
|
|
128
|
+
|
|
129
|
+
chain = if DIMENSION_FILTER_ON_VALUES.include?(value)
|
|
130
|
+
chain.public_send(df.name)
|
|
105
131
|
else
|
|
106
|
-
chain.public_send(
|
|
132
|
+
chain.public_send(df.name, value)
|
|
107
133
|
end
|
|
108
134
|
end
|
|
109
135
|
chain
|
|
110
136
|
end
|
|
111
137
|
|
|
138
|
+
# A lambda without parameters is a toggle: it is applied unless its value is `false`. A lambda with
|
|
139
|
+
# parameters is always passed the value, so it can take `true` and `false` as well.
|
|
112
140
|
def process_lambda_dimension_filter(chain)
|
|
113
|
-
@dimension_filters[:lambda].each do |df,
|
|
114
|
-
chain = if
|
|
115
|
-
chain.scoping { model.instance_exec(&df.body) }
|
|
141
|
+
@dimension_filters[:lambda].each do |df, value|
|
|
142
|
+
chain = if df.body.arity.nonzero?
|
|
143
|
+
chain.scoping { model.instance_exec(value, &df.body) }
|
|
144
|
+
elsif DIMENSION_FILTER_OFF_VALUES.include?(value)
|
|
145
|
+
chain
|
|
116
146
|
else
|
|
117
|
-
chain.scoping { model.instance_exec(
|
|
147
|
+
chain.scoping { model.instance_exec(&df.body) }
|
|
118
148
|
end
|
|
119
149
|
end
|
|
120
150
|
chain
|
|
@@ -125,8 +155,12 @@ module ActiveReporting
|
|
|
125
155
|
@dimension_filters[:ransack].each do |dm, value|
|
|
126
156
|
ransack_hash[dm.name] = value
|
|
127
157
|
end
|
|
128
|
-
|
|
129
|
-
|
|
158
|
+
return chain if ransack_hash.empty?
|
|
159
|
+
unless Configuration.ransack_available
|
|
160
|
+
raise RansackNotAvailable, 'Ransack not available. Please include it in your Gemfile.'
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
chain.ransack(ransack_hash).result
|
|
130
164
|
end
|
|
131
165
|
|
|
132
166
|
def having_statement
|
|
@@ -135,12 +169,16 @@ module ActiveReporting
|
|
|
135
169
|
end.join(' AND ')
|
|
136
170
|
end
|
|
137
171
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
@
|
|
141
|
-
|
|
142
|
-
|
|
172
|
+
def build_order_by
|
|
173
|
+
@metric.order_by_dimension.map do |dimension_key, direction|
|
|
174
|
+
dim = @dimensions.detect { |d| d.name.to_sym == dimension_key.to_sym }
|
|
175
|
+
if dim.nil?
|
|
176
|
+
raise UnknownDimension,
|
|
177
|
+
"Cannot order by '#{dimension_key}'; it is not one of the report's dimensions " \
|
|
178
|
+
"(#{@dimensions.map(&:name).join(', ')})"
|
|
143
179
|
end
|
|
180
|
+
|
|
181
|
+
dim.order_by_statement(direction: direction)
|
|
144
182
|
end
|
|
145
183
|
end
|
|
146
184
|
|
|
@@ -148,7 +186,8 @@ module ActiveReporting
|
|
|
148
186
|
@dimensions.each do |dimension|
|
|
149
187
|
callback = dimension.label_callback
|
|
150
188
|
next unless callback
|
|
151
|
-
|
|
189
|
+
|
|
190
|
+
key = dimension.label_name.to_s
|
|
152
191
|
@data.each do |hash|
|
|
153
192
|
hash[key] = callback.call(hash[key])
|
|
154
193
|
end
|