rails-schema 0.1.1 → 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/CHANGELOG.md +35 -0
- data/CLAUDE.md +48 -0
- data/PROJECT.md +62 -17
- data/README.md +39 -2
- 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/configuration.rb +2 -1
- data/lib/rails/schema/extractor/association_reader.rb +15 -4
- data/lib/rails/schema/extractor/column_reader.rb +2 -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/extractor/structure_sql_parser.rb +131 -0
- data/lib/rails/schema/version.rb +1 -1
- data/lib/rails/schema.rb +59 -1
- metadata +8 -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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.2] - 2026-02-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `StructureSqlParser` for extracting schema from `db/structure.sql` files
|
|
12
|
+
- `schema_format` configuration option (`:ruby`, `:sql`, `:auto`)
|
|
13
|
+
- `warn` messages to all silent rescue blocks in `AssociationReader` and `ColumnReader`
|
|
14
|
+
|
|
15
|
+
## [0.1.1] - 2026-02-17
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- ERD-style connections with crow's foot notation, directional indicators, and column-level attachment points
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Refactored edge routing with cubic Bezier curves and improved self-referential association handling
|
|
24
|
+
|
|
25
|
+
## [0.1.0] - 2026-02-15
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Initial release
|
|
30
|
+
- Interactive HTML visualization of Rails database schema (force-directed ERD)
|
|
31
|
+
- Model introspection: associations, columns, and schema file parsing
|
|
32
|
+
- Self-contained single HTML file output (no external dependencies)
|
|
33
|
+
- Searchable sidebar, click-to-focus, dark/light theme, keyboard shortcuts
|
|
34
|
+
- Rake task (`rails_schema:generate`) and programmatic API
|
|
35
|
+
- Configuration DSL: output path, title, theme, expand columns, exclude models
|
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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**Name:** `rails-schema`
|
|
10
10
|
**Module:** `Rails::Schema`
|
|
11
|
-
**Version:** `0.1.
|
|
11
|
+
**Version:** `0.1.2`
|
|
12
12
|
|
|
13
13
|
Rails::Schema introspects a Rails app's models, associations, and database columns at runtime, then generates a single self-contained HTML file with an interactive, explorable entity-relationship diagram. No external server, no SaaS dependency — just one command and a browser.
|
|
14
14
|
|
|
@@ -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` |
|
|
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` |
|
|
@@ -52,13 +52,23 @@ Rails::Schema.generate(output: "docs/schema.html")
|
|
|
52
52
|
|
|
53
53
|
```ruby
|
|
54
54
|
def generate(output: nil)
|
|
55
|
-
schema_data =
|
|
55
|
+
schema_data = parse_schema
|
|
56
56
|
models = Extractor::ModelScanner.new(schema_data: schema_data).scan
|
|
57
57
|
column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
|
|
58
58
|
graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
|
|
59
59
|
generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
|
|
60
60
|
generator.render_to_file(output)
|
|
61
61
|
end
|
|
62
|
+
|
|
63
|
+
def parse_schema
|
|
64
|
+
case configuration.schema_format
|
|
65
|
+
when :ruby then Extractor::SchemaFileParser.new.parse
|
|
66
|
+
when :sql then Extractor::StructureSqlParser.new.parse
|
|
67
|
+
when :auto
|
|
68
|
+
data = Extractor::SchemaFileParser.new.parse
|
|
69
|
+
data.empty? ? Extractor::StructureSqlParser.new.parse : data
|
|
70
|
+
end
|
|
71
|
+
end
|
|
62
72
|
```
|
|
63
73
|
|
|
64
74
|
---
|
|
@@ -68,12 +78,13 @@ end
|
|
|
68
78
|
### 3.1 Sources of Truth
|
|
69
79
|
|
|
70
80
|
1. **`db/schema.rb` parsing** — `SchemaFileParser` parses the schema file line-by-line with regex to extract table names, column definitions (name, type, nullable, default), and primary key info. This is attempted first and used as a fast, database-free source.
|
|
71
|
-
2.
|
|
72
|
-
3.
|
|
81
|
+
2. **`db/structure.sql` parsing** — `StructureSqlParser` parses SQL `CREATE TABLE` statements for projects using `config.active_record.schema_format = :sql`. Maps SQL types to Rails-friendly types, detects `NOT NULL`, `DEFAULT` values, and primary keys. Handles schema-qualified names (`public.users`), timestamp precision (`timestamp(6)`), and both quoted and unquoted identifiers.
|
|
82
|
+
3. **ActiveRecord reflection API** — `AssociationReader` uses `Model.reflect_on_all_associations` for associations (`has_many`, `belongs_to`, `has_one`, `has_and_belongs_to_many`), including `:through` and `:polymorphic`.
|
|
83
|
+
4. **`Model.columns`** — `ColumnReader` falls back to `model.columns` via ActiveRecord when a table is not found in schema_data.
|
|
73
84
|
|
|
74
85
|
### 3.2 Model Discovery
|
|
75
86
|
|
|
76
|
-
`ModelScanner` discovers models by:
|
|
87
|
+
`ModelScanner` (ActiveRecord) discovers models by:
|
|
77
88
|
|
|
78
89
|
1. Calling `Rails.application.eager_load!` (with Zeitwerk support and multiple fallback strategies)
|
|
79
90
|
2. Collecting `ActiveRecord::Base.descendants`
|
|
@@ -83,6 +94,14 @@ end
|
|
|
83
94
|
|
|
84
95
|
When `schema_data` is available, table existence is checked against parsed schema data instead of hitting the database.
|
|
85
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
|
+
|
|
86
105
|
### 3.3 Schema File Parser
|
|
87
106
|
|
|
88
107
|
`SchemaFileParser` provides database-free column extraction:
|
|
@@ -92,7 +111,19 @@ When `schema_data` is available, table existence is checked against parsed schem
|
|
|
92
111
|
- Handles custom primary key types (`id: :uuid`, `id: :bigint`) and `id: false`
|
|
93
112
|
- Skips index definitions
|
|
94
113
|
|
|
95
|
-
### 3.4
|
|
114
|
+
### 3.4 Structure SQL Parser
|
|
115
|
+
|
|
116
|
+
`StructureSqlParser` provides database-free column extraction from SQL dumps:
|
|
117
|
+
|
|
118
|
+
- Parses `CREATE TABLE` statements from `db/structure.sql`
|
|
119
|
+
- Maps SQL types to Rails types (e.g. `character varying` → `string`, `bigint` → `bigint`, `timestamp without time zone` → `datetime`)
|
|
120
|
+
- Handles schema-qualified table names (`public.users` → `users`)
|
|
121
|
+
- Handles timestamp precision (`timestamp(6) without time zone`)
|
|
122
|
+
- Detects primary keys from `CONSTRAINT ... PRIMARY KEY` and inline `PRIMARY KEY`
|
|
123
|
+
- Extracts `NOT NULL`, `DEFAULT` values (strings, numbers, booleans)
|
|
124
|
+
- Skips constraint lines (`CONSTRAINT`, `UNIQUE`, `CHECK`, `FOREIGN KEY`, etc.)
|
|
125
|
+
|
|
126
|
+
### 3.5 Intermediate Data Format (JSON Graph)
|
|
96
127
|
|
|
97
128
|
```json
|
|
98
129
|
{
|
|
@@ -195,6 +226,7 @@ Rails::Schema.configure do |config|
|
|
|
195
226
|
config.title = "Database Schema" # Page title
|
|
196
227
|
config.theme = :auto # :light, :dark, :auto
|
|
197
228
|
config.expand_columns = false # Start with columns expanded
|
|
229
|
+
config.schema_format = :auto # :auto, :ruby, :sql, or :mongoid
|
|
198
230
|
end
|
|
199
231
|
```
|
|
200
232
|
|
|
@@ -207,14 +239,20 @@ rails-schema/
|
|
|
207
239
|
├── lib/
|
|
208
240
|
│ ├── rails/schema.rb # Entry point, configuration DSL, generate method
|
|
209
241
|
│ └── rails/schema/
|
|
210
|
-
│ ├── version.rb # VERSION = "0.1.
|
|
211
|
-
│ ├── configuration.rb # Config object (
|
|
242
|
+
│ ├── version.rb # VERSION = "0.1.2"
|
|
243
|
+
│ ├── configuration.rb # Config object (6 attributes)
|
|
212
244
|
│ ├── railtie.rb # Rails integration, rake task
|
|
213
245
|
│ ├── extractor/
|
|
214
246
|
│ │ ├── model_scanner.rb # Discovers AR models
|
|
215
247
|
│ │ ├── association_reader.rb # Reads reflections
|
|
216
248
|
│ │ ├── column_reader.rb # Reads columns (schema_data or AR)
|
|
217
|
-
│ │
|
|
249
|
+
│ │ ├── schema_file_parser.rb # Parses db/schema.rb
|
|
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
|
|
218
256
|
│ ├── transformer/
|
|
219
257
|
│ │ ├── graph_builder.rb # Builds node/edge graph
|
|
220
258
|
│ │ ├── node.rb # Value object
|
|
@@ -230,7 +268,8 @@ rails-schema/
|
|
|
230
268
|
├── spec/
|
|
231
269
|
│ ├── spec_helper.rb
|
|
232
270
|
│ ├── support/
|
|
233
|
-
│ │
|
|
271
|
+
│ │ ├── test_models.rb # User, Post, Comment, Tag AR models
|
|
272
|
+
│ │ └── mongoid_test_models.rb # MongoidUser, MongoidPost, MongoidComment
|
|
234
273
|
│ └── rails/schema/
|
|
235
274
|
│ ├── rails_schema_spec.rb
|
|
236
275
|
│ ├── configuration_spec.rb
|
|
@@ -238,7 +277,13 @@ rails-schema/
|
|
|
238
277
|
│ │ ├── model_scanner_spec.rb
|
|
239
278
|
│ │ ├── column_reader_spec.rb
|
|
240
279
|
│ │ ├── association_reader_spec.rb
|
|
241
|
-
│ │
|
|
280
|
+
│ │ ├── schema_file_parser_spec.rb
|
|
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
|
|
242
287
|
│ ├── transformer/
|
|
243
288
|
│ │ └── graph_builder_spec.rb
|
|
244
289
|
│ └── renderer/
|
|
@@ -263,9 +308,9 @@ rails-schema/
|
|
|
263
308
|
|
|
264
309
|
A mounted engine requires a running server. A static file can be generated in CI, committed to the repo, and opened by anyone — including non-developers looking at a data model.
|
|
265
310
|
|
|
266
|
-
### Why parse schema.rb?
|
|
311
|
+
### Why parse schema.rb / structure.sql?
|
|
267
312
|
|
|
268
|
-
Parsing `db/schema.rb` allows column extraction without a database connection. This means the gem can work in CI environments or development setups where the database isn't running. It also avoids eager-loading the entire app just to read column metadata.
|
|
313
|
+
Parsing `db/schema.rb` or `db/structure.sql` allows column extraction without a database connection. This means the gem can work in CI environments or development setups where the database isn't running. It also avoids eager-loading the entire app just to read column metadata. The `schema_format: :auto` default tries `schema.rb` first, then falls back to `structure.sql`, so the gem works out of the box regardless of which format a project uses.
|
|
269
314
|
|
|
270
315
|
### Why force-directed layout?
|
|
271
316
|
|
|
@@ -292,12 +337,12 @@ spec.add_dependency "railties", ">= 6.0"
|
|
|
292
337
|
|
|
293
338
|
| Layer | Approach |
|
|
294
339
|
|---|---|
|
|
295
|
-
| Extractor | Unit tests with in-memory SQLite models (User, Post, Comment, Tag) |
|
|
340
|
+
| Extractor | Unit tests with in-memory SQLite models (User, Post, Comment, Tag); rescue-path warnings tested via `output(...).to_stderr` |
|
|
296
341
|
| Transformer | Pure Ruby unit tests — graph building, edge filtering |
|
|
297
342
|
| Renderer | Output tests — verify HTML structure, embedded data, script injection safety |
|
|
298
343
|
| Configuration | Unit tests for defaults and attribute setting |
|
|
299
344
|
|
|
300
|
-
**
|
|
345
|
+
**153 tests, all passing.** Run with `bundle exec rspec`.
|
|
301
346
|
|
|
302
347
|
---
|
|
303
348
|
|
|
@@ -319,4 +364,4 @@ spec.add_dependency "railties", ">= 6.0"
|
|
|
319
364
|
|
|
320
365
|
---
|
|
321
366
|
|
|
322
|
-
*Document reflects the current implementation (v0.1.
|
|
367
|
+
*Document reflects the current implementation (v0.1.2). Future enhancements are aspirational and subject to refinement.*
|
data/README.md
CHANGED
|
@@ -48,6 +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, :sql, or :mongoid
|
|
51
52
|
config.exclude_models = [
|
|
52
53
|
"ActiveStorage::Blob",
|
|
53
54
|
"ActiveStorage::Attachment",
|
|
@@ -56,13 +57,49 @@ Rails::Schema.configure do |config|
|
|
|
56
57
|
end
|
|
57
58
|
```
|
|
58
59
|
|
|
60
|
+
| Option | Default | Description |
|
|
61
|
+
|--------|---------|-------------|
|
|
62
|
+
| `output_path` | `"docs/schema.html"` | Path for the generated HTML file |
|
|
63
|
+
| `title` | `"Database Schema"` | Title shown in the HTML page |
|
|
64
|
+
| `theme` | `:auto` | Color theme — `:auto`, `:light`, or `:dark` |
|
|
65
|
+
| `expand_columns` | `false` | Whether model nodes start with columns expanded |
|
|
66
|
+
| `schema_format` | `:auto` | Schema source — `:auto`, `:ruby`, `:sql`, or `:mongoid` (see below) |
|
|
67
|
+
| `exclude_models` | `[]` | Models to hide; supports exact names and wildcard prefixes (`"ActionMailbox::*"`) |
|
|
68
|
+
|
|
69
|
+
### Schema format
|
|
70
|
+
|
|
71
|
+
Rails projects can use either `db/schema.rb` (Ruby DSL) or `db/structure.sql` (raw SQL dump) to represent the database schema. Set `config.active_record.schema_format = :sql` in your Rails app to use `structure.sql`.
|
|
72
|
+
|
|
73
|
+
| Value | Behavior |
|
|
74
|
+
|-------|----------|
|
|
75
|
+
| `:auto` | Tries `db/schema.rb` first, falls back to `db/structure.sql`. If Mongoid is detected, uses the Mongoid pipeline instead |
|
|
76
|
+
| `:ruby` | Only reads `db/schema.rb` |
|
|
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`.
|
|
93
|
+
|
|
59
94
|
## How it works
|
|
60
95
|
|
|
61
|
-
The gem parses your `db/schema.rb` 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
|
|
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).
|
|
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.
|
|
62
99
|
|
|
63
100
|
## Features
|
|
64
101
|
|
|
65
|
-
- **No database required** — reads
|
|
102
|
+
- **No database required** — reads from `db/schema.rb`, `db/structure.sql`, or Mongoid model introspection
|
|
66
103
|
- **Force-directed layout** — models cluster naturally by association density
|
|
67
104
|
- **Searchable sidebar** — filter models by name or table
|
|
68
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>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Rails
|
|
4
4
|
module Schema
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :output_path, :exclude_models, :title, :theme, :expand_columns
|
|
6
|
+
attr_accessor :output_path, :exclude_models, :title, :theme, :expand_columns, :schema_format
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@output_path = "docs/schema.html"
|
|
@@ -11,6 +11,7 @@ module Rails
|
|
|
11
11
|
@title = "Database Schema"
|
|
12
12
|
@theme = :auto
|
|
13
13
|
@expand_columns = false
|
|
14
|
+
@schema_format = :auto
|
|
14
15
|
end
|
|
15
16
|
end
|
|
16
17
|
end
|
|
@@ -26,16 +26,27 @@ module Rails
|
|
|
26
26
|
association_type: ref.macro.to_s,
|
|
27
27
|
label: ref.name.to_s,
|
|
28
28
|
foreign_key: ref.foreign_key.to_s,
|
|
29
|
-
through: ref
|
|
30
|
-
polymorphic: ref
|
|
29
|
+
through: through_name(ref),
|
|
30
|
+
polymorphic: polymorphic?(ref)
|
|
31
31
|
}
|
|
32
|
-
rescue StandardError
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
warn "[rails-schema] Could not read association #{ref.name} on #{model.name}: #{e.class}: #{e.message}"
|
|
33
34
|
nil
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
def through_name(ref)
|
|
38
|
+
ref.options[:through]&.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def polymorphic?(ref)
|
|
42
|
+
ref.options[:as] ? true : false
|
|
43
|
+
end
|
|
44
|
+
|
|
36
45
|
def target_model_name(ref)
|
|
37
46
|
ref.klass.name
|
|
38
|
-
rescue StandardError
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
warn "[rails-schema] Could not resolve target for #{ref.name}, " \
|
|
49
|
+
"falling back to #{ref.class_name}: #{e.class}: #{e.message}"
|
|
39
50
|
ref.class_name
|
|
40
51
|
end
|
|
41
52
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Schema
|
|
5
|
+
module Extractor
|
|
6
|
+
class StructureSqlParser
|
|
7
|
+
SQL_TYPE_MAP = {
|
|
8
|
+
"character varying" => "string", "varchar" => "string",
|
|
9
|
+
"integer" => "integer", "smallint" => "integer", "serial" => "integer",
|
|
10
|
+
"bigint" => "bigint", "bigserial" => "bigint",
|
|
11
|
+
"boolean" => "boolean", "text" => "text",
|
|
12
|
+
"timestamp without time zone" => "datetime", "timestamp with time zone" => "datetime",
|
|
13
|
+
"timestamp" => "datetime",
|
|
14
|
+
"json" => "json", "jsonb" => "jsonb", "uuid" => "uuid",
|
|
15
|
+
"numeric" => "decimal", "decimal" => "decimal", "money" => "decimal",
|
|
16
|
+
"date" => "date",
|
|
17
|
+
"float" => "float", "double precision" => "float", "real" => "float",
|
|
18
|
+
"bytea" => "binary"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
COMPOUND_TYPE_RE = /\A(character\s+varying|bit\s+varying|double\s+precision|
|
|
22
|
+
timestamp(?:\(\d+\))?\s+with(?:out)?\s+time\s+zone)/ix
|
|
23
|
+
CONSTRAINT_RE = /\A(CONSTRAINT|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY)\b/i
|
|
24
|
+
PK_CONSTRAINT_RE = /PRIMARY\s+KEY\s*\(([^)]+)\)/i
|
|
25
|
+
|
|
26
|
+
def initialize(structure_path = nil)
|
|
27
|
+
@structure_path = structure_path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse
|
|
31
|
+
path = resolve_path
|
|
32
|
+
return {} unless path && File.exist?(path)
|
|
33
|
+
|
|
34
|
+
parse_content(File.read(path))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_content(content)
|
|
38
|
+
tables = {}
|
|
39
|
+
|
|
40
|
+
content.scan(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."]+)\s*\((.*?)\)\s*;/mi) do |table_name, body|
|
|
41
|
+
name = extract_table_name(table_name)
|
|
42
|
+
columns, pk_columns = parse_table_body(body)
|
|
43
|
+
pk_columns.each { |pk| columns.find { |c| c[:name] == pk }&.[]= :primary, true }
|
|
44
|
+
tables[name] = columns
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
tables
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def resolve_path
|
|
53
|
+
return @structure_path if @structure_path
|
|
54
|
+
return ::Rails.root.join("db", "structure.sql").to_s if defined?(::Rails.root) && ::Rails.root
|
|
55
|
+
|
|
56
|
+
File.join(Dir.pwd, "db", "structure.sql")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unquote(identifier) = identifier.delete('"')
|
|
60
|
+
|
|
61
|
+
def extract_table_name(raw)
|
|
62
|
+
unquote(raw).split(".").last
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_table_body(body)
|
|
66
|
+
columns = []
|
|
67
|
+
pk_columns = []
|
|
68
|
+
body.each_line do |raw|
|
|
69
|
+
line = raw.strip.chomp(",")
|
|
70
|
+
next if line.empty?
|
|
71
|
+
|
|
72
|
+
if (pk = extract_pk_constraint(line))
|
|
73
|
+
pk_columns.concat(pk)
|
|
74
|
+
elsif !line.match?(CONSTRAINT_RE) && (col = parse_column_line(line))
|
|
75
|
+
pk_columns << col[:name] if col.delete(:inline_pk)
|
|
76
|
+
columns << col
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
[columns, pk_columns]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def extract_pk_constraint(line)
|
|
83
|
+
return unless (match = line.match(PK_CONSTRAINT_RE))
|
|
84
|
+
|
|
85
|
+
match[1].split(",").map { |c| unquote(c.strip) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_column_line(line)
|
|
89
|
+
match = line.match(/\A("?\w+"?)\s+(.+)/i)
|
|
90
|
+
return nil unless match
|
|
91
|
+
|
|
92
|
+
rest = match[2]
|
|
93
|
+
type = extract_type(rest)
|
|
94
|
+
return nil unless type
|
|
95
|
+
|
|
96
|
+
build_column(unquote(match[1]), rest, type)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_column(col_name, rest, type)
|
|
100
|
+
{
|
|
101
|
+
name: col_name,
|
|
102
|
+
type: SQL_TYPE_MAP.fetch(type, type),
|
|
103
|
+
nullable: !rest.match?(/\bNOT\s+NULL\b/i),
|
|
104
|
+
default: extract_default(rest),
|
|
105
|
+
primary: false,
|
|
106
|
+
inline_pk: rest.match?(/\bPRIMARY\s+KEY\b/i)
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_type(rest)
|
|
111
|
+
if (m = rest.match(COMPOUND_TYPE_RE))
|
|
112
|
+
m[1].downcase.gsub(/\(\d+\)/, "")
|
|
113
|
+
elsif rest.match?(/\A(FOREIGN\s+KEY)\b/i)
|
|
114
|
+
nil
|
|
115
|
+
else
|
|
116
|
+
rest[/\A(\w+)/i, 1]&.downcase
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract_default(rest)
|
|
121
|
+
case rest
|
|
122
|
+
when /\bDEFAULT\s+'([^']*)'(?:::\w+)?/i, /\bDEFAULT\s+(\d+(?:\.\d+)?)\b/i
|
|
123
|
+
Regexp.last_match(1)
|
|
124
|
+
when /\bDEFAULT\s+true\b/i then "true"
|
|
125
|
+
when /\bDEFAULT\s+false\b/i then "false"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/rails/schema/version.rb
CHANGED
data/lib/rails/schema.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "schema/configuration"
|
|
|
5
5
|
require_relative "schema/transformer/node"
|
|
6
6
|
require_relative "schema/transformer/edge"
|
|
7
7
|
require_relative "schema/extractor/schema_file_parser"
|
|
8
|
+
require_relative "schema/extractor/structure_sql_parser"
|
|
8
9
|
require_relative "schema/extractor/model_scanner"
|
|
9
10
|
require_relative "schema/extractor/column_reader"
|
|
10
11
|
require_relative "schema/extractor/association_reader"
|
|
@@ -29,13 +30,70 @@ module Rails
|
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def generate(output: nil)
|
|
32
|
-
|
|
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:)
|
|
54
|
+
schema_data = parse_schema
|
|
33
55
|
models = Extractor::ModelScanner.new(schema_data: schema_data).scan
|
|
34
56
|
column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
|
|
35
57
|
graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
|
|
58
|
+
graph_data[:metadata][:mode] = "active_record"
|
|
59
|
+
generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
|
|
60
|
+
generator.render_to_file(output)
|
|
61
|
+
end
|
|
62
|
+
|
|
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"
|
|
36
82
|
generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
|
|
37
83
|
generator.render_to_file(output)
|
|
38
84
|
end
|
|
85
|
+
|
|
86
|
+
def parse_schema
|
|
87
|
+
case configuration.schema_format
|
|
88
|
+
when :ruby
|
|
89
|
+
Extractor::SchemaFileParser.new.parse
|
|
90
|
+
when :sql
|
|
91
|
+
Extractor::StructureSqlParser.new.parse
|
|
92
|
+
when :auto
|
|
93
|
+
data = Extractor::SchemaFileParser.new.parse
|
|
94
|
+
data.empty? ? Extractor::StructureSqlParser.new.parse : data
|
|
95
|
+
end
|
|
96
|
+
end
|
|
39
97
|
end
|
|
40
98
|
end
|
|
41
99
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-schema
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Kislichenko
|
|
@@ -45,6 +45,8 @@ executables: []
|
|
|
45
45
|
extensions: []
|
|
46
46
|
extra_rdoc_files: []
|
|
47
47
|
files:
|
|
48
|
+
- CHANGELOG.md
|
|
49
|
+
- CLAUDE.md
|
|
48
50
|
- LICENSE.txt
|
|
49
51
|
- PROJECT.md
|
|
50
52
|
- README.md
|
|
@@ -60,7 +62,12 @@ files:
|
|
|
60
62
|
- lib/rails/schema/extractor/association_reader.rb
|
|
61
63
|
- lib/rails/schema/extractor/column_reader.rb
|
|
62
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
|
|
63
69
|
- lib/rails/schema/extractor/schema_file_parser.rb
|
|
70
|
+
- lib/rails/schema/extractor/structure_sql_parser.rb
|
|
64
71
|
- lib/rails/schema/railtie.rb
|
|
65
72
|
- lib/rails/schema/renderer/html_generator.rb
|
|
66
73
|
- lib/rails/schema/transformer/edge.rb
|