blast_radius 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/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +259 -0
- data/lib/blast_radius/active_record_extension.rb +62 -0
- data/lib/blast_radius/analyzer.rb +60 -0
- data/lib/blast_radius/configuration.rb +31 -0
- data/lib/blast_radius/dependency_tree.rb +87 -0
- data/lib/blast_radius/formatters/base.rb +28 -0
- data/lib/blast_radius/formatters/dot_formatter.rb +61 -0
- data/lib/blast_radius/formatters/html_formatter.rb +144 -0
- data/lib/blast_radius/formatters/json_formatter.rb +36 -0
- data/lib/blast_radius/formatters/mermaid_formatter.rb +49 -0
- data/lib/blast_radius/formatters/text_formatter.rb +43 -0
- data/lib/blast_radius/html/graph.js +852 -0
- data/lib/blast_radius/html/styles.css +475 -0
- data/lib/blast_radius/html/template.html.erb +112 -0
- data/lib/blast_radius/impact_calculator.rb +129 -0
- data/lib/blast_radius/node.rb +65 -0
- data/lib/blast_radius/railtie.rb +21 -0
- data/lib/blast_radius/version.rb +5 -0
- data/lib/blast_radius.rb +47 -0
- data/lib/tasks/blast_radius.rake +102 -0
- metadata +164 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 654553ebc700ee352c5d6f5bc909effe458664ad3b24f138dc9a54320336db4c
|
|
4
|
+
data.tar.gz: 54cb0c8cd25bfac54475c94465588c2029faf455f4c6e9d8ece8ea00b917f625
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c33eae8d1de31cbb141e1217a83711be7cc4577c6b6da178de494234d9849368346e9f71df1d84ecf97a6bb5ba4f456f284dda6e6372af6a2b264bd68d3593c0
|
|
7
|
+
data.tar.gz: 39729fe6274532a7f78a0476efe9f929597841730d0985122e3b3a6d5b2f3caff955dad328a4c1112f5f30818ae44bd7e01b95c47cb742956cb01386d19ae068
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## 0.1.0 - 2025-12-17
|
|
11
|
+
|
|
12
|
+
- Initial release
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
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,259 @@
|
|
|
1
|
+
# BlastRadius
|
|
2
|
+
|
|
3
|
+
A Ruby gem for visualizing the cascade deletion impact in Rails applications. BlastRadius helps you understand which records will be deleted when you delete a record, based on ActiveRecord associations with `dependent` options.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Dependency Analysis: Analyze ActiveRecord model associations and their `dependent` options
|
|
8
|
+
- Cascade Visualization: Build and visualize dependency trees showing cascade deletion paths
|
|
9
|
+
- Impact Calculation: Calculate the actual number of records that would be deleted
|
|
10
|
+
- Multiple Dependent Types: Support for `:destroy`, `:delete_all`, `:destroy_async`, `:nullify`, `:restrict_with_exception`, `:restrict_with_error`
|
|
11
|
+
- Multiple Output Formats:
|
|
12
|
+
- Text: ASCII tree for terminal/console
|
|
13
|
+
- Mermaid: Diagrams for Markdown (GitHub, GitLab)
|
|
14
|
+
- JSON: Machine-readable format for APIs
|
|
15
|
+
- DOT: Graphviz format for generating images (PNG, SVG, PDF)
|
|
16
|
+
- HTML: Self-contained interactive visualization
|
|
17
|
+
- Rails Integration: ActiveRecord DSL, Rake tasks, and console helpers
|
|
18
|
+
- Configurable: Depth limits, model exclusion patterns, and format options
|
|
19
|
+
- Safe: Dry-run mode to preview deletions without actually deleting
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add this line to your application's Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem 'blast_radius'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
And then execute:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### Basic Usage
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# Analyze a model
|
|
41
|
+
tree = BlastRadius.analyze(User)
|
|
42
|
+
|
|
43
|
+
# Format as text
|
|
44
|
+
formatter = BlastRadius::Formatters::TextFormatter.new
|
|
45
|
+
puts formatter.format(tree)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Rails Integration
|
|
49
|
+
|
|
50
|
+
#### ActiveRecord DSL
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Class method - analyze model dependencies
|
|
54
|
+
User.blast_radius # Default format (text)
|
|
55
|
+
User.blast_radius(format: :mermaid) # Mermaid format
|
|
56
|
+
User.blast_radius(format: :json) # JSON format
|
|
57
|
+
User.blast_radius(format: :dot) # DOT (Graphviz) format
|
|
58
|
+
User.blast_radius(format: :html) # HTML format
|
|
59
|
+
|
|
60
|
+
# Instance methods - analyze specific record impact
|
|
61
|
+
user = User.find(1)
|
|
62
|
+
user.blast_radius_impact # Returns hash of model => count
|
|
63
|
+
user.blast_radius_dry_run # Returns detailed impact with sample IDs
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### Rake Tasks
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Visualize all models
|
|
70
|
+
rails blast_radius:visualize
|
|
71
|
+
|
|
72
|
+
# Visualize specific model
|
|
73
|
+
rails blast_radius:visualize[User]
|
|
74
|
+
|
|
75
|
+
# Visualize with different format
|
|
76
|
+
rails blast_radius:visualize[User,mermaid]
|
|
77
|
+
FORMAT=json rails blast_radius:visualize[User]
|
|
78
|
+
|
|
79
|
+
# Calculate impact of deleting a specific record
|
|
80
|
+
rails blast_radius:impact[User,123]
|
|
81
|
+
|
|
82
|
+
# Generate HTML file
|
|
83
|
+
rails blast_radius:html[User,output.html]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Impact Calculation
|
|
87
|
+
|
|
88
|
+
Calculate the actual number of records that would be deleted:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
user = User.find(1)
|
|
92
|
+
|
|
93
|
+
# Simple count
|
|
94
|
+
impact = user.blast_radius_impact
|
|
95
|
+
# => {"Post" => 5, "Comment" => 20, "Like" => 100}
|
|
96
|
+
|
|
97
|
+
# Detailed information with sample IDs
|
|
98
|
+
detailed = user.blast_radius_dry_run
|
|
99
|
+
# => {
|
|
100
|
+
# "Post" => {
|
|
101
|
+
# count: 5,
|
|
102
|
+
# dependent_type: :destroy,
|
|
103
|
+
# sample_ids: [1, 2, 3, 4, 5]
|
|
104
|
+
# },
|
|
105
|
+
# "Comment" => {...}
|
|
106
|
+
# }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Configuration
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# config/initializers/blast_radius.rb
|
|
113
|
+
BlastRadius.configure do |config|
|
|
114
|
+
config.max_depth = 10
|
|
115
|
+
config.default_format = :text
|
|
116
|
+
config.include_nullify = false
|
|
117
|
+
config.include_restrict = false
|
|
118
|
+
config.exclude_models = [/^HABTM_/, /^ActiveStorage::/]
|
|
119
|
+
config.colorize = true
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Output Format Examples
|
|
124
|
+
|
|
125
|
+
BlastRadius supports multiple output formats for different use cases. See the [`examples/`](./examples/) directory for complete examples.
|
|
126
|
+
|
|
127
|
+
### Text Format (ASCII Tree)
|
|
128
|
+
|
|
129
|
+
Terminal-friendly output with box-drawing characters:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
User
|
|
133
|
+
├── [destroy] posts (Post)
|
|
134
|
+
│ ├── [destroy] comments (Comment)
|
|
135
|
+
│ │ └── [destroy] reactions (Reaction)
|
|
136
|
+
│ └── [delete_all] likes (Like)
|
|
137
|
+
├── [destroy] profile (Profile)
|
|
138
|
+
└── [destroy_async] notifications (Notification)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Mermaid Format
|
|
142
|
+
|
|
143
|
+
For documentation in Markdown (GitHub, GitLab):
|
|
144
|
+
|
|
145
|
+
```mermaid
|
|
146
|
+
graph TD
|
|
147
|
+
User -->|posts|destroy| node1[Post]
|
|
148
|
+
node1 -->|comments|destroy| node2[Comment]
|
|
149
|
+
node2 -->|reactions|destroy| node3[Reaction]
|
|
150
|
+
node1 -->|likes|delete_all| node4[Like]
|
|
151
|
+
User -->|profile|destroy| node5[Profile]
|
|
152
|
+
User -->|notifications|destroy_async| node6[Notification]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### JSON Format
|
|
156
|
+
|
|
157
|
+
For programmatic processing:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"model": "User",
|
|
162
|
+
"children": [
|
|
163
|
+
{
|
|
164
|
+
"model": "Post",
|
|
165
|
+
"association": "posts",
|
|
166
|
+
"type": "has_many",
|
|
167
|
+
"dependent": "destroy",
|
|
168
|
+
"children": [
|
|
169
|
+
{
|
|
170
|
+
"model": "Comment",
|
|
171
|
+
"association": "comments",
|
|
172
|
+
"type": "has_many",
|
|
173
|
+
"dependent": "destroy"
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### DOT Format (Graphviz)
|
|
182
|
+
|
|
183
|
+
Generate PNG/SVG/PDF diagrams:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
User.blast_radius(format: :dot) # => DOT format output
|
|
187
|
+
# Save and convert:
|
|
188
|
+
# dot -Tpng output.dot -o diagram.png
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### HTML Format (Interactive ER Diagram)
|
|
192
|
+
|
|
193
|
+
The HTML formatter generates a professional, interactive Entity-Relationship diagram with cascade deletion heatmap:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
# Generate interactive HTML
|
|
197
|
+
html = User.blast_radius(format: :html)
|
|
198
|
+
File.write('blast_radius.html', html)
|
|
199
|
+
|
|
200
|
+
# Open in browser to see:
|
|
201
|
+
# - Full ER diagram of all models
|
|
202
|
+
# - Click any model to see cascade deletion impact
|
|
203
|
+
# - Color-coded heatmap (red → orange → yellow by depth)
|
|
204
|
+
# - Multiple layout algorithms (Force, Tree)
|
|
205
|
+
# - Zoom, pan, and export capabilities
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Core Features:
|
|
209
|
+
- Heatmap Visualization: Click on any table to see which other tables will be affected when deleting records
|
|
210
|
+
- Depth-Based Coloring:
|
|
211
|
+
- 🔴 Red: Directly deleted (depth 1)
|
|
212
|
+
- 🟠 Orange: Indirectly deleted (depth 2)
|
|
213
|
+
- 🟡 Yellow: Further cascades (depth 3+)
|
|
214
|
+
- ⬜ Gray: Unaffected tables
|
|
215
|
+
|
|
216
|
+
Layout Algorithms:
|
|
217
|
+
- ⚡ Force: Physics-based automatic layout (default)
|
|
218
|
+
- 🌳 Tree: Hierarchical top-down view from selected model
|
|
219
|
+
|
|
220
|
+
Interaction:
|
|
221
|
+
- Mouse Wheel: Zoom in/out
|
|
222
|
+
- Drag Background: Pan across the graph
|
|
223
|
+
- Click Node: Select and see cascade impact
|
|
224
|
+
- Hover: Tooltips with model and association details
|
|
225
|
+
- Fit to Screen: Auto-zoom to show all nodes
|
|
226
|
+
|
|
227
|
+
Export:
|
|
228
|
+
- SVG: Vector format for editing
|
|
229
|
+
- PNG: Raster format for presentations
|
|
230
|
+
|
|
231
|
+
Keyboard Shortcuts:
|
|
232
|
+
- `Escape`: Reset selection
|
|
233
|
+
- `F`: Fit to screen
|
|
234
|
+
- `+/-`: Zoom in/out
|
|
235
|
+
|
|
236
|
+
Technical:
|
|
237
|
+
- No External Dependencies: Completely self-contained HTML file
|
|
238
|
+
- Dark Mode: Automatically adapts to OS preference
|
|
239
|
+
- Responsive Design: Works on desktop and mobile
|
|
240
|
+
- Console Access: `window.blastRadiusGraph` for programmatic control
|
|
241
|
+
|
|
242
|
+
See the [`examples/`](./examples/) directory for complete output samples.
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
|
247
|
+
|
|
248
|
+
## Requirements
|
|
249
|
+
|
|
250
|
+
- Ruby >= 3.0.0
|
|
251
|
+
- Rails >= 6.1 (ActiveRecord)
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
256
|
+
|
|
257
|
+
## Contributing
|
|
258
|
+
|
|
259
|
+
Bug reports and pull requests are welcome on GitHub.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlastRadius
|
|
4
|
+
module ActiveRecordExtension
|
|
5
|
+
module ClassMethods
|
|
6
|
+
# Analyze the blast radius for this model class
|
|
7
|
+
#
|
|
8
|
+
# @param options [Hash] options
|
|
9
|
+
# - :format [Symbol] output format (:text, :mermaid, :json, :dot, :html)
|
|
10
|
+
# - :max_depth [Integer] maximum depth
|
|
11
|
+
# - :include_nullify [Boolean] include nullify associations
|
|
12
|
+
# - :include_restrict [Boolean] include restrict associations
|
|
13
|
+
# @return [String] formatted dependency tree
|
|
14
|
+
def blast_radius(options = {})
|
|
15
|
+
format = options.delete(:format) || BlastRadius.configuration.default_format
|
|
16
|
+
tree = BlastRadius.analyze(self, options)
|
|
17
|
+
|
|
18
|
+
formatter = formatter_for(format)
|
|
19
|
+
formatter.format(tree)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def formatter_for(format)
|
|
25
|
+
case format
|
|
26
|
+
when :text
|
|
27
|
+
BlastRadius::Formatters::TextFormatter.new
|
|
28
|
+
when :mermaid
|
|
29
|
+
BlastRadius::Formatters::MermaidFormatter.new
|
|
30
|
+
when :json
|
|
31
|
+
BlastRadius::Formatters::JsonFormatter.new
|
|
32
|
+
when :dot
|
|
33
|
+
BlastRadius::Formatters::DotFormatter.new
|
|
34
|
+
when :html
|
|
35
|
+
BlastRadius::Formatters::HtmlFormatter.new
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Unknown format: #{format}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module InstanceMethods
|
|
43
|
+
# Calculate the blast radius impact for this specific record
|
|
44
|
+
#
|
|
45
|
+
# @param options [Hash] options (same as ImpactCalculator#calculate)
|
|
46
|
+
# @return [Hash] hash of model names to record counts
|
|
47
|
+
def blast_radius_impact(options = {})
|
|
48
|
+
calculator = BlastRadius::ImpactCalculator.new
|
|
49
|
+
calculator.calculate(self, options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Perform a dry run to see what would be deleted
|
|
53
|
+
#
|
|
54
|
+
# @param options [Hash] options (same as ImpactCalculator#dry_run)
|
|
55
|
+
# @return [Hash] detailed impact information
|
|
56
|
+
def blast_radius_dry_run(options = {})
|
|
57
|
+
calculator = BlastRadius::ImpactCalculator.new
|
|
58
|
+
calculator.dry_run(self, options)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlastRadius
|
|
4
|
+
class Analyzer
|
|
5
|
+
# Analyze model associations and extract those with dependent options
|
|
6
|
+
#
|
|
7
|
+
# @param model_class [Class] ActiveRecord model class
|
|
8
|
+
# @return [Array<Hash>] array of association information
|
|
9
|
+
# - :name [Symbol] association name
|
|
10
|
+
# - :type [Symbol] :has_many, :has_one, :belongs_to
|
|
11
|
+
# - :dependent [Symbol] :destroy, :delete_all, etc.
|
|
12
|
+
# - :class_name [String] associated model name
|
|
13
|
+
# - :through [Symbol, nil] through association name if applicable
|
|
14
|
+
def analyze(model_class)
|
|
15
|
+
return [] unless valid_model?(model_class)
|
|
16
|
+
|
|
17
|
+
associations = []
|
|
18
|
+
|
|
19
|
+
model_class.reflect_on_all_associations.each do |reflection|
|
|
20
|
+
next unless has_dependent_option?(reflection)
|
|
21
|
+
|
|
22
|
+
associations << build_association_info(reflection)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
associations
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get all models in Rails application
|
|
29
|
+
# @return [Array<Class>] array of ActiveRecord model classes
|
|
30
|
+
def all_models
|
|
31
|
+
if defined?(Rails)
|
|
32
|
+
Rails.application.eager_load!
|
|
33
|
+
ApplicationRecord.descendants
|
|
34
|
+
else
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def valid_model?(model_class)
|
|
42
|
+
model_class.is_a?(Class) && model_class < ActiveRecord::Base
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def has_dependent_option?(reflection)
|
|
46
|
+
reflection.options[:dependent].present?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_association_info(reflection)
|
|
50
|
+
{
|
|
51
|
+
name: reflection.name,
|
|
52
|
+
type: reflection.macro,
|
|
53
|
+
dependent: reflection.options[:dependent],
|
|
54
|
+
class_name: reflection.class_name,
|
|
55
|
+
through: reflection.options[:through],
|
|
56
|
+
polymorphic: reflection.options[:polymorphic] || false
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlastRadius
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :max_depth,
|
|
6
|
+
:default_format,
|
|
7
|
+
:include_nullify,
|
|
8
|
+
:include_restrict,
|
|
9
|
+
:exclude_models,
|
|
10
|
+
:enable_web_ui,
|
|
11
|
+
:colorize,
|
|
12
|
+
:colors
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@max_depth = 10
|
|
16
|
+
@default_format = :text
|
|
17
|
+
@include_nullify = false
|
|
18
|
+
@include_restrict = false
|
|
19
|
+
@exclude_models = [/^HABTM_/, /^ActiveStorage::/]
|
|
20
|
+
@enable_web_ui = false
|
|
21
|
+
@colorize = true
|
|
22
|
+
@colors = {
|
|
23
|
+
destroy: :red,
|
|
24
|
+
delete_all: :yellow,
|
|
25
|
+
destroy_async: :magenta,
|
|
26
|
+
nullify: :blue,
|
|
27
|
+
restrict: :gray
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlastRadius
|
|
4
|
+
class DependencyTree
|
|
5
|
+
def initialize(configuration = nil)
|
|
6
|
+
@configuration = configuration || BlastRadius.configuration
|
|
7
|
+
@analyzer = Analyzer.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Build dependency tree from a root model
|
|
11
|
+
#
|
|
12
|
+
# @param root_model [Class] root ActiveRecord model class
|
|
13
|
+
# @param options [Hash] options
|
|
14
|
+
# - :max_depth [Integer] maximum depth (default: from configuration)
|
|
15
|
+
# - :include_nullify [Boolean] include nullify associations (default: from configuration)
|
|
16
|
+
# - :include_restrict [Boolean] include restrict associations (default: from configuration)
|
|
17
|
+
# @return [Node] root node of dependency tree
|
|
18
|
+
def build(root_model, options = {})
|
|
19
|
+
max_depth = options[:max_depth] || @configuration.max_depth
|
|
20
|
+
include_nullify = options.fetch(:include_nullify, @configuration.include_nullify)
|
|
21
|
+
include_restrict = options.fetch(:include_restrict, @configuration.include_restrict)
|
|
22
|
+
|
|
23
|
+
root_node = Node.new(root_model, depth: 0)
|
|
24
|
+
build_tree(root_node, max_depth, include_nullify, include_restrict)
|
|
25
|
+
root_node
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_tree(node, max_depth, include_nullify, include_restrict)
|
|
31
|
+
return if node.depth >= max_depth
|
|
32
|
+
return if node.circular_reference?
|
|
33
|
+
|
|
34
|
+
associations = @analyzer.analyze(node.model_class)
|
|
35
|
+
associations = filter_associations(associations, include_nullify, include_restrict)
|
|
36
|
+
|
|
37
|
+
associations.each do |assoc|
|
|
38
|
+
next if excluded_model?(assoc[:class_name])
|
|
39
|
+
next if assoc[:polymorphic] # Skip polymorphic associations for now
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
associated_class = assoc[:class_name].constantize
|
|
43
|
+
rescue NameError
|
|
44
|
+
# Skip if class doesn't exist
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
child_node = Node.new(
|
|
49
|
+
associated_class,
|
|
50
|
+
association_name: assoc[:name],
|
|
51
|
+
dependent_type: assoc[:dependent],
|
|
52
|
+
association_type: assoc[:type],
|
|
53
|
+
through: assoc[:through],
|
|
54
|
+
polymorphic: assoc[:polymorphic],
|
|
55
|
+
parent: node,
|
|
56
|
+
depth: node.depth + 1
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
node.add_child(child_node)
|
|
60
|
+
build_tree(child_node, max_depth, include_nullify, include_restrict)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def filter_associations(associations, include_nullify, include_restrict)
|
|
65
|
+
associations.select do |assoc|
|
|
66
|
+
dependent = assoc[:dependent]
|
|
67
|
+
|
|
68
|
+
case dependent
|
|
69
|
+
when :destroy, :delete_all, :destroy_async
|
|
70
|
+
true
|
|
71
|
+
when :nullify
|
|
72
|
+
include_nullify
|
|
73
|
+
when :restrict_with_exception, :restrict_with_error
|
|
74
|
+
include_restrict
|
|
75
|
+
else
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def excluded_model?(model_name)
|
|
82
|
+
@configuration.exclude_models.any? do |pattern|
|
|
83
|
+
pattern.is_a?(Regexp) ? pattern.match?(model_name) : pattern == model_name
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlastRadius
|
|
4
|
+
module Formatters
|
|
5
|
+
class Base
|
|
6
|
+
def initialize(configuration = nil)
|
|
7
|
+
@configuration = configuration || BlastRadius.configuration
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Format the dependency tree
|
|
11
|
+
# @param root_node [Node] root node of dependency tree
|
|
12
|
+
# @return [String] formatted output
|
|
13
|
+
def format(root_node)
|
|
14
|
+
raise NotImplementedError, 'Subclasses must implement #format'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
protected
|
|
18
|
+
|
|
19
|
+
def colorize?
|
|
20
|
+
@configuration.colorize
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def color_for(dependent_type)
|
|
24
|
+
@configuration.colors[dependent_type] || :default
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module BlastRadius
|
|
6
|
+
module Formatters
|
|
7
|
+
class DotFormatter < Base
|
|
8
|
+
# Format dependency tree as Graphviz DOT format
|
|
9
|
+
# @param root_node [Node] root node of dependency tree
|
|
10
|
+
# @return [String] DOT format representation
|
|
11
|
+
def format(root_node)
|
|
12
|
+
@edges = []
|
|
13
|
+
@nodes = Set.new
|
|
14
|
+
|
|
15
|
+
# Collect all nodes and edges
|
|
16
|
+
collect_nodes_and_edges(root_node)
|
|
17
|
+
|
|
18
|
+
# Build DOT output
|
|
19
|
+
lines = [
|
|
20
|
+
'digraph blast_radius {',
|
|
21
|
+
' rankdir=LR;',
|
|
22
|
+
' node [shape=box];',
|
|
23
|
+
''
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Add nodes
|
|
27
|
+
@nodes.each do |node_name|
|
|
28
|
+
lines << %( "#{node_name}";)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
lines << ''
|
|
32
|
+
|
|
33
|
+
# Add edges
|
|
34
|
+
@edges.each do |edge|
|
|
35
|
+
lines << edge
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
lines << '}'
|
|
39
|
+
lines.join("\n")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def collect_nodes_and_edges(node)
|
|
45
|
+
@nodes.add(node.model_name)
|
|
46
|
+
|
|
47
|
+
node.children.each do |child|
|
|
48
|
+
@nodes.add(child.model_name)
|
|
49
|
+
|
|
50
|
+
# Create edge with label
|
|
51
|
+
label = "#{child.association_name}\\n#{child.dependent_type}"
|
|
52
|
+
edge = %( "#{node.model_name}" -> "#{child.model_name}" [label="#{label}"];)
|
|
53
|
+
@edges << edge
|
|
54
|
+
|
|
55
|
+
# Recursively process children
|
|
56
|
+
collect_nodes_and_edges(child) unless child.children.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|