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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2df49b10f106235177d40b7029a8519ab45d7ab88395eb26fd56855e69a1ada
4
- data.tar.gz: 483fc63985bf291ff4a069793aaa9ff8a4e82d1c83c405dedce717fddc5d9dc6
3
+ metadata.gz: 7eaa2e258c8608dfd893bb5220840fc9febce43478aca9573ae0dbb885b33bf5
4
+ data.tar.gz: ed99614fc744e7f32e6c439a8f61e99b5d94787a684c694fbca7ed1eb546e6c6
5
5
  SHA512:
6
- metadata.gz: 20e9000c791458cb09cc530ba00e88b3033290c4b8124e6f10afc0d5ba0cf9af9bee932517753987da5f1955d4a13dd92d6c8315cf651962f45449b88bd02183
7
- data.tar.gz: 70bf1e3d3321abe358bf68d2e99a0d2a018e6f8c8e70cbe86d42e2c7358fdf0870c8d5f806628f63a2913fc431993576480fe03c10f1a8157148c5358ecf67da
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
+ ![Gem Version](https://img.shields.io/gem/v/rails_lens)
4
+ ![GitHub stars](https://img.shields.io/github/stars/seuros/rails_lens)
5
+ ![Downloads](https://img.shields.io/gem/dt/rails_lens)
6
+ ![License](https://img.shields.io/github/license/seuros/rails_lens)
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
- lines << "Types: #{delegated_type_info[:types].join(', ')}"
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
- notes.concat(analyze_indexes)
33
- notes.concat(analyze_foreign_keys)
34
- notes.concat(analyze_associations)
35
- notes.concat(analyze_columns)
36
- notes.concat(analyze_performance)
37
- notes.concat(analyze_best_practices)
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
- @providers.each do |provider|
31
- next unless provider.applicable?(model_class)
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
- begin
34
- result = provider.process(model_class)
35
+ begin
36
+ result = provider.process(model_class, connection)
35
37
 
36
- case provider.type
37
- when :schema
38
- results[:schema] = result
39
- when :section
40
- results[:sections] << result if result
41
- when :notes
42
- results[:notes].concat(Array(result))
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)
@@ -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
- connection = model_class.connection
8
- adapter_name = detect_adapter_name(connection)
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(connection, model_class.table_name)
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
- output << ' }'
79
- output << ''
80
-
81
- Rails.logger.debug { "Added entity: #{model_display_name}" } if options[:verbose]
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
- # Don't add partial entity if there's an error
85
- # Remove the opening brace line if it was added
86
- output.pop if output.last&.end_with?(' {')
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
- return unless model.respond_to?(:_ct)
169
-
170
- output << " #{format_model_name(model)} }o--o{ #{format_model_name(model)} : \"closure_tree\""
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