callback_hell 0.2.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +22 -0
- data/README.md +174 -0
- data/file_id.diz +19 -0
- data/lib/callback_hell/analyzers/callback_analyzer.rb +158 -0
- data/lib/callback_hell/analyzers/validation_analyzer.rb +82 -0
- data/lib/callback_hell/callback.rb +56 -0
- data/lib/callback_hell/collector.rb +96 -0
- data/lib/callback_hell/railtie.rb +7 -0
- data/lib/callback_hell/reports/base.rb +18 -0
- data/lib/callback_hell/reports/callbacks/github.rb +29 -0
- data/lib/callback_hell/reports/callbacks/line.rb +19 -0
- data/lib/callback_hell/reports/callbacks/table.rb +29 -0
- data/lib/callback_hell/reports/github_base.rb +67 -0
- data/lib/callback_hell/reports/line_base.rb +42 -0
- data/lib/callback_hell/reports/table_base.rb +92 -0
- data/lib/callback_hell/reports/validations/github.rb +19 -0
- data/lib/callback_hell/reports/validations/line.rb +24 -0
- data/lib/callback_hell/reports/validations/table.rb +19 -0
- data/lib/callback_hell/runner.rb +65 -0
- data/lib/callback_hell/stats.rb +92 -0
- data/lib/callback_hell/version.rb +5 -0
- data/lib/callback_hell.rb +28 -0
- data/lib/tasks/callback_hell.rake +145 -0
- metadata +184 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1e3c885812a73a0a4fd89036e2d1897b64a467130924de668bdfb7dd4c90091b
|
4
|
+
data.tar.gz: 55d44b2e7296574ec3657b9e4d44ea4dfc5b68b9510f87853ff33e41fc40ad9c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a43af18de970ceab56f70a67cbe08c7348b3ff69a5acdbeba4dbdb395173bcfe96c40bc9a1f441074512d42a142e58183c43bcb415c875fdbb93d3e7753b15b7
|
7
|
+
data.tar.gz: 7f51fd27e54b2ee70cf3927d716f3d5c95bfb1d14f240deadd0806ccd9200e480644240c24db0ea045dfb735786519ef844c83794955de24d2c78ae03f3f9bdf
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# Change log
|
2
|
+
|
3
|
+
## 0.2.0
|
4
|
+
|
5
|
+
- Initial public release. ([@yaroslav][])
|
6
|
+
|
7
|
+
- Make the default namespace-level rake task to run the full report (callbacks and validations). ([@yaroslav][])
|
8
|
+
|
9
|
+
## 0.1.1
|
10
|
+
|
11
|
+
- Migrated from `terminal-table` to `table_tennis`. ([@palkan][])
|
12
|
+
|
13
|
+
- Added `mode=default | full` parameter to hide Rails internal callbacks/validations by default. ([@palkan][])
|
14
|
+
|
15
|
+
## 0.1.0
|
16
|
+
|
17
|
+
- Initial version. See `README.md` for details. ([@yaroslav][])
|
18
|
+
|
19
|
+
[@palkan]: https://github.com/palkan
|
20
|
+
[@yaroslav]: https://github.com/yaroslav
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2024 Yaroslav Markin, Vladimir Dementyev
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
[](https://rubygems.org/gems/callback_hell) [](https://github.com/evilmartians/callback_hell/actions)
|
2
|
+
|
3
|
+
# Callback Hell
|
4
|
+
|
5
|
+
> You live in it.
|
6
|
+
|
7
|
+
Callback Hell is a Ruby gem for use with Ruby on Rails applications.
|
8
|
+
|
9
|
+
It analyzes your Rails application models and provides actionable insights on callbacks and validations. Use it to identify models that might benefit from refactoring, spot callback pollution from gems and associations, and keep your models clean and maintainable.
|
10
|
+
|
11
|
+
<a href="https://evilmartians.com/?utm_source=callback_hell">
|
12
|
+
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54">
|
13
|
+
</a>
|
14
|
+
|
15
|
+
## Why bother?
|
16
|
+
|
17
|
+
As Rails applications grow, callbacks can quickly spiral out of control. Callback Hell helps you:
|
18
|
+
|
19
|
+
- **Visualize callback complexity** across your entire application
|
20
|
+
- **Identify callback hotspots** that need refactoring attention
|
21
|
+
- **Track callback origins**: distinguish your code from Rails internals and gem callbacks
|
22
|
+
- **Spot inheritance issues** and understand callback propagation
|
23
|
+
- **Audit conditional callbacks** that might be hiding bugs
|
24
|
+
|
25
|
+
## Quick start
|
26
|
+
|
27
|
+
Add to your Rails application's `Gemfile`:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
gem "callback_hell", group: :development
|
31
|
+
```
|
32
|
+
|
33
|
+
And then:
|
34
|
+
|
35
|
+
```bash
|
36
|
+
bundle install
|
37
|
+
```
|
38
|
+
|
39
|
+
### Basic usage
|
40
|
+
|
41
|
+
Generate a complete analysis report (callbacks _and_ validations):
|
42
|
+
|
43
|
+
```bash
|
44
|
+
bin/rails ch
|
45
|
+
```
|
46
|
+
|
47
|
+
Sample output:
|
48
|
+
|
49
|
+
<img src="https://raw.githubusercontent.com/evilmartians/callback_hell/refs/heads/main/assets/report.png" width="758" height="767" alt="Callback Hell sample output">
|
50
|
+
|
51
|
+
Or run specific reports:
|
52
|
+
|
53
|
+
```bash
|
54
|
+
# Just callbacks
|
55
|
+
bin/rails ch:callbacks
|
56
|
+
|
57
|
+
# Just validations
|
58
|
+
bin/rails ch:validations
|
59
|
+
```
|
60
|
+
|
61
|
+
### Requirements
|
62
|
+
|
63
|
+
We support Ruby 3.0+ and Rails 7.0+.
|
64
|
+
|
65
|
+
## Usage
|
66
|
+
|
67
|
+
**Note:** you can use both `rake` or `bin/rails` as you wish.
|
68
|
+
|
69
|
+
### Command line options
|
70
|
+
|
71
|
+
All rake tasks support the following options:
|
72
|
+
|
73
|
+
#### Output formats
|
74
|
+
|
75
|
+
```bash
|
76
|
+
# Table format (default)
|
77
|
+
bin/rails callback_hell:callbacks format=table
|
78
|
+
|
79
|
+
# Line format: detailed per-callback breakdown, useful for debugging
|
80
|
+
bin/rails callback_hell:callbacks format=line
|
81
|
+
|
82
|
+
# GitHub Actions format for CI/CD
|
83
|
+
bin/rails callback_hell:callbacks format=github
|
84
|
+
```
|
85
|
+
|
86
|
+
#### Model filtering
|
87
|
+
|
88
|
+
```bash
|
89
|
+
# Analyze specific model by class name
|
90
|
+
bin/rails ch model=User
|
91
|
+
|
92
|
+
# Or by file path
|
93
|
+
bin/rails ch model=admin/user
|
94
|
+
|
95
|
+
# Works with namespaced models too
|
96
|
+
bin/rails ch model=Admin::User
|
97
|
+
```
|
98
|
+
|
99
|
+
#### Sorting
|
100
|
+
|
101
|
+
```bash
|
102
|
+
# Sort by callback count (default)
|
103
|
+
bin/rails ch sort=size:desc
|
104
|
+
|
105
|
+
# Sort alphabetically
|
106
|
+
bin/rails ch sort=name:asc
|
107
|
+
```
|
108
|
+
|
109
|
+
#### Analysis modes
|
110
|
+
|
111
|
+
```bash
|
112
|
+
# Default mode - your callbacks only
|
113
|
+
bin/rails ch mode=default
|
114
|
+
|
115
|
+
# Full mode - includes Rails internals and associations
|
116
|
+
bin/rails ch mode=full
|
117
|
+
```
|
118
|
+
|
119
|
+
#### Custom model paths
|
120
|
+
|
121
|
+
```bash
|
122
|
+
# Include models from engines or non-standard locations
|
123
|
+
bin/rails ch path=engines/admin/app/models,lib/models
|
124
|
+
```
|
125
|
+
|
126
|
+
### Understanding the output
|
127
|
+
|
128
|
+
#### Callback origins
|
129
|
+
|
130
|
+
- **Own**: Callbacks defined in your application code
|
131
|
+
- **Rails**: Built-in Rails framework callbacks
|
132
|
+
- **Gems**: Callbacks from external gems
|
133
|
+
- **Inherited**: Callbacks inherited from parent classes or modules
|
134
|
+
|
135
|
+
#### Special Categories
|
136
|
+
|
137
|
+
- **Conditional**: Callbacks with `:if` or `:unless` conditions
|
138
|
+
- **Associations**: Auto-generated by Rails associations (`has_many`, `belongs_to`, etc.)
|
139
|
+
- **Attributes**: Generated by Rails attribute features (encryption, normalization, etc.)
|
140
|
+
|
141
|
+
### Integration with CI/CD
|
142
|
+
|
143
|
+
You can try the GitHub Actions format to integrate with your CI pipeline:
|
144
|
+
|
145
|
+
```yaml
|
146
|
+
# .github/workflows/callback_analysis.yml
|
147
|
+
name: Callback Analysis
|
148
|
+
on: [push, pull_request]
|
149
|
+
|
150
|
+
jobs:
|
151
|
+
analyze:
|
152
|
+
runs-on: ubuntu-latest
|
153
|
+
steps:
|
154
|
+
- uses: actions/checkout@v3
|
155
|
+
- uses: ruby/setup-ruby@v1
|
156
|
+
with:
|
157
|
+
bundler-cache: true
|
158
|
+
- name: Analyze callbacks
|
159
|
+
run: bin/rails ch format=github
|
160
|
+
```
|
161
|
+
|
162
|
+
## Credits and acknowledgements
|
163
|
+
|
164
|
+
Callback Hell is a spiritual successor of the [arca](https://github.com/jonmagic/arca) gem by [Jonathan Hoyt](https://github.com/jonmagic).
|
165
|
+
|
166
|
+
The entire idea and a detailed specification for the gem was done by [Vladimir Dementyev](https://github.com/palkan) who initially offered it as a test task for [Evil Martians](https://evilmartians.com/?utm_source=callback_hell) Ruby developer take-home task.
|
167
|
+
|
168
|
+
## Contributing
|
169
|
+
|
170
|
+
Bug reports and pull requests are welcome at [https://github.com/evilmartians/callback_hell](https://github.com/evilmartians/callback_hell).
|
171
|
+
|
172
|
+
## License
|
173
|
+
|
174
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/file_id.diz
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
.. ..
|
2
|
+
.GP YH.
|
3
|
+
GP^Y$b.dBBBBBBBBBBBBBBBBBBBb.d$Y^YD .dBBBBBBBBBBBBBBBBBBBb.
|
4
|
+
Y$$$$$$$$$$$$$$$$$$$$$$$$$Y dSSSSSSSSSSSSSSSSSSSSSSSSSb
|
5
|
+
d$$$$$$$$$$$$$$$$$$$$$$$$$$$b dSYSSSYYSSSYYSSSYYSSSYYSSSYSb
|
6
|
+
M$$$$$$$$$$$$$$$$$$$$$$$$$$$M MS.`Y´.L`Y´.L`Y´.L`Y´.L`Y´.SM
|
7
|
+
M$$$$$$$$$$$$$$$$$$$$$$$$$$$M MSSb.dSSb.dSSb.dSSb.dSSb.dSSM
|
8
|
+
M$$$$$$P^^^Y$$$$$P^^^Y$$$$$$M MSSSSSSP^^^YSSSSSP^^^YSSSSSSM
|
9
|
+
M$$$$$´.qop.`$$$´.qop.`$$$$$M .qGSSSSSS´.qop.`SSS´.qop.`SSSSSSDp.
|
10
|
+
M$$$$$ G( )D $$$ G( )D $$$$$M GY´MSSSSS G( )D SSS G( )D SSSSSM`YD
|
11
|
+
M$$$$$.`YPP´.$$$.`YPP´.$$$$$M Gb.MSSSSS.`YPP´.SSS.`YPP´.SSSSSM.dD
|
12
|
+
M$$$$$$bwwwd$$$$$bwwwd$$$$$$M `YGSSSSSSSbwwwdSSSSSbwwwdSSSSSSSDY´
|
13
|
+
M$$$$$$$$$$$$$$$$$$$$$$$$$$$M MSSSSSSSSSSSSSSSSSSSSSSSSSSSM
|
14
|
+
M$$$$$$$$$$$$$$$$$$$$$$$$$$$M MSSSSSSSSSSSSSSSSSSSSSSSSSSSM
|
15
|
+
MBBBBBBBBBBBBBBBBBBBBBBBBBBBM MBBBBBBBBBBBBBBBBBBBBBBBBBBBM
|
16
|
+
|
17
|
+
twitter.com/evilmartians github.com/evilmartians jobs: evl.ms/jobs
|
18
|
+
|
19
|
+
since 2006
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CallbackHell
|
4
|
+
module Analyzers
|
5
|
+
class CallbackAnalyzer
|
6
|
+
RAILS_GEMS = %w[
|
7
|
+
actioncable actionmailbox actionmailer actionpack actiontext
|
8
|
+
actionview activejob activemodel activerecord activestorage
|
9
|
+
activesupport railties
|
10
|
+
].freeze
|
11
|
+
|
12
|
+
RAILS_ATTRIBUTE_OWNERS = [
|
13
|
+
defined?(ActiveRecord::Normalization) ? ActiveRecord::Normalization : ActiveModel::Attributes::Normalization,
|
14
|
+
ActiveRecord::Encryption::EncryptableRecord
|
15
|
+
].freeze
|
16
|
+
|
17
|
+
def initialize(callback, model, defining_class)
|
18
|
+
@callback = callback
|
19
|
+
@model = model
|
20
|
+
@defining_class = defining_class
|
21
|
+
@filter = callback.filter
|
22
|
+
end
|
23
|
+
|
24
|
+
def origin
|
25
|
+
if rails_callback?
|
26
|
+
:rails
|
27
|
+
elsif external_class?
|
28
|
+
:gems
|
29
|
+
elsif !@filter.is_a?(Symbol)
|
30
|
+
:own
|
31
|
+
else
|
32
|
+
external_method?(callback_method) ? :gems : :own
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def inherited?
|
37
|
+
@model != @defining_class
|
38
|
+
end
|
39
|
+
|
40
|
+
def conditional?
|
41
|
+
[@callback.instance_variable_get(:@if),
|
42
|
+
@callback.instance_variable_get(:@unless)].any? do |condition|
|
43
|
+
next false if condition.nil?
|
44
|
+
[*condition].any? { |c| c.is_a?(Symbol) || c.is_a?(Proc) }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def association_generated?
|
49
|
+
generated_by_module?("GeneratedAssociationMethods") ||
|
50
|
+
from_rails_path?(%r{/active_record/(autosave_association\.rb|associations/builder)}) ||
|
51
|
+
ValidationAnalyzer.belongs_to_validator?(@filter, @model)
|
52
|
+
end
|
53
|
+
|
54
|
+
def attribute_generated?
|
55
|
+
generated_by_module?("GeneratedAttributeMethods") ||
|
56
|
+
generated_by_rails_attributes? ||
|
57
|
+
from_rails_path?("active_record/attribute_methods/")
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def rails_callback?
|
63
|
+
ValidationAnalyzer.belongs_to_validator?(@filter, @model) || standard_rails_callback?
|
64
|
+
end
|
65
|
+
|
66
|
+
def standard_rails_callback?
|
67
|
+
case @filter
|
68
|
+
when Symbol, Proc then from_rails_path?
|
69
|
+
else @defining_class == ApplicationRecord
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def callback_owner
|
74
|
+
@callback_owner ||= determine_owner
|
75
|
+
end
|
76
|
+
|
77
|
+
def determine_owner
|
78
|
+
case @filter
|
79
|
+
when Symbol then callback_method&.owner
|
80
|
+
when Proc then nil
|
81
|
+
when ActiveModel::Validator, ActiveModel::EachValidator then @defining_class
|
82
|
+
else @filter.class
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def callback_method
|
87
|
+
return nil unless @filter.is_a?(Symbol) || @filter.is_a?(String)
|
88
|
+
|
89
|
+
@callback_method ||= begin
|
90
|
+
@model.instance_method(@filter)
|
91
|
+
rescue
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def source_location
|
97
|
+
@source_location ||= case @filter
|
98
|
+
when Symbol, String then callback_method&.source_location&.first
|
99
|
+
when Proc then @filter.source_location&.first
|
100
|
+
end.to_s
|
101
|
+
end
|
102
|
+
|
103
|
+
def external_class?
|
104
|
+
@defining_class != @model && !@model.ancestors.include?(@defining_class)
|
105
|
+
end
|
106
|
+
|
107
|
+
def external_method?(method)
|
108
|
+
return false unless method
|
109
|
+
|
110
|
+
source = method.source_location&.first.to_s
|
111
|
+
!from_app_path?(source)
|
112
|
+
end
|
113
|
+
|
114
|
+
def from_app_path?(path)
|
115
|
+
path.start_with?(Rails.root.to_s) &&
|
116
|
+
!path.start_with?(Rails.root.join("vendor").to_s)
|
117
|
+
end
|
118
|
+
|
119
|
+
def generated_by_module?(suffix)
|
120
|
+
callback_method&.owner&.name&.end_with?("::" + suffix) || false
|
121
|
+
end
|
122
|
+
|
123
|
+
def generated_by_rails_attributes?
|
124
|
+
method = callback_method
|
125
|
+
return false unless method
|
126
|
+
|
127
|
+
RAILS_ATTRIBUTE_OWNERS.include?(method.owner)
|
128
|
+
end
|
129
|
+
|
130
|
+
def from_rails_path?(subpath = nil)
|
131
|
+
return false if source_location.empty?
|
132
|
+
|
133
|
+
rails_paths.any? do |rails_path|
|
134
|
+
case subpath
|
135
|
+
when String
|
136
|
+
source_location.include?("/#{subpath}")
|
137
|
+
when Regexp
|
138
|
+
source_location.match?(subpath)
|
139
|
+
else
|
140
|
+
source_location.include?(rails_path)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def rails_paths
|
146
|
+
@rails_paths ||= RAILS_GEMS.map { |name| Gem::Specification.find_by_name(name).full_gem_path }
|
147
|
+
end
|
148
|
+
|
149
|
+
def rails_module?(mod)
|
150
|
+
mod.name&.start_with?("ActiveRecord::", "ActiveModel::")
|
151
|
+
end
|
152
|
+
|
153
|
+
def validator?(obj)
|
154
|
+
obj.is_a?(ActiveModel::Validator)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CallbackHell
|
4
|
+
module Analyzers
|
5
|
+
class ValidationAnalyzer
|
6
|
+
STANDARD_VALIDATIONS = %w[
|
7
|
+
presence uniqueness format length inclusion exclusion
|
8
|
+
numericality acceptance confirmation
|
9
|
+
].freeze
|
10
|
+
|
11
|
+
STANDARD_VALIDATION_PATTERN = /^validates?_(#{STANDARD_VALIDATIONS.join("|")})(?:_of)?$/
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def belongs_to_validator?(filter, model)
|
15
|
+
presence_validator?(filter) &&
|
16
|
+
association_attribute?(filter.attributes.first, model, :belongs_to)
|
17
|
+
end
|
18
|
+
|
19
|
+
def detect_type(filter, model)
|
20
|
+
return nil unless filter
|
21
|
+
|
22
|
+
if belongs_to_validator?(filter, model)
|
23
|
+
"associated"
|
24
|
+
elsif validator?(filter)
|
25
|
+
validator_type(filter)
|
26
|
+
else
|
27
|
+
normalize_validation_name(filter.to_s)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def human_method_name(filter)
|
32
|
+
case filter
|
33
|
+
when Proc then format_proc_location(filter)
|
34
|
+
when Class, ActiveModel::Validator then format_validator(filter)
|
35
|
+
else filter.to_s
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def presence_validator?(filter)
|
42
|
+
filter.is_a?(ActiveRecord::Validations::PresenceValidator)
|
43
|
+
end
|
44
|
+
|
45
|
+
def association_attribute?(attribute, model, macro)
|
46
|
+
model.reflect_on_association(attribute)&.macro == macro
|
47
|
+
end
|
48
|
+
|
49
|
+
def validator?(obj)
|
50
|
+
obj.class <= ActiveModel::EachValidator
|
51
|
+
end
|
52
|
+
|
53
|
+
def validator_type(validator)
|
54
|
+
validator.class.name.demodulize.sub("Validator", "").underscore
|
55
|
+
end
|
56
|
+
|
57
|
+
def normalize_validation_name(name)
|
58
|
+
case name
|
59
|
+
when STANDARD_VALIDATION_PATTERN, /^validate_(#{STANDARD_VALIDATIONS.join("|")})$/
|
60
|
+
$1
|
61
|
+
when /associated_records_for_/
|
62
|
+
"associated"
|
63
|
+
else
|
64
|
+
"custom"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def format_proc_location(proc)
|
69
|
+
location = proc.source_location
|
70
|
+
return "Proc (unknown location)" unless location
|
71
|
+
|
72
|
+
file = location.first.split("/").last(2).join("/")
|
73
|
+
"Proc (#{file}:#{location.last})"
|
74
|
+
end
|
75
|
+
|
76
|
+
def format_validator(validator)
|
77
|
+
"#{validator.class.name.split("::").last} (#{validator.attributes.join(", ")})"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CallbackHell
|
4
|
+
class Callback
|
5
|
+
attr_reader :model, :method_name, :conditional, :origin, :inherited, :kind,
|
6
|
+
:association_generated, :attribute_generated, :callback, :defining_class,
|
7
|
+
:fingerprint
|
8
|
+
|
9
|
+
def initialize(model:, rails_callback:, name:, defining_class:)
|
10
|
+
@model = model
|
11
|
+
@callback = rails_callback
|
12
|
+
@name = name
|
13
|
+
@defining_class = defining_class
|
14
|
+
|
15
|
+
analyzer = Analyzers::CallbackAnalyzer.new(@callback, model, defining_class)
|
16
|
+
|
17
|
+
@kind = @callback.kind
|
18
|
+
@method_name = @callback.filter
|
19
|
+
@conditional = analyzer.conditional?
|
20
|
+
@origin = analyzer.origin
|
21
|
+
@inherited = analyzer.inherited?
|
22
|
+
@association_generated = analyzer.association_generated?
|
23
|
+
@attribute_generated = analyzer.attribute_generated?
|
24
|
+
# fingerprint allows us to de-duplicate callbacks/validations;
|
25
|
+
# in most cases, it's just an object_id, but for named validations/callbacks,
|
26
|
+
# it's a combination of the name, kind and the method_name.
|
27
|
+
# The "0" and "1" prefixes define how to handle duplicates (1 means last write wins, 0 means first write wins)
|
28
|
+
@fingerprint = (@method_name.is_a?(Symbol) && @origin != :rails) ? ["1", @name, @kind, @method_name].join("-") : "0-#{@callback.object_id}"
|
29
|
+
end
|
30
|
+
|
31
|
+
def callback_group
|
32
|
+
@name.to_s
|
33
|
+
end
|
34
|
+
|
35
|
+
def validation_type
|
36
|
+
return nil unless callback_group == "validate"
|
37
|
+
Analyzers::ValidationAnalyzer.detect_type(@callback.filter, model)
|
38
|
+
end
|
39
|
+
|
40
|
+
def human_method_name
|
41
|
+
Analyzers::ValidationAnalyzer.human_method_name(@callback.filter)
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_s
|
45
|
+
[
|
46
|
+
"#{model.name}: #{human_method_name}",
|
47
|
+
"kind=#{kind}_#{callback_group}",
|
48
|
+
"origin=#{origin}",
|
49
|
+
inherited ? "inherited=true" : nil,
|
50
|
+
conditional ? "conditional=true" : nil,
|
51
|
+
association_generated ? "association=true" : nil,
|
52
|
+
attribute_generated ? "attribute=true" : nil
|
53
|
+
].compact.join(" ")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "set"
|
4
|
+
|
5
|
+
module CallbackHell
|
6
|
+
class Collector
|
7
|
+
attr_reader :models
|
8
|
+
|
9
|
+
def initialize(models = nil, paths: nil, kind: :callbacks, mode: :default)
|
10
|
+
@paths = paths
|
11
|
+
@kind = kind
|
12
|
+
@mode = mode
|
13
|
+
|
14
|
+
eager_load!
|
15
|
+
@models = Set.new(models ? [*models] : ApplicationRecord.descendants)
|
16
|
+
end
|
17
|
+
|
18
|
+
def collect(select_models = models)
|
19
|
+
select_models.flat_map { |model| collect_for_model(model) }
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def eager_load!
|
25
|
+
Rails.application.eager_load!
|
26
|
+
load_additional_paths
|
27
|
+
end
|
28
|
+
|
29
|
+
def collect_for_model(model)
|
30
|
+
model.ancestors.select { |ancestor| ancestor < ActiveRecord::Base }
|
31
|
+
# collect from parent to child to correctly handle inheritance
|
32
|
+
.reverse
|
33
|
+
.flat_map { |ancestor| collect_callbacks_for_class(model, ancestor) }
|
34
|
+
.group_by(&:fingerprint)
|
35
|
+
# merge groups
|
36
|
+
.transform_values do |callbacks|
|
37
|
+
probe = callbacks.first
|
38
|
+
if probe.fingerprint.start_with?("1")
|
39
|
+
# we must keep the last non-matching callback (i.e., if all callbacks are the same,
|
40
|
+
# we must keep the first one)
|
41
|
+
callbacks.each do |clbk|
|
42
|
+
if clbk.callback != probe.callback
|
43
|
+
probe = clbk
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
probe
|
48
|
+
end
|
49
|
+
.values
|
50
|
+
end
|
51
|
+
|
52
|
+
def collect_callbacks_for_class(model, klass)
|
53
|
+
callbacks = klass.__callbacks
|
54
|
+
callbacks = callbacks.slice(:validate) if @kind == :validations
|
55
|
+
|
56
|
+
callbacks.flat_map do |kind, chain|
|
57
|
+
chain.map { |callback| build_callback(model, callback, kind, klass) }
|
58
|
+
end.then do |collected|
|
59
|
+
next collected if @mode == :full
|
60
|
+
|
61
|
+
collected.reject do |c|
|
62
|
+
c.association_generated || c.attribute_generated || (
|
63
|
+
@kind != :validations && c.callback_group == "validate"
|
64
|
+
)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def build_callback(model, callback, kind, klass)
|
70
|
+
Callback.new(
|
71
|
+
model: model,
|
72
|
+
rails_callback: callback,
|
73
|
+
name: kind,
|
74
|
+
defining_class: klass
|
75
|
+
)
|
76
|
+
end
|
77
|
+
|
78
|
+
def load_additional_paths
|
79
|
+
model_paths.each do |path|
|
80
|
+
Dir[File.join(path, "**", "*.rb")].sort.each { |file| require_dependency file }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def model_paths
|
85
|
+
@model_paths ||= begin
|
86
|
+
paths = engine_paths
|
87
|
+
paths += [@paths] if @paths
|
88
|
+
paths.select(&:exist?)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def engine_paths
|
93
|
+
@engine_paths ||= Rails::Engine.subclasses.map { |engine| engine.root.join("app/models") }
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CallbackHell
|
4
|
+
module Reports
|
5
|
+
class Base
|
6
|
+
attr_reader :callbacks, :stats
|
7
|
+
|
8
|
+
def initialize(callbacks, **opts)
|
9
|
+
@callbacks = callbacks
|
10
|
+
@stats = Stats.new(callbacks, **opts)
|
11
|
+
end
|
12
|
+
|
13
|
+
def generate
|
14
|
+
raise NotImplementedError
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CallbackHell
|
4
|
+
module Reports
|
5
|
+
module Callbacks
|
6
|
+
class Github < GithubBase
|
7
|
+
private
|
8
|
+
|
9
|
+
def report_title
|
10
|
+
"Callback Hell callbacks report"
|
11
|
+
end
|
12
|
+
|
13
|
+
def format_group_name(callback)
|
14
|
+
"#{timing_symbol(callback.kind)}/#{callback.callback_group}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def timing_symbol(timing)
|
18
|
+
case timing
|
19
|
+
when :before, "before" then "⇥"
|
20
|
+
when :after, "after" then "↦"
|
21
|
+
when :around, "around" then "↔"
|
22
|
+
else
|
23
|
+
" "
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|