rails-schema 0.1.0 → 0.1.2
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 +35 -0
- data/PROJECT.md +41 -15
- data/README.md +22 -2
- data/docs/index.html +252 -16
- data/docs/screenshot.png +0 -0
- data/lib/rails/schema/assets/app.js +229 -11
- data/lib/rails/schema/assets/style.css +22 -0
- data/lib/rails/schema/assets/template.html.erb +6 -4
- data/lib/rails/schema/configuration.rb +2 -1
- data/lib/rails/schema/extractor/association_reader.rb +15 -4
- data/lib/rails/schema/extractor/column_reader.rb +2 -1
- data/lib/rails/schema/extractor/structure_sql_parser.rb +131 -0
- data/lib/rails/schema/version.rb +1 -1
- data/lib/rails/schema.rb +16 -1
- metadata +3 -1
data/docs/screenshot.png
CHANGED
|
Binary file
|
|
@@ -16,13 +16,66 @@
|
|
|
16
16
|
var NODE_COLUMN_HEIGHT = 18;
|
|
17
17
|
var NODE_PADDING = 8;
|
|
18
18
|
|
|
19
|
+
// Bezier curve tuning for edge routing
|
|
20
|
+
var MIN_LOOP_OFFSET = 60; // Minimum control-point distance for same-side (loop) edges
|
|
21
|
+
var LOOP_OFFSET_RATIO = 0.5; // Proportion of vertical gap used for loop curve spread
|
|
22
|
+
var MIN_CURVE_OFFSET = 50; // Minimum control-point distance for opposite-side edges
|
|
23
|
+
var CURVE_OFFSET_RATIO = 0.4; // Proportion of horizontal gap used for curve spread
|
|
24
|
+
|
|
19
25
|
// Setup SVG
|
|
20
26
|
var svgEl = document.getElementById("schema-svg");
|
|
21
27
|
var svg = d3.select(svgEl);
|
|
28
|
+
var defs = svg.append("defs");
|
|
22
29
|
var container = svg.append("g").attr("class", "zoom-container");
|
|
23
30
|
var edgeGroup = container.append("g").attr("class", "edges");
|
|
24
31
|
var nodeGroup = container.append("g").attr("class", "nodes");
|
|
25
32
|
|
|
33
|
+
// Marker definitions for crow's foot notation
|
|
34
|
+
var MARKER_TYPES = {
|
|
35
|
+
belongs_to: "--edge-belongs-to",
|
|
36
|
+
has_many: "--edge-has-many",
|
|
37
|
+
has_one: "--edge-has-one",
|
|
38
|
+
has_and_belongs_to_many: "--edge-habtm"
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function buildMarkers() {
|
|
42
|
+
defs.selectAll("marker").remove();
|
|
43
|
+
var cs = getComputedStyle(document.documentElement);
|
|
44
|
+
Object.keys(MARKER_TYPES).forEach(function(type) {
|
|
45
|
+
var color = cs.getPropertyValue(MARKER_TYPES[type]).trim();
|
|
46
|
+
|
|
47
|
+
// "one" marker: perpendicular bar
|
|
48
|
+
var one = defs.append("marker")
|
|
49
|
+
.attr("id", "marker-one-" + type)
|
|
50
|
+
.attr("viewBox", "0 0 4 10")
|
|
51
|
+
.attr("refX", 4).attr("refY", 5)
|
|
52
|
+
.attr("markerWidth", 4).attr("markerHeight", 10)
|
|
53
|
+
.attr("markerUnits", "userSpaceOnUse")
|
|
54
|
+
.attr("orient", "auto-start-reverse");
|
|
55
|
+
one.append("path")
|
|
56
|
+
.attr("d", "M 4 0 L 4 10")
|
|
57
|
+
.attr("stroke", color)
|
|
58
|
+
.attr("stroke-width", 1.5)
|
|
59
|
+
.attr("fill", "none");
|
|
60
|
+
|
|
61
|
+
// "many" marker: crow's foot (three tines)
|
|
62
|
+
var many = defs.append("marker")
|
|
63
|
+
.attr("id", "marker-many-" + type)
|
|
64
|
+
.attr("viewBox", "0 0 10 10")
|
|
65
|
+
.attr("refX", 10).attr("refY", 5)
|
|
66
|
+
.attr("markerWidth", 10).attr("markerHeight", 10)
|
|
67
|
+
.attr("markerUnits", "userSpaceOnUse")
|
|
68
|
+
.attr("orient", "auto-start-reverse");
|
|
69
|
+
many.append("path")
|
|
70
|
+
.attr("d", "M 10 5 L 0 0 M 10 5 L 0 10 M 10 5 L 0 5")
|
|
71
|
+
.attr("stroke", color)
|
|
72
|
+
.attr("stroke-width", 1.5)
|
|
73
|
+
.attr("fill", "none");
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
buildMarkers();
|
|
78
|
+
|
|
26
79
|
// Compute node heights
|
|
27
80
|
nodes.forEach(function(n) {
|
|
28
81
|
var cols = config.expand_columns ? n.columns : n.columns.slice(0, 4);
|
|
@@ -31,6 +84,20 @@
|
|
|
31
84
|
if (!config.expand_columns && n.columns.length > 4) {
|
|
32
85
|
n._height += NODE_COLUMN_HEIGHT; // "+N more" row
|
|
33
86
|
}
|
|
87
|
+
|
|
88
|
+
// Build column y-offset map (from node center)
|
|
89
|
+
n._colYMap = {};
|
|
90
|
+
var startY = -n._height / 2 + NODE_HEADER_HEIGHT + NODE_PADDING;
|
|
91
|
+
cols.forEach(function(col, i) {
|
|
92
|
+
n._colYMap[col.name] = startY + i * NODE_COLUMN_HEIGHT + 10;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Store "+N more" row y-offset for collapsed column fallback
|
|
96
|
+
if (!config.expand_columns && n.columns.length > 4) {
|
|
97
|
+
n._moreRowY = startY + cols.length * NODE_COLUMN_HEIGHT + 10;
|
|
98
|
+
} else {
|
|
99
|
+
n._moreRowY = null;
|
|
100
|
+
}
|
|
34
101
|
});
|
|
35
102
|
|
|
36
103
|
// Build adjacency for focus
|
|
@@ -41,6 +108,76 @@
|
|
|
41
108
|
if (adjacency[e.to]) adjacency[e.to].add(e.from);
|
|
42
109
|
});
|
|
43
110
|
|
|
111
|
+
// Marker assignment per association type
|
|
112
|
+
var MARKER_MAP = {
|
|
113
|
+
belongs_to: { start: "many", end: "one" },
|
|
114
|
+
has_many: { start: "one", end: "many" },
|
|
115
|
+
has_one: { start: "one", end: "one" },
|
|
116
|
+
has_and_belongs_to_many: { start: "many", end: "many" }
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function getColumnY(node, colName) {
|
|
120
|
+
if (colName && node._colYMap[colName] !== undefined) {
|
|
121
|
+
return node._colYMap[colName];
|
|
122
|
+
}
|
|
123
|
+
if (node._moreRowY !== null) return node._moreRowY;
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getPkColumnY(node) {
|
|
128
|
+
for (var i = 0; i < node._displayCols.length; i++) {
|
|
129
|
+
if (node._displayCols[i].primary) {
|
|
130
|
+
return node._colYMap[node._displayCols[i].name];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (node._moreRowY !== null) return node._moreRowY;
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getConnectionPoints(d) {
|
|
138
|
+
var src = d.source;
|
|
139
|
+
var tgt = d.target;
|
|
140
|
+
var assocType = d.data.association_type;
|
|
141
|
+
var fk = d.data.foreign_key;
|
|
142
|
+
var srcColY = 0;
|
|
143
|
+
var tgtColY = 0;
|
|
144
|
+
|
|
145
|
+
if (assocType === "belongs_to") {
|
|
146
|
+
srcColY = getColumnY(src, fk);
|
|
147
|
+
tgtColY = getPkColumnY(tgt);
|
|
148
|
+
} else if (assocType === "has_many" || assocType === "has_one") {
|
|
149
|
+
srcColY = getPkColumnY(src);
|
|
150
|
+
tgtColY = getColumnY(tgt, fk);
|
|
151
|
+
} else {
|
|
152
|
+
srcColY = getPkColumnY(src);
|
|
153
|
+
tgtColY = getPkColumnY(tgt);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
var dx = tgt.x - src.x;
|
|
157
|
+
var srcX, tgtX, sameDirection;
|
|
158
|
+
|
|
159
|
+
if (Math.abs(dx) > NODE_WIDTH) {
|
|
160
|
+
sameDirection = false;
|
|
161
|
+
if (dx > 0) {
|
|
162
|
+
srcX = src.x + NODE_WIDTH / 2;
|
|
163
|
+
tgtX = tgt.x - NODE_WIDTH / 2;
|
|
164
|
+
} else {
|
|
165
|
+
srcX = src.x - NODE_WIDTH / 2;
|
|
166
|
+
tgtX = tgt.x + NODE_WIDTH / 2;
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
sameDirection = true;
|
|
170
|
+
srcX = src.x + NODE_WIDTH / 2;
|
|
171
|
+
tgtX = tgt.x + NODE_WIDTH / 2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
x1: srcX, y1: src.y + srcColY,
|
|
176
|
+
x2: tgtX, y2: tgt.y + tgtColY,
|
|
177
|
+
sameDirection: sameDirection
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
44
181
|
// d3-force simulation
|
|
45
182
|
function setupSimulation() {
|
|
46
183
|
var nodeMap = {};
|
|
@@ -57,10 +194,10 @@
|
|
|
57
194
|
if (simulation) simulation.stop();
|
|
58
195
|
|
|
59
196
|
simulation = d3.forceSimulation(simNodes)
|
|
60
|
-
.force("link", d3.forceLink(simEdges).id(function(d) { return d.id; }).distance(
|
|
61
|
-
.force("charge", d3.forceManyBody().strength(-
|
|
197
|
+
.force("link", d3.forceLink(simEdges).id(function(d) { return d.id; }).distance(320))
|
|
198
|
+
.force("charge", d3.forceManyBody().strength(-600))
|
|
62
199
|
.force("center", d3.forceCenter(svgEl.clientWidth / 2, svgEl.clientHeight / 2))
|
|
63
|
-
.force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 +
|
|
200
|
+
.force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 + 50; }))
|
|
64
201
|
.on("tick", ticked);
|
|
65
202
|
|
|
66
203
|
return { simNodes: simNodes, simEdges: simEdges };
|
|
@@ -78,17 +215,40 @@
|
|
|
78
215
|
function renderEdges(simEdges) {
|
|
79
216
|
edgeGroup.selectAll(".edge-group").remove();
|
|
80
217
|
|
|
218
|
+
// Compute parallel edge offsets for edges between same node pair
|
|
219
|
+
var pairCounts = {};
|
|
220
|
+
simEdges.forEach(function(d) {
|
|
221
|
+
var key = [d.data.from, d.data.to].sort().join("||");
|
|
222
|
+
pairCounts[key] = (pairCounts[key] || 0) + 1;
|
|
223
|
+
});
|
|
224
|
+
var pairSeen = {};
|
|
225
|
+
simEdges.forEach(function(d) {
|
|
226
|
+
var key = [d.data.from, d.data.to].sort().join("||");
|
|
227
|
+
pairSeen[key] = (pairSeen[key] || 0);
|
|
228
|
+
d._pairCount = pairCounts[key];
|
|
229
|
+
d._pairIndex = pairSeen[key];
|
|
230
|
+
pairSeen[key]++;
|
|
231
|
+
});
|
|
232
|
+
|
|
81
233
|
var eGroups = edgeGroup.selectAll(".edge-group")
|
|
82
234
|
.data(simEdges, function(d) { return d.data.from + "-" + d.data.to + "-" + d.data.label; })
|
|
83
235
|
.enter().append("g")
|
|
84
236
|
.attr("class", "edge-group");
|
|
85
237
|
|
|
86
|
-
eGroups.append("
|
|
238
|
+
eGroups.append("path")
|
|
87
239
|
.attr("class", function(d) {
|
|
88
240
|
var cls = "edge-line " + d.data.association_type;
|
|
89
241
|
if (d.data.through) cls += " through";
|
|
90
242
|
if (d.data.polymorphic) cls += " polymorphic-edge";
|
|
91
243
|
return cls;
|
|
244
|
+
})
|
|
245
|
+
.attr("marker-start", function(d) {
|
|
246
|
+
var m = MARKER_MAP[d.data.association_type];
|
|
247
|
+
return m ? "url(#marker-" + m.start + "-" + d.data.association_type + ")" : null;
|
|
248
|
+
})
|
|
249
|
+
.attr("marker-end", function(d) {
|
|
250
|
+
var m = MARKER_MAP[d.data.association_type];
|
|
251
|
+
return m ? "url(#marker-" + m.end + "-" + d.data.association_type + ")" : null;
|
|
92
252
|
});
|
|
93
253
|
|
|
94
254
|
eGroups.append("text")
|
|
@@ -192,14 +352,66 @@
|
|
|
192
352
|
function ticked() {
|
|
193
353
|
edgeGroup.selectAll(".edge-group").each(function(d) {
|
|
194
354
|
var g = d3.select(this);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
355
|
+
var src = d.source;
|
|
356
|
+
var tgt = d.target;
|
|
357
|
+
|
|
358
|
+
// Self-referential association (markers are applied at path creation time via marker-start/marker-end)
|
|
359
|
+
if (src.id === tgt.id) {
|
|
360
|
+
var selfX = src.x + NODE_WIDTH / 2;
|
|
361
|
+
var selfY1 = src.y - 20;
|
|
362
|
+
var selfY2 = src.y + 20;
|
|
363
|
+
var selfOffset = 60;
|
|
364
|
+
g.select("path").attr("d",
|
|
365
|
+
"M " + selfX + " " + selfY1 +
|
|
366
|
+
" C " + (selfX + selfOffset) + " " + selfY1 +
|
|
367
|
+
", " + (selfX + selfOffset) + " " + selfY2 +
|
|
368
|
+
", " + selfX + " " + selfY2);
|
|
369
|
+
g.select("text")
|
|
370
|
+
.attr("x", selfX + selfOffset + 5)
|
|
371
|
+
.attr("y", src.y);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
var connPts = getConnectionPoints(d);
|
|
376
|
+
|
|
377
|
+
// Parallel edge offset
|
|
378
|
+
var parallelOffset = 0;
|
|
379
|
+
if (d._pairCount > 1) {
|
|
380
|
+
parallelOffset = (d._pairIndex - (d._pairCount - 1) / 2) * 8;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var y1 = connPts.y1 + parallelOffset;
|
|
384
|
+
var y2 = connPts.y2 + parallelOffset;
|
|
385
|
+
|
|
386
|
+
// Compute cubic bezier control points
|
|
387
|
+
var cp1x, cp1y, cp2x, cp2y;
|
|
388
|
+
if (connPts.sameDirection) {
|
|
389
|
+
var loopOffset = Math.max(MIN_LOOP_OFFSET, Math.abs(y2 - y1) * LOOP_OFFSET_RATIO);
|
|
390
|
+
cp1x = connPts.x1 + loopOffset;
|
|
391
|
+
cp1y = y1;
|
|
392
|
+
cp2x = connPts.x2 + loopOffset;
|
|
393
|
+
cp2y = y2;
|
|
394
|
+
} else {
|
|
395
|
+
var dx = connPts.x2 - connPts.x1;
|
|
396
|
+
var cpOffset = Math.max(MIN_CURVE_OFFSET, Math.abs(dx) * CURVE_OFFSET_RATIO);
|
|
397
|
+
var dir = Math.sign(dx) || 1;
|
|
398
|
+
cp1x = connPts.x1 + cpOffset * dir;
|
|
399
|
+
cp1y = y1;
|
|
400
|
+
cp2x = connPts.x2 - cpOffset * dir;
|
|
401
|
+
cp2y = y2;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
g.select("path").attr("d",
|
|
405
|
+
"M " + connPts.x1 + " " + y1 +
|
|
406
|
+
" C " + cp1x + " " + cp1y +
|
|
407
|
+
", " + cp2x + " " + cp2y +
|
|
408
|
+
", " + connPts.x2 + " " + y2);
|
|
409
|
+
|
|
410
|
+
var labelX = 0.125 * connPts.x1 + 0.375 * cp1x + 0.375 * cp2x + 0.125 * connPts.x2;
|
|
411
|
+
var labelY = (y1 + y2) / 2;
|
|
200
412
|
g.select("text")
|
|
201
|
-
.attr("x",
|
|
202
|
-
.attr("y",
|
|
413
|
+
.attr("x", labelX)
|
|
414
|
+
.attr("y", labelY);
|
|
203
415
|
});
|
|
204
416
|
|
|
205
417
|
nodeGroup.selectAll(".node-group")
|
|
@@ -417,6 +629,12 @@
|
|
|
417
629
|
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
418
630
|
html.classList.add(isDark ? "light" : "dark");
|
|
419
631
|
}
|
|
632
|
+
buildMarkers();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Rebuild markers when system theme changes
|
|
636
|
+
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function() {
|
|
637
|
+
buildMarkers();
|
|
420
638
|
});
|
|
421
639
|
|
|
422
640
|
function fitToScreen() {
|
|
@@ -310,6 +310,8 @@ body {
|
|
|
310
310
|
.edge-line {
|
|
311
311
|
fill: none;
|
|
312
312
|
stroke-width: 1.5;
|
|
313
|
+
stroke-linecap: round;
|
|
314
|
+
stroke-linejoin: round;
|
|
313
315
|
}
|
|
314
316
|
|
|
315
317
|
.edge-line.belongs_to { stroke: var(--edge-belongs-to); }
|
|
@@ -471,3 +473,23 @@ body {
|
|
|
471
473
|
.legend-line.has_many { background: var(--edge-has-many); }
|
|
472
474
|
.legend-line.has_one { background: var(--edge-has-one); }
|
|
473
475
|
.legend-line.habtm { background: var(--edge-habtm); }
|
|
476
|
+
|
|
477
|
+
.legend-line.through {
|
|
478
|
+
background: var(--edge-color);
|
|
479
|
+
background-image: repeating-linear-gradient(
|
|
480
|
+
to right,
|
|
481
|
+
var(--edge-color) 0, var(--edge-color) 6px,
|
|
482
|
+
transparent 6px, transparent 9px
|
|
483
|
+
);
|
|
484
|
+
background-color: transparent;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.legend-line.polymorphic {
|
|
488
|
+
background: var(--edge-color);
|
|
489
|
+
background-image: repeating-linear-gradient(
|
|
490
|
+
to right,
|
|
491
|
+
var(--edge-color) 0, var(--edge-color) 3px,
|
|
492
|
+
transparent 3px, transparent 6px
|
|
493
|
+
);
|
|
494
|
+
background-color: transparent;
|
|
495
|
+
}
|
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
|
|
23
23
|
<div id="toolbar">
|
|
24
24
|
<div class="legend">
|
|
25
|
-
<span class="legend-item"><span class="legend-line belongs_to"></span> belongs_to</span>
|
|
26
|
-
<span class="legend-item"><span class="legend-line has_many"></span> has_many</span>
|
|
27
|
-
<span class="legend-item"><span class="legend-line has_one"></span> has_one</span>
|
|
28
|
-
<span class="legend-item"><span class="legend-line habtm"></span> habtm</span>
|
|
25
|
+
<span class="legend-item"><span class="legend-line belongs_to"></span> belongs_to (M:1)</span>
|
|
26
|
+
<span class="legend-item"><span class="legend-line has_many"></span> has_many (1:M)</span>
|
|
27
|
+
<span class="legend-item"><span class="legend-line has_one"></span> has_one (1:1)</span>
|
|
28
|
+
<span class="legend-item"><span class="legend-line habtm"></span> habtm (M:M)</span>
|
|
29
|
+
<span class="legend-item"><span class="legend-line through"></span> :through</span>
|
|
30
|
+
<span class="legend-item"><span class="legend-line polymorphic"></span> polymorphic</span>
|
|
29
31
|
</div>
|
|
30
32
|
<div class="spacer"></div>
|
|
31
33
|
<span class="zoom-info" id="zoom-info">100%</span>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Rails
|
|
4
4
|
module Schema
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :output_path, :exclude_models, :title, :theme, :expand_columns
|
|
6
|
+
attr_accessor :output_path, :exclude_models, :title, :theme, :expand_columns, :schema_format
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@output_path = "docs/schema.html"
|
|
@@ -11,6 +11,7 @@ module Rails
|
|
|
11
11
|
@title = "Database Schema"
|
|
12
12
|
@theme = :auto
|
|
13
13
|
@expand_columns = false
|
|
14
|
+
@schema_format = :auto
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|
|
@@ -26,16 +26,27 @@ module Rails
|
|
|
26
26
|
association_type: ref.macro.to_s,
|
|
27
27
|
label: ref.name.to_s,
|
|
28
28
|
foreign_key: ref.foreign_key.to_s,
|
|
29
|
-
through: ref
|
|
30
|
-
polymorphic: ref
|
|
29
|
+
through: through_name(ref),
|
|
30
|
+
polymorphic: polymorphic?(ref)
|
|
31
31
|
}
|
|
32
|
-
rescue StandardError
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
warn "[rails-schema] Could not read association #{ref.name} on #{model.name}: #{e.class}: #{e.message}"
|
|
33
34
|
nil
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
def through_name(ref)
|
|
38
|
+
ref.options[:through]&.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def polymorphic?(ref)
|
|
42
|
+
ref.options[:as] ? true : false
|
|
43
|
+
end
|
|
44
|
+
|
|
36
45
|
def target_model_name(ref)
|
|
37
46
|
ref.klass.name
|
|
38
|
-
rescue StandardError
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
warn "[rails-schema] Could not resolve target for #{ref.name}, " \
|
|
49
|
+
"falling back to #{ref.class_name}: #{e.class}: #{e.message}"
|
|
39
50
|
ref.class_name
|
|
40
51
|
end
|
|
41
52
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Schema
|
|
5
|
+
module Extractor
|
|
6
|
+
class StructureSqlParser
|
|
7
|
+
SQL_TYPE_MAP = {
|
|
8
|
+
"character varying" => "string", "varchar" => "string",
|
|
9
|
+
"integer" => "integer", "smallint" => "integer", "serial" => "integer",
|
|
10
|
+
"bigint" => "bigint", "bigserial" => "bigint",
|
|
11
|
+
"boolean" => "boolean", "text" => "text",
|
|
12
|
+
"timestamp without time zone" => "datetime", "timestamp with time zone" => "datetime",
|
|
13
|
+
"timestamp" => "datetime",
|
|
14
|
+
"json" => "json", "jsonb" => "jsonb", "uuid" => "uuid",
|
|
15
|
+
"numeric" => "decimal", "decimal" => "decimal", "money" => "decimal",
|
|
16
|
+
"date" => "date",
|
|
17
|
+
"float" => "float", "double precision" => "float", "real" => "float",
|
|
18
|
+
"bytea" => "binary"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
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
|
|
25
|
+
|
|
26
|
+
def initialize(structure_path = nil)
|
|
27
|
+
@structure_path = structure_path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse
|
|
31
|
+
path = resolve_path
|
|
32
|
+
return {} unless path && File.exist?(path)
|
|
33
|
+
|
|
34
|
+
parse_content(File.read(path))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_content(content)
|
|
38
|
+
tables = {}
|
|
39
|
+
|
|
40
|
+
content.scan(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."]+)\s*\((.*?)\)\s*;/mi) do |table_name, body|
|
|
41
|
+
name = extract_table_name(table_name)
|
|
42
|
+
columns, pk_columns = parse_table_body(body)
|
|
43
|
+
pk_columns.each { |pk| columns.find { |c| c[:name] == pk }&.[]= :primary, true }
|
|
44
|
+
tables[name] = columns
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
tables
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def resolve_path
|
|
53
|
+
return @structure_path if @structure_path
|
|
54
|
+
return ::Rails.root.join("db", "structure.sql").to_s if defined?(::Rails.root) && ::Rails.root
|
|
55
|
+
|
|
56
|
+
File.join(Dir.pwd, "db", "structure.sql")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unquote(identifier) = identifier.delete('"')
|
|
60
|
+
|
|
61
|
+
def extract_table_name(raw)
|
|
62
|
+
unquote(raw).split(".").last
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_table_body(body)
|
|
66
|
+
columns = []
|
|
67
|
+
pk_columns = []
|
|
68
|
+
body.each_line do |raw|
|
|
69
|
+
line = raw.strip.chomp(",")
|
|
70
|
+
next if line.empty?
|
|
71
|
+
|
|
72
|
+
if (pk = extract_pk_constraint(line))
|
|
73
|
+
pk_columns.concat(pk)
|
|
74
|
+
elsif !line.match?(CONSTRAINT_RE) && (col = parse_column_line(line))
|
|
75
|
+
pk_columns << col[:name] if col.delete(:inline_pk)
|
|
76
|
+
columns << col
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
[columns, pk_columns]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def extract_pk_constraint(line)
|
|
83
|
+
return unless (match = line.match(PK_CONSTRAINT_RE))
|
|
84
|
+
|
|
85
|
+
match[1].split(",").map { |c| unquote(c.strip) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_column_line(line)
|
|
89
|
+
match = line.match(/\A("?\w+"?)\s+(.+)/i)
|
|
90
|
+
return nil unless match
|
|
91
|
+
|
|
92
|
+
rest = match[2]
|
|
93
|
+
type = extract_type(rest)
|
|
94
|
+
return nil unless type
|
|
95
|
+
|
|
96
|
+
build_column(unquote(match[1]), rest, type)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_column(col_name, rest, type)
|
|
100
|
+
{
|
|
101
|
+
name: col_name,
|
|
102
|
+
type: SQL_TYPE_MAP.fetch(type, type),
|
|
103
|
+
nullable: !rest.match?(/\bNOT\s+NULL\b/i),
|
|
104
|
+
default: extract_default(rest),
|
|
105
|
+
primary: false,
|
|
106
|
+
inline_pk: rest.match?(/\bPRIMARY\s+KEY\b/i)
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_type(rest)
|
|
111
|
+
if (m = rest.match(COMPOUND_TYPE_RE))
|
|
112
|
+
m[1].downcase.gsub(/\(\d+\)/, "")
|
|
113
|
+
elsif rest.match?(/\A(FOREIGN\s+KEY)\b/i)
|
|
114
|
+
nil
|
|
115
|
+
else
|
|
116
|
+
rest[/\A(\w+)/i, 1]&.downcase
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract_default(rest)
|
|
121
|
+
case rest
|
|
122
|
+
when /\bDEFAULT\s+'([^']*)'(?:::\w+)?/i, /\bDEFAULT\s+(\d+(?:\.\d+)?)\b/i
|
|
123
|
+
Regexp.last_match(1)
|
|
124
|
+
when /\bDEFAULT\s+true\b/i then "true"
|
|
125
|
+
when /\bDEFAULT\s+false\b/i then "false"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/rails/schema/version.rb
CHANGED
data/lib/rails/schema.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "schema/configuration"
|
|
|
5
5
|
require_relative "schema/transformer/node"
|
|
6
6
|
require_relative "schema/transformer/edge"
|
|
7
7
|
require_relative "schema/extractor/schema_file_parser"
|
|
8
|
+
require_relative "schema/extractor/structure_sql_parser"
|
|
8
9
|
require_relative "schema/extractor/model_scanner"
|
|
9
10
|
require_relative "schema/extractor/column_reader"
|
|
10
11
|
require_relative "schema/extractor/association_reader"
|
|
@@ -29,13 +30,27 @@ module Rails
|
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def generate(output: nil)
|
|
32
|
-
schema_data =
|
|
33
|
+
schema_data = parse_schema
|
|
33
34
|
models = Extractor::ModelScanner.new(schema_data: schema_data).scan
|
|
34
35
|
column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
|
|
35
36
|
graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
|
|
36
37
|
generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
|
|
37
38
|
generator.render_to_file(output)
|
|
38
39
|
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_schema
|
|
44
|
+
case configuration.schema_format
|
|
45
|
+
when :ruby
|
|
46
|
+
Extractor::SchemaFileParser.new.parse
|
|
47
|
+
when :sql
|
|
48
|
+
Extractor::StructureSqlParser.new.parse
|
|
49
|
+
when :auto
|
|
50
|
+
data = Extractor::SchemaFileParser.new.parse
|
|
51
|
+
data.empty? ? Extractor::StructureSqlParser.new.parse : data
|
|
52
|
+
end
|
|
53
|
+
end
|
|
39
54
|
end
|
|
40
55
|
end
|
|
41
56
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Kislichenko
|
|
@@ -45,6 +45,7 @@ executables: []
|
|
|
45
45
|
extensions: []
|
|
46
46
|
extra_rdoc_files: []
|
|
47
47
|
files:
|
|
48
|
+
- CHANGELOG.md
|
|
48
49
|
- LICENSE.txt
|
|
49
50
|
- PROJECT.md
|
|
50
51
|
- README.md
|
|
@@ -61,6 +62,7 @@ files:
|
|
|
61
62
|
- lib/rails/schema/extractor/column_reader.rb
|
|
62
63
|
- lib/rails/schema/extractor/model_scanner.rb
|
|
63
64
|
- lib/rails/schema/extractor/schema_file_parser.rb
|
|
65
|
+
- lib/rails/schema/extractor/structure_sql_parser.rb
|
|
64
66
|
- lib/rails/schema/railtie.rb
|
|
65
67
|
- lib/rails/schema/renderer/html_generator.rb
|
|
66
68
|
- lib/rails/schema/transformer/edge.rb
|