erd_map 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +106 -0
- data/Rakefile +5 -0
- data/app/assets/stylesheets/erd_map/application.css +15 -0
- data/app/controllers/erd_map/application_controller.rb +4 -0
- data/app/controllers/erd_map/erd_map_controller.rb +29 -0
- data/app/helpers/erd_map/application_helper.rb +4 -0
- data/app/models/erd_map/application_record.rb +5 -0
- data/app/views/layouts/erd_map/application.html.erb +17 -0
- data/config/initializers/erd_map.rb +3 -0
- data/config/routes.rb +6 -0
- data/lib/erd_map/engine.rb +5 -0
- data/lib/erd_map/graph.rb +225 -0
- data/lib/erd_map/graph_manager.js +592 -0
- data/lib/erd_map/graph_renderer.rb +298 -0
- data/lib/erd_map/map_builder.rb +115 -0
- data/lib/erd_map/plot.rb +195 -0
- data/lib/erd_map/py_call_modules.rb +25 -0
- data/lib/erd_map/version.rb +3 -0
- data/lib/erd_map.rb +19 -0
- data/lib/tasks/erd_map_tasks.rake +8 -0
- metadata +108 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9b868db53c728786986ef918092b37608399f674c1e0a410fc5f3e9b67d707ad
|
4
|
+
data.tar.gz: 55b585aead0da7727b75e90651ec06dd3636d032cd39078d488169ed20f1374b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1ee3f5b8c5d70475d4a6567086c57be4c27e6a1f730f94fb314db64d965621189d43e729c364c08dfcfffde36ac5053513926572e84a295fdae4da2e0c3985ec
|
7
|
+
data.tar.gz: 3067212a98992a87fe1f55787d6a5683a603e0b80a7372bae4456ad6daf332d02d466137aa3503f28b44eebc01cb23153ff53fe1a4fe3921dc2c1a4e3381f23e
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright makicamel
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
# ErdMap
|
2
|
+
|
3
|
+
ErdMap is an ERD map viewer as a Rails engine.
|
4
|
+
|
5
|
+
Ruby on Rails applications represent their concepts through models. However, since Rails applications often contain numerous models, understanding them can be difficult. ErdMap helps you comprehend your application by visualizing key models and their associations. It provides a clear starting point for understanding the architecture of your application.
|
6
|
+
ErdMap initially displays the most "important" models. Then, You can "zoom in" to reveal the next important models interactively, much like navigating a map application.
|
7
|
+
|
8
|
+
### Try It Out
|
9
|
+
|
10
|
+
Sample visualizations below, based on open-source Rails applications. To try it yourself, open the example HTML files in the `sample` directory.
|
11
|
+
|
12
|
+
| [Redmine](https://github.com/redmine/redmine) | [Mastodon](https://github.com/mastodon/mastodon) |
|
13
|
+
| ------- | -------- |
|
14
|
+
|  |  |
|
15
|
+
|
16
|
+
## Dependencies
|
17
|
+
|
18
|
+
ErdMap requires Python3 and the following packages: [`networkx`](https://github.com/networkx/networkx), [`bokeh`](https://github.com/bokeh/bokeh), and [`scipy`](https://github.com/scipy/scipy).
|
19
|
+
For Python installation details, refer to the [pyenv installation guide](https://github.com/pyenv/pyenv#installation).
|
20
|
+
|
21
|
+
<details><summary>An example for Mac users with Zsh using pyenv for installation</summary>
|
22
|
+
|
23
|
+
```bash
|
24
|
+
# Install pyenv
|
25
|
+
brew install pyenv
|
26
|
+
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
|
27
|
+
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
|
28
|
+
echo 'eval "$(pyenv init - zsh)"' >> ~/.zshrc
|
29
|
+
|
30
|
+
# Install latest version of python
|
31
|
+
pyenv install $(pyenv install --list | grep -E '^\s*[0-9]+\.[0-9]+\.[0-9]+$' | tail -n 1)
|
32
|
+
pyenv global $(pyenv install --list | grep -E '^\s*[0-9]+\.[0-9]+\.[0-9]+$' | tail -n 1)
|
33
|
+
|
34
|
+
# Install packages using pip
|
35
|
+
pip install networkx bokeh scipy
|
36
|
+
```
|
37
|
+
|
38
|
+
</details>
|
39
|
+
|
40
|
+
## Installation
|
41
|
+
|
42
|
+
Add this line to your application's Gemfile:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
gem "erd_map", group: [:development]
|
46
|
+
```
|
47
|
+
|
48
|
+
And then execute:
|
49
|
+
|
50
|
+
```bash
|
51
|
+
$ bundle
|
52
|
+
```
|
53
|
+
|
54
|
+
## Usage
|
55
|
+
|
56
|
+
Add the following to your `config/routes.rb` and access `/erd_map` in your browser:
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
Rails.application.routes.draw do
|
60
|
+
mount ErdMap::Engine => "erd_map"
|
61
|
+
end
|
62
|
+
```
|
63
|
+
|
64
|
+
The initial computation might take several seconds. Once completed, the "ErdMap" visualization will be displayed. After the first generation, the map will be cached as an HTML file, so subsequent accesses will display the map instantly without regeneration. If you want to regenerate the map, click the "Re-Compute" button.
|
65
|
+
|
66
|
+
The generated HTML file is saved at `/{rails_root}/tmp/erd_map/map.html`.
|
67
|
+
|
68
|
+
### Task
|
69
|
+
|
70
|
+
You can also explicitly generate the HTML file using rails task.
|
71
|
+
|
72
|
+
```bash
|
73
|
+
bundle exec rails erd_map
|
74
|
+
```
|
75
|
+
|
76
|
+
### Map Controls
|
77
|
+
|
78
|
+
- Navigation
|
79
|
+
- Wheel Mode: Toggle zooming with the mouse wheel
|
80
|
+
- Zoom In: Reveal more models
|
81
|
+
- Zoom Out: Display fewer models
|
82
|
+
|
83
|
+
- Display Options
|
84
|
+
- Tap Mode: Switch between showing associations or communities (see [Algorithm](https://github.com/makicamel/erd_map#Algorithm) section for more about communities)
|
85
|
+
- Display Mode: Toggle between showing only model names or including foreign keys
|
86
|
+
|
87
|
+
- Layout
|
88
|
+
- Re-Layout: Randomly rearrange the displayed models
|
89
|
+
- Re-Compute: Regenerate the map to reflect updates to the models
|
90
|
+
|
91
|
+
## Algorithm
|
92
|
+
|
93
|
+
The initial display shows only the three most "important" models. These models are larger in size, while models displayed upon zooming in are slightly smaller. Importance here is determined by **eigenvector centrality**.
|
94
|
+
|
95
|
+
**Eigenvector centrality** is an indicator of how well a model is connected to other highly connected and important models. It considers not just the number of connections a model has, but also the number of important nodes it is connected to.
|
96
|
+
|
97
|
+
Additionally, models are organized into groups (communities) and assigned colors for each community. These communities are detected using the **Louvain method**, which discovers strongly connected communities in a network. The method moves and merges nodes iteratively to optimize communities, maximizing modularity (the density of connections), and dividing the network into natural clusters.
|
98
|
+
|
99
|
+
Both eigenvector centrality and Louvain method implementations are provided by [NetworkX](https://github.com/networkx/networkx) library.
|
100
|
+
|
101
|
+
## Contributing
|
102
|
+
|
103
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/makicamel/erd_map. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/makicamel/erd_map/blob/main/CODE_OF_CONDUCT.md).
|
104
|
+
|
105
|
+
## License
|
106
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
/*
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
3
|
+
* listed below.
|
4
|
+
*
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
7
|
+
*
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
11
|
+
* It is generally better to create a new file per style scope.
|
12
|
+
*
|
13
|
+
*= require_tree .
|
14
|
+
*= require_self
|
15
|
+
*/
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ErdMap
|
4
|
+
class ErdMapController < ApplicationController
|
5
|
+
FILE_PATH = Rails.root.join("tmp", "erd_map", "map.html")
|
6
|
+
|
7
|
+
def index
|
8
|
+
if File.exist?(FILE_PATH)
|
9
|
+
render html: File.read(FILE_PATH).html_safe
|
10
|
+
else
|
11
|
+
_stdout, stderr, status = Open3.capture3("rails runner 'ErdMap::MapBuilder.build'")
|
12
|
+
if status.success?
|
13
|
+
render html: File.read(FILE_PATH).html_safe
|
14
|
+
else
|
15
|
+
render plain: "Error: #{stderr}", status: :unprocessable_entity
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def update
|
21
|
+
_stdout, stderr, status = Open3.capture3("rails runner 'ErdMap::MapBuilder.build'")
|
22
|
+
if status.success?
|
23
|
+
head :ok
|
24
|
+
else
|
25
|
+
render json: { message: "Error: \n#{stderr}" }, status: :unprocessable_entity
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Erd map</title>
|
5
|
+
<%= csrf_meta_tags %>
|
6
|
+
<%= csp_meta_tag %>
|
7
|
+
|
8
|
+
<%= yield :head %>
|
9
|
+
|
10
|
+
<%= stylesheet_link_tag "erd_map/application", media: "all" %>
|
11
|
+
</head>
|
12
|
+
<body>
|
13
|
+
|
14
|
+
<%= yield %>
|
15
|
+
|
16
|
+
</body>
|
17
|
+
</html>
|
data/config/routes.rb
ADDED
@@ -0,0 +1,225 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ErdMap
|
4
|
+
class Graph
|
5
|
+
CHUNK_SIZE = 3
|
6
|
+
MAX_COMMUNITY_SIZE = 20
|
7
|
+
|
8
|
+
# @return Array: [{ "NodeA" => [x, y] }, { "NodeA" => [x, y], "NodeB" => [x, y], "NodeC" => [x, y] }, ...]
|
9
|
+
def layouts_by_chunk
|
10
|
+
return @layouts_by_chunk if @layouts_by_chunk
|
11
|
+
|
12
|
+
@layouts_by_chunk = []
|
13
|
+
|
14
|
+
chunked_nodes.each_with_index do |_, i|
|
15
|
+
display_nodes = chunked_nodes[0..i].flatten
|
16
|
+
nodes_size = display_nodes.size
|
17
|
+
k = 1.0 / Math.sqrt(nodes_size) * 3.0
|
18
|
+
|
19
|
+
subgraph = whole_graph.subgraph(display_nodes)
|
20
|
+
layout = nx.spring_layout(subgraph, seed: 1, k: k)
|
21
|
+
|
22
|
+
layout_hash = {}
|
23
|
+
layout.each do |node, xy|
|
24
|
+
layout_hash[node] = [xy[0].to_f, xy[1].to_f]
|
25
|
+
end
|
26
|
+
|
27
|
+
@layouts_by_chunk << layout_hash
|
28
|
+
end
|
29
|
+
|
30
|
+
@layouts_by_chunk
|
31
|
+
end
|
32
|
+
|
33
|
+
# [[nodeA, nodeB, nodeC], [nodeD, nodeE, nodeF, nodeG, ...], ...]
|
34
|
+
def chunked_nodes
|
35
|
+
return @chunked_nodes if @chunked_nodes
|
36
|
+
|
37
|
+
centralities = nx.eigenvector_centrality(whole_graph) # { node_name => centrality }
|
38
|
+
sorted_nodes = centralities.sort_by { |_node, centrality| centrality }.reverse.map(&:first)
|
39
|
+
|
40
|
+
chunk_sizes = []
|
41
|
+
total_nodes = sorted_nodes.size
|
42
|
+
while chunk_sizes.sum < total_nodes
|
43
|
+
chunk_sizes << (CHUNK_SIZE ** (chunk_sizes.size + 1))
|
44
|
+
end
|
45
|
+
|
46
|
+
offset = 0
|
47
|
+
@chunked_nodes = chunk_sizes.each_with_object([]) do |size, nodes|
|
48
|
+
slice = sorted_nodes[offset, size]
|
49
|
+
break nodes if slice.nil? || slice.empty?
|
50
|
+
offset += size
|
51
|
+
nodes << slice
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# @return Hash: { String: Integer }
|
56
|
+
def node_with_community_index
|
57
|
+
return @node_with_community_index if @node_with_community_index
|
58
|
+
|
59
|
+
whole_communities = networkx_community.louvain_communities(whole_graph).map { |communities| PyCall::List.new(communities).to_a }
|
60
|
+
communities = split_communities(whole_graph, whole_communities)
|
61
|
+
|
62
|
+
@node_with_community_index = {}
|
63
|
+
communities.each_with_index do |community, i|
|
64
|
+
community.each do |node_name|
|
65
|
+
@node_with_community_index[node_name] = i
|
66
|
+
end
|
67
|
+
end
|
68
|
+
@node_with_community_index
|
69
|
+
end
|
70
|
+
|
71
|
+
def initial_nodes
|
72
|
+
chunked_nodes.first
|
73
|
+
end
|
74
|
+
|
75
|
+
def initial_layout
|
76
|
+
layouts_by_chunk.first
|
77
|
+
end
|
78
|
+
|
79
|
+
def whole_layout
|
80
|
+
layouts_by_chunk.last
|
81
|
+
end
|
82
|
+
|
83
|
+
def node_names
|
84
|
+
@node_names ||= PyCall::List.new(whole_graph.nodes)
|
85
|
+
end
|
86
|
+
|
87
|
+
def edges
|
88
|
+
@edges ||= PyCall::List.new(whole_graph.edges)
|
89
|
+
end
|
90
|
+
|
91
|
+
def node_radius
|
92
|
+
@node_radius ||= node_names.map { |node_name| nodes_with_radius_according_to_chunk_index[node_name] }
|
93
|
+
end
|
94
|
+
|
95
|
+
def connections
|
96
|
+
@connections ||= edges.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(a, b), hash|
|
97
|
+
hash[a] << b
|
98
|
+
hash[b] << a
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def association_columns
|
103
|
+
return @association_columns if @association_columns
|
104
|
+
|
105
|
+
@association_columns = Hash.new { |hash, key| hash[key] = [] }
|
106
|
+
whole_models.each do |model|
|
107
|
+
model.reflect_on_all_associations(:belongs_to).select { |mod| !mod.options[:polymorphic] }.map do |target|
|
108
|
+
if target.try(:foreign_key) && model.column_names.include?(target.foreign_key)
|
109
|
+
@association_columns[model.name] << target.foreign_key
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
@association_columns
|
114
|
+
end
|
115
|
+
|
116
|
+
def node_colors
|
117
|
+
return @node_colors if @node_colors
|
118
|
+
|
119
|
+
palette = [
|
120
|
+
"#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99",
|
121
|
+
"#e74446", "#fdbf6f", "#ff7f00", "#cab2d6", "#7850a4",
|
122
|
+
"#ffff99", "#b8693d", "#8dd3c7", "#ffffb3", "#bebada",
|
123
|
+
"#fb8072", "#80b1d3", "#fdb462", "#b3de69", "#fccde5",
|
124
|
+
"#d9d9d9", "#bc80bd", "#ccebc5", "#ffed6f", "#1b9e77",
|
125
|
+
"#d95f02", "#7570b3", "#ef73b2", "#66a61e", "#e6ab02"
|
126
|
+
]
|
127
|
+
community_map = node_with_community_index
|
128
|
+
@node_colors = node_names.map do |node_name|
|
129
|
+
community_index = community_map[node_name]
|
130
|
+
palette[community_index % palette.size]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
attr_reader :nx, :networkx_community
|
137
|
+
attr_reader :whole_graph
|
138
|
+
|
139
|
+
def initialize
|
140
|
+
import_modules = ErdMap.py_call_modules.imported_modules
|
141
|
+
@nx = import_modules[:nx]
|
142
|
+
@networkx_community = import_modules[:networkx_community]
|
143
|
+
@whole_graph = build_whole_graph
|
144
|
+
end
|
145
|
+
|
146
|
+
def whole_models
|
147
|
+
Rails.application.eager_load!
|
148
|
+
@whole_models ||= ActiveRecord::Base.descendants
|
149
|
+
.reject { |model| model.name.in?(%w[ActiveRecord::SchemaMigration ActiveRecord::InternalMetadata]) }
|
150
|
+
.select(&:table_exists?)
|
151
|
+
end
|
152
|
+
|
153
|
+
def build_whole_graph
|
154
|
+
whole_graph = nx.Graph.new
|
155
|
+
|
156
|
+
whole_models.each do |model|
|
157
|
+
whole_graph.add_node(model.name)
|
158
|
+
[:has_many, :has_one, :belongs_to].each do |association_type|
|
159
|
+
model
|
160
|
+
.reflect_on_all_associations(association_type)
|
161
|
+
.select { |mod| !mod.options[:polymorphic] && !mod.options[:anonymous_class] }
|
162
|
+
.map(&:class_name)
|
163
|
+
.uniq
|
164
|
+
.select { |target| target.constantize.respond_to?(:column_names) }
|
165
|
+
.map do |target|
|
166
|
+
if association_type == :belongs_to
|
167
|
+
whole_graph.add_edge(target, model.name)
|
168
|
+
else
|
169
|
+
whole_graph.add_edge(model.name, target)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
whole_graph
|
175
|
+
end
|
176
|
+
|
177
|
+
# { "NodeA" => 0, "NodeB" => 0, "NodeC" => 1, ... }
|
178
|
+
def nodes_with_chunk_index
|
179
|
+
return @nodes_with_chunk_index if @nodes_with_chunk_index
|
180
|
+
@nodes_with_chunk_index = {}
|
181
|
+
chunked_nodes.each_with_index do |chunk, i|
|
182
|
+
chunk.each { |node_name| @nodes_with_chunk_index[node_name] = i }
|
183
|
+
end
|
184
|
+
@nodes_with_chunk_index
|
185
|
+
end
|
186
|
+
|
187
|
+
def nodes_with_radius_according_to_chunk_index
|
188
|
+
return @nodes_with_radius_according_to_chunk_index if @nodes_with_radius_according_to_chunk_index
|
189
|
+
|
190
|
+
max_node_size = 60
|
191
|
+
min_node_size = 20
|
192
|
+
node_size_step = 10
|
193
|
+
|
194
|
+
@nodes_with_radius_according_to_chunk_index = {}
|
195
|
+
chunked_nodes.each_with_index do |chunk, chunk_index|
|
196
|
+
chunk.each do |node_name|
|
197
|
+
size = max_node_size - (chunk_index * node_size_step)
|
198
|
+
@nodes_with_radius_according_to_chunk_index[node_name] = (size < min_node_size) ? min_node_size : size
|
199
|
+
end
|
200
|
+
end
|
201
|
+
@nodes_with_radius_according_to_chunk_index
|
202
|
+
end
|
203
|
+
|
204
|
+
def split_communities(graph, communities)
|
205
|
+
result = []
|
206
|
+
|
207
|
+
communities.each do |community|
|
208
|
+
if community.size <= MAX_COMMUNITY_SIZE
|
209
|
+
result << community
|
210
|
+
else
|
211
|
+
subgraph = graph.subgraph(community)
|
212
|
+
sub_communities = networkx_community.louvain_communities(subgraph).map { |comm| PyCall::List.new(comm).to_a }
|
213
|
+
if sub_communities.size == 1 && (sub_communities[0] - community).empty?
|
214
|
+
result << community
|
215
|
+
else
|
216
|
+
splitted_sub = split_communities(subgraph, sub_communities)
|
217
|
+
result.concat(splitted_sub)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
result
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|