rails-schema 0.1.7 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06e278b737001f1ceeae2ef9d2014893a265125c7c011191a222a3d3fc48745e
4
- data.tar.gz: '039a8aef58083c1ff5e023fee87e113c394a0dc1f008e37421460187f6fb3d44'
3
+ metadata.gz: d97a36c459cf91334bda4a6feb1bb99222432a71d75cf8fa368215facf465012
4
+ data.tar.gz: 5851a6d08e91c981fc15801a4cb805bb107079e8ede44c6ea5dc6da033302253
5
5
  SHA512:
6
- metadata.gz: 17d59e29828e31ee3298a2f5b0f86b24d31240912b77668874f2be071c610f3af50641ebadaaf1c449d6cd532c0b5bdc4ac36c6137cdc15f5ca075a102c1a9e1
7
- data.tar.gz: 979e7da7b4e3fb215b2f25095ae0eae96d6901efa5cb0915a5a76273571ec222ccc5fa41f2495e15126f3c01312a4096f5e44966a0b94c8e0fc59228dcb0b957
6
+ metadata.gz: 968db5097006071930a3972b96d7aa6b0a616133999aee055e45466612f6141dff125f6e38adb426c60dbf4dfc4ab31e1171613637577164a825e4db2be0684e
7
+ data.tar.gz: 735d4895a9e7b80ade035acfeab3439b810b572e5ff4835dfe1e0c2160d727aa59f9616910eecd72316813c448e429f40f50546ea33096b78f7b6ac1d3f4bd0e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ 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.8] - 2026-06-14
8
+
9
+ ### Added
10
+
11
+ - SQL view support — models backed by a SQL view (`CREATE VIEW`, e.g. `self.table_name = "some_view"`) now appear on the diagram instead of being silently dropped. `StructureSqlParser` parses `CREATE VIEW` statements (column names from the SQLite hint comment or the `SELECT` list); columns are read from live DB introspection when a connection is available (accurate types) and fall back to the parsed names offline. View nodes are visually distinguished with a "VIEW" badge and a dashed border, plus a "VIEW" tag in the detail panel.
12
+
13
+ ### Fixed
14
+
15
+ - SQLite `structure.sql` parsing — `StructureSqlParser` now correctly extracts all columns from SQLite-format dumps, which place the entire `CREATE TABLE` on a single line. Previously only the primary key was read (every model rendered with just its `id`). Column splitting is now parenthesis- and quote-aware, so commas inside type modifiers (`decimal(5,4)`), `FOREIGN KEY` clauses, and string literals no longer break parsing; inline C-style comments (`/* ... */`) are stripped. PostgreSQL `pg_dump` output continues to parse as before.
16
+
7
17
  ## [0.1.7] - 2026-04-25
8
18
 
9
19
  ### Added
data/CLAUDE.md CHANGED
@@ -47,9 +47,10 @@ end
47
47
  ### Data Extraction Strategy
48
48
 
49
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
50
+ 2. **`db/structure.sql`** — `StructureSqlParser` parses SQL `CREATE TABLE` statements, maps SQL types to Rails types. Column splitting is parenthesis/quote-aware (a top-level-comma splitter), so it handles both PostgreSQL `pg_dump` (one column per line) and SQLite (whole table on one line) dumps. Also parses `CREATE VIEW` statements — view names go into `#views` (a Set), and view column names come from the SQLite hint comment (`/* viewname(col,...) */`) or the `SELECT` list
51
51
  3. **ActiveRecord reflection API** — `AssociationReader` uses `reflect_on_all_associations` for associations
52
52
  4. **`Model.columns`** — `ColumnReader` falls back to this when table not found in schema_data
53
+ 5. **SQL views** — `parse_schema` returns `[columns, view_names]`. `ColumnReader.new(view_tables:)` reads view columns from live DB introspection first (accurate types), falling back to the parsed names offline. `GraphBuilder.new(view_tables:)` sets `Node#view`, which the frontend renders with a "VIEW" badge + dashed border. Views are always included (no config flag); the model just needs `self.table_name` pointing at the view
53
54
 
54
55
  ### Model Discovery
55
56
 
@@ -58,6 +58,7 @@
58
58
  var NODE_HEADER_HEIGHT = 36;
59
59
  var NODE_COLUMN_HEIGHT = 18;
60
60
  var NODE_PADDING = 8;
61
+ var VIEW_BADGE_WIDTH = 34;
61
62
 
62
63
  // Layout persistence
63
64
  function computeFingerprint() {
@@ -707,7 +708,7 @@
707
708
 
708
709
  // Background rect
709
710
  nGroups.append("rect")
710
- .attr("class", "node-rect")
711
+ .attr("class", function(d) { return "node-rect" + (d.view ? " is-view" : ""); })
711
712
  .attr("width", NODE_WIDTH)
712
713
  .attr("height", function(d) { return d._height; })
713
714
  .attr("x", -NODE_WIDTH / 2)
@@ -755,6 +756,24 @@
755
756
  .attr("dominant-baseline", "central")
756
757
  .text(function(d) { return truncateText(d.table_name, NODE_WIDTH - NODE_PADDING * 2, "10px " + getComputedStyle(document.body).fontFamily); });
757
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
+
758
777
  // Columns
759
778
  nGroups.each(function(d) {
760
779
  var g = d3.select(this);
@@ -1034,7 +1053,9 @@
1034
1053
  html += '<h2 title="' + escapeHtml(node.id) + '">' + escapeHtml(node.id) + '</h2>';
1035
1054
  html += '<button id="detail-close" onclick="window.__closeDetail()">&times;</button>';
1036
1055
  html += '</div>';
1037
- 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>';
1038
1059
 
1039
1060
  html += '<h3>Columns</h3>';
1040
1061
  html += '<ul class="column-list">';
@@ -364,6 +364,22 @@ body {
364
364
  stroke-width: 2.5;
365
365
  }
366
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
+
367
383
  .node-header-rect {
368
384
  fill: var(--node-header-bg);
369
385
  rx: 8;
@@ -504,6 +520,18 @@ body {
504
520
  white-space: nowrap;
505
521
  }
506
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
+
507
535
  #detail-content h3 {
508
536
  font-size: 13px;
509
537
  font-weight: 600;
@@ -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
 
@@ -7,10 +7,11 @@ module Rails
7
7
  module Transformer
8
8
  class GraphBuilder
9
9
  def initialize(column_reader: Extractor::ColumnReader.new, association_reader: Extractor::AssociationReader.new,
10
- configuration: ::Rails::Schema.configuration)
10
+ configuration: ::Rails::Schema.configuration, view_tables: nil)
11
11
  @column_reader = column_reader
12
12
  @association_reader = association_reader
13
13
  @group_proc = configuration.resolved_group_proc
14
+ @view_tables = view_tables ? Set.new(view_tables) : Set.new
14
15
  end
15
16
 
16
17
  def build(models)
@@ -47,7 +48,8 @@ module Rails
47
48
  id: unique_id,
48
49
  table_name: model.table_name,
49
50
  columns: @column_reader.read(model),
50
- group: group
51
+ group: group,
52
+ view: @view_tables.include?(model.table_name)
51
53
  )
52
54
  end
53
55
 
@@ -4,18 +4,20 @@ module Rails
4
4
  module Schema
5
5
  module Transformer
6
6
  class Node
7
- attr_reader :id, :table_name, :columns, :group
7
+ attr_reader :id, :table_name, :columns, :group, :view
8
8
 
9
- def initialize(id:, table_name:, columns: [], group: [])
9
+ def initialize(id:, table_name:, columns: [], group: [], view: false)
10
10
  @id = id
11
11
  @table_name = table_name
12
12
  @columns = columns
13
13
  @group = group
14
+ @view = view
14
15
  end
15
16
 
16
17
  def to_h
17
18
  h = { id: @id, table_name: @table_name, columns: @columns }
18
19
  h[:group] = @group unless @group.empty?
20
+ h[:view] = true if @view
19
21
  h
20
22
  end
21
23
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module Schema
5
- VERSION = "0.1.7"
5
+ VERSION = "0.1.8"
6
6
  end
7
7
  end
data/lib/rails/schema.rb CHANGED
@@ -52,10 +52,10 @@ module Rails
52
52
  private
53
53
 
54
54
  def generate_active_record(output:)
55
- schema_data = parse_schema
55
+ schema_data, views = parse_schema
56
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)
57
+ column_reader = Extractor::ColumnReader.new(schema_data: schema_data, view_tables: views)
58
+ graph_data = Transformer::GraphBuilder.new(column_reader: column_reader, view_tables: views).build(models)
59
59
  graph_data[:metadata][:mode] = "active_record"
60
60
  generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
61
61
  generator.render_to_file(output)
@@ -84,17 +84,24 @@ module Rails
84
84
  generator.render_to_file(output)
85
85
  end
86
86
 
87
+ # Returns [columns_by_table, view_table_names]. For :auto, falls through
88
+ # to structure.sql when schema.rb yields nothing.
87
89
  def parse_schema
88
90
  case configuration.schema_format
89
91
  when :ruby
90
- Extractor::SchemaFileParser.new.parse
92
+ parse_with(Extractor::SchemaFileParser.new)
91
93
  when :sql
92
- Extractor::StructureSqlParser.new.parse
94
+ parse_with(Extractor::StructureSqlParser.new)
93
95
  when :auto
94
- data = Extractor::SchemaFileParser.new.parse
95
- data.empty? ? Extractor::StructureSqlParser.new.parse : data
96
+ columns, views = parse_with(Extractor::SchemaFileParser.new)
97
+ columns.empty? ? parse_with(Extractor::StructureSqlParser.new) : [columns, views]
96
98
  end
97
99
  end
100
+
101
+ def parse_with(parser)
102
+ columns = parser.parse
103
+ [columns, parser.views]
104
+ end
98
105
  end
99
106
  end
100
107
  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.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Kislichenko