rails_lens 0.2.0 → 0.2.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 +27 -0
- data/README.md +70 -0
- data/lib/rails_lens/analyzers/delegated_types.rb +6 -1
- data/lib/rails_lens/analyzers/notes.rb +114 -6
- data/lib/rails_lens/annotation_pipeline.rb +26 -21
- data/lib/rails_lens/cli.rb +6 -0
- data/lib/rails_lens/connection.rb +4 -4
- data/lib/rails_lens/erd/visualizer.rb +86 -11
- data/lib/rails_lens/model_detector.rb +89 -5
- data/lib/rails_lens/providers/base.rb +1 -1
- data/lib/rails_lens/providers/extension_notes_provider.rb +3 -2
- data/lib/rails_lens/providers/extensions_provider.rb +1 -1
- data/lib/rails_lens/providers/index_notes_provider.rb +3 -2
- data/lib/rails_lens/providers/inheritance_provider.rb +1 -1
- data/lib/rails_lens/providers/notes_provider_base.rb +3 -2
- data/lib/rails_lens/providers/schema_provider.rb +28 -13
- data/lib/rails_lens/providers/section_provider_base.rb +1 -1
- data/lib/rails_lens/providers/view_notes_provider.rb +22 -0
- data/lib/rails_lens/providers/view_provider.rb +67 -0
- data/lib/rails_lens/schema/adapters/mysql.rb +119 -7
- data/lib/rails_lens/schema/adapters/postgresql.rb +163 -1
- data/lib/rails_lens/schema/adapters/sqlite3.rb +114 -1
- data/lib/rails_lens/schema/annotation_manager.rb +2 -2
- data/lib/rails_lens/version.rb +1 -1
- data/lib/rails_lens/view_metadata.rb +98 -0
- metadata +4 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7eaa2e258c8608dfd893bb5220840fc9febce43478aca9573ae0dbb885b33bf5
|
4
|
+
data.tar.gz: ed99614fc744e7f32e6c439a8f61e99b5d94787a684c694fbca7ed1eb546e6c6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0fc9a2a6d597e0d5a5e9b5e1c5eddf79bdca77448e780686fd28eabc26c5085829fe543595bdde05d31949a0e5ca42475bd379d89b9d552c128c06070d7a555c
|
7
|
+
data.tar.gz: 3982294a0d8a11b0ba516a7f90dc5595b488a3dcae79ef874c8d7d0cb0369f4a6438304adf1dd476d3aa8542bd445ec44022b549dcdeb48bf63084b5bebdd22d
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,32 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## [0.2.3](https://github.com/seuros/rails_lens/compare/rails_lens/v0.2.2...rails_lens/v0.2.3) (2025-07-31)
|
4
|
+
|
5
|
+
|
6
|
+
### Features
|
7
|
+
|
8
|
+
* add database view annotation support ([#7](https://github.com/seuros/rails_lens/issues/7)) ([a42fdcd](https://github.com/seuros/rails_lens/commit/a42fdcdfe4da9e2a086488e0c5e0c72d2f3c5d3d))
|
9
|
+
|
10
|
+
|
11
|
+
### Bug Fixes
|
12
|
+
|
13
|
+
* centralize connection management to prevent "too many clients" errors ([#9](https://github.com/seuros/rails_lens/issues/9)) ([c5d85c7](https://github.com/seuros/rails_lens/commit/c5d85c7239d1eff49494a05582cb00a8e7402618))
|
14
|
+
|
15
|
+
## [0.2.2](https://github.com/seuros/rails_lens/compare/rails_lens/v0.2.1...rails_lens/v0.2.2) (2025-07-31)
|
16
|
+
|
17
|
+
|
18
|
+
### Bug Fixes
|
19
|
+
|
20
|
+
* remove from format from the erd visualize ([#5](https://github.com/seuros/rails_lens/issues/5)) ([c2efdc7](https://github.com/seuros/rails_lens/commit/c2efdc7011425fcd8b46dce54d811ce166b0c660))
|
21
|
+
|
22
|
+
## [0.2.1](https://github.com/seuros/rails_lens/compare/rails_lens/v0.2.0...rails_lens/v0.2.1) (2025-07-30)
|
23
|
+
|
24
|
+
|
25
|
+
### Bug Fixes
|
26
|
+
|
27
|
+
* resolve GitHub issue [#2](https://github.com/seuros/rails_lens/issues/2) CLI and provider errors ([6d92c67](https://github.com/seuros/rails_lens/commit/6d92c679f1da9186ec4f357c243b41bc57eecd94))
|
28
|
+
* resolve GitHub issue [#2](https://github.com/seuros/rails_lens/issues/2) CLI and provider errors ([a583373](https://github.com/seuros/rails_lens/commit/a583373b40ee7fdde32b3e97295448b1ecaa7ca5))
|
29
|
+
|
3
30
|
## [0.2.0](https://github.com/seuros/rails_lens/compare/rails_lens-v0.1.0...rails_lens/v0.2.0) (2025-07-30)
|
4
31
|
|
5
32
|
|
data/README.md
CHANGED
@@ -1,5 +1,10 @@
|
|
1
1
|
# Rails Lens 🔍
|
2
2
|
|
3
|
+

|
4
|
+

|
5
|
+

|
6
|
+

|
7
|
+
|
3
8
|
> **Precision optics for the Rails universe** - Where every model has perfect clarity through spacetime
|
4
9
|
|
5
10
|
Rails Lens provides intelligent model annotations and ERD generation for Rails applications with database-specific adapters, multi-database support, and advanced code analysis.
|
@@ -26,6 +31,71 @@ Rails Lens provides intelligent model annotations and ERD generation for Rails a
|
|
26
31
|
**✨ Advanced Features:**
|
27
32
|
- STI hierarchy mapping • Delegated types • Polymorphic associations • Enum analysis • LRDL-optimized output
|
28
33
|
|
34
|
+
## Showcase: Real-World Example
|
35
|
+
|
36
|
+
Rescued from AWS limbo, Rails Lens delivers cosmic schema clarity. See this `Announcement` model:
|
37
|
+
|
38
|
+
```ruby
|
39
|
+
# frozen_string_literal: true
|
40
|
+
|
41
|
+
# <rails-lens:schema:begin>
|
42
|
+
# table = "announcements"
|
43
|
+
# database_dialect = "PostgreSQL"
|
44
|
+
#
|
45
|
+
# columns = [
|
46
|
+
# { name = "id", type = "integer", primary_key = true, nullable = false },
|
47
|
+
# { name = "body", type = "text", nullable = true },
|
48
|
+
# { name = "audience", type = "string", nullable = true },
|
49
|
+
# { name = "scheduled_at", type = "datetime", nullable = true },
|
50
|
+
# { name = "created_at", type = "datetime", nullable = false },
|
51
|
+
# { name = "updated_at", type = "datetime", nullable = false }
|
52
|
+
# ]
|
53
|
+
#
|
54
|
+
# == Polymorphic Associations
|
55
|
+
# Polymorphic Targets:
|
56
|
+
# - entry (as: :entryable)
|
57
|
+
#
|
58
|
+
# == Enums
|
59
|
+
# - audience: { all_users: "all_users", crew_only: "crew_only", officers_only: "officers_only", command_staff: "command_staff" } (string)
|
60
|
+
#
|
61
|
+
# == Notes
|
62
|
+
# - Column 'body' should probably have NOT NULL constraint
|
63
|
+
# - Column 'audience' should probably have NOT NULL constraint
|
64
|
+
# - String column 'audience' has no length limit - consider adding one
|
65
|
+
# - Large text column 'body' is frequently queried - consider separate storage
|
66
|
+
# <rails-lens:schema:end>
|
67
|
+
class Announcement < ApplicationRecord
|
68
|
+
enum :audience, { all_users: 'all_users', crew_only: 'crew_only', officers_only: 'officers_only', command_staff: 'command_staff' }, suffix: true
|
69
|
+
has_one :entry, as: :entryable, dependent: :destroy
|
70
|
+
validates :audience, presence: true
|
71
|
+
validates :body, presence: true
|
72
|
+
scope :recent, -> { order(created_at: :desc) }
|
73
|
+
end
|
74
|
+
```
|
75
|
+
|
76
|
+
**ERD Visualization:**
|
77
|
+
```mermaid
|
78
|
+
erDiagram
|
79
|
+
Announcement ||--o{ Entry : entryable
|
80
|
+
Announcement {
|
81
|
+
integer id PK
|
82
|
+
text body
|
83
|
+
string audience
|
84
|
+
datetime scheduled_at
|
85
|
+
datetime created_at
|
86
|
+
datetime updated_at
|
87
|
+
}
|
88
|
+
Entry {
|
89
|
+
integer id PK
|
90
|
+
string entryable_type
|
91
|
+
integer entryable_id
|
92
|
+
datetime created_at
|
93
|
+
datetime updated_at
|
94
|
+
}
|
95
|
+
```
|
96
|
+
|
97
|
+
**No grepping, no LLM hallucinations. Try it:** `gem install rails_lens`
|
98
|
+
|
29
99
|
## Requirements
|
30
100
|
|
31
101
|
- Ruby >= 3.4.0
|
@@ -17,7 +17,12 @@ module RailsLens
|
|
17
17
|
|
18
18
|
lines << "Type Column: #{delegated_type_info[:type_column]}"
|
19
19
|
lines << "ID Column: #{delegated_type_info[:id_column]}"
|
20
|
-
|
20
|
+
types_list = if delegated_type_info[:types].respond_to?(:keys)
|
21
|
+
delegated_type_info[:types].keys
|
22
|
+
else
|
23
|
+
Array(delegated_type_info[:types])
|
24
|
+
end
|
25
|
+
lines << "Types: #{types_list.join(', ')}"
|
21
26
|
|
22
27
|
lines.join("\n")
|
23
28
|
end
|
@@ -29,12 +29,22 @@ module RailsLens
|
|
29
29
|
|
30
30
|
notes = []
|
31
31
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
32
|
+
# Check if this model is backed by a view
|
33
|
+
is_view = ModelDetector.view_exists?(model_class)
|
34
|
+
|
35
|
+
if is_view
|
36
|
+
# For views, add view-specific checks
|
37
|
+
notes.concat(analyze_view_readonly)
|
38
|
+
notes.concat(analyze_view_gotchas)
|
39
|
+
else
|
40
|
+
# For tables, run all standard checks
|
41
|
+
notes.concat(analyze_indexes)
|
42
|
+
notes.concat(analyze_foreign_keys)
|
43
|
+
notes.concat(analyze_associations)
|
44
|
+
notes.concat(analyze_columns)
|
45
|
+
notes.concat(analyze_performance)
|
46
|
+
notes.concat(analyze_best_practices)
|
47
|
+
end
|
38
48
|
|
39
49
|
notes.compact.uniq
|
40
50
|
rescue ActiveRecord::StatementInvalid => e
|
@@ -47,6 +57,54 @@ module RailsLens
|
|
47
57
|
|
48
58
|
private
|
49
59
|
|
60
|
+
def analyze_view_readonly
|
61
|
+
notes = []
|
62
|
+
|
63
|
+
# Check if this model is backed by a database view
|
64
|
+
if ModelDetector.view_exists?(model_class)
|
65
|
+
notes << '👁️ View-backed model: read-only'
|
66
|
+
|
67
|
+
# Check if model has readonly implementation
|
68
|
+
unless has_readonly_implementation?
|
69
|
+
notes << 'Add readonly? method'
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
notes
|
74
|
+
rescue StandardError => e
|
75
|
+
Rails.logger.debug { "Error checking view readonly status for #{model_class.name}: #{e.message}" }
|
76
|
+
[]
|
77
|
+
end
|
78
|
+
|
79
|
+
def analyze_view_gotchas
|
80
|
+
notes = []
|
81
|
+
view_metadata = ViewMetadata.new(model_class)
|
82
|
+
|
83
|
+
# Check for materialized view specific issues
|
84
|
+
if view_metadata.materialized_view?
|
85
|
+
notes << '🔄 Materialized view: data may be stale until refreshed'
|
86
|
+
unless has_refresh_methods?
|
87
|
+
notes << 'Add refresh! method for manual updates'
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Check for nested views (view depending on other views)
|
92
|
+
dependencies = view_metadata.dependencies
|
93
|
+
if dependencies.any? { |dep| view_exists_by_name?(dep) }
|
94
|
+
notes << '⚠️ Nested views detected: may impact query performance'
|
95
|
+
end
|
96
|
+
|
97
|
+
# Check for readonly implementation
|
98
|
+
unless has_readonly_implementation?
|
99
|
+
notes << '🔒 Add readonly protection to prevent write operations'
|
100
|
+
end
|
101
|
+
|
102
|
+
notes
|
103
|
+
rescue StandardError => e
|
104
|
+
Rails.logger.debug { "Error analyzing view gotchas for #{model_class.name}: #{e.message}" }
|
105
|
+
[]
|
106
|
+
end
|
107
|
+
|
50
108
|
def analyze_indexes
|
51
109
|
notes = []
|
52
110
|
|
@@ -320,6 +378,56 @@ module RailsLens
|
|
320
378
|
column.type == :uuid || (column.type == :string && column.name.match?(/uuid|guid/))
|
321
379
|
end
|
322
380
|
end
|
381
|
+
|
382
|
+
def has_readonly_implementation?
|
383
|
+
# Check if model has readonly? method defined (not just inherited from ActiveRecord)
|
384
|
+
model_class.method_defined?(:readonly?) &&
|
385
|
+
model_class.instance_method(:readonly?).owner != ActiveRecord::Base
|
386
|
+
rescue StandardError
|
387
|
+
false
|
388
|
+
end
|
389
|
+
|
390
|
+
def has_refresh_methods?
|
391
|
+
# Check if model has refresh! method for materialized views
|
392
|
+
model_class.respond_to?(:refresh!) || model_class.respond_to?(:refresh_concurrently!)
|
393
|
+
rescue StandardError
|
394
|
+
false
|
395
|
+
end
|
396
|
+
|
397
|
+
def view_exists_by_name?(view_name)
|
398
|
+
# Check if a view exists in the database by name
|
399
|
+
case @connection.adapter_name.downcase
|
400
|
+
when 'postgresql'
|
401
|
+
result = @connection.exec_query(<<~SQL.squish, 'Check PostgreSQL View Existence')
|
402
|
+
SELECT 1 FROM information_schema.views#{' '}
|
403
|
+
WHERE table_name = '#{@connection.quote_string(view_name)}'
|
404
|
+
UNION ALL
|
405
|
+
SELECT 1 FROM pg_matviews#{' '}
|
406
|
+
WHERE matviewname = '#{@connection.quote_string(view_name)}'
|
407
|
+
LIMIT 1
|
408
|
+
SQL
|
409
|
+
result.rows.any?
|
410
|
+
when 'mysql', 'mysql2'
|
411
|
+
result = @connection.exec_query(<<~SQL.squish, 'Check MySQL View Existence')
|
412
|
+
SELECT 1 FROM information_schema.views#{' '}
|
413
|
+
WHERE table_name = '#{@connection.quote_string(view_name)}'
|
414
|
+
LIMIT 1
|
415
|
+
SQL
|
416
|
+
result.rows.any?
|
417
|
+
when 'sqlite', 'sqlite3'
|
418
|
+
result = @connection.exec_query(<<~SQL.squish, 'Check SQLite View Existence')
|
419
|
+
SELECT 1 FROM sqlite_master#{' '}
|
420
|
+
WHERE type = 'view' AND name = '#{@connection.quote_string(view_name)}'
|
421
|
+
LIMIT 1
|
422
|
+
SQL
|
423
|
+
result.rows.any?
|
424
|
+
else
|
425
|
+
false
|
426
|
+
end
|
427
|
+
rescue StandardError => e
|
428
|
+
Rails.logger.debug { "Error checking view existence for #{view_name}: #{e.message}" }
|
429
|
+
false
|
430
|
+
end
|
323
431
|
end
|
324
432
|
end
|
325
433
|
end
|
@@ -27,30 +27,33 @@ module RailsLens
|
|
27
27
|
notes: []
|
28
28
|
}
|
29
29
|
|
30
|
-
|
31
|
-
|
30
|
+
# Use the model's connection pool to manage a single connection for all providers
|
31
|
+
model_class.connection_pool.with_connection do |connection|
|
32
|
+
@providers.each do |provider|
|
33
|
+
next unless provider.applicable?(model_class)
|
32
34
|
|
33
|
-
|
34
|
-
|
35
|
+
begin
|
36
|
+
result = provider.process(model_class, connection)
|
35
37
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
38
|
+
case provider.type
|
39
|
+
when :schema
|
40
|
+
results[:schema] = result
|
41
|
+
when :section
|
42
|
+
results[:sections] << result if result
|
43
|
+
when :notes
|
44
|
+
results[:notes].concat(Array(result))
|
45
|
+
end
|
46
|
+
rescue ActiveRecord::StatementInvalid => e
|
47
|
+
warn "Provider #{provider.class} database error for #{model_class}: #{e.message}"
|
48
|
+
rescue ActiveRecord::ConnectionNotDefined => e
|
49
|
+
warn "Provider #{provider.class} connection error for #{model_class}: #{e.message}"
|
50
|
+
rescue NameError, NoMethodError => e
|
51
|
+
warn "Provider #{provider.class} method error for #{model_class}: #{e.message}"
|
52
|
+
rescue RailsLens::Error => e
|
53
|
+
warn "Provider #{provider.class} rails_lens error for #{model_class}: #{e.message}"
|
54
|
+
rescue StandardError => e
|
55
|
+
warn "Provider #{provider.class} unexpected error for #{model_class}: #{e.message}"
|
43
56
|
end
|
44
|
-
rescue ActiveRecord::StatementInvalid => e
|
45
|
-
warn "Provider #{provider.class} database error for #{model_class}: #{e.message}"
|
46
|
-
rescue ActiveRecord::ConnectionNotDefined => e
|
47
|
-
warn "Provider #{provider.class} connection error for #{model_class}: #{e.message}"
|
48
|
-
rescue NameError, NoMethodError => e
|
49
|
-
warn "Provider #{provider.class} method error for #{model_class}: #{e.message}"
|
50
|
-
rescue RailsLens::Error => e
|
51
|
-
warn "Provider #{provider.class} rails_lens error for #{model_class}: #{e.message}"
|
52
|
-
rescue StandardError => e
|
53
|
-
warn "Provider #{provider.class} unexpected error for #{model_class}: #{e.message}"
|
54
57
|
end
|
55
58
|
end
|
56
59
|
|
@@ -65,6 +68,7 @@ module RailsLens
|
|
65
68
|
|
66
69
|
# Section providers (additional structured content)
|
67
70
|
register(Providers::ExtensionsProvider.new) if RailsLens.config.extensions[:enabled]
|
71
|
+
register(Providers::ViewProvider.new)
|
68
72
|
register(Providers::InheritanceProvider.new)
|
69
73
|
register(Providers::EnumsProvider.new)
|
70
74
|
register(Providers::DelegatedTypesProvider.new)
|
@@ -75,6 +79,7 @@ module RailsLens
|
|
75
79
|
# Notes providers (analysis and recommendations)
|
76
80
|
return unless RailsLens.config.schema[:include_notes]
|
77
81
|
|
82
|
+
register(Providers::ViewNotesProvider.new)
|
78
83
|
register(Providers::IndexNotesProvider.new)
|
79
84
|
register(Providers::ForeignKeyNotesProvider.new)
|
80
85
|
register(Providers::AssociationNotesProvider.new)
|
data/lib/rails_lens/cli.rb
CHANGED
@@ -8,6 +8,11 @@ module RailsLens
|
|
8
8
|
class CLI < Thor
|
9
9
|
include CLIErrorHandler
|
10
10
|
|
11
|
+
# Thor configuration: exit with proper status codes on failure (modern behavior)
|
12
|
+
def self.exit_on_failure?
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
11
16
|
class_option :config, type: :string, default: '.rails-lens.yml', desc: 'Configuration file path'
|
12
17
|
class_option :dry_run, type: :boolean, desc: 'Show what would be done without making changes'
|
13
18
|
class_option :verbose, type: :boolean, desc: 'Verbose output'
|
@@ -15,6 +20,7 @@ module RailsLens
|
|
15
20
|
|
16
21
|
desc 'annotate', 'Annotate Rails models with schema information'
|
17
22
|
option :models, type: :array, desc: 'Specific models to annotate'
|
23
|
+
option :include_abstract, type: :boolean, desc: 'Include abstract classes'
|
18
24
|
option :position, type: :string, enum: %w[before after top bottom], desc: 'Annotation position'
|
19
25
|
option :routes, type: :boolean, desc: 'Annotate controller routes'
|
20
26
|
option :mailers, type: :boolean, desc: 'Annotate mailer methods'
|
@@ -3,12 +3,12 @@
|
|
3
3
|
module RailsLens
|
4
4
|
class Connection
|
5
5
|
class << self
|
6
|
-
def adapter_for(model_class)
|
7
|
-
|
8
|
-
adapter_name = detect_adapter_name(
|
6
|
+
def adapter_for(model_class, connection = nil)
|
7
|
+
conn = connection || model_class.connection
|
8
|
+
adapter_name = detect_adapter_name(conn)
|
9
9
|
|
10
10
|
adapter_class = resolve_adapter_class(adapter_name)
|
11
|
-
adapter_class.new(
|
11
|
+
adapter_class.new(conn, model_class.table_name)
|
12
12
|
end
|
13
13
|
|
14
14
|
def resolve_adapter_class(adapter_name)
|
@@ -63,9 +63,22 @@ module RailsLens
|
|
63
63
|
end
|
64
64
|
|
65
65
|
group_models.each do |model|
|
66
|
+
# Additional safety check: Skip abstract models that might have slipped through
|
67
|
+
next if model.abstract_class?
|
68
|
+
|
69
|
+
# Skip models without valid tables/views or columns
|
70
|
+
# Include both table-backed and view-backed models
|
71
|
+
is_view = ModelDetector.view_exists?(model)
|
72
|
+
has_data_source = is_view || (model.table_exists? && model.columns.present?)
|
73
|
+
next unless has_data_source
|
74
|
+
|
66
75
|
model_display_name = format_model_name(model)
|
76
|
+
|
67
77
|
output << " #{model_display_name} {"
|
78
|
+
# Track opening brace position for error recovery
|
79
|
+
brace_position = output.size
|
68
80
|
|
81
|
+
columns_added = false
|
69
82
|
model.columns.each do |column|
|
70
83
|
type_str = format_column_type(column)
|
71
84
|
name_str = column.name
|
@@ -73,23 +86,42 @@ module RailsLens
|
|
73
86
|
key_str = keys.map(&:to_s).join(' ')
|
74
87
|
|
75
88
|
output << " #{type_str} #{name_str}#{" #{key_str}" unless key_str.empty?}"
|
89
|
+
columns_added = true
|
76
90
|
end
|
77
91
|
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
92
|
+
# Only close the entity if we successfully added columns
|
93
|
+
if columns_added
|
94
|
+
output << ' }'
|
95
|
+
output << ''
|
96
|
+
Rails.logger.debug { "Added entity: #{model_display_name}" } if options[:verbose]
|
97
|
+
else
|
98
|
+
# Remove the opening brace if no columns were added
|
99
|
+
output.slice!(brace_position..-1)
|
100
|
+
Rails.logger.debug { "Skipped entity #{model_display_name}: no columns found" } if options[:verbose]
|
101
|
+
end
|
82
102
|
rescue StandardError => e
|
83
103
|
Rails.logger.debug { "Warning: Could not add entity #{model.name}: #{e.message}" }
|
84
|
-
#
|
85
|
-
|
86
|
-
|
104
|
+
# Remove any partial entity content added since the opening brace
|
105
|
+
if output.size > brace_position
|
106
|
+
output.slice!(brace_position..-1)
|
107
|
+
end
|
87
108
|
end
|
88
109
|
end
|
89
110
|
|
111
|
+
# Add visual styling for views vs tables
|
112
|
+
add_visual_styling(output, models)
|
113
|
+
|
90
114
|
# Add relationships
|
91
115
|
output << ' %% Relationships'
|
92
116
|
models.each do |model|
|
117
|
+
# Skip abstract models in relationship generation too
|
118
|
+
next if model.abstract_class?
|
119
|
+
|
120
|
+
# Include both table-backed and view-backed models
|
121
|
+
is_view = ModelDetector.view_exists?(model)
|
122
|
+
has_data_source = is_view || (model.table_exists? && model.columns.present?)
|
123
|
+
next unless has_data_source
|
124
|
+
|
93
125
|
add_model_relationships(output, model, models)
|
94
126
|
end
|
95
127
|
|
@@ -152,6 +184,10 @@ module RailsLens
|
|
152
184
|
|
153
185
|
next unless target_model && models.include?(target_model)
|
154
186
|
|
187
|
+
# Skip relationships to abstract models
|
188
|
+
next if target_model.abstract_class?
|
189
|
+
next unless target_model.table_exists? && target_model.columns.present?
|
190
|
+
|
155
191
|
case association.macro
|
156
192
|
when :belongs_to
|
157
193
|
add_belongs_to_relationship(output, model, association, target_model)
|
@@ -164,10 +200,12 @@ module RailsLens
|
|
164
200
|
end
|
165
201
|
end
|
166
202
|
|
167
|
-
# Check for closure_tree self-reference
|
168
|
-
|
169
|
-
|
170
|
-
|
203
|
+
# Check for closure_tree self-reference - but only if model is not abstract
|
204
|
+
# rubocop:disable Style/GuardClause
|
205
|
+
if model.respond_to?(:_ct) && !model.abstract_class?
|
206
|
+
output << " #{format_model_name(model)} }o--o{ #{format_model_name(model)} : \"closure_tree\""
|
207
|
+
end
|
208
|
+
# rubocop:enable Style/GuardClause
|
171
209
|
end
|
172
210
|
|
173
211
|
def add_belongs_to_relationship(output, model, association, target_model)
|
@@ -225,6 +263,43 @@ module RailsLens
|
|
225
263
|
output << ' }}%%'
|
226
264
|
end
|
227
265
|
|
266
|
+
def add_visual_styling(output, models)
|
267
|
+
# Add class definitions for visual distinction between tables and views
|
268
|
+
output << ''
|
269
|
+
output << ' %% Entity Styling'
|
270
|
+
|
271
|
+
# Define styling classes
|
272
|
+
output << ' classDef tableEntity fill:#f9f9f9,stroke:#333,stroke-width:2px'
|
273
|
+
output << ' classDef viewEntity fill:#e6f3ff,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5'
|
274
|
+
output << ' classDef materializedViewEntity fill:#ffe6e6,stroke:#333,stroke-width:3px,stroke-dasharray: 5 5'
|
275
|
+
|
276
|
+
# Apply styling to each model
|
277
|
+
models.each do |model|
|
278
|
+
next if model.abstract_class?
|
279
|
+
|
280
|
+
is_view = ModelDetector.view_exists?(model)
|
281
|
+
has_data_source = is_view || (model.table_exists? && model.columns.present?)
|
282
|
+
next unless has_data_source
|
283
|
+
|
284
|
+
model_display_name = format_model_name(model)
|
285
|
+
|
286
|
+
if is_view
|
287
|
+
view_metadata = ViewMetadata.new(model)
|
288
|
+
output << if view_metadata.materialized_view?
|
289
|
+
" class #{model_display_name} materializedViewEntity"
|
290
|
+
else
|
291
|
+
" class #{model_display_name} viewEntity"
|
292
|
+
end
|
293
|
+
else
|
294
|
+
output << " class #{model_display_name} tableEntity"
|
295
|
+
end
|
296
|
+
rescue StandardError => e
|
297
|
+
Rails.logger.debug { "Warning: Could not apply styling to #{model.name}: #{e.message}" }
|
298
|
+
end
|
299
|
+
|
300
|
+
output << ''
|
301
|
+
end
|
302
|
+
|
228
303
|
def group_models_by_database(models)
|
229
304
|
grouped = Hash.new { |h, k| h[k] = [] }
|
230
305
|
|
@@ -37,8 +37,81 @@ module RailsLens
|
|
37
37
|
concrete_models.select { |model| model.superclass != ActiveRecord::Base && concrete_models.include?(model.superclass) }
|
38
38
|
end
|
39
39
|
|
40
|
+
def view_backed_models
|
41
|
+
detect_models.select { |model| view_exists?(model) }
|
42
|
+
end
|
43
|
+
|
44
|
+
def table_backed_models
|
45
|
+
detect_models.reject { |model| view_exists?(model) }
|
46
|
+
end
|
47
|
+
|
48
|
+
def view_exists?(model_class)
|
49
|
+
return false if model_class.abstract_class?
|
50
|
+
return false unless model_class.table_name
|
51
|
+
|
52
|
+
# Cache view existence checks for performance
|
53
|
+
@view_cache ||= {}
|
54
|
+
cache_key = "#{model_class.connection.object_id}_#{model_class.table_name}"
|
55
|
+
|
56
|
+
return @view_cache[cache_key] if @view_cache.key?(cache_key)
|
57
|
+
|
58
|
+
@view_cache[cache_key] = check_view_existence(model_class)
|
59
|
+
end
|
60
|
+
|
40
61
|
private
|
41
62
|
|
63
|
+
def check_view_existence(model_class)
|
64
|
+
connection = model_class.connection
|
65
|
+
table_name = model_class.table_name
|
66
|
+
|
67
|
+
case connection.adapter_name.downcase
|
68
|
+
when 'postgresql'
|
69
|
+
check_postgresql_view(connection, table_name)
|
70
|
+
when 'mysql', 'mysql2'
|
71
|
+
check_mysql_view(connection, table_name)
|
72
|
+
when 'sqlite', 'sqlite3'
|
73
|
+
check_sqlite_view(connection, table_name)
|
74
|
+
else
|
75
|
+
false # Unsupported adapter
|
76
|
+
end
|
77
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined
|
78
|
+
false # If we can't check, assume it's not a view
|
79
|
+
end
|
80
|
+
|
81
|
+
# rubocop:disable Naming/PredicateMethod
|
82
|
+
def check_postgresql_view(connection, table_name)
|
83
|
+
# Check both regular views and materialized views
|
84
|
+
result = connection.exec_query(<<~SQL.squish, 'Check PostgreSQL View')
|
85
|
+
SELECT 1 FROM information_schema.views#{' '}
|
86
|
+
WHERE table_name = '#{connection.quote_string(table_name)}'
|
87
|
+
UNION ALL
|
88
|
+
SELECT 1 FROM pg_matviews#{' '}
|
89
|
+
WHERE matviewname = '#{connection.quote_string(table_name)}'
|
90
|
+
LIMIT 1
|
91
|
+
SQL
|
92
|
+
result.rows.any?
|
93
|
+
end
|
94
|
+
|
95
|
+
def check_mysql_view(connection, table_name)
|
96
|
+
result = connection.exec_query(<<~SQL.squish, 'Check MySQL View')
|
97
|
+
SELECT 1 FROM information_schema.views#{' '}
|
98
|
+
WHERE table_name = '#{connection.quote_string(table_name)}'
|
99
|
+
AND table_schema = DATABASE()
|
100
|
+
LIMIT 1
|
101
|
+
SQL
|
102
|
+
result.rows.any?
|
103
|
+
end
|
104
|
+
|
105
|
+
def check_sqlite_view(connection, table_name)
|
106
|
+
result = connection.exec_query(<<~SQL.squish, 'Check SQLite View')
|
107
|
+
SELECT 1 FROM sqlite_master#{' '}
|
108
|
+
WHERE type = 'view' AND name = '#{connection.quote_string(table_name)}'
|
109
|
+
LIMIT 1
|
110
|
+
SQL
|
111
|
+
result.rows.any?
|
112
|
+
end
|
113
|
+
# rubocop:enable Naming/PredicateMethod
|
114
|
+
|
42
115
|
def eager_load_models
|
43
116
|
# Zeitwerk is always available in Rails 7+
|
44
117
|
Zeitwerk::Loader.eager_load_all
|
@@ -125,7 +198,7 @@ module RailsLens
|
|
125
198
|
|
126
199
|
# Exclude abstract models and models without valid tables
|
127
200
|
before_count = models.size
|
128
|
-
models = filter_models_concurrently(models, trace_filtering)
|
201
|
+
models = filter_models_concurrently(models, trace_filtering, options)
|
129
202
|
log_filter_step('Abstract/invalid table removal', before_count, models.size, trace_filtering)
|
130
203
|
|
131
204
|
# Exclude tables from configuration
|
@@ -183,7 +256,7 @@ module RailsLens
|
|
183
256
|
end
|
184
257
|
end
|
185
258
|
|
186
|
-
def filter_models_concurrently(models, trace_filtering)
|
259
|
+
def filter_models_concurrently(models, trace_filtering, options = {})
|
187
260
|
# Use concurrent futures to check table existence in parallel
|
188
261
|
futures = models.map do |model|
|
189
262
|
Concurrent::Future.execute do
|
@@ -191,10 +264,13 @@ module RailsLens
|
|
191
264
|
reason = nil
|
192
265
|
|
193
266
|
begin
|
194
|
-
# Skip abstract models
|
195
|
-
if model.abstract_class?
|
267
|
+
# Skip abstract models unless explicitly included
|
268
|
+
if model.abstract_class? && !options[:include_abstract]
|
196
269
|
should_exclude = true
|
197
270
|
reason = 'abstract class'
|
271
|
+
# For abstract models that are included, skip table checks
|
272
|
+
elsif model.abstract_class? && options[:include_abstract]
|
273
|
+
reason = 'abstract class (included)'
|
198
274
|
# Skip models without configured tables
|
199
275
|
elsif !model.table_name
|
200
276
|
should_exclude = true
|
@@ -203,8 +279,12 @@ module RailsLens
|
|
203
279
|
elsif !model.table_exists?
|
204
280
|
should_exclude = true
|
205
281
|
reason = "table '#{model.table_name}' does not exist"
|
282
|
+
# Additional check: Skip models that don't have any columns
|
283
|
+
elsif model.columns.empty?
|
284
|
+
should_exclude = true
|
285
|
+
reason = "table '#{model.table_name}' has no columns"
|
206
286
|
else
|
207
|
-
reason = "table '#{model.table_name}' exists"
|
287
|
+
reason = "table '#{model.table_name}' exists with #{model.columns.size} columns"
|
208
288
|
end
|
209
289
|
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined => e
|
210
290
|
should_exclude = true
|
@@ -212,6 +292,10 @@ module RailsLens
|
|
212
292
|
rescue NameError, NoMethodError => e
|
213
293
|
should_exclude = true
|
214
294
|
reason = "method error checking model - #{e.message}"
|
295
|
+
rescue StandardError => e
|
296
|
+
# Catch any other errors and exclude the model to prevent ERD corruption
|
297
|
+
should_exclude = true
|
298
|
+
reason = "unexpected error checking model - #{e.message}"
|
215
299
|
end
|
216
300
|
|
217
301
|
if trace_filtering
|