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 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