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.
@@ -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,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 = determin_aggregate(aggregate.to_sym)
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.each do |name, _|
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 determin_aggregate(agg)
50
+ def validate_aggregate(agg)
50
51
  raise UnknownAggregate, "Unknown aggregate '#{agg}'" unless AGGREGATES.include?(agg)
51
- @aggregate = agg
52
+
53
+ agg
52
54
  end
53
55
  end
54
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
@@ -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
- @metric_filter = @metric.metric_filter.merge(metric_filter)
25
- @ordering = @metric.order_by_dimension
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.exec_query(statement.to_sql).to_a
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: order_by_statement
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
- statement = process_ransack_dimension_filter(statement)
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}(#{measure})"
102
+ "#{@metric.aggregate.to_s.upcase}(#{measure_fragment})"
85
103
  end
86
104
  end
87
105
 
88
- def measure
89
- @metric.measure || fact_model.measure
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
- map { |d| d.name.to_sym }
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 |dm, args|
103
- chain = if [true, 'true'].include?(args)
104
- chain.public_send(dm.name)
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(dm.name, args)
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, args|
114
- chain = if [true, 'true'].include?(args)
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(args, &df.body) }
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
- chain = chain.ransack(ransack_hash).result if ransack_hash.present?
129
- chain
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 order_by_statement
139
- [].tap do |o|
140
- @ordering.each do |dimension_key, direction|
141
- dim = @dimensions.detect { |d| d.name.to_sym == dimension_key.to_sym }
142
- o << dim.order_by_statement(direction: direction) if dim
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
- key = "#{dimension.name}_#{dimension.label}"
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