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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd8ff0998a757ff875a17baad2fcb148ace4239da0781e1b7fbdd4c6367cef2c
4
- data.tar.gz: 316ee68af897e1b866b79b28d3f0889f116982a48b94a123cde5170ac851b4e1
3
+ metadata.gz: f2739361482925da496d90c15b8aa28c386587f7efaa9d37b918d1847eaf2c45
4
+ data.tar.gz: 501c16713ac1d1d10eccf5679a820c920b7459321c07fbf2168c1091e12a1f01
5
5
  SHA512:
6
- metadata.gz: 73f92eed3389912530e9ff393e761bbc6e86a84e1c6beff4428de03f295a2fe91f2b230d25d8a3d0b783761b099dca43b4bc62ea05538a469c925eae5c9044d2
7
- data.tar.gz: b1217306b02aa8e40b78cea0d0d31dca84621c4b676e79a638bf8644891e301a3d850e2f645d9c0b745724c4a03c0837936cbe1776e9794a89e4e73580589256
6
+ metadata.gz: 3d8e32d39baba7d583ce6a9734e1040252927ef1eee97db85ed6b28e47484f99c31fcea3e897a1f6be55f79da2eb5795227d36a742a11c627ca07a31f0247a5a
7
+ data.tar.gz: 94ebd8032809fbede8e295375cf76f073c183807b417868567670f40effb4df7d5e130cd94ae79a8cd80f50c4c35b3a1c24c1878ef2e53704e1c4016dce7f027
data/CHANGELOG.md CHANGED
@@ -1,108 +1,164 @@
1
+ ## 1.0.0 (2026-10-09)
2
+
3
+ ### Breaking Changes
4
+
5
+ - Only support supported versions of Ruby and Rails.
6
+ - Require ActiveRecord and ActiveSupport 8.0 or newer (previously any version was allowed)
7
+ - `Configuration.metric_lookup_class=` no longer singularizes the given name. `metric_lookup_class = StoredMetrics` now resolves to `::StoredMetrics` (previously `::StoredMetric`). If you relied on the old behavior (e.g. `metric_lookup_class = :metrics` to mean `::Metric`), pass the singular name instead.
8
+ - Removed the `{ created_at: :month }` shorthand for datetime drills on degenerate dimensions, deprecated in 0.6.0. Use `{ created_at: { datetime_drill: :month } }` instead; the old form now raises `InvalidDimensionLabel` with the replacement syntax. This also removes the spurious deprecation warning that was printed for every plain degenerate dimension (e.g. `dimensions: [:kind]`)
9
+ - Ransack is no longer required when the gem is loaded; it is loaded the first time a ransack feature is used
10
+ - Fact models generated for models without a `[ModelName]FactModel` class are no longer assigned to a constant (previously `PostFactModel` was defined the first time `Post.fact_model` was called). Define the fact model class if you need to reference it
11
+ - On MySQL, `datetime_drill` now truncates datetimes like PostgreSQL's `date_trunc` and returns `DATETIME` values, instead of extracting a part as an integer. For example, a `month` drill now returns `2026-09-01 00:00:00` instead of `9`, so the same month in different years is no longer grouped together. `week` now returns the Monday the week starts on (previously the day of the week) and `day` returns the date at midnight (previously the day of the month)
12
+ - Measures are now quoted and qualified with the fact model's table (e.g. `SUM("sales"."total")`). This fixes ambiguous column errors when a joined dimension table has a column with the same name as the measure, and stops a measure from injecting SQL. A measure that was a SQL expression (e.g. `measure: 'price * quantity'`) must now be wrapped in `Arel.sql`: `measure: Arel.sql('price * quantity')`
13
+ - A standard dimension that uses a hierarchy level other than its default label (`field:`) or a `datetime_drill` no longer includes its identifier in the query, so its rows actually roll up. Previously the report also grouped by the dimension table's primary key, returning one row per record (e.g. one row per day labeled with its month) unless `dimension_identifiers: false` was passed. These rows no longer have a `<dimension>_identifier` key
14
+ - A scope dimension filter given `false` or `'false'` is now left out of the query instead of being called with `false` as its argument (previously this raised `ArgumentError` for scopes without parameters). A scope that takes a boolean can no longer be passed `false`; use a lambda dimension filter instead. See "Dimension filter values" in the README
15
+ - A lambda dimension filter with parameters is now always passed its value, including `true` (previously `true` called it without an argument, raising `ArgumentError`). A lambda without parameters is now applied for any value except `false` or `'false'`, which leave it out (previously `false` raised `ArgumentError`)
16
+
17
+ ### Bug Fixes
18
+
19
+ - Fix `datetime_drill: :date` generating invalid SQL on PostgreSQL by quoting the column as a string literal
20
+ - `ActiveReporting.fetch_metric` now raises `BadMetricLookupClass` instead of `NameError` when the configured `metric_lookup_class` is not defined
21
+ - Dimension label callbacks are now applied when the dimension uses a custom `name:`, uses the default `:name` label, or is a degenerate dimension (previously they were silently skipped in all of these cases)
22
+ - Auto-generated fact models for namespaced models (e.g. `Admin::Post`) are now created in the model's namespace (`Admin::PostFactModel`) instead of raising `NameError`
23
+ - A `NameError` raised while loading a defined fact model is no longer swallowed and replaced with an empty auto-generated fact model
24
+ - Metric names are now quoted in the generated SQL, so metrics named after reserved words (e.g. `:order`) no longer produce invalid queries
25
+ - An invalid `order_by_dimension` direction now raises `ActiveReporting::InvalidOrderDirection` (a `RuntimeError` subclass, so existing `rescue RuntimeError` code still works) with a message naming the dimension
26
+ - Using a ransack dimension filter without ransack installed now raises `RansackNotAvailable` instead of `NoMethodError`
27
+ - `metric_filter` operators may now be strings (e.g. `{ 'gt' => 3 }` from request params), and an unknown operator raises `ActiveReporting::UnknownMetricFilterOperator` when the metric or report is built (previously both generated invalid SQL that failed when the report ran)
28
+ - Ordering by a dimension that isn't one of the report's dimensions now raises `UnknownDimension` when the report is created (previously the ordering was silently dropped). An invalid ordering direction also now raises `InvalidOrderDirection` when the report is created instead of when it runs
29
+ - Dimension filters that fall back to ransack are no longer stored on the fact model when a report uses them. Running reports no longer modifies fact models, which are shared between threads, and unknown filter names from user input no longer accumulate in memory
30
+ - `Configuration.ransack_fallback = false` no longer raises `RansackNotAvailable` when ransack is not installed
31
+ - Fact models are looked up again after Rails reloads code. Previously a generated fact model kept pointing to the model class from before the reload, and a model that isn't reloaded (e.g. from an engine) kept returning the old fact model
32
+
33
+ ### Features
34
+
35
+ - `datetime_drill` now works on SQLite. All databases now truncate datetimes the same way PostgreSQL's `date_trunc` does
36
+ - `datetime_drill` now works with the Trilogy MySQL adapter (and any other adapter built on Rails' MySQL, PostgreSQL, or SQLite adapters)
37
+ - `datetime_drill` now works on `date` columns and on PostgreSQL `timestamp with time zone` (`timestamptz`) columns. Previously only columns Rails reports as `datetime` were accepted, so these raised `InvalidDimensionLabel`
38
+
39
+ ### Misc
40
+
41
+ - `Dimension#hierarchical?` and `Dimension#association` now cache `false`/`nil` results instead of recomputing them on every call
42
+ - Add gem metadata (changelog, source, and bug tracker links) and require MFA for releases
43
+ - Requiring the gem no longer forces `ActiveRecord::Base` to load; models are extended via `ActiveSupport.on_load(:active_record)`
44
+ - Report queries now run through `select_all`, so they use the ActiveRecord query cache and appear as `ActiveReporting` in SQL logs and instrumentation
45
+ - Datetime drill SQL moved to `ActiveReporting::DatetimeDrill`; `ReportingDimension::SUPPORTED_DBS` was removed
46
+ - Fix typos and inaccurate descriptions in documentation comments, error messages, and the README
47
+ - Document why `has_many` dimensions give wrong results and what to use instead
48
+ - `Configuration` uses `mattr_reader` with defaults instead of hand-written getters
49
+ - Development: `bin/setup` installs the adapter for `DB` and creates the test database, database adapters are optional Gemfile groups, and CI runs RuboCop (now including the tests) and codespell
50
+
51
+ ## 0.6.2 (2024-03-18)
52
+
53
+ ### Features
54
+
55
+ - `Metric` can now take an optional `measure` argument to override the measure that would normally be used.
56
+
1
57
  ## 0.6.1 (2020-08-29)
2
58
 
3
59
  ### Misc
4
60
 
5
- * Add `date` as an option for datetime drills - *germanotm*
61
+ - Add `date` as an option for datetime drills - _germanotm_
6
62
 
7
63
  ## 0.6.0 (2020-08-21)
8
64
 
9
65
  ### Features
10
66
 
11
- * Support to implicit hierarchical on datetime columns in MySQL (#33) - *germanotm*
12
- * Added `{ datetime_drill: :month }` option for reporting dimentions to explicitly - *germanotm*
67
+ - Support to implicit hierarchical on datetime columns in MySQL (#33) - _germanotm_
68
+ - Added `{ datetime_drill: :month }` option for reporting dimensions to explicitly - _germanotm_
13
69
 
14
- This depricates the use of key-value only use for report dimension options (ie, `dimensions: [{ dim: single_option }]`).
70
+ This deprecates the use of key-value only use for report dimension options (ie, `dimensions: [{ dim: single_option }]`).
15
71
  Instead, use `dimensions: [{ dim: { option: value} }]` See the README for all reporting dimension options.
16
72
 
17
73
  ## 0.5.1 (2020-06-31)
18
74
 
19
75
  ### Features
20
76
 
21
- * Allow dimensions defined in a `Metric` to use LEFT OUTER JOINs via a new `:join_method` option (#32) - *germanotm*
77
+ - Allow dimensions defined in a `Metric` to use LEFT OUTER JOINs via a new `:join_method` option (#32) - _germanotm_
22
78
 
23
79
  ### Misc
24
80
 
25
- * Fixed warning about initialized variables
26
- * Fixed Ruby 2.7 warning
81
+ - Fixed warning about initialized variables
82
+ - Fixed Ruby 2.7 warning
27
83
 
28
84
  ## 0.5.0 (2020-06-30)
29
85
 
30
86
  ### Bug Fixes
31
87
 
32
- * Fix Missing quotation marks in column names causing SQL errors on MYSQL (#30) - *germanotm*
88
+ - Fix Missing quotation marks in column names causing SQL errors on MYSQL (#30) - _germanotm_
33
89
 
34
90
  ### Misc
35
91
 
36
- * Update matrix to only supported Rubies and Rails versions. Rails 5.2+ and Ruby 2.5+ are officially supported now.
92
+ - Update matrix to only supported Rubies and Rails versions. Rails 5.2+ and Ruby 2.5+ are officially supported now.
37
93
 
38
94
  ## 0.4.2 (2019-11-01)
39
95
 
40
96
  ### Misc
41
97
 
42
- * Test against Rails 6.0 final
43
- * Fixed deprecated call to `to_hash` - *joshforbes*
44
- * Corrected readme entry for `dimesions` option for `ActiveReporting::Metric` - *joshforbes*
98
+ - Test against Rails 6.0 final
99
+ - Fixed deprecated call to `to_hash` - _joshforbes_
100
+ - Corrected readme entry for `dimensions` option for `ActiveReporting::Metric` - _joshforbes_
45
101
 
46
102
  ## 0.4.1 (2019-05-28)
47
103
 
48
104
  ### Features
49
105
 
50
- * Hierarchical dimensions may now have custom keys in result (#16) - *andresgutgon*
106
+ - Hierarchical dimensions may now have custom keys in result (#16) - _andresgutgon_
51
107
 
52
108
  ### Misc
53
109
 
54
- * Test against Raisl 6.0RC
55
- * Loosen AR requirements. The gem will install for any AR version, but only ones listed in the README are supported
56
- * Test against active Rubies
110
+ - Test against Raisl 6.0RC
111
+ - Loosen AR requirements. The gem will install for any AR version, but only ones listed in the README are supported
112
+ - Test against active Rubies
57
113
 
58
114
  ## 0.4.0 (2018-05-02)
59
115
 
60
116
  ### Breaking Changes
61
117
 
62
- * Gemspec now requires Ruby 2.3 and later to install
118
+ - Gemspec now requires Ruby 2.3 and later to install
63
119
 
64
120
  ### Features
65
121
 
66
- * Dimension off of `datetime` columns by date parts in PostgreSQL (See README for details) (#10) - *niborg*
122
+ - Dimension off of `datetime` columns by date parts in PostgreSQL (See README for details) (#10) - _niborg_
67
123
 
68
124
  ## 0.3.0 (2018-04-12)
69
125
 
70
126
  ### Bug Fixes
71
127
 
72
- * Specify rescue from LoadError for ransack (#9) - *niborg*
73
- * Fix ransack fallback logic (#8) - *germanotm*
128
+ - Specify rescue from LoadError for ransack (#9) - _niborg_
129
+ - Fix ransack fallback logic (#8) - _germanotm_
74
130
 
75
131
  ### Misc
76
132
 
77
- * Test against Rails 5.2
78
- * Test against Ruby 2.5
79
- * Drop support for Rails 5.0 (EOL-ed)
80
- * Drop support for Ruby 2.2 (EOL-ed)
133
+ - Test against Rails 5.2
134
+ - Test against Ruby 2.5
135
+ - Drop support for Rails 5.0 (EOL-ed)
136
+ - Drop support for Ruby 2.2 (EOL-ed)
81
137
 
82
138
  ## 0.2.0 (2017-06-17)
83
139
 
84
140
  ### Breaking Changes
85
141
 
86
- * `FactModel.use_model` renamed to `FactModel.model=`
142
+ - `FactModel.use_model` renamed to `FactModel.model=`
87
143
 
88
144
  ### Bug Fixes
89
145
 
90
- * `metric` lives on fact model and not metric (#3) - *wheeyls*
146
+ - `metric` lives on fact model and not metric (#3) - _wheeyls_
91
147
 
92
148
  ### Misc
93
149
 
94
- * Readme corrections and updates (#2) - *wheeyls*
150
+ - Readme corrections and updates (#2) - _wheeyls_
95
151
 
96
152
  ## 0.1.1 (2017-04-22)
97
153
 
98
154
  ### Bug Fixes
99
155
 
100
- * Fix MySQL querying
156
+ - Fix MySQL querying
101
157
 
102
158
  ### Misc
103
159
 
104
- * Properly test against multiple dbs and versions of ActiveRecord in CI
160
+ - Properly test against multiple dbs and versions of ActiveRecord in CI
105
161
 
106
162
  ## 0.1.0 (2017-04-16)
107
163
 
108
- * Initial release
164
+ - Initial release
data/README.md CHANGED
@@ -1,6 +1,4 @@
1
- [![Build Status](https://travis-ci.org/t27duck/active_reporting.svg?branch=master)](https://travis-ci.org/t27duck/active_reporting)
2
-
3
- [![Code Climate](https://codeclimate.com/github/t27duck/active_reporting/badges/gpa.svg)](https://codeclimate.com/github/t27duck/active_reporting)
1
+ ![Build Status](https://github.com/t27duck/active_reporting/workflows/CI/badge.svg)
4
2
 
5
3
  # ActiveReporting
6
4
 
@@ -8,9 +6,9 @@ ActiveReporting implements various terminology used in Relational Online Analyti
8
6
 
9
7
  ActiveReporting officially supports MySQL, PostgreSQL, and SQLite.
10
8
 
11
- ActiveReporting officially supports Ruby 2.5 and later. Other versions may work, but are not supported.
9
+ ActiveReporting officially supports [Ruby versions under official support](https://endoflife.date/ruby).
12
10
 
13
- ActiveReporting officially supports Rails 5.2, and 6.0. Other versions may work, but are not supported.
11
+ ActiveReporting officially supports [Rails versions under official support](https://endoflife.date/rails).
14
12
 
15
13
  ## Installation
16
14
 
@@ -30,13 +28,13 @@ Or install it yourself as:
30
28
 
31
29
  ## What is "Reporting"?
32
30
 
33
- Reporting is the collection and presentation data so that it can be analyzed. Our databases only store one thing: data. Data is great for computers but mostly worthless to humans. What ActiveReoprting does is turn that *data* into *information* to help humans make decisions.
31
+ Reporting is the collection and presentation of data so that it can be analyzed. Our databases only store one thing: data. Data is great for computers but mostly worthless to humans. What ActiveReporting does is turn that _data_ into _information_ to help humans make decisions.
34
32
 
35
33
  ## Terminology
36
34
 
37
35
  ROLAP uses a set of terms to describe how a report is generated. ActiveReporting implements them in the closest way possible in Ruby-land.
38
36
 
39
- ### Fact table (can be sometimes called fact model)
37
+ ### Fact table (sometimes called fact model)
40
38
 
41
39
  A fact table is the primary table where information is derived from in a report. It commonly contains fact columns (usually numeric values) and dimension columns (foreign keys to other tables or values that can be grouped together).
42
40
 
@@ -50,9 +48,9 @@ A dimension is a point of data used to "slice and dice" data from a fact model.
50
48
 
51
49
  Examples:
52
50
 
53
- * A sales rep on a fact table of sales
54
- * A state of an sale on a state machine
55
- * The manufacture on a fact table of widgets
51
+ - A sales rep on a fact table of sales
52
+ - A state of a sale on a state machine
53
+ - The manufacturer on a fact table of widgets
56
54
 
57
55
  SQL Equivalent: JOIN, GROUP BY
58
56
 
@@ -64,8 +62,8 @@ A hierarchy for a dimension is related attributes that live on a dimension table
64
62
 
65
63
  Examples:
66
64
 
67
- * Dates: Date, Month, Year, Quarter
68
- * Mobile Phone: Model, Manufacture, OS, Wireless Technology
65
+ - Dates: Date, Month, Year, Quarter
66
+ - Mobile Phone: Model, Manufacturer, OS, Wireless Technology
69
67
 
70
68
  ### Dimension Member (also known as dimension labels)
71
69
 
@@ -73,7 +71,7 @@ This is information related to a dimension. When the dimension lives on the fact
73
71
 
74
72
  Examples:
75
73
 
76
- * When dimensioning blog posts by category, the dimension is the category_id which leads to the categories table. The label would be the category name.
74
+ - When dimensioning blog posts by category, the dimension is the category_id which leads to the categories table. The label would be the category name.
77
75
 
78
76
  ### Dimension Filter (or just "filter")
79
77
 
@@ -89,8 +87,8 @@ A measure is a column in a fact table (usually a numeric value) used in aggregat
89
87
 
90
88
  Examples:
91
89
 
92
- * Total amount in a sale
93
- * Number of units used in a transaction
90
+ - Total amount in a sale
91
+ - Number of units used in a transaction
94
92
 
95
93
  SQL Equivalent: Column in the fact table used in an aggregation function
96
94
 
@@ -98,7 +96,7 @@ Rails: ActiveRecord attribute
98
96
 
99
97
  ### Metric
100
98
 
101
- A metric is a measured value and the subject of the report. It is the result of *the* question you want answered.
99
+ A metric is a measured value and the subject of the report. It is the result of _the_ question you want answered.
102
100
 
103
101
  SQL Equivalent: A query result
104
102
 
@@ -108,7 +106,7 @@ Rails: The result of an ActiveRecord query
108
106
 
109
107
  Star schema is a way of structuring your relational data. It is one of the most common forms of organization for relational data warehousing. The layout of a star schema consists of a fact table referencing one or more dimension tables. When laid out in an entity relationship diagram, it resembles a star.
110
108
 
111
- [TODO: ADD PICTURE HERE]
109
+ ![Star schema: a sales fact table in the center references four dimension tables: sales_reps, phones, sale_dates, and stores](docs/images/star_schema.svg)
112
110
 
113
111
  More information: https://en.wikipedia.org/wiki/Star_schema
114
112
 
@@ -116,7 +114,7 @@ More information: https://en.wikipedia.org/wiki/Star_schema
116
114
 
117
115
  Snowflake schema is a super class of star schema. A fact table still resides in the middle of the diagram, but dimension tables are normalized out into multiple tables resulting in the resemblance of a snowflake.
118
116
 
119
- [TODO: ADD PICTURE HERE]
117
+ ![Snowflake schema: the same sales fact table, with sales_reps, phones, and stores normalized out into regions, manufacturers, operating_systems, cities, and states](docs/images/snowflake_schema.svg)
120
118
 
121
119
  More information: https://en.wikipedia.org/wiki/Snowflake_schema
122
120
 
@@ -142,16 +140,15 @@ ActiveReporting::Configuration.setting = value
142
140
 
143
141
  `default_measure` - If a fact model does not specify a measure to use for aggregates, this value will be used. (Default: `:value`)
144
142
 
145
- `ransack_fallback` - If the ransack gem is loaded, allow all unknown dimension filters to be delegated to ransack. (Default: `false`)
146
-
147
- `metric_lookup_class` - The name of a constant used to lookup prebuilt `Reporting::Metric` objects by name. The constant should define a class method called `#lookup` which can take a string or symbol of the metric name. (Default: `::Metric`)
143
+ `ransack_fallback` - If the ransack gem is available, allow all unknown dimension filters to be delegated to ransack. (Default: `false`)
148
144
 
145
+ `metric_lookup_class` - The name of a constant used to lookup prebuilt `ActiveReporting::Metric` objects by name. The constant should define a class method called `#lookup` which can take a string or symbol of the metric name. Accepts a class, string, or symbol; the name is used as-is (`:stored_metrics` becomes `::StoredMetrics`). (Default: `::Metric`)
149
146
 
150
147
  ## ActiveReporting::FactModel
151
148
 
152
149
  In ActiveReporting, a fact model stores configuration information on how it can be used in reports. We use the term fact model instead of fact table because this class "models how the fact table interacts with dimensions and other reporting features".
153
150
 
154
- You can put these classes anywhere you want in your app, though I recommend putting them in `app/fact_models`
151
+ You can put these classes anywhere you want in your app, though I recommend putting them in `app/fact_models` so Rails autoloads and reloads them with your models
155
152
 
156
153
  ### Linking a fact model to an ActiveRecord model
157
154
 
@@ -165,7 +162,7 @@ class TicketFactModel < ActiveReporting::FactModel
165
162
  end
166
163
  ```
167
164
 
168
- Alternatively, you may manually specify the model manually with `self.model=`
165
+ Alternatively, you may manually specify the model with `self.model=`
169
166
 
170
167
  ```ruby
171
168
  class TicketFactModel < ActiveReporting::FactModel
@@ -177,9 +174,13 @@ class TicketFactModel < ActiveReporting::FactModel
177
174
  end
178
175
  ```
179
176
 
177
+ If a model doesn't have a fact model, ActiveReporting generates one with the default configuration the first time it's needed (for example, when the model is used as a dimension). Generated fact models aren't assigned to a constant, so define a `[ModelName]FactModel` class if you need to refer to one.
178
+
179
+ In Rails, fact models are looked up again after code is reloaded in development, so changes to models and fact models take effect without restarting the server.
180
+
180
181
  ### Configuring a fact model's measure
181
182
 
182
- ActiveReporting assumes the column of a fact model used for summing, averaging, etc. is called `value`. This may be changed on a fact model using `measure=`. You may pass in a string or symbol of the column you wish to use for aggregations.
183
+ ActiveReporting assumes the column of a fact model used for summing, averaging, etc. is called `value`. This may be changed on a fact model using `measure=`. You may pass in a string or symbol of the column you wish to use for aggregations. The column is quoted and qualified with the fact model's table. To aggregate a SQL expression instead of a column, wrap it in `Arel.sql`, e.g. `self.measure = Arel.sql('base_price + taxes')`. Only use `Arel.sql` with trusted input.
183
184
 
184
185
  ```ruby
185
186
  class SaleFactModel < ActiveReporting::FactModel
@@ -191,7 +192,7 @@ end
191
192
 
192
193
  ### Declaring dimensions on a fact model
193
194
 
194
- You must declare what a fact model is dimensional by. A valid dimension is a column on the fact model's ActiveRecord model or a `belongs_to`/`has_one through` relationship. `has_many` relationships do not work (well) at all.
195
+ You must declare what a fact model is dimensional by. A valid dimension is a column on the fact model's ActiveRecord model or a `belongs_to`/`has_one` relationship (including `has_one :through`). Avoid `has_many` relationships; see [below](#avoid-has_many-dimensions).
195
196
 
196
197
  ```ruby
197
198
  class TicketFactModel < ActiveReporting::FactModel
@@ -201,6 +202,53 @@ class TicketFactModel < ActiveReporting::FactModel
201
202
  end
202
203
  ```
203
204
 
205
+ ### Avoid `has_many` dimensions
206
+
207
+ A dimension that is a relationship joins its table to the fact table. With `belongs_to` and `has_one`, each fact row joins to at most one row, so aggregates are correct. With `has_many` (or `has_many :through` or `has_and_belongs_to_many`), each fact row is repeated once for every related record before it is aggregated. ActiveReporting allows these dimensions and raises no error, but the results are wrong:
208
+
209
+ - `count` counts related records instead of facts
210
+ - `sum` and `avg` include a fact's measure once for every related record (`max` and `min` are unaffected)
211
+ - A fact related to records in more than one group is counted in each group, so the rows add up to more than the total
212
+
213
+ For example, counting tickets by the type of author who commented on them:
214
+
215
+ ```ruby
216
+ class Ticket < ActiveRecord::Base
217
+ has_many :comments
218
+ end
219
+
220
+ class CommentFactModel < ActiveReporting::FactModel
221
+ dimension_hierarchy %i[author_type]
222
+ end
223
+
224
+ class TicketFactModel < ActiveReporting::FactModel
225
+ dimension :comments # has_many relationship
226
+ end
227
+
228
+ ActiveReporting::Metric.new(:ticket_count, fact_model: TicketFactModel, dimensions: [{ comments: { field: :author_type } }])
229
+ ```
230
+
231
+ A ticket with three comments from staff is counted three times under `staff`, and a ticket with comments from both staff and customers is counted under both. The query is effectively `SELECT COUNT(*) ... FROM tickets INNER JOIN comments ... GROUP BY comments.author_type`.
232
+
233
+ Instead:
234
+
235
+ - **Use the "many" side as the fact model.** Report on comments, with the ticket as a `belongs_to` dimension. A fact model's rows should be the most detailed level the report needs.
236
+
237
+ ```ruby
238
+ class CommentFactModel < ActiveReporting::FactModel
239
+ dimension :ticket # belongs_to relationship
240
+ dimension :author_type # Column on the comments table
241
+ end
242
+ ```
243
+
244
+ - **Filter instead of grouping.** To count tickets that have a matching related record, use a dimension filter with a subquery. A subquery doesn't repeat fact rows the way a join does.
245
+
246
+ ```ruby
247
+ class TicketFactModel < ActiveReporting::FactModel
248
+ dimension_filter :commented_on_by, ->(type) { where(id: Comment.where(author_type: type).select(:ticket_id)) }
249
+ end
250
+ ```
251
+
204
252
  ### When a fact model is used as a dimension
205
253
 
206
254
  When another fact model uses a relationship as a dimension, that ActiveRecord model's fact model class can hold configuration information for how to act when used as a dimension.
@@ -215,7 +263,7 @@ end
215
263
 
216
264
  ### Dimension Hierarchies
217
265
 
218
- For dimensions that can have a hierarchy (such as a mobile phone), you can declare the what columns make it up. This will allow reports to dimension against a fact model and be able to use different labels to group by.
266
+ For dimensions that can have a hierarchy (such as a mobile phone), you can declare what columns make it up. This will allow reports to dimension against a fact model and be able to use different labels to group by.
219
267
 
220
268
  ```ruby
221
269
  class PhoneFactModel < ActiveReporting::FactModel
@@ -225,7 +273,7 @@ end
225
273
 
226
274
  ### Drill down / Roll up (Drill up) with datetime columns
227
275
 
228
- The fastest approach to group by certain date metrics is to create so-called "date dimensions" and add on columns for each desired hierarchy. For those users that are restricted from organizing their data in this way, ActiveRporting provides a `datetime_drill` option that can be passed with the dimension on the metric definition to drill datetime columns.
276
+ The fastest approach to group by certain date metrics is to create so-called "date dimensions" and add on columns for each desired hierarchy. For those users that are restricted from organizing their data in this way, ActiveReporting provides a `datetime_drill` option that can be passed with the dimension on the metric definition to drill datetime columns.
229
277
 
230
278
  To use, declare a datetime dimension on a fact model as normal:
231
279
 
@@ -252,9 +300,15 @@ When creating a metric, ActiveReporting will recognize the following datetime hi
252
300
  - millennium
253
301
  - date
254
302
 
255
- Under the hood Active Reporting uses specific database functions to manipulate datetime columns. Postgres provides a way to group by `datetime` column data on the fly using the [`date_trunc` function](https://www.postgresql.org/docs/8.1/static/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC). On Mysql this can be done using [Date and Time Functions](https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html).
303
+ A datetime drill truncates the datetime to the given level, like PostgreSQL's [`date_trunc` function](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC). For example, a `month` drill on `2026-09-15 10:30:00` returns `2026-09-01 00:00:00`, so the same month in different years is grouped separately, and a `week` drill returns the Monday the week starts on. The `date` drill returns the date portion of the datetime.
304
+
305
+ Datetime drills may be used on `datetime`, `timestamp`, `timestamptz` (PostgreSQL's `timestamp with time zone`), and `date` columns. A drill on a `date` column is treated as a datetime at midnight.
306
+
307
+ This works the same on every supported database:
256
308
 
257
- *NOTE*: PRs welcomed to support this functionality in other databases.
309
+ - **PostgreSQL** (including PostGIS) uses `date_trunc` and returns timestamps.
310
+ - **MySQL** (both the `mysql2` and `trilogy` adapters) builds the truncated value with [date and time functions](https://dev.mysql.com/doc/refman/8.4/en/date-and-time-functions.html) and returns `DATETIME` values.
311
+ - **SQLite** emulates `date_trunc` and returns the truncated datetime as `YYYY-MM-DD HH:MM:SS` text.
258
312
 
259
313
  ## Configuring Dimension Filters
260
314
 
@@ -274,7 +328,34 @@ The first example exposes the `Ticket.open` scope to the fact model allowing it
274
328
 
275
329
  The second example defines a lambda to be invoked like a Rails scope. It joins against the `category` relationship on `Ticket` and filters by the category's name.
276
330
 
277
- The third example defines a filter called "subject_cont" and will delegate it to ransack when called.
331
+ The third example defines a filter called "subject_cont" and will delegate it to ransack when called. Ransack is loaded the first time it's needed; add it to your Gemfile to use it. Ransack 4 and newer also require the model to allowlist searchable attributes with a `ransackable_attributes` class method (here, `Ticket.ransackable_attributes` must include `subject`).
332
+
333
+ ### Dimension filter values
334
+
335
+ A report passes each dimension filter's value to the filter. How the value is used depends on the kind of filter, which matters most when values come from request params as strings:
336
+
337
+ | Filter | `true` / `'true'` | `false` / `'false'` | Any other value |
338
+ |---|---|---|---|
339
+ | Lambda without parameters, e.g. `-> { where(open: true) }` | Applied | Left out | Applied |
340
+ | Lambda with a parameter, e.g. `->(x) { where(name: x) }` | Passed as the argument | Passed as the argument | Passed as the argument |
341
+ | Scope | Called without an argument | Left out | Passed as the argument |
342
+
343
+ A scope is called without an argument for `true` because Rails defines every scope as a method that accepts any arguments, so ActiveReporting can't tell whether a scope takes one. As a result, a scope can't be passed `true` or `false`. For a filter that takes a boolean, use a lambda with a parameter instead:
344
+
345
+ ```ruby
346
+ class TicketFactModel < ActiveReporting::FactModel
347
+ # `{ open: true }` applies `Ticket.open`, `{ open: false }` leaves it out
348
+ dimension_filter :open
349
+
350
+ # `{ resolved: true }` and `{ resolved: false }` both reach the lambda. Values from params
351
+ # are strings, so cast them yourself, e.g. with ActiveModel::Type::Boolean
352
+ dimension_filter :resolved, ->(resolved) { where(resolved: ActiveModel::Type::Boolean.new.cast(resolved)) }
353
+ end
354
+ ```
355
+
356
+ Ransack filters are always passed their value as is, and ransack decides what it means.
357
+
358
+ Only `false` and `'false'` leave a filter out. Other "falsy" strings such as `'0'` are passed to a scope as an argument, since a scope may take a number.
278
359
 
279
360
  Only dimension filters defined in the fact model may be used. Whitelisting available filters allows for more control over what the user may filter by. Giving the user full control to call any scope or method from the ActiveRecord model could lead to unexpected results, poor performing queries, or possible security concerns.
280
361
 
@@ -306,23 +387,25 @@ my_metric = ActiveReporting::Metric.new(
306
387
 
307
388
  `dimensions` - An array of dimensions used for the metric. When given just a symbol, the default dimension label will be used for the dimension.
308
389
 
309
- You may pass a hash instead of a symbol to customize the dimension options (example: { dimension_name: { option1: value, option2: value}}). The avaliable options are:
390
+ You may pass a hash instead of a symbol to customize the dimension options (example: { dimension_name: { option1: value, option2: value}}). The available options are:
310
391
 
311
- - `field` - Specify the hierarchy level that should be used instead the default dimension label. Ex: `[:sales_rep, {mobile_phone: { field :manufacture }}]`. If you use a hash instead of a Symbol to define a hierarchy the `field` item must be a valid field in your table.
392
+ - `field` - Specify the hierarchy level that should be used instead of the default dimension label. Ex: `[:sales_rep, {mobile_phone: { field: :manufacturer }}]`. If you use a hash instead of a Symbol to define a hierarchy the `field` item must be a valid field in your table.
312
393
 
313
- - `name` - You may costumize the label alias, by default the dimension name will be used. The `name` can be whatever label you want. Ex :`[{sale_date: { field: :month, name: :a_custom_name_for_month }}]`.
394
+ - `name` - You may customize the label alias, by default the dimension name will be used. The `name` can be whatever label you want. Ex: `[{sale_date: { field: :month, name: :a_custom_name_for_month }}]`.
314
395
 
315
396
  - `join_method` - You may choose the join_method with the dimension. The default value for join_method is :joins which does a standard "INNER JOIN", but you can pass a :left_outer_joins to use "LEFT OUTER JOIN" instead. Ex: `[{sales_rep: { join_method: :left_outer_joins }}]`
316
397
 
317
398
  - `datetime_drill` - To drill up and down over datetime column you may pass a `datetime_drill`. Ex: `[:sales_rep, { order: { field: :created_at, datetime_drill: :month }}]`. This option will perform an implicit drill over datetime columns and not a date dimension relationship.
318
399
 
319
- `dimension_filter` - A hash were the keys are dimension filter names and the values are the values passed into the filter.
400
+ `dimension_filter` - A hash where the keys are dimension filter names and the values are the values passed into the filter.
320
401
 
321
402
  `metric_filter` - An additional HAVING clause to be tacked on to the end of the query. This allows for the further filtering of the end results based on the value of the aggregate. (Examples: `{gt: 3}`, `{eq: 5}`, `{lte: 7}`)
322
403
 
323
- `order_by_dimension` - Allows you to set the ordering of the results based on a dimension label. (Examples: `{author: :desc}`, `{sales_ref: :asc}`)
404
+ `measure` - Override the measure on the `ActiveReporting::FactModel` used.
405
+
406
+ `order_by_dimension` - Allows you to set the ordering of the results based on a dimension label. The dimension must be one of the report's dimensions, either from the metric or added by the report, or `UnknownDimension` is raised. (Examples: `{author: :desc}`, `{sales_rep: :asc}`)
324
407
 
325
- For those using Postgres, you can take advantage of implicit hierarchies in `datetime` columns, as mentioned above:
408
+ You can take advantage of datetime drills on `datetime` columns, as mentioned above:
326
409
 
327
410
  ```ruby
328
411
  class UserFactModel < ActiveReporting::FactModel
@@ -338,7 +421,7 @@ my_metric = ActiveReporting::Metric.new(
338
421
 
339
422
  ## ActiveReporting::Report
340
423
 
341
- A `Report` takes an `ActiveReporting::Metric` and ties everything together. It is responsible for building and executing the query to generate a result. The result is an simple array of hashing.
424
+ A `Report` takes an `ActiveReporting::Metric` and ties everything together. It is responsible for building and executing the query to generate a result. The result is a simple array of hashes.
342
425
 
343
426
  ```ruby
344
427
  metric = ActiveReporting::Metric.new(
@@ -355,13 +438,13 @@ report.run
355
438
 
356
439
  A `Report` may also take additional arguments to merge with the `Metric`'s information. This can be user input for additional filters, or to expand on a base `Metric`.
357
440
 
358
- `dimension_identifiers` - When true, the result will include the database identifier columns of the dimensions. For example, when running a report for the total number of sales dimensioned by sales rep, the rep's IDs from the `sales_reps` table will be included. (Default `true`)
441
+ `dimension_identifiers` - When true, the result will include the database identifier columns of the dimensions. For example, when running a report for the total number of sales dimensioned by sales rep, the rep's IDs from the `sales_reps` table will be included. Identifiers are only included for dimensions labeled by their default label; a dimension using another hierarchy level (`field`) or a `datetime_drill` groups many records into one row, so it has no identifier. (Default `true`)
359
442
 
360
- `dimension_filter` - A hash that will be merged with the `Metric`'s dimension filters.
443
+ `dimension_filter` - A hash that will be merged with the `Metric`'s dimension filters. The `Metric`'s dimension filters are part of its definition, so when both set the same filter, the `Metric`'s value is used. This makes it safe to pass user input here without it widening what the metric measures.
361
444
 
362
445
  `dimensions` - An array of additional dimensions which are merged with the `Metric`'s dimensions.
363
446
 
364
- `metric_filter` - Sets the HAVING clause of the final query and is merged with the `Metric`'s metric filter.
447
+ `metric_filter` - Sets the HAVING clause of the final query and is merged with the `Metric`'s metric filter. Unlike dimension filters, when both set the same operator, the `Report`'s value replaces the `Metric`'s.
365
448
 
366
449
  ```ruby
367
450
  metric = ActiveReporting::Metric.new(
@@ -376,7 +459,7 @@ report.run
376
459
  => [{sale_count: 17, sales_rep: 'Mary Sue'}]
377
460
  ```
378
461
 
379
- It may be more DRY to store ready-made metrics in a database table or stored in memory to use as the bases for various reports. You can pass a string or symbol into a `Report` instead of a `Metric` to look up an pre-made metric. This is done by passing the symbol or string into the `lookup` class method on the constant defined in `ActiveReporting::Configuration.metric_lookup_class`.
462
+ It may be more DRY to store ready-made metrics in a database table or stored in memory to use as the basis for various reports. You can pass a string or symbol into a `Report` instead of a `Metric` to look up a pre-made metric. This is done by passing the symbol or string into the `lookup` class method on the constant defined in `ActiveReporting::Configuration.metric_lookup_class`.
380
463
 
381
464
  ```ruby
382
465
  class StoredMetrics
@@ -392,18 +475,31 @@ report = ActiveReporting::Report.new(:a_stored_metric, ...)
392
475
 
393
476
  ## Development
394
477
 
395
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
478
+ After checking out the repo, run `bin/setup` to install dependencies, then `bundle exec rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
479
+
480
+ Before opening a pull request, run `bundle exec rubocop` and, if you have [codespell](https://github.com/codespell-project/codespell) installed, `codespell`. CI runs both.
396
481
 
397
482
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
398
483
 
399
484
  ## Testing
400
485
 
401
- You can run the test suite using `rake test`. To test against a particular database, you'll need to set the
402
- appropriate `DB` environment variable, e.g. `DB=pg rake test`.
486
+ The tests run against an in-memory SQLite database by default. To test against another database, set `DB` to `pg`, `mysql` (the `mysql2` adapter), or `trilogy`, and run `bin/setup` once for it:
487
+
488
+ ```
489
+ DB=pg bin/setup
490
+ DB=pg bundle exec rake test
491
+ ```
492
+
493
+ `bin/setup` installs the database's adapter gem (each one is in an optional Gemfile group, so you only need the client libraries for databases you test against) and creates the `active_reporting_test` database. It needs the database server to be running. Connection settings come from these environment variables:
494
+
495
+ - PostgreSQL: `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD` (plus the standard `PG*` variables, such as `PGPORT`)
496
+ - MySQL: `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`
497
+
498
+ Set `RAILS` (e.g. `RAILS=8.0`) when installing and running to test against a specific Rails version.
403
499
 
404
500
  ## Contributing
405
501
 
406
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/active_reporting. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
502
+ Bug reports and pull requests are welcome on GitHub at https://github.com/t27duck/active_reporting. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
407
503
 
408
504
  ## License
409
505
 
@@ -5,19 +5,11 @@ module ActiveReporting
5
5
  module ActiveRecordAdaptor
6
6
  # Returns the ActiveReporting::FactModel related to the model.
7
7
  #
8
- # If one is not explictily defined, a constant will be created which
9
- # inherits from ActiveReporting::Factmodel named [MyModel]FactModel
8
+ # If a [MyModel]FactModel class is not defined, a fact model is generated for the model.
10
9
  #
11
10
  # @return [ActiveReporting::FactModel]
12
11
  def fact_model
13
- const_name = "#{name}FactModel"
14
- @fact_model ||= begin
15
- const_name.constantize
16
- rescue NameError
17
- const = Object.const_set(const_name, Class.new(ActiveReporting::FactModel))
18
- const.model = self
19
- const
20
- end
12
+ FactModelRegistry.fetch(self)
21
13
  end
22
14
  end
23
15
  end