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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ed36b91c2cc6ff7abad9f7e84311d355052a09dbe663afc53f427ffccf11579
4
- data.tar.gz: ec99b68c56d6494974257a305c16520a5d64e2bb2cd96105a0471269c491f812
3
+ metadata.gz: 3209269f5b2888c4144c95abffc3455328d429e11286f1805ff1ab86990f0a50
4
+ data.tar.gz: 25f0e0c9ea02edb84d192ace0ba79470ea8b5951666234a05d72182e6d793383
5
5
  SHA512:
6
- metadata.gz: f1c7bb5c91510867e0ff7213d70dcdcc6e04ca88676389dcb35aa00d3e47ab0b5c8e91997e557aad72f821d498272bb540ffd77097f6a99c73a4cc7a54f0b13e
7
- data.tar.gz: 2216ce3565d37fb8b0ff6552f7fbd3905f7211cfc7ae734be828b6af6bc84813e57be41b27c39a3f6ad01b1352c5de0955d3edede47bf2665db8bf14eacc77e7
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.0...HEAD
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
- ![Diagrammer example output](docs/screenshot.png)
5
+ ![Diagrammer example output](https://raw.githubusercontent.com/alex-andreiev/diagrammer/main/docs/screenshot.png)
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
 
@@ -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
- File.write(@output, html)
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 = {}; // table_name -> node
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 = {}; // column name -> row element
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
- var fkName = (r.name || "") + "_id";
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: "one", to: "many" };
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 = {}, comps = [];
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(&:name)
12
+ models = selected_models.sort_by { |model| model.name.to_s }
17
13
  {
18
14
  tables: unique_tables(models),
19
- relationships: relationships_for(models)
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 relationships_for, which is keyed by table name.
24
+ # via RelationshipMapper, which is keyed by table name.
29
25
  def unique_tables(models)
30
- models.each_with_object({}) do |model, by_table|
31
- by_table[model.table_name] ||= table_for(model)
32
- end.values
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Diagrammer
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.1'
5
5
  end
data/lib/diagrammer.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'diagrammer/version'
4
+ require_relative 'diagrammer/relationship_mapper'
4
5
  require_relative 'diagrammer/model_introspector'
5
6
  require_relative 'diagrammer/html_renderer'
6
7
  require_relative 'diagrammer/generator'
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.0
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-06-28 00:00:00.000000000 Z
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