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 +7 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +113 -0
- data/Rakefile +12 -0
- data/lib/schema_erd/version.rb +5 -0
- data/lib/schema_erd.rb +894 -0
- data/schema_erd.gemspec +27 -0
- data/test/schema_erd_test.rb +82 -0
- data/test/test_helper.rb +7 -0
- metadata +96 -0
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
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
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 = { "&" => "&", '"' => """, "'" => "'", "<" => "<", ">" => ">" }.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
|
data/schema_erd.gemspec
ADDED
|
@@ -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 "<script data-name="x">Tom & Jerry's</script>", 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
|
data/test/test_helper.rb
ADDED
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: []
|