activerecord_callback_lens 0.4.0 → 0.5.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 +4 -4
- data/README.md +19 -0
- data/lib/activerecord_callback_lens/cli/cli.rb +9 -7
- data/lib/activerecord_callback_lens/collector/callback_definition.rb +108 -0
- data/lib/activerecord_callback_lens/renderer/graphviz_renderer.rb +8 -4
- data/lib/activerecord_callback_lens/renderer/html_renderer.rb +13 -14
- data/lib/activerecord_callback_lens/renderer/mermaid_renderer.rb +8 -4
- data/lib/activerecord_callback_lens/tasks/callback_lens_helpers.rb +3 -3
- data/lib/activerecord_callback_lens/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4135903a860b81ecc58cade60bf40079716b57147a5ed5103385efb8888deabc
|
|
4
|
+
data.tar.gz: 76ef506c8897d02301f1fb6aa629c4364c5041f452e75f0547cb9085d3bc8883
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbd09f9d42c5a00a442cb3eb798eec0e07ecd6e68402f167310f171d1b1551a29704bc365fedc9d5f96598284c2703ae42c6a72151446d6342a6d62afc42dc71
|
|
7
|
+
data.tar.gz: a21cac7359e0e8939b642bca3f45630bbd66b586796051916902255b976f90336316268f231ec6f71560f3f6241b4a25078d8d7e50a80c3a320eb69c7d52bfaa
|
data/README.md
CHANGED
|
@@ -148,6 +148,25 @@ graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
|
148
148
|
puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
+
## Example app
|
|
152
|
+
|
|
153
|
+
A runnable example Rails app lives in [`examples/blog_app/`](examples/blog_app/).
|
|
154
|
+
It defines a small blog domain (`Article`, `User`, `Comment`) with callbacks
|
|
155
|
+
covering every lifecycle event and condition style, and points its `Gemfile` at
|
|
156
|
+
this repo via `path: "../.."` so it always exercises the current gem code:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
cd examples/blog_app
|
|
160
|
+
bundle install
|
|
161
|
+
bin/rails callback_lens:analyze MODEL=Article # Mermaid diagram
|
|
162
|
+
bin/rails callback_lens:analyze MODEL=Article EXPAND=true # expanded predicates
|
|
163
|
+
bin/rails callback_lens:html MODEL=Article OUT=reports/article.html
|
|
164
|
+
bin/callback_lens_demo # programmatic API demo
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
See [`examples/blog_app/README.md`](examples/blog_app/README.md) for the full
|
|
168
|
+
command matrix and expected output.
|
|
169
|
+
|
|
151
170
|
## How it works
|
|
152
171
|
|
|
153
172
|
| Layer | Class | Responsibility |
|
|
@@ -48,7 +48,7 @@ module ActiverecordCallbackLens
|
|
|
48
48
|
def analyze(model_name)
|
|
49
49
|
model_class = resolve_model(model_name)
|
|
50
50
|
definitions, graph = build_pipeline(model_class, expand: options[:expand])
|
|
51
|
-
render_outputs(graph, definitions)
|
|
51
|
+
render_outputs(graph, definitions, expand: options[:expand])
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
private
|
|
@@ -58,11 +58,12 @@ module ActiverecordCallbackLens
|
|
|
58
58
|
#
|
|
59
59
|
# @param graph [Graph::Graph]
|
|
60
60
|
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
61
|
+
# @param expand [Boolean]
|
|
61
62
|
# @return [void]
|
|
62
|
-
def render_outputs(graph, definitions)
|
|
63
|
-
puts Renderer::MermaidRenderer.render(graph) if options[:mermaid]
|
|
64
|
-
puts Renderer::GraphvizRenderer.render(graph) if options[:graphviz]
|
|
65
|
-
write_html(graph, definitions, options[:html]) if options[:html]
|
|
63
|
+
def render_outputs(graph, definitions, expand:)
|
|
64
|
+
puts Renderer::MermaidRenderer.render(graph, expand: expand) if options[:mermaid]
|
|
65
|
+
puts Renderer::GraphvizRenderer.render(graph, expand: expand) if options[:graphviz]
|
|
66
|
+
write_html(graph, definitions, options[:html], expand: expand) if options[:html]
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
# Resolves a model class by name, printing a friendly error and exiting
|
|
@@ -105,9 +106,10 @@ module ActiverecordCallbackLens
|
|
|
105
106
|
# @param graph [Graph::Graph]
|
|
106
107
|
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
107
108
|
# @param path [String]
|
|
109
|
+
# @param expand [Boolean]
|
|
108
110
|
# @return [void]
|
|
109
|
-
def write_html(graph, definitions, path)
|
|
110
|
-
html = Renderer::HtmlRenderer.render(graph, definitions: definitions)
|
|
111
|
+
def write_html(graph, definitions, path, expand: false)
|
|
112
|
+
html = Renderer::HtmlRenderer.render(graph, definitions: definitions, expand: expand)
|
|
111
113
|
File.write(path, html)
|
|
112
114
|
puts "HTML report written to #{path}"
|
|
113
115
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
3
5
|
module ActiverecordCallbackLens
|
|
4
6
|
module Collector
|
|
5
7
|
# Represents a single callback registered on an ActiveRecord model.
|
|
@@ -17,5 +19,111 @@ module ActiverecordCallbackLens
|
|
|
17
19
|
:condition_tree, # ConditionTree::Node | nil — parsed logical tree
|
|
18
20
|
:source_location # [String, Integer] | nil — file and line of the filter
|
|
19
21
|
)
|
|
22
|
+
|
|
23
|
+
# Reopened to add the shared formatting behaviour every renderer needs: the
|
|
24
|
+
# callback's lifecycle name and a human-readable label for its filter. Keeping
|
|
25
|
+
# this on the domain object (rather than duplicating it across the Mermaid,
|
|
26
|
+
# Graphviz, and HTML renderers) guarantees identical output everywhere and
|
|
27
|
+
# isolates the one I/O-heavy path (proc source slicing) for focused testing.
|
|
28
|
+
class CallbackDefinition
|
|
29
|
+
# The callback's lifecycle name, e.g. "before_save". Centralises the string
|
|
30
|
+
# the renderers previously each rebuilt from phase + event.
|
|
31
|
+
#
|
|
32
|
+
# @return [String]
|
|
33
|
+
def callback_name
|
|
34
|
+
"#{phase}_#{event}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A human-readable label for the filter (Symbol, String, or Proc).
|
|
38
|
+
#
|
|
39
|
+
# Symbol/String filters render as their own text. A Proc renders as
|
|
40
|
+
# "(proc)" by default; when +expand+ is true it renders its actual source
|
|
41
|
+
# snippet (e.g. "-> { compute_reading_time }"), falling back to "(proc)" on
|
|
42
|
+
# any I/O or parse error.
|
|
43
|
+
#
|
|
44
|
+
# @param expand [Boolean]
|
|
45
|
+
# @return [String]
|
|
46
|
+
def filter_label(expand: false)
|
|
47
|
+
case filter
|
|
48
|
+
when Proc
|
|
49
|
+
return "(proc)" unless expand
|
|
50
|
+
|
|
51
|
+
proc_source || "(proc)"
|
|
52
|
+
else
|
|
53
|
+
filter.to_s
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Extracts the Proc's raw source snippet by reading its source file with
|
|
60
|
+
# Prism and slicing the enclosing lambda/block node, reusing the same
|
|
61
|
+
# technique as ConditionParser#parse_proc. Returns nil on any error
|
|
62
|
+
# (nil source_location, missing/unreadable file, parse failure, slice
|
|
63
|
+
# error), so #filter_label falls back to "(proc)".
|
|
64
|
+
#
|
|
65
|
+
# @return [String, nil] the single-line source snippet, or nil on any error
|
|
66
|
+
def proc_source
|
|
67
|
+
file, line = filter.source_location
|
|
68
|
+
return nil unless file && File.exist?(file)
|
|
69
|
+
|
|
70
|
+
result = Prism.parse_file(file)
|
|
71
|
+
return nil unless result.success?
|
|
72
|
+
|
|
73
|
+
locator = ProcNodeLocator.new(target_line: line)
|
|
74
|
+
locator.visit(result.value)
|
|
75
|
+
node = locator.node
|
|
76
|
+
return nil if node.nil?
|
|
77
|
+
|
|
78
|
+
node.location.slice.strip.gsub(/\s*\n\s*/, " ")
|
|
79
|
+
rescue StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A Prism visitor that captures the innermost LambdaNode or BlockNode whose
|
|
84
|
+
# source range encloses a target line — the *whole* node (so its
|
|
85
|
+
# location.slice yields the full "-> { ... }" / "{ ... }" text), unlike
|
|
86
|
+
# ConditionParser::LambdaLocator which captures only the node body.
|
|
87
|
+
class ProcNodeLocator < Prism::Visitor
|
|
88
|
+
# @return [Prism::Node, nil] the innermost matching lambda/block node
|
|
89
|
+
attr_reader :node
|
|
90
|
+
|
|
91
|
+
# @param target_line [Integer] the line the proc's source_location reports
|
|
92
|
+
def initialize(target_line:)
|
|
93
|
+
@target_line = target_line
|
|
94
|
+
@node = nil
|
|
95
|
+
super()
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param lambda_node [Prism::LambdaNode]
|
|
99
|
+
# @return [void]
|
|
100
|
+
def visit_lambda_node(lambda_node)
|
|
101
|
+
capture(lambda_node)
|
|
102
|
+
super
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param block_node [Prism::BlockNode]
|
|
106
|
+
# @return [void]
|
|
107
|
+
def visit_block_node(block_node)
|
|
108
|
+
capture(block_node)
|
|
109
|
+
super
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Records the candidate when it encloses the target line. Because the
|
|
115
|
+
# visitor descends depth-first, the last (innermost) enclosing match
|
|
116
|
+
# wins, isolating nested blocks correctly.
|
|
117
|
+
#
|
|
118
|
+
# @param candidate [Prism::LambdaNode, Prism::BlockNode]
|
|
119
|
+
# @return [void]
|
|
120
|
+
def capture(candidate)
|
|
121
|
+
location = candidate.location
|
|
122
|
+
return unless @target_line.between?(location.start_line, location.end_line)
|
|
123
|
+
|
|
124
|
+
@node = candidate
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
20
128
|
end
|
|
21
129
|
end
|
|
@@ -21,14 +21,17 @@ module ActiverecordCallbackLens
|
|
|
21
21
|
# `\"` so they cannot break the surrounding DOT label syntax.
|
|
22
22
|
class GraphvizRenderer
|
|
23
23
|
# @param graph [Graph::Graph]
|
|
24
|
+
# @param expand [Boolean] expand proc filter labels to their source snippet
|
|
24
25
|
# @return [String] DOT language string
|
|
25
|
-
def self.render(graph)
|
|
26
|
-
new(graph).render
|
|
26
|
+
def self.render(graph, expand: false)
|
|
27
|
+
new(graph, expand: expand).render
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# @param graph [Graph::Graph]
|
|
30
|
-
|
|
31
|
+
# @param expand [Boolean]
|
|
32
|
+
def initialize(graph, expand: false)
|
|
31
33
|
@graph = graph
|
|
34
|
+
@expand = expand
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
# @return [String] DOT language string
|
|
@@ -73,7 +76,8 @@ module ActiverecordCallbackLens
|
|
|
73
76
|
# @return [String]
|
|
74
77
|
def node_label(node)
|
|
75
78
|
case node
|
|
76
|
-
when Graph::CallbackNode
|
|
79
|
+
when Graph::CallbackNode
|
|
80
|
+
"#{node.definition.callback_name}: #{node.definition.filter_label(expand: @expand)}"
|
|
77
81
|
when Graph::PredicateNode then node.predicate_name
|
|
78
82
|
when Graph::MethodNode then node.method_name
|
|
79
83
|
when Graph::ConditionNode then node.tree_node.class.name.split("::").last
|
|
@@ -97,16 +97,19 @@ module ActiverecordCallbackLens
|
|
|
97
97
|
|
|
98
98
|
# @param graph [Graph::Graph] the assembled dependency graph
|
|
99
99
|
# @param definitions [Array<Collector::CallbackDefinition>] parsed definitions
|
|
100
|
+
# @param expand [Boolean] expand proc filter labels to their source snippet
|
|
100
101
|
# @return [String] a complete HTML document
|
|
101
|
-
def self.render(graph, definitions:)
|
|
102
|
-
new(graph, definitions).render
|
|
102
|
+
def self.render(graph, definitions:, expand: false)
|
|
103
|
+
new(graph, definitions, expand: expand).render
|
|
103
104
|
end
|
|
104
105
|
|
|
105
106
|
# @param graph [Graph::Graph]
|
|
106
107
|
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
107
|
-
|
|
108
|
+
# @param expand [Boolean]
|
|
109
|
+
def initialize(graph, definitions, expand: false)
|
|
108
110
|
@graph = graph
|
|
109
111
|
@definitions = definitions
|
|
112
|
+
@expand = expand
|
|
110
113
|
end
|
|
111
114
|
|
|
112
115
|
# Assembles the full HTML document.
|
|
@@ -138,7 +141,7 @@ module ActiverecordCallbackLens
|
|
|
138
141
|
"<tr>" \
|
|
139
142
|
"<td>#{escape(definition.phase)}</td>" \
|
|
140
143
|
"<td>#{escape(definition.event)}</td>" \
|
|
141
|
-
"<td>#{escape(filter_label(
|
|
144
|
+
"<td>#{escape(definition.filter_label(expand: @expand))}</td>" \
|
|
142
145
|
"<td>#{escape(conditions_label(definition))}</td>" \
|
|
143
146
|
"</tr>"
|
|
144
147
|
end
|
|
@@ -156,7 +159,9 @@ module ActiverecordCallbackLens
|
|
|
156
159
|
# Section 2: callbacks in canonical create-path execution order.
|
|
157
160
|
def execution_flow
|
|
158
161
|
ordered = ExecutionOrderAnalyzer.sort(@definitions, operation: :create)
|
|
159
|
-
items = ordered.map
|
|
162
|
+
items = ordered.map do |d|
|
|
163
|
+
"<li>#{escape("#{d.callback_name}: #{d.filter_label(expand: @expand)}")}</li>"
|
|
164
|
+
end
|
|
160
165
|
<<~HTML.chomp
|
|
161
166
|
<h2>Execution Flow</h2>
|
|
162
167
|
<ol>
|
|
@@ -171,7 +176,7 @@ module ActiverecordCallbackLens
|
|
|
171
176
|
trees = @definitions.filter_map do |definition|
|
|
172
177
|
next if definition.condition_tree.nil?
|
|
173
178
|
|
|
174
|
-
"<li>#{escape("#{definition.
|
|
179
|
+
"<li>#{escape("#{definition.callback_name}: #{definition.filter_label(expand: @expand)}")}" \
|
|
175
180
|
"#{ConditionTreeHtml.new(definition.condition_tree).to_html}</li>"
|
|
176
181
|
end
|
|
177
182
|
<<~HTML.chomp
|
|
@@ -186,14 +191,14 @@ module ActiverecordCallbackLens
|
|
|
186
191
|
def mermaid_section
|
|
187
192
|
<<~HTML.chomp
|
|
188
193
|
<h2>Mermaid Diagram</h2>
|
|
189
|
-
<pre class="mermaid">#{escape(MermaidRenderer.render(@graph))}</pre>
|
|
194
|
+
<pre class="mermaid">#{escape(MermaidRenderer.render(@graph, expand: @expand))}</pre>
|
|
190
195
|
HTML
|
|
191
196
|
end
|
|
192
197
|
|
|
193
198
|
# Section 5: the inline Graphviz SVG, or an empty string when +dot+ is not
|
|
194
199
|
# installed (GraphvizRenderer#to_svg returns nil).
|
|
195
200
|
def svg_section
|
|
196
|
-
svg = GraphvizRenderer.new(@graph).to_svg
|
|
201
|
+
svg = GraphvizRenderer.new(@graph, expand: @expand).to_svg
|
|
197
202
|
return "" if svg.nil?
|
|
198
203
|
|
|
199
204
|
<<~HTML.chomp
|
|
@@ -202,12 +207,6 @@ module ActiverecordCallbackLens
|
|
|
202
207
|
HTML
|
|
203
208
|
end
|
|
204
209
|
|
|
205
|
-
# A human-readable label for a definition's filter (Symbol, String, or Proc).
|
|
206
|
-
def filter_label(definition)
|
|
207
|
-
filter = definition.filter
|
|
208
|
-
filter.is_a?(Proc) ? "(proc)" : filter.to_s
|
|
209
|
-
end
|
|
210
|
-
|
|
211
210
|
# A comma-joined label of a definition's condition leaf names, or "".
|
|
212
211
|
def conditions_label(definition)
|
|
213
212
|
tree = definition.condition_tree
|
|
@@ -19,14 +19,17 @@ module ActiverecordCallbackLens
|
|
|
19
19
|
# surrounding Mermaid label syntax.
|
|
20
20
|
class MermaidRenderer
|
|
21
21
|
# @param graph [Graph::Graph]
|
|
22
|
+
# @param expand [Boolean] expand proc filter labels to their source snippet
|
|
22
23
|
# @return [String]
|
|
23
|
-
def self.render(graph)
|
|
24
|
-
new(graph).render
|
|
24
|
+
def self.render(graph, expand: false)
|
|
25
|
+
new(graph, expand: expand).render
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
# @param graph [Graph::Graph]
|
|
28
|
-
|
|
29
|
+
# @param expand [Boolean]
|
|
30
|
+
def initialize(graph, expand: false)
|
|
29
31
|
@graph = graph
|
|
32
|
+
@expand = expand
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# @return [String]
|
|
@@ -55,7 +58,8 @@ module ActiverecordCallbackLens
|
|
|
55
58
|
# @return [String]
|
|
56
59
|
def node_label(node)
|
|
57
60
|
case node
|
|
58
|
-
when Graph::CallbackNode
|
|
61
|
+
when Graph::CallbackNode
|
|
62
|
+
"#{node.definition.callback_name}: #{node.definition.filter_label(expand: @expand)}"
|
|
59
63
|
when Graph::PredicateNode then node.predicate_name
|
|
60
64
|
when Graph::MethodNode then node.method_name
|
|
61
65
|
when Graph::ConditionNode then node.tree_node.class.name.split("::").last
|
|
@@ -43,7 +43,7 @@ module CallbackLensRakeHelpers
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
46
|
-
ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
46
|
+
ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph, expand: expand)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# Runs the full pipeline (collect -> parse -> [expand] -> build -> render) for
|
|
@@ -63,7 +63,7 @@ module CallbackLensRakeHelpers
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
66
|
-
ActiverecordCallbackLens::Renderer::GraphvizRenderer.render(graph)
|
|
66
|
+
ActiverecordCallbackLens::Renderer::GraphvizRenderer.render(graph, expand: expand)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
# Runs the full pipeline (collect -> parse -> [expand] -> build) for a model and
|
|
@@ -85,7 +85,7 @@ module CallbackLensRakeHelpers
|
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
87
|
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
88
|
-
ActiverecordCallbackLens::Renderer::HtmlRenderer.render(graph, definitions: definitions)
|
|
88
|
+
ActiverecordCallbackLens::Renderer::HtmlRenderer.render(graph, definitions: definitions, expand: expand)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
# Parses the EXPAND environment variable using the strict truthy rule: only the
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord_callback_lens
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eraxel.Dev
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|