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.
@@ -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
- layoutOrphans();
673
- layoutSelfRefNodes();
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
- d.fx = null;
824
- d.fy = null;
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()">&times;</button>';
897
1055
  html += '</div>';
898
- html += '<div class="detail-table" title="' + escapeHtml(node.table_name) + '">' + escapeHtml(node.table_name) + '</div>';
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 ? "&#9654;" : "&#9660;";
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
- // Fit after simulation settles
1383
- setTimeout(function() { fitToScreen(); }, 1500);
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
- <button id="export-mermaid-btn" title="Export as Mermaid (Markdown)">Export Mermaid</button>
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 &#9660;</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 @schema_data&.key?(model.table_name)
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.each_line do |raw|
71
- line = raw.strip.chomp(",")
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