schema_erd 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e0909d6e3250f2b42bfda5607086a83517de9cbe64466d058cea239228eae3c
4
+ data.tar.gz: bfd827568642d3175fd65dcb826c80318d2c314a46cb76ac72039e22801d9ff3
5
+ SHA512:
6
+ metadata.gz: 508215f63809b5a533bf0012b61eaf5a76d8ff0dbbfae0eda1bfa7af762a82f5479b44da742edcc7dbbf29298ed069f3ec983db3a815049c5908aa274f6e58f4
7
+ data.tar.gz: a5b61b9ecbaaca092adc4058e2f773cc15fae622a4b2e86ce4593dffde330b77866d10f39c50f6312e3121d42c9d05e9cd0a928d158ceaf78e29c1150a7908f7
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - Update project authorship.
6
+
7
+ ## 0.1.0
8
+
9
+ - First release.
10
+ - Parse tables, columns, indexes, defaults, constraints, and foreign keys from `db/schema.rb`.
11
+ - Explore schemas on an interactive, searchable, zoomable canvas.
12
+ - Focus a table and its direct relationships with shareable URLs.
13
+ - Preserve hand-tuned layouts in local storage.
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dreaming Tulpa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Schema ERD
2
+
3
+ **Your Rails schema, alive.**
4
+
5
+ Schema ERD turns `db/schema.rb` into an interactive database map inside your Rails app. Mount one Rack endpoint and get a canvas you can search, pan, zoom, rearrange, and explore—without Graphviz, Node, model loading, or a build step.
6
+
7
+ ```ruby
8
+ # config/routes.rb
9
+ mount SchemaErd::App.new(schema_path: Rails.root.join("db/schema.rb")), at: "/rails/erd"
10
+ ```
11
+
12
+ Open `/rails/erd`. That is the whole setup.
13
+
14
+ ## Why Schema ERD?
15
+
16
+ Most ERD tools generate an artifact. Schema ERD gives you a workspace.
17
+
18
+ - **Reads the source of truth.** Tables, columns, indexes, defaults, null constraints, primary keys, and foreign keys come straight from `db/schema.rb`.
19
+ - **Feels immediate.** Refresh the page after a migration; there is nothing to regenerate.
20
+ - **Handles real schemas.** Search tables and columns, hide Rails internals, toggle detail, and fit the entire graph to the window.
21
+ - **Makes relationships navigable.** Click a table to isolate its references and dependents. The selected table lives in the URL, ready to paste into a pull request or team chat.
22
+ - **Gets out of your way.** Drag cards into place, pan the canvas, zoom with the controls or keyboard, and let local storage remember the layout.
23
+ - **Ships almost nothing.** The rendered page is standalone HTML, CSS, and JavaScript. The gem has no runtime dependencies.
24
+
25
+ ## Installation
26
+
27
+ Schema ERD is a development tool. Keep it out of production:
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ group :development do
32
+ gem "schema_erd"
33
+ end
34
+ ```
35
+
36
+ Then mount it behind a development-only route:
37
+
38
+ ```ruby
39
+ # config/routes.rb
40
+ Rails.application.routes.draw do
41
+ if Rails.env.development?
42
+ mount SchemaErd::App.new(
43
+ schema_path: Rails.root.join("db/schema.rb")
44
+ ), at: "/rails/erd"
45
+ end
46
+ end
47
+ ```
48
+
49
+ Run `bundle install`, boot Rails, and visit [http://localhost:3000/rails/erd](http://localhost:3000/rails/erd).
50
+
51
+ ## Controls
52
+
53
+ | Action | Control |
54
+ | --- | --- |
55
+ | Find a table or column | Search box |
56
+ | Focus a table and its neighbors | Click its header |
57
+ | Move a table | Drag its header |
58
+ | Pan | Drag empty canvas space |
59
+ | Zoom | Buttons, `+` / `-`, or <kbd>Ctrl</kbd>/<kbd>⌘</kbd> + wheel |
60
+ | Reset zoom | `0` |
61
+ | Fit the diagram | `F` |
62
+ | Leave focused view | <kbd>Esc</kbd> or “Show all tables” |
63
+
64
+ Layout positions are stored in the browser and keyed by the schema version. A new migration gets a fresh layout; older layouts stay intact.
65
+
66
+ ## Rails internals
67
+
68
+ Schema ERD recognizes common framework-owned tables such as Active Storage, sessions, `ar_internal_metadata`, and `schema_migrations`. They are visible by default and can be hidden from the controls.
69
+
70
+ ## Requirements and scope
71
+
72
+ - Ruby 3.2 or newer.
73
+ - A Rails-style Ruby schema file (`db/schema.rb`).
74
+ - Any Rack-compatible Rails version capable of mounting an object that responds to `call`.
75
+
76
+ `structure.sql` is not supported. Schema ERD parses the stable shape of Rails-generated Ruby schema files; it does not execute the schema or boot your models.
77
+
78
+ ## Use outside Rails
79
+
80
+ The app is an ordinary Rack endpoint, so Rails is convenient rather than required:
81
+
82
+ ```ruby
83
+ require "schema_erd"
84
+
85
+ run SchemaErd::App.new(schema_path: File.expand_path("db/schema.rb", __dir__))
86
+ ```
87
+
88
+ The parser is public too:
89
+
90
+ ```ruby
91
+ schema = SchemaErd::Parser.call(File.read("db/schema.rb"))
92
+
93
+ schema.tables.first.name
94
+ schema.relationships.first.from_table
95
+ ```
96
+
97
+ ## Security
98
+
99
+ An ERD exposes table and column names. Mount it only where intended—normally in development—and add your own authentication if you expose it in a shared environment. Responses are marked `no-store` and `noindex, nofollow`, but those headers are not access control.
100
+
101
+ ## Development
102
+
103
+ ```sh
104
+ bundle install
105
+ bundle exec rake
106
+ bundle exec rake build
107
+ ```
108
+
109
+ Bug reports and focused pull requests are welcome. Please include a minimal `schema.rb` fragment when reporting parser behavior.
110
+
111
+ ## License
112
+
113
+ Schema ERD is available under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+ require "bundler/gem_tasks"
5
+
6
+ Rake::TestTask.new do |task|
7
+ task.libs << "test"
8
+ task.pattern = "test/**/*_test.rb"
9
+ task.warning = true
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaErd
4
+ VERSION = "0.1.1"
5
+ end
data/lib/schema_erd.rb ADDED
@@ -0,0 +1,894 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "schema_erd/version"
5
+
6
+ module SchemaErd
7
+ module Html
8
+ ESCAPES = { "&" => "&amp;", '"' => "&quot;", "'" => "&#39;", "<" => "&lt;", ">" => "&gt;" }.freeze
9
+
10
+ def self.escape(value)
11
+ value.to_s.gsub(/[&"'<>]/, ESCAPES)
12
+ end
13
+ end
14
+
15
+ Column = Data.define(:name, :type, :nullable, :default, :primary_key, :foreign_key_to)
16
+ Index = Data.define(:columns, :unique)
17
+ Table = Data.define(:name, :columns, :indexes)
18
+ Relationship = Data.define(:from_table, :to_table, :column)
19
+ Schema = Data.define(:version, :tables, :relationships)
20
+
21
+ class Parser
22
+ COLUMN_PATTERN = /^\s+t\.(\w+)\s+"([^"]+)"(.*)$/
23
+ INDEX_PATTERN = /^\s+t\.index\s+\[(.+)\](.*)$/
24
+ TABLE_PATTERN = /^\s+create_table "([^"]+)"(.*) do \|t\|$/
25
+ FOREIGN_KEY_PATTERN = /^\s+add_foreign_key "([^"]+)", "([^"]+)"(.*)$/
26
+
27
+ def self.call(source)
28
+ new(source).call
29
+ end
30
+
31
+ def initialize(source)
32
+ @source = source
33
+ @tables = []
34
+ @relationships = []
35
+ @current_table = nil
36
+ end
37
+
38
+ def call
39
+ source.each_line { |line| parse_line(line) }
40
+ mark_foreign_keys
41
+
42
+ Schema.new(version:, tables:, relationships:)
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :source, :tables, :relationships
48
+
49
+ def parse_line(line)
50
+ table_match = line.match(TABLE_PATTERN)
51
+ return start_table(table_match) if table_match
52
+ return finish_table if current_table && line.match?(/^\s+end$/)
53
+ return parse_table_line(line) if current_table
54
+
55
+ foreign_key_match = line.match(FOREIGN_KEY_PATTERN)
56
+ parse_foreign_key(foreign_key_match) if foreign_key_match
57
+ end
58
+
59
+ def start_table(match)
60
+ name = match[1]
61
+ options = match[2]
62
+ columns = []
63
+ columns << Column.new(name: "id", type: primary_key_type(options), nullable: false, default: nil, primary_key: true, foreign_key_to: nil) unless options.include?("id: false")
64
+ @current_table = { name:, columns:, indexes: [] }
65
+ end
66
+
67
+ def finish_table
68
+ tables << Table.new(**current_table)
69
+ @current_table = nil
70
+ end
71
+
72
+ def parse_table_line(line)
73
+ if (match = line.match(INDEX_PATTERN))
74
+ columns, options = match.captures
75
+ current_table[:indexes] << Index.new(columns: columns.scan(/"([^"]+)"/).flatten, unique: options.include?("unique: true"))
76
+ return
77
+ end
78
+
79
+ match = line.match(COLUMN_PATTERN)
80
+ return unless match
81
+
82
+ type, name, options = match.captures
83
+ current_table[:columns] << Column.new(
84
+ name:,
85
+ type: column_type(type, options),
86
+ nullable: !options.include?("null: false"),
87
+ default: parse_default(options),
88
+ primary_key: false,
89
+ foreign_key_to: nil
90
+ )
91
+ end
92
+
93
+ def parse_foreign_key(match)
94
+ from_table, to_table, options = match.captures
95
+ column = options[/column:\s+"([^"]+)"/, 1] || inferred_foreign_key(to_table)
96
+ relationships << Relationship.new(from_table:, to_table:, column:)
97
+ end
98
+
99
+ def mark_foreign_keys
100
+ targets = relationships.group_by { |relationship| [ relationship.from_table, relationship.column ] }
101
+
102
+ @tables = tables.map do |table|
103
+ columns = table.columns.map do |column|
104
+ relationship = targets[[ table.name, column.name ]]&.first
105
+ relationship ? column.with(foreign_key_to: relationship.to_table) : column
106
+ end
107
+ table.with(columns:)
108
+ end
109
+ end
110
+
111
+ def current_table
112
+ @current_table
113
+ end
114
+
115
+ def version
116
+ raw = source[/\.define\(version:\s*([0-9_]+)\)/, 1]
117
+ raw&.tr("_", "-")
118
+ end
119
+
120
+ def primary_key_type(options)
121
+ options[/id:\s*:(\w+)/, 1] || "bigint"
122
+ end
123
+
124
+ def column_type(type, options)
125
+ details = []
126
+ limit = options[/limit:\s*(\d+)/, 1]
127
+ details << limit if limit
128
+ precision = options[/precision:\s*(\d+)/, 1]
129
+ scale = options[/scale:\s*(\d+)/, 1]
130
+ details << [ precision, scale ].compact.join(",") if precision
131
+ details.empty? ? type : "#{type}(#{details.join(",")})"
132
+ end
133
+
134
+ def parse_default(options)
135
+ raw = options[/default:\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|->\s*\{.*\}|[^,]+)/, 1]
136
+ return if raw.nil?
137
+
138
+ raw = raw.strip
139
+ quoted = (raw.start_with?('"') && raw.end_with?('"')) || (raw.start_with?("'") && raw.end_with?("'"))
140
+ quoted ? raw[1..-2] : raw
141
+ end
142
+
143
+ def inferred_foreign_key(table_name)
144
+ singular = if defined?(ActiveSupport::Inflector)
145
+ ActiveSupport::Inflector.singularize(table_name)
146
+ else
147
+ table_name.sub(/ies\z/, "y").sub(/s\z/, "")
148
+ end
149
+ "#{singular}_id"
150
+ end
151
+ end
152
+
153
+ class App
154
+ def initialize(schema_path:)
155
+ @schema_path = schema_path
156
+ end
157
+
158
+ def call(env)
159
+ return not_found unless env["REQUEST_METHOD"] == "GET"
160
+
161
+ schema = Parser.call(File.read(schema_path))
162
+ body = Page.new(schema:, schema_path:).render
163
+ [ 200, headers, [ body ] ]
164
+ rescue Errno::ENOENT => error
165
+ [ 500, headers, [ "<h1>Schema unavailable</h1><pre>#{Html.escape(error.message)}</pre>" ] ]
166
+ end
167
+
168
+ private
169
+
170
+ attr_reader :schema_path
171
+
172
+ def headers
173
+ {
174
+ "content-type" => "text/html; charset=utf-8",
175
+ "cache-control" => "no-store",
176
+ "x-robots-tag" => "noindex, nofollow"
177
+ }
178
+ end
179
+
180
+ def not_found
181
+ [ 404, { "content-type" => "text/plain; charset=utf-8" }, [ "Not found" ] ]
182
+ end
183
+ end
184
+
185
+ class Page
186
+ RAILS_INTERNAL_PREFIXES = %w[active_storage_ sessions ar_internal_metadata schema_migrations].freeze
187
+
188
+ def initialize(schema:, schema_path:)
189
+ @schema = schema
190
+ @schema_path = schema_path
191
+ end
192
+
193
+ def render
194
+ <<~HTML
195
+ <!doctype html>
196
+ <html lang="en">
197
+ <head>
198
+ <meta charset="utf-8">
199
+ <meta name="viewport" content="width=device-width, initial-scale=1">
200
+ <title>Rails database ERD</title>
201
+ <style>#{styles}</style>
202
+ </head>
203
+ <body class="hide-attributes">
204
+ <aside class="controls-card">
205
+ <header>
206
+ <div>
207
+ <p class="eyebrow">Development tool</p>
208
+ <h1>Database ERD</h1>
209
+ </div>
210
+ <a class="refresh" href="" title="Reload schema" aria-label="Reload schema">↻</a>
211
+ </header>
212
+ <p class="subtitle" title="#{escape(schema_path.to_s)}">Schema #{escape(schema.version || "unknown")}</p>
213
+ <section class="toolbar" aria-label="Diagram controls">
214
+ <label class="search">
215
+ <span>Search</span>
216
+ <input id="search" type="search" placeholder="Tables or columns…" autofocus>
217
+ </label>
218
+ <div class="toggle-grid">
219
+ <label><input id="relationships" type="checkbox" checked> Relationships</label>
220
+ <label><input id="internals" type="checkbox" checked> Rails internals</label>
221
+ <label><input id="attributes" type="checkbox"> Attributes</label>
222
+ <label><input id="details" type="checkbox" checked> Constraints</label>
223
+ </div>
224
+ <div class="control-row">
225
+ <div class="zoom-controls" aria-label="Zoom controls">
226
+ <button id="zoom-out" type="button" title="Zoom out (−)" aria-label="Zoom out">−</button>
227
+ <button id="zoom-reset" type="button" title="Reset zoom (0)">100%</button>
228
+ <button id="zoom-in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
229
+ <button id="zoom-fit" type="button" title="Fit the complete diagram (F)">Fit</button>
230
+ </div>
231
+ <button id="reset-layout" class="secondary-control" type="button">Reset</button>
232
+ </div>
233
+ <button id="clear-focus" class="clear-focus" type="button" hidden>Show all tables</button>
234
+ <div class="stats"><strong id="visible-count">#{schema.tables.size}</strong> tables · #{schema.relationships.size} foreign keys</div>
235
+ </section>
236
+ <details class="help">
237
+ <summary>Canvas controls</summary>
238
+ <p>Drag the canvas to pan and table headers to move them. Click a header to arrange direct relationships. Use Ctrl/⌘ + wheel to zoom.</p>
239
+ </details>
240
+ </aside>
241
+ <main id="viewport">
242
+ <div id="surface">
243
+ <div id="canvas">
244
+ <svg id="lines" aria-hidden="true">
245
+ <defs>
246
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto-start-reverse">
247
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke"></path>
248
+ </marker>
249
+ </defs>
250
+ </svg>
251
+ <div id="tables">#{table_cards}</div>
252
+ </div>
253
+ </div>
254
+ </main>
255
+ <script>#{javascript}</script>
256
+ </body>
257
+ </html>
258
+ HTML
259
+ end
260
+
261
+ private
262
+
263
+ attr_reader :schema, :schema_path
264
+
265
+ def table_cards
266
+ schema.tables.map do |table|
267
+ internal = RAILS_INTERNAL_PREFIXES.any? { |prefix| table.name.start_with?(prefix) }
268
+ search_text = ([ table.name ] + table.columns.flat_map { |column| [ column.name, column.type ] }).join(" ").downcase
269
+ <<~HTML
270
+ <article class="table-card" id="table-#{escape(table.name)}" data-table="#{escape(table.name)}" data-search="#{escape(search_text)}" data-internal="#{internal}">
271
+ <div class="table-heading">
272
+ <h2>#{escape(table.name)}</h2>
273
+ <span>#{table.columns.size} columns</span>
274
+ </div>
275
+ <ol class="columns">#{column_rows(table)}</ol>
276
+ #{indexes(table)}
277
+ </article>
278
+ HTML
279
+ end.join
280
+ end
281
+
282
+ def column_rows(table)
283
+ table.columns.map do |column|
284
+ badges = []
285
+ badges << '<span class="badge pk">PK</span>' if column.primary_key
286
+ badges << '<span class="badge fk">FK</span>' if column.foreign_key_to
287
+ constraints = []
288
+ constraints << "not null" unless column.nullable
289
+ constraints << "default: #{escape(column.default)}" unless column.default.nil?
290
+ relation = column.foreign_key_to ? "<span class=\"relation-target\">→ #{escape(column.foreign_key_to)}</span>" : ""
291
+ <<~HTML
292
+ <li data-column="#{escape(column.name)}">
293
+ <div class="column-main">
294
+ <span class="column-name">#{badges.join}#{escape(column.name)}</span>
295
+ <code>#{escape(column.type)}</code>
296
+ </div>
297
+ <div class="constraints">#{relation}#{constraints.map { |constraint| "<span>#{constraint}</span>" }.join}</div>
298
+ </li>
299
+ HTML
300
+ end.join
301
+ end
302
+
303
+ def indexes(table)
304
+ return "" if table.indexes.empty?
305
+
306
+ rows = table.indexes.map do |index|
307
+ unique = index.unique ? '<span class="badge unique">unique</span>' : ""
308
+ "<li>#{unique}<code>#{escape(index.columns.join(", "))}</code></li>"
309
+ end.join
310
+ "<details><summary>#{table.indexes.size} indexes</summary><ul class=\"indexes\">#{rows}</ul></details>"
311
+ end
312
+
313
+ def escape(value)
314
+ Html.escape(value)
315
+ end
316
+
317
+ def styles
318
+ <<~CSS
319
+ :root { color-scheme: light; --ink: #172033; --muted: #64748b; --line: #dbe4ef; --accent: #ff5a1f; --accent-soft: #fff3ed; --blue: #2563eb; }
320
+ * { box-sizing: border-box; }
321
+ html, body { height: 100%; }
322
+ body { margin: 0; overflow: hidden; background: #f6f8fb; color: var(--ink); font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
323
+ .controls-card { position: fixed; z-index: 30; top: 16px; left: 16px; width: min(340px, calc(100vw - 32px)); border: 1px solid rgba(203,213,225,.9); border-radius: 14px; padding: 14px; background: rgba(255,255,255,.94); box-shadow: 0 12px 32px rgba(15,23,42,.16); backdrop-filter: blur(12px); }
324
+ .controls-card > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
325
+ h1 { margin: 0; font-size: 18px; line-height: 1.15; letter-spacing: -.025em; }
326
+ .eyebrow { margin: 0 0 2px; color: var(--accent); font-size: 9px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
327
+ .subtitle { overflow: hidden; margin: 5px 0 0; color: var(--muted); font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
328
+ .refresh { display: grid; flex: none; width: 32px; height: 32px; place-items: center; border: 1px solid #cbd5e1; border-radius: 8px; color: var(--ink); background: white; font-size: 19px; font-weight: 700; line-height: 1; text-decoration: none; }
329
+ .refresh:hover { border-color: #fdba95; color: var(--accent); background: var(--accent-soft); }
330
+ .toolbar { display: grid; gap: 10px; margin-top: 12px; }
331
+ .toggle-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px 12px; }
332
+ .toolbar label:not(.search) { display: flex; align-items: center; gap: 6px; min-height: 24px; white-space: nowrap; font-size: 11px; font-weight: 650; }
333
+ .search { display: grid; gap: 3px; color: var(--muted); font-size: 9px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
334
+ input[type="search"] { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 7px 10px; color: var(--ink); background: white; font: inherit; outline: none; }
335
+ input[type="search"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
336
+ input[type="checkbox"] { accent-color: var(--accent); }
337
+ button { font: inherit; }
338
+ .control-row { display: flex; gap: 8px; }
339
+ .zoom-controls { display: flex; flex: 1; align-items: center; min-height: 32px; overflow: hidden; border: 1px solid #cbd5e1; border-radius: 8px; background: white; }
340
+ .zoom-controls button { flex: 1; min-width: 30px; height: 30px; border: 0; border-right: 1px solid #e2e8f0; color: var(--ink); background: white; font-size: 12px; font-weight: 750; cursor: pointer; }
341
+ .zoom-controls button:hover { background: var(--accent-soft); color: var(--accent); }
342
+ .zoom-controls button:last-child { border-right: 0; }
343
+ #zoom-reset { min-width: 48px; color: var(--muted); font-size: 10px; }
344
+ .clear-focus, .secondary-control { min-height: 32px; border: 1px solid #cbd5e1; border-radius: 8px; padding: 0 10px; color: var(--ink); background: white; font-size: 11px; font-weight: 750; cursor: pointer; white-space: nowrap; }
345
+ .clear-focus { border-color: #fdba95; color: #c2410c; background: var(--accent-soft); }
346
+ .secondary-control:hover { border-color: #fdba95; color: var(--accent); background: var(--accent-soft); }
347
+ .stats { color: var(--muted); font-size: 10px; white-space: nowrap; }
348
+ .stats strong { color: var(--ink); }
349
+ .help { margin-top: 9px; border-top: 1px solid #e2e8f0; padding-top: 8px; color: var(--muted); font-size: 10px; }
350
+ .help summary { cursor: pointer; font-weight: 700; }
351
+ .help p { margin: 6px 0 0; }
352
+ #viewport { width: 100vw; height: 100vh; overflow: auto; background: #eef2f7; cursor: grab; overscroll-behavior: none; touch-action: none; user-select: none; }
353
+ #viewport.panning { cursor: grabbing; }
354
+ #surface { position: relative; }
355
+ #canvas { position: absolute; top: 0; left: 0; width: 12000px; height: 12000px; transform-origin: top left; background-color: #f7f9fc; background-image: radial-gradient(circle, #cbd5e1 1.2px, transparent 1.2px); background-size: 24px 24px; }
356
+ #tables { position: absolute; z-index: 2; inset: 0; pointer-events: none; }
357
+ #lines { position: absolute; z-index: 1; inset: 0; width: 12000px; height: 12000px; overflow: visible; pointer-events: none; }
358
+ #lines path.relationship { fill: none; stroke: #9fb0c5; stroke-width: 1.35; opacity: .42; marker-end: url(#arrow); }
359
+ #lines path.relationship.active { stroke: var(--accent); stroke-width: 2.5; opacity: .95; }
360
+ #lines path.relationship.dimmed { opacity: .06; }
361
+ #lines.hidden { display: none; }
362
+ .table-card { position: absolute; width: 260px; overflow: hidden; border: 1px solid #ccd7e5; border-radius: 10px; background: white; box-shadow: 0 3px 10px rgba(15,23,42,.07); transition: border-color .15s, box-shadow .15s, opacity .15s; pointer-events: auto; }
363
+ .table-card:hover, .table-card.selected { border-color: var(--accent); box-shadow: 0 7px 22px rgba(255,90,31,.16); }
364
+ .table-card.dragging { z-index: 10; border-color: var(--accent); box-shadow: 0 14px 34px rgba(15,23,42,.2); transition: none; }
365
+ .table-card.dimmed { opacity: .28; }
366
+ .table-card[hidden] { display: none; }
367
+ .table-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 8px 10px; background: #eef3f8; border-bottom: 1px solid var(--line); cursor: grab; touch-action: none; }
368
+ .table-card.dragging .table-heading { cursor: grabbing; }
369
+ .table-heading h2 { overflow: hidden; margin: 0; font: 750 12px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
370
+ .table-heading span { flex: none; color: var(--muted); font-size: 9px; }
371
+ .columns { margin: 0; padding: 0; list-style: none; }
372
+ .columns li { padding: 7px 11px; border-bottom: 1px solid #edf1f6; }
373
+ body.hide-attributes .columns, body.hide-attributes .table-card > details { display: none; }
374
+ .column-main { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
375
+ .column-name { min-width: 0; overflow-wrap: anywhere; font: 650 12px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; }
376
+ code { color: #52637a; font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; }
377
+ .badge { display: inline-block; min-width: 20px; margin-right: 5px; border-radius: 4px; padding: 1px 4px; color: white; font: 800 8px/1.35 ui-sans-serif, system-ui; text-align: center; vertical-align: 1px; }
378
+ .badge.pk { background: #7c3aed; } .badge.fk { background: var(--blue); } .badge.unique { background: #0f766e; }
379
+ .constraints { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-top: 3px; color: #8a98aa; font-size: 9px; }
380
+ body.hide-details .constraints { display: none; }
381
+ .relation-target { color: var(--blue); font-weight: 700; }
382
+ .table-card > details { padding: 7px 11px 9px; background: #fafbfd; color: var(--muted); font-size: 10px; }
383
+ .table-card > details > summary { cursor: pointer; font-weight: 700; }
384
+ .indexes { display: grid; gap: 5px; margin: 7px 0 0; padding: 0; list-style: none; }
385
+ .indexes li { display: flex; align-items: center; gap: 4px; }
386
+ @media (max-width: 900px) {
387
+ .controls-card { top: 10px; left: 10px; width: min(320px, calc(100vw - 20px)); }
388
+ }
389
+ CSS
390
+ end
391
+
392
+ def javascript
393
+ relationships = schema.relationships.map { |relationship| relationship.to_h }
394
+ layout_key = "schema-erd-layout:#{schema.version || "unknown"}"
395
+ <<~JAVASCRIPT
396
+ const relationships = #{JSON.generate(relationships)};
397
+ const layoutKey = #{JSON.generate(layout_key)};
398
+ const CANVAS_WIDTH = 12000;
399
+ const CANVAS_HEIGHT = 12000;
400
+ const CARD_WIDTH = 260;
401
+ const COLUMN_GAP = 50;
402
+ const ROW_GAP = 22;
403
+ const search = document.querySelector("#search");
404
+ const internalToggle = document.querySelector("#internals");
405
+ const relationshipToggle = document.querySelector("#relationships");
406
+ const attributeToggle = document.querySelector("#attributes");
407
+ const detailToggle = document.querySelector("#details");
408
+ const viewport = document.querySelector("#viewport");
409
+ const surface = document.querySelector("#surface");
410
+ const tables = document.querySelector("#tables");
411
+ const cards = [...document.querySelectorAll(".table-card")];
412
+ const svg = document.querySelector("#lines");
413
+ const canvas = document.querySelector("#canvas");
414
+ const zoomOutButton = document.querySelector("#zoom-out");
415
+ const zoomResetButton = document.querySelector("#zoom-reset");
416
+ const zoomInButton = document.querySelector("#zoom-in");
417
+ const zoomFitButton = document.querySelector("#zoom-fit");
418
+ const clearFocusButton = document.querySelector("#clear-focus");
419
+ const resetLayoutButton = document.querySelector("#reset-layout");
420
+ let selectedTable = null;
421
+ let scale = 1;
422
+ let positions = {};
423
+ let layoutFrame = null;
424
+
425
+ function visible(card) {
426
+ return !card.hidden;
427
+ }
428
+
429
+ function matchesFilters(card) {
430
+ const query = search.value.trim().toLowerCase();
431
+ const matchesQuery = !query || card.dataset.search.includes(query);
432
+ const matchesInternals = internalToggle.checked || card.dataset.internal !== "true";
433
+ return matchesQuery && matchesInternals;
434
+ }
435
+
436
+ function updateVisibleCount() {
437
+ document.querySelector("#visible-count").textContent = cards.filter(visible).length;
438
+ }
439
+
440
+ function clamp(value, minimum, maximum) {
441
+ return Math.max(minimum, Math.min(maximum, value));
442
+ }
443
+
444
+ function cardPosition(card) {
445
+ return positions[card.dataset.table] || { x: card.offsetLeft, y: card.offsetTop };
446
+ }
447
+
448
+ function setCardPosition(card, x, y) {
449
+ const position = {
450
+ x: clamp(Math.round(x), 40, CANVAS_WIDTH - card.offsetWidth - 40),
451
+ y: clamp(Math.round(y), 40, CANVAS_HEIGHT - card.offsetHeight - 40)
452
+ };
453
+ positions[card.dataset.table] = position;
454
+ card.style.left = `${position.x}px`;
455
+ card.style.top = `${position.y}px`;
456
+ }
457
+
458
+ function persistPositions() {
459
+ try {
460
+ localStorage.setItem(layoutKey, JSON.stringify(positions));
461
+ } catch (_error) {
462
+ // The diagram remains usable when local storage is disabled.
463
+ }
464
+ }
465
+
466
+ function restorePositions() {
467
+ try {
468
+ const stored = JSON.parse(localStorage.getItem(layoutKey));
469
+ if (!stored || typeof stored !== "object") return false;
470
+ if (!cards.every(card => Number.isFinite(stored[card.dataset.table]?.x) && Number.isFinite(stored[card.dataset.table]?.y))) return false;
471
+ positions = stored;
472
+ cards.forEach(card => setCardPosition(card, positions[card.dataset.table].x, positions[card.dataset.table].y));
473
+ return true;
474
+ } catch (_error) {
475
+ return false;
476
+ }
477
+ }
478
+
479
+ function defaultLayout() {
480
+ const columns = Math.min(8, Math.max(1, Math.ceil(Math.sqrt(cards.length))));
481
+ const originX = (CANVAS_WIDTH - (columns * CARD_WIDTH + (columns - 1) * COLUMN_GAP)) / 2;
482
+ const originY = 1200;
483
+ const heights = Array(columns).fill(originY);
484
+ positions = {};
485
+
486
+ cards.forEach(card => {
487
+ const column = heights.indexOf(Math.min(...heights));
488
+ setCardPosition(card, originX + column * (CARD_WIDTH + COLUMN_GAP), heights[column]);
489
+ heights[column] += Math.max(card.offsetHeight, 36) + ROW_GAP;
490
+ });
491
+ }
492
+
493
+ function drawRelationships() {
494
+ svg.querySelectorAll(".relationship").forEach(path => path.remove());
495
+ svg.classList.toggle("hidden", !relationshipToggle.checked);
496
+ if (!relationshipToggle.checked) return;
497
+
498
+ svg.setAttribute("viewBox", `0 0 ${canvas.scrollWidth} ${canvas.scrollHeight}`);
499
+ relationships.forEach((relationship, index) => {
500
+ const source = document.querySelector(`[data-table="${CSS.escape(relationship.from_table)}"]`);
501
+ const target = document.querySelector(`[data-table="${CSS.escape(relationship.to_table)}"]`);
502
+ if (!source || !target || !visible(source) || !visible(target)) return;
503
+
504
+ const sourceRect = {
505
+ left: source.offsetLeft,
506
+ top: source.offsetTop,
507
+ right: source.offsetLeft + source.offsetWidth,
508
+ bottom: source.offsetTop + source.offsetHeight,
509
+ width: source.offsetWidth,
510
+ height: source.offsetHeight
511
+ };
512
+ const targetRect = {
513
+ left: target.offsetLeft,
514
+ top: target.offsetTop,
515
+ right: target.offsetLeft + target.offsetWidth,
516
+ bottom: target.offsetTop + target.offsetHeight,
517
+ width: target.offsetWidth,
518
+ height: target.offsetHeight
519
+ };
520
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
521
+ if (source === target) {
522
+ const x = sourceRect.right;
523
+ const centerY = sourceRect.top + sourceRect.height / 2;
524
+ const halfSpan = clamp(sourceRect.height * .18, 8, 24);
525
+ const y1 = centerY - halfSpan;
526
+ const y2 = centerY + halfSpan;
527
+ path.setAttribute("d", `M ${x} ${y1} C ${x + 56} ${y1}, ${x + 56} ${y2}, ${x} ${y2}`);
528
+ } else {
529
+ const goingRight = targetRect.left >= sourceRect.left;
530
+ const x1 = goingRight ? sourceRect.right : sourceRect.left;
531
+ const y1 = sourceRect.top + Math.min(sourceRect.height * .34 + (index % 5) * 11, sourceRect.height - 18);
532
+ const x2 = goingRight ? targetRect.left : targetRect.right;
533
+ const y2 = targetRect.top + Math.min(targetRect.height * .34 + (index % 4) * 12, targetRect.height - 18);
534
+ const bend = Math.max(45, Math.abs(x2 - x1) * .45);
535
+ path.setAttribute("d", `M ${x1} ${y1} C ${x1 + (goingRight ? bend : -bend)} ${y1}, ${x2 + (goingRight ? -bend : bend)} ${y2}, ${x2} ${y2}`);
536
+ }
537
+ path.setAttribute("class", "relationship");
538
+ path.dataset.from = relationship.from_table;
539
+ path.dataset.to = relationship.to_table;
540
+ path.dataset.column = relationship.column;
541
+ svg.appendChild(path);
542
+ });
543
+ highlight(selectedTable);
544
+ }
545
+
546
+ function syncSurface() {
547
+ surface.style.width = `${CANVAS_WIDTH * scale}px`;
548
+ surface.style.height = `${CANVAS_HEIGHT * scale}px`;
549
+ }
550
+
551
+ function scheduleLayout() {
552
+ if (layoutFrame) cancelAnimationFrame(layoutFrame);
553
+ layoutFrame = requestAnimationFrame(() => {
554
+ layoutFrame = null;
555
+ syncSurface();
556
+ drawRelationships();
557
+ });
558
+ }
559
+
560
+ function setZoom(nextScale, anchorX = viewport.clientWidth / 2, anchorY = viewport.clientHeight / 2) {
561
+ const newScale = Math.max(.15, Math.min(2, Math.round(nextScale * 100) / 100));
562
+ const worldX = (viewport.scrollLeft + anchorX) / scale;
563
+ const worldY = (viewport.scrollTop + anchorY) / scale;
564
+ scale = newScale;
565
+ canvas.style.transform = `scale(${scale})`;
566
+ zoomResetButton.textContent = `${Math.round(scale * 100)}%`;
567
+ syncSurface();
568
+ viewport.scrollLeft = worldX * scale - anchorX;
569
+ viewport.scrollTop = worldY * scale - anchorY;
570
+ }
571
+
572
+ function visibleBounds() {
573
+ const visibleCards = cards.filter(visible);
574
+ if (!visibleCards.length) return null;
575
+ return visibleCards.reduce((bounds, card) => ({
576
+ left: Math.min(bounds.left, card.offsetLeft),
577
+ top: Math.min(bounds.top, card.offsetTop),
578
+ right: Math.max(bounds.right, card.offsetLeft + card.offsetWidth),
579
+ bottom: Math.max(bounds.bottom, card.offsetTop + card.offsetHeight)
580
+ }), { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity });
581
+ }
582
+
583
+ function centerBounds(bounds, behavior = "smooth") {
584
+ if (!bounds) return;
585
+ viewport.scrollTo({
586
+ left: ((bounds.left + bounds.right) / 2) * scale - viewport.clientWidth / 2,
587
+ top: ((bounds.top + bounds.bottom) / 2) * scale - viewport.clientHeight / 2,
588
+ behavior
589
+ });
590
+ }
591
+
592
+ function showDiagramStart(behavior = "auto") {
593
+ const bounds = visibleBounds();
594
+ if (!bounds) return;
595
+ viewport.scrollTo({
596
+ left: ((bounds.left + bounds.right) / 2) * scale - viewport.clientWidth / 2,
597
+ top: bounds.top * scale - 70,
598
+ behavior
599
+ });
600
+ }
601
+
602
+ function fitDiagram() {
603
+ const bounds = visibleBounds();
604
+ if (!bounds) return;
605
+ const availableWidth = Math.max(200, viewport.clientWidth - 28);
606
+ const availableHeight = Math.max(200, viewport.clientHeight - 28);
607
+ const fittedScale = Math.min(1, availableWidth / (bounds.right - bounds.left), availableHeight / (bounds.bottom - bounds.top));
608
+ setZoom(Math.max(.15, fittedScale));
609
+ centerBounds(bounds);
610
+ }
611
+
612
+ function highlight(tableName) {
613
+ if (!tableName) return;
614
+ const related = new Set([tableName]);
615
+ relationships.forEach(relationship => {
616
+ if (relationship.from_table === tableName) related.add(relationship.to_table);
617
+ if (relationship.to_table === tableName) related.add(relationship.from_table);
618
+ });
619
+ cards.filter(visible).forEach(card => card.classList.toggle("dimmed", !related.has(card.dataset.table)));
620
+ svg.querySelectorAll(".relationship").forEach(path => {
621
+ const active = path.dataset.from === tableName || path.dataset.to === tableName;
622
+ path.classList.toggle("active", active);
623
+ path.classList.toggle("dimmed", !active);
624
+ });
625
+ }
626
+
627
+ function clearHighlight() {
628
+ cards.forEach(card => card.classList.remove("dimmed"));
629
+ svg.querySelectorAll(".relationship").forEach(path => path.classList.remove("active", "dimmed"));
630
+ }
631
+
632
+ function placeColumn(columnCards, x, centerY) {
633
+ const totalHeight = columnCards.reduce((sum, card) => sum + card.offsetHeight, 0) + Math.max(0, columnCards.length - 1) * ROW_GAP;
634
+ let y = centerY - totalHeight / 2;
635
+ columnCards.forEach(card => {
636
+ setCardPosition(card, x, y);
637
+ y += card.offsetHeight + ROW_GAP;
638
+ });
639
+ }
640
+
641
+ function relatedCards(tableName) {
642
+ const referenceNames = new Set();
643
+ const dependentNames = new Set();
644
+ relationships.forEach(relationship => {
645
+ if (relationship.from_table === tableName && relationship.to_table !== tableName) referenceNames.add(relationship.to_table);
646
+ if (relationship.to_table === tableName && relationship.from_table !== tableName) dependentNames.add(relationship.from_table);
647
+ });
648
+ referenceNames.forEach(name => dependentNames.delete(name));
649
+ const allowed = card => internalToggle.checked || card.dataset.internal !== "true";
650
+ const byName = names => cards
651
+ .filter(card => names.has(card.dataset.table) && allowed(card))
652
+ .sort((a, b) => a.dataset.table.localeCompare(b.dataset.table));
653
+ return { references: byName(referenceNames), dependents: byName(dependentNames) };
654
+ }
655
+
656
+ function centerCard(card) {
657
+ viewport.scrollTo({
658
+ left: (card.offsetLeft + card.offsetWidth / 2) * scale - viewport.clientWidth / 2,
659
+ top: (card.offsetTop + Math.min(card.offsetHeight / 2, 220)) * scale - viewport.clientHeight / 2,
660
+ behavior: "smooth"
661
+ });
662
+ }
663
+
664
+ function tableFromUrl() {
665
+ const tableName = new URL(window.location.href).searchParams.get("table");
666
+ return cards.some(card => card.dataset.table === tableName) ? tableName : null;
667
+ }
668
+
669
+ function updateTableUrl(tableName, historyMode) {
670
+ if (!historyMode) return;
671
+ const url = new URL(window.location.href);
672
+ if (tableName) url.searchParams.set("table", tableName);
673
+ else url.searchParams.delete("table");
674
+ const method = historyMode === "replace" ? "replaceState" : "pushState";
675
+ window.history[method]({ table: tableName }, "", url);
676
+ }
677
+
678
+ function applyFilters() {
679
+ selectedTable = null;
680
+ clearFocusButton.hidden = true;
681
+ cards.forEach(card => {
682
+ card.hidden = !matchesFilters(card);
683
+ card.classList.remove("selected");
684
+ });
685
+ clearHighlight();
686
+ updateVisibleCount();
687
+ scheduleLayout();
688
+ updateTableUrl(null, "replace");
689
+ }
690
+
691
+ function focusTable(tableName, { historyMode = "push" } = {}) {
692
+ if (selectedTable === tableName) {
693
+ resetLayout({ historyMode });
694
+ return;
695
+ }
696
+
697
+ clearHighlight();
698
+ const selectedCard = cards.find(card => card.dataset.table === tableName);
699
+ if (!selectedCard) return;
700
+ const { references, dependents } = relatedCards(tableName);
701
+ const related = new Set([ selectedCard, ...references, ...dependents ]);
702
+ const viewportCenterX = (viewport.scrollLeft + viewport.clientWidth / 2) / scale;
703
+ const viewportCenterY = (viewport.scrollTop + viewport.clientHeight / 2) / scale;
704
+ const selectedX = clamp(viewportCenterX - selectedCard.offsetWidth / 2, 560, CANVAS_WIDTH - 900);
705
+ const selectedY = clamp(viewportCenterY - Math.min(selectedCard.offsetHeight / 2, 180), 300, CANVAS_HEIGHT - selectedCard.offsetHeight - 300);
706
+
707
+ cards.forEach(card => {
708
+ card.hidden = !related.has(card);
709
+ card.classList.toggle("selected", card === selectedCard);
710
+ });
711
+ setCardPosition(selectedCard, selectedX, selectedY);
712
+ placeColumn(references, selectedX - CARD_WIDTH - 120, selectedY + selectedCard.offsetHeight / 2);
713
+ placeColumn(dependents, selectedX + selectedCard.offsetWidth + 120, selectedY + selectedCard.offsetHeight / 2);
714
+
715
+ selectedTable = tableName;
716
+ clearFocusButton.hidden = false;
717
+ updateVisibleCount();
718
+ persistPositions();
719
+ scheduleLayout();
720
+ updateTableUrl(tableName, historyMode);
721
+ requestAnimationFrame(() => centerCard(selectedCard));
722
+ }
723
+
724
+ function clearFocus() {
725
+ selectedTable = null;
726
+ clearFocusButton.hidden = true;
727
+ cards.forEach(card => {
728
+ card.hidden = !matchesFilters(card);
729
+ card.classList.remove("selected");
730
+ });
731
+ clearHighlight();
732
+ updateVisibleCount();
733
+ scheduleLayout();
734
+ }
735
+
736
+ function resetLayout({ historyMode = "push", behavior = "smooth" } = {}) {
737
+ try {
738
+ localStorage.removeItem(layoutKey);
739
+ } catch (_error) {
740
+ // Reset still works for the current page.
741
+ }
742
+ clearFocus();
743
+ defaultLayout();
744
+ scheduleLayout();
745
+ updateTableUrl(null, historyMode);
746
+ requestAnimationFrame(() => showDiagramStart(behavior));
747
+ }
748
+
749
+ function reflowAfterCardResize() {
750
+ requestAnimationFrame(() => {
751
+ if (selectedTable) {
752
+ const tableName = selectedTable;
753
+ selectedTable = null;
754
+ focusTable(tableName, { historyMode: null });
755
+ } else {
756
+ defaultLayout();
757
+ persistPositions();
758
+ scheduleLayout();
759
+ requestAnimationFrame(() => showDiagramStart("smooth"));
760
+ }
761
+ });
762
+ }
763
+
764
+ function enableCardDragging(card) {
765
+ const heading = card.querySelector(".table-heading");
766
+ heading.addEventListener("pointerdown", event => {
767
+ if (event.button !== 0) return;
768
+ event.preventDefault();
769
+ event.stopPropagation();
770
+ const start = cardPosition(card);
771
+ const startX = event.clientX;
772
+ const startY = event.clientY;
773
+ let moved = false;
774
+ heading.setPointerCapture(event.pointerId);
775
+
776
+ const move = moveEvent => {
777
+ const deltaX = moveEvent.clientX - startX;
778
+ const deltaY = moveEvent.clientY - startY;
779
+ if (!moved && Math.hypot(deltaX, deltaY) < 4) return;
780
+ moved = true;
781
+ card.classList.add("dragging");
782
+ setCardPosition(card, start.x + deltaX / scale, start.y + deltaY / scale);
783
+ scheduleLayout();
784
+ };
785
+
786
+ const finish = finishEvent => {
787
+ heading.removeEventListener("pointermove", move);
788
+ heading.removeEventListener("pointerup", finish);
789
+ heading.removeEventListener("pointercancel", finish);
790
+ if (heading.hasPointerCapture(finishEvent.pointerId)) heading.releasePointerCapture(finishEvent.pointerId);
791
+ card.classList.remove("dragging");
792
+ if (moved) {
793
+ persistPositions();
794
+ scheduleLayout();
795
+ } else {
796
+ focusTable(card.dataset.table);
797
+ }
798
+ };
799
+
800
+ heading.addEventListener("pointermove", move);
801
+ heading.addEventListener("pointerup", finish);
802
+ heading.addEventListener("pointercancel", finish);
803
+ });
804
+ }
805
+
806
+ cards.forEach(card => {
807
+ card.addEventListener("mouseenter", () => { if (!selectedTable) highlight(card.dataset.table); });
808
+ card.addEventListener("mouseleave", () => { if (!selectedTable) clearHighlight(); });
809
+ enableCardDragging(card);
810
+ });
811
+
812
+ search.addEventListener("input", applyFilters);
813
+ internalToggle.addEventListener("change", applyFilters);
814
+ relationshipToggle.addEventListener("change", drawRelationships);
815
+ attributeToggle.addEventListener("change", () => {
816
+ document.body.classList.toggle("hide-attributes", !attributeToggle.checked);
817
+ reflowAfterCardResize();
818
+ });
819
+ detailToggle.addEventListener("change", () => {
820
+ document.body.classList.toggle("hide-details", !detailToggle.checked);
821
+ reflowAfterCardResize();
822
+ });
823
+ clearFocusButton.addEventListener("click", resetLayout);
824
+ resetLayoutButton.addEventListener("click", resetLayout);
825
+ zoomOutButton.addEventListener("click", () => setZoom(scale - .1));
826
+ zoomResetButton.addEventListener("click", () => setZoom(1));
827
+ zoomInButton.addEventListener("click", () => setZoom(scale + .1));
828
+ zoomFitButton.addEventListener("click", fitDiagram);
829
+ viewport.addEventListener("pointerdown", event => {
830
+ if (event.button !== 0 || event.target.closest(".table-card")) return;
831
+ event.preventDefault();
832
+ const startX = event.clientX;
833
+ const startY = event.clientY;
834
+ const startLeft = viewport.scrollLeft;
835
+ const startTop = viewport.scrollTop;
836
+ viewport.classList.add("panning");
837
+ viewport.setPointerCapture(event.pointerId);
838
+
839
+ const move = moveEvent => {
840
+ viewport.scrollLeft = startLeft - (moveEvent.clientX - startX);
841
+ viewport.scrollTop = startTop - (moveEvent.clientY - startY);
842
+ };
843
+ const finish = finishEvent => {
844
+ viewport.removeEventListener("pointermove", move);
845
+ viewport.removeEventListener("pointerup", finish);
846
+ viewport.removeEventListener("pointercancel", finish);
847
+ if (viewport.hasPointerCapture(finishEvent.pointerId)) viewport.releasePointerCapture(finishEvent.pointerId);
848
+ viewport.classList.remove("panning");
849
+ };
850
+
851
+ viewport.addEventListener("pointermove", move);
852
+ viewport.addEventListener("pointerup", finish);
853
+ viewport.addEventListener("pointercancel", finish);
854
+ });
855
+ viewport.addEventListener("wheel", event => {
856
+ if (!event.ctrlKey && !event.metaKey) return;
857
+ event.preventDefault();
858
+ const rect = viewport.getBoundingClientRect();
859
+ setZoom(scale + (event.deltaY < 0 ? .1 : -.1), event.clientX - rect.left, event.clientY - rect.top);
860
+ }, { passive: false });
861
+ document.addEventListener("keydown", event => {
862
+ if (event.target.matches("input, textarea, select")) return;
863
+ if (event.key === "Escape" && selectedTable) resetLayout();
864
+ if (event.key === "-" || event.key === "_") setZoom(scale - .1);
865
+ if (event.key === "+" || event.key === "=") setZoom(scale + .1);
866
+ if (event.key === "0") setZoom(1);
867
+ if (event.key.toLowerCase() === "f") fitDiagram();
868
+ });
869
+ window.addEventListener("resize", () => scheduleLayout());
870
+ window.addEventListener("popstate", () => {
871
+ const tableName = tableFromUrl();
872
+ if (tableName) {
873
+ selectedTable = null;
874
+ focusTable(tableName, { historyMode: null });
875
+ } else {
876
+ resetLayout({ historyMode: null });
877
+ }
878
+ });
879
+ const resizeObserver = new ResizeObserver(() => scheduleLayout());
880
+ cards.forEach(card => resizeObserver.observe(card));
881
+
882
+ const initialTable = tableFromUrl();
883
+ if (!restorePositions()) defaultLayout();
884
+ syncSurface();
885
+ setZoom(.75, 0, 0);
886
+ scheduleLayout();
887
+ requestAnimationFrame(() => {
888
+ if (initialTable) focusTable(initialTable, { historyMode: null });
889
+ else showDiagramStart();
890
+ });
891
+ JAVASCRIPT
892
+ end
893
+ end
894
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/schema_erd/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "schema_erd"
7
+ spec.version = SchemaErd::VERSION
8
+ spec.authors = [ "Dreaming Tulpa" ]
9
+ spec.email = [ "hey@dreamingtulpa.com" ]
10
+
11
+ spec.summary = "An interactive ERD for your Rails schema"
12
+ spec.description = "Mount a fast, interactive entity-relationship diagram generated directly from db/schema.rb."
13
+ spec.homepage = "https://github.com/codegestalt/schema_erd"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
21
+ spec.files = Dir["CHANGELOG.md", "Gemfile", "LICENSE.txt", "README.md", "Rakefile", "schema_erd.gemspec", "lib/**/*", "test/**/*"]
22
+ spec.require_paths = [ "lib" ]
23
+
24
+ spec.add_development_dependency "minitest", "~> 5.0"
25
+ spec.add_development_dependency "rack", ">= 2.2"
26
+ spec.add_development_dependency "rake", "~> 13.0"
27
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "test_helper"
5
+
6
+ class SchemaErdTest < Minitest::Test
7
+ SCHEMA = <<~RUBY
8
+ ActiveRecord::Schema[8.1].define(version: 2026_07_16_123456) do
9
+ create_table "accounts", force: :cascade do |t|
10
+ t.string "name", null: false
11
+ t.index ["name"], name: "index_accounts_on_name", unique: true
12
+ end
13
+
14
+ create_table "invoices", force: :cascade do |t|
15
+ t.bigint "account_id", null: false
16
+ t.decimal "total", precision: 12, scale: 2, default: "0.0", null: false
17
+ end
18
+
19
+ add_foreign_key "invoices", "accounts"
20
+ end
21
+ RUBY
22
+
23
+ def test_escapes_html_without_an_external_dependency
24
+ assert_equal "&lt;script data-name=&quot;x&quot;&gt;Tom &amp; Jerry&#39;s&lt;/script&gt;", SchemaErd::Html.escape(%(<script data-name="x">Tom & Jerry's</script>))
25
+ end
26
+
27
+ def test_parses_generated_schema_tables_attributes_indexes_and_relationships
28
+ schema = SchemaErd::Parser.call(SCHEMA)
29
+
30
+ assert_equal "2026-07-16-123456", schema.version
31
+ assert_equal %w[accounts invoices], schema.tables.map(&:name)
32
+ assert_equal %w[id name], schema.tables.first.columns.map(&:name)
33
+ assert schema.tables.first.indexes.first.unique
34
+
35
+ total = schema.tables.last.columns.find { |column| column.name == "total" }
36
+ assert_equal "decimal(12,2)", total.type
37
+ assert_equal "0.0", total.default
38
+ refute total.nullable
39
+
40
+ account_id = schema.tables.last.columns.find { |column| column.name == "account_id" }
41
+ assert_equal "accounts", account_id.foreign_key_to
42
+ assert_equal [ "invoices", "accounts", "account_id" ], schema.relationships.first.deconstruct
43
+ end
44
+
45
+ def test_renders_a_standalone_interactive_erd_page
46
+ Tempfile.create([ "schema_erd", ".rb" ]) do |file|
47
+ file.write(SCHEMA)
48
+ file.flush
49
+
50
+ response = Rack::MockRequest.new(SchemaErd::App.new(schema_path: file.path)).get("/")
51
+
52
+ assert_equal 200, response.status
53
+ assert_includes response.body, "Database ERD"
54
+ assert_includes response.body, "<aside class=\"controls-card\">"
55
+ assert_includes response.body, "data-table=\"invoices\""
56
+ assert_includes response.body, "account_id"
57
+ assert_includes response.body, "relationships"
58
+ assert_includes response.body, "id=\"zoom-out\""
59
+ assert_includes response.body, "id=\"zoom-fit\""
60
+ assert_includes response.body, "id=\"clear-focus\""
61
+ assert_includes response.body, "id=\"reset-layout\""
62
+ assert_includes response.body, "<body class=\"hide-attributes\">"
63
+ assert_includes response.body, "<input id=\"attributes\" type=\"checkbox\">"
64
+ assert_includes response.body, "hide-attributes"
65
+ assert_includes response.body, "#viewport { width: 100vw; height: 100vh"
66
+ assert_includes response.body, "function focusTable(tableName, { historyMode = \"push\" } = {})"
67
+ assert_includes response.body, "function tableFromUrl()"
68
+ assert_includes response.body, "url.searchParams.set(\"table\", tableName)"
69
+ assert_includes response.body, "window.addEventListener(\"popstate\""
70
+ assert_includes response.body, "focusTable(initialTable, { historyMode: null })"
71
+ assert_includes response.body, "function reflowAfterCardResize()"
72
+ assert_includes response.body, "fill=\"context-stroke\""
73
+ assert_includes response.body, "const halfSpan = clamp(sourceRect.height * .18, 8, 24)"
74
+ assert_includes response.body, "setZoom(.75, 0, 0)"
75
+ assert_includes response.body, "clearFocusButton.addEventListener(\"click\", resetLayout)"
76
+ assert_includes response.body, "function enableCardDragging(card)"
77
+ assert_includes response.body, "viewport.addEventListener(\"pointerdown\""
78
+ assert_includes response.body, "localStorage.setItem(layoutKey"
79
+ assert_equal "no-store", response["cache-control"]
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "minitest/autorun"
6
+ require "rack/mock"
7
+ require "schema_erd"
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: schema_erd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Dreaming Tulpa
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: Mount a fast, interactive entity-relationship diagram generated directly
55
+ from db/schema.rb.
56
+ email:
57
+ - hey@dreamingtulpa.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - Gemfile
64
+ - LICENSE.txt
65
+ - README.md
66
+ - Rakefile
67
+ - lib/schema_erd.rb
68
+ - lib/schema_erd/version.rb
69
+ - schema_erd.gemspec
70
+ - test/schema_erd_test.rb
71
+ - test/test_helper.rb
72
+ homepage: https://github.com/codegestalt/schema_erd
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ allowed_push_host: https://rubygems.org
77
+ source_code_uri: https://github.com/codegestalt/schema_erd
78
+ changelog_uri: https://github.com/codegestalt/schema_erd/blob/main/CHANGELOG.md
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.2.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.10
94
+ specification_version: 4
95
+ summary: An interactive ERD for your Rails schema
96
+ test_files: []