diagrammer 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +38 -1
- data/README.md +1 -1
- data/lib/diagrammer/generator.rb +11 -1
- data/lib/diagrammer/html_renderer.rb +12 -8
- data/lib/diagrammer/model_introspector.rb +22 -42
- data/lib/diagrammer/relationship_mapper.rb +133 -0
- data/lib/diagrammer/version.rb +1 -1
- data/lib/diagrammer.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3209269f5b2888c4144c95abffc3455328d429e11286f1805ff1ab86990f0a50
|
|
4
|
+
data.tar.gz: 25f0e0c9ea02edb84d192ace0ba79470ea8b5951666234a05d72182e6d793383
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1111b36151de7366fba5eee4f5f0a56ca3cecbd731baea320973296f20129e706e2c024df1a0e47aae51f041accb74f22afe56d805897281eaec83a2e584fe06
|
|
7
|
+
data.tar.gz: 51e7569e6d222052fbce83141c86d238b6de67943658e86c9d7e0d35bdc07c4169bbac66cbb4be75b47bb4b4f2fbb4b24b3faf9bf06ba4e793f8e5653a66cf54
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.1] - 2026-07-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Do not crash on anonymous ActiveRecord classes. Sorting models by `name`
|
|
15
|
+
raised `ArgumentError: comparison of NilClass with String failed` when any
|
|
16
|
+
`Class.new(ActiveRecord::Base)` was loaded in the process.
|
|
17
|
+
- Do not crash when `Rails` is loaded without an application (engines, gem test
|
|
18
|
+
suites). The eager-load guard checked `Rails.respond_to?(:application)`, which
|
|
19
|
+
is true even while `Rails.application` is still `nil`.
|
|
20
|
+
- Draw one connector per relationship instead of two. A `belongs_to` and its
|
|
21
|
+
inverse `has_many`/`has_one` describe the same foreign key, and both were
|
|
22
|
+
emitted, producing duplicate lines with contradicting crow's feet. On a
|
|
23
|
+
17-table application this halved the edge count from 36 to 18.
|
|
24
|
+
- Label a shared table with its STI base class rather than whichever subclass
|
|
25
|
+
sorted first alphabetically (`AdminUser` no longer names the `users` card),
|
|
26
|
+
and never let an anonymous class name a table it shares.
|
|
27
|
+
- Anchor connectors on the real foreign key column instead of guessing
|
|
28
|
+
`"#{association_name}_id"`, which missed every association declared with a
|
|
29
|
+
custom `:foreign_key`.
|
|
30
|
+
- Create missing parent directories for the output path, so writing to a nested
|
|
31
|
+
path such as `tmp/diagrams/erd.html` no longer raises `Errno::ENOENT`.
|
|
32
|
+
- Keep graph traversal correct for tables and columns whose names collide with
|
|
33
|
+
`Object.prototype` members (`constructor`, `__proto__`), which previously
|
|
34
|
+
stranded such a card at the origin, unlaid-out and overlapping its neighbours.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- Relationships are now oriented from the table holding the foreign key to the
|
|
39
|
+
table it references, and carry a `foreign_key` field. `has_many :through` is
|
|
40
|
+
no longer emitted, since it has no foreign key of its own and the underlying
|
|
41
|
+
associations already draw every physical link along the path.
|
|
42
|
+
- Relationship mapping moved into `Diagrammer::RelationshipMapper`.
|
|
43
|
+
- The README screenshot uses an absolute URL so it renders outside the
|
|
44
|
+
repository, where the previous relative path resolved to nothing.
|
|
45
|
+
|
|
10
46
|
## [0.1.0] - 2026-06-28
|
|
11
47
|
|
|
12
48
|
### Added
|
|
@@ -21,5 +57,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
21
57
|
cluster-based layout that fills the viewport width, and zoom/pan/drag — no
|
|
22
58
|
Graphviz, no CDN, no network access required.
|
|
23
59
|
|
|
24
|
-
[Unreleased]: https://github.com/alex-andreiev/diagrammer/compare/v0.1.
|
|
60
|
+
[Unreleased]: https://github.com/alex-andreiev/diagrammer/compare/v0.1.1...HEAD
|
|
61
|
+
[0.1.1]: https://github.com/alex-andreiev/diagrammer/compare/v0.1.0...v0.1.1
|
|
25
62
|
[0.1.0]: https://github.com/alex-andreiev/diagrammer/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Diagrammer is a Rails gem that generates a standalone, interactive database relationship diagram from ActiveRecord models.
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+

|
|
6
6
|
|
|
7
7
|
It introspects your Rails application, reads model columns and associations, and writes a single browser-friendly HTML file. The diagram renders as draggable table cards — similar in spirit to dbdiagram.io — directly in the browser. The main design goal is a zero-dependency workflow: no Graphviz, no system packages, no local diagram renderer, no PDF toolchain, and no network access required to view the result.
|
|
8
8
|
|
data/lib/diagrammer/generator.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
3
5
|
module Diagrammer
|
|
4
6
|
class Generator
|
|
5
7
|
def initialize(output:, models: nil, introspector: ModelIntrospector.new(models: models))
|
|
@@ -15,12 +17,20 @@ module Diagrammer
|
|
|
15
17
|
notice: notice_for(diagram)
|
|
16
18
|
).call
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
write(html)
|
|
19
21
|
@output.to_s
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
private
|
|
23
25
|
|
|
26
|
+
# The rake task defaults to Rails.root, but an explicit path may point into
|
|
27
|
+
# a directory that does not exist yet (tmp/, doc/, a CI artifact dir).
|
|
28
|
+
def write(html)
|
|
29
|
+
path = @output.to_s
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
31
|
+
File.write(path, html)
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
def notice_for(diagram)
|
|
25
35
|
return unless diagram.fetch(:tables).empty?
|
|
26
36
|
|
|
@@ -242,7 +242,9 @@ module Diagrammer
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
// ---- Build cards -------------------------------------------------
|
|
245
|
-
var nodes =
|
|
245
|
+
var nodes = Object.create(null); // table_name -> node (null proto: table
|
|
246
|
+
// names like "constructor" must not
|
|
247
|
+
// collide with Object.prototype)
|
|
246
248
|
var order = []; // stable iteration order
|
|
247
249
|
tables.forEach(function (t) {
|
|
248
250
|
var el = document.createElement("div");
|
|
@@ -253,7 +255,7 @@ module Diagrammer
|
|
|
253
255
|
header.textContent = t.table_name;
|
|
254
256
|
el.appendChild(header);
|
|
255
257
|
|
|
256
|
-
var rows =
|
|
258
|
+
var rows = Object.create(null); // column name -> row element
|
|
257
259
|
(t.columns || []).forEach(function (c) {
|
|
258
260
|
var row = document.createElement("div");
|
|
259
261
|
row.className = "col" + (c.primary_key ? " is-pk" : "") + (c.foreign_key ? " is-fk" : "");
|
|
@@ -300,7 +302,9 @@ module Diagrammer
|
|
|
300
302
|
relationships.forEach(function (r) {
|
|
301
303
|
var from = nodes[r.from], to = nodes[r.to];
|
|
302
304
|
if (!from || !to) { return; }
|
|
303
|
-
|
|
305
|
+
// The real FK column beats guessing "<assoc>_id", which misses
|
|
306
|
+
// whenever the association was declared with a custom :foreign_key.
|
|
307
|
+
var fkName = r.foreign_key || ((r.name || "") + "_id");
|
|
304
308
|
var fkRow = from.rows[fkName] ? fkName : null;
|
|
305
309
|
var pkCol = pkColumn(to);
|
|
306
310
|
var link = { from: from, to: to, fkRow: fkRow, pkRow: pkCol, card: cardinality(r.macro), group: null };
|
|
@@ -317,13 +321,13 @@ module Diagrammer
|
|
|
317
321
|
}
|
|
318
322
|
|
|
319
323
|
// Map an association macro to crow's-foot cardinality at each end.
|
|
324
|
+
// Edges always run FK holder -> referenced table, so "from" is the
|
|
325
|
+
// child side for every macro except habtm.
|
|
320
326
|
function cardinality(macro) {
|
|
321
327
|
switch (macro) {
|
|
322
|
-
case "belongs_to": return { from: "many", to: "one" };
|
|
323
328
|
case "has_one": return { from: "one", to: "one" };
|
|
324
|
-
case "has_many": return { from: "one", to: "many" };
|
|
325
329
|
case "has_and_belongs_to_many": return { from: "many", to: "many" };
|
|
326
|
-
default: return { from: "
|
|
330
|
+
default: return { from: "many", to: "one" };
|
|
327
331
|
}
|
|
328
332
|
}
|
|
329
333
|
|
|
@@ -358,7 +362,7 @@ module Diagrammer
|
|
|
358
362
|
}
|
|
359
363
|
|
|
360
364
|
function connectedComponents() {
|
|
361
|
-
var seen =
|
|
365
|
+
var seen = Object.create(null), comps = [];
|
|
362
366
|
order.forEach(function (start) {
|
|
363
367
|
if (seen[start.name]) { return; }
|
|
364
368
|
var queue = [start], comp = [];
|
|
@@ -675,7 +679,7 @@ module Diagrammer
|
|
|
675
679
|
viewport.addEventListener("pointercancel", endPointer);
|
|
676
680
|
|
|
677
681
|
function highlight(node, on) {
|
|
678
|
-
var related =
|
|
682
|
+
var related = Object.create(null);
|
|
679
683
|
related[node.name] = true;
|
|
680
684
|
node.edges.forEach(function (l) {
|
|
681
685
|
related[l.from.name] = true; related[l.to.name] = true;
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'set'
|
|
4
|
-
|
|
5
3
|
module Diagrammer
|
|
6
4
|
class ModelIntrospector
|
|
7
|
-
ASSOCIATION_MACROS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
|
|
8
|
-
|
|
9
5
|
def initialize(models: nil)
|
|
10
6
|
@models = models
|
|
11
7
|
end
|
|
@@ -13,10 +9,10 @@ module Diagrammer
|
|
|
13
9
|
def call
|
|
14
10
|
eager_load_rails_application
|
|
15
11
|
|
|
16
|
-
models = selected_models.sort_by
|
|
12
|
+
models = selected_models.sort_by { |model| model.name.to_s }
|
|
17
13
|
{
|
|
18
14
|
tables: unique_tables(models),
|
|
19
|
-
relationships:
|
|
15
|
+
relationships: RelationshipMapper.new(models: models).call
|
|
20
16
|
}
|
|
21
17
|
end
|
|
22
18
|
|
|
@@ -25,15 +21,30 @@ module Diagrammer
|
|
|
25
21
|
# Several models can share one database table (STI subclasses, gem base
|
|
26
22
|
# classes such as I18n's Translation, multi-schema rpush models). Emit a
|
|
27
23
|
# single card per table; associations from every model still merge onto it
|
|
28
|
-
# via
|
|
24
|
+
# via RelationshipMapper, which is keyed by table name.
|
|
29
25
|
def unique_tables(models)
|
|
30
|
-
models.
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
models.group_by(&:table_name).map { |_table, group| table_for(preferred_model(group)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Models are sorted by name, so an STI subclass can win the card label over
|
|
30
|
+
# its own base class ("AdminUser" < "User"). Prefer the root of the
|
|
31
|
+
# hierarchy, and never let an anonymous class name a table it shares.
|
|
32
|
+
def preferred_model(group)
|
|
33
|
+
named = group.reject { |model| model.name.to_s.empty? }
|
|
34
|
+
candidates = named.empty? ? group : named
|
|
35
|
+
candidates.find { |model| sti_base?(model) } || candidates.first
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
def sti_base?(model)
|
|
39
|
+
model.respond_to?(:base_class) && model.base_class == model
|
|
40
|
+
rescue StandardError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Rails.respond_to?(:application) is true even before an application is
|
|
45
|
+
# defined (engines, gem test suites), where the reader returns nil.
|
|
35
46
|
def eager_load_rails_application
|
|
36
|
-
return unless defined?(Rails) && Rails.respond_to?(:application)
|
|
47
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
37
48
|
|
|
38
49
|
Rails.application.eager_load!
|
|
39
50
|
end
|
|
@@ -78,36 +89,5 @@ module Diagrammer
|
|
|
78
89
|
}
|
|
79
90
|
end
|
|
80
91
|
end
|
|
81
|
-
|
|
82
|
-
def relationships_for(models)
|
|
83
|
-
table_names = models.to_set(&:table_name)
|
|
84
|
-
models.flat_map { |model| model_relationships(model, table_names) }.uniq
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def model_relationships(model, table_names)
|
|
88
|
-
model.reflect_on_all_associations.filter_map do |association|
|
|
89
|
-
relationship_for(model, association, table_names)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def relationship_for(model, association, table_names)
|
|
94
|
-
return unless ASSOCIATION_MACROS.include?(association.macro)
|
|
95
|
-
|
|
96
|
-
target_table = association_table_name(association)
|
|
97
|
-
return unless target_table && table_names.include?(target_table)
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
from: model.table_name,
|
|
101
|
-
to: target_table,
|
|
102
|
-
name: association.name.to_s,
|
|
103
|
-
macro: association.macro
|
|
104
|
-
}
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def association_table_name(association)
|
|
108
|
-
association.klass.table_name
|
|
109
|
-
rescue StandardError
|
|
110
|
-
nil
|
|
111
|
-
end
|
|
112
92
|
end
|
|
113
93
|
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Diagrammer
|
|
6
|
+
# Turns ActiveRecord reflections into the table-to-table edges the renderer
|
|
7
|
+
# draws. Every edge is oriented from the table holding the foreign key to the
|
|
8
|
+
# table it references, so the renderer can anchor the line on the FK column.
|
|
9
|
+
class RelationshipMapper
|
|
10
|
+
ASSOCIATION_MACROS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
|
|
11
|
+
|
|
12
|
+
# Ordered weakest-to-strongest. When a belongs_to and its inverse collapse
|
|
13
|
+
# onto one edge, the strongest macro in the group decides the cardinality.
|
|
14
|
+
MACRO_STRENGTH = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(models:)
|
|
17
|
+
@models = models
|
|
18
|
+
@table_names = models.to_set(&:table_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
candidates = @models.flat_map { |model| model_relationships(model) }
|
|
23
|
+
merge_reciprocals(candidates)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def model_relationships(model)
|
|
29
|
+
model.reflect_on_all_associations.filter_map do |association|
|
|
30
|
+
relationship_for(model, association)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A belongs_to and its inverse has_many/has_one describe the *same* physical
|
|
35
|
+
# foreign key. Emitting both draws two connectors between the same pair of
|
|
36
|
+
# cards, with contradicting crow's feet, so collapse them onto one edge.
|
|
37
|
+
def merge_reciprocals(candidates)
|
|
38
|
+
candidates.group_by { |relationship| relationship[:key] }.map do |_key, group|
|
|
39
|
+
representative(group)
|
|
40
|
+
.merge(macro: strongest_macro(group))
|
|
41
|
+
.except(:key)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The belongs_to side names the association after the foreign key, which
|
|
46
|
+
# makes the better edge label; fall back to whichever side we have.
|
|
47
|
+
def representative(group)
|
|
48
|
+
group.find { |relationship| relationship[:macro] == :belongs_to } || group.first
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def strongest_macro(group)
|
|
52
|
+
group.map { |relationship| relationship[:macro] }
|
|
53
|
+
.max_by { |macro| MACRO_STRENGTH.index(macro) || -1 }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def relationship_for(model, association)
|
|
57
|
+
return unless ASSOCIATION_MACROS.include?(association.macro)
|
|
58
|
+
# A has_many :through has no foreign key of its own; the underlying
|
|
59
|
+
# associations already draw every physical link along the path.
|
|
60
|
+
return if through_association?(association)
|
|
61
|
+
|
|
62
|
+
target_table = association_table_name(association)
|
|
63
|
+
return unless target_table && @table_names.include?(target_table)
|
|
64
|
+
|
|
65
|
+
if association.macro == :has_and_belongs_to_many
|
|
66
|
+
habtm_relationship(model, association, target_table)
|
|
67
|
+
else
|
|
68
|
+
directed_relationship(model, association, target_table)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def directed_relationship(model, association, target_table)
|
|
73
|
+
foreign_key = association_foreign_key(association)
|
|
74
|
+
child, parent = endpoints(model, association, target_table)
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
key: [child, parent, foreign_key],
|
|
78
|
+
from: child, to: parent,
|
|
79
|
+
name: association.name.to_s,
|
|
80
|
+
macro: association.macro,
|
|
81
|
+
foreign_key: foreign_key
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Only belongs_to declares the foreign key on its own table; has_many and
|
|
86
|
+
# has_one point at a key living on the far side.
|
|
87
|
+
def endpoints(model, association, target_table)
|
|
88
|
+
if association.macro == :belongs_to
|
|
89
|
+
[model.table_name, target_table]
|
|
90
|
+
else
|
|
91
|
+
[target_table, model.table_name]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Both sides of a habtm share one join table, which is what identifies the
|
|
96
|
+
# edge; the endpoints are sorted so either side produces the same key.
|
|
97
|
+
def habtm_relationship(model, association, target_table)
|
|
98
|
+
from, to = [model.table_name, target_table].sort
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
key: [:habtm, association_join_table(association), from, to],
|
|
102
|
+
from: from, to: to,
|
|
103
|
+
name: association.name.to_s,
|
|
104
|
+
macro: association.macro,
|
|
105
|
+
foreign_key: nil
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def through_association?(association)
|
|
110
|
+
association.respond_to?(:through_reflection?) && association.through_reflection?
|
|
111
|
+
rescue StandardError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def association_table_name(association)
|
|
116
|
+
association.klass.table_name
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def association_foreign_key(association)
|
|
122
|
+
association.foreign_key.to_s
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def association_join_table(association)
|
|
128
|
+
association.join_table.to_s
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/diagrammer/version.rb
CHANGED
data/lib/diagrammer.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: diagrammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Andreiev
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-07-21 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/diagrammer/html_renderer.rb
|
|
96
96
|
- lib/diagrammer/model_introspector.rb
|
|
97
97
|
- lib/diagrammer/railtie.rb
|
|
98
|
+
- lib/diagrammer/relationship_mapper.rb
|
|
98
99
|
- lib/diagrammer/tasks/diagrammer.rake
|
|
99
100
|
- lib/diagrammer/version.rb
|
|
100
101
|
homepage: https://github.com/alex-andreiev/diagrammer
|