erd_map 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ | ![](sample/images/redmine.png) | ![](sample/images/mastdon.png) |
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,5 @@
1
+ require "bundler/setup"
2
+
3
+ # load "rails/tasks/statistics.rake"
4
+
5
+ require "bundler/gem_tasks"
@@ -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,4 @@
1
+ module ErdMap
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -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,4 @@
1
+ module ErdMap
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,5 @@
1
+ module ErdMap
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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>
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ ErdMap.py_call_modules = ErdMap::PyCallModules.new
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ ErdMap::Engine.routes.draw do
4
+ root to: "erd_map#index"
5
+ put "/", to: "erd_map#update"
6
+ end
@@ -0,0 +1,5 @@
1
+ module ErdMap
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ErdMap
4
+ end
5
+ end
@@ -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