rails-schema 0.1.3 → 0.1.4

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: 37ed34e2667a948b30551666b19657a3bf6e1a484e4fe4660a3da03c9d356f66
4
- data.tar.gz: c7f9ac452ae98d4188718c47879a2b1fc2769b21d308b7d07f89779c42273f95
3
+ metadata.gz: 71fa755a65ac8dcdb27f419857dd5ede16a13d4c26e5b9a40bc7e8ec1f41d36b
4
+ data.tar.gz: 4940bf9f24e51d6caf3e5e70526f7a2ed8d097c352e99920e6191212c1d6137e
5
5
  SHA512:
6
- metadata.gz: a21acfd8d250d9fffdd67acc95a6743167f26cf2754c4d71d35af9a74a1f450c4954eb6443fe20d8198e066f8ef8e783924ed4cab3a7b39f7f2f9006e5913af7
7
- data.tar.gz: 837e5b1171e30ff17aca2e8871bed916d691203c28e8a876d92948ef38018ce23856f7263c95d96c3334c04be853b46bad48cd09101f5b7cf42da742f1db9246
6
+ metadata.gz: 8004dd0d9b8a82008ec7e4b53c0ec444067ed1ba477e1bafe59dcd18810149bdc713e1c5f1009ae3bcd456a6cfcd46e62be112cd3f1ae4d0a44ccd2929f644f2
7
+ data.tar.gz: 4269f97b5b87daf25cfd24ed9138b4ed363e3a9ea4c7f70695abc53517216cc04d299567c1c48a80b6f2fae9b99b602434574110c3cd2afc1ccdea0e4c5a6bf0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.1.4] - 2026-03-08
8
+
9
+ ### Added
10
+
11
+ - Support for Ruby 2.7+ and Rails 5.2+ (improved compatibility with older versions)
12
+
13
+ ### Changed
14
+
15
+ - Self-referential-only models (all edges point to themselves) are now placed in a vertical column to the left of the main graph instead of floating in the force simulation
16
+ - True orphan models (zero edges) continue to appear in rows above the diagram
17
+ - Improved class names and table names visibility
18
+
19
+ ## [0.1.3] - 2026-03-01
20
+
21
+ ### Added
22
+
23
+ - Mongoid support: visualize MongoDB-backed models without a schema file (`schema_format: :mongoid`)
24
+ - Auto-detection of Mongoid when `schema_format: :auto` and `Mongoid::Document` is defined
25
+ - Mongoid extractors: `ModelScanner`, `ModelAdapter`, `ColumnReader`, `AssociationReader`
26
+ - Support for all Mongoid association types: `has_many`, `has_one`, `belongs_to`, `has_and_belongs_to_many`, `embeds_many`, `embeds_one`, `embedded_in`
27
+ - Embedded document styling in the frontend (dashed borders for embed associations)
28
+ - Engine model eager-loading for Mongoid apps
29
+
7
30
  ## [0.1.2] - 2026-02-22
8
31
 
9
32
  ### Added
data/CLAUDE.md CHANGED
@@ -16,16 +16,58 @@ bundle exec rspec spec/rails/schema/extractor/mongoid/ # Mongoid tests only
16
16
 
17
17
  Three-layer pipeline: **Extractor → Transformer → Renderer**
18
18
 
19
+ | Layer | Responsibility | Key Classes |
20
+ |---|---|---|
21
+ | **Extractor** | Introspects Rails environment; collects models, columns, associations | `ModelScanner`, `ColumnReader`, `AssociationReader`, `SchemaFileParser`, `StructureSqlParser`, plus `Mongoid::ModelScanner`, `Mongoid::ModelAdapter`, `Mongoid::ColumnReader`, `Mongoid::AssociationReader` |
22
+ | **Transformer** | Normalizes extracted data into a serializable graph (nodes + edges + metadata) | `GraphBuilder`, `Node`, `Edge` |
23
+ | **Renderer** | Injects graph data into an HTML/JS/CSS template via ERB | `HtmlGenerator` |
24
+ | **Railtie** | Provides the `rails_schema:generate` rake task | `Railtie` |
25
+
26
+ ### Key Paths
27
+
19
28
  - `lib/rails/schema.rb` — entry point, `generate` dispatches to ActiveRecord or Mongoid pipeline
20
29
  - `lib/rails/schema/extractor/` — model discovery, column/association reading, schema file parsing
21
- - `lib/rails/schema/extractor/mongoid/` — Mongoid-specific extractors (model_scanner, model_adapter, column_reader, association_reader)
22
- - `lib/rails/schema/transformer/` — builds normalized graph JSON (nodes + edges + metadata)
30
+ - `lib/rails/schema/extractor/mongoid/` — Mongoid-specific extractors
31
+ - `lib/rails/schema/transformer/` — builds normalized graph JSON
23
32
  - `lib/rails/schema/renderer/` — ERB-based HTML generation with inlined JS/CSS/data
24
33
  - `lib/rails/schema/assets/` — frontend (vanilla JS + d3-force, CSS, HTML template)
25
34
 
35
+ ### Generation Pipeline
36
+
37
+ ```ruby
38
+ def generate(output: nil)
39
+ schema_data = parse_schema
40
+ models = Extractor::ModelScanner.new(schema_data: schema_data).scan
41
+ column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
42
+ graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
43
+ Renderer::HtmlGenerator.new(graph_data: graph_data).render_to_file(output)
44
+ end
45
+ ```
46
+
47
+ ### Data Extraction Strategy
48
+
49
+ 1. **`db/schema.rb`** — `SchemaFileParser` parses with regex (table names, columns, types, nullable, defaults, PKs)
50
+ 2. **`db/structure.sql`** — `StructureSqlParser` parses SQL `CREATE TABLE` statements, maps SQL types to Rails types
51
+ 3. **ActiveRecord reflection API** — `AssociationReader` uses `reflect_on_all_associations` for associations
52
+ 4. **`Model.columns`** — `ColumnReader` falls back to this when table not found in schema_data
53
+
54
+ ### Model Discovery
55
+
56
+ `ModelScanner` (ActiveRecord):
57
+ 1. Calls `Rails.application.eager_load!` (Zeitwerk support, multiple fallback strategies including `LoadError` rescue)
58
+ 2. Collects `ActiveRecord::Base.descendants`
59
+ 3. Filters: abstract classes, anonymous classes, models without known tables
60
+ 4. Applies `exclude_models` config (supports wildcard prefix like `"ActiveStorage::*"`)
61
+
62
+ `Mongoid::ModelScanner`:
63
+ 1. Eager-loads via Zeitwerk or file glob with fallbacks
64
+ 2. Scans `ObjectSpace` for `Mongoid::Document` includers
65
+ 3. Also loads models from mounted Rails engines
66
+ 4. Returns models wrapped in `ModelAdapter` for GraphBuilder compatibility
67
+
26
68
  ## Key Conventions
27
69
 
28
- - Ruby >= 3.2, Rails >= 6.0
70
+ - Ruby >= 2.7, Rails >= 5.2
29
71
  - Double quotes for strings (RuboCop enforced)
30
72
  - RuboCop max method length: 15 lines, default ABC/complexity limits
31
73
  - No `Style/Documentation` required
@@ -44,5 +86,22 @@ Three-layer pipeline: **Extractor → Transformer → Renderer**
44
86
  ## Testing Notes
45
87
 
46
88
  - Always run `bundle exec rubocop` before committing — CI checks both tests and linting
89
+ - CI matrix: Ruby 2.7, 3.0, 3.1, 3.2, 3.3, 3.4
47
90
  - Mongoid specs stub `Rails::Engine` and `Rails::Application` since they may not exist in test env
48
91
  - SimpleCov is enabled; coverage report goes to `coverage/`
92
+
93
+ ## Frontend
94
+
95
+ Single self-contained HTML file — no CDN, no network requests. D3 is vendored/minified.
96
+
97
+ - **Vanilla JS + d3-force** for graph rendering
98
+ - **CSS custom properties** for dark/light theming
99
+ - Features: searchable sidebar, click-to-focus, detail panel, zoom/pan, keyboard shortcuts (`/` search, `Esc` deselect, `+/-` zoom, `F` fit)
100
+
101
+ ## Design Decisions
102
+
103
+ - **Single HTML file** — zero deployment friction, offline-first, portable (CI, GitHub Pages, email)
104
+ - **Not a mounted engine** — static file works without a running server
105
+ - **Parse schema files** — works without DB connection (CI environments, no local DB)
106
+ - **Force-directed layout** — handles unknown schemas gracefully without pre-defined positions
107
+ - **Node layout categories** — three-way partition in `app.js`: connected nodes use force simulation, self-ref-only models (all edges point to themselves) are placed in a vertical left column via `layoutSelfRefNodes()`, true orphans (zero edges) are placed in rows above the diagram via `layoutOrphans()`
data/README.md CHANGED
@@ -4,6 +4,13 @@ Interactive HTML visualization of your Rails database schema. Introspects your a
4
4
 
5
5
  No external server, no CDN — just one command and a browser.
6
6
 
7
+ ## Compatibility
8
+
9
+ | Dependency | Required version |
10
+ |-----------|-----------------|
11
+ | Ruby | >= 2.7 |
12
+ | Rails | >= 5.2 |
13
+
7
14
  **[Live example](https://andrew2net.github.io/rails-schema/)** — generated from [Fizzy](https://www.fizzy.do), a modern spin on kanban for tracking just about anything, created by [37signals](https://37signals.com).
8
15
 
9
16
  ![Rails Schema screenshot](docs/screenshot.png)
@@ -100,7 +107,7 @@ For Mongoid apps, the gem introspects model classes at runtime to read field def
100
107
  ## Features
101
108
 
102
109
  - **No database required** — reads from `db/schema.rb`, `db/structure.sql`, or Mongoid model introspection
103
- - **Force-directed layout** — models cluster naturally by association density
110
+ - **Force-directed layout** — models cluster naturally by association density; self-referential-only models are placed in a left column, true orphans in rows above
104
111
  - **Searchable sidebar** — filter models by name or table
105
112
  - **Click-to-focus** — click a model to highlight its neighborhood, fading unrelated models
106
113
  - **Detail panel** — full column list and associations for the selected model
@@ -195,6 +195,10 @@
195
195
  }
196
196
 
197
197
  // d3-force simulation
198
+ var orphanNodes = [];
199
+ var connectedNodes = [];
200
+ var selfRefNodes = [];
201
+
198
202
  function setupSimulation() {
199
203
  var nodeMap = {};
200
204
  nodes.forEach(function(n) { nodeMap[n.id] = n; });
@@ -207,18 +211,156 @@
207
211
 
208
212
  var simNodes = nodes.filter(function(n) { return visibleModels.has(n.id); });
209
213
 
214
+ // Partition into connected, self-ref-only, and orphan nodes
215
+ var connectedIds = new Set();
216
+ var selfRefIds = new Set();
217
+ simEdges.forEach(function(e) {
218
+ if (e.data.from !== e.data.to) {
219
+ connectedIds.add(e.data.from);
220
+ connectedIds.add(e.data.to);
221
+ } else {
222
+ selfRefIds.add(e.data.from);
223
+ }
224
+ });
225
+ // Self-ref-only = has self-ref edge but no cross-model edges
226
+ selfRefIds.forEach(function(id) { if (connectedIds.has(id)) selfRefIds.delete(id); });
227
+
228
+ connectedNodes = simNodes.filter(function(n) { return connectedIds.has(n.id); });
229
+ selfRefNodes = simNodes.filter(function(n) { return selfRefIds.has(n.id); });
230
+ selfRefNodes.sort(function(a, b) { return a.id.localeCompare(b.id); });
231
+ orphanNodes = simNodes.filter(function(n) { return !connectedIds.has(n.id) && !selfRefIds.has(n.id); });
232
+ orphanNodes.sort(function(a, b) { return a.id.localeCompare(b.id); });
233
+
234
+ // Exclude self-ref-only edges from force simulation
235
+ var forceEdges = simEdges.filter(function(e) {
236
+ return !selfRefIds.has(e.data.from) || connectedIds.has(e.data.from);
237
+ });
238
+
210
239
  if (simulation) simulation.stop();
211
240
 
212
- simulation = d3.forceSimulation(simNodes)
213
- .force("link", d3.forceLink(simEdges).id(function(d) { return d.id; }).distance(320))
241
+ simulation = d3.forceSimulation(connectedNodes)
242
+ .force("link", d3.forceLink(forceEdges).id(function(d) { return d.id; }).distance(320))
214
243
  .force("charge", d3.forceManyBody().strength(-600))
215
244
  .force("center", d3.forceCenter(svgEl.clientWidth / 2, svgEl.clientHeight / 2))
216
245
  .force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 + 50; }))
217
246
  .on("tick", ticked);
218
247
 
248
+ // Manually resolve edge references for self-ref-only nodes
249
+ simEdges.forEach(function(e) {
250
+ if (typeof e.source === 'string' && selfRefIds.has(e.source)) {
251
+ var node = selfRefNodes.find(function(n) { return n.id === e.source; });
252
+ e.source = node;
253
+ e.target = node;
254
+ }
255
+ });
256
+
219
257
  return { simNodes: simNodes, simEdges: simEdges };
220
258
  }
221
259
 
260
+ function layoutOrphans() {
261
+ if (orphanNodes.length === 0) return;
262
+
263
+ // Find connected graph bounds
264
+ var minY = Infinity, minX = Infinity, maxX = -Infinity;
265
+ connectedNodes.forEach(function(n) {
266
+ var top = n.y - n._height / 2;
267
+ if (top < minY) minY = top;
268
+ if (n.x - NODE_WIDTH / 2 < minX) minX = n.x - NODE_WIDTH / 2;
269
+ if (n.x + NODE_WIDTH / 2 > maxX) maxX = n.x + NODE_WIDTH / 2;
270
+ });
271
+
272
+ // Defaults when no connected nodes
273
+ if (minY === Infinity) minY = svgEl.clientHeight / 2;
274
+ if (minX === Infinity) minX = svgEl.clientWidth / 2 - NODE_WIDTH;
275
+ if (maxX === -Infinity) maxX = svgEl.clientWidth / 2 + NODE_WIDTH;
276
+
277
+ var centerX = (minX + maxX) / 2;
278
+ var gap = 20;
279
+ var rowGap = 30;
280
+
281
+ // Lay out orphans into rows (wrap when exceeding connected graph width)
282
+ var maxRowWidth = Math.max(maxX - minX, NODE_WIDTH * 3);
283
+ var rows = [[]];
284
+ var rowWidths = [0];
285
+
286
+ orphanNodes.forEach(function(n) {
287
+ var lastRow = rows[rows.length - 1];
288
+ var lastWidth = rowWidths[rows.length - 1];
289
+ var addedWidth = (lastRow.length > 0 ? gap : 0) + NODE_WIDTH;
290
+
291
+ if (lastRow.length > 0 && lastWidth + addedWidth > maxRowWidth) {
292
+ rows.push([n]);
293
+ rowWidths.push(NODE_WIDTH);
294
+ } else {
295
+ lastRow.push(n);
296
+ rowWidths[rows.length - 1] = lastWidth + addedWidth;
297
+ }
298
+ });
299
+
300
+ // Position rows above the diagram, bottom row first (closest to graph)
301
+ var currentY = minY - rowGap;
302
+ for (var r = rows.length - 1; r >= 0; r--) {
303
+ var row = rows[r];
304
+ var rowHeight = 0;
305
+ row.forEach(function(n) { if (n._height > rowHeight) rowHeight = n._height; });
306
+
307
+ currentY -= rowHeight / 2;
308
+ var startX = centerX - rowWidths[r] / 2 + NODE_WIDTH / 2;
309
+
310
+ row.forEach(function(n, i) {
311
+ n.x = startX + i * (NODE_WIDTH + gap);
312
+ n.y = currentY;
313
+ });
314
+
315
+ currentY -= rowHeight / 2 + rowGap;
316
+ }
317
+
318
+ }
319
+
320
+ function layoutSelfRefNodes() {
321
+ if (selfRefNodes.length === 0) return;
322
+
323
+ var minX = Infinity, minY = Infinity, maxY = -Infinity;
324
+ connectedNodes.forEach(function(n) {
325
+ if (n.x - NODE_WIDTH / 2 < minX) minX = n.x - NODE_WIDTH / 2;
326
+ if (n.y - n._height / 2 < minY) minY = n.y - n._height / 2;
327
+ if (n.y + n._height / 2 > maxY) maxY = n.y + n._height / 2;
328
+ });
329
+
330
+ if (minX === Infinity) minX = svgEl.clientWidth / 2;
331
+ if (minY === Infinity) minY = svgEl.clientHeight / 2 - 100;
332
+ if (maxY === -Infinity) maxY = svgEl.clientHeight / 2 + 100;
333
+
334
+ var gap = 20;
335
+ var colGap = 40;
336
+ var centerY = (minY + maxY) / 2;
337
+
338
+ var totalHeight = 0;
339
+ selfRefNodes.forEach(function(n) { totalHeight += n._height; });
340
+ totalHeight += (selfRefNodes.length - 1) * gap;
341
+
342
+ var currentY = centerY - totalHeight / 2;
343
+ var x = minX - NODE_WIDTH - colGap;
344
+
345
+ selfRefNodes.forEach(function(n) {
346
+ n.x = x;
347
+ n.y = currentY + n._height / 2;
348
+ currentY += n._height + gap;
349
+ });
350
+ }
351
+
352
+ function truncateText(text, maxWidth, font) {
353
+ var canvas = document.createElement("canvas");
354
+ var ctx = canvas.getContext("2d");
355
+ ctx.font = font + " " + getComputedStyle(document.body).fontFamily;
356
+ if (ctx.measureText(text).width <= maxWidth) return text;
357
+ var truncated = text;
358
+ while (truncated.length > 0 && ctx.measureText(truncated + "…").width > maxWidth) {
359
+ truncated = truncated.slice(0, -1);
360
+ }
361
+ return truncated + "…";
362
+ }
363
+
222
364
  // Render
223
365
  var currentSim;
224
366
 
@@ -324,7 +466,7 @@
324
466
  .attr("y", function(d) { return -d._height / 2 + 16; })
325
467
  .attr("text-anchor", "middle")
326
468
  .attr("dominant-baseline", "central")
327
- .text(function(d) { return d.id; });
469
+ .text(function(d) { return truncateText(d.id, NODE_WIDTH - NODE_PADDING * 2, "bold 13px"); });
328
470
 
329
471
  // Table name
330
472
  nGroups.append("text")
@@ -333,7 +475,7 @@
333
475
  .attr("y", function(d) { return -d._height / 2 + 30; })
334
476
  .attr("text-anchor", "middle")
335
477
  .attr("dominant-baseline", "central")
336
- .text(function(d) { return d.table_name; });
478
+ .text(function(d) { return truncateText(d.table_name, NODE_WIDTH - NODE_PADDING * 2, "10px"); });
337
479
 
338
480
  // Columns
339
481
  nGroups.each(function(d) {
@@ -369,6 +511,8 @@
369
511
  }
370
512
 
371
513
  function ticked() {
514
+ layoutOrphans();
515
+ layoutSelfRefNodes();
372
516
  edgeGroup.selectAll(".edge-group").each(function(d) {
373
517
  var g = d3.select(this);
374
518
  var src = d.source;
@@ -487,18 +631,30 @@
487
631
  }
488
632
 
489
633
  // Drag behavior
634
+ function isOrphan(d) {
635
+ return orphanNodes.indexOf(d) !== -1 || selfRefNodes.indexOf(d) !== -1;
636
+ }
637
+
490
638
  function dragStarted(event, d) {
639
+ if (isOrphan(d)) return;
491
640
  if (!event.active) simulation.alphaTarget(0.3).restart();
492
641
  d.fx = d.x;
493
642
  d.fy = d.y;
494
643
  }
495
644
 
496
645
  function dragged(event, d) {
646
+ if (isOrphan(d)) {
647
+ d.x = event.x;
648
+ d.y = event.y;
649
+ ticked();
650
+ return;
651
+ }
497
652
  d.fx = event.x;
498
653
  d.fy = event.y;
499
654
  }
500
655
 
501
656
  function dragEnded(event, d) {
657
+ if (isOrphan(d)) return;
502
658
  if (!event.active) simulation.alphaTarget(0);
503
659
  d.fx = null;
504
660
  d.fy = null;
@@ -559,10 +715,11 @@
559
715
 
560
716
  var nodeEdges = edges.filter(function(e) { return e.from === nodeId || e.to === nodeId; });
561
717
 
562
- var html = '<div style="position:relative;">';
718
+ var html = '<div class="detail-header">';
719
+ html += '<h2 title="' + escapeHtml(node.id) + '">' + escapeHtml(node.id) + '</h2>';
563
720
  html += '<button id="detail-close" onclick="window.__closeDetail()">&times;</button>';
564
- html += '<h2>' + escapeHtml(node.id) + '</h2>';
565
- html += '<div class="detail-table">' + escapeHtml(node.table_name) + '</div>';
721
+ html += '</div>';
722
+ html += '<div class="detail-table" title="' + escapeHtml(node.table_name) + '">' + escapeHtml(node.table_name) + '</div>';
566
723
 
567
724
  html += '<h3>Columns</h3>';
568
725
  html += '<ul class="column-list">';
@@ -587,7 +744,6 @@
587
744
  html += '</ul>';
588
745
  }
589
746
 
590
- html += '</div>';
591
747
  content.innerHTML = html;
592
748
 
593
749
  // Click association targets to navigate
@@ -769,5 +925,5 @@
769
925
  render();
770
926
 
771
927
  // Fit after simulation settles
772
- setTimeout(fitToScreen, 1500);
928
+ setTimeout(function() { fitToScreen(); }, 1500);
773
929
  })();
@@ -375,19 +375,35 @@ body {
375
375
 
376
376
  #detail-content {
377
377
  padding: 16px;
378
+ padding-top: calc(var(--toolbar-height) + 16px);
378
379
  width: var(--detail-width);
379
380
  }
380
381
 
382
+ .detail-header {
383
+ display: flex;
384
+ align-items: center;
385
+ position: relative;
386
+ padding-right: 28px;
387
+ }
388
+
381
389
  #detail-content h2 {
390
+ flex: 1;
391
+ min-width: 0;
382
392
  font-size: 18px;
383
393
  font-weight: 700;
384
394
  margin-bottom: 4px;
395
+ overflow: hidden;
396
+ text-overflow: ellipsis;
397
+ white-space: nowrap;
385
398
  }
386
399
 
387
400
  #detail-content .detail-table {
388
401
  font-size: 12px;
389
402
  color: var(--text-secondary);
390
403
  margin-bottom: 16px;
404
+ overflow: hidden;
405
+ text-overflow: ellipsis;
406
+ white-space: nowrap;
391
407
  }
392
408
 
393
409
  #detail-content h3 {
@@ -456,15 +472,16 @@ body {
456
472
  }
457
473
 
458
474
  #detail-close {
459
- position: absolute;
460
- top: 12px;
461
- right: 12px;
462
475
  background: none;
463
476
  border: none;
464
477
  color: var(--text-secondary);
465
478
  cursor: pointer;
466
479
  font-size: 18px;
467
480
  padding: 4px;
481
+ flex-shrink: 0;
482
+ position: absolute;
483
+ top: -8px;
484
+ right: 0;
468
485
  }
469
486
 
470
487
  /* Legend */
@@ -6,7 +6,7 @@ module Rails
6
6
  class ModelScanner
7
7
  def initialize(configuration: ::Rails::Schema.configuration, schema_data: nil)
8
8
  @configuration = configuration
9
- @schema_data = schema_data
9
+ @schema_data = schema_data.nil? || schema_data.empty? ? nil : schema_data
10
10
  end
11
11
 
12
12
  def scan
@@ -43,7 +43,7 @@ module Rails
43
43
  else
44
44
  loader.eager_load
45
45
  end
46
- rescue StandardError => e
46
+ rescue StandardError, LoadError => e
47
47
  warn "[rails-schema] Zeitwerk eager_load failed (#{e.class}: #{e.message}), " \
48
48
  "trying Rails.application.eager_load!"
49
49
  eager_load_via_application!
@@ -51,7 +51,7 @@ module Rails
51
51
 
52
52
  def eager_load_via_application!
53
53
  ::Rails.application.eager_load!
54
- rescue StandardError => e
54
+ rescue StandardError, LoadError => e
55
55
  warn "[rails-schema] eager_load! failed (#{e.class}: #{e.message}), " \
56
56
  "falling back to per-file model loading"
57
57
  eager_load_model_files!
@@ -63,9 +63,9 @@ module Rails
63
63
  models_path = ::Rails.root.join("app", "models")
64
64
  return unless models_path.exist?
65
65
 
66
- Dir.glob(models_path.join("**/*.rb")).each do |file|
66
+ Dir.glob(models_path.join("**/*.rb")).sort.each do |file|
67
67
  require file
68
- rescue StandardError => e
68
+ rescue StandardError, LoadError => e
69
69
  warn "[rails-schema] Could not load #{file}: #{e.class}: #{e.message}"
70
70
  end
71
71
  end
@@ -23,9 +23,9 @@ module Rails
23
23
  model.respond_to?(method, include_private) || super
24
24
  end
25
25
 
26
- def method_missing(method, ...)
26
+ def method_missing(method, *args, **kwargs, &block)
27
27
  if model.respond_to?(method)
28
- model.send(method, ...)
28
+ model.send(method, *args, **kwargs, &block)
29
29
  else
30
30
  super
31
31
  end
@@ -67,7 +67,7 @@ module Rails
67
67
  models_path = ::Rails.root.join("app", "models")
68
68
  return unless models_path.exist?
69
69
 
70
- Dir.glob(models_path.join("**/*.rb")).each do |file|
70
+ Dir.glob(models_path.join("**/*.rb")).sort.each do |file|
71
71
  require file
72
72
  rescue StandardError => e
73
73
  warn "[rails-schema] Could not load #{file}: #{e.class}: #{e.message}"
@@ -111,7 +111,7 @@ module Rails
111
111
 
112
112
  def eager_load_engine_files(paths)
113
113
  paths.each do |path|
114
- Dir.glob(File.join(path, "**/*.rb")).each do |file|
114
+ Dir.glob(File.join(path, "**/*.rb")).sort.each do |file|
115
115
  require file
116
116
  rescue StandardError => e
117
117
  warn "[rails-schema] Could not load engine model #{file}: #{e.class}: #{e.message}"
@@ -19,9 +19,9 @@ module Rails
19
19
  }.freeze
20
20
 
21
21
  COMPOUND_TYPE_RE = /\A(character\s+varying|bit\s+varying|double\s+precision|
22
- timestamp(?:\(\d+\))?\s+with(?:out)?\s+time\s+zone)/ix
23
- CONSTRAINT_RE = /\A(CONSTRAINT|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY)\b/i
24
- PK_CONSTRAINT_RE = /PRIMARY\s+KEY\s*\(([^)]+)\)/i
22
+ timestamp(?:\(\d+\))?\s+with(?:out)?\s+time\s+zone)/ix.freeze
23
+ CONSTRAINT_RE = /\A(CONSTRAINT|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY)\b/i.freeze
24
+ PK_CONSTRAINT_RE = /PRIMARY\s+KEY\s*\(([^)]+)\)/i.freeze
25
25
 
26
26
  def initialize(structure_path = nil)
27
27
  @structure_path = structure_path
@@ -56,7 +56,9 @@ module Rails
56
56
  File.join(Dir.pwd, "db", "structure.sql")
57
57
  end
58
58
 
59
- def unquote(identifier) = identifier.delete('"')
59
+ def unquote(identifier)
60
+ identifier.delete('"')
61
+ end
60
62
 
61
63
  def extract_table_name(raw)
62
64
  unquote(raw).split(".").last
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Rails
4
6
  module Schema
5
7
  module Transformer
@@ -10,9 +12,12 @@ module Rails
10
12
  end
11
13
 
12
14
  def build(models)
13
- node_names = models.to_set(&:name)
14
- nodes = models.map { |m| build_node(m) }
15
- edges = models.flat_map { |m| build_edges(m, node_names) }
15
+ model_ids = assign_unique_ids(models)
16
+ name_to_id = {}
17
+ model_ids.each { |m, uid| name_to_id[m.name] ||= uid }
18
+
19
+ nodes = model_ids.map { |m, uid| build_node(m, uid) }
20
+ edges = model_ids.flat_map { |m, uid| build_edges(m, uid, name_to_id) }
16
21
 
17
22
  {
18
23
  nodes: nodes.map(&:to_h),
@@ -23,21 +28,29 @@ module Rails
23
28
 
24
29
  private
25
30
 
26
- def build_node(model)
31
+ def assign_unique_ids(models)
32
+ counts = models.group_by(&:name).transform_values(&:size)
33
+ models.map do |m|
34
+ uid = counts[m.name] > 1 ? "#{m.name} (#{m.table_name})" : m.name
35
+ [m, uid]
36
+ end
37
+ end
38
+
39
+ def build_node(model, unique_id)
27
40
  Node.new(
28
- id: model.name,
41
+ id: unique_id,
29
42
  table_name: model.table_name,
30
43
  columns: @column_reader.read(model)
31
44
  )
32
45
  end
33
46
 
34
- def build_edges(model, node_names)
47
+ def build_edges(model, unique_id, name_to_id)
35
48
  @association_reader.read(model).filter_map do |assoc|
36
- next unless node_names.include?(assoc[:to])
49
+ next unless name_to_id.key?(assoc[:to])
37
50
 
38
51
  Edge.new(
39
- from: assoc[:from],
40
- to: assoc[:to],
52
+ from: unique_id,
53
+ to: name_to_id[assoc[:to]],
41
54
  association_type: assoc[:association_type],
42
55
  label: assoc[:label],
43
56
  foreign_key: assoc[:foreign_key],
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module Schema
5
- VERSION = "0.1.3"
5
+ VERSION = "0.1.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Kislichenko
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-03-08 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activerecord
@@ -15,28 +16,42 @@ dependencies:
15
16
  requirements:
16
17
  - - ">="
17
18
  - !ruby/object:Gem::Version
18
- version: '6.0'
19
+ version: '5.2'
19
20
  type: :runtime
20
21
  prerelease: false
21
22
  version_requirements: !ruby/object:Gem::Requirement
22
23
  requirements:
23
24
  - - ">="
24
25
  - !ruby/object:Gem::Version
25
- version: '6.0'
26
+ version: '5.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: psych
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '4.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '4.0'
26
41
  - !ruby/object:Gem::Dependency
27
42
  name: railties
28
43
  requirement: !ruby/object:Gem::Requirement
29
44
  requirements:
30
45
  - - ">="
31
46
  - !ruby/object:Gem::Version
32
- version: '6.0'
47
+ version: '5.2'
33
48
  type: :runtime
34
49
  prerelease: false
35
50
  version_requirements: !ruby/object:Gem::Requirement
36
51
  requirements:
37
52
  - - ">="
38
53
  - !ruby/object:Gem::Version
39
- version: '6.0'
54
+ version: '5.2'
40
55
  description: Introspects a Rails app's models, associations, and columns, then generates
41
56
  a single self-contained HTML file with an interactive entity-relationship diagram.
42
57
  email:
@@ -48,7 +63,6 @@ files:
48
63
  - CHANGELOG.md
49
64
  - CLAUDE.md
50
65
  - LICENSE.txt
51
- - PROJECT.md
52
66
  - README.md
53
67
  - Rakefile
54
68
  - docs/index.html
@@ -75,14 +89,15 @@ files:
75
89
  - lib/rails/schema/transformer/node.rb
76
90
  - lib/rails/schema/version.rb
77
91
  - sig/rails/schema.rbs
78
- homepage: https://github.com/nicholaides/rails-schema
92
+ homepage: https://github.com/andrew2net/rails-schema
79
93
  licenses:
80
94
  - MIT
81
95
  metadata:
82
- homepage_uri: https://github.com/nicholaides/rails-schema
83
- source_code_uri: https://github.com/nicholaides/rails-schema
84
- changelog_uri: https://github.com/nicholaides/rails-schema/blob/main/CHANGELOG.md
96
+ homepage_uri: https://github.com/andrew2net/rails-schema
97
+ source_code_uri: https://github.com/andrew2net/rails-schema
98
+ changelog_uri: https://github.com/andrew2net/rails-schema/blob/main/CHANGELOG.md
85
99
  rubygems_mfa_required: 'true'
100
+ post_install_message:
86
101
  rdoc_options: []
87
102
  require_paths:
88
103
  - lib
@@ -90,14 +105,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
90
105
  requirements:
91
106
  - - ">="
92
107
  - !ruby/object:Gem::Version
93
- version: 3.2.0
108
+ version: 2.7.0
94
109
  required_rubygems_version: !ruby/object:Gem::Requirement
95
110
  requirements:
96
111
  - - ">="
97
112
  - !ruby/object:Gem::Version
98
113
  version: '0'
99
114
  requirements: []
100
- rubygems_version: 3.6.9
115
+ rubygems_version: 3.1.6
116
+ signing_key:
101
117
  specification_version: 4
102
118
  summary: Interactive HTML visualization of your Rails database schema
103
119
  test_files: []
data/PROJECT.md DELETED
@@ -1,367 +0,0 @@
1
- # Rails::Schema — Project Design
2
-
3
- **A Ruby gem that generates an interactive HTML/JS/CSS page to visualize the database schema of a Rails application.**
4
-
5
- ---
6
-
7
- ## 1. Gem Overview
8
-
9
- **Name:** `rails-schema`
10
- **Module:** `Rails::Schema`
11
- **Version:** `0.1.2`
12
-
13
- Rails::Schema introspects a Rails app's models, associations, and database columns at runtime, then generates a single self-contained HTML file with an interactive, explorable entity-relationship diagram. No external server, no SaaS dependency — just one command and a browser.
14
-
15
- ```bash
16
- # Rake task
17
- rake rails_schema:generate
18
-
19
- # Programmatic
20
- Rails::Schema.generate(output: "docs/schema.html")
21
- ```
22
-
23
- ---
24
-
25
- ## 2. Architecture
26
-
27
- ```
28
- ┌──────────────────────────────────────────────────────┐
29
- │ rails-schema gem │
30
- ├──────────────┬──────────────┬────────────────────────┤
31
- │ Extractor │ Transformer │ Renderer │
32
- │ (Ruby) │ (Ruby) │ (ERB → HTML/JS/CSS) │
33
- ├──────────────┼──────────────┼────────────────────────┤
34
- │ Reads Rails │ Builds a │ Produces a single │
35
- │ models, │ normalized │ self-contained .html │
36
- │ reflections, │ graph JSON │ file with embedded │
37
- │ schema.rb, │ structure │ JS app + CSS │
38
- │ columns │ │ │
39
- └──────────────┴──────────────┴────────────────────────┘
40
- ```
41
-
42
- ### 2.1 Layer Breakdown
43
-
44
- | Layer | Responsibility | Key Classes |
45
- |---|---|---|
46
- | **Extractor** | Introspects Rails environment; collects models, columns, associations | `Rails::Schema::Extractor::ModelScanner`, `ColumnReader`, `AssociationReader`, `SchemaFileParser`, `StructureSqlParser`, plus `Mongoid::ModelScanner`, `Mongoid::ModelAdapter`, `Mongoid::ColumnReader`, `Mongoid::AssociationReader` |
47
- | **Transformer** | Normalizes extracted data into a serializable graph structure (nodes + edges + metadata) | `Rails::Schema::Transformer::GraphBuilder`, `Node`, `Edge` |
48
- | **Renderer** | Takes the graph data and injects it into an HTML/JS/CSS template using ERB | `Rails::Schema::Renderer::HtmlGenerator` |
49
- | **Railtie** | Provides the `rails_schema:generate` rake task | `Rails::Schema::Railtie` |
50
-
51
- ### 2.2 Generation Pipeline
52
-
53
- ```ruby
54
- def generate(output: nil)
55
- schema_data = parse_schema
56
- models = Extractor::ModelScanner.new(schema_data: schema_data).scan
57
- column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
58
- graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
59
- generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
60
- generator.render_to_file(output)
61
- end
62
-
63
- def parse_schema
64
- case configuration.schema_format
65
- when :ruby then Extractor::SchemaFileParser.new.parse
66
- when :sql then Extractor::StructureSqlParser.new.parse
67
- when :auto
68
- data = Extractor::SchemaFileParser.new.parse
69
- data.empty? ? Extractor::StructureSqlParser.new.parse : data
70
- end
71
- end
72
- ```
73
-
74
- ---
75
-
76
- ## 3. Data Extraction Strategy
77
-
78
- ### 3.1 Sources of Truth
79
-
80
- 1. **`db/schema.rb` parsing** — `SchemaFileParser` parses the schema file line-by-line with regex to extract table names, column definitions (name, type, nullable, default), and primary key info. This is attempted first and used as a fast, database-free source.
81
- 2. **`db/structure.sql` parsing** — `StructureSqlParser` parses SQL `CREATE TABLE` statements for projects using `config.active_record.schema_format = :sql`. Maps SQL types to Rails-friendly types, detects `NOT NULL`, `DEFAULT` values, and primary keys. Handles schema-qualified names (`public.users`), timestamp precision (`timestamp(6)`), and both quoted and unquoted identifiers.
82
- 3. **ActiveRecord reflection API** — `AssociationReader` uses `Model.reflect_on_all_associations` for associations (`has_many`, `belongs_to`, `has_one`, `has_and_belongs_to_many`), including `:through` and `:polymorphic`.
83
- 4. **`Model.columns`** — `ColumnReader` falls back to `model.columns` via ActiveRecord when a table is not found in schema_data.
84
-
85
- ### 3.2 Model Discovery
86
-
87
- `ModelScanner` (ActiveRecord) discovers models by:
88
-
89
- 1. Calling `Rails.application.eager_load!` (with Zeitwerk support and multiple fallback strategies)
90
- 2. Collecting `ActiveRecord::Base.descendants`
91
- 3. Filtering out abstract classes, anonymous classes, and models without known tables
92
- 4. Applying `exclude_models` configuration (supports wildcard prefix matching like `"ActiveStorage::*"`)
93
- 5. Returning models sorted by name
94
-
95
- When `schema_data` is available, table existence is checked against parsed schema data instead of hitting the database.
96
-
97
- `Mongoid::ModelScanner` discovers Mongoid models by:
98
-
99
- 1. Eager-loading models via Zeitwerk or file glob (with fallback strategies)
100
- 2. Scanning `ObjectSpace` for classes that include `Mongoid::Document`
101
- 3. Also eager-loads models from mounted Rails engines
102
- 4. Applying `exclude_models` configuration
103
- 5. Returning models sorted by name, wrapped in `ModelAdapter` for GraphBuilder compatibility
104
-
105
- ### 3.3 Schema File Parser
106
-
107
- `SchemaFileParser` provides database-free column extraction:
108
-
109
- - Parses `create_table` blocks from `db/schema.rb`
110
- - Extracts column types, names, nullability, and defaults (string, numeric, boolean)
111
- - Handles custom primary key types (`id: :uuid`, `id: :bigint`) and `id: false`
112
- - Skips index definitions
113
-
114
- ### 3.4 Structure SQL Parser
115
-
116
- `StructureSqlParser` provides database-free column extraction from SQL dumps:
117
-
118
- - Parses `CREATE TABLE` statements from `db/structure.sql`
119
- - Maps SQL types to Rails types (e.g. `character varying` → `string`, `bigint` → `bigint`, `timestamp without time zone` → `datetime`)
120
- - Handles schema-qualified table names (`public.users` → `users`)
121
- - Handles timestamp precision (`timestamp(6) without time zone`)
122
- - Detects primary keys from `CONSTRAINT ... PRIMARY KEY` and inline `PRIMARY KEY`
123
- - Extracts `NOT NULL`, `DEFAULT` values (strings, numbers, booleans)
124
- - Skips constraint lines (`CONSTRAINT`, `UNIQUE`, `CHECK`, `FOREIGN KEY`, etc.)
125
-
126
- ### 3.5 Intermediate Data Format (JSON Graph)
127
-
128
- ```json
129
- {
130
- "nodes": [
131
- {
132
- "id": "User",
133
- "table": "users",
134
- "columns": [
135
- { "name": "id", "type": "bigint", "primary": true },
136
- { "name": "email", "type": "string", "nullable": false },
137
- { "name": "name", "type": "string", "nullable": true }
138
- ]
139
- }
140
- ],
141
- "edges": [
142
- {
143
- "from": "User",
144
- "to": "Post",
145
- "type": "has_many",
146
- "through": null,
147
- "foreign_key": "user_id",
148
- "polymorphic": false,
149
- "label": "posts"
150
- }
151
- ],
152
- "metadata": {
153
- "rails_version": "7.2.0",
154
- "generated_at": "2026-02-15T12:00:00Z",
155
- "model_count": 42
156
- }
157
- }
158
- ```
159
-
160
- ---
161
-
162
- ## 4. Interactive Frontend Design
163
-
164
- The generated HTML file is a **single self-contained file** — no CDN dependencies, no network requests. All JS and CSS are inlined. The JSON graph is embedded as a `<script>` tag.
165
-
166
- ### 4.1 Technology Choices
167
-
168
- | Concern | Choice | Rationale |
169
- |---|---|---|
170
- | Graph rendering | **SVG + d3-force** (vendored/minified) | DOM-level interactivity, good for typical schema sizes |
171
- | Layout algorithm | Force-directed (d3-force) | Natural clustering of related models |
172
- | UI framework | Vanilla JS | Zero dependencies, small file size |
173
- | Styling | CSS custom properties + embedded stylesheet | Theming support, dark/light mode |
174
-
175
- ### 4.2 Implemented Interactive Features
176
-
177
- #### A. Model Selector Panel (left sidebar, 280px)
178
-
179
- - Searchable list of all models with filtering
180
- - Multi-select checkboxes to toggle visibility
181
- - Model count display
182
-
183
- #### B. Canvas / Diagram Area (center)
184
-
185
- - **Nodes** = model cards showing:
186
- - Model name (bold header)
187
- - Column list (expandable/collapsible — collapsed by default)
188
- - Primary key highlighted
189
- - Column types shown in a muted typeface
190
- - **Edges** = association lines:
191
- - Color-coded by association type
192
- - Labels on hover (association name + foreign key)
193
- - **Force-directed layout** that stabilizes, then allows manual drag-and-drop
194
-
195
- #### C. Zoom & Navigation
196
-
197
- - Scroll-wheel zoom with smooth interpolation
198
- - Pinch-to-zoom on trackpads
199
- - Fit-to-screen button
200
- - Zoom-to-selection (click a model in sidebar to center on it)
201
-
202
- #### D. Focus Mode
203
-
204
- When a user clicks on a model node:
205
-
206
- 1. The selected model and its directly associated models are highlighted
207
- 2. All other nodes and edges fade to reduced opacity
208
- 3. A detail panel (right sidebar, 320px) shows full column/association info
209
- 4. Press `Esc` or click background to exit
210
-
211
- #### E. Toolbar (48px)
212
-
213
- - Dark / Light theme toggle (respects `prefers-color-scheme`)
214
- - Fit-to-screen button
215
- - Keyboard shortcuts: `/` to focus search, `Esc` to deselect
216
-
217
- ---
218
-
219
- ## 5. Configuration
220
-
221
- ```ruby
222
- # config/initializers/rails_schema.rb
223
- Rails::Schema.configure do |config|
224
- config.output_path = "docs/schema.html" # Output file location
225
- config.exclude_models = [] # Models to exclude (supports "Namespace::*" wildcards)
226
- config.title = "Database Schema" # Page title
227
- config.theme = :auto # :light, :dark, :auto
228
- config.expand_columns = false # Start with columns expanded
229
- config.schema_format = :auto # :auto, :ruby, :sql, or :mongoid
230
- end
231
- ```
232
-
233
- ---
234
-
235
- ## 6. Gem Structure
236
-
237
- ```
238
- rails-schema/
239
- ├── lib/
240
- │ ├── rails/schema.rb # Entry point, configuration DSL, generate method
241
- │ └── rails/schema/
242
- │ ├── version.rb # VERSION = "0.1.2"
243
- │ ├── configuration.rb # Config object (6 attributes)
244
- │ ├── railtie.rb # Rails integration, rake task
245
- │ ├── extractor/
246
- │ │ ├── model_scanner.rb # Discovers AR models
247
- │ │ ├── association_reader.rb # Reads reflections
248
- │ │ ├── column_reader.rb # Reads columns (schema_data or AR)
249
- │ │ ├── schema_file_parser.rb # Parses db/schema.rb
250
- │ │ ├── structure_sql_parser.rb # Parses db/structure.sql
251
- │ │ └── mongoid/
252
- │ │ ├── model_scanner.rb # Discovers Mongoid models
253
- │ │ ├── model_adapter.rb # Wraps Mongoid model for GraphBuilder
254
- │ │ ├── column_reader.rb # Reads Mongoid fields
255
- │ │ └── association_reader.rb # Reads Mongoid relations
256
- │ ├── transformer/
257
- │ │ ├── graph_builder.rb # Builds node/edge graph
258
- │ │ ├── node.rb # Value object
259
- │ │ └── edge.rb # Value object
260
- │ ├── renderer/
261
- │ │ └── html_generator.rb # ERB rendering, asset inlining
262
- │ └── assets/
263
- │ ├── template.html.erb # Main HTML template
264
- │ ├── app.js # Interactive frontend (vanilla JS)
265
- │ ├── style.css # Stylesheet with CSS custom properties
266
- │ └── vendor/
267
- │ └── d3.min.js # Vendored d3 library
268
- ├── spec/
269
- │ ├── spec_helper.rb
270
- │ ├── support/
271
- │ │ ├── test_models.rb # User, Post, Comment, Tag AR models
272
- │ │ └── mongoid_test_models.rb # MongoidUser, MongoidPost, MongoidComment
273
- │ └── rails/schema/
274
- │ ├── rails_schema_spec.rb
275
- │ ├── configuration_spec.rb
276
- │ ├── extractor/
277
- │ │ ├── model_scanner_spec.rb
278
- │ │ ├── column_reader_spec.rb
279
- │ │ ├── association_reader_spec.rb
280
- │ │ ├── schema_file_parser_spec.rb
281
- │ │ ├── structure_sql_parser_spec.rb
282
- │ │ └── mongoid/
283
- │ │ ├── model_scanner_spec.rb
284
- │ │ ├── model_adapter_spec.rb
285
- │ │ ├── column_reader_spec.rb
286
- │ │ └── association_reader_spec.rb
287
- │ ├── transformer/
288
- │ │ └── graph_builder_spec.rb
289
- │ └── renderer/
290
- │ └── html_generator_spec.rb
291
- ├── Gemfile
292
- ├── rails-schema.gemspec
293
- ├── LICENSE.txt
294
- └── README.md
295
- ```
296
-
297
- ---
298
-
299
- ## 7. Key Design Decisions
300
-
301
- ### Why a single HTML file?
302
-
303
- - **Zero deployment friction** — open in any browser, share via Slack/email, commit to repo
304
- - **Offline-first** — works on airplane mode, no CDN failures
305
- - **Portable** — CI can generate it, GitHub Pages can host it, anyone can view it
306
-
307
- ### Why not a mounted Rails engine?
308
-
309
- A mounted engine requires a running server. A static file can be generated in CI, committed to the repo, and opened by anyone — including non-developers looking at a data model.
310
-
311
- ### Why parse schema.rb / structure.sql?
312
-
313
- Parsing `db/schema.rb` or `db/structure.sql` allows column extraction without a database connection. This means the gem can work in CI environments or development setups where the database isn't running. It also avoids eager-loading the entire app just to read column metadata. The `schema_format: :auto` default tries `schema.rb` first, then falls back to `structure.sql`, so the gem works out of the box regardless of which format a project uses.
314
-
315
- ### Why force-directed layout?
316
-
317
- It handles unknown schemas gracefully — you don't need to pre-define positions. Combined with drag-and-drop repositioning, it gives the best default experience.
318
-
319
- ---
320
-
321
- ## 8. Dependencies
322
-
323
- ```ruby
324
- # rails-schema.gemspec
325
- spec.add_dependency "activerecord", ">= 6.0"
326
- spec.add_dependency "railties", ">= 6.0"
327
-
328
- # Development
329
- # rspec (~> 3.0), rubocop (~> 1.21), sqlite3
330
- ```
331
-
332
- **Zero runtime JS dependencies shipped to the user** — d3 is vendored and minified into the template. The HTML file has no external requests.
333
-
334
- ---
335
-
336
- ## 9. Testing Strategy
337
-
338
- | Layer | Approach |
339
- |---|---|
340
- | Extractor | Unit tests with in-memory SQLite models (User, Post, Comment, Tag); rescue-path warnings tested via `output(...).to_stderr` |
341
- | Transformer | Pure Ruby unit tests — graph building, edge filtering |
342
- | Renderer | Output tests — verify HTML structure, embedded data, script injection safety |
343
- | Configuration | Unit tests for defaults and attribute setting |
344
-
345
- **153 tests, all passing.** Run with `bundle exec rspec`.
346
-
347
- ---
348
-
349
- ## 10. Future Enhancements (Roadmap)
350
-
351
- 1. **CLI executable** — `bundle exec rails_schema` binary for standalone usage
352
- 2. **Live mode** — a mounted Rails engine with hot-reload when migrations run
353
- 3. **Additional layout modes** — hierarchical, circular, grid
354
- 4. **Validation extraction** — read `Model.validators` for presence, uniqueness constraints
355
- 5. **STI handling** — group models sharing a table, show children as badges
356
- 6. **Concern extraction** — display included modules on model nodes
357
- 7. **Export options** — PNG, SVG, Mermaid ER diagram, raw JSON
358
- 8. **Schema diff** — compare two generated JSONs and highlight changes
359
- 9. **Multi-database support** — Rails 6+ multi-DB configs
360
- 10. **Minimap** — thumbnail overview for large schemas
361
- 11. **Permalink / State URL** — encode view state in URL hash for sharing
362
- 12. **Advanced filtering** — `include_only`, namespace grouping, tag-based filters
363
- 13. **Custom CSS/JS injection** — user-provided assets inlined into output
364
-
365
- ---
366
-
367
- *Document reflects the current implementation (v0.1.2). Future enhancements are aspirational and subject to refinement.*