activerecord_callback_lens 0.2.1 → 0.4.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 +62 -4
- data/lib/activerecord_callback_lens/cli/cli.rb +50 -10
- data/lib/activerecord_callback_lens/execution_order_analyzer.rb +56 -0
- data/lib/activerecord_callback_lens/renderer/graphviz_renderer.rb +95 -0
- data/lib/activerecord_callback_lens/renderer/html_renderer.rb +225 -0
- data/lib/activerecord_callback_lens/tasks/callback_lens.rake +18 -0
- data/lib/activerecord_callback_lens/tasks/callback_lens_helpers.rb +42 -0
- data/lib/activerecord_callback_lens/version.rb +1 -1
- data/lib/activerecord_callback_lens.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d449ee130b618f98ee114f459fbb178a5b7cded7465853e2b1eb5c2c39893061
|
|
4
|
+
data.tar.gz: 0b64c3820a229bb381b597faecc9936e29d3a053cb174beccbe557d1eb86f368
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb0f92c15fb5e7c8ed1bcdd1a1bb25f2a70e2c9b7b898d88b6614369ea24a7aeb2c8627e37b192a404259b9fa3143582d27e8a45671fd8ce52b0b8e052db1e01
|
|
7
|
+
data.tar.gz: 1f01ab230f4ac8b50a604df94a33eb95c63e0f8bfdc1eaba1a4997bf069e0e4272365b556a5e5fffa6af687d1017d5b10bdd9937a8944e2d532005c37b7a0804
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**X-ray your ActiveRecord callbacks.**
|
|
4
4
|
|
|
5
|
-
Callbacks are easy to add and hard to reason about. `activerecord_callback_lens` statically analyzes the callbacks registered on your ActiveRecord models, parses their `if`/`unless` conditions into logical trees, and renders the result as a Mermaid diagram so you can see exactly what runs and why.
|
|
5
|
+
Callbacks are easy to add and hard to reason about. `activerecord_callback_lens` statically analyzes the callbacks registered on your ActiveRecord models, parses their `if`/`unless` conditions into logical trees, and renders the result as a Mermaid diagram, Graphviz DOT graph, or a self-contained HTML report so you can see exactly what runs and why.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
@@ -76,6 +76,60 @@ rake callback_lens:mermaid MODEL=User EXPAND=true
|
|
|
76
76
|
Only the exact value `EXPAND=true` (case-insensitive) enables expansion; any
|
|
77
77
|
other value (`EXPAND=1`, `EXPAND=yes`, empty, or absent) leaves it off.
|
|
78
78
|
|
|
79
|
+
#### Graphviz DOT output
|
|
80
|
+
|
|
81
|
+
Pass `--graphviz` (CLI) or use `callback_lens:graphviz` (Rake) to output a
|
|
82
|
+
[Graphviz DOT](https://graphviz.org/) diagram to stdout. Pipe it to `dot` to
|
|
83
|
+
generate an image:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# CLI — print DOT to stdout
|
|
87
|
+
callback_lens analyze User --graphviz
|
|
88
|
+
|
|
89
|
+
# CLI — combine with Mermaid output
|
|
90
|
+
callback_lens analyze User --mermaid --graphviz
|
|
91
|
+
|
|
92
|
+
# CLI — pipe to dot for a PNG
|
|
93
|
+
callback_lens analyze User --graphviz | dot -Tpng -o callbacks.png
|
|
94
|
+
|
|
95
|
+
# Rake
|
|
96
|
+
rake callback_lens:graphviz MODEL=User
|
|
97
|
+
rake callback_lens:graphviz MODEL=User EXPAND=true
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Requires [Graphviz](https://graphviz.org/download/) only when piping to `dot`.
|
|
101
|
+
|
|
102
|
+
#### HTML report
|
|
103
|
+
|
|
104
|
+
Pass `--html FILE` (CLI) or use `callback_lens:html` (Rake) to generate a
|
|
105
|
+
self-contained HTML report. The file embeds a Mermaid diagram (via CDN), an
|
|
106
|
+
inline Graphviz SVG (when `dot` is installed), a callback list table, an
|
|
107
|
+
execution-order flow, and a nested dependency tree for every condition — no
|
|
108
|
+
external dependencies at view time.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# CLI — write report to a file
|
|
112
|
+
callback_lens analyze User --html report.html
|
|
113
|
+
|
|
114
|
+
# CLI — combine with stdout output
|
|
115
|
+
callback_lens analyze User --mermaid --html report.html
|
|
116
|
+
|
|
117
|
+
# Rake — default output filename: callback_lens_report.html
|
|
118
|
+
rake callback_lens:html MODEL=User
|
|
119
|
+
|
|
120
|
+
# Rake — custom output path
|
|
121
|
+
rake callback_lens:html MODEL=User OUT=tmp/user_callbacks.html
|
|
122
|
+
|
|
123
|
+
# Rake — with recursive method expansion
|
|
124
|
+
rake callback_lens:html MODEL=User OUT=tmp/user_callbacks.html EXPAND=true
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| ENV variable | Required | Default | Description |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| `MODEL` | Yes | — | ActiveRecord model class name |
|
|
130
|
+
| `OUT` | No | `callback_lens_report.html` | Output path for the HTML file |
|
|
131
|
+
| `EXPAND` | No | `false` | Set to `true` to recursively expand method conditions |
|
|
132
|
+
|
|
79
133
|
### Programmatic API
|
|
80
134
|
|
|
81
135
|
```ruby
|
|
@@ -100,8 +154,12 @@ puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
|
100
154
|
|---|---|---|
|
|
101
155
|
| Collector | `CallbackCollector` | Reads ActiveRecord's internal `_save_callbacks`, `_create_callbacks`, `_update_callbacks`, `_destroy_callbacks`, and `_validation_callbacks` chains |
|
|
102
156
|
| Parser | `ConditionParser` | Uses [Prism](https://github.com/ruby/prism) to parse `Proc`/`Lambda` conditions into `AndNode`/`OrNode`/`NotNode`/`PredicateNode` trees; `Symbol` conditions become `MethodRefNode` stubs |
|
|
157
|
+
| Resolver | `MethodResolver` | Recursively expands `MethodRefNode` symbols into full `ConditionTree` sub-trees (up to 5 levels deep) with cycle detection |
|
|
103
158
|
| Graph | `GraphBuilder` | Assembles a DAG of `CallbackNode`, `ConditionNode`, `PredicateNode`, and `MethodNode` values |
|
|
159
|
+
| Analyzer | `ExecutionOrderAnalyzer` | Sorts definitions into canonical Rails execution order (create or update path) |
|
|
104
160
|
| Renderer | `MermaidRenderer` | Serializes the graph to a Mermaid `graph TD` string |
|
|
161
|
+
| Renderer | `GraphvizRenderer` | Serializes the graph to a Graphviz DOT string; `#to_svg` shells out to `dot` |
|
|
162
|
+
| Renderer | `HtmlRenderer` | Produces a self-contained HTML report embedding Mermaid, SVG, callback table, execution flow, and dependency tree |
|
|
105
163
|
|
|
106
164
|
## Condition tree nodes
|
|
107
165
|
|
|
@@ -118,9 +176,9 @@ puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
|
118
176
|
| Version | Feature |
|
|
119
177
|
|---|---|
|
|
120
178
|
| v0.1 | CallbackCollector, Prism condition parser, Mermaid renderer, CLI, Rake task |
|
|
121
|
-
|
|
|
122
|
-
| v0.3 | Graphviz / DOT renderer |
|
|
123
|
-
| v0.4 | HTML report
|
|
179
|
+
| v0.2 | MethodResolver — recursive expansion of Symbol conditions (`--expand` / `EXPAND=true`) |
|
|
180
|
+
| v0.3 | Graphviz / DOT renderer (`--graphviz` / `callback_lens:graphviz`) |
|
|
181
|
+
| **v0.4** | HTML report — callback list table, execution-order flow, dependency tree, embedded Mermaid and Graphviz SVG (`--html` / `callback_lens:html`) |
|
|
124
182
|
| v1.0 | Runtime tracer via `ActiveSupport::Notifications` |
|
|
125
183
|
| v2.0 | RBS analysis, cross-model dependency graph |
|
|
126
184
|
|
|
@@ -7,15 +7,20 @@ require_relative "../parser/condition_parser"
|
|
|
7
7
|
require_relative "../resolver/method_resolver"
|
|
8
8
|
require_relative "../graph/graph_builder"
|
|
9
9
|
require_relative "../renderer/mermaid_renderer"
|
|
10
|
+
require_relative "../renderer/graphviz_renderer"
|
|
11
|
+
require_relative "../renderer/html_renderer"
|
|
10
12
|
|
|
11
13
|
module ActiverecordCallbackLens
|
|
12
14
|
module CLI
|
|
13
15
|
# Thor application exposing the callback_lens command-line interface.
|
|
14
16
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# It provides a single command, +analyze+, which runs the full pipeline
|
|
18
|
+
# (collect -> parse -> build graph -> render) and prints the result to
|
|
19
|
+
# stdout. Output format is selectable per invocation: a Mermaid diagram
|
|
20
|
+
# (+--mermaid+, on by default) and/or a Graphviz DOT graph (+--graphviz+);
|
|
21
|
+
# the two flags are independent and may be combined. An unknown model name
|
|
22
|
+
# is reported with a friendly message and a non-zero exit status rather than
|
|
23
|
+
# a Ruby backtrace.
|
|
19
24
|
class App < Thor
|
|
20
25
|
# Tells Thor to exit with a non-zero status when a command raises, so the
|
|
21
26
|
# +exit 1+ paths below propagate a failure code to the shell.
|
|
@@ -25,22 +30,41 @@ module ActiverecordCallbackLens
|
|
|
25
30
|
true
|
|
26
31
|
end
|
|
27
32
|
|
|
28
|
-
desc "analyze MODEL", "Analyze callbacks for a model class and print a Mermaid diagram"
|
|
33
|
+
desc "analyze MODEL", "Analyze callbacks for a model class and print a Mermaid and/or Graphviz diagram"
|
|
29
34
|
option :mermaid, type: :boolean, default: true, desc: "Output a Mermaid diagram to stdout"
|
|
35
|
+
option :graphviz, type: :boolean, default: false,
|
|
36
|
+
desc: "Output DOT graph via Graphviz to stdout"
|
|
30
37
|
option :expand, type: :boolean, default: false,
|
|
31
38
|
desc: "Expand method conditions recursively (up to depth 5)"
|
|
39
|
+
option :html, type: :string, desc: "Write HTML report to FILE"
|
|
32
40
|
# Runs the analysis pipeline for +model_name+ and prints the result.
|
|
33
41
|
#
|
|
42
|
+
# When +--html FILE+ is given, a self-contained HTML report is written to
|
|
43
|
+
# FILE (in addition to any stdout output selected by the other flags) and a
|
|
44
|
+
# confirmation line is printed.
|
|
45
|
+
#
|
|
34
46
|
# @param model_name [String] the ActiveRecord model class name
|
|
35
47
|
# @return [void]
|
|
36
48
|
def analyze(model_name)
|
|
37
49
|
model_class = resolve_model(model_name)
|
|
38
|
-
graph =
|
|
39
|
-
|
|
50
|
+
definitions, graph = build_pipeline(model_class, expand: options[:expand])
|
|
51
|
+
render_outputs(graph, definitions)
|
|
40
52
|
end
|
|
41
53
|
|
|
42
54
|
private
|
|
43
55
|
|
|
56
|
+
# Emits each output selected by the analyze options: Mermaid and/or Graphviz
|
|
57
|
+
# to stdout, and an HTML report to a file when +--html+ is given.
|
|
58
|
+
#
|
|
59
|
+
# @param graph [Graph::Graph]
|
|
60
|
+
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
61
|
+
# @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]
|
|
66
|
+
end
|
|
67
|
+
|
|
44
68
|
# Resolves a model class by name, printing a friendly error and exiting
|
|
45
69
|
# non-zero when the constant cannot be found.
|
|
46
70
|
#
|
|
@@ -60,16 +84,32 @@ module ActiverecordCallbackLens
|
|
|
60
84
|
# MethodRefNodes resolved into ConditionTree sub-trees via MethodResolver.
|
|
61
85
|
# When false, the pipeline is identical to v0.1 output.
|
|
62
86
|
#
|
|
87
|
+
# Returns both the parsed definitions and the assembled graph so renderers
|
|
88
|
+
# that need the definitions directly (e.g. the HTML report) can access them
|
|
89
|
+
# without re-running the pipeline.
|
|
90
|
+
#
|
|
63
91
|
# @param model_class [Class]
|
|
64
92
|
# @param expand [Boolean]
|
|
65
|
-
# @return [Graph::Graph]
|
|
66
|
-
def
|
|
93
|
+
# @return [Array(Array<Collector::CallbackDefinition>, Graph::Graph)]
|
|
94
|
+
def build_pipeline(model_class, expand: false)
|
|
67
95
|
definitions = Collector::CallbackCollector.collect(model_class)
|
|
68
96
|
definitions = definitions.map { |definition| Parser::ConditionParser.parse(definition) }
|
|
69
97
|
if expand
|
|
70
98
|
definitions = definitions.map { |definition| Resolver::MethodResolver.expand(definition, model_class) }
|
|
71
99
|
end
|
|
72
|
-
Graph::GraphBuilder.build(definitions)
|
|
100
|
+
[definitions, Graph::GraphBuilder.build(definitions)]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Writes the HTML report to +path+ and prints a confirmation line.
|
|
104
|
+
#
|
|
105
|
+
# @param graph [Graph::Graph]
|
|
106
|
+
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
107
|
+
# @param path [String]
|
|
108
|
+
# @return [void]
|
|
109
|
+
def write_html(graph, definitions, path)
|
|
110
|
+
html = Renderer::HtmlRenderer.render(graph, definitions: definitions)
|
|
111
|
+
File.write(path, html)
|
|
112
|
+
puts "HTML report written to #{path}"
|
|
73
113
|
end
|
|
74
114
|
end
|
|
75
115
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiverecordCallbackLens
|
|
4
|
+
# Sorts a list of Collector::CallbackDefinition objects into the canonical
|
|
5
|
+
# order in which Rails executes callbacks on a record save.
|
|
6
|
+
#
|
|
7
|
+
# Two orderings are supported, selected via the +operation+ keyword:
|
|
8
|
+
#
|
|
9
|
+
# * +:create+ (the default) follows the create path
|
|
10
|
+
# (before_create / after_create around the INSERT).
|
|
11
|
+
# * +:update+ follows the update path
|
|
12
|
+
# (before_update / after_update around the UPDATE).
|
|
13
|
+
#
|
|
14
|
+
# A definition's position is derived from its +phase_event+ pair (for example
|
|
15
|
+
# +before_save+). Callbacks whose pair is not part of the canonical order are
|
|
16
|
+
# placed at the end (sort index 999) while preserving their relative order,
|
|
17
|
+
# because Ruby's Array#sort_by is stable for equal keys.
|
|
18
|
+
class ExecutionOrderAnalyzer
|
|
19
|
+
# Canonical create-path order. Mirrors spec section 7.1 (create path).
|
|
20
|
+
SAVE_ORDER = %i[
|
|
21
|
+
before_validation after_validation
|
|
22
|
+
before_save
|
|
23
|
+
before_create
|
|
24
|
+
after_create
|
|
25
|
+
after_save
|
|
26
|
+
after_commit
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Canonical update-path order. Mirrors spec section 7.1 (update path).
|
|
30
|
+
UPDATE_ORDER = %i[
|
|
31
|
+
before_validation after_validation
|
|
32
|
+
before_save
|
|
33
|
+
before_update
|
|
34
|
+
after_update
|
|
35
|
+
after_save
|
|
36
|
+
after_commit
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# Sort index assigned to callbacks whose phase_event pair is not part of the
|
|
40
|
+
# canonical order, so they sort after every recognised callback.
|
|
41
|
+
UNRECOGNISED_INDEX = 999
|
|
42
|
+
|
|
43
|
+
# Sorts +definitions+ into canonical execution order.
|
|
44
|
+
#
|
|
45
|
+
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
46
|
+
# @param operation [Symbol] :create (default) or :update
|
|
47
|
+
# @return [Array<Collector::CallbackDefinition>] a new sorted array
|
|
48
|
+
def self.sort(definitions, operation: :create)
|
|
49
|
+
order = operation == :update ? UPDATE_ORDER : SAVE_ORDER
|
|
50
|
+
definitions.sort_by do |definition|
|
|
51
|
+
key = :"#{definition.phase}_#{definition.event}"
|
|
52
|
+
order.index(key) || UNRECOGNISED_INDEX
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../graph/nodes"
|
|
4
|
+
|
|
5
|
+
module ActiverecordCallbackLens
|
|
6
|
+
module Renderer
|
|
7
|
+
# Renders a Graph::Graph as a Graphviz DOT language string.
|
|
8
|
+
#
|
|
9
|
+
# The output is a `digraph` block with a left-to-right rank direction, one
|
|
10
|
+
# declaration per node and one arrow per edge:
|
|
11
|
+
#
|
|
12
|
+
# digraph callback_lens {
|
|
13
|
+
# rankdir=LR;
|
|
14
|
+
# n0 [label="before_save"];
|
|
15
|
+
# n1 [label="active?"];
|
|
16
|
+
# n1 -> n0;
|
|
17
|
+
# }
|
|
18
|
+
#
|
|
19
|
+
# Node labels are derived from the node type (see #node_label) using the same
|
|
20
|
+
# four-case logic as MermaidRenderer. Double quotes in a label are escaped as
|
|
21
|
+
# `\"` so they cannot break the surrounding DOT label syntax.
|
|
22
|
+
class GraphvizRenderer
|
|
23
|
+
# @param graph [Graph::Graph]
|
|
24
|
+
# @return [String] DOT language string
|
|
25
|
+
def self.render(graph)
|
|
26
|
+
new(graph).render
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param graph [Graph::Graph]
|
|
30
|
+
def initialize(graph)
|
|
31
|
+
@graph = graph
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] DOT language string
|
|
35
|
+
def render
|
|
36
|
+
lines = ["digraph callback_lens {", " rankdir=LR;"]
|
|
37
|
+
lines.concat(node_declarations)
|
|
38
|
+
lines.concat(edge_declarations)
|
|
39
|
+
lines << "}"
|
|
40
|
+
lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Shells out to the `dot` binary and returns the rendered SVG string.
|
|
44
|
+
# Returns nil when Graphviz is not installed (the `dot` binary is absent
|
|
45
|
+
# from PATH) rather than raising.
|
|
46
|
+
#
|
|
47
|
+
# @return [String, nil]
|
|
48
|
+
def to_svg
|
|
49
|
+
IO.popen(["dot", "-Tsvg"], "r+") do |io|
|
|
50
|
+
io.write(render)
|
|
51
|
+
io.close_write
|
|
52
|
+
io.read
|
|
53
|
+
end
|
|
54
|
+
rescue Errno::ENOENT
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @return [Array<String>]
|
|
61
|
+
def node_declarations
|
|
62
|
+
@graph.nodes.map { |node| " #{node.id} [label=\"#{escape(node_label(node))}\"];" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Array<String>]
|
|
66
|
+
def edge_declarations
|
|
67
|
+
@graph.edges.map { |edge| " #{edge.from_id} -> #{edge.to_id};" }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Derives the human-readable label for a graph node from its type.
|
|
71
|
+
#
|
|
72
|
+
# @param node [Graph::CallbackNode, Graph::PredicateNode, Graph::MethodNode, Graph::ConditionNode]
|
|
73
|
+
# @return [String]
|
|
74
|
+
def node_label(node)
|
|
75
|
+
case node
|
|
76
|
+
when Graph::CallbackNode then "#{node.definition.phase}_#{node.definition.event}"
|
|
77
|
+
when Graph::PredicateNode then node.predicate_name
|
|
78
|
+
when Graph::MethodNode then node.method_name
|
|
79
|
+
when Graph::ConditionNode then node.tree_node.class.name.split("::").last
|
|
80
|
+
else node.id
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Escapes characters that would otherwise break a `label="..."` DOT label.
|
|
85
|
+
# Double quotes become a backslash-escaped quote so the label stays a
|
|
86
|
+
# single, valid DOT token. (DOT uses `\"`, unlike Mermaid's `"`.)
|
|
87
|
+
#
|
|
88
|
+
# @param label [String]
|
|
89
|
+
# @return [String]
|
|
90
|
+
def escape(label)
|
|
91
|
+
label.to_s.gsub("\"", "\\\"")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
require_relative "mermaid_renderer"
|
|
6
|
+
require_relative "graphviz_renderer"
|
|
7
|
+
require_relative "../execution_order_analyzer"
|
|
8
|
+
require_relative "../parser/condition_tree"
|
|
9
|
+
|
|
10
|
+
module ActiverecordCallbackLens
|
|
11
|
+
module Renderer
|
|
12
|
+
# Renders a single ConditionTree node (and its descendants) as nested +<ul>+
|
|
13
|
+
# markup, and exposes the flat list of leaf condition names. Extracted from
|
|
14
|
+
# HtmlRenderer so the recursive tree handling is a single, testable
|
|
15
|
+
# responsibility separate from page assembly.
|
|
16
|
+
#
|
|
17
|
+
# All names are HTML escaped via CGI.escapeHTML so values containing +<+,
|
|
18
|
+
# +>+ or +&+ cannot break the surrounding markup.
|
|
19
|
+
class ConditionTreeHtml
|
|
20
|
+
Tree = Parser::ConditionTree
|
|
21
|
+
|
|
22
|
+
# @param node [Parser::ConditionTree::Node]
|
|
23
|
+
def initialize(node)
|
|
24
|
+
@node = node
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [String] nested <ul> markup for the node
|
|
28
|
+
def to_html
|
|
29
|
+
render(@node)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Array<String>] leaf names (predicate + method refs), depth-first
|
|
33
|
+
def names
|
|
34
|
+
leaf_names(@node)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def render(node)
|
|
40
|
+
case node
|
|
41
|
+
when Tree::AndNode then combinator("AND", node.children)
|
|
42
|
+
when Tree::OrNode then combinator("OR", node.children)
|
|
43
|
+
when Tree::NotNode then combinator("NOT", [node.child])
|
|
44
|
+
when Tree::PredicateNode then "<ul><li>#{escape(node.name)}</li></ul>"
|
|
45
|
+
when Tree::MethodRefNode then method_ref(node)
|
|
46
|
+
else ""
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def combinator(label, children)
|
|
51
|
+
items = children.map { |child| "<li>#{render(child)}</li>" }
|
|
52
|
+
"<ul><li>#{escape(label)}</li>#{items.join}</ul>"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def method_ref(node)
|
|
56
|
+
inner = node.expanded_tree.nil? ? "" : render(node.expanded_tree)
|
|
57
|
+
"<ul><li>#{escape(node.name)}#{inner}</li></ul>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def leaf_names(node)
|
|
61
|
+
case node
|
|
62
|
+
when Tree::AndNode, Tree::OrNode
|
|
63
|
+
node.children.flat_map { |child| leaf_names(child) }
|
|
64
|
+
when Tree::NotNode
|
|
65
|
+
leaf_names(node.child)
|
|
66
|
+
when Tree::PredicateNode, Tree::MethodRefNode
|
|
67
|
+
[node.name]
|
|
68
|
+
else
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def escape(value)
|
|
74
|
+
CGI.escapeHTML(value.to_s)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Renders a self-contained HTML report for a model's callbacks.
|
|
79
|
+
#
|
|
80
|
+
# The document embeds five sections (spec section 8.3):
|
|
81
|
+
#
|
|
82
|
+
# 1. Callback List — a table with columns Phase, Event, Filter, Conditions.
|
|
83
|
+
# 2. Execution Flow — an ordered list sorted by ExecutionOrderAnalyzer
|
|
84
|
+
# (the +:create+ path).
|
|
85
|
+
# 3. Dependency Tree — a nested +<ul>+ built from each definition's
|
|
86
|
+
# ConditionTree (via ConditionTreeHtml).
|
|
87
|
+
# 4. Mermaid Diagram — a +<pre class="mermaid">+ block populated by
|
|
88
|
+
# MermaidRenderer and rendered client-side via the Mermaid CDN script.
|
|
89
|
+
# 5. Graphviz SVG — the inline +<svg>+ produced by GraphvizRenderer#to_svg
|
|
90
|
+
# when the +dot+ binary is available; the section is omitted otherwise.
|
|
91
|
+
#
|
|
92
|
+
# All user-derived text is HTML escaped with CGI.escapeHTML.
|
|
93
|
+
class HtmlRenderer
|
|
94
|
+
# The Mermaid.js bundle loaded from a CDN so the report stays a single,
|
|
95
|
+
# dependency-free HTML file.
|
|
96
|
+
MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"
|
|
97
|
+
|
|
98
|
+
# @param graph [Graph::Graph] the assembled dependency graph
|
|
99
|
+
# @param definitions [Array<Collector::CallbackDefinition>] parsed definitions
|
|
100
|
+
# @return [String] a complete HTML document
|
|
101
|
+
def self.render(graph, definitions:)
|
|
102
|
+
new(graph, definitions).render
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param graph [Graph::Graph]
|
|
106
|
+
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
107
|
+
def initialize(graph, definitions)
|
|
108
|
+
@graph = graph
|
|
109
|
+
@definitions = definitions
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Assembles the full HTML document.
|
|
113
|
+
#
|
|
114
|
+
# @return [String]
|
|
115
|
+
def render
|
|
116
|
+
<<~HTML
|
|
117
|
+
<!DOCTYPE html>
|
|
118
|
+
<html>
|
|
119
|
+
<head><meta charset="utf-8"><title>Callback Lens</title></head>
|
|
120
|
+
<body>
|
|
121
|
+
#{callback_table}
|
|
122
|
+
#{execution_flow}
|
|
123
|
+
#{dependency_trees}
|
|
124
|
+
#{mermaid_section}
|
|
125
|
+
#{svg_section}
|
|
126
|
+
<script src="#{MERMAID_CDN}"></script>
|
|
127
|
+
<script>mermaid.initialize({startOnLoad:true});</script>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
|
130
|
+
HTML
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# Section 1: a table with one row per definition.
|
|
136
|
+
def callback_table
|
|
137
|
+
rows = @definitions.map do |definition|
|
|
138
|
+
"<tr>" \
|
|
139
|
+
"<td>#{escape(definition.phase)}</td>" \
|
|
140
|
+
"<td>#{escape(definition.event)}</td>" \
|
|
141
|
+
"<td>#{escape(filter_label(definition))}</td>" \
|
|
142
|
+
"<td>#{escape(conditions_label(definition))}</td>" \
|
|
143
|
+
"</tr>"
|
|
144
|
+
end
|
|
145
|
+
<<~HTML.chomp
|
|
146
|
+
<h2>Callback List</h2>
|
|
147
|
+
<table>
|
|
148
|
+
<thead><tr><th>Phase</th><th>Event</th><th>Filter</th><th>Conditions</th></tr></thead>
|
|
149
|
+
<tbody>
|
|
150
|
+
#{rows.join("\n")}
|
|
151
|
+
</tbody>
|
|
152
|
+
</table>
|
|
153
|
+
HTML
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Section 2: callbacks in canonical create-path execution order.
|
|
157
|
+
def execution_flow
|
|
158
|
+
ordered = ExecutionOrderAnalyzer.sort(@definitions, operation: :create)
|
|
159
|
+
items = ordered.map { |d| "<li>#{escape("#{d.phase}_#{d.event}")}</li>" }
|
|
160
|
+
<<~HTML.chomp
|
|
161
|
+
<h2>Execution Flow</h2>
|
|
162
|
+
<ol>
|
|
163
|
+
#{items.join("\n")}
|
|
164
|
+
</ol>
|
|
165
|
+
HTML
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Section 3: a nested dependency tree per definition that carries a
|
|
169
|
+
# condition_tree. Definitions without conditions are skipped.
|
|
170
|
+
def dependency_trees
|
|
171
|
+
trees = @definitions.filter_map do |definition|
|
|
172
|
+
next if definition.condition_tree.nil?
|
|
173
|
+
|
|
174
|
+
"<li>#{escape("#{definition.phase}_#{definition.event}")}" \
|
|
175
|
+
"#{ConditionTreeHtml.new(definition.condition_tree).to_html}</li>"
|
|
176
|
+
end
|
|
177
|
+
<<~HTML.chomp
|
|
178
|
+
<h2>Dependency Tree</h2>
|
|
179
|
+
<ul>
|
|
180
|
+
#{trees.join("\n")}
|
|
181
|
+
</ul>
|
|
182
|
+
HTML
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Section 4: the Mermaid source wrapped in a client-rendered block.
|
|
186
|
+
def mermaid_section
|
|
187
|
+
<<~HTML.chomp
|
|
188
|
+
<h2>Mermaid Diagram</h2>
|
|
189
|
+
<pre class="mermaid">#{escape(MermaidRenderer.render(@graph))}</pre>
|
|
190
|
+
HTML
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Section 5: the inline Graphviz SVG, or an empty string when +dot+ is not
|
|
194
|
+
# installed (GraphvizRenderer#to_svg returns nil).
|
|
195
|
+
def svg_section
|
|
196
|
+
svg = GraphvizRenderer.new(@graph).to_svg
|
|
197
|
+
return "" if svg.nil?
|
|
198
|
+
|
|
199
|
+
<<~HTML.chomp
|
|
200
|
+
<h2>Graphviz SVG</h2>
|
|
201
|
+
<div class="graphviz">#{svg}</div>
|
|
202
|
+
HTML
|
|
203
|
+
end
|
|
204
|
+
|
|
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
|
+
# A comma-joined label of a definition's condition leaf names, or "".
|
|
212
|
+
def conditions_label(definition)
|
|
213
|
+
tree = definition.condition_tree
|
|
214
|
+
return "" if tree.nil?
|
|
215
|
+
|
|
216
|
+
ConditionTreeHtml.new(tree).names.join(", ")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# HTML-escapes a value so it is safe to embed as element text.
|
|
220
|
+
def escape(value)
|
|
221
|
+
CGI.escapeHTML(value.to_s)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -17,4 +17,22 @@ namespace :callback_lens do
|
|
|
17
17
|
expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
|
|
18
18
|
puts CallbackLensRakeHelpers.render_mermaid(model_class, expand: expand)
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
desc "Write Graphviz DOT to STDOUT for MODEL " \
|
|
22
|
+
"(e.g. rake callback_lens:graphviz MODEL=User EXPAND=true)"
|
|
23
|
+
task graphviz: :environment do
|
|
24
|
+
model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
|
|
25
|
+
expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
|
|
26
|
+
puts CallbackLensRakeHelpers.render_graphviz(model_class, expand: expand)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
desc "Write a self-contained HTML report to OUT for MODEL " \
|
|
30
|
+
"(e.g. rake callback_lens:html MODEL=User OUT=report.html EXPAND=true)"
|
|
31
|
+
task html: :environment do
|
|
32
|
+
model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
|
|
33
|
+
expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
|
|
34
|
+
out = ENV.fetch("OUT", "callback_lens_report.html")
|
|
35
|
+
File.write(out, CallbackLensRakeHelpers.render_html(model_class, expand: expand))
|
|
36
|
+
puts "Report written to #{out}"
|
|
37
|
+
end
|
|
20
38
|
end
|
|
@@ -46,6 +46,48 @@ module CallbackLensRakeHelpers
|
|
|
46
46
|
ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Runs the full pipeline (collect -> parse -> [expand] -> build -> render) for
|
|
50
|
+
# a model and returns a Graphviz DOT language string. Mirrors +render_mermaid+
|
|
51
|
+
# but uses the GraphvizRenderer; +expand+ is threaded through identically so
|
|
52
|
+
# the +EXPAND=true+ rake convention applies to the graphviz task too.
|
|
53
|
+
#
|
|
54
|
+
# @param model_class [Class]
|
|
55
|
+
# @param expand [Boolean]
|
|
56
|
+
# @return [String] DOT language string
|
|
57
|
+
def render_graphviz(model_class, expand: false)
|
|
58
|
+
definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(model_class)
|
|
59
|
+
definitions = definitions.map { |definition| ActiverecordCallbackLens::Parser::ConditionParser.parse(definition) }
|
|
60
|
+
if expand
|
|
61
|
+
definitions = definitions.map do |definition|
|
|
62
|
+
ActiverecordCallbackLens::Resolver::MethodResolver.expand(definition, model_class)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
66
|
+
ActiverecordCallbackLens::Renderer::GraphvizRenderer.render(graph)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Runs the full pipeline (collect -> parse -> [expand] -> build) for a model and
|
|
70
|
+
# returns a self-contained HTML report via HtmlRenderer. Mirrors
|
|
71
|
+
# +render_mermaid+ / +render_graphviz+ but passes both the parsed definitions
|
|
72
|
+
# and the assembled graph to the HtmlRenderer, which needs the definitions to
|
|
73
|
+
# build the callback table, execution flow, and dependency tree. +expand+ is
|
|
74
|
+
# threaded through identically so the +EXPAND=true+ rake convention applies.
|
|
75
|
+
#
|
|
76
|
+
# @param model_class [Class]
|
|
77
|
+
# @param expand [Boolean]
|
|
78
|
+
# @return [String] a complete HTML document
|
|
79
|
+
def render_html(model_class, expand: false)
|
|
80
|
+
definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(model_class)
|
|
81
|
+
definitions = definitions.map { |definition| ActiverecordCallbackLens::Parser::ConditionParser.parse(definition) }
|
|
82
|
+
if expand
|
|
83
|
+
definitions = definitions.map do |definition|
|
|
84
|
+
ActiverecordCallbackLens::Resolver::MethodResolver.expand(definition, model_class)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
88
|
+
ActiverecordCallbackLens::Renderer::HtmlRenderer.render(graph, definitions: definitions)
|
|
89
|
+
end
|
|
90
|
+
|
|
49
91
|
# Parses the EXPAND environment variable using the strict truthy rule: only the
|
|
50
92
|
# exact string "true" (case-insensitive, surrounding whitespace stripped)
|
|
51
93
|
# enables expansion. Any other value ("1", "yes", "", nil) leaves it off.
|
|
@@ -9,7 +9,10 @@ require "activerecord_callback_lens/parser/condition_parser"
|
|
|
9
9
|
require "activerecord_callback_lens/resolver/method_resolver"
|
|
10
10
|
require "activerecord_callback_lens/graph/nodes"
|
|
11
11
|
require "activerecord_callback_lens/graph/graph_builder"
|
|
12
|
+
require "activerecord_callback_lens/execution_order_analyzer"
|
|
12
13
|
require "activerecord_callback_lens/renderer/mermaid_renderer"
|
|
14
|
+
require "activerecord_callback_lens/renderer/graphviz_renderer"
|
|
15
|
+
require "activerecord_callback_lens/renderer/html_renderer"
|
|
13
16
|
require "activerecord_callback_lens/cli/cli"
|
|
14
17
|
require "activerecord_callback_lens/railtie" if defined?(Rails::Railtie)
|
|
15
18
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eraxel.Dev
|
|
@@ -75,12 +75,15 @@ files:
|
|
|
75
75
|
- lib/activerecord_callback_lens/cli/cli.rb
|
|
76
76
|
- lib/activerecord_callback_lens/collector/callback_collector.rb
|
|
77
77
|
- lib/activerecord_callback_lens/collector/callback_definition.rb
|
|
78
|
+
- lib/activerecord_callback_lens/execution_order_analyzer.rb
|
|
78
79
|
- lib/activerecord_callback_lens/graph/graph_builder.rb
|
|
79
80
|
- lib/activerecord_callback_lens/graph/nodes.rb
|
|
80
81
|
- lib/activerecord_callback_lens/parser/ast_walker.rb
|
|
81
82
|
- lib/activerecord_callback_lens/parser/condition_parser.rb
|
|
82
83
|
- lib/activerecord_callback_lens/parser/condition_tree.rb
|
|
83
84
|
- lib/activerecord_callback_lens/railtie.rb
|
|
85
|
+
- lib/activerecord_callback_lens/renderer/graphviz_renderer.rb
|
|
86
|
+
- lib/activerecord_callback_lens/renderer/html_renderer.rb
|
|
84
87
|
- lib/activerecord_callback_lens/renderer/mermaid_renderer.rb
|
|
85
88
|
- lib/activerecord_callback_lens/resolver/method_resolver.rb
|
|
86
89
|
- lib/activerecord_callback_lens/tasks/callback_lens.rake
|