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.
@@ -4,13 +4,17 @@ require 'forwardable'
4
4
  module ActiveReporting
5
5
  class ReportingDimension
6
6
  extend Forwardable
7
- SUPPORTED_DBS = %w[PostgreSQL PostGIS Mysql2].freeze
7
+
8
8
  # Values for the Postgres `date_trunc` method.
9
- # See https://www.postgresql.org/docs/10/static/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC
9
+ # See https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC
10
10
  DATETIME_HIERARCHIES = %i[microseconds milliseconds second minute hour day week month quarter year decade
11
11
  century millennium date].freeze
12
+ # Column types a datetime drill can be used on. PostgreSQL reports `timestamp with time zone` columns as
13
+ # `timestamptz`, and `timestamp without time zone` columns as `timestamp` when its `datetime_type` is
14
+ # `:timestamptz`.
15
+ DATETIME_DRILL_COLUMN_TYPES = %i[datetime timestamp timestamptz date].freeze
12
16
  JOIN_METHODS = { joins: :joins, left_outer_joins: :left_outer_joins }.freeze
13
- attr_reader :join_method, :label
17
+ attr_reader :join_method, :label, :label_name
14
18
 
15
19
  def_delegators :@dimension, :name, :type, :klass, :association, :model, :hierarchical?
16
20
 
@@ -19,15 +23,17 @@ module ActiveReporting
19
23
  dimension_name, options = dim.is_a?(Hash) ? Array(dim).flatten : [dim, nil]
20
24
  found_dimension = fact_model.dimensions[dimension_name.to_sym]
21
25
 
22
- raise(UnknownDimension, "Dimension '#{dimension_name}' not found on fact model '#{fact_model}'") if found_dimension.nil?
26
+ if found_dimension.nil?
27
+ raise(UnknownDimension,
28
+ "Dimension '#{dimension_name}' not found on fact model '#{fact_model}'")
29
+ end
23
30
 
24
- # Ambiguous behavior with string option for degenerate and standard dimension
25
- if !options.is_a?(Hash) && found_dimension.type == Dimension::TYPES[:degenerate]
26
- ActiveSupport::Deprecation.warn <<~EOS
27
- direct use of implict hierarchies is deprecated and will be removed in future versions. \
28
- Please use `:datetime_drill` option instead.
29
- EOS
30
- options = { datetime_drill: options }
31
+ # The `{ created_at: :month }` shorthand for datetime drills was removed in 0.7.0
32
+ if options && !options.is_a?(Hash) && found_dimension.type == Dimension::TYPES[:degenerate]
33
+ raise InvalidDimensionLabel,
34
+ "'#{dimension_name}' is not hierarchical. For a datetime drill, use " \
35
+ "`{ #{dimension_name}: { datetime_drill: :#{options} } }` " \
36
+ "instead of `{ #{dimension_name}: :#{options} }`"
31
37
  end
32
38
  new(found_dimension, **label_config(options))
33
39
  end
@@ -39,9 +45,7 @@ module ActiveReporting
39
45
  #
40
46
  # @param [Symbol|Hash] options
41
47
  def self.label_config(options)
42
- unless options.is_a?(Hash)
43
- return { label: options }
44
- end
48
+ return { label: options } unless options.is_a?(Hash)
45
49
 
46
50
  {
47
51
  label: options[:field],
@@ -75,7 +79,7 @@ module ActiveReporting
75
79
  # @return [Array]
76
80
  def select_statement(with_identifier: true)
77
81
  ss = ["#{label_fragment} AS #{label_fragment_alias}"]
78
- ss << "#{identifier_fragment} AS #{identifier_fragment_alias}" if with_identifier && type == Dimension::TYPES[:standard]
82
+ ss << "#{identifier_fragment} AS #{identifier_fragment_alias}" if with_identifier && identifiable?
79
83
  ss
80
84
  end
81
85
 
@@ -84,7 +88,7 @@ module ActiveReporting
84
88
  # @return [Array]
85
89
  def group_by_statement(with_identifier: true)
86
90
  group = [label_fragment]
87
- group << identifier_fragment if with_identifier && type == Dimension::TYPES[:standard]
91
+ group << identifier_fragment if with_identifier && identifiable?
88
92
  group
89
93
  end
90
94
 
@@ -93,36 +97,52 @@ module ActiveReporting
93
97
  # @return [String]
94
98
  def order_by_statement(direction:)
95
99
  direction = direction.to_s.upcase
96
- raise "Ording direction should be 'asc' or 'desc'" unless %w[ASC DESC].include?(direction)
100
+ unless %w[ASC DESC].include?(direction)
101
+ raise InvalidOrderDirection,
102
+ "Ordering direction for #{name} should be 'asc' or 'desc', got '#{direction.downcase}'"
103
+ end
104
+
97
105
  "#{label_fragment} #{direction}"
98
106
  end
99
107
 
108
+ # Tells if the dimension's identifier is included in queries when the report asks for identifiers.
109
+ #
110
+ # Only a standard dimension labeled by its default label has one row per record. Grouping a dimension
111
+ # drilled to a hierarchy level or datetime by its identifier as well would split each roll-up back into one
112
+ # row per record.
113
+ #
114
+ # @return [Boolean]
115
+ def identifiable?
116
+ type == Dimension::TYPES[:standard] && @datetime_drill.nil? && @label == dimension_fact_model.dimension_label
117
+ end
118
+
100
119
  # Looks up the dimension label callback for the label
101
120
  #
102
121
  # @return [Lambda, NilClass]
103
122
  def label_callback
104
- klass.fact_model.dimension_label_callbacks[@label]
123
+ klass.fact_model.dimension_label_callbacks[@label.to_sym]
105
124
  end
106
125
 
107
126
  private ####################################################################
108
127
 
109
128
  def determine_label_field(label_field)
110
- @label = if label_field.present? && validate_hierarchical_label(label_field)
111
- type == Dimension::TYPES[:degenerate] ? name : label_field.to_sym
112
- elsif type == Dimension::TYPES[:degenerate]
129
+ validate_hierarchical_label(label_field) if label_field.present?
130
+
131
+ @label = if type == Dimension::TYPES[:degenerate]
113
132
  name
133
+ elsif label_field.present?
134
+ label_field.to_sym
114
135
  else
115
136
  dimension_fact_model.dimension_label || Configuration.default_dimension_label
116
137
  end
117
138
  end
118
139
 
119
140
  def determine_label_name(label_name)
120
-
121
141
  if label_name
122
142
  @label_name = label_name
123
143
  else
124
144
  @label_name = name
125
- @label_name += "_#{@label}" if (type == Dimension::TYPES[:standard] && @label != :name)
145
+ @label_name += "_#{@label}" if type == Dimension::TYPES[:standard] && @label != :name
126
146
  @label_name += "_#{@datetime_drill}" if @datetime_drill
127
147
  end
128
148
  @label_name
@@ -130,6 +150,7 @@ module ActiveReporting
130
150
 
131
151
  def determine_datetime_drill(datetime_drill)
132
152
  return unless datetime_drill
153
+
133
154
  validate_supported_database_for_datetime_hierarchies
134
155
  validate_against_datetime_hierarchies(datetime_drill)
135
156
  validate_label_is_datetime
@@ -147,18 +168,19 @@ module ActiveReporting
147
168
  end
148
169
 
149
170
  def validate_hierarchical_label(hierarchical_label)
150
- validate_dimension_is_hierachical(hierarchical_label)
171
+ validate_dimension_is_hierarchical(hierarchical_label)
151
172
  validate_against_fact_model_properties(hierarchical_label)
152
- true
153
173
  end
154
174
 
155
- def validate_dimension_is_hierachical(hierarchical_label)
175
+ def validate_dimension_is_hierarchical(hierarchical_label)
156
176
  return if hierarchical?
177
+
157
178
  raise InvalidDimensionLabel, "#{name} must be hierarchical to use label #{hierarchical_label}"
158
179
  end
159
180
 
160
181
  def validate_supported_database_for_datetime_hierarchies
161
- return if SUPPORTED_DBS.include?(model.connection.adapter_name)
182
+ return if datetime_drill_adapter
183
+
162
184
  raise InvalidDimensionLabel,
163
185
  "Cannot utilize datetime grouping for #{name}; " \
164
186
  "database #{model.connection.adapter_name} is not supported"
@@ -166,67 +188,26 @@ module ActiveReporting
166
188
 
167
189
  def validate_against_datetime_hierarchies(hierarchical_label)
168
190
  return if DATETIME_HIERARCHIES.include?(hierarchical_label.to_sym)
191
+
169
192
  raise InvalidDimensionLabel, "#{hierarchical_label} is not a valid datetime grouping label in #{name}"
170
193
  end
171
194
 
172
195
  def validate_label_is_datetime
173
- return if dimension_fact_model.model.column_for_attribute(@label).type == :datetime
174
- raise InvalidDimensionLabel, "'#{@label}' is not a datetime column"
196
+ return if DATETIME_DRILL_COLUMN_TYPES.include?(dimension_fact_model.model.column_for_attribute(@label).type)
197
+
198
+ raise InvalidDimensionLabel, "'#{@label}' is not a datetime or date column"
175
199
  end
176
200
 
177
201
  def validate_against_fact_model_properties(hierarchical_label)
178
202
  return if dimension_fact_model.hierarchical_levels.include?(hierarchical_label.to_sym)
179
- raise InvalidDimensionLabel, "#{hierarchical_label} is not a hierarchical label in #{name}"
180
- end
181
203
 
182
- def datetime_drill_label_fragment(column)
183
- if model.connection.adapter_name == "Mysql2"
184
- datetime_drill_mysql(column)
185
- else # Postgress
186
- datetime_drill_postgress(column)
187
- end
204
+ raise InvalidDimensionLabel, "#{hierarchical_label} is not a hierarchical label in #{name}"
188
205
  end
189
206
 
190
- def datetime_drill_postgress(column)
191
- case @datetime_drill.to_sym
192
- when :date
193
- "DATE('#{column}')"
194
- else
195
- "DATE_TRUNC('#{@datetime_drill}', #{column})"
196
- end
197
- end
207
+ def datetime_drill_adapter
208
+ return @datetime_drill_adapter if defined?(@datetime_drill_adapter)
198
209
 
199
- def datetime_drill_mysql(column)
200
- case @datetime_drill.to_sym
201
- when :microseconds
202
- "MICROSECOND(#{column})"
203
- when :milliseconds
204
- "MICROSECOND(#{column}) DIV 1000"
205
- when :second
206
- "SECOND(#{column})"
207
- when :minute
208
- "MINUTE(#{column})"
209
- when :hour
210
- "HOUR(#{column})"
211
- when :day
212
- "DAY(#{column})"
213
- when :week
214
- "WEEKDAY(#{column})"
215
- when :month
216
- "MONTH(#{column})"
217
- when :quarter
218
- "QUARTER(#{column})"
219
- when :year
220
- "YEAR(#{column})"
221
- when :decade
222
- "YEAR(#{column}) DIV 10"
223
- when :century
224
- "YEAR(#{column}) DIV 100"
225
- when :millennium
226
- "YEAR(#{column}) DIV 1000"
227
- when :date
228
- "DATE(#{column})"
229
- end
210
+ @datetime_drill_adapter = DatetimeDrill.adapter_for(model.connection)
230
211
  end
231
212
 
232
213
  def identifier_fragment
@@ -234,17 +215,17 @@ module ActiveReporting
234
215
  end
235
216
 
236
217
  def identifier_fragment_alias
237
- "#{model.connection.quote_column_name("#{name}_identifier")}"
218
+ model.connection.quote_column_name("#{name}_identifier").to_s
238
219
  end
239
220
 
240
221
  def label_fragment
241
222
  fragment = "#{klass.quoted_table_name}.#{model.connection.quote_column_name(@label)}"
242
- fragment = datetime_drill_label_fragment(fragment) if @datetime_drill
223
+ fragment = DatetimeDrill.fragment(datetime_drill_adapter, @datetime_drill, fragment) if @datetime_drill
243
224
  fragment
244
225
  end
245
226
 
246
227
  def label_fragment_alias
247
- "#{model.connection.quote_column_name(@label_name)}"
228
+ model.connection.quote_column_name(@label_name).to_s
248
229
  end
249
230
 
250
231
  def dimension_fact_model
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveReporting
4
- VERSION = '0.6.2'
4
+ VERSION = '1.0.0'
5
5
  end
@@ -3,43 +3,63 @@
3
3
  require 'active_record'
4
4
  require 'active_reporting/active_record_adaptor'
5
5
  require 'active_reporting/configuration'
6
+ require 'active_reporting/datetime_drill'
6
7
  require 'active_reporting/dimension'
7
8
  require 'active_reporting/dimension_filter'
8
9
  require 'active_reporting/metric'
9
10
  require 'active_reporting/fact_model'
11
+ require 'active_reporting/fact_model_registry'
10
12
  require 'active_reporting/report'
11
13
  require 'active_reporting/reporting_dimension'
12
14
  require 'active_reporting/version'
15
+ require 'active_reporting/railtie' if defined?(Rails::Railtie)
13
16
 
14
- begin
15
- require 'ransack'
16
- ActiveReporting::Configuration.ransack_available = true
17
- rescue LoadError, StandardError
18
- ActiveReporting::Configuration.ransack_available = false
19
- end
20
-
21
- ActiveRecord::Base.extend(ActiveReporting::ActiveRecordAdaptor)
17
+ ActiveSupport.on_load(:active_record) { extend ActiveReporting::ActiveRecordAdaptor }
22
18
 
23
19
  module ActiveReporting
24
20
  def self.fetch_metric(name)
25
- klass = Configuration.metric_lookup_class
26
- unless defined?(klass.constantize)
21
+ klass_name = Configuration.metric_lookup_class
22
+ klass = klass_name.safe_constantize
23
+ if klass.nil?
27
24
  raise BadMetricLookupClass,
28
- "#{klass} not defined. Please define a class responsible for looking up a metric by name." \
29
- ' You may define your own class and set it with `ActiveReporting::Configuration.metric_lookup_class=`.'
25
+ "#{klass_name} not defined. Please define a class responsible for looking up a metric by name. " \
26
+ 'You may define your own class and set it with `ActiveReporting::Configuration.metric_lookup_class=`.'
30
27
  end
31
- unless klass.constantize.respond_to?(:lookup)
32
- raise BadMetricLookupClass, "#{klass} needs to define a class method called 'lookup'"
28
+ unless klass.respond_to?(:lookup)
29
+ raise BadMetricLookupClass, "#{klass_name} needs to define a class method called 'lookup'"
33
30
  end
34
- klass.constantize.lookup(name)
31
+
32
+ klass.lookup(name)
33
+ end
34
+
35
+ class BadMetricLookupClass < StandardError
36
+ end
37
+
38
+ class InvalidDimensionLabel < StandardError
39
+ end
40
+
41
+ # Subclasses RuntimeError, which was raised for invalid directions before this class existed
42
+ class InvalidOrderDirection < RuntimeError
43
+ end
44
+
45
+ class RansackNotAvailable < StandardError
35
46
  end
36
47
 
37
- BadMetricLookupClass = Class.new(StandardError)
38
- InvalidDimensionLabel = Class.new(StandardError)
39
- RansackNotAvailable = Class.new(StandardError)
40
- UnknownAggregate = Class.new(StandardError)
41
- UnknownDimension = Class.new(StandardError)
42
- UnknownDimensionFilter = Class.new(StandardError)
43
- UnknownMetric = Class.new(StandardError)
44
- UnknownJoinMethod = Class.new(StandardError)
48
+ class UnknownAggregate < StandardError
49
+ end
50
+
51
+ class UnknownDimension < StandardError
52
+ end
53
+
54
+ class UnknownDimensionFilter < StandardError
55
+ end
56
+
57
+ class UnknownMetric < StandardError
58
+ end
59
+
60
+ class UnknownMetricFilterOperator < StandardError
61
+ end
62
+
63
+ class UnknownJoinMethod < StandardError
64
+ end
45
65
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_reporting
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tony Drake
8
- autorequire:
9
- bindir: exe
8
+ bindir: bin
10
9
  cert_chain: []
11
- date: 2024-03-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -16,161 +15,61 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: '8.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
25
+ version: '8.0'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: activesupport
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
30
  - - ">="
32
31
  - !ruby/object:Gem::Version
33
- version: '0'
32
+ version: '8.0'
34
33
  type: :runtime
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
- version: '0'
41
- - !ruby/object:Gem::Dependency
42
- name: bundler
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :development
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">="
53
- - !ruby/object:Gem::Version
54
- version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: minitest
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - "~>"
60
- - !ruby/object:Gem::Version
61
- version: '5.0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - "~>"
67
- - !ruby/object:Gem::Version
68
- version: '5.0'
69
- - !ruby/object:Gem::Dependency
70
- name: mysql2
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '0'
83
- - !ruby/object:Gem::Dependency
84
- name: pg
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: rake
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'
111
- - !ruby/object:Gem::Dependency
112
- name: ransack
113
- requirement: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - ">="
116
- - !ruby/object:Gem::Version
117
- version: '0'
118
- type: :development
119
- prerelease: false
120
- version_requirements: !ruby/object:Gem::Requirement
121
- requirements:
122
- - - ">="
123
- - !ruby/object:Gem::Version
124
- version: '0'
125
- - !ruby/object:Gem::Dependency
126
- name: sqlite3
127
- requirement: !ruby/object:Gem::Requirement
128
- requirements:
129
- - - ">="
130
- - !ruby/object:Gem::Version
131
- version: '0'
132
- type: :development
133
- prerelease: false
134
- version_requirements: !ruby/object:Gem::Requirement
135
- requirements:
136
- - - ">="
137
- - !ruby/object:Gem::Version
138
- version: '0'
139
- description:
39
+ version: '8.0'
40
+ description: ActiveReporting implements ROLAP (Relational Online Analytical Processing)
41
+ concepts such as fact models, dimensions, and metrics on top of ActiveRecord, providing
42
+ a DSL for describing reports and analytics on your data.
140
43
  email:
141
44
  - t27duck@gmail.com
142
45
  executables: []
143
46
  extensions: []
144
47
  extra_rdoc_files: []
145
48
  files:
146
- - ".github/dependabot.yml"
147
- - ".github/workflows/tests.yml"
148
- - ".gitignore"
149
- - ".rubocop.yml"
150
49
  - CHANGELOG.md
151
- - CODE_OF_CONDUCT.md
152
- - Gemfile
153
50
  - LICENSE.txt
154
51
  - README.md
155
- - Rakefile
156
- - active_reporting.gemspec
157
- - bin/console
158
- - bin/setup
159
52
  - lib/active_reporting.rb
160
53
  - lib/active_reporting/active_record_adaptor.rb
161
54
  - lib/active_reporting/configuration.rb
55
+ - lib/active_reporting/datetime_drill.rb
162
56
  - lib/active_reporting/dimension.rb
163
57
  - lib/active_reporting/dimension_filter.rb
164
58
  - lib/active_reporting/fact_model.rb
59
+ - lib/active_reporting/fact_model_registry.rb
165
60
  - lib/active_reporting/metric.rb
61
+ - lib/active_reporting/railtie.rb
166
62
  - lib/active_reporting/report.rb
167
63
  - lib/active_reporting/reporting_dimension.rb
168
64
  - lib/active_reporting/version.rb
169
65
  homepage: https://github.com/t27duck/active_reporting
170
66
  licenses:
171
67
  - MIT
172
- metadata: {}
173
- post_install_message:
68
+ metadata:
69
+ bug_tracker_uri: https://github.com/t27duck/active_reporting/issues
70
+ changelog_uri: https://github.com/t27duck/active_reporting/blob/main/CHANGELOG.md
71
+ source_code_uri: https://github.com/t27duck/active_reporting
72
+ rubygems_mfa_required: 'true'
174
73
  rdoc_options: []
175
74
  require_paths:
176
75
  - lib
@@ -178,15 +77,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
178
77
  requirements:
179
78
  - - ">="
180
79
  - !ruby/object:Gem::Version
181
- version: '2.3'
80
+ version: '3.3'
182
81
  required_rubygems_version: !ruby/object:Gem::Requirement
183
82
  requirements:
184
83
  - - ">="
185
84
  - !ruby/object:Gem::Version
186
85
  version: '0'
187
86
  requirements: []
188
- rubygems_version: 3.5.3
189
- signing_key:
87
+ rubygems_version: 4.0.20
190
88
  specification_version: 4
191
89
  summary: Add relational OLAP-like functionality for ActiveRecord
192
90
  test_files: []
@@ -1,11 +0,0 @@
1
- # To get started with Dependabot version updates, you'll need to specify which
2
- # package ecosystems to update and where the package manifests are located.
3
- # Please see the documentation for all configuration options:
4
- # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5
-
6
- version: 2
7
- updates:
8
- - package-ecosystem: "github-actions"
9
- directory: "/"
10
- schedule:
11
- interval: "weekly"