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 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "as_minimizer"
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsMinimizer
4
+ VERSION = "0.1.0"
5
+ end
@@ -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: []