active_reporting 0.6.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc7dff49fb9bffe3ea663751a710d20343a8dd6a218a088433e772884ab3412b
4
- data.tar.gz: 9c03f8ed6f0247d911da6290b120c8b74643cc780761585cd8b0b85ef4a55ff3
3
+ metadata.gz: f2739361482925da496d90c15b8aa28c386587f7efaa9d37b918d1847eaf2c45
4
+ data.tar.gz: 501c16713ac1d1d10eccf5679a820c920b7459321c07fbf2168c1091e12a1f01
5
5
  SHA512:
6
- metadata.gz: 4c48dae4c7a6a5389241ef0ee3d01038073d2c75582b8b60aaa93be0e41a3e70abc84f87d609071bca9e86b72968a842f65af199d1a7f7f84e8c449ab720cf9e
7
- data.tar.gz: 003b8752db40a55ac09dde37d6f32d21bc14d921c88011393339b4f59554dedfe10557d49545f929d5ea1c7f5a64911fd1fda9fc5bf0d67823e9b68eae9396c1
6
+ metadata.gz: 3d8e32d39baba7d583ce6a9734e1040252927ef1eee97db85ed6b28e47484f99c31fcea3e897a1f6be55f79da2eb5795227d36a742a11c627ca07a31f0247a5a
7
+ data.tar.gz: 94ebd8032809fbede8e295375cf76f073c183807b417868567670f40effb4df7d5e130cd94ae79a8cd80f50c4c35b3a1c24c1878ef2e53704e1c4016dce7f027
data/CHANGELOG.md CHANGED
@@ -1,114 +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
+
1
51
  ## 0.6.2 (2024-03-18)
2
52
 
3
53
  ### Features
4
54
 
5
- * `Metric` can now take an optional `measure` argument to override the measure that would normally be used.
55
+ - `Metric` can now take an optional `measure` argument to override the measure that would normally be used.
6
56
 
7
57
  ## 0.6.1 (2020-08-29)
8
58
 
9
59
  ### Misc
10
60
 
11
- * Add `date` as an option for datetime drills - *germanotm*
61
+ - Add `date` as an option for datetime drills - _germanotm_
12
62
 
13
63
  ## 0.6.0 (2020-08-21)
14
64
 
15
65
  ### Features
16
66
 
17
- * Support to implicit hierarchical on datetime columns in MySQL (#33) - *germanotm*
18
- * 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_
19
69
 
20
- 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 }]`).
21
71
  Instead, use `dimensions: [{ dim: { option: value} }]` See the README for all reporting dimension options.
22
72
 
23
73
  ## 0.5.1 (2020-06-31)
24
74
 
25
75
  ### Features
26
76
 
27
- * 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_
28
78
 
29
79
  ### Misc
30
80
 
31
- * Fixed warning about initialized variables
32
- * Fixed Ruby 2.7 warning
81
+ - Fixed warning about initialized variables
82
+ - Fixed Ruby 2.7 warning
33
83
 
34
84
  ## 0.5.0 (2020-06-30)
35
85
 
36
86
  ### Bug Fixes
37
87
 
38
- * 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_
39
89
 
40
90
  ### Misc
41
91
 
42
- * 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.
43
93
 
44
94
  ## 0.4.2 (2019-11-01)
45
95
 
46
96
  ### Misc
47
97
 
48
- * Test against Rails 6.0 final
49
- * Fixed deprecated call to `to_hash` - *joshforbes*
50
- * 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_
51
101
 
52
102
  ## 0.4.1 (2019-05-28)
53
103
 
54
104
  ### Features
55
105
 
56
- * Hierarchical dimensions may now have custom keys in result (#16) - *andresgutgon*
106
+ - Hierarchical dimensions may now have custom keys in result (#16) - _andresgutgon_
57
107
 
58
108
  ### Misc
59
109
 
60
- * Test against Raisl 6.0RC
61
- * Loosen AR requirements. The gem will install for any AR version, but only ones listed in the README are supported
62
- * 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
63
113
 
64
114
  ## 0.4.0 (2018-05-02)
65
115
 
66
116
  ### Breaking Changes
67
117
 
68
- * Gemspec now requires Ruby 2.3 and later to install
118
+ - Gemspec now requires Ruby 2.3 and later to install
69
119
 
70
120
  ### Features
71
121
 
72
- * 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_
73
123
 
74
124
  ## 0.3.0 (2018-04-12)
75
125
 
76
126
  ### Bug Fixes
77
127
 
78
- * Specify rescue from LoadError for ransack (#9) - *niborg*
79
- * Fix ransack fallback logic (#8) - *germanotm*
128
+ - Specify rescue from LoadError for ransack (#9) - _niborg_
129
+ - Fix ransack fallback logic (#8) - _germanotm_
80
130
 
81
131
  ### Misc
82
132
 
83
- * Test against Rails 5.2
84
- * Test against Ruby 2.5
85
- * Drop support for Rails 5.0 (EOL-ed)
86
- * 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)
87
137
 
88
138
  ## 0.2.0 (2017-06-17)
89
139
 
90
140
  ### Breaking Changes
91
141
 
92
- * `FactModel.use_model` renamed to `FactModel.model=`
142
+ - `FactModel.use_model` renamed to `FactModel.model=`
93
143
 
94
144
  ### Bug Fixes
95
145
 
96
- * `metric` lives on fact model and not metric (#3) - *wheeyls*
146
+ - `metric` lives on fact model and not metric (#3) - _wheeyls_
97
147
 
98
148
  ### Misc
99
149
 
100
- * Readme corrections and updates (#2) - *wheeyls*
150
+ - Readme corrections and updates (#2) - _wheeyls_
101
151
 
102
152
  ## 0.1.1 (2017-04-22)
103
153
 
104
154
  ### Bug Fixes
105
155
 
106
- * Fix MySQL querying
156
+ - Fix MySQL querying
107
157
 
108
158
  ### Misc
109
159
 
110
- * Properly test against multiple dbs and versions of ActiveRecord in CI
160
+ - Properly test against multiple dbs and versions of ActiveRecord in CI
111
161
 
112
162
  ## 0.1.0 (2017-04-16)
113
163
 
114
- * Initial release
164
+ - Initial release
data/README.md CHANGED
@@ -6,9 +6,9 @@ ActiveReporting implements various terminology used in Relational Online Analyti
6
6
 
7
7
  ActiveReporting officially supports MySQL, PostgreSQL, and SQLite.
8
8
 
9
- ActiveReporting officially supports Ruby 3.0 and later. Other versions may work, but are not supported.
9
+ ActiveReporting officially supports [Ruby versions under official support](https://endoflife.date/ruby).
10
10
 
11
- ActiveReporting officially supports Rails 6.1 - 7.1. Other versions may work, but are not supported.
11
+ ActiveReporting officially supports [Rails versions under official support](https://endoflife.date/rails).
12
12
 
13
13
  ## Installation
14
14
 
@@ -28,13 +28,13 @@ Or install it yourself as:
28
28
 
29
29
  ## What is "Reporting"?
30
30
 
31
- 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.
32
32
 
33
33
  ## Terminology
34
34
 
35
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.
36
36
 
37
- ### Fact table (can be sometimes called fact model)
37
+ ### Fact table (sometimes called fact model)
38
38
 
39
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).
40
40
 
@@ -48,9 +48,9 @@ A dimension is a point of data used to "slice and dice" data from a fact model.
48
48
 
49
49
  Examples:
50
50
 
51
- * A sales rep on a fact table of sales
52
- * A state of an sale on a state machine
53
- * 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
54
54
 
55
55
  SQL Equivalent: JOIN, GROUP BY
56
56
 
@@ -62,8 +62,8 @@ A hierarchy for a dimension is related attributes that live on a dimension table
62
62
 
63
63
  Examples:
64
64
 
65
- * Dates: Date, Month, Year, Quarter
66
- * Mobile Phone: Model, Manufacture, OS, Wireless Technology
65
+ - Dates: Date, Month, Year, Quarter
66
+ - Mobile Phone: Model, Manufacturer, OS, Wireless Technology
67
67
 
68
68
  ### Dimension Member (also known as dimension labels)
69
69
 
@@ -71,7 +71,7 @@ This is information related to a dimension. When the dimension lives on the fact
71
71
 
72
72
  Examples:
73
73
 
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.
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.
75
75
 
76
76
  ### Dimension Filter (or just "filter")
77
77
 
@@ -87,8 +87,8 @@ A measure is a column in a fact table (usually a numeric value) used in aggregat
87
87
 
88
88
  Examples:
89
89
 
90
- * Total amount in a sale
91
- * Number of units used in a transaction
90
+ - Total amount in a sale
91
+ - Number of units used in a transaction
92
92
 
93
93
  SQL Equivalent: Column in the fact table used in an aggregation function
94
94
 
@@ -96,7 +96,7 @@ Rails: ActiveRecord attribute
96
96
 
97
97
  ### Metric
98
98
 
99
- 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.
100
100
 
101
101
  SQL Equivalent: A query result
102
102
 
@@ -106,7 +106,7 @@ Rails: The result of an ActiveRecord query
106
106
 
107
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.
108
108
 
109
- [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)
110
110
 
111
111
  More information: https://en.wikipedia.org/wiki/Star_schema
112
112
 
@@ -114,7 +114,7 @@ More information: https://en.wikipedia.org/wiki/Star_schema
114
114
 
115
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.
116
116
 
117
- [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)
118
118
 
119
119
  More information: https://en.wikipedia.org/wiki/Snowflake_schema
120
120
 
@@ -140,16 +140,15 @@ ActiveReporting::Configuration.setting = value
140
140
 
141
141
  `default_measure` - If a fact model does not specify a measure to use for aggregates, this value will be used. (Default: `:value`)
142
142
 
143
- `ransack_fallback` - If the ransack gem is loaded, allow all unknown dimension filters to be delegated to ransack. (Default: `false`)
144
-
145
- `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`)
146
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`)
147
146
 
148
147
  ## ActiveReporting::FactModel
149
148
 
150
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".
151
150
 
152
- 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
153
152
 
154
153
  ### Linking a fact model to an ActiveRecord model
155
154
 
@@ -163,7 +162,7 @@ class TicketFactModel < ActiveReporting::FactModel
163
162
  end
164
163
  ```
165
164
 
166
- Alternatively, you may manually specify the model manually with `self.model=`
165
+ Alternatively, you may manually specify the model with `self.model=`
167
166
 
168
167
  ```ruby
169
168
  class TicketFactModel < ActiveReporting::FactModel
@@ -175,9 +174,13 @@ class TicketFactModel < ActiveReporting::FactModel
175
174
  end
176
175
  ```
177
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
+
178
181
  ### Configuring a fact model's measure
179
182
 
180
- 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.
181
184
 
182
185
  ```ruby
183
186
  class SaleFactModel < ActiveReporting::FactModel
@@ -189,7 +192,7 @@ end
189
192
 
190
193
  ### Declaring dimensions on a fact model
191
194
 
192
- 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).
193
196
 
194
197
  ```ruby
195
198
  class TicketFactModel < ActiveReporting::FactModel
@@ -199,6 +202,53 @@ class TicketFactModel < ActiveReporting::FactModel
199
202
  end
200
203
  ```
201
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
+
202
252
  ### When a fact model is used as a dimension
203
253
 
204
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.
@@ -213,7 +263,7 @@ end
213
263
 
214
264
  ### Dimension Hierarchies
215
265
 
216
- 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.
217
267
 
218
268
  ```ruby
219
269
  class PhoneFactModel < ActiveReporting::FactModel
@@ -223,7 +273,7 @@ end
223
273
 
224
274
  ### Drill down / Roll up (Drill up) with datetime columns
225
275
 
226
- 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.
227
277
 
228
278
  To use, declare a datetime dimension on a fact model as normal:
229
279
 
@@ -250,9 +300,15 @@ When creating a metric, ActiveReporting will recognize the following datetime hi
250
300
  - millennium
251
301
  - date
252
302
 
253
- 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:
254
308
 
255
- *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.
256
312
 
257
313
  ## Configuring Dimension Filters
258
314
 
@@ -272,7 +328,34 @@ The first example exposes the `Ticket.open` scope to the fact model allowing it
272
328
 
273
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.
274
330
 
275
- 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.
276
359
 
277
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.
278
361
 
@@ -304,25 +387,25 @@ my_metric = ActiveReporting::Metric.new(
304
387
 
305
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.
306
389
 
307
- 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:
308
391
 
309
- - `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.
310
393
 
311
- - `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 }}]`.
312
395
 
313
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 }}]`
314
397
 
315
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.
316
399
 
317
- `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.
318
401
 
319
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}`)
320
403
 
321
- `measure` - Override the measure on the `ActiveReporting::Factmodel` used.
404
+ `measure` - Override the measure on the `ActiveReporting::FactModel` used.
322
405
 
323
- `order_by_dimension` - Allows you to set the ordering of the results based on a dimension label. (Examples: `{author: :desc}`, `{sales_ref: :asc}`)
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