activesupport-minimizer 0.1.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 +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +80 -0
- data/lib/activesupport-minimizer.rb +3 -0
- data/lib/as_minimizer/census.rb +51 -0
- data/lib/as_minimizer/check.rb +130 -0
- data/lib/as_minimizer/railtie.rb +34 -0
- data/lib/as_minimizer/unroller.rb +308 -0
- data/lib/as_minimizer/version.rb +5 -0
- data/lib/as_minimizer.rb +18 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 057c323c4f9d34ec9172be627d4a8362422d43fcc0ff40a2e6df1bdc46ea85dc
|
|
4
|
+
data.tar.gz: ce5c57db54cde067e88a7396afe0c38934fb8cc1b322a3835db4a79837a94221
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d89cf16715590c6689131ec5d6f7ffa15226f7a171fcfc736b3d82331abc58d48eb62d1fbb0a5d5a5a7bcd2c6155a4337e868e246786adc5b1f51de2218e099c
|
|
7
|
+
data.tar.gz: bcc03a8edb2d24e2e996ef3312046a8aa45516b83d1a767a33fbff1d772a866584083fc1df57f2bd43522d4e46215014ab614fb215f275aaedd8953b7e8208f3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-18)
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- ActiveSupport::Callbacks chains run as one generated method per class and chain (per action for controllers).
|
|
8
|
+
- `source_dir` writes the generated methods out, so backtraces point at real files.
|
|
9
|
+
- Census and check of callbacks naming methods that don't exist.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sampo Kuokkanen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# activesupport-minimizer
|
|
2
|
+
|
|
3
|
+
Runs `ActiveSupport::Callbacks` chains as generated methods: controller
|
|
4
|
+
`before_action`/`around_action`/`after_action`, model `before_save`,
|
|
5
|
+
`after_commit`, validations, `after_initialize`, Active Job callbacks — any
|
|
6
|
+
class using `define_callbacks`. Part of [rails-minify](../rails-minify).
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# config/environments/production.rb
|
|
10
|
+
config.as_minimizer.unroll_callbacks = true
|
|
11
|
+
config.as_minimizer.source_dir = "tmp/rails_minify/callbacks" # optional: write the generated methods out
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The first time a chain runs for a class (for controllers: per action), it
|
|
15
|
+
becomes one method:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
before_save :normalize
|
|
19
|
+
before_save :check, if: :published?
|
|
20
|
+
|
|
21
|
+
# Post#save
|
|
22
|
+
halted = false
|
|
23
|
+
value = nil
|
|
24
|
+
if !halted
|
|
25
|
+
halted = true
|
|
26
|
+
catch(:abort) do
|
|
27
|
+
self.normalize
|
|
28
|
+
halted = false
|
|
29
|
+
end
|
|
30
|
+
halted_callback_hook(:normalize, :save) if halted
|
|
31
|
+
end
|
|
32
|
+
if !halted && self.published?
|
|
33
|
+
...
|
|
34
|
+
end
|
|
35
|
+
value = !halted && (block_given? ? yield : true)
|
|
36
|
+
...
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It follows `run_callbacks`: before callbacks in order with the chain's
|
|
40
|
+
terminator after each (`throw :abort`, `performed?` for controllers, or any
|
|
41
|
+
custom one), around callbacks nesting the rest of the chain (passed as a
|
|
42
|
+
Proc, like ActiveSupport does), after callbacks last-defined first, skipped
|
|
43
|
+
after a halt when the chain says so. Symbols, procs of any arity, objects
|
|
44
|
+
(`before_save(record)`, `validate(record)`, `around(controller)`) and
|
|
45
|
+
`Conditionals::Value` work as filters and conditions. For controllers,
|
|
46
|
+
`only:`/`except:` are decided per action, so other actions' filters are gone;
|
|
47
|
+
with `raise_on_missing_callback_actions` on they are checked at runtime
|
|
48
|
+
instead, as Rails does. A chain that changes gets a new method.
|
|
49
|
+
|
|
50
|
+
## Debugging
|
|
51
|
+
|
|
52
|
+
Backtraces name the chain and the line in it:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
app/models/user.rb:140:in 'User#generate_username'
|
|
56
|
+
unrolled: User#validation:32:in 'block in User#__as_validation'
|
|
57
|
+
unrolled: User#validation:31:in 'Kernel#catch'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
where stock Rails shows `CallTemplate::MethodCall#make_lambda`,
|
|
61
|
+
`Filters::Before#call`, `DefaultTerminator#call`... With `source_dir`, those
|
|
62
|
+
frames point at a real file (`tmp/rails_minify/callbacks/User/validation.rb`),
|
|
63
|
+
so you can open the exact code that ran. `AsMinimizer::Unroller.source_for(User, :save)`
|
|
64
|
+
prints it too.
|
|
65
|
+
|
|
66
|
+
## Numbers
|
|
67
|
+
|
|
68
|
+
Ruby 4.0, Rails 8.1, µs per call (`bench/models.rb`, a model with 2
|
|
69
|
+
validations, 2 before_save, 2 after_save, after_initialize):
|
|
70
|
+
|
|
71
|
+
| | stock | unrolled | stock (YJIT) | unrolled (YJIT) |
|
|
72
|
+
|--------------------------|------:|---------:|-------------:|----------------:|
|
|
73
|
+
| `valid?` | 13.9 | 9.2 | 5.8 | 3.2 |
|
|
74
|
+
| `Post.new` | 7.0 | 6.4 | 2.8 | 2.4 |
|
|
75
|
+
| `create!` (SQLite memory)| 182 | 174 | 70 | 65 |
|
|
76
|
+
|
|
77
|
+
A controller action behind 9 callbacks: 12.6 µs of callback overhead → 4.0 µs
|
|
78
|
+
(6.4 → 2.3 µs with YJIT).
|
|
79
|
+
|
|
80
|
+
golf-buddy's test suite (4317 runs) gives the same results with it on.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsMinimizer
|
|
4
|
+
# How many callback chains the unroller can run, and why the rest run
|
|
5
|
+
# through ActiveSupport. Controllers are checked per routed action.
|
|
6
|
+
module Census
|
|
7
|
+
Result = Data.define(:unrolled, :fallbacks, :problems) do
|
|
8
|
+
# fallbacks: { reason => ["PostsController#process_action[show]", ...] }
|
|
9
|
+
def lines(verbose: false)
|
|
10
|
+
total = unrolled + fallbacks.values.sum(&:size)
|
|
11
|
+
out = ["callback chains: #{unrolled}/#{total} unrolled"]
|
|
12
|
+
fallbacks.sort_by { -it[1].size }.each do |reason, labels|
|
|
13
|
+
out << " #{labels.size} through ActiveSupport: #{reason}"
|
|
14
|
+
labels.first(verbose ? labels.size : 3).each { out << " #{it}" }
|
|
15
|
+
end
|
|
16
|
+
missing = problems.count { it.status == :missing }
|
|
17
|
+
out << "callback targets: #{missing} missing, #{problems.size - missing} possibly answered by method_missing"
|
|
18
|
+
problems.each { out << " #{it}" } if verbose || missing.positive?
|
|
19
|
+
out
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.report(classes = Check.callback_classes, routed: nil)
|
|
24
|
+
unrolled = 0
|
|
25
|
+
fallbacks = Hash.new { |h, k| h[k] = [] }
|
|
26
|
+
classes.each do |klass|
|
|
27
|
+
name = Check.name_of(klass)
|
|
28
|
+
klass.__callbacks.each do |kind, chain|
|
|
29
|
+
next if chain.empty?
|
|
30
|
+
|
|
31
|
+
actions = kind == :process_action && klass.respond_to?(:action_methods) ? controller_actions(klass, routed) : [nil]
|
|
32
|
+
actions.each do |action|
|
|
33
|
+
Unroller::Generator.new(klass, kind, action, chain).source
|
|
34
|
+
unrolled += 1
|
|
35
|
+
rescue Unroller::Unsupported => e
|
|
36
|
+
fallbacks[e.message] << "#{name}##{kind}#{"[#{action}]" if action}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
Result.new(unrolled, fallbacks.to_h, Check.callback_targets(classes, routed: routed))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.controller_actions(klass, routed)
|
|
44
|
+
return [] if klass.abstract?
|
|
45
|
+
|
|
46
|
+
actions = klass.action_methods.to_a
|
|
47
|
+
actions &= routed[klass.name].to_a if routed&.key?(klass.name)
|
|
48
|
+
actions.sort
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsMinimizer
|
|
4
|
+
# Callbacks naming methods that don't exist. Rails registers callbacks by
|
|
5
|
+
# name and only finds out when the chain runs: a NoMethodError, for a
|
|
6
|
+
# controller a 500 on every action the filter covers. (Roundhouse's
|
|
7
|
+
# filter_targets check, for a running app.)
|
|
8
|
+
module Check
|
|
9
|
+
Problem = Data.define(:klass, :chain, :kind, :role, :target, :status, :actions) do
|
|
10
|
+
def to_s
|
|
11
|
+
where = actions ? " (#{actions.empty? ? "no action" : actions.join(", ")})" : ""
|
|
12
|
+
verb = status == :missing ? "names no method" : "may be answered by method_missing"
|
|
13
|
+
"#{Check.name_of(klass)}: #{kind}_#{chain} #{role} #{target.inspect} #{verb}#{where}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# method_missing implementations whose answers are known: attribute methods.
|
|
18
|
+
ATTRIBUTE_METHOD_MISSING = %w[ActiveModel::AttributeMethods ActiveRecord::AttributeMethods ArMinimizer::Slim::Record].freeze
|
|
19
|
+
PLAIN_METHOD_MISSING = %w[BasicObject Kernel].freeze
|
|
20
|
+
|
|
21
|
+
# Some classes redefine .name (and raise); Module#name is nil for anonymous ones.
|
|
22
|
+
def self.name_of(klass)
|
|
23
|
+
Module.instance_method(:name).bind_call(klass) || (klass.name rescue nil) || klass.inspect
|
|
24
|
+
rescue StandardError, ScriptError
|
|
25
|
+
klass.inspect
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# :present, :missing, or :unknown (the class has its own method_missing).
|
|
30
|
+
def status(klass, name)
|
|
31
|
+
return :present if klass.method_defined?(name) || klass.private_method_defined?(name) || klass.protected_method_defined?(name)
|
|
32
|
+
return :present if attribute_method?(klass, name)
|
|
33
|
+
|
|
34
|
+
owner = klass.instance_method(:method_missing).owner.name.to_s
|
|
35
|
+
if PLAIN_METHOD_MISSING.include?(owner) || ATTRIBUTE_METHOD_MISSING.include?(owner)
|
|
36
|
+
:missing
|
|
37
|
+
else
|
|
38
|
+
:unknown
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Every named class using callbacks, or the ones given. routed:
|
|
43
|
+
# { "PostsController" => Set["show", ...] } limits controllers to the
|
|
44
|
+
# actions a request can reach (public helper methods are actions too).
|
|
45
|
+
def callback_targets(classes = callback_classes, routed: nil)
|
|
46
|
+
@routed = routed
|
|
47
|
+
classes.flat_map { problems_in(it) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# From a RouteSet: controller class name => routed action names.
|
|
51
|
+
def routed_actions(routes)
|
|
52
|
+
routes.routes.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |route, map|
|
|
53
|
+
controller, action = route.defaults.values_at(:controller, :action)
|
|
54
|
+
map["#{controller.camelize}Controller"] << action.to_s if controller && action
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def callback_classes
|
|
59
|
+
ObjectSpace.each_object(Class).filter_map do |klass|
|
|
60
|
+
name = Module.instance_method(:name).bind_call(klass) # some classes redefine .name (and raise)
|
|
61
|
+
[name, klass] if name && klass < ActiveSupport::Callbacks && klass.respond_to?(:__callbacks)
|
|
62
|
+
rescue StandardError, ScriptError
|
|
63
|
+
nil
|
|
64
|
+
end.sort_by(&:first).map(&:last)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def problems_in(klass)
|
|
70
|
+
klass.__callbacks.flat_map do |chain_name, chain|
|
|
71
|
+
actions = controller_actions(klass, chain_name)
|
|
72
|
+
next [] if actions&.empty? # abstract controllers: checked through their subclasses
|
|
73
|
+
|
|
74
|
+
chain.flat_map do |callback|
|
|
75
|
+
targets(callback).filter_map do |name, role|
|
|
76
|
+
status = status(klass, name)
|
|
77
|
+
next if status == :present
|
|
78
|
+
|
|
79
|
+
applies = actions&.select { applies?(callback, it) }
|
|
80
|
+
next if applies&.empty?
|
|
81
|
+
|
|
82
|
+
Problem.new(klass, chain_name, callback.kind, role, name, status, applies)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def targets(callback)
|
|
89
|
+
list = []
|
|
90
|
+
list << [callback.filter, :filter] if callback.filter.is_a?(Symbol)
|
|
91
|
+
(callback.instance_variable_get(:@if) + callback.instance_variable_get(:@unless)).each do |c|
|
|
92
|
+
list << [c, :condition] if c.is_a?(Symbol)
|
|
93
|
+
end
|
|
94
|
+
list
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def controller_actions(klass, chain_name)
|
|
98
|
+
return unless chain_name == :process_action && klass.respond_to?(:action_methods)
|
|
99
|
+
|
|
100
|
+
return [] if klass.abstract?
|
|
101
|
+
|
|
102
|
+
actions = klass.action_methods.to_a
|
|
103
|
+
actions &= @routed[klass.name].to_a if @routed&.key?(klass.name)
|
|
104
|
+
actions.sort
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def applies?(callback, action)
|
|
108
|
+
callback.instance_variable_get(:@if).all? { !action_filter?(it) || listed?(it, action) } &&
|
|
109
|
+
callback.instance_variable_get(:@unless).none? { action_filter?(it) && listed?(it, action) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def action_filter?(c) = defined?(AbstractController::Callbacks::ActionFilter) && c.is_a?(AbstractController::Callbacks::ActionFilter)
|
|
113
|
+
|
|
114
|
+
def listed?(filter, action) = filter.instance_variable_get(:@actions).include?(action)
|
|
115
|
+
|
|
116
|
+
# name_changed?, saved_change_to_title?, ... for a real attribute (Slim may not have generated it).
|
|
117
|
+
def attribute_method?(klass, name)
|
|
118
|
+
return false unless klass.respond_to?(:attribute_method_patterns) && klass.respond_to?(:attribute_names)
|
|
119
|
+
|
|
120
|
+
attributes = klass.attribute_names
|
|
121
|
+
klass.attribute_method_patterns.any? do |pattern|
|
|
122
|
+
attr = pattern.respond_to?(:matched_attribute_name) ? pattern.matched_attribute_name(name.to_s) : pattern.match(name.to_s)&.attr_name
|
|
123
|
+
attr && (attributes.include?(attr) || klass.attribute_alias?(attr))
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsMinimizer
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
config.as_minimizer = ActiveSupport::OrderedOptions.new
|
|
6
|
+
|
|
7
|
+
# config.as_minimizer.unroll_callbacks = true (e.g. config/environments/production.rb)
|
|
8
|
+
# config.as_minimizer.source_dir = "tmp/rails_minify/callbacks" # write the generated methods out
|
|
9
|
+
initializer "as_minimizer" do |app|
|
|
10
|
+
options = app.config.as_minimizer
|
|
11
|
+
next unless options.unroll_callbacks
|
|
12
|
+
|
|
13
|
+
AsMinimizer::Unroller.source_dir = app.root.join(options.source_dir) if options.source_dir
|
|
14
|
+
AsMinimizer::Unroller.install!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
rake_tasks do
|
|
18
|
+
namespace :as_minimizer do
|
|
19
|
+
desc "List callbacks (before_action, before_save, ...) naming methods that don't exist; fails if any"
|
|
20
|
+
task check: :environment do
|
|
21
|
+
Rails.application.eager_load!
|
|
22
|
+
# Routes define methods too (Devise's authenticate_user!); Rails 8 loads them lazily.
|
|
23
|
+
app = Rails.application
|
|
24
|
+
app.respond_to?(:reload_routes_unless_loaded) ? app.reload_routes_unless_loaded : app.reload_routes!
|
|
25
|
+
problems = AsMinimizer::Check.callback_targets(routed: AsMinimizer::Check.routed_actions(app.routes))
|
|
26
|
+
problems.each { puts " #{it}" }
|
|
27
|
+
missing = problems.count { it.status == :missing }
|
|
28
|
+
puts "as_minimizer: #{missing} missing, #{problems.size - missing} possibly answered by method_missing"
|
|
29
|
+
abort if missing.positive?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module AsMinimizer
|
|
6
|
+
# Runs a class's callback chain (controller actions, model save/create/
|
|
7
|
+
# update/destroy/commit, validations, jobs: anything on ActiveSupport::Callbacks)
|
|
8
|
+
# as one generated method, built the first time the chain runs:
|
|
9
|
+
#
|
|
10
|
+
# before_save :normalize
|
|
11
|
+
# before_save :check, if: :published?
|
|
12
|
+
# after_save :notify
|
|
13
|
+
#
|
|
14
|
+
# # Post#save then runs (abbreviated)
|
|
15
|
+
# def __as_save
|
|
16
|
+
# halted = false
|
|
17
|
+
# value = nil
|
|
18
|
+
# if !halted
|
|
19
|
+
# halted = true
|
|
20
|
+
# catch(:abort) do
|
|
21
|
+
# self.normalize
|
|
22
|
+
# halted = false
|
|
23
|
+
# end
|
|
24
|
+
# halted_callback_hook(:normalize, :save) if halted
|
|
25
|
+
# end
|
|
26
|
+
# ...
|
|
27
|
+
# value = !halted && (block_given? ? yield : true)
|
|
28
|
+
# if !halted && ::AsMinimizer::Unroller::OBJECTS[3].call(self, value)
|
|
29
|
+
# self.notify
|
|
30
|
+
# end
|
|
31
|
+
# value
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# It follows ActiveSupport::Callbacks#run_callbacks: before callbacks in
|
|
35
|
+
# order, the chain's terminator after each (performed? for controllers,
|
|
36
|
+
# throw :abort for the rest, or any custom terminator), around callbacks
|
|
37
|
+
# nesting the rest of the chain, after callbacks last-defined first and
|
|
38
|
+
# skipped after a halt when the chain says so. For controllers, `only:` and
|
|
39
|
+
# `except:` are decided per action when the method is generated.
|
|
40
|
+
#
|
|
41
|
+
# The generated code is named in backtraces ("unrolled: Post#save"), and with
|
|
42
|
+
# source_dir set, written to a file there, so backtraces point at code you can open.
|
|
43
|
+
module Unroller
|
|
44
|
+
# Filters and conditions that aren't symbols are referenced from here.
|
|
45
|
+
OBJECTS = []
|
|
46
|
+
MUTEX = Mutex.new
|
|
47
|
+
Unsupported = Class.new(StandardError)
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
attr_accessor :source_dir
|
|
51
|
+
|
|
52
|
+
def install!
|
|
53
|
+
@enabled = true
|
|
54
|
+
ActiveSupport::Callbacks.prepend(Hook) unless ActiveSupport::Callbacks < Hook
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def disable! = @enabled = false
|
|
58
|
+
|
|
59
|
+
def enabled? = @enabled == true
|
|
60
|
+
|
|
61
|
+
# The generated method's name, or nil to let ActiveSupport run the chain.
|
|
62
|
+
def method_for(target, kind)
|
|
63
|
+
klass = target.class
|
|
64
|
+
chain = target.__callbacks[kind]
|
|
65
|
+
action = target.action_name if kind == :process_action && target.respond_to?(:action_name)
|
|
66
|
+
cache = klass.instance_variable_get(:@__as_unrolled) ||
|
|
67
|
+
MUTEX.synchronize { klass.instance_variable_get(:@__as_unrolled) || klass.instance_variable_set(:@__as_unrolled, {}) }
|
|
68
|
+
hit = (per_kind = cache[kind]) && per_kind[action]
|
|
69
|
+
return hit[1] if hit && hit[0].equal?(chain)
|
|
70
|
+
|
|
71
|
+
MUTEX.synchronize do
|
|
72
|
+
per_kind = (cache[kind] ||= {})
|
|
73
|
+
hit = per_kind[action]
|
|
74
|
+
return hit[1] if hit && hit[0].equal?(chain)
|
|
75
|
+
|
|
76
|
+
name = generate(klass, kind, action, chain)
|
|
77
|
+
per_kind[action] = [chain, name]
|
|
78
|
+
name
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def source_for(klass, kind, action = nil) = Generator.new(klass, kind, action, klass.__callbacks[kind]).source
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def generate(klass, kind, action, chain)
|
|
87
|
+
generator = Generator.new(klass, kind, action, chain)
|
|
88
|
+
body = generator.source
|
|
89
|
+
warn_missing(klass, kind, action, generator.targets)
|
|
90
|
+
name = :"__as_#{kind}#{"_#{action.unpack1("H*")}" if action}"
|
|
91
|
+
label = "#{klass.name || "anonymous"}##{kind}#{"[#{action}]" if action}"
|
|
92
|
+
code = "# #{label} callbacks, unrolled by activesupport-minimizer\ndef #{name}\n#{body}\nend\n"
|
|
93
|
+
klass.class_eval(code, file_for(klass, kind, action, label, code), 1)
|
|
94
|
+
klass.send(:private, name)
|
|
95
|
+
name
|
|
96
|
+
rescue Unsupported
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Rails finds out when the chain runs (NoMethodError, a 500 for a
|
|
101
|
+
# controller); say so when the method is generated, once per target.
|
|
102
|
+
def warn_missing(klass, kind, action, targets)
|
|
103
|
+
targets.each do |name, role, callback_kind|
|
|
104
|
+
next unless Check.status(klass, name) == :missing
|
|
105
|
+
|
|
106
|
+
@warned ||= {}
|
|
107
|
+
next if @warned[[klass, kind, name]]
|
|
108
|
+
|
|
109
|
+
@warned[[klass, kind, name]] = true
|
|
110
|
+
AsMinimizer.warn("activesupport-minimizer: #{klass.name || klass}'s #{callback_kind}_#{kind} #{role} #{name.inspect} " \
|
|
111
|
+
"names no method#{" (action #{action})" if action}; running the chain will raise NoMethodError")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def file_for(klass, kind, action, label, code)
|
|
116
|
+
return "unrolled: #{label}" unless source_dir
|
|
117
|
+
|
|
118
|
+
path = File.join(source_dir.to_s, (klass.name || "anonymous_#{klass.object_id}").gsub("::", "/"),
|
|
119
|
+
"#{kind}#{"_#{action}" if action}.rb")
|
|
120
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
121
|
+
File.write(path, code)
|
|
122
|
+
path
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
module Hook
|
|
127
|
+
def run_callbacks(kind, type = nil, &block)
|
|
128
|
+
if type.nil? && Unroller.enabled? && (name = Unroller.method_for(self, kind))
|
|
129
|
+
__send__(name, &block)
|
|
130
|
+
else
|
|
131
|
+
super
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class Generator
|
|
137
|
+
Level = Struct.new(:befores, :afters, :around, :nested)
|
|
138
|
+
|
|
139
|
+
# Symbols the generated code calls (filters and conditions that apply).
|
|
140
|
+
attr_reader :targets
|
|
141
|
+
|
|
142
|
+
def initialize(klass, kind, action, chain)
|
|
143
|
+
@klass = klass
|
|
144
|
+
@kind = kind
|
|
145
|
+
@action = action
|
|
146
|
+
@chain = chain
|
|
147
|
+
@config = chain.config
|
|
148
|
+
@lambdas = 0
|
|
149
|
+
@targets = []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def source
|
|
153
|
+
terminator!
|
|
154
|
+
"halted = false\nvalue = nil\n#{level(levels)}value"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def terminator!
|
|
160
|
+
terminator = @config[:terminator]
|
|
161
|
+
@terminator =
|
|
162
|
+
if terminator.equal?(ActiveSupport::Callbacks::CallbackChain.const_get(:DEFAULT_TERMINATOR))
|
|
163
|
+
:abort
|
|
164
|
+
elsif terminator.is_a?(Proc) && terminator.source_location&.first&.end_with?("abstract_controller/callbacks.rb")
|
|
165
|
+
:performed
|
|
166
|
+
else
|
|
167
|
+
object(terminator)
|
|
168
|
+
end
|
|
169
|
+
@skip_after = @config[:skip_after_callbacks_if_terminated]
|
|
170
|
+
# With raise_on_missing_callback_actions, only:/except: check the action list at runtime.
|
|
171
|
+
@static_actions = @action && !(@klass.respond_to?(:raise_on_missing_callback_actions) && @klass.raise_on_missing_callback_actions)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# What CallbackChain#compile builds: each around opens a nested level;
|
|
175
|
+
# befores keep definition order, afters run last-defined first.
|
|
176
|
+
def levels
|
|
177
|
+
current = Level.new([], [], nil, nil)
|
|
178
|
+
@chain.to_a.reverse_each do |callback|
|
|
179
|
+
case callback.kind
|
|
180
|
+
when :before then current.befores.unshift(callback)
|
|
181
|
+
when :after then current.afters.push(callback)
|
|
182
|
+
when :around then current = Level.new([], [], callback, current)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
current
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def level(lvl)
|
|
189
|
+
code = +""
|
|
190
|
+
lvl.befores.each { code << before(it) }
|
|
191
|
+
if lvl.around
|
|
192
|
+
code << around(lvl.around, level(lvl.nested))
|
|
193
|
+
else
|
|
194
|
+
code << "value = !halted && (block_given? ? yield : true)\n"
|
|
195
|
+
end
|
|
196
|
+
lvl.afters.each { code << after(it) }
|
|
197
|
+
code
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def before(callback)
|
|
201
|
+
cond = conditions(callback) or return ""
|
|
202
|
+
call = call(callback, callback.filter)
|
|
203
|
+
check = if cond == "true" then "!halted" else "!halted && #{cond}" end
|
|
204
|
+
hook = "halted_callback_hook(#{literal(callback.filter)}, #{callback.name.inspect})"
|
|
205
|
+
case @terminator
|
|
206
|
+
when :performed
|
|
207
|
+
"if #{check}\n #{call}\n if performed?\n halted = true\n #{hook}\n end\nend\n"
|
|
208
|
+
when :abort
|
|
209
|
+
"if #{check}\n halted = true\n catch(:abort) do\n #{call}\n halted = false\n end\n #{hook} if halted\nend\n"
|
|
210
|
+
else
|
|
211
|
+
"if #{check}\n if #{@terminator}.call(self, -> { #{call} })\n halted = true\n #{hook}\n end\nend\n"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def after(callback)
|
|
216
|
+
cond = conditions(callback) or return ""
|
|
217
|
+
check = [("!halted" if @skip_after), (cond unless cond == "true")].compact
|
|
218
|
+
call = call(callback, callback.filter)
|
|
219
|
+
check.empty? ? "#{call}\n" : "if #{check.join(" && ")}\n #{call}\nend\n"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The rest of the chain goes in a proc: the around callback gets it as
|
|
223
|
+
# its block, and it runs directly when the around is skipped.
|
|
224
|
+
def around(callback, inner)
|
|
225
|
+
cond = conditions(callback)
|
|
226
|
+
inner_name = "inner#{@lambdas += 1}"
|
|
227
|
+
code = "#{inner_name} = proc do\n#{inner}end\n" # a Proc, as ActiveSupport passes: extra arguments are ignored
|
|
228
|
+
return code << "#{inner_name}.()\n" unless cond
|
|
229
|
+
|
|
230
|
+
skip = cond == "true" ? "halted" : "halted || !(#{cond})"
|
|
231
|
+
code << "if #{skip}\n #{inner_name}.()\nelse\n #{call(callback, callback.filter, inner_name)}\nend\n"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Ruby for the callback's :if/:unless, "true" when there are none, nil
|
|
235
|
+
# when it never runs (an `only:`/`except:` that rules out this action).
|
|
236
|
+
def conditions(callback)
|
|
237
|
+
parts = []
|
|
238
|
+
callback.instance_variable_get(:@if).each do |c|
|
|
239
|
+
if static_action_filter?(c)
|
|
240
|
+
return nil unless action_listed?(c)
|
|
241
|
+
else
|
|
242
|
+
parts << condition(callback, c)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
callback.instance_variable_get(:@unless).each do |c|
|
|
246
|
+
if static_action_filter?(c)
|
|
247
|
+
return nil if action_listed?(c)
|
|
248
|
+
else
|
|
249
|
+
parts << "!(#{condition(callback, c)})"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
parts.empty? ? "true" : parts.join(" && ")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def static_action_filter?(c)
|
|
256
|
+
@static_actions && defined?(AbstractController::Callbacks::ActionFilter) && c.is_a?(AbstractController::Callbacks::ActionFilter)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def action_listed?(filter) = filter.instance_variable_get(:@actions).include?(@action)
|
|
260
|
+
|
|
261
|
+
# As CallTemplate.build + make_lambda.
|
|
262
|
+
def condition(callback, c)
|
|
263
|
+
case c
|
|
264
|
+
when Symbol then (@targets << [c, :condition, callback.kind]; "self.#{method_call(c)}")
|
|
265
|
+
when ActiveSupport::Callbacks::Conditionals::Value then "#{object(c)}.call(self, value)"
|
|
266
|
+
when Proc
|
|
267
|
+
raise Unsupported, "a condition proc with 2 parameters" if c.arity == 2 # needs a block, which conditions never get: ActiveSupport raises
|
|
268
|
+
[1, -2].include?(c.arity) ? "instance_exec(self, &#{object(c)})" : "instance_exec(&#{object(c)})"
|
|
269
|
+
else "#{object(c)}.#{scope_method(callback)}(self)"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def call(callback, filter, block = nil)
|
|
274
|
+
case filter
|
|
275
|
+
when Symbol then (@targets << [filter, :filter, callback.kind]; "self.#{method_call(filter)}#{"(&#{block})" if block}")
|
|
276
|
+
when Proc
|
|
277
|
+
case filter.arity
|
|
278
|
+
when 2
|
|
279
|
+
raise Unsupported, "a before/after proc with 2 parameters" unless block # ActiveSupport raises ArgumentError here
|
|
280
|
+
"instance_exec(self, #{block}, &#{object(filter)})"
|
|
281
|
+
when 1, -2 then "instance_exec(self, &#{object(filter)})"
|
|
282
|
+
else "instance_exec(&#{object(filter)})"
|
|
283
|
+
end
|
|
284
|
+
else "#{object(filter)}.#{scope_method(callback)}(#{["self", ("&#{block}" if block)].compact.join(", ")})"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The method an object filter answers to: before_save, validate, around, ...
|
|
289
|
+
def scope_method(callback)
|
|
290
|
+
name = Array(@config[:scope]).map { callback.public_send(it) }.join("_")
|
|
291
|
+
method_call(name)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def method_call(name)
|
|
295
|
+
raise Unsupported, "method name #{name.inspect} isn't an identifier" unless name.to_s.match?(/\A[a-zA-Z_]\w*[?!]?\z/)
|
|
296
|
+
|
|
297
|
+
name.to_s
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def literal(filter) = filter.is_a?(Symbol) ? filter.inspect : object(filter)
|
|
301
|
+
|
|
302
|
+
def object(obj)
|
|
303
|
+
index = OBJECTS.index { it.equal?(obj) } || (OBJECTS << obj).size - 1
|
|
304
|
+
"::AsMinimizer::Unroller::OBJECTS[#{index}]"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
data/lib/as_minimizer.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/callbacks"
|
|
5
|
+
require "as_minimizer/version"
|
|
6
|
+
|
|
7
|
+
module AsMinimizer
|
|
8
|
+
autoload :Unroller, "as_minimizer/unroller"
|
|
9
|
+
autoload :Check, "as_minimizer/check"
|
|
10
|
+
autoload :Census, "as_minimizer/census"
|
|
11
|
+
|
|
12
|
+
def self.warn(message)
|
|
13
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
14
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require "as_minimizer/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activesupport-minimizer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sampo Kuokkanen
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.3'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '8.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.3'
|
|
32
|
+
description: Runs ActiveSupport::Callbacks chains (controller filters, model and validation
|
|
33
|
+
callbacks, Active Job callbacks) as one generated method per chain instead of the
|
|
34
|
+
filter and lambda objects Rails walks on every run.
|
|
35
|
+
executables: []
|
|
36
|
+
extensions: []
|
|
37
|
+
extra_rdoc_files: []
|
|
38
|
+
files:
|
|
39
|
+
- CHANGELOG.md
|
|
40
|
+
- LICENSE.txt
|
|
41
|
+
- README.md
|
|
42
|
+
- lib/activesupport-minimizer.rb
|
|
43
|
+
- lib/as_minimizer.rb
|
|
44
|
+
- lib/as_minimizer/census.rb
|
|
45
|
+
- lib/as_minimizer/check.rb
|
|
46
|
+
- lib/as_minimizer/railtie.rb
|
|
47
|
+
- lib/as_minimizer/unroller.rb
|
|
48
|
+
- lib/as_minimizer/version.rb
|
|
49
|
+
homepage: https://github.com/sampokuokkanen/rails-minify/tree/main/activesupport-minimizer
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
source_code_uri: https://github.com/sampokuokkanen/rails-minify/tree/main/activesupport-minimizer
|
|
54
|
+
changelog_uri: https://github.com/sampokuokkanen/rails-minify/blob/main/activesupport-minimizer/CHANGELOG.md
|
|
55
|
+
bug_tracker_uri: https://github.com/sampokuokkanen/rails-minify/issues
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.4'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 4.0.20
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Run ActiveSupport callback chains (controllers, models, validations, jobs)
|
|
74
|
+
as generated methods
|
|
75
|
+
test_files: []
|