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 +4 -4
- data/CHANGELOG.md +23 -0
- data/CLAUDE.md +62 -3
- data/README.md +8 -1
- data/lib/rails/schema/assets/app.js +165 -9
- data/lib/rails/schema/assets/style.css +20 -3
- data/lib/rails/schema/extractor/model_scanner.rb +5 -5
- data/lib/rails/schema/extractor/mongoid/model_adapter.rb +2 -2
- data/lib/rails/schema/extractor/mongoid/model_scanner.rb +2 -2
- data/lib/rails/schema/extractor/structure_sql_parser.rb +6 -4
- data/lib/rails/schema/transformer/graph_builder.rb +22 -9
- data/lib/rails/schema/version.rb +1 -1
- metadata +29 -13
- data/PROJECT.md +0 -367
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71fa755a65ac8dcdb27f419857dd5ede16a13d4c26e5b9a40bc7e8ec1f41d36b
|
|
4
|
+
data.tar.gz: 4940bf9f24e51d6caf3e5e70526f7a2ed8d097c352e99920e6191212c1d6137e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
22
|
-
- `lib/rails/schema/transformer/` — builds normalized graph JSON
|
|
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 >=
|
|
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
|

|
|
@@ -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(
|
|
213
|
-
.force("link", d3.forceLink(
|
|
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
|
|
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()">×</button>';
|
|
564
|
-
html += '
|
|
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)
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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:
|
|
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,
|
|
47
|
+
def build_edges(model, unique_id, name_to_id)
|
|
35
48
|
@association_reader.read(model).filter_map do |assoc|
|
|
36
|
-
next unless
|
|
49
|
+
next unless name_to_id.key?(assoc[:to])
|
|
37
50
|
|
|
38
51
|
Edge.new(
|
|
39
|
-
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],
|
data/lib/rails/schema/version.rb
CHANGED
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.
|
|
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:
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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/
|
|
92
|
+
homepage: https://github.com/andrew2net/rails-schema
|
|
79
93
|
licenses:
|
|
80
94
|
- MIT
|
|
81
95
|
metadata:
|
|
82
|
-
homepage_uri: https://github.com/
|
|
83
|
-
source_code_uri: https://github.com/
|
|
84
|
-
changelog_uri: https://github.com/
|
|
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:
|
|
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
|
|
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.*
|