activesupport-minimizer 0.1.0 → 0.1.1

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: 057c323c4f9d34ec9172be627d4a8362422d43fcc0ff40a2e6df1bdc46ea85dc
4
- data.tar.gz: ce5c57db54cde067e88a7396afe0c38934fb8cc1b322a3835db4a79837a94221
3
+ metadata.gz: b015df981e76956005ec4dbec6f10808c1fe61a2be2d17395be4bc622ae0541d
4
+ data.tar.gz: c569ebbecfa90d6b135966578016eb725e9b705e8bb3896443a87fa096c9c340
5
5
  SHA512:
6
- metadata.gz: d89cf16715590c6689131ec5d6f7ffa15226f7a171fcfc736b3d82331abc58d48eb62d1fbb0a5d5a5a7bcd2c6155a4337e868e246786adc5b1f51de2218e099c
7
- data.tar.gz: bcc03a8edb2d24e2e996ef3312046a8aa45516b83d1a767a33fbff1d772a866584083fc1df57f2bd43522d4e46215014ab614fb215f275aaedd8953b7e8208f3
6
+ metadata.gz: a65151b39365a9eaeefb4bf743edfd103e20f0c9e3cdb9efb38384ed61a4b6f771263df98a8c15e766d00072686fd25fea6abf7187f62b70bce6ec42db751112
7
+ data.tar.gz: c9f8740139908120958cec43192c1b241cdb7df945aa2bf01851dc0c05060bd67e1ff8f8b32170c56fcabbe8f5e3b872f01bcbe1c07fe402f00df6acb9de35a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 (2026-10-18)
4
+
5
+ - Unrolled callbacks: one method per class and chain instead of one per controller action (`only:`/`except:` become a Set lookup), and classes whose chains are the same share one method body. Fewer methods for the JIT to compile.
6
+ - `Check.long_chains`: chains with more than a limit (default 5) of the app's own callbacks. Callbacks inherited from excluded classes (default `ApplicationController`) don't count; a base class over the limit is reported once, with the classes that inherit it.
7
+ - `Check.callback_loops`: model callbacks that call `save`, `update`, `touch`, ... on their own record, which runs the callbacks again (found with Prism), with the guard if there is one.
8
+ - Census counts per class, not per action.
9
+
3
10
  ## 0.1.0 (2026-10-18)
4
11
 
5
12
  First release.
data/README.md CHANGED
@@ -77,4 +77,4 @@ validations, 2 before_save, 2 after_save, after_initialize):
77
77
  A controller action behind 9 callbacks: 12.6 µs of callback overhead → 4.0 µs
78
78
  (6.4 → 2.3 µs with YJIT).
79
79
 
80
- golf-buddy's test suite (4317 runs) gives the same results with it on.
80
+ [Japan Voyage](https://japanvoyage.jp)'s test suite (4317 runs) gives the same results with it on.
@@ -28,12 +28,14 @@ module AsMinimizer
28
28
  klass.__callbacks.each do |kind, chain|
29
29
  next if chain.empty?
30
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
31
+ # Controllers no route reaches (abstract ones included) never run their chain.
32
+ next if kind == :process_action && klass.respond_to?(:action_methods) && controller_actions(klass, routed).empty?
33
+
34
+ begin
35
+ Unroller::Generator.new(klass, kind, chain).source
34
36
  unrolled += 1
35
37
  rescue Unroller::Unsupported => e
36
- fallbacks[e.message] << "#{name}##{kind}#{"[#{action}]" if action}"
38
+ fallbacks[e.message] << "#{name}##{kind}"
37
39
  end
38
40
  end
39
41
  end
@@ -14,6 +14,33 @@ module AsMinimizer
14
14
  end
15
15
  end
16
16
 
17
+ # A chain with more of the app's own callbacks than the limit.
18
+ LongChain = Data.define(:klass, :chain, :callbacks, :limit, :action, :also) do
19
+ def to_s
20
+ where = action ? " (#{action})" : ""
21
+ others = also.empty? ? "" : " (inherited by #{also.size}: #{also.first(3).map { Check.name_of(it) }.join(", ")}#{", ..." if also.size > 3})"
22
+ "#{Check.name_of(klass)}#{others}: #{chain} chain has #{callbacks.size} callbacks#{where}, more than #{limit}\n" +
23
+ callbacks.map { " #{it}" }.join("\n")
24
+ end
25
+ end
26
+
27
+ # A model callback that saves (updates, touches, ...) the record it runs
28
+ # for: that runs the chain again, so it repeats until something stops it.
29
+ Loop = Data.define(:klass, :chain, :callback, :call, :location, :guard) do
30
+ def to_s
31
+ "#{Check.name_of(klass)}: #{callback} calls #{call} on the record itself (#{location}), " \
32
+ "which runs the #{chain} callbacks again#{"; guarded by #{guard}, check it can't stay true" if guard}"
33
+ end
34
+ end
35
+
36
+ # Chains whose callbacks are validations, not callbacks.
37
+ SKIP_CHAINS = %i[validate].freeze
38
+ # Model chains a save, update or touch of the same record runs again.
39
+ REENTERED_CHAINS = %i[validation save create update commit touch].freeze
40
+ # Calls on the record itself that run its callbacks (update_column(s) and
41
+ # update_all don't).
42
+ SELF_SAVES = %i[save save! update update! update_attribute update_attributes update_attributes! touch increment! decrement! toggle!].freeze
43
+
17
44
  # method_missing implementations whose answers are known: attribute methods.
18
45
  ATTRIBUTE_METHOD_MISSING = %w[ActiveModel::AttributeMethods ActiveRecord::AttributeMethods ArMinimizer::Slim::Record].freeze
19
46
  PLAIN_METHOD_MISSING = %w[BasicObject Kernel].freeze
@@ -55,6 +82,67 @@ module AsMinimizer
55
82
  end
56
83
  end
57
84
 
85
+ # Chains with more than +limit+ of the app's own callbacks (defined under
86
+ # +root+, not in gems). Controllers: their action with the most.
87
+ # Callbacks a subclass inherits from an +exclude+d class (the
88
+ # ApplicationController default: time zone, locale, current user, ...,
89
+ # the per-request basics) don't count. A class over the limit is reported
90
+ # once, with how many classes inherit its chain; those aren't repeated.
91
+ def long_chains(classes = callback_classes, limit: 5, root:, routed: nil, exclude: ["ApplicationController"])
92
+ @routed = routed
93
+ excluded = exclude.filter_map { Object.const_get(it) if Object.const_defined?(it) }
94
+ reported = {} # [class, chain] => LongChain
95
+ classes.sort_by { it.ancestors.size }.each do |klass|
96
+ next if excluded.include?(klass)
97
+
98
+ klass.__callbacks.each do |chain_name, chain|
99
+ next if SKIP_CHAINS.include?(chain_name)
100
+ next if chain.none?
101
+
102
+ # Already over the limit in a superclass: covered by that report.
103
+ parent = klass.ancestors.drop(1).find { reported.key?([it, chain_name]) }
104
+ if parent
105
+ reported[[parent, chain_name]].also << klass
106
+ next
107
+ end
108
+
109
+ inherited = excluded.select { klass < it && it.respond_to?(:__callbacks) }
110
+ .flat_map { it.__callbacks[chain_name].to_a }.to_set
111
+ actions = controller_actions(klass, chain_name)
112
+ candidates = actions.nil? || actions.empty? ? [nil] : actions # a base class: all of its callbacks
113
+ action, own = candidates.map do |act|
114
+ [act, chain.select { |cb| !inherited.include?(cb) && (act.nil? || applies?(cb, act)) && own_location(klass, cb, root) }]
115
+ end.max_by { it[1].size }
116
+ next unless own.size > limit
117
+
118
+ reported[[klass, chain_name]] = LongChain.new(klass, chain_name, own.map { describe(klass, it, root) }, limit, action, [])
119
+ end
120
+ end
121
+ reported.values
122
+ end
123
+
124
+ # Model callbacks (under +root+) that call save, update, touch, ... on
125
+ # the record they run for. allow: ["Trip#recalculate", ...] to accept one.
126
+ def callback_loops(classes = callback_classes, root:, allow: [])
127
+ classes.flat_map do |klass|
128
+ next [] unless defined?(ActiveRecord::Base) && klass < ActiveRecord::Base
129
+
130
+ klass.__callbacks.flat_map do |chain_name, chain|
131
+ next [] unless REENTERED_CHAINS.include?(chain_name)
132
+
133
+ chain.flat_map do |callback|
134
+ next [] unless (location = own_location(klass, callback, root))
135
+ next [] if allow.include?("#{name_of(klass)}##{callback.filter}")
136
+
137
+ self_saves(*location).map do |call, line|
138
+ Loop.new(klass, chain_name, "#{callback.kind}_#{chain_name} #{callback.filter.is_a?(Symbol) ? callback.filter.inspect : "block"}",
139
+ call, "#{relative(location[0], root)}:#{line}", guard(callback))
140
+ end
141
+ end
142
+ end
143
+ end.uniq { [it.klass, it.callback, it.call, it.location] }
144
+ end
145
+
58
146
  def callback_classes
59
147
  ObjectSpace.each_object(Class).filter_map do |klass|
60
148
  name = Module.instance_method(:name).bind_call(klass) # some classes redefine .name (and raise)
@@ -113,6 +201,65 @@ module AsMinimizer
113
201
 
114
202
  def listed?(filter, action) = filter.instance_variable_get(:@actions).include?(action)
115
203
 
204
+ # [file, line] of the callback's code when it's the app's own (under root, not a gem), else nil.
205
+ def own_location(klass, callback, root)
206
+ filter = callback.filter
207
+ location =
208
+ case filter
209
+ when Symbol then (klass.instance_method(filter).source_location rescue nil)
210
+ when Proc then filter.source_location
211
+ end
212
+ return unless location && location[0].start_with?(root.to_s) && !location[0].match?(%r{/(vendor|gems)/})
213
+
214
+ location
215
+ end
216
+
217
+ def describe(klass, callback, root)
218
+ file, line = own_location(klass, callback, root)
219
+ name = callback.filter.is_a?(Symbol) ? callback.filter.inspect : "block"
220
+ "#{callback.kind}_#{callback.name} #{name} (#{relative(file, root)}:#{line})"
221
+ end
222
+
223
+ def relative(file, root) = file.delete_prefix("#{root}/")
224
+
225
+ # The :if/:unless that might stop a loop, as written. Rails' own
226
+ # condition on model after_* callbacks (skip after a halt) isn't one.
227
+ def guard(callback)
228
+ conditions = (callback.instance_variable_get(:@if).map { ["if", it] } + callback.instance_variable_get(:@unless).map { ["unless", it] })
229
+ .reject { |_, c| c.is_a?(ActiveSupport::Callbacks::Conditionals::Value) }
230
+ conditions.filter_map { |kind, c| "#{kind}: #{c.inspect}" if c.is_a?(Symbol) }.first ||
231
+ (conditions.any? ? "a #{conditions.first[0]}: condition" : nil)
232
+ end
233
+
234
+ # [[call, line]] for calls in SELF_SAVES on self in the method or block
235
+ # starting at file:line.
236
+ def self_saves(file, line)
237
+ require "prism"
238
+ @parsed ||= {}
239
+ root_node = (@parsed[file] ||= Prism.parse_file(file).value)
240
+ body = find_starting(root_node, line) or return []
241
+ found = []
242
+ walk = lambda do |node|
243
+ if node.is_a?(Prism::CallNode) && SELF_SAVES.include?(node.name) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
244
+ found << [node.name.to_s, node.location.start_line]
245
+ end
246
+ node.compact_child_nodes.each(&walk)
247
+ end
248
+ walk.(body)
249
+ found
250
+ end
251
+
252
+ def find_starting(node, line)
253
+ return node if (node.is_a?(Prism::DefNode) || node.is_a?(Prism::LambdaNode) || node.is_a?(Prism::BlockNode)) && node.location.start_line == line
254
+
255
+ node.compact_child_nodes.each do |child|
256
+ next unless child.location.start_line <= line && child.location.end_line >= line
257
+
258
+ found = find_starting(child, line) and return found
259
+ end
260
+ nil
261
+ end
262
+
116
263
  # name_changed?, saved_change_to_title?, ... for a real attribute (Slim may not have generated it).
117
264
  def attribute_method?(klass, name)
118
265
  return false unless klass.respond_to?(:attribute_method_patterns) && klass.respond_to?(:attribute_names)
@@ -36,13 +36,21 @@ module AsMinimizer
36
36
  # throw :abort for the rest, or any custom terminator), around callbacks
37
37
  # nesting the rest of the chain, after callbacks last-defined first and
38
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.
39
+ # `except:` are a lookup of action_name in the filter's action set.
40
+ #
41
+ # One method per class and chain (not per controller action), and one body
42
+ # per distinct chain: classes whose chains come out the same (subclasses of
43
+ # a controller that add no callbacks of their own, ...) get the same method
44
+ # body, so a JIT compiles it once.
40
45
  #
41
46
  # The generated code is named in backtraces ("unrolled: Post#save"), and with
42
47
  # source_dir set, written to a file there, so backtraces point at code you can open.
43
48
  module Unroller
44
49
  # Filters and conditions that aren't symbols are referenced from here.
45
50
  OBJECTS = []
51
+ # [kind, body] => a module holding the generated method; classes with the
52
+ # same chain define theirs from its UnboundMethod, sharing the body.
53
+ SHARED = {}
46
54
  MUTEX = Mutex.new
47
55
  Unsupported = Class.new(StandardError)
48
56
 
@@ -62,35 +70,43 @@ module AsMinimizer
62
70
  def method_for(target, kind)
63
71
  klass = target.class
64
72
  chain = target.__callbacks[kind]
65
- action = target.action_name if kind == :process_action && target.respond_to?(:action_name)
66
73
  cache = klass.instance_variable_get(:@__as_unrolled) ||
67
74
  MUTEX.synchronize { klass.instance_variable_get(:@__as_unrolled) || klass.instance_variable_set(:@__as_unrolled, {}) }
68
- hit = (per_kind = cache[kind]) && per_kind[action]
75
+ hit = cache[kind]
69
76
  return hit[1] if hit && hit[0].equal?(chain)
70
77
 
71
78
  MUTEX.synchronize do
72
- per_kind = (cache[kind] ||= {})
73
- hit = per_kind[action]
79
+ hit = cache[kind]
74
80
  return hit[1] if hit && hit[0].equal?(chain)
75
81
 
76
- name = generate(klass, kind, action, chain)
77
- per_kind[action] = [chain, name]
82
+ name = generate(klass, kind, chain)
83
+ cache[kind] = [chain, name]
78
84
  name
79
85
  end
80
86
  end
81
87
 
82
- def source_for(klass, kind, action = nil) = Generator.new(klass, kind, action, klass.__callbacks[kind]).source
88
+ def source_for(klass, kind) = Generator.new(klass, kind, klass.__callbacks[kind]).source
89
+
90
+ # Generated methods, and the distinct bodies behind them.
91
+ def stats
92
+ classes = ObjectSpace.each_object(Class).select { it.instance_variable_defined?(:@__as_unrolled) }
93
+ { methods: classes.sum { it.instance_variable_get(:@__as_unrolled).count { |_, (_, name)| name } }, bodies: SHARED.size }
94
+ end
83
95
 
84
96
  private
85
97
 
86
- def generate(klass, kind, action, chain)
87
- generator = Generator.new(klass, kind, action, chain)
98
+ def generate(klass, kind, chain)
99
+ generator = Generator.new(klass, kind, chain)
88
100
  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)
101
+ warn_missing(klass, kind, generator.targets)
102
+ name = :"__as_#{kind}"
103
+ shared = SHARED[[kind, body]] ||= begin
104
+ label = "#{klass.name || "anonymous"}##{kind}"
105
+ code = "# #{label} callbacks (and every class whose chain is the same), unrolled by activesupport-minimizer\n" \
106
+ "def #{name}\n#{body}\nend\n"
107
+ Module.new.tap { it.module_eval(code, file_for(klass, kind, label, code), 1) }
108
+ end
109
+ klass.send(:define_method, name, shared.instance_method(name))
94
110
  klass.send(:private, name)
95
111
  name
96
112
  rescue Unsupported
@@ -99,7 +115,7 @@ module AsMinimizer
99
115
 
100
116
  # Rails finds out when the chain runs (NoMethodError, a 500 for a
101
117
  # controller); say so when the method is generated, once per target.
102
- def warn_missing(klass, kind, action, targets)
118
+ def warn_missing(klass, kind, targets)
103
119
  targets.each do |name, role, callback_kind|
104
120
  next unless Check.status(klass, name) == :missing
105
121
 
@@ -108,15 +124,14 @@ module AsMinimizer
108
124
 
109
125
  @warned[[klass, kind, name]] = true
110
126
  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")
127
+ "names no method; running the chain will raise NoMethodError")
112
128
  end
113
129
  end
114
130
 
115
- def file_for(klass, kind, action, label, code)
131
+ def file_for(klass, kind, label, code)
116
132
  return "unrolled: #{label}" unless source_dir
117
133
 
118
- path = File.join(source_dir.to_s, (klass.name || "anonymous_#{klass.object_id}").gsub("::", "/"),
119
- "#{kind}#{"_#{action}" if action}.rb")
134
+ path = File.join(source_dir.to_s, (klass.name || "anonymous_#{klass.object_id}").gsub("::", "/"), "#{kind}.rb")
120
135
  FileUtils.mkdir_p(File.dirname(path))
121
136
  File.write(path, code)
122
137
  path
@@ -139,10 +154,9 @@ module AsMinimizer
139
154
  # Symbols the generated code calls (filters and conditions that apply).
140
155
  attr_reader :targets
141
156
 
142
- def initialize(klass, kind, action, chain)
157
+ def initialize(klass, kind, chain)
143
158
  @klass = klass
144
159
  @kind = kind
145
- @action = action
146
160
  @chain = chain
147
161
  @config = chain.config
148
162
  @lambdas = 0
@@ -167,8 +181,9 @@ module AsMinimizer
167
181
  object(terminator)
168
182
  end
169
183
  @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)
184
+ # With raise_on_missing_callback_actions, only:/except: go through the
185
+ # filter object, which raises for actions that don't exist.
186
+ @inline_actions = !(@klass.respond_to?(:raise_on_missing_callback_actions) && @klass.raise_on_missing_callback_actions)
172
187
  end
173
188
 
174
189
  # What CallbackChain#compile builds: each around opens a nested level;
@@ -232,19 +247,21 @@ module AsMinimizer
232
247
  end
233
248
 
234
249
  # 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).
250
+ # when it can never run (an `only:` with no actions).
236
251
  def conditions(callback)
237
252
  parts = []
238
253
  callback.instance_variable_get(:@if).each do |c|
239
- if static_action_filter?(c)
240
- return nil unless action_listed?(c)
254
+ if inline_action_filter?(c)
255
+ return nil if actions_of(c).empty?
256
+
257
+ parts << "#{object(actions_of(c))}.include?(action_name)"
241
258
  else
242
259
  parts << condition(callback, c)
243
260
  end
244
261
  end
245
262
  callback.instance_variable_get(:@unless).each do |c|
246
- if static_action_filter?(c)
247
- return nil if action_listed?(c)
263
+ if inline_action_filter?(c)
264
+ parts << "!#{object(actions_of(c))}.include?(action_name)" unless actions_of(c).empty?
248
265
  else
249
266
  parts << "!(#{condition(callback, c)})"
250
267
  end
@@ -252,11 +269,12 @@ module AsMinimizer
252
269
  parts.empty? ? "true" : parts.join(" && ")
253
270
  end
254
271
 
255
- def static_action_filter?(c)
256
- @static_actions && defined?(AbstractController::Callbacks::ActionFilter) && c.is_a?(AbstractController::Callbacks::ActionFilter)
272
+ def inline_action_filter?(c)
273
+ @inline_actions && defined?(AbstractController::Callbacks::ActionFilter) && c.is_a?(AbstractController::Callbacks::ActionFilter)
257
274
  end
258
275
 
259
- def action_listed?(filter) = filter.instance_variable_get(:@actions).include?(@action)
276
+ # only:/except: actions, as the frozen Set of Strings Rails keeps.
277
+ def actions_of(filter) = filter.instance_variable_get(:@actions)
260
278
 
261
279
  # As CallTemplate.build + make_lambda.
262
280
  def condition(callback, c)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsMinimizer
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activesupport-minimizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sampo Kuokkanen