rails-schema 0.1.6 → 0.1.8
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 +19 -0
- data/CLAUDE.md +7 -2
- data/README.md +5 -1
- data/docs/index.html +270 -9
- data/lib/rails/schema/assets/app.js +227 -9
- data/lib/rails/schema/assets/style.css +82 -0
- data/lib/rails/schema/assets/template.html.erb +11 -1
- data/lib/rails/schema/extractor/column_reader.rb +17 -2
- data/lib/rails/schema/extractor/schema_file_parser.rb +8 -0
- data/lib/rails/schema/extractor/structure_sql_parser.rb +96 -2
- data/lib/rails/schema/transformer/graph_builder.rb +4 -2
- data/lib/rails/schema/transformer/node.rb +4 -2
- data/lib/rails/schema/version.rb +1 -1
- data/lib/rails/schema.rb +14 -7
- metadata +1 -1
|
@@ -23,6 +23,27 @@
|
|
|
23
23
|
showThroughEdges = this.checked;
|
|
24
24
|
render();
|
|
25
25
|
if (selectedNode) selectNode(selectedNode);
|
|
26
|
+
autoSave();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Freeze layout toggle — when enabled, all simulation forces are removed so
|
|
31
|
+
// nodes stay wherever they are dropped without being pushed by the simulation.
|
|
32
|
+
var manualPositioningCheckbox = document.getElementById("manual-positioning");
|
|
33
|
+
function removeAllForces() {
|
|
34
|
+
["link", "charge", "center", "collide", "x", "y", "group"].forEach(function(name) {
|
|
35
|
+
simulation.force(name, null);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (manualPositioningCheckbox) {
|
|
39
|
+
manualPositioningCheckbox.addEventListener("change", function () {
|
|
40
|
+
if (this.checked) {
|
|
41
|
+
removeAllForces();
|
|
42
|
+
} else {
|
|
43
|
+
render();
|
|
44
|
+
if (selectedNode) selectNode(selectedNode);
|
|
45
|
+
}
|
|
46
|
+
autoSave();
|
|
26
47
|
});
|
|
27
48
|
}
|
|
28
49
|
|
|
@@ -37,6 +58,114 @@
|
|
|
37
58
|
var NODE_HEADER_HEIGHT = 36;
|
|
38
59
|
var NODE_COLUMN_HEIGHT = 18;
|
|
39
60
|
var NODE_PADDING = 8;
|
|
61
|
+
var VIEW_BADGE_WIDTH = 34;
|
|
62
|
+
|
|
63
|
+
// Layout persistence
|
|
64
|
+
function computeFingerprint() {
|
|
65
|
+
var ids = nodes.map(function(n) { return n.id; }).sort().join(",");
|
|
66
|
+
var hash = 5381;
|
|
67
|
+
for (var i = 0; i < ids.length; i++) {
|
|
68
|
+
hash = ((hash << 5) + hash + ids.charCodeAt(i)) & 0xffffffff;
|
|
69
|
+
}
|
|
70
|
+
return hash.toString(16);
|
|
71
|
+
}
|
|
72
|
+
var layoutStorageKey = "rails-schema-layout:" + computeFingerprint();
|
|
73
|
+
|
|
74
|
+
function serializeLayout() {
|
|
75
|
+
var positions = {};
|
|
76
|
+
nodes.forEach(function(n) {
|
|
77
|
+
if (n.x !== undefined && n.y !== undefined) {
|
|
78
|
+
positions[n.id] = { x: n.x, y: n.y };
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
version: 1,
|
|
83
|
+
schemaFingerprint: computeFingerprint(),
|
|
84
|
+
nodePositions: positions,
|
|
85
|
+
visibleModels: Array.from(visibleModels),
|
|
86
|
+
zoomTransform: { k: zoomTransform.k, x: zoomTransform.x, y: zoomTransform.y },
|
|
87
|
+
collapsedGroups: Array.from(collapsedGroups),
|
|
88
|
+
manualPositioning: manualPositioningCheckbox ? manualPositioningCheckbox.checked : false,
|
|
89
|
+
showThroughEdges: showThroughEdges
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applyLayout(layout) {
|
|
94
|
+
if (!layout || layout.version !== 1) return;
|
|
95
|
+
|
|
96
|
+
var nodeIdSet = new Set(nodes.map(function(n) { return n.id; }));
|
|
97
|
+
|
|
98
|
+
// Restore visibility
|
|
99
|
+
if (layout.visibleModels) {
|
|
100
|
+
visibleModels.clear();
|
|
101
|
+
layout.visibleModels.forEach(function(id) {
|
|
102
|
+
if (nodeIdSet.has(id)) visibleModels.add(id);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Restore collapsed groups
|
|
107
|
+
collapsedGroups.clear();
|
|
108
|
+
if (layout.collapsedGroups) {
|
|
109
|
+
layout.collapsedGroups.forEach(function(g) { collapsedGroups.add(g); });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Restore through edges
|
|
113
|
+
if (layout.showThroughEdges !== undefined) {
|
|
114
|
+
showThroughEdges = layout.showThroughEdges;
|
|
115
|
+
if (throughCheckbox) throughCheckbox.checked = showThroughEdges;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Rebuild with restored visibility
|
|
119
|
+
buildSidebar();
|
|
120
|
+
render();
|
|
121
|
+
|
|
122
|
+
var manual = !!layout.manualPositioning;
|
|
123
|
+
|
|
124
|
+
// Apply positions AFTER render created the simulation nodes. Pin with fx/fy
|
|
125
|
+
// only when restoring into manual mode; otherwise seed x/y as initial
|
|
126
|
+
// conditions and let the simulation evolve from there.
|
|
127
|
+
if (layout.nodePositions) {
|
|
128
|
+
nodes.forEach(function(n) {
|
|
129
|
+
var saved = layout.nodePositions[n.id];
|
|
130
|
+
if (saved) {
|
|
131
|
+
n.x = saved.x;
|
|
132
|
+
n.y = saved.y;
|
|
133
|
+
if (manual) {
|
|
134
|
+
n.fx = saved.x;
|
|
135
|
+
n.fy = saved.y;
|
|
136
|
+
} else {
|
|
137
|
+
n.fx = null;
|
|
138
|
+
n.fy = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (manualPositioningCheckbox) {
|
|
145
|
+
manualPositioningCheckbox.checked = manual;
|
|
146
|
+
}
|
|
147
|
+
if (manual) {
|
|
148
|
+
removeAllForces();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Update display
|
|
152
|
+
ticked();
|
|
153
|
+
|
|
154
|
+
// Restore zoom
|
|
155
|
+
if (layout.zoomTransform) {
|
|
156
|
+
var t = layout.zoomTransform;
|
|
157
|
+
var transform = d3.zoomIdentity.translate(t.x, t.y).scale(t.k);
|
|
158
|
+
svg.call(zoom.transform, transform);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function autoSave() {
|
|
163
|
+
try {
|
|
164
|
+
localStorage.setItem(layoutStorageKey, JSON.stringify(serializeLayout()));
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// localStorage full or unavailable — silently ignore
|
|
167
|
+
}
|
|
168
|
+
}
|
|
40
169
|
|
|
41
170
|
// Bezier curve tuning for edge routing
|
|
42
171
|
var MIN_LOOP_OFFSET = 60; // Minimum control-point distance for same-side (loop) edges
|
|
@@ -287,7 +416,8 @@
|
|
|
287
416
|
.force("charge", d3.forceManyBody().strength(-600))
|
|
288
417
|
.force("center", d3.forceCenter(svgEl.clientWidth / 2, svgEl.clientHeight / 2))
|
|
289
418
|
.force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 + 50; }))
|
|
290
|
-
.on("tick", ticked)
|
|
419
|
+
.on("tick", ticked)
|
|
420
|
+
.on("end", autoSave);
|
|
291
421
|
|
|
292
422
|
if (config.grouping_enabled) {
|
|
293
423
|
simulation
|
|
@@ -491,6 +621,9 @@
|
|
|
491
621
|
currentSim = setupSimulation();
|
|
492
622
|
renderEdges(currentSim.simEdges);
|
|
493
623
|
renderNodes(currentSim.simNodes);
|
|
624
|
+
if (manualPositioningCheckbox && manualPositioningCheckbox.checked) {
|
|
625
|
+
removeAllForces();
|
|
626
|
+
}
|
|
494
627
|
}
|
|
495
628
|
|
|
496
629
|
function renderEdges(simEdges) {
|
|
@@ -575,7 +708,7 @@
|
|
|
575
708
|
|
|
576
709
|
// Background rect
|
|
577
710
|
nGroups.append("rect")
|
|
578
|
-
.attr("class", "node-rect")
|
|
711
|
+
.attr("class", function(d) { return "node-rect" + (d.view ? " is-view" : ""); })
|
|
579
712
|
.attr("width", NODE_WIDTH)
|
|
580
713
|
.attr("height", function(d) { return d._height; })
|
|
581
714
|
.attr("x", -NODE_WIDTH / 2)
|
|
@@ -623,6 +756,24 @@
|
|
|
623
756
|
.attr("dominant-baseline", "central")
|
|
624
757
|
.text(function(d) { return truncateText(d.table_name, NODE_WIDTH - NODE_PADDING * 2, "10px " + getComputedStyle(document.body).fontFamily); });
|
|
625
758
|
|
|
759
|
+
// VIEW badge — a pill floating just above the node, centered, for view-backed models
|
|
760
|
+
var viewNodes = nGroups.filter(function(d) { return d.view; });
|
|
761
|
+
viewNodes.append("rect")
|
|
762
|
+
.attr("class", "node-view-badge-bg")
|
|
763
|
+
.attr("width", VIEW_BADGE_WIDTH)
|
|
764
|
+
.attr("height", 13)
|
|
765
|
+
.attr("rx", 3)
|
|
766
|
+
.attr("ry", 3)
|
|
767
|
+
.attr("x", -VIEW_BADGE_WIDTH / 2)
|
|
768
|
+
.attr("y", function(d) { return -d._height / 2 - 16; });
|
|
769
|
+
viewNodes.append("text")
|
|
770
|
+
.attr("class", "node-view-badge")
|
|
771
|
+
.attr("x", 0)
|
|
772
|
+
.attr("y", function(d) { return -d._height / 2 - 9; })
|
|
773
|
+
.attr("text-anchor", "middle")
|
|
774
|
+
.attr("dominant-baseline", "central")
|
|
775
|
+
.text("VIEW");
|
|
776
|
+
|
|
626
777
|
// Columns
|
|
627
778
|
nGroups.each(function(d) {
|
|
628
779
|
var g = d3.select(this);
|
|
@@ -669,8 +820,10 @@
|
|
|
669
820
|
}
|
|
670
821
|
|
|
671
822
|
function ticked() {
|
|
672
|
-
|
|
673
|
-
|
|
823
|
+
if (!manualPositioningCheckbox?.checked) {
|
|
824
|
+
layoutOrphans();
|
|
825
|
+
layoutSelfRefNodes();
|
|
826
|
+
}
|
|
674
827
|
edgeGroup.selectAll(".edge-group").each(function(d) {
|
|
675
828
|
var g = d3.select(this);
|
|
676
829
|
var src = d.source;
|
|
@@ -820,8 +973,11 @@
|
|
|
820
973
|
function dragEnded(event, d) {
|
|
821
974
|
if (isOrphan(d)) return;
|
|
822
975
|
if (!event.active) simulation.alphaTarget(0);
|
|
823
|
-
|
|
824
|
-
|
|
976
|
+
if (!manualPositioningCheckbox?.checked) {
|
|
977
|
+
d.fx = null;
|
|
978
|
+
d.fy = null;
|
|
979
|
+
}
|
|
980
|
+
autoSave();
|
|
825
981
|
}
|
|
826
982
|
|
|
827
983
|
// Zoom
|
|
@@ -831,6 +987,7 @@
|
|
|
831
987
|
zoomTransform = event.transform;
|
|
832
988
|
container.attr("transform", event.transform);
|
|
833
989
|
document.getElementById("zoom-info").textContent = Math.round(event.transform.k * 100) + "%";
|
|
990
|
+
autoSave();
|
|
834
991
|
});
|
|
835
992
|
|
|
836
993
|
svg.call(zoom);
|
|
@@ -870,6 +1027,7 @@
|
|
|
870
1027
|
render();
|
|
871
1028
|
selectNode(nodeId);
|
|
872
1029
|
setTimeout(fitToScreen, 100);
|
|
1030
|
+
autoSave();
|
|
873
1031
|
}
|
|
874
1032
|
|
|
875
1033
|
function deselectNode() {
|
|
@@ -895,7 +1053,9 @@
|
|
|
895
1053
|
html += '<h2 title="' + escapeHtml(node.id) + '">' + escapeHtml(node.id) + '</h2>';
|
|
896
1054
|
html += '<button id="detail-close" onclick="window.__closeDetail()">×</button>';
|
|
897
1055
|
html += '</div>';
|
|
898
|
-
html += '<div class="detail-table" title="' + escapeHtml(node.table_name) + '">' + escapeHtml(node.table_name)
|
|
1056
|
+
html += '<div class="detail-table" title="' + escapeHtml(node.table_name) + '">' + escapeHtml(node.table_name);
|
|
1057
|
+
if (node.view) html += ' <span class="detail-view-tag">VIEW</span>';
|
|
1058
|
+
html += '</div>';
|
|
899
1059
|
|
|
900
1060
|
html += '<h3>Columns</h3>';
|
|
901
1061
|
html += '<ul class="column-list">';
|
|
@@ -979,6 +1139,7 @@
|
|
|
979
1139
|
buildSidebar();
|
|
980
1140
|
render();
|
|
981
1141
|
setTimeout(fitToScreen, 100);
|
|
1142
|
+
autoSave();
|
|
982
1143
|
});
|
|
983
1144
|
|
|
984
1145
|
div.addEventListener("click", function(e) {
|
|
@@ -1039,6 +1200,7 @@
|
|
|
1039
1200
|
buildSidebar();
|
|
1040
1201
|
render();
|
|
1041
1202
|
setTimeout(fitToScreen, 100);
|
|
1203
|
+
autoSave();
|
|
1042
1204
|
});
|
|
1043
1205
|
|
|
1044
1206
|
var clickTimer = null;
|
|
@@ -1057,6 +1219,7 @@
|
|
|
1057
1219
|
var nowCollapsed = collapsedGroups.has(groupKey);
|
|
1058
1220
|
content.style.display = nowCollapsed ? "none" : "";
|
|
1059
1221
|
toggle.innerHTML = nowCollapsed ? "▶" : "▼";
|
|
1222
|
+
autoSave();
|
|
1060
1223
|
}, 250);
|
|
1061
1224
|
});
|
|
1062
1225
|
|
|
@@ -1073,6 +1236,7 @@
|
|
|
1073
1236
|
buildSidebar();
|
|
1074
1237
|
render();
|
|
1075
1238
|
setTimeout(fitToScreen, 100);
|
|
1239
|
+
autoSave();
|
|
1076
1240
|
});
|
|
1077
1241
|
|
|
1078
1242
|
return header;
|
|
@@ -1180,6 +1344,7 @@
|
|
|
1180
1344
|
buildSidebar();
|
|
1181
1345
|
render();
|
|
1182
1346
|
setTimeout(fitToScreen, 100);
|
|
1347
|
+
autoSave();
|
|
1183
1348
|
});
|
|
1184
1349
|
|
|
1185
1350
|
document.getElementById("deselect-all-btn").addEventListener("click", function() {
|
|
@@ -1187,6 +1352,7 @@
|
|
|
1187
1352
|
buildSidebar();
|
|
1188
1353
|
render();
|
|
1189
1354
|
setTimeout(fitToScreen, 100);
|
|
1355
|
+
autoSave();
|
|
1190
1356
|
});
|
|
1191
1357
|
|
|
1192
1358
|
// Collapse/Expand all groups
|
|
@@ -1221,6 +1387,7 @@
|
|
|
1221
1387
|
document.querySelectorAll(".group-toggle").forEach(function(el) {
|
|
1222
1388
|
el.innerHTML = shouldExpand ? "\u25BC" : "\u25B6";
|
|
1223
1389
|
});
|
|
1390
|
+
autoSave();
|
|
1224
1391
|
});
|
|
1225
1392
|
}
|
|
1226
1393
|
|
|
@@ -1372,13 +1539,64 @@
|
|
|
1372
1539
|
triggerDownload(blob, "schema-diagram.mmd");
|
|
1373
1540
|
}
|
|
1374
1541
|
|
|
1542
|
+
// File menu dropdown
|
|
1543
|
+
var fileMenuBtn = document.getElementById("file-menu-btn");
|
|
1544
|
+
var fileMenu = document.getElementById("file-menu");
|
|
1545
|
+
fileMenuBtn.addEventListener("click", function(e) {
|
|
1546
|
+
e.stopPropagation();
|
|
1547
|
+
fileMenu.classList.toggle("open");
|
|
1548
|
+
});
|
|
1549
|
+
document.addEventListener("click", function() {
|
|
1550
|
+
fileMenu.classList.remove("open");
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1375
1553
|
// Export button listener
|
|
1376
1554
|
document.getElementById("export-mermaid-btn").addEventListener("click", function() { exportMermaid(); });
|
|
1377
1555
|
|
|
1556
|
+
// Save Layout
|
|
1557
|
+
document.getElementById("save-layout-btn").addEventListener("click", function() {
|
|
1558
|
+
var layout = serializeLayout();
|
|
1559
|
+
var blob = new Blob([JSON.stringify(layout, null, 2)], { type: "application/json" });
|
|
1560
|
+
var title = document.title.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-");
|
|
1561
|
+
triggerDownload(blob, title + "-layout.json");
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
// Load Layout
|
|
1565
|
+
var loadLayoutInput = document.getElementById("load-layout-input");
|
|
1566
|
+
document.getElementById("load-layout-btn").addEventListener("click", function() {
|
|
1567
|
+
loadLayoutInput.click();
|
|
1568
|
+
});
|
|
1569
|
+
loadLayoutInput.addEventListener("change", function() {
|
|
1570
|
+
var file = this.files[0];
|
|
1571
|
+
if (!file) return;
|
|
1572
|
+
var reader = new FileReader();
|
|
1573
|
+
reader.onload = function(e) {
|
|
1574
|
+
try {
|
|
1575
|
+
var layout = JSON.parse(e.target.result);
|
|
1576
|
+
applyLayout(layout);
|
|
1577
|
+
autoSave();
|
|
1578
|
+
} catch (err) {
|
|
1579
|
+
console.error("Failed to load layout:", err);
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
reader.readAsText(file);
|
|
1583
|
+
this.value = "";
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1378
1586
|
// Init
|
|
1379
1587
|
buildSidebar();
|
|
1380
1588
|
render();
|
|
1381
1589
|
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1590
|
+
// Restore saved layout or fit to screen
|
|
1591
|
+
var savedLayout = null;
|
|
1592
|
+
try {
|
|
1593
|
+
var stored = localStorage.getItem(layoutStorageKey);
|
|
1594
|
+
if (stored) savedLayout = JSON.parse(stored);
|
|
1595
|
+
} catch (e) { /* ignore */ }
|
|
1596
|
+
|
|
1597
|
+
if (savedLayout) {
|
|
1598
|
+
applyLayout(savedLayout);
|
|
1599
|
+
} else {
|
|
1600
|
+
setTimeout(function() { fitToScreen(); }, 1500);
|
|
1601
|
+
}
|
|
1384
1602
|
})();
|
|
@@ -142,11 +142,65 @@ body {
|
|
|
142
142
|
margin: 0 4px;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
#toolbar label.toolbar-label {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 4px;
|
|
149
|
+
font-size: 13px;
|
|
150
|
+
color: var(--text-secondary);
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#toolbar label.toolbar-label input[type="checkbox"] {
|
|
156
|
+
margin: 0;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
accent-color: var(--accent);
|
|
159
|
+
}
|
|
160
|
+
|
|
145
161
|
#toolbar .shortcuts-hint {
|
|
146
162
|
font-size: 11px;
|
|
147
163
|
color: var(--text-muted);
|
|
148
164
|
}
|
|
149
165
|
|
|
166
|
+
.toolbar-dropdown {
|
|
167
|
+
position: relative;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.toolbar-dropdown-menu {
|
|
171
|
+
display: none;
|
|
172
|
+
position: absolute;
|
|
173
|
+
top: 100%;
|
|
174
|
+
right: 0;
|
|
175
|
+
margin-top: 4px;
|
|
176
|
+
background: var(--bg-primary);
|
|
177
|
+
border: 1px solid var(--border-color);
|
|
178
|
+
border-radius: 6px;
|
|
179
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
180
|
+
z-index: 300;
|
|
181
|
+
min-width: 160px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.toolbar-dropdown-menu.open {
|
|
185
|
+
display: flex;
|
|
186
|
+
flex-direction: column;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.toolbar-dropdown-menu button {
|
|
190
|
+
border: none;
|
|
191
|
+
border-radius: 0;
|
|
192
|
+
text-align: left;
|
|
193
|
+
padding: 8px 12px;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.toolbar-dropdown-menu button:first-child {
|
|
197
|
+
border-radius: 6px 6px 0 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.toolbar-dropdown-menu button:last-of-type {
|
|
201
|
+
border-radius: 0 0 6px 6px;
|
|
202
|
+
}
|
|
203
|
+
|
|
150
204
|
/* Sidebar */
|
|
151
205
|
#sidebar {
|
|
152
206
|
width: var(--sidebar-width);
|
|
@@ -310,6 +364,22 @@ body {
|
|
|
310
364
|
stroke-width: 2.5;
|
|
311
365
|
}
|
|
312
366
|
|
|
367
|
+
.node-rect.is-view {
|
|
368
|
+
stroke-dasharray: 5 3;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.node-view-badge-bg {
|
|
372
|
+
fill: var(--accent);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.node-view-badge {
|
|
376
|
+
fill: #ffffff;
|
|
377
|
+
font-size: 8px;
|
|
378
|
+
font-weight: 700;
|
|
379
|
+
letter-spacing: 0.5px;
|
|
380
|
+
font-family: inherit;
|
|
381
|
+
}
|
|
382
|
+
|
|
313
383
|
.node-header-rect {
|
|
314
384
|
fill: var(--node-header-bg);
|
|
315
385
|
rx: 8;
|
|
@@ -450,6 +520,18 @@ body {
|
|
|
450
520
|
white-space: nowrap;
|
|
451
521
|
}
|
|
452
522
|
|
|
523
|
+
#detail-content .detail-view-tag {
|
|
524
|
+
display: inline-block;
|
|
525
|
+
font-size: 9px;
|
|
526
|
+
font-weight: 700;
|
|
527
|
+
letter-spacing: 0.5px;
|
|
528
|
+
color: var(--node-header-text);
|
|
529
|
+
background: var(--accent);
|
|
530
|
+
border-radius: 3px;
|
|
531
|
+
padding: 1px 5px;
|
|
532
|
+
vertical-align: middle;
|
|
533
|
+
}
|
|
534
|
+
|
|
453
535
|
#detail-content h3 {
|
|
454
536
|
font-size: 13px;
|
|
455
537
|
font-weight: 600;
|
|
@@ -42,7 +42,17 @@
|
|
|
42
42
|
<button id="fit-btn" title="Fit to screen (F)">Fit</button>
|
|
43
43
|
<button id="theme-btn" title="Toggle theme">Theme</button>
|
|
44
44
|
<span class="toolbar-divider"></span>
|
|
45
|
-
<
|
|
45
|
+
<label class="toolbar-label" for="manual-positioning"><input type="checkbox" id="manual-positioning">Manual Positioning</label>
|
|
46
|
+
<span class="toolbar-divider"></span>
|
|
47
|
+
<div class="toolbar-dropdown">
|
|
48
|
+
<button id="file-menu-btn">File ▼</button>
|
|
49
|
+
<div class="toolbar-dropdown-menu" id="file-menu">
|
|
50
|
+
<button id="export-mermaid-btn">Export Mermaid</button>
|
|
51
|
+
<button id="save-layout-btn">Save Layout</button>
|
|
52
|
+
<button id="load-layout-btn">Load Layout</button>
|
|
53
|
+
<input type="file" id="load-layout-input" accept=".json" style="display:none">
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
46
56
|
<span class="shortcuts-hint">/ search · Esc deselect · F fit</span>
|
|
47
57
|
</div>
|
|
48
58
|
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module Rails
|
|
4
6
|
module Schema
|
|
5
7
|
module Extractor
|
|
6
8
|
class ColumnReader
|
|
7
|
-
def initialize(schema_data: nil)
|
|
9
|
+
def initialize(schema_data: nil, view_tables: nil)
|
|
8
10
|
@schema_data = schema_data
|
|
11
|
+
@view_tables = view_tables ? Set.new(view_tables) : Set.new
|
|
9
12
|
end
|
|
10
13
|
|
|
11
14
|
def read(model)
|
|
12
|
-
if @
|
|
15
|
+
if @view_tables.include?(model.table_name)
|
|
16
|
+
read_view(model)
|
|
17
|
+
elsif @schema_data&.key?(model.table_name)
|
|
13
18
|
@schema_data[model.table_name]
|
|
14
19
|
else
|
|
15
20
|
read_from_model(model)
|
|
@@ -18,6 +23,16 @@ module Rails
|
|
|
18
23
|
|
|
19
24
|
private
|
|
20
25
|
|
|
26
|
+
# Views have no column types in their DDL, so prefer live DB
|
|
27
|
+
# introspection (accurate names + types) and fall back to the parsed
|
|
28
|
+
# column names when no database connection is available.
|
|
29
|
+
def read_view(model)
|
|
30
|
+
from_db = read_from_model(model)
|
|
31
|
+
return from_db unless from_db.empty?
|
|
32
|
+
|
|
33
|
+
@schema_data&.fetch(model.table_name, []) || []
|
|
34
|
+
end
|
|
35
|
+
|
|
21
36
|
def read_from_model(model)
|
|
22
37
|
model.columns.map do |col|
|
|
23
38
|
{
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module Rails
|
|
4
6
|
module Schema
|
|
5
7
|
module Extractor
|
|
6
8
|
class SchemaFileParser
|
|
9
|
+
# schema.rb does not declare SQL views, so this is always empty; the
|
|
10
|
+
# reader exists so SchemaFileParser and StructureSqlParser share an
|
|
11
|
+
# interface (see Rails::Schema.parse_schema).
|
|
12
|
+
attr_reader :views
|
|
13
|
+
|
|
7
14
|
def initialize(schema_path = nil)
|
|
8
15
|
@schema_path = schema_path
|
|
16
|
+
@views = Set.new
|
|
9
17
|
end
|
|
10
18
|
|
|
11
19
|
def parse
|
|
@@ -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 Extractor
|
|
@@ -23,8 +25,14 @@ module Rails
|
|
|
23
25
|
CONSTRAINT_RE = /\A(CONSTRAINT|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY)\b/i.freeze
|
|
24
26
|
PK_CONSTRAINT_RE = /PRIMARY\s+KEY\s*\(([^)]+)\)/i.freeze
|
|
25
27
|
|
|
28
|
+
# Set of table names that are backed by SQL views (CREATE VIEW), not
|
|
29
|
+
# real tables. Populated by #parse / #parse_content. Lets downstream
|
|
30
|
+
# code badge view-backed models on the diagram.
|
|
31
|
+
attr_reader :views
|
|
32
|
+
|
|
26
33
|
def initialize(structure_path = nil)
|
|
27
34
|
@structure_path = structure_path
|
|
35
|
+
@views = Set.new
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
def parse
|
|
@@ -36,6 +44,7 @@ module Rails
|
|
|
36
44
|
|
|
37
45
|
def parse_content(content)
|
|
38
46
|
tables = {}
|
|
47
|
+
@views = Set.new
|
|
39
48
|
|
|
40
49
|
content.scan(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."]+)\s*\((.*?)\)\s*;/mi) do |table_name, body|
|
|
41
50
|
name = extract_table_name(table_name)
|
|
@@ -44,11 +53,52 @@ module Rails
|
|
|
44
53
|
tables[name] = columns
|
|
45
54
|
end
|
|
46
55
|
|
|
56
|
+
parse_views(content, tables)
|
|
47
57
|
tables
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
private
|
|
51
61
|
|
|
62
|
+
# Views carry no column-type information in their DDL, so offline we can
|
|
63
|
+
# only recover column names (from the SQLite hint comment or the SELECT
|
|
64
|
+
# list). ColumnReader prefers live DB introspection for views when a
|
|
65
|
+
# connection is available; these names are the offline fallback.
|
|
66
|
+
def parse_views(content, tables)
|
|
67
|
+
content.scan(/CREATE\s+VIEW\s+([\w."]+)\s+AS\b(.*?);/mi) do |raw_name, body|
|
|
68
|
+
name = extract_table_name(raw_name)
|
|
69
|
+
@views << name
|
|
70
|
+
tables[name] = view_columns(name, body)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def view_columns(name, body)
|
|
75
|
+
view_column_names(name, body).map do |col_name|
|
|
76
|
+
{ name: col_name, type: "", nullable: true, default: nil, primary: false }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def view_column_names(name, body)
|
|
81
|
+
if (match = body.match(%r{/\*\s*#{Regexp.escape(name)}\s*\(([^)]*)\)\s*\*/}))
|
|
82
|
+
match[1].split(",").map { |c| unquote(c.strip) }
|
|
83
|
+
else
|
|
84
|
+
select_list_aliases(body)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def select_list_aliases(body)
|
|
89
|
+
match = body.match(/\bSELECT\b(.*?)\bFROM\b/mi)
|
|
90
|
+
return [] unless match
|
|
91
|
+
|
|
92
|
+
split_columns(match[1]).filter_map { |item| column_alias(item) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def column_alias(item)
|
|
96
|
+
item = strip_comments(item).strip
|
|
97
|
+
return nil if item.empty? || item.include?("*")
|
|
98
|
+
|
|
99
|
+
item[/\bAS\s+"?(\w+)"?\s*\z/i, 1] || item[/"?(\w+)"?\s*\z/, 1]
|
|
100
|
+
end
|
|
101
|
+
|
|
52
102
|
def resolve_path
|
|
53
103
|
return @structure_path if @structure_path
|
|
54
104
|
return ::Rails.root.join("db", "structure.sql").to_s if defined?(::Rails.root) && ::Rails.root
|
|
@@ -67,8 +117,8 @@ module Rails
|
|
|
67
117
|
def parse_table_body(body)
|
|
68
118
|
columns = []
|
|
69
119
|
pk_columns = []
|
|
70
|
-
body.
|
|
71
|
-
line =
|
|
120
|
+
split_columns(body).each do |segment|
|
|
121
|
+
line = segment.strip
|
|
72
122
|
next if line.empty?
|
|
73
123
|
|
|
74
124
|
if (pk = extract_pk_constraint(line))
|
|
@@ -81,6 +131,50 @@ module Rails
|
|
|
81
131
|
[columns, pk_columns]
|
|
82
132
|
end
|
|
83
133
|
|
|
134
|
+
# Splits a table body on top-level commas, ignoring commas inside
|
|
135
|
+
# parentheses (e.g. decimal(5,4), FK clauses) or quoted strings. This
|
|
136
|
+
# handles both PostgreSQL (one column per line) and SQLite (whole table
|
|
137
|
+
# on a single line) structure.sql dumps.
|
|
138
|
+
def split_columns(body)
|
|
139
|
+
segments = []
|
|
140
|
+
current = +""
|
|
141
|
+
state = { depth: 0, squote: false, dquote: false }
|
|
142
|
+
strip_comments(body).each_char do |ch|
|
|
143
|
+
if split_point?(ch, state)
|
|
144
|
+
segments << current
|
|
145
|
+
current = +""
|
|
146
|
+
else
|
|
147
|
+
current << ch
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
segments << current
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def split_point?(char, state)
|
|
154
|
+
toggle_quote(char, state)
|
|
155
|
+
return false if quoted?(state)
|
|
156
|
+
|
|
157
|
+
case char
|
|
158
|
+
when "(" then state[:depth] += 1
|
|
159
|
+
when ")" then state[:depth] -= 1
|
|
160
|
+
when "," then return state[:depth].zero?
|
|
161
|
+
end
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def toggle_quote(char, state)
|
|
166
|
+
state[:squote] = !state[:squote] if char == "'" && !state[:dquote]
|
|
167
|
+
state[:dquote] = !state[:dquote] if char == '"' && !state[:squote]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def quoted?(state)
|
|
171
|
+
state[:squote] || state[:dquote]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def strip_comments(sql)
|
|
175
|
+
sql.gsub(%r{/\*.*?\*/}m, "")
|
|
176
|
+
end
|
|
177
|
+
|
|
84
178
|
def extract_pk_constraint(line)
|
|
85
179
|
return unless (match = line.match(PK_CONSTRAINT_RE))
|
|
86
180
|
|