rails-schema 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CLAUDE.md +48 -0
- data/PROJECT.md +26 -7
- data/README.md +21 -4
- data/lib/rails/schema/assets/app.js +72 -4
- data/lib/rails/schema/assets/style.css +35 -0
- data/lib/rails/schema/assets/template.html.erb +3 -1
- data/lib/rails/schema/extractor/mongoid/association_reader.rb +59 -0
- data/lib/rails/schema/extractor/mongoid/column_reader.rb +71 -0
- data/lib/rails/schema/extractor/mongoid/model_adapter.rb +37 -0
- data/lib/rails/schema/extractor/mongoid/model_scanner.rb +135 -0
- data/lib/rails/schema/version.rb +1 -1
- data/lib/rails/schema.rb +44 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37ed34e2667a948b30551666b19657a3bf6e1a484e4fe4660a3da03c9d356f66
|
|
4
|
+
data.tar.gz: c7f9ac452ae98d4188718c47879a2b1fc2769b21d308b7d07f89779c42273f95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a21acfd8d250d9fffdd67acc95a6743167f26cf2754c4d71d35af9a74a1f450c4954eb6443fe20d8198e066f8ef8e783924ed4cab3a7b39f7f2f9006e5913af7
|
|
7
|
+
data.tar.gz: 837e5b1171e30ff17aca2e8871bed916d691203c28e8a876d92948ef38018ce23856f7263c95d96c3334c04be853b46bad48cd09101f5b7cf42da742f1db9246
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# CLAUDE.md — Project Instructions for Claude Code
|
|
2
|
+
|
|
3
|
+
## Project
|
|
4
|
+
|
|
5
|
+
`rails-schema` — a Ruby gem that generates an interactive HTML entity-relationship diagram from a Rails app's models, associations, and columns. Single self-contained HTML output, no server needed.
|
|
6
|
+
|
|
7
|
+
## Quick Commands
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle exec rspec # Run all tests
|
|
11
|
+
bundle exec rubocop # Run linter
|
|
12
|
+
bundle exec rspec spec/rails/schema/extractor/mongoid/ # Mongoid tests only
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
Three-layer pipeline: **Extractor → Transformer → Renderer**
|
|
18
|
+
|
|
19
|
+
- `lib/rails/schema.rb` — entry point, `generate` dispatches to ActiveRecord or Mongoid pipeline
|
|
20
|
+
- `lib/rails/schema/extractor/` — model discovery, column/association reading, schema file parsing
|
|
21
|
+
- `lib/rails/schema/extractor/mongoid/` — Mongoid-specific extractors (model_scanner, model_adapter, column_reader, association_reader)
|
|
22
|
+
- `lib/rails/schema/transformer/` — builds normalized graph JSON (nodes + edges + metadata)
|
|
23
|
+
- `lib/rails/schema/renderer/` — ERB-based HTML generation with inlined JS/CSS/data
|
|
24
|
+
- `lib/rails/schema/assets/` — frontend (vanilla JS + d3-force, CSS, HTML template)
|
|
25
|
+
|
|
26
|
+
## Key Conventions
|
|
27
|
+
|
|
28
|
+
- Ruby >= 3.2, Rails >= 6.0
|
|
29
|
+
- Double quotes for strings (RuboCop enforced)
|
|
30
|
+
- RuboCop max method length: 15 lines, default ABC/complexity limits
|
|
31
|
+
- No `Style/Documentation` required
|
|
32
|
+
- `spec/support/test_models.rb` — ActiveRecord test models (User, Post, Comment, Tag)
|
|
33
|
+
- `spec/support/mongoid_test_models.rb` — Mongoid test models (MongoidUser, MongoidPost, MongoidComment)
|
|
34
|
+
- Tests use in-memory SQLite for ActiveRecord, stubbed Mongoid::Document for Mongoid
|
|
35
|
+
- `config.before(:each) { Rails::Schema.reset_configuration! }` in spec_helper
|
|
36
|
+
|
|
37
|
+
## Schema Formats
|
|
38
|
+
|
|
39
|
+
`config.schema_format` supports: `:auto`, `:ruby`, `:sql`, `:mongoid`
|
|
40
|
+
|
|
41
|
+
- `:auto` — tries schema.rb, falls back to structure.sql; auto-detects Mongoid if `Mongoid::Document` is defined
|
|
42
|
+
- `:mongoid` — runtime introspection of Mongoid models (no schema file needed)
|
|
43
|
+
|
|
44
|
+
## Testing Notes
|
|
45
|
+
|
|
46
|
+
- Always run `bundle exec rubocop` before committing — CI checks both tests and linting
|
|
47
|
+
- Mongoid specs stub `Rails::Engine` and `Rails::Application` since they may not exist in test env
|
|
48
|
+
- SimpleCov is enabled; coverage report goes to `coverage/`
|
data/PROJECT.md
CHANGED
|
@@ -43,7 +43,7 @@ Rails::Schema.generate(output: "docs/schema.html")
|
|
|
43
43
|
|
|
44
44
|
| Layer | Responsibility | Key Classes |
|
|
45
45
|
|---|---|---|
|
|
46
|
-
| **Extractor** | Introspects Rails environment; collects models, columns, associations | `Rails::Schema::Extractor::ModelScanner`, `ColumnReader`, `AssociationReader`, `SchemaFileParser`, `StructureSqlParser` |
|
|
46
|
+
| **Extractor** | Introspects Rails environment; collects models, columns, associations | `Rails::Schema::Extractor::ModelScanner`, `ColumnReader`, `AssociationReader`, `SchemaFileParser`, `StructureSqlParser`, plus `Mongoid::ModelScanner`, `Mongoid::ModelAdapter`, `Mongoid::ColumnReader`, `Mongoid::AssociationReader` |
|
|
47
47
|
| **Transformer** | Normalizes extracted data into a serializable graph structure (nodes + edges + metadata) | `Rails::Schema::Transformer::GraphBuilder`, `Node`, `Edge` |
|
|
48
48
|
| **Renderer** | Takes the graph data and injects it into an HTML/JS/CSS template using ERB | `Rails::Schema::Renderer::HtmlGenerator` |
|
|
49
49
|
| **Railtie** | Provides the `rails_schema:generate` rake task | `Rails::Schema::Railtie` |
|
|
@@ -84,7 +84,7 @@ end
|
|
|
84
84
|
|
|
85
85
|
### 3.2 Model Discovery
|
|
86
86
|
|
|
87
|
-
`ModelScanner` discovers models by:
|
|
87
|
+
`ModelScanner` (ActiveRecord) discovers models by:
|
|
88
88
|
|
|
89
89
|
1. Calling `Rails.application.eager_load!` (with Zeitwerk support and multiple fallback strategies)
|
|
90
90
|
2. Collecting `ActiveRecord::Base.descendants`
|
|
@@ -94,6 +94,14 @@ end
|
|
|
94
94
|
|
|
95
95
|
When `schema_data` is available, table existence is checked against parsed schema data instead of hitting the database.
|
|
96
96
|
|
|
97
|
+
`Mongoid::ModelScanner` discovers Mongoid models by:
|
|
98
|
+
|
|
99
|
+
1. Eager-loading models via Zeitwerk or file glob (with fallback strategies)
|
|
100
|
+
2. Scanning `ObjectSpace` for classes that include `Mongoid::Document`
|
|
101
|
+
3. Also eager-loads models from mounted Rails engines
|
|
102
|
+
4. Applying `exclude_models` configuration
|
|
103
|
+
5. Returning models sorted by name, wrapped in `ModelAdapter` for GraphBuilder compatibility
|
|
104
|
+
|
|
97
105
|
### 3.3 Schema File Parser
|
|
98
106
|
|
|
99
107
|
`SchemaFileParser` provides database-free column extraction:
|
|
@@ -218,7 +226,7 @@ Rails::Schema.configure do |config|
|
|
|
218
226
|
config.title = "Database Schema" # Page title
|
|
219
227
|
config.theme = :auto # :light, :dark, :auto
|
|
220
228
|
config.expand_columns = false # Start with columns expanded
|
|
221
|
-
config.schema_format = :auto # :auto, :ruby, or :
|
|
229
|
+
config.schema_format = :auto # :auto, :ruby, :sql, or :mongoid
|
|
222
230
|
end
|
|
223
231
|
```
|
|
224
232
|
|
|
@@ -239,7 +247,12 @@ rails-schema/
|
|
|
239
247
|
│ │ ├── association_reader.rb # Reads reflections
|
|
240
248
|
│ │ ├── column_reader.rb # Reads columns (schema_data or AR)
|
|
241
249
|
│ │ ├── schema_file_parser.rb # Parses db/schema.rb
|
|
242
|
-
│ │
|
|
250
|
+
│ │ ├── structure_sql_parser.rb # Parses db/structure.sql
|
|
251
|
+
│ │ └── mongoid/
|
|
252
|
+
│ │ ├── model_scanner.rb # Discovers Mongoid models
|
|
253
|
+
│ │ ├── model_adapter.rb # Wraps Mongoid model for GraphBuilder
|
|
254
|
+
│ │ ├── column_reader.rb # Reads Mongoid fields
|
|
255
|
+
│ │ └── association_reader.rb # Reads Mongoid relations
|
|
243
256
|
│ ├── transformer/
|
|
244
257
|
│ │ ├── graph_builder.rb # Builds node/edge graph
|
|
245
258
|
│ │ ├── node.rb # Value object
|
|
@@ -255,7 +268,8 @@ rails-schema/
|
|
|
255
268
|
├── spec/
|
|
256
269
|
│ ├── spec_helper.rb
|
|
257
270
|
│ ├── support/
|
|
258
|
-
│ │
|
|
271
|
+
│ │ ├── test_models.rb # User, Post, Comment, Tag AR models
|
|
272
|
+
│ │ └── mongoid_test_models.rb # MongoidUser, MongoidPost, MongoidComment
|
|
259
273
|
│ └── rails/schema/
|
|
260
274
|
│ ├── rails_schema_spec.rb
|
|
261
275
|
│ ├── configuration_spec.rb
|
|
@@ -264,7 +278,12 @@ rails-schema/
|
|
|
264
278
|
│ │ ├── column_reader_spec.rb
|
|
265
279
|
│ │ ├── association_reader_spec.rb
|
|
266
280
|
│ │ ├── schema_file_parser_spec.rb
|
|
267
|
-
│ │
|
|
281
|
+
│ │ ├── structure_sql_parser_spec.rb
|
|
282
|
+
│ │ └── mongoid/
|
|
283
|
+
│ │ ├── model_scanner_spec.rb
|
|
284
|
+
│ │ ├── model_adapter_spec.rb
|
|
285
|
+
│ │ ├── column_reader_spec.rb
|
|
286
|
+
│ │ └── association_reader_spec.rb
|
|
268
287
|
│ ├── transformer/
|
|
269
288
|
│ │ └── graph_builder_spec.rb
|
|
270
289
|
│ └── renderer/
|
|
@@ -323,7 +342,7 @@ spec.add_dependency "railties", ">= 6.0"
|
|
|
323
342
|
| Renderer | Output tests — verify HTML structure, embedded data, script injection safety |
|
|
324
343
|
| Configuration | Unit tests for defaults and attribute setting |
|
|
325
344
|
|
|
326
|
-
**
|
|
345
|
+
**153 tests, all passing.** Run with `bundle exec rspec`.
|
|
327
346
|
|
|
328
347
|
---
|
|
329
348
|
|
data/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Rails::Schema.configure do |config|
|
|
|
48
48
|
config.title = "My App Schema"
|
|
49
49
|
config.theme = :auto # :auto, :light, or :dark
|
|
50
50
|
config.expand_columns = false # start with columns collapsed
|
|
51
|
-
config.schema_format = :auto # :auto, :ruby, or :
|
|
51
|
+
config.schema_format = :auto # :auto, :ruby, :sql, or :mongoid
|
|
52
52
|
config.exclude_models = [
|
|
53
53
|
"ActiveStorage::Blob",
|
|
54
54
|
"ActiveStorage::Attachment",
|
|
@@ -63,7 +63,7 @@ end
|
|
|
63
63
|
| `title` | `"Database Schema"` | Title shown in the HTML page |
|
|
64
64
|
| `theme` | `:auto` | Color theme — `:auto`, `:light`, or `:dark` |
|
|
65
65
|
| `expand_columns` | `false` | Whether model nodes start with columns expanded |
|
|
66
|
-
| `schema_format` | `:auto` | Schema source — `:auto`, `:ruby`, or `:
|
|
66
|
+
| `schema_format` | `:auto` | Schema source — `:auto`, `:ruby`, `:sql`, or `:mongoid` (see below) |
|
|
67
67
|
| `exclude_models` | `[]` | Models to hide; supports exact names and wildcard prefixes (`"ActionMailbox::*"`) |
|
|
68
68
|
|
|
69
69
|
### Schema format
|
|
@@ -72,17 +72,34 @@ Rails projects can use either `db/schema.rb` (Ruby DSL) or `db/structure.sql` (r
|
|
|
72
72
|
|
|
73
73
|
| Value | Behavior |
|
|
74
74
|
|-------|----------|
|
|
75
|
-
| `:auto` | Tries `db/schema.rb` first, falls back to `db/structure.sql
|
|
75
|
+
| `:auto` | Tries `db/schema.rb` first, falls back to `db/structure.sql`. If Mongoid is detected, uses the Mongoid pipeline instead |
|
|
76
76
|
| `:ruby` | Only reads `db/schema.rb` |
|
|
77
77
|
| `:sql` | Only reads `db/structure.sql` |
|
|
78
|
+
| `:mongoid` | Introspects Mongoid models directly (see below) |
|
|
79
|
+
|
|
80
|
+
### Mongoid support
|
|
81
|
+
|
|
82
|
+
If your app uses [Mongoid](https://www.mongodb.com/docs/mongoid/current/) instead of ActiveRecord, rails-schema can introspect your Mongoid models directly — no schema file needed.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Rails::Schema.configure do |config|
|
|
86
|
+
config.schema_format = :mongoid
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
When set to `:auto`, Mongoid mode activates automatically if `Mongoid::Document` is defined.
|
|
91
|
+
|
|
92
|
+
The Mongoid pipeline reads fields, types, defaults, and presence validations from your models, and discovers all association types including `embeds_many`, `embeds_one`, `embedded_in`, and `has_and_belongs_to_many`.
|
|
78
93
|
|
|
79
94
|
## How it works
|
|
80
95
|
|
|
81
96
|
The gem parses your `db/schema.rb` or `db/structure.sql` file to extract table and column information — **no database connection required**. It also introspects loaded ActiveRecord models for association metadata. This means the gem works even if you don't have a local database set up, as long as a schema file is present (which is standard in Rails projects under version control).
|
|
82
97
|
|
|
98
|
+
For Mongoid apps, the gem introspects model classes at runtime to read field definitions, associations, and validations — no schema file or database connection required.
|
|
99
|
+
|
|
83
100
|
## Features
|
|
84
101
|
|
|
85
|
-
- **No database required** — reads from `db/schema.rb
|
|
102
|
+
- **No database required** — reads from `db/schema.rb`, `db/structure.sql`, or Mongoid model introspection
|
|
86
103
|
- **Force-directed layout** — models cluster naturally by association density
|
|
87
104
|
- **Searchable sidebar** — filter models by name or table
|
|
88
105
|
- **Click-to-focus** — click a model to highlight its neighborhood, fading unrelated models
|
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
var nodes = data.nodes;
|
|
7
7
|
var edges = data.edges;
|
|
8
8
|
|
|
9
|
+
// Hide legend items that don't apply to the current schema mode
|
|
10
|
+
var mode = data.metadata && data.metadata.mode;
|
|
11
|
+
if (mode) {
|
|
12
|
+
var hideMode = mode === "mongoid" ? "active_record" : "mongoid";
|
|
13
|
+
document.querySelectorAll('.legend-item[data-mode="' + hideMode + '"]').forEach(function(el) {
|
|
14
|
+
el.style.display = "none";
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
// State
|
|
10
19
|
var selectedNode = null;
|
|
11
20
|
var visibleModels = new Set(nodes.map(function(n) { return n.id; }));
|
|
@@ -35,7 +44,10 @@
|
|
|
35
44
|
belongs_to: "--edge-belongs-to",
|
|
36
45
|
has_many: "--edge-has-many",
|
|
37
46
|
has_one: "--edge-has-one",
|
|
38
|
-
has_and_belongs_to_many: "--edge-habtm"
|
|
47
|
+
has_and_belongs_to_many: "--edge-habtm",
|
|
48
|
+
embeds_many: "--edge-embeds-many",
|
|
49
|
+
embeds_one: "--edge-embeds-one",
|
|
50
|
+
embedded_in: "--edge-embedded-in"
|
|
39
51
|
};
|
|
40
52
|
|
|
41
53
|
function buildMarkers() {
|
|
@@ -113,7 +125,10 @@
|
|
|
113
125
|
belongs_to: { start: "many", end: "one" },
|
|
114
126
|
has_many: { start: "one", end: "many" },
|
|
115
127
|
has_one: { start: "one", end: "one" },
|
|
116
|
-
has_and_belongs_to_many: { start: "many", end: "many" }
|
|
128
|
+
has_and_belongs_to_many: { start: "many", end: "many" },
|
|
129
|
+
embeds_many: { start: "one", end: "many" },
|
|
130
|
+
embeds_one: { start: "one", end: "one" },
|
|
131
|
+
embedded_in: { start: "many", end: "one" }
|
|
117
132
|
};
|
|
118
133
|
|
|
119
134
|
function getColumnY(node, colName) {
|
|
@@ -142,10 +157,11 @@
|
|
|
142
157
|
var srcColY = 0;
|
|
143
158
|
var tgtColY = 0;
|
|
144
159
|
|
|
145
|
-
if (assocType === "belongs_to") {
|
|
160
|
+
if (assocType === "belongs_to" || assocType === "embedded_in") {
|
|
146
161
|
srcColY = getColumnY(src, fk);
|
|
147
162
|
tgtColY = getPkColumnY(tgt);
|
|
148
|
-
} else if (assocType === "has_many" || assocType === "has_one"
|
|
163
|
+
} else if (assocType === "has_many" || assocType === "has_one" ||
|
|
164
|
+
assocType === "embeds_many" || assocType === "embeds_one") {
|
|
149
165
|
srcColY = getPkColumnY(src);
|
|
150
166
|
tgtColY = getColumnY(tgt, fk);
|
|
151
167
|
} else {
|
|
@@ -251,6 +267,9 @@
|
|
|
251
267
|
return m ? "url(#marker-" + m.end + "-" + d.data.association_type + ")" : null;
|
|
252
268
|
});
|
|
253
269
|
|
|
270
|
+
eGroups.append("rect")
|
|
271
|
+
.attr("class", "edge-label-bg");
|
|
272
|
+
|
|
254
273
|
eGroups.append("text")
|
|
255
274
|
.attr("class", "edge-label")
|
|
256
275
|
.attr("text-anchor", "middle")
|
|
@@ -414,10 +433,59 @@
|
|
|
414
433
|
.attr("y", labelY);
|
|
415
434
|
});
|
|
416
435
|
|
|
436
|
+
resolveEdgeLabelOverlaps();
|
|
437
|
+
|
|
417
438
|
nodeGroup.selectAll(".node-group")
|
|
418
439
|
.attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; });
|
|
419
440
|
}
|
|
420
441
|
|
|
442
|
+
function resolveEdgeLabelOverlaps() {
|
|
443
|
+
var labels = [];
|
|
444
|
+
edgeGroup.selectAll(".edge-group text").each(function() {
|
|
445
|
+
var el = d3.select(this);
|
|
446
|
+
var x = +el.attr("x");
|
|
447
|
+
var y = +el.attr("y");
|
|
448
|
+
var text = el.text();
|
|
449
|
+
var w = text.length * 6; // approximate width at 10px font
|
|
450
|
+
var h = 14; // approximate height
|
|
451
|
+
labels.push({ el: el, x: x, y: y, w: w, h: h });
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Run a few nudge passes
|
|
455
|
+
for (var pass = 0; pass < 3; pass++) {
|
|
456
|
+
for (var i = 0; i < labels.length; i++) {
|
|
457
|
+
for (var j = i + 1; j < labels.length; j++) {
|
|
458
|
+
var a = labels[i], b = labels[j];
|
|
459
|
+
var overlapX = (a.w + b.w) / 2 - Math.abs(a.x - b.x);
|
|
460
|
+
var overlapY = (a.h + b.h) / 2 - Math.abs(a.y - b.y);
|
|
461
|
+
if (overlapX > 0 && overlapY > 0) {
|
|
462
|
+
// Push apart vertically
|
|
463
|
+
var shift = (overlapY / 2) + 2;
|
|
464
|
+
if (a.y <= b.y) {
|
|
465
|
+
a.y -= shift;
|
|
466
|
+
b.y += shift;
|
|
467
|
+
} else {
|
|
468
|
+
a.y += shift;
|
|
469
|
+
b.y -= shift;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Apply resolved positions and update background rects
|
|
477
|
+
labels.forEach(function(l) {
|
|
478
|
+
l.el.attr("y", l.y);
|
|
479
|
+
var bg = d3.select(l.el.node().previousElementSibling);
|
|
480
|
+
if (bg.classed("edge-label-bg")) {
|
|
481
|
+
bg.attr("x", l.x - l.w / 2)
|
|
482
|
+
.attr("y", l.y - l.h + 2)
|
|
483
|
+
.attr("width", l.w)
|
|
484
|
+
.attr("height", l.h);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
421
489
|
// Drag behavior
|
|
422
490
|
function dragStarted(event, d) {
|
|
423
491
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
--edge-has-many: #2ec4b6;
|
|
18
18
|
--edge-has-one: #ff6b6b;
|
|
19
19
|
--edge-habtm: #ffd166;
|
|
20
|
+
--edge-embeds-many: #a855f7;
|
|
21
|
+
--edge-embeds-one: #c084fc;
|
|
22
|
+
--edge-embedded-in: #a855f7;
|
|
20
23
|
--shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
21
24
|
--sidebar-width: 280px;
|
|
22
25
|
--detail-width: 320px;
|
|
@@ -42,6 +45,9 @@
|
|
|
42
45
|
--edge-has-many: #2ec4b6;
|
|
43
46
|
--edge-has-one: #ff6b6b;
|
|
44
47
|
--edge-habtm: #ffd166;
|
|
48
|
+
--edge-embeds-many: #a855f7;
|
|
49
|
+
--edge-embeds-one: #c084fc;
|
|
50
|
+
--edge-embedded-in: #a855f7;
|
|
45
51
|
--shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -65,6 +71,9 @@
|
|
|
65
71
|
--edge-has-many: #2ec4b6;
|
|
66
72
|
--edge-has-one: #ff6b6b;
|
|
67
73
|
--edge-habtm: #ffd166;
|
|
74
|
+
--edge-embeds-many: #a855f7;
|
|
75
|
+
--edge-embeds-one: #c084fc;
|
|
76
|
+
--edge-embedded-in: #a855f7;
|
|
68
77
|
--shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
69
78
|
}
|
|
70
79
|
}
|
|
@@ -318,6 +327,9 @@ body {
|
|
|
318
327
|
.edge-line.has_many { stroke: var(--edge-has-many); }
|
|
319
328
|
.edge-line.has_one { stroke: var(--edge-has-one); }
|
|
320
329
|
.edge-line.has_and_belongs_to_many { stroke: var(--edge-habtm); }
|
|
330
|
+
.edge-line.embeds_many { stroke: var(--edge-embeds-many); stroke-dasharray: 4 2; }
|
|
331
|
+
.edge-line.embeds_one { stroke: var(--edge-embeds-one); stroke-dasharray: 4 2; }
|
|
332
|
+
.edge-line.embedded_in { stroke: var(--edge-embedded-in); stroke-dasharray: 4 2; }
|
|
321
333
|
|
|
322
334
|
.edge-line.through {
|
|
323
335
|
stroke-dasharray: 6 3;
|
|
@@ -334,6 +346,13 @@ body {
|
|
|
334
346
|
pointer-events: none;
|
|
335
347
|
}
|
|
336
348
|
|
|
349
|
+
.edge-label-bg {
|
|
350
|
+
fill: var(--bg-primary);
|
|
351
|
+
opacity: 0.85;
|
|
352
|
+
rx: 2;
|
|
353
|
+
pointer-events: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
337
356
|
/* Faded state for non-focused elements */
|
|
338
357
|
.faded { opacity: 0.15; transition: opacity 0.3s; }
|
|
339
358
|
.highlighted { transition: opacity 0.3s; }
|
|
@@ -473,6 +492,22 @@ body {
|
|
|
473
492
|
.legend-line.has_many { background: var(--edge-has-many); }
|
|
474
493
|
.legend-line.has_one { background: var(--edge-has-one); }
|
|
475
494
|
.legend-line.habtm { background: var(--edge-habtm); }
|
|
495
|
+
.legend-line.embeds_many {
|
|
496
|
+
background-image: repeating-linear-gradient(
|
|
497
|
+
to right,
|
|
498
|
+
var(--edge-embeds-many) 0, var(--edge-embeds-many) 4px,
|
|
499
|
+
transparent 4px, transparent 6px
|
|
500
|
+
);
|
|
501
|
+
background-color: transparent;
|
|
502
|
+
}
|
|
503
|
+
.legend-line.embeds_one {
|
|
504
|
+
background-image: repeating-linear-gradient(
|
|
505
|
+
to right,
|
|
506
|
+
var(--edge-embeds-one) 0, var(--edge-embeds-one) 4px,
|
|
507
|
+
transparent 4px, transparent 6px
|
|
508
|
+
);
|
|
509
|
+
background-color: transparent;
|
|
510
|
+
}
|
|
476
511
|
|
|
477
512
|
.legend-line.through {
|
|
478
513
|
background: var(--edge-color);
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
<span class="legend-item"><span class="legend-line has_many"></span> has_many (1:M)</span>
|
|
27
27
|
<span class="legend-item"><span class="legend-line has_one"></span> has_one (1:1)</span>
|
|
28
28
|
<span class="legend-item"><span class="legend-line habtm"></span> habtm (M:M)</span>
|
|
29
|
-
<span class="legend-item"><span class="legend-line
|
|
29
|
+
<span class="legend-item" data-mode="mongoid"><span class="legend-line embeds_many"></span> embeds_many</span>
|
|
30
|
+
<span class="legend-item" data-mode="mongoid"><span class="legend-line embeds_one"></span> embeds_one</span>
|
|
31
|
+
<span class="legend-item" data-mode="active_record"><span class="legend-line through"></span> :through</span>
|
|
30
32
|
<span class="legend-item"><span class="legend-line polymorphic"></span> polymorphic</span>
|
|
31
33
|
</div>
|
|
32
34
|
<div class="spacer"></div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Schema
|
|
5
|
+
module Extractor
|
|
6
|
+
module Mongoid
|
|
7
|
+
class AssociationReader
|
|
8
|
+
ASSOCIATION_TYPE_MAP = {
|
|
9
|
+
"Mongoid::Association::Referenced::HasMany" => "has_many",
|
|
10
|
+
"Mongoid::Association::Referenced::HasOne" => "has_one",
|
|
11
|
+
"Mongoid::Association::Referenced::BelongsTo" => "belongs_to",
|
|
12
|
+
"Mongoid::Association::Referenced::HasAndBelongsToMany" => "has_and_belongs_to_many",
|
|
13
|
+
"Mongoid::Association::Embedded::EmbedsMany" => "embeds_many",
|
|
14
|
+
"Mongoid::Association::Embedded::EmbedsOne" => "embeds_one",
|
|
15
|
+
"Mongoid::Association::Embedded::EmbeddedIn" => "embedded_in"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def read(model)
|
|
19
|
+
model.relations.filter_map do |name, metadata|
|
|
20
|
+
next if skip_association?(metadata)
|
|
21
|
+
|
|
22
|
+
build_association_data(model, name, metadata)
|
|
23
|
+
end
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
warn "[rails-schema] Could not read Mongoid relations for #{model.name}: #{e.class}: #{e.message}"
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def skip_association?(metadata)
|
|
32
|
+
association_type_string(metadata) == "belongs_to" && metadata.polymorphic?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_association_data(model, name, metadata)
|
|
36
|
+
{
|
|
37
|
+
from: model.name,
|
|
38
|
+
to: metadata.class_name,
|
|
39
|
+
association_type: association_type_string(metadata),
|
|
40
|
+
label: name.to_s,
|
|
41
|
+
foreign_key: metadata.respond_to?(:foreign_key) ? metadata.foreign_key&.to_s : nil,
|
|
42
|
+
through: nil,
|
|
43
|
+
polymorphic: metadata.respond_to?(:as) && metadata.as ? true : false
|
|
44
|
+
}
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
warn "[rails-schema] Could not read Mongoid association #{name} on #{model.name}: " \
|
|
47
|
+
"#{e.class}: #{e.message}"
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def association_type_string(metadata)
|
|
52
|
+
# metadata.class returns the association class (e.g. Mongoid::Association::Referenced::HasMany)
|
|
53
|
+
ASSOCIATION_TYPE_MAP.fetch(metadata.class.name, metadata.class.name)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Schema
|
|
5
|
+
module Extractor
|
|
6
|
+
module Mongoid
|
|
7
|
+
class ColumnReader
|
|
8
|
+
TYPE_MAP = {
|
|
9
|
+
"String" => "string",
|
|
10
|
+
"Integer" => "integer",
|
|
11
|
+
"Float" => "float",
|
|
12
|
+
"BigDecimal" => "decimal",
|
|
13
|
+
"Date" => "date",
|
|
14
|
+
"Time" => "datetime",
|
|
15
|
+
"DateTime" => "datetime",
|
|
16
|
+
"Array" => "array",
|
|
17
|
+
"Hash" => "hash",
|
|
18
|
+
"Regexp" => "regexp",
|
|
19
|
+
"Symbol" => "symbol",
|
|
20
|
+
"Range" => "range",
|
|
21
|
+
"BSON::ObjectId" => "object_id",
|
|
22
|
+
"Mongoid::Boolean" => "boolean",
|
|
23
|
+
"TrueClass" => "boolean",
|
|
24
|
+
"FalseClass" => "boolean"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def read(model)
|
|
28
|
+
model.fields.map do |name, field|
|
|
29
|
+
{
|
|
30
|
+
name: name,
|
|
31
|
+
type: map_type(field.type),
|
|
32
|
+
nullable: !required_field?(model, name),
|
|
33
|
+
primary: name == "_id",
|
|
34
|
+
default: format_default(field.default_val)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
warn "[rails-schema] Could not read Mongoid fields for #{model.name}: #{e.class}: #{e.message}"
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def required_field?(model, field_name)
|
|
45
|
+
return true if field_name == "_id"
|
|
46
|
+
return false unless model.respond_to?(:validators_on)
|
|
47
|
+
|
|
48
|
+
model.validators_on(field_name).any? do |v|
|
|
49
|
+
v.class.name.include?("PresenceValidator")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def map_type(type)
|
|
54
|
+
return "object" if type.nil?
|
|
55
|
+
|
|
56
|
+
TYPE_MAP.fetch(type.name, type.name.downcase)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def format_default(default)
|
|
60
|
+
case default
|
|
61
|
+
when Proc
|
|
62
|
+
"(dynamic)"
|
|
63
|
+
else
|
|
64
|
+
default
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Schema
|
|
5
|
+
module Extractor
|
|
6
|
+
module Mongoid
|
|
7
|
+
class ModelAdapter
|
|
8
|
+
attr_reader :model
|
|
9
|
+
|
|
10
|
+
def initialize(model)
|
|
11
|
+
@model = model
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name
|
|
15
|
+
model.name
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def table_name
|
|
19
|
+
model.collection_name.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def respond_to_missing?(method, include_private = false)
|
|
23
|
+
model.respond_to?(method, include_private) || super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def method_missing(method, ...)
|
|
27
|
+
if model.respond_to?(method)
|
|
28
|
+
model.send(method, ...)
|
|
29
|
+
else
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Schema
|
|
5
|
+
module Extractor
|
|
6
|
+
module Mongoid
|
|
7
|
+
class ModelScanner
|
|
8
|
+
def initialize(configuration: ::Rails::Schema.configuration)
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def scan
|
|
13
|
+
eager_load_models!
|
|
14
|
+
|
|
15
|
+
candidates = ObjectSpace.each_object(Class).select do |klass|
|
|
16
|
+
klass.include?(::Mongoid::Document)
|
|
17
|
+
rescue StandardError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
named = candidates.reject { |m| m.name.nil? }
|
|
22
|
+
included = named.reject { |m| excluded?(m) }
|
|
23
|
+
|
|
24
|
+
included.sort_by(&:name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def eager_load_models!
|
|
30
|
+
return unless defined?(::Rails.application) && ::Rails.application
|
|
31
|
+
|
|
32
|
+
if zeitwerk_available?
|
|
33
|
+
eager_load_via_zeitwerk!
|
|
34
|
+
else
|
|
35
|
+
eager_load_via_application!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
eager_load_engine_models!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def eager_load_via_zeitwerk!
|
|
42
|
+
loader = ::Rails.autoloaders.main
|
|
43
|
+
models_path = ::Rails.root&.join("app", "models")&.to_s
|
|
44
|
+
|
|
45
|
+
if models_path && File.directory?(models_path) && loader.respond_to?(:eager_load_dir)
|
|
46
|
+
loader.eager_load_dir(models_path)
|
|
47
|
+
else
|
|
48
|
+
loader.eager_load
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
warn "[rails-schema] Zeitwerk eager_load failed (#{e.class}: #{e.message}), " \
|
|
52
|
+
"trying Rails.application.eager_load!"
|
|
53
|
+
eager_load_via_application!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def eager_load_via_application!
|
|
57
|
+
::Rails.application.eager_load!
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
warn "[rails-schema] eager_load! failed (#{e.class}: #{e.message}), " \
|
|
60
|
+
"falling back to per-file model loading"
|
|
61
|
+
eager_load_model_files!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def eager_load_model_files!
|
|
65
|
+
return unless defined?(::Rails.root) && ::Rails.root
|
|
66
|
+
|
|
67
|
+
models_path = ::Rails.root.join("app", "models")
|
|
68
|
+
return unless models_path.exist?
|
|
69
|
+
|
|
70
|
+
Dir.glob(models_path.join("**/*.rb")).each do |file|
|
|
71
|
+
require file
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
warn "[rails-schema] Could not load #{file}: #{e.class}: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def eager_load_engine_models!
|
|
78
|
+
return unless defined?(::Rails::Engine)
|
|
79
|
+
|
|
80
|
+
::Rails::Engine.subclasses.each do |engine_class|
|
|
81
|
+
next if engine_class <= ::Rails::Application
|
|
82
|
+
|
|
83
|
+
engine = engine_class.instance
|
|
84
|
+
next unless engine
|
|
85
|
+
|
|
86
|
+
eager_load_engine(engine)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
warn "[rails-schema] Could not eager-load engine #{engine_class}: #{e.class}: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def eager_load_engine(engine)
|
|
93
|
+
models_paths = engine.paths["app/models"]&.existent || []
|
|
94
|
+
return if models_paths.empty?
|
|
95
|
+
|
|
96
|
+
if zeitwerk_available?
|
|
97
|
+
eager_load_engine_zeitwerk(models_paths)
|
|
98
|
+
else
|
|
99
|
+
eager_load_engine_files(models_paths)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def zeitwerk_available?
|
|
104
|
+
defined?(::Rails.autoloaders) && ::Rails.autoloaders.respond_to?(:main)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def eager_load_engine_zeitwerk(paths)
|
|
108
|
+
loader = ::Rails.autoloaders.main
|
|
109
|
+
paths.each { |path| loader.eager_load_dir(path) if loader.respond_to?(:eager_load_dir) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def eager_load_engine_files(paths)
|
|
113
|
+
paths.each do |path|
|
|
114
|
+
Dir.glob(File.join(path, "**/*.rb")).each do |file|
|
|
115
|
+
require file
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
warn "[rails-schema] Could not load engine model #{file}: #{e.class}: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def excluded?(model)
|
|
123
|
+
@configuration.exclude_models.any? do |pattern|
|
|
124
|
+
if pattern.end_with?("*")
|
|
125
|
+
model.name.start_with?(pattern.delete_suffix("*"))
|
|
126
|
+
else
|
|
127
|
+
model.name == pattern
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/rails/schema/version.rb
CHANGED
data/lib/rails/schema.rb
CHANGED
|
@@ -30,15 +30,58 @@ module Rails
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def generate(output: nil)
|
|
33
|
+
if mongoid_mode?
|
|
34
|
+
generate_mongoid(output: output)
|
|
35
|
+
else
|
|
36
|
+
generate_active_record(output: output)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mongoid_mode?
|
|
41
|
+
case configuration.schema_format
|
|
42
|
+
when :mongoid
|
|
43
|
+
true
|
|
44
|
+
when :auto
|
|
45
|
+
defined?(::Mongoid::Document) ? true : false
|
|
46
|
+
else
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def generate_active_record(output:)
|
|
33
54
|
schema_data = parse_schema
|
|
34
55
|
models = Extractor::ModelScanner.new(schema_data: schema_data).scan
|
|
35
56
|
column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
|
|
36
57
|
graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
|
|
58
|
+
graph_data[:metadata][:mode] = "active_record"
|
|
37
59
|
generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
|
|
38
60
|
generator.render_to_file(output)
|
|
39
61
|
end
|
|
40
62
|
|
|
41
|
-
|
|
63
|
+
def require_mongoid_extractors
|
|
64
|
+
require_relative "schema/extractor/mongoid/model_scanner"
|
|
65
|
+
require_relative "schema/extractor/mongoid/model_adapter"
|
|
66
|
+
require_relative "schema/extractor/mongoid/column_reader"
|
|
67
|
+
require_relative "schema/extractor/mongoid/association_reader"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def generate_mongoid(output:)
|
|
71
|
+
require_mongoid_extractors
|
|
72
|
+
|
|
73
|
+
raw_models = Extractor::Mongoid::ModelScanner.new.scan
|
|
74
|
+
models = raw_models.map { |m| Extractor::Mongoid::ModelAdapter.new(m) }
|
|
75
|
+
column_reader = Extractor::Mongoid::ColumnReader.new
|
|
76
|
+
association_reader = Extractor::Mongoid::AssociationReader.new
|
|
77
|
+
graph_data = Transformer::GraphBuilder.new(
|
|
78
|
+
column_reader: column_reader,
|
|
79
|
+
association_reader: association_reader
|
|
80
|
+
).build(models)
|
|
81
|
+
graph_data[:metadata][:mode] = "mongoid"
|
|
82
|
+
generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
|
|
83
|
+
generator.render_to_file(output)
|
|
84
|
+
end
|
|
42
85
|
|
|
43
86
|
def parse_schema
|
|
44
87
|
case configuration.schema_format
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-schema
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Kislichenko
|
|
@@ -46,6 +46,7 @@ extensions: []
|
|
|
46
46
|
extra_rdoc_files: []
|
|
47
47
|
files:
|
|
48
48
|
- CHANGELOG.md
|
|
49
|
+
- CLAUDE.md
|
|
49
50
|
- LICENSE.txt
|
|
50
51
|
- PROJECT.md
|
|
51
52
|
- README.md
|
|
@@ -61,6 +62,10 @@ files:
|
|
|
61
62
|
- lib/rails/schema/extractor/association_reader.rb
|
|
62
63
|
- lib/rails/schema/extractor/column_reader.rb
|
|
63
64
|
- lib/rails/schema/extractor/model_scanner.rb
|
|
65
|
+
- lib/rails/schema/extractor/mongoid/association_reader.rb
|
|
66
|
+
- lib/rails/schema/extractor/mongoid/column_reader.rb
|
|
67
|
+
- lib/rails/schema/extractor/mongoid/model_adapter.rb
|
|
68
|
+
- lib/rails/schema/extractor/mongoid/model_scanner.rb
|
|
64
69
|
- lib/rails/schema/extractor/schema_file_parser.rb
|
|
65
70
|
- lib/rails/schema/extractor/structure_sql_parser.rb
|
|
66
71
|
- lib/rails/schema/railtie.rb
|