activerecord_callback_lens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +110 -0
- data/activerecord_callback_lens.gemspec +40 -0
- data/exe/callback_lens +6 -0
- data/lib/activerecord_callback_lens/cli/cli.rb +64 -0
- data/lib/activerecord_callback_lens/collector/callback_collector.rb +96 -0
- data/lib/activerecord_callback_lens/collector/callback_definition.rb +21 -0
- data/lib/activerecord_callback_lens/graph/graph_builder.rb +115 -0
- data/lib/activerecord_callback_lens/graph/nodes.rb +38 -0
- data/lib/activerecord_callback_lens/parser/condition_parser.rb +173 -0
- data/lib/activerecord_callback_lens/parser/condition_tree.rb +25 -0
- data/lib/activerecord_callback_lens/railtie.rb +15 -0
- data/lib/activerecord_callback_lens/renderer/mermaid_renderer.rb +77 -0
- data/lib/activerecord_callback_lens/tasks/callback_lens.rake +17 -0
- data/lib/activerecord_callback_lens/tasks/callback_lens_helpers.rb +35 -0
- data/lib/activerecord_callback_lens/version.rb +5 -0
- data/lib/activerecord_callback_lens.rb +16 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a539b633bd3793bd684d7a93236783afbdb13b946400fd968c204281c3435e7f
|
|
4
|
+
data.tar.gz: 806014fbee749570c24bac4df333d85f96157d16e42ba5b880380682c729102d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 44ab27417d490c5ab1e39b8d3f900f92aa94ece3334be91dd65a04113b77ad2c93a1b95a09b5ec12e90344bd9911f7239e2b9f6864c11cd1f673d4cd23ec792a
|
|
7
|
+
data.tar.gz: 384e76570141682a41e7fd5dd2402580999a52e24c8196271739017b83452d4ec5ccd94fcf1918ef8a04f1a8842ea1c49ed16a68653b665796d427fdff29be53
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eraxel.Dev
|
|
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,110 @@
|
|
|
1
|
+
# activerecord_callback_lens
|
|
2
|
+
|
|
3
|
+
**X-ray your ActiveRecord callbacks.**
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Ruby >= 3.2
|
|
10
|
+
- ActiveRecord >= 7.0, < 9.0
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add to your `Gemfile`:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "activerecord_callback_lens"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install directly:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
gem install activerecord_callback_lens
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### CLI
|
|
29
|
+
|
|
30
|
+
Point the `callback_lens` binary at any loaded ActiveRecord model:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
callback_lens analyze User
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This prints a Mermaid `graph TD` diagram to stdout showing every callback and its condition dependencies.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
graph TD
|
|
40
|
+
n0["before_save"]
|
|
41
|
+
n1["AndNode"]
|
|
42
|
+
n2["saved_change_to_title?"]
|
|
43
|
+
n3["status_completed?"]
|
|
44
|
+
n2 --> n1
|
|
45
|
+
n3 --> n1
|
|
46
|
+
n1 --> n0
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> **Note:** The model class must be loaded before analysis. In a Rails app, run the binary inside `rails runner` or require your environment first.
|
|
50
|
+
|
|
51
|
+
### Rake task (Rails)
|
|
52
|
+
|
|
53
|
+
The gem ships a Railtie that automatically loads the `callback_lens` namespace into your Rails rake tasks:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
rake callback_lens:analyze MODEL=User
|
|
57
|
+
rake callback_lens:mermaid MODEL=User # alias
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Programmatic API
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
require "activerecord_callback_lens"
|
|
64
|
+
|
|
65
|
+
# 1. Collect raw callback definitions
|
|
66
|
+
definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(User)
|
|
67
|
+
|
|
68
|
+
# 2. Parse if/unless conditions into ConditionTrees
|
|
69
|
+
definitions = definitions.map { |d| ActiverecordCallbackLens::Parser::ConditionParser.parse(d) }
|
|
70
|
+
|
|
71
|
+
# 3. Build a dependency graph
|
|
72
|
+
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
73
|
+
|
|
74
|
+
# 4. Render as Mermaid
|
|
75
|
+
puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## How it works
|
|
79
|
+
|
|
80
|
+
| Layer | Class | Responsibility |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| Collector | `CallbackCollector` | Reads ActiveRecord's internal `_save_callbacks`, `_create_callbacks`, `_update_callbacks`, `_destroy_callbacks`, and `_validation_callbacks` chains |
|
|
83
|
+
| 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 |
|
|
84
|
+
| Graph | `GraphBuilder` | Assembles a DAG of `CallbackNode`, `ConditionNode`, `PredicateNode`, and `MethodNode` values |
|
|
85
|
+
| Renderer | `MermaidRenderer` | Serializes the graph to a Mermaid `graph TD` string |
|
|
86
|
+
|
|
87
|
+
## Condition tree nodes
|
|
88
|
+
|
|
89
|
+
| Node | Meaning |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `AndNode` | All children must be true |
|
|
92
|
+
| `OrNode` | At least one child must be true |
|
|
93
|
+
| `NotNode` | Negates its child (from `unless:`) |
|
|
94
|
+
| `PredicateNode` | A leaf predicate call (e.g. `saved_change_to_title?`) |
|
|
95
|
+
| `MethodRefNode` | A Symbol condition (e.g. `:sync_required?`) — expanded in v0.2 |
|
|
96
|
+
|
|
97
|
+
## Roadmap
|
|
98
|
+
|
|
99
|
+
| Version | Feature |
|
|
100
|
+
|---|---|
|
|
101
|
+
| **v0.1** | CallbackCollector, Prism condition parser, Mermaid renderer, CLI, Rake task |
|
|
102
|
+
| v0.2 | MethodResolver — recursive expansion of Symbol conditions |
|
|
103
|
+
| v0.3 | Graphviz / DOT renderer |
|
|
104
|
+
| v0.4 | HTML report (callback list, execution flow, embedded diagram) |
|
|
105
|
+
| v1.0 | Runtime tracer via `ActiveSupport::Notifications` |
|
|
106
|
+
| v2.0 | RBS analysis, cross-model dependency graph |
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/activerecord_callback_lens/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |s|
|
|
6
|
+
s.name = "activerecord_callback_lens"
|
|
7
|
+
s.version = ActiverecordCallbackLens::VERSION
|
|
8
|
+
s.summary = "X-ray your ActiveRecord callbacks"
|
|
9
|
+
s.description = "Inspect, parse, and visualize the callbacks registered on your " \
|
|
10
|
+
"ActiveRecord models — including their if/unless conditions — as " \
|
|
11
|
+
"graphs and diagrams."
|
|
12
|
+
s.authors = ["Eraxel.Dev"]
|
|
13
|
+
s.email = ["masacode.vancouver@gmail.com"]
|
|
14
|
+
s.homepage = "https://github.com/eraxel/activerecord_callback_lens"
|
|
15
|
+
s.license = "MIT"
|
|
16
|
+
|
|
17
|
+
s.required_ruby_version = ">= 3.2"
|
|
18
|
+
|
|
19
|
+
s.bindir = "exe"
|
|
20
|
+
s.executables = ["callback_lens"]
|
|
21
|
+
s.require_paths = ["lib"]
|
|
22
|
+
s.files = Dir[
|
|
23
|
+
"lib/**/*.rb",
|
|
24
|
+
"lib/**/*.rake",
|
|
25
|
+
"exe/*",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md",
|
|
28
|
+
"activerecord_callback_lens.gemspec"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
s.metadata = {
|
|
32
|
+
"homepage_uri" => s.homepage,
|
|
33
|
+
"source_code_uri" => s.homepage,
|
|
34
|
+
"rubygems_mfa_required" => "true"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
s.add_dependency "activerecord", ">= 7.0", "< 9.0"
|
|
38
|
+
s.add_dependency "prism", "~> 1.0"
|
|
39
|
+
s.add_dependency "thor", "~> 1.0"
|
|
40
|
+
end
|
data/exe/callback_lens
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
require_relative "../collector/callback_collector"
|
|
6
|
+
require_relative "../parser/condition_parser"
|
|
7
|
+
require_relative "../graph/graph_builder"
|
|
8
|
+
require_relative "../renderer/mermaid_renderer"
|
|
9
|
+
|
|
10
|
+
module ActiverecordCallbackLens
|
|
11
|
+
module CLI
|
|
12
|
+
# Thor application exposing the callback_lens command-line interface.
|
|
13
|
+
#
|
|
14
|
+
# For v0.1 it provides a single command, +analyze+, which runs the full
|
|
15
|
+
# pipeline (collect -> parse -> build graph -> render) and prints a Mermaid
|
|
16
|
+
# diagram to stdout. An unknown model name is reported with a friendly
|
|
17
|
+
# message and a non-zero exit status rather than a Ruby backtrace.
|
|
18
|
+
class App < Thor
|
|
19
|
+
# Tells Thor to exit with a non-zero status when a command raises, so the
|
|
20
|
+
# +exit 1+ paths below propagate a failure code to the shell.
|
|
21
|
+
#
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def self.exit_on_failure?
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "analyze MODEL", "Analyze callbacks for a model class and print a Mermaid diagram"
|
|
28
|
+
option :mermaid, type: :boolean, default: true, desc: "Output a Mermaid diagram to stdout"
|
|
29
|
+
# Runs the analysis pipeline for +model_name+ and prints the result.
|
|
30
|
+
#
|
|
31
|
+
# @param model_name [String] the ActiveRecord model class name
|
|
32
|
+
# @return [void]
|
|
33
|
+
def analyze(model_name)
|
|
34
|
+
model_class = resolve_model(model_name)
|
|
35
|
+
graph = build_graph(model_class)
|
|
36
|
+
puts Renderer::MermaidRenderer.render(graph) if options[:mermaid]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Resolves a model class by name, printing a friendly error and exiting
|
|
42
|
+
# non-zero when the constant cannot be found.
|
|
43
|
+
#
|
|
44
|
+
# @param model_name [String]
|
|
45
|
+
# @return [Class]
|
|
46
|
+
def resolve_model(model_name)
|
|
47
|
+
Object.const_get(model_name)
|
|
48
|
+
rescue NameError
|
|
49
|
+
warn "Error: cannot find model class '#{model_name}'. Make sure it is loaded."
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Collect -> Parse -> Build the dependency graph for a model class.
|
|
54
|
+
#
|
|
55
|
+
# @param model_class [Class]
|
|
56
|
+
# @return [Graph::Graph]
|
|
57
|
+
def build_graph(model_class)
|
|
58
|
+
definitions = Collector::CallbackCollector.collect(model_class)
|
|
59
|
+
definitions = definitions.map { |definition| Parser::ConditionParser.parse(definition) }
|
|
60
|
+
Graph::GraphBuilder.build(definitions)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "callback_definition"
|
|
4
|
+
|
|
5
|
+
module ActiverecordCallbackLens
|
|
6
|
+
module Collector
|
|
7
|
+
# Reads ActiveRecord's internal callback chains for a model class and returns
|
|
8
|
+
# an array of CallbackDefinition structs with raw condition data, ready for
|
|
9
|
+
# the Parser layer to populate +condition_tree+.
|
|
10
|
+
#
|
|
11
|
+
# Each of the five callback events (save, create, update, destroy, validation)
|
|
12
|
+
# is accessed through ActiveRecord's private chain accessor and every
|
|
13
|
+
# ActiveSupport::Callbacks::Callback in the chain is converted into one
|
|
14
|
+
# CallbackDefinition.
|
|
15
|
+
class CallbackCollector
|
|
16
|
+
# The callback events enumerated, in a stable order.
|
|
17
|
+
EVENTS = %i[save create update destroy validation].freeze
|
|
18
|
+
|
|
19
|
+
# Maps each event to ActiveRecord's internal callback chain accessor.
|
|
20
|
+
CHAIN_METHODS = {
|
|
21
|
+
save: :_save_callbacks,
|
|
22
|
+
create: :_create_callbacks,
|
|
23
|
+
update: :_update_callbacks,
|
|
24
|
+
destroy: :_destroy_callbacks,
|
|
25
|
+
validation: :_validation_callbacks
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Collect all callbacks registered on a model class.
|
|
29
|
+
#
|
|
30
|
+
# @param model_class [Class] an ActiveRecord::Base subclass
|
|
31
|
+
# @return [Array<CallbackDefinition>]
|
|
32
|
+
def self.collect(model_class)
|
|
33
|
+
new(model_class).collect
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param model_class [Class] an ActiveRecord::Base subclass
|
|
37
|
+
def initialize(model_class)
|
|
38
|
+
@model_class = model_class
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array<CallbackDefinition>]
|
|
42
|
+
def collect
|
|
43
|
+
EVENTS.flat_map { |event| collect_event(event) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# @param event [Symbol]
|
|
49
|
+
# @return [Array<CallbackDefinition>]
|
|
50
|
+
def collect_event(event)
|
|
51
|
+
chain = @model_class.send(CHAIN_METHODS[event])
|
|
52
|
+
chain.map { |callback| build_definition(callback, event) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param callback [ActiveSupport::Callbacks::Callback]
|
|
56
|
+
# @param event [Symbol]
|
|
57
|
+
# @return [CallbackDefinition]
|
|
58
|
+
def build_definition(callback, event)
|
|
59
|
+
filter = callback.filter
|
|
60
|
+
CallbackDefinition.new(
|
|
61
|
+
model: @model_class,
|
|
62
|
+
event: event,
|
|
63
|
+
phase: callback.kind,
|
|
64
|
+
filter: filter,
|
|
65
|
+
raw_conditions: extract_conditions(callback),
|
|
66
|
+
condition_tree: nil,
|
|
67
|
+
source_location: resolve_location(filter)
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Extracts the raw if/unless condition arrays from a callback's internals.
|
|
72
|
+
#
|
|
73
|
+
# @param callback [ActiveSupport::Callbacks::Callback]
|
|
74
|
+
# @return [Hash{Symbol => Array}]
|
|
75
|
+
def extract_conditions(callback)
|
|
76
|
+
{
|
|
77
|
+
if: Array(callback.instance_variable_get(:@if)),
|
|
78
|
+
unless: Array(callback.instance_variable_get(:@unless))
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Resolves the source location for a filter when possible.
|
|
83
|
+
#
|
|
84
|
+
# Proc/Lambda filters expose +source_location+; Symbol filters are resolved
|
|
85
|
+
# later by the MethodResolver (v0.2), and String filters have no location.
|
|
86
|
+
#
|
|
87
|
+
# @param filter [Symbol, Proc, String]
|
|
88
|
+
# @return [Array(String, Integer), nil]
|
|
89
|
+
def resolve_location(filter)
|
|
90
|
+
case filter
|
|
91
|
+
when Proc then filter.source_location
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiverecordCallbackLens
|
|
4
|
+
module Collector
|
|
5
|
+
# Represents a single callback registered on an ActiveRecord model.
|
|
6
|
+
#
|
|
7
|
+
# event + phase together reconstruct the full callback name
|
|
8
|
+
# (e.g. :before + :save => before_save).
|
|
9
|
+
# raw_conditions holds the unprocessed if/unless arrays from AR internals.
|
|
10
|
+
# condition_tree is nil until the Parser populates it (nil when no conditions).
|
|
11
|
+
CallbackDefinition = Data.define(
|
|
12
|
+
:model, # Class — the ActiveRecord model class
|
|
13
|
+
:event, # Symbol — :save, :create, :update, :destroy, :validation
|
|
14
|
+
:phase, # Symbol — :before, :after, :around
|
|
15
|
+
:filter, # Symbol | Proc | String — the callback body
|
|
16
|
+
:raw_conditions, # { if: [...], unless: [...] } — raw arrays from AR internals
|
|
17
|
+
:condition_tree, # ConditionTree::Node | nil — parsed logical tree
|
|
18
|
+
:source_location # [String, Integer] | nil — file and line of the filter
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nodes"
|
|
4
|
+
require_relative "../parser/condition_tree"
|
|
5
|
+
|
|
6
|
+
module ActiverecordCallbackLens
|
|
7
|
+
module Graph
|
|
8
|
+
# Converts an array of parsed CallbackDefinition objects into a Graph.
|
|
9
|
+
#
|
|
10
|
+
# Each definition becomes a CallbackNode. When a definition has a
|
|
11
|
+
# condition_tree, the tree is walked recursively and mapped onto graph nodes:
|
|
12
|
+
#
|
|
13
|
+
# AndNode / OrNode -> ConditionNode, then recurse into children
|
|
14
|
+
# NotNode -> no node; recurse into the child (negation is dropped
|
|
15
|
+
# for the v0.1 dependency view)
|
|
16
|
+
# PredicateNode -> PredicateNode graph node
|
|
17
|
+
# MethodRefNode -> MethodNode; recurse into expanded_tree when present
|
|
18
|
+
#
|
|
19
|
+
# Every condition/predicate/method node gets a +:requires+ edge pointing at
|
|
20
|
+
# its parent (the callback or enclosing combinator), so edges flow from a
|
|
21
|
+
# dependency up to the thing that depends on it.
|
|
22
|
+
class GraphBuilder
|
|
23
|
+
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
24
|
+
# @return [Graph::Graph]
|
|
25
|
+
def self.build(definitions)
|
|
26
|
+
new(definitions).build
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param definitions [Array<Collector::CallbackDefinition>]
|
|
30
|
+
def initialize(definitions)
|
|
31
|
+
@definitions = definitions
|
|
32
|
+
@nodes = []
|
|
33
|
+
@edges = []
|
|
34
|
+
@counter = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Graph::Graph]
|
|
38
|
+
def build
|
|
39
|
+
@definitions.each { |definition| add_callback(definition) }
|
|
40
|
+
Graph.new(nodes: @nodes, edges: @edges)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Generates a stable, monotonically increasing node id ("n0", "n1", ...).
|
|
46
|
+
#
|
|
47
|
+
# @return [String]
|
|
48
|
+
def next_id
|
|
49
|
+
id = "n#{@counter}"
|
|
50
|
+
@counter += 1
|
|
51
|
+
id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param definition [Collector::CallbackDefinition]
|
|
55
|
+
# @return [void]
|
|
56
|
+
def add_callback(definition)
|
|
57
|
+
callback_node = CallbackNode.new(id: next_id, definition: definition)
|
|
58
|
+
@nodes << callback_node
|
|
59
|
+
return unless definition.condition_tree
|
|
60
|
+
|
|
61
|
+
add_tree(definition.condition_tree, parent_id: callback_node.id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Recursively maps a ConditionTree node onto graph nodes and edges.
|
|
65
|
+
#
|
|
66
|
+
# @param tree_node [Parser::ConditionTree::Node, nil]
|
|
67
|
+
# @param parent_id [String] id of the node this subtree depends on
|
|
68
|
+
# @return [void]
|
|
69
|
+
def add_tree(tree_node, parent_id:)
|
|
70
|
+
case tree_node
|
|
71
|
+
when Parser::ConditionTree::AndNode, Parser::ConditionTree::OrNode
|
|
72
|
+
add_combinator(tree_node, parent_id: parent_id)
|
|
73
|
+
when Parser::ConditionTree::NotNode
|
|
74
|
+
add_tree(tree_node.child, parent_id: parent_id)
|
|
75
|
+
when Parser::ConditionTree::PredicateNode
|
|
76
|
+
add_leaf(PredicateNode.new(id: next_id, predicate_name: tree_node.name), parent_id: parent_id)
|
|
77
|
+
when Parser::ConditionTree::MethodRefNode
|
|
78
|
+
add_method_ref(tree_node, parent_id: parent_id)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param tree_node [Parser::ConditionTree::AndNode, Parser::ConditionTree::OrNode]
|
|
83
|
+
# @param parent_id [String]
|
|
84
|
+
# @return [void]
|
|
85
|
+
def add_combinator(tree_node, parent_id:)
|
|
86
|
+
condition_node = ConditionNode.new(id: next_id, tree_node: tree_node)
|
|
87
|
+
@nodes << condition_node
|
|
88
|
+
@edges << Edge.new(from_id: condition_node.id, to_id: parent_id, label: :requires)
|
|
89
|
+
tree_node.children.each { |child| add_tree(child, parent_id: condition_node.id) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param tree_node [Parser::ConditionTree::MethodRefNode]
|
|
93
|
+
# @param parent_id [String]
|
|
94
|
+
# @return [void]
|
|
95
|
+
def add_method_ref(tree_node, parent_id:)
|
|
96
|
+
method_node = MethodNode.new(id: next_id, method_name: tree_node.name)
|
|
97
|
+
@nodes << method_node
|
|
98
|
+
@edges << Edge.new(from_id: method_node.id, to_id: parent_id, label: :requires)
|
|
99
|
+
return unless tree_node.expanded_tree
|
|
100
|
+
|
|
101
|
+
add_tree(tree_node.expanded_tree, parent_id: method_node.id)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Appends a leaf node and its edge to the enclosing parent.
|
|
105
|
+
#
|
|
106
|
+
# @param node [Graph::PredicateNode]
|
|
107
|
+
# @param parent_id [String]
|
|
108
|
+
# @return [void]
|
|
109
|
+
def add_leaf(node, parent_id:)
|
|
110
|
+
@nodes << node
|
|
111
|
+
@edges << Edge.new(from_id: node.id, to_id: parent_id, label: :requires)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiverecordCallbackLens
|
|
4
|
+
module Graph
|
|
5
|
+
# The graph layer turns a list of CallbackDefinition objects (with their
|
|
6
|
+
# parsed ConditionTrees) into a directed acyclic graph of typed nodes and
|
|
7
|
+
# labelled edges, ready for a renderer to emit.
|
|
8
|
+
#
|
|
9
|
+
# Every node carries a unique +id+ (assigned by the GraphBuilder) plus the
|
|
10
|
+
# domain object it represents. Edges connect a child node to its parent via
|
|
11
|
+
# +from_id+ / +to_id+ and carry a +label+ describing the relationship
|
|
12
|
+
# (+:requires+ for condition/predicate/method dependencies).
|
|
13
|
+
#
|
|
14
|
+
# All node and edge types are immutable Data values.
|
|
15
|
+
|
|
16
|
+
# One node per CallbackDefinition, e.g. before_save / after_commit.
|
|
17
|
+
# definition: Collector::CallbackDefinition
|
|
18
|
+
CallbackNode = Data.define(:id, :definition)
|
|
19
|
+
|
|
20
|
+
# One node per logical combinator (AndNode / OrNode) in a ConditionTree.
|
|
21
|
+
# tree_node: ConditionTree::AndNode | ConditionTree::OrNode
|
|
22
|
+
ConditionNode = Data.define(:id, :tree_node)
|
|
23
|
+
|
|
24
|
+
# One node per Symbol condition (e.g. :sync_required?). method_name: String
|
|
25
|
+
MethodNode = Data.define(:id, :method_name)
|
|
26
|
+
|
|
27
|
+
# One node per predicate method call (e.g. saved_change_to_title?).
|
|
28
|
+
# predicate_name: String
|
|
29
|
+
PredicateNode = Data.define(:id, :predicate_name)
|
|
30
|
+
|
|
31
|
+
# A directed edge from a child node to its parent node. label: Symbol
|
|
32
|
+
Edge = Data.define(:from_id, :to_id, :label)
|
|
33
|
+
|
|
34
|
+
# The assembled graph: a flat list of nodes and a flat list of edges.
|
|
35
|
+
# nodes: Array<node>, edges: Array<Edge>
|
|
36
|
+
Graph = Data.define(:nodes, :edges)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "condition_tree"
|
|
6
|
+
|
|
7
|
+
module ActiverecordCallbackLens
|
|
8
|
+
module Parser
|
|
9
|
+
# Turns a CallbackDefinition's raw if/unless conditions into a structured
|
|
10
|
+
# ConditionTree.
|
|
11
|
+
#
|
|
12
|
+
# Proc/Lambda conditions are parsed by reading their source file with Prism,
|
|
13
|
+
# isolating the enclosing lambda/block node by line number, and recursively
|
|
14
|
+
# mapping the boolean AST (`&&`, `||`, `!`, predicate calls) onto ConditionTree
|
|
15
|
+
# nodes. Symbol conditions become MethodRefNodes whose expansion is deferred to
|
|
16
|
+
# the MethodResolver. Any other entry kind (e.g. ActiveModel's framework
|
|
17
|
+
# injected Conditionals::Value guard on after_* callbacks) is ignored.
|
|
18
|
+
#
|
|
19
|
+
# Parsing never raises on missing or unreadable source: a nil source_location
|
|
20
|
+
# or a missing file is skipped silently, and a Prism parse failure warns to
|
|
21
|
+
# $stderr while leaving the condition unresolved.
|
|
22
|
+
class ConditionParser
|
|
23
|
+
# @param definition [Collector::CallbackDefinition]
|
|
24
|
+
# @return [Collector::CallbackDefinition] a copy with condition_tree populated
|
|
25
|
+
def self.parse(definition)
|
|
26
|
+
new(definition).parse
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param definition [Collector::CallbackDefinition]
|
|
30
|
+
def initialize(definition)
|
|
31
|
+
@definition = definition
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Collector::CallbackDefinition] a copy with condition_tree set
|
|
35
|
+
def parse
|
|
36
|
+
@definition.with(condition_tree: build_tree)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Assembles if_nodes and negated unless_nodes into a single tree.
|
|
42
|
+
#
|
|
43
|
+
# @return [ConditionTree::Node, nil] nil when there are no resolvable conditions
|
|
44
|
+
def build_tree
|
|
45
|
+
conditions = @definition.raw_conditions
|
|
46
|
+
if_nodes = Array(conditions[:if]).map { |c| parse_condition(c) }
|
|
47
|
+
unless_nodes = Array(conditions[:unless]).map { |c| negate(parse_condition(c)) }
|
|
48
|
+
|
|
49
|
+
combine((if_nodes + unless_nodes).compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reduces a flat list of resolved condition nodes to a single tree.
|
|
53
|
+
#
|
|
54
|
+
# @param nodes [Array<ConditionTree::Node>]
|
|
55
|
+
# @return [ConditionTree::Node, nil]
|
|
56
|
+
def combine(nodes)
|
|
57
|
+
case nodes.size
|
|
58
|
+
when 0 then nil
|
|
59
|
+
when 1 then nodes.first
|
|
60
|
+
else ConditionTree::AndNode.new(children: nodes)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param node [ConditionTree::Node, nil]
|
|
65
|
+
# @return [ConditionTree::NotNode, nil]
|
|
66
|
+
def negate(node)
|
|
67
|
+
return nil if node.nil?
|
|
68
|
+
|
|
69
|
+
ConditionTree::NotNode.new(child: node)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Dispatches a single raw condition entry to the right handler.
|
|
73
|
+
#
|
|
74
|
+
# @param condition [Symbol, Proc, Object]
|
|
75
|
+
# @return [ConditionTree::Node, nil]
|
|
76
|
+
def parse_condition(condition)
|
|
77
|
+
case condition
|
|
78
|
+
when Symbol then ConditionTree::MethodRefNode.new(name: condition.to_s, expanded_tree: nil)
|
|
79
|
+
when Proc then parse_proc(condition)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parses a Proc/Lambda condition by reading and walking its source.
|
|
84
|
+
#
|
|
85
|
+
# @param proc_obj [Proc]
|
|
86
|
+
# @return [ConditionTree::Node, nil]
|
|
87
|
+
def parse_proc(proc_obj)
|
|
88
|
+
file, line = proc_obj.source_location
|
|
89
|
+
return nil unless file && File.exist?(file)
|
|
90
|
+
|
|
91
|
+
result = Prism.parse_file(file)
|
|
92
|
+
unless result.success?
|
|
93
|
+
warn("[ActiverecordCallbackLens] Failed to parse #{file}: #{result.errors.map(&:message).join(', ')}")
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
locator = LambdaLocator.new(target_line: line)
|
|
98
|
+
locator.visit(result.value)
|
|
99
|
+
walk(locator.node)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Recursively maps a Prism boolean AST onto ConditionTree nodes.
|
|
103
|
+
#
|
|
104
|
+
# @param node [Prism::Node, nil]
|
|
105
|
+
# @return [ConditionTree::Node, nil]
|
|
106
|
+
def walk(node)
|
|
107
|
+
case node
|
|
108
|
+
in Prism::StatementsNode then walk(node.body.last)
|
|
109
|
+
in Prism::ParenthesesNode then walk(node.body)
|
|
110
|
+
in Prism::AndNode then combinator(ConditionTree::AndNode, node)
|
|
111
|
+
in Prism::OrNode then combinator(ConditionTree::OrNode, node)
|
|
112
|
+
in Prism::CallNode if node.name == :! then negate(walk(node.receiver))
|
|
113
|
+
in Prism::CallNode then ConditionTree::PredicateNode.new(name: node.name.to_s)
|
|
114
|
+
else nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Builds an And/Or combinator from a binary Prism node, walking both sides
|
|
119
|
+
# and dropping any unresolved (nil) operand.
|
|
120
|
+
#
|
|
121
|
+
# @param klass [Class] ConditionTree::AndNode or ConditionTree::OrNode
|
|
122
|
+
# @param node [Prism::AndNode, Prism::OrNode]
|
|
123
|
+
# @return [ConditionTree::Node]
|
|
124
|
+
def combinator(klass, node)
|
|
125
|
+
klass.new(children: [walk(node.left), walk(node.right)].compact)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A Prism visitor that captures the innermost LambdaNode or BlockNode whose
|
|
129
|
+
# source range encloses a target line. Used to isolate a single proc's body
|
|
130
|
+
# from the surrounding file AST.
|
|
131
|
+
class LambdaLocator < Prism::Visitor
|
|
132
|
+
# @return [Prism::Node, nil] the body of the innermost matching lambda/block
|
|
133
|
+
attr_reader :node
|
|
134
|
+
|
|
135
|
+
# @param target_line [Integer] the line the proc's source_location reports
|
|
136
|
+
def initialize(target_line:)
|
|
137
|
+
@target_line = target_line
|
|
138
|
+
@node = nil
|
|
139
|
+
super()
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param lambda_node [Prism::LambdaNode]
|
|
143
|
+
# @return [void]
|
|
144
|
+
def visit_lambda_node(lambda_node)
|
|
145
|
+
capture(lambda_node)
|
|
146
|
+
super
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @param block_node [Prism::BlockNode]
|
|
150
|
+
# @return [void]
|
|
151
|
+
def visit_block_node(block_node)
|
|
152
|
+
capture(block_node)
|
|
153
|
+
super
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# Records the candidate's body when it encloses the target line. Because
|
|
159
|
+
# the visitor descends depth-first, the last (innermost) enclosing match
|
|
160
|
+
# wins, isolating nested blocks correctly.
|
|
161
|
+
#
|
|
162
|
+
# @param candidate [Prism::LambdaNode, Prism::BlockNode]
|
|
163
|
+
# @return [void]
|
|
164
|
+
def capture(candidate)
|
|
165
|
+
location = candidate.location
|
|
166
|
+
return unless @target_line.between?(location.start_line, location.end_line)
|
|
167
|
+
|
|
168
|
+
@node = candidate.body
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiverecordCallbackLens
|
|
4
|
+
module Parser
|
|
5
|
+
# The ConditionTree is the parsed, logical representation of a callback's
|
|
6
|
+
# if/unless conditions. Every node is an immutable Data type.
|
|
7
|
+
module ConditionTree
|
|
8
|
+
# Base node carrying an arbitrary set of children. Retained for
|
|
9
|
+
# forward-compatibility with generic tree traversals.
|
|
10
|
+
Node = Data.define(:children)
|
|
11
|
+
|
|
12
|
+
# Logical combinators.
|
|
13
|
+
AndNode = Data.define(:children) # children: Array<Node>
|
|
14
|
+
OrNode = Data.define(:children) # children: Array<Node>
|
|
15
|
+
NotNode = Data.define(:child) # child: Node
|
|
16
|
+
|
|
17
|
+
# Leaf: a single predicate method call, e.g. "saved_change_to_title?".
|
|
18
|
+
PredicateNode = Data.define(:name) # name: String
|
|
19
|
+
|
|
20
|
+
# Leaf: a symbol condition such as :sync_required?.
|
|
21
|
+
# expanded_tree is the result of MethodResolver, nil in v0.1 / when unresolved.
|
|
22
|
+
MethodRefNode = Data.define(:name, :expanded_tree) # name: String, expanded_tree: Node | nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiverecordCallbackLens
|
|
4
|
+
# Rails integration hook. When the gem is loaded inside a Rails application,
|
|
5
|
+
# this Railtie registers the gem's Rake tasks so that
|
|
6
|
+
# +rake callback_lens:analyze MODEL=User+ works without any manual +require+.
|
|
7
|
+
#
|
|
8
|
+
# The file is only loaded when +Rails::Railtie+ is defined (guarded by the
|
|
9
|
+
# top-level entry point), so it is a no-op outside of Rails.
|
|
10
|
+
class Railtie < Rails::Railtie
|
|
11
|
+
rake_tasks do
|
|
12
|
+
load File.expand_path("tasks/callback_lens.rake", __dir__)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
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 Mermaid `graph TD` (top-down) diagram string.
|
|
8
|
+
#
|
|
9
|
+
# The output is a header line followed by one declaration per node and one
|
|
10
|
+
# arrow per edge:
|
|
11
|
+
#
|
|
12
|
+
# graph TD
|
|
13
|
+
# n0["before_save"]
|
|
14
|
+
# n1["active?"]
|
|
15
|
+
# n1 --> n0
|
|
16
|
+
#
|
|
17
|
+
# Node labels are derived from the node type (see #node_label) and are
|
|
18
|
+
# escaped so that quotes in a predicate or method name cannot break the
|
|
19
|
+
# surrounding Mermaid label syntax.
|
|
20
|
+
class MermaidRenderer
|
|
21
|
+
# @param graph [Graph::Graph]
|
|
22
|
+
# @return [String]
|
|
23
|
+
def self.render(graph)
|
|
24
|
+
new(graph).render
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param graph [Graph::Graph]
|
|
28
|
+
def initialize(graph)
|
|
29
|
+
@graph = graph
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String]
|
|
33
|
+
def render
|
|
34
|
+
lines = ["graph TD"]
|
|
35
|
+
lines.concat(node_declarations)
|
|
36
|
+
lines.concat(edge_declarations)
|
|
37
|
+
lines.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @return [Array<String>]
|
|
43
|
+
def node_declarations
|
|
44
|
+
@graph.nodes.map { |node| " #{node.id}[\"#{escape(node_label(node))}\"]" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<String>]
|
|
48
|
+
def edge_declarations
|
|
49
|
+
@graph.edges.map { |edge| " #{edge.from_id} --> #{edge.to_id}" }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Derives the human-readable label for a graph node from its type.
|
|
53
|
+
#
|
|
54
|
+
# @param node [Graph::CallbackNode, Graph::PredicateNode, Graph::MethodNode, Graph::ConditionNode]
|
|
55
|
+
# @return [String]
|
|
56
|
+
def node_label(node)
|
|
57
|
+
case node
|
|
58
|
+
when Graph::CallbackNode then "#{node.definition.phase}_#{node.definition.event}"
|
|
59
|
+
when Graph::PredicateNode then node.predicate_name
|
|
60
|
+
when Graph::MethodNode then node.method_name
|
|
61
|
+
when Graph::ConditionNode then node.tree_node.class.name.split("::").last
|
|
62
|
+
else node.id
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Escapes characters that would otherwise break a `["..."]` Mermaid label.
|
|
67
|
+
# Double quotes become the Mermaid HTML entity and the escape character
|
|
68
|
+
# itself is normalised so the label stays a single, valid token.
|
|
69
|
+
#
|
|
70
|
+
# @param label [String]
|
|
71
|
+
# @return [String]
|
|
72
|
+
def escape(label)
|
|
73
|
+
label.to_s.gsub("\"", """)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "callback_lens_helpers"
|
|
4
|
+
|
|
5
|
+
namespace :callback_lens do
|
|
6
|
+
desc "Print callback Mermaid diagram for MODEL (e.g. rake callback_lens:analyze MODEL=User)"
|
|
7
|
+
task analyze: :environment do
|
|
8
|
+
model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
|
|
9
|
+
puts CallbackLensRakeHelpers.render_mermaid(model_class)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "Alias for callback_lens:analyze — print Mermaid diagram for MODEL"
|
|
13
|
+
task mermaid: :environment do
|
|
14
|
+
model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
|
|
15
|
+
puts CallbackLensRakeHelpers.render_mermaid(model_class)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "activerecord_callback_lens"
|
|
4
|
+
|
|
5
|
+
# Helpers backing the callback_lens Rake tasks. Extracted into a plain Ruby
|
|
6
|
+
# module (rather than task-local methods) so the logic is namespaced and unit
|
|
7
|
+
# testable without loading Rake or invoking a task.
|
|
8
|
+
module CallbackLensRakeHelpers
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Resolves an ActiveRecord model class from its name, raising a descriptive
|
|
12
|
+
# error when MODEL is missing or the constant cannot be found.
|
|
13
|
+
#
|
|
14
|
+
# @param name [String, nil] the value of the MODEL environment variable
|
|
15
|
+
# @return [Class]
|
|
16
|
+
# @raise [RuntimeError] when name is nil/empty or the class is not loaded
|
|
17
|
+
def resolve_model!(name)
|
|
18
|
+
raise "MODEL is required. Usage: rake callback_lens:analyze MODEL=User" if name.nil? || name.empty?
|
|
19
|
+
|
|
20
|
+
Object.const_get(name)
|
|
21
|
+
rescue NameError
|
|
22
|
+
raise "Cannot find model class '#{name}'. Make sure it is loaded."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Runs the full pipeline (collect -> parse -> build -> render) for a model.
|
|
26
|
+
#
|
|
27
|
+
# @param model_class [Class]
|
|
28
|
+
# @return [String] the Mermaid diagram
|
|
29
|
+
def render_mermaid(model_class)
|
|
30
|
+
definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(model_class)
|
|
31
|
+
definitions = definitions.map { |definition| ActiverecordCallbackLens::Parser::ConditionParser.parse(definition) }
|
|
32
|
+
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
33
|
+
ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "activerecord_callback_lens/version"
|
|
4
|
+
require "activerecord_callback_lens/collector/callback_definition"
|
|
5
|
+
require "activerecord_callback_lens/collector/callback_collector"
|
|
6
|
+
require "activerecord_callback_lens/parser/condition_tree"
|
|
7
|
+
require "activerecord_callback_lens/parser/condition_parser"
|
|
8
|
+
require "activerecord_callback_lens/graph/nodes"
|
|
9
|
+
require "activerecord_callback_lens/graph/graph_builder"
|
|
10
|
+
require "activerecord_callback_lens/renderer/mermaid_renderer"
|
|
11
|
+
require "activerecord_callback_lens/cli/cli"
|
|
12
|
+
require "activerecord_callback_lens/railtie" if defined?(Rails::Railtie)
|
|
13
|
+
|
|
14
|
+
# Top-level namespace for the gem. Subsequent tasks append their requires above.
|
|
15
|
+
module ActiverecordCallbackLens
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord_callback_lens
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Eraxel.Dev
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9.0'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9.0'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: prism
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.0'
|
|
40
|
+
type: :runtime
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: thor
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.0'
|
|
54
|
+
type: :runtime
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.0'
|
|
61
|
+
description: Inspect, parse, and visualize the callbacks registered on your ActiveRecord
|
|
62
|
+
models — including their if/unless conditions — as graphs and diagrams.
|
|
63
|
+
email:
|
|
64
|
+
- masacode.vancouver@gmail.com
|
|
65
|
+
executables:
|
|
66
|
+
- callback_lens
|
|
67
|
+
extensions: []
|
|
68
|
+
extra_rdoc_files: []
|
|
69
|
+
files:
|
|
70
|
+
- LICENSE
|
|
71
|
+
- README.md
|
|
72
|
+
- activerecord_callback_lens.gemspec
|
|
73
|
+
- exe/callback_lens
|
|
74
|
+
- lib/activerecord_callback_lens.rb
|
|
75
|
+
- lib/activerecord_callback_lens/cli/cli.rb
|
|
76
|
+
- lib/activerecord_callback_lens/collector/callback_collector.rb
|
|
77
|
+
- lib/activerecord_callback_lens/collector/callback_definition.rb
|
|
78
|
+
- lib/activerecord_callback_lens/graph/graph_builder.rb
|
|
79
|
+
- lib/activerecord_callback_lens/graph/nodes.rb
|
|
80
|
+
- lib/activerecord_callback_lens/parser/condition_parser.rb
|
|
81
|
+
- lib/activerecord_callback_lens/parser/condition_tree.rb
|
|
82
|
+
- lib/activerecord_callback_lens/railtie.rb
|
|
83
|
+
- lib/activerecord_callback_lens/renderer/mermaid_renderer.rb
|
|
84
|
+
- lib/activerecord_callback_lens/tasks/callback_lens.rake
|
|
85
|
+
- lib/activerecord_callback_lens/tasks/callback_lens_helpers.rb
|
|
86
|
+
- lib/activerecord_callback_lens/version.rb
|
|
87
|
+
homepage: https://github.com/eraxel/activerecord_callback_lens
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata:
|
|
91
|
+
homepage_uri: https://github.com/eraxel/activerecord_callback_lens
|
|
92
|
+
source_code_uri: https://github.com/eraxel/activerecord_callback_lens
|
|
93
|
+
rubygems_mfa_required: 'true'
|
|
94
|
+
post_install_message:
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.2'
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 3.4.19
|
|
110
|
+
signing_key:
|
|
111
|
+
specification_version: 4
|
|
112
|
+
summary: X-ray your ActiveRecord callbacks
|
|
113
|
+
test_files: []
|