active_reporting 0.6.1 → 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.
@@ -2,22 +2,64 @@
2
2
 
3
3
  module ActiveReporting
4
4
  module Configuration
5
- class << self
6
- # Determines if ransack is available for use in the gem
7
- #
8
- # @return [Boolean]
9
- attr_accessor :ransack_available
10
- end
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
- # The default label used by all dimensions if not set otherwise
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
- # Default value is `:name`
19
- def self.default_dimension_label
20
- @default_dimension_label ||= :name
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
- @default_dimension_label = dimension_label.to_sym
70
+ @@default_dimension_label = dimension_label.to_sym
29
71
  end
30
72
 
31
- # The default measture for all fact models
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
- @default_measure = measure.to_sym
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 unless ransack_available
60
- @ransack_fallback = fallback
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 `Reporting::Metric`
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
- @metric_lookup_class = "::#{klass_name.to_s.classify}"
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 model [ActiveRecord::Base]
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
- # Determins the type of the dimension
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 ||= !klass.fact_model.hierarchical_levels.empty?
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 [Boolean]
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 dimension
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 ||= model.reflect_on_association(@name)
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 to link to this fact model.
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
- @model.instance_variable_set('@fact_model', self)
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 call back for a given dimension label. The returned value of
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 [Stirng, Symbol] The name of the dimension filter
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
- dm = @dimension_filters[name.to_sym]
171
- return dm if dm.present?
172
- return @dimension_filters[name.to_sym] = DimensionFilter.build(name, :ransack) if ransack_fallback
173
- raise UnknownDimensionFilter, "Dimension filter '#{name}' not found on fact model '#{self.name}'"
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,8 +6,10 @@ module ActiveReporting
6
6
 
7
7
  class Metric
8
8
  extend Forwardable
9
+
9
10
  def_delegators :@fact_model, :model
10
- attr_reader :fact_model, :name, :dimensions, :dimension_filter, :aggregate, :metric_filter, :order_by_dimension
11
+ attr_reader :fact_model, :name, :dimensions, :dimension_filter, :aggregate
12
+ attr_reader :metric_filter, :order_by_dimension, :measure
11
13
 
12
14
  def initialize(
13
15
  name,
@@ -16,15 +18,17 @@ module ActiveReporting
16
18
  dimensions: [],
17
19
  dimension_filter: {},
18
20
  metric_filter: {},
19
- order_by_dimension: {}
21
+ order_by_dimension: {},
22
+ measure: nil
20
23
  )
21
24
  @name = name.to_sym
22
25
  @fact_model = fact_model
23
26
  @dimension_filter = dimension_filter
24
- @aggregate = determin_aggregate(aggregate.to_sym)
25
- @metric_filter = metric_filter
27
+ @aggregate = validate_aggregate(aggregate.to_sym)
28
+ @metric_filter = Report.normalize_metric_filter(metric_filter)
26
29
  @dimensions = ReportingDimension.build_from_dimensions(@fact_model, Array(dimensions))
27
30
  @order_by_dimension = order_by_dimension
31
+ @measure = measure
28
32
  check_dimension_filter
29
33
  end
30
34
 
@@ -38,14 +42,15 @@ module ActiveReporting
38
42
  private ####################################################################
39
43
 
40
44
  def check_dimension_filter
41
- @dimension_filter.each do |name, _|
45
+ @dimension_filter.each_key do |name|
42
46
  @fact_model.find_dimension_filter(name)
43
47
  end
44
48
  end
45
49
 
46
- def determin_aggregate(agg)
50
+ def validate_aggregate(agg)
47
51
  raise UnknownAggregate, "Unknown aggregate '#{agg}'" unless AGGREGATES.include?(agg)
48
- @aggregate = agg
52
+
53
+ agg
49
54
  end
50
55
  end
51
56
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveReporting
4
+ class Railtie < Rails::Railtie
5
+ initializer 'active_reporting.clear_fact_models_on_reload' do |app|
6
+ app.reloader.before_class_unload { ActiveReporting::FactModelRegistry.clear }
7
+ end
8
+ end
9
+ end