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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ed18d27750908c4973c29dd17904a623b056b9636f7a61da0a15d710575327b
4
- data.tar.gz: 88f5fe0cb1c8bc618f138afaf11792733324bdca5a27bbc21ae179f83277e173
3
+ metadata.gz: 37ed34e2667a948b30551666b19657a3bf6e1a484e4fe4660a3da03c9d356f66
4
+ data.tar.gz: c7f9ac452ae98d4188718c47879a2b1fc2769b21d308b7d07f89779c42273f95
5
5
  SHA512:
6
- metadata.gz: a5c174cade562f7b8026c4410fd2034a82fe325936ac32770e3604e7f3e68cea63f23364e48f929574993d3f7cf26f916b89c380459ae53537f81b39efd406c6
7
- data.tar.gz: 9cc8c949efa541ffe58d0227c3ec0ae540529518bed58993530ea6890d41f5a43a0e6bda3533db7862e8cc2c0da6acaead494686f1f8f111623ab6f7fc873658
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.0`
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 = Extractor::SchemaFileParser.new.parse
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. **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`.
72
- 3. **`Model.columns`** — `ColumnReader` falls back to `model.columns` via ActiveRecord when a table is not found in schema_data.
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 Intermediate Data Format (JSON Graph)
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.0"
211
- │ ├── configuration.rb # Config object (5 attributes)
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
- │ │ └── schema_file_parser.rb # Parses db/schema.rb
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
- │ │ └── test_models.rb # User, Post, Comment, Tag models
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
- │ │ └── schema_file_parser_spec.rb
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
- **66 tests, all passing.** Run with `bundle exec rspec`.
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.0). Future enhancements are aspirational and subject to refinement.*
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 `db/schema.rb` is present (which is standard in Rails projects under version control).
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 directly from `db/schema.rb`
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 through"></span> :through</span>
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.options[:through]&.to_s,
30
- polymorphic: ref.options[:as] ? true : false
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
@@ -28,7 +28,8 @@ module Rails
28
28
  default: col.default
29
29
  }
30
30
  end
31
- rescue StandardError
31
+ rescue StandardError => e
32
+ warn "[rails-schema] Could not read columns for #{model.name}: #{e.class}: #{e.message}"
32
33
  []
33
34
  end
34
35
  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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module Schema
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.3"
6
6
  end
7
7
  end
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
- schema_data = Extractor::SchemaFileParser.new.parse
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.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