rails-schema 0.1.2 → 0.1.3

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