rails_lens 0.2.2 → 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: 41ebdd3c52cca1c77790aa896dcc5962acb92c44eec90a2dceeda98f310aefb7
4
- data.tar.gz: 72e49695a796e9d030fd80aedab53d7dea055db82c98e8023a6a2bb4293e139f
3
+ metadata.gz: 7eaa2e258c8608dfd893bb5220840fc9febce43478aca9573ae0dbb885b33bf5
4
+ data.tar.gz: ed99614fc744e7f32e6c439a8f61e99b5d94787a684c694fbca7ed1eb546e6c6
5
5
  SHA512:
6
- metadata.gz: d0e890e0ae21326d6176542668277a1c8e5d6336f013dec8cc8854f892c0136c6a40fb4557789dc661c47de70fbf82c0e59dcc75769b4a41e613f681e75f9a9f
7
- data.tar.gz: d5c0d6aaecbd3a46986cc86507b3550347bd09e69f4126721fdb9acec05110a2f622fa2555d8c37151144efdd89ef5b87129576d3bc66c02f2b8cdd3404fcb77
6
+ metadata.gz: 0fc9a2a6d597e0d5a5e9b5e1c5eddf79bdca77448e780686fd28eabc26c5085829fe543595bdde05d31949a0e5ca42475bd379d89b9d552c128c06070d7a555c
7
+ data.tar.gz: 3982294a0d8a11b0ba516a7f90dc5595b488a3dcae79ef874c8d7d0cb0369f4a6438304adf1dd476d3aa8542bd445ec44022b549dcdeb48bf63084b5bebdd22d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## [0.2.2](https://github.com/seuros/rails_lens/compare/rails_lens/v0.2.1...rails_lens/v0.2.2) (2025-07-31)
4
16
 
5
17
 
@@ -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)
@@ -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)
@@ -66,8 +66,11 @@ module RailsLens
66
66
  # Additional safety check: Skip abstract models that might have slipped through
67
67
  next if model.abstract_class?
68
68
 
69
- # Skip models without valid tables or columns
70
- next unless model.table_exists? && model.columns.present?
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
71
74
 
72
75
  model_display_name = format_model_name(model)
73
76
 
@@ -105,12 +108,19 @@ module RailsLens
105
108
  end
106
109
  end
107
110
 
111
+ # Add visual styling for views vs tables
112
+ add_visual_styling(output, models)
113
+
108
114
  # Add relationships
109
115
  output << ' %% Relationships'
110
116
  models.each do |model|
111
117
  # Skip abstract models in relationship generation too
112
118
  next if model.abstract_class?
113
- next unless model.table_exists? && model.columns.present?
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
114
124
 
115
125
  add_model_relationships(output, model, models)
116
126
  end
@@ -253,6 +263,43 @@ module RailsLens
253
263
  output << ' }}%%'
254
264
  end
255
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
+
256
303
  def group_models_by_database(models)
257
304
  grouped = Hash.new { |h, k| h[k] = [] }
258
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
@@ -21,7 +21,7 @@ module RailsLens
21
21
  # For :schema type - returns a string with the schema content
22
22
  # For :section type - returns a hash with { title: String, content: String } or nil
23
23
  # For :notes type - returns an array of note strings
24
- def process(model_class)
24
+ def process(model_class, connection = nil)
25
25
  raise NotImplementedError, "#{self.class} must implement #process"
26
26
  end
27
27
 
@@ -8,10 +8,11 @@ module RailsLens
8
8
  end
9
9
 
10
10
  def applicable?(model_class)
11
- RailsLens.config.extensions[:enabled] && model_has_table?(model_class)
11
+ # Only applicable to tables, not views
12
+ RailsLens.config.extensions[:enabled] && model_has_table?(model_class) && !ModelDetector.view_exists?(model_class)
12
13
  end
13
14
 
14
- def process(model_class)
15
+ def process(model_class, connection = nil)
15
16
  results = ExtensionLoader.apply_extensions(model_class)
16
17
  results[:notes]
17
18
  end
@@ -7,7 +7,7 @@ module RailsLens
7
7
  :section
8
8
  end
9
9
 
10
- def process(model_class)
10
+ def process(model_class, connection = nil)
11
11
  results = ExtensionLoader.apply_extensions(model_class)
12
12
 
13
13
  return nil if results[:annotations].empty?
@@ -8,10 +8,11 @@ module RailsLens
8
8
  end
9
9
 
10
10
  def applicable?(model_class)
11
- model_has_table?(model_class)
11
+ # Only applicable to tables, not views
12
+ model_has_table?(model_class) && !ModelDetector.view_exists?(model_class)
12
13
  end
13
14
 
14
- def process(model_class)
15
+ def process(model_class, connection = nil)
15
16
  analyzer = Analyzers::IndexAnalyzer.new(model_class)
16
17
  analyzer.analyze
17
18
  end
@@ -7,7 +7,7 @@ module RailsLens
7
7
  :section
8
8
  end
9
9
 
10
- def process(model_class)
10
+ def process(model_class, connection = nil)
11
11
  analyzer = Analyzers::Inheritance.new(model_class)
12
12
  content = analyzer.analyze
13
13
 
@@ -9,14 +9,15 @@ module RailsLens
9
9
  end
10
10
 
11
11
  def applicable?(model_class)
12
- model_has_table?(model_class)
12
+ # Only applicable to tables, not views
13
+ model_has_table?(model_class) && !ModelDetector.view_exists?(model_class)
13
14
  end
14
15
 
15
16
  def analyzer_class
16
17
  raise NotImplementedError, "#{self.class} must implement #analyzer_class"
17
18
  end
18
19
 
19
- def process(model_class)
20
+ def process(model_class, connection = nil)
20
21
  analyzer = analyzer_class.new(model_class)
21
22
  analyzer.analyze
22
23
  end
@@ -11,17 +11,19 @@ module RailsLens
11
11
  true # Always applicable - handles both abstract and regular models
12
12
  end
13
13
 
14
- def process(model_class)
14
+ def process(model_class, connection = nil)
15
+ # Use passed connection or fall back to model's connection
16
+ conn = connection || model_class.connection
17
+
15
18
  if model_class.abstract_class?
16
19
  # For abstract classes, show database connection information in TOML format
17
- connection = model_class.connection
18
- adapter_name = connection.adapter_name
20
+ adapter_name = conn.adapter_name
19
21
 
20
22
  lines = []
21
23
 
22
24
  # Get connection name
23
25
  begin
24
- connection_name = connection.pool.db_config.name
26
+ connection_name = conn.pool.db_config.name
25
27
  lines << "connection = \"#{connection_name}\""
26
28
  rescue StandardError
27
29
  lines << 'connection = "unknown"'
@@ -31,7 +33,7 @@ module RailsLens
31
33
 
32
34
  # Add database version information
33
35
  begin
34
- db_version = connection.database_version
36
+ db_version = conn.database_version
35
37
  lines << "database_version = \"#{db_version}\""
36
38
  rescue StandardError
37
39
  lines << 'database_version = "unknown"'
@@ -39,7 +41,7 @@ module RailsLens
39
41
 
40
42
  # Add database name if available
41
43
  begin
42
- db_name = connection.current_database
44
+ db_name = conn.current_database
43
45
  lines << "database_name = \"#{db_name}\"" if db_name
44
46
  rescue StandardError
45
47
  # Skip if can't get database name
@@ -51,8 +53,8 @@ module RailsLens
51
53
 
52
54
  lines.join("\n")
53
55
  else
54
- # Add schema information for regular models
55
- adapter = Connection.adapter_for(model_class)
56
+ # Add schema information for regular models (tables or views)
57
+ adapter = Connection.adapter_for(model_class, conn)
56
58
  adapter.generate_annotation(model_class)
57
59
  end
58
60
  end
@@ -12,7 +12,7 @@ module RailsLens
12
12
  raise NotImplementedError, "#{self.class} must implement #analyzer_class"
13
13
  end
14
14
 
15
- def process(model_class)
15
+ def process(model_class, connection = nil)
16
16
  analyzer = analyzer_class.new(model_class)
17
17
  content = analyzer.analyze
18
18
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ # View-specific notes provider that wraps the Notes analyzer for views only
6
+ class ViewNotesProvider < Base
7
+ def type
8
+ :notes
9
+ end
10
+
11
+ def applicable?(model_class)
12
+ # Only applicable to views
13
+ model_has_table?(model_class) && ModelDetector.view_exists?(model_class)
14
+ end
15
+
16
+ def process(model_class, connection = nil)
17
+ analyzer = Analyzers::Notes.new(model_class)
18
+ analyzer.analyze
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ class ViewProvider < SectionProviderBase
6
+ def applicable?(model_class)
7
+ # Only applicable for models backed by views
8
+ ModelDetector.view_exists?(model_class)
9
+ end
10
+
11
+ def process(model_class, connection = nil)
12
+ view_metadata = ViewMetadata.new(model_class)
13
+
14
+ return nil unless view_metadata.view_exists?
15
+
16
+ {
17
+ title: '== View Information',
18
+ content: generate_view_content(view_metadata)
19
+ }
20
+ end
21
+
22
+ private
23
+
24
+ def generate_view_content(view_metadata)
25
+ lines = []
26
+
27
+ # View type (regular or materialized)
28
+ if view_metadata.view_type
29
+ lines << "View Type: #{view_metadata.view_type}"
30
+ end
31
+
32
+ # Updatable status
33
+ lines << "Updatable: #{view_metadata.updatable? ? 'Yes' : 'No'}"
34
+
35
+ # Dependencies
36
+ dependencies = view_metadata.dependencies
37
+ if dependencies.any?
38
+ lines << "Dependencies: #{dependencies.join(', ')}"
39
+ end
40
+
41
+ # Refresh strategy for materialized views
42
+ if view_metadata.materialized_view? && view_metadata.refresh_strategy
43
+ lines << "Refresh Strategy: #{view_metadata.refresh_strategy}"
44
+ end
45
+
46
+ # Last refreshed timestamp for materialized views
47
+ if view_metadata.materialized_view? && view_metadata.last_refreshed
48
+ lines << "Last Refreshed: #{view_metadata.last_refreshed}"
49
+ end
50
+
51
+ # View definition (truncated for readability)
52
+ if view_metadata.view_definition
53
+ definition = view_metadata.view_definition
54
+ # Truncate long definitions
55
+ if definition.length > 200
56
+ definition = "#{definition[0..200]}..."
57
+ end
58
+ # Remove extra whitespace and newlines
59
+ definition = definition.gsub(/\s+/, ' ').strip
60
+ lines << "Definition: #{definition}"
61
+ end
62
+
63
+ lines.join("\n")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -8,7 +8,15 @@ module RailsLens
8
8
  'MySQL'
9
9
  end
10
10
 
11
- def generate_annotation(_model_class)
11
+ def generate_annotation(model_class)
12
+ if model_class && ModelDetector.view_exists?(model_class)
13
+ generate_view_annotation(model_class)
14
+ else
15
+ generate_table_annotation(model_class)
16
+ end
17
+ end
18
+
19
+ def generate_table_annotation(_model_class)
12
20
  lines = []
13
21
  lines << "table = \"#{table_name}\""
14
22
  lines << "database_dialect = \"#{database_dialect}\""
@@ -37,6 +45,27 @@ module RailsLens
37
45
  lines.join("\n")
38
46
  end
39
47
 
48
+ def generate_view_annotation(model_class)
49
+ lines = []
50
+ lines << "view = \"#{table_name}\""
51
+ lines << "database_dialect = \"#{database_dialect}\""
52
+
53
+ # Fetch all view metadata in a single query
54
+ view_info = fetch_view_metadata
55
+
56
+ if view_info
57
+ lines << "view_type = \"#{view_info[:type]}\"" if view_info[:type]
58
+ lines << "updatable = #{view_info[:updatable]}"
59
+ end
60
+
61
+ lines << ''
62
+
63
+ add_columns_toml(lines)
64
+ add_view_dependencies_toml(lines, view_info)
65
+
66
+ lines.join("\n")
67
+ end
68
+
40
69
  protected
41
70
 
42
71
  def format_column(column)
@@ -273,6 +302,89 @@ module RailsLens
273
302
  # MySQL specific errors
274
303
  Rails.logger.debug { "MySQL error fetching partitions: #{e.message}" }
275
304
  end
305
+
306
+ def add_view_dependencies_toml(lines, view_info)
307
+ return unless view_info && view_info[:dependencies]
308
+
309
+ dependencies = view_info[:dependencies]
310
+ return if dependencies.empty?
311
+
312
+ lines << ''
313
+ lines << "view_dependencies = [#{dependencies.map { |d| "\"#{d}\"" }.join(', ')}]"
314
+ end
315
+
316
+ # MySQL-specific view methods
317
+ public
318
+
319
+ # Fetch all view metadata in a single consolidated query
320
+ def fetch_view_metadata
321
+ result = connection.exec_query(<<~SQL.squish, 'MySQL View Metadata')
322
+ SELECT#{' '}
323
+ v.is_updatable,
324
+ COALESCE(
325
+ (
326
+ SELECT GROUP_CONCAT(DISTINCT vtu.table_name ORDER BY vtu.table_name)
327
+ FROM information_schema.view_table_usage vtu
328
+ WHERE vtu.view_schema = DATABASE()
329
+ AND vtu.view_name = '#{connection.quote_string(table_name)}'
330
+ ),
331
+ ''
332
+ ) as dependencies
333
+ FROM information_schema.views v
334
+ WHERE v.table_schema = DATABASE()
335
+ AND v.table_name = '#{connection.quote_string(table_name)}'
336
+ LIMIT 1
337
+ SQL
338
+
339
+ return nil if result.rows.empty?
340
+
341
+ row = result.rows.first
342
+ {
343
+ type: 'regular', # MySQL only supports regular views
344
+ updatable: row[0] == 'YES',
345
+ dependencies: row[1].to_s.split(',').reject(&:empty?)
346
+ }
347
+ rescue ActiveRecord::StatementInvalid, Mysql2::Error => e
348
+ Rails.logger.debug { "Failed to fetch view metadata for #{table_name}: #{e.message}" }
349
+ nil
350
+ end
351
+
352
+ # Legacy methods - kept for backward compatibility but now use consolidated query
353
+ def view_type
354
+ @view_metadata ||= fetch_view_metadata
355
+ @view_metadata&.dig(:type)
356
+ end
357
+
358
+ def view_updatable?
359
+ @view_metadata ||= fetch_view_metadata
360
+ @view_metadata&.dig(:updatable) || false
361
+ end
362
+
363
+ def view_dependencies
364
+ @view_metadata ||= fetch_view_metadata
365
+ @view_metadata&.dig(:dependencies) || []
366
+ end
367
+
368
+ def view_definition
369
+ result = connection.exec_query(<<~SQL.squish, 'MySQL View Definition')
370
+ SELECT view_definition FROM information_schema.views
371
+ WHERE table_schema = DATABASE()
372
+ AND table_name = '#{connection.quote_string(table_name)}'
373
+ LIMIT 1
374
+ SQL
375
+
376
+ result.rows.first&.first&.strip
377
+ rescue ActiveRecord::StatementInvalid, Mysql2::Error
378
+ nil
379
+ end
380
+
381
+ def view_refresh_strategy
382
+ nil # MySQL doesn't have materialized views
383
+ end
384
+
385
+ def view_last_refreshed
386
+ nil # MySQL doesn't have materialized views
387
+ end
276
388
  end
277
389
  end
278
390
  end
@@ -8,7 +8,15 @@ module RailsLens
8
8
  'PostgreSQL'
9
9
  end
10
10
 
11
- def generate_annotation(_model_class)
11
+ def generate_annotation(model_class)
12
+ if model_class && ModelDetector.view_exists?(model_class)
13
+ generate_view_annotation(model_class)
14
+ else
15
+ generate_table_annotation(model_class)
16
+ end
17
+ end
18
+
19
+ def generate_table_annotation(_model_class)
12
20
  lines = []
13
21
  lines << "table = \"#{table_name}\""
14
22
  lines << "database_dialect = \"#{database_dialect}\""
@@ -26,6 +34,35 @@ module RailsLens
26
34
  lines.join("\n")
27
35
  end
28
36
 
37
+ def generate_view_annotation(model_class)
38
+ lines = []
39
+ lines << "view = \"#{table_name}\""
40
+ lines << "database_dialect = \"#{database_dialect}\""
41
+
42
+ # Add schema information for PostgreSQL
43
+ lines << "schema = \"#{schema_name}\"" if schema_name && schema_name != 'public'
44
+
45
+ # Fetch all view metadata in a single query
46
+ view_info = fetch_view_metadata
47
+
48
+ if view_info
49
+ lines << "view_type = \"#{view_info[:type]}\"" if view_info[:type]
50
+ lines << "updatable = #{view_info[:updatable]}"
51
+
52
+ if view_info[:type] == 'materialized'
53
+ lines << 'materialized = true'
54
+ lines << 'refresh_strategy = "manual"'
55
+ end
56
+ end
57
+
58
+ lines << ''
59
+
60
+ add_columns_toml(lines)
61
+ add_view_dependencies_toml(lines, view_info)
62
+
63
+ lines.join("\n")
64
+ end
65
+
29
66
  protected
30
67
 
31
68
  def schema_name
@@ -191,6 +228,131 @@ module RailsLens
191
228
  lines << ''
192
229
  lines << "table_comment = \"#{comment.gsub('"', '\"')}\""
193
230
  end
231
+
232
+ def add_view_dependencies_toml(lines, view_info)
233
+ return unless view_info && view_info[:dependencies]
234
+
235
+ dependencies = view_info[:dependencies]
236
+ return if dependencies.empty?
237
+
238
+ lines << ''
239
+ lines << "view_dependencies = [#{dependencies.map { |d| "\"#{d}\"" }.join(', ')}]"
240
+ end
241
+
242
+ # PostgreSQL-specific view methods
243
+ public
244
+
245
+ # Fetch all view metadata in a single consolidated query
246
+ def fetch_view_metadata
247
+ result = connection.exec_query(<<~SQL.squish, 'PostgreSQL View Metadata')
248
+ WITH view_info AS (
249
+ -- Check for materialized view
250
+ SELECT#{' '}
251
+ 'materialized' as view_type,
252
+ false as is_updatable,
253
+ mv.matviewname as view_name
254
+ FROM pg_matviews mv
255
+ WHERE mv.matviewname = '#{connection.quote_string(table_name)}'
256
+ #{' '}
257
+ UNION ALL
258
+ #{' '}
259
+ -- Check for regular view
260
+ SELECT#{' '}
261
+ 'regular' as view_type,
262
+ CASE WHEN v.is_updatable = 'YES' THEN true ELSE false END as is_updatable,
263
+ v.table_name as view_name
264
+ FROM information_schema.views v
265
+ WHERE v.table_name = '#{connection.quote_string(table_name)}'
266
+ ),
267
+ dependencies AS (
268
+ SELECT DISTINCT c2.relname as dependency_name
269
+ FROM pg_class c1
270
+ JOIN pg_depend d ON c1.oid = d.objid
271
+ JOIN pg_class c2 ON d.refobjid = c2.oid
272
+ WHERE c1.relname = '#{connection.quote_string(table_name)}'
273
+ AND c1.relkind IN ('v', 'm')
274
+ AND c2.relkind IN ('r', 'v', 'm')
275
+ AND d.deptype = 'n'
276
+ )
277
+ SELECT#{' '}
278
+ vi.view_type,
279
+ vi.is_updatable,
280
+ COALESCE(
281
+ (SELECT array_agg(dependency_name ORDER BY dependency_name) FROM dependencies),
282
+ ARRAY[]::text[]
283
+ ) as dependencies
284
+ FROM view_info vi
285
+ LIMIT 1
286
+ SQL
287
+
288
+ return nil if result.rows.empty?
289
+
290
+ row = result.rows.first
291
+ {
292
+ type: row[0],
293
+ updatable: ['t', true].include?(row[1]),
294
+ dependencies: row[2] || []
295
+ }
296
+ rescue ActiveRecord::StatementInvalid, PG::Error => e
297
+ Rails.logger.debug { "Failed to fetch view metadata for #{table_name}: #{e.message}" }
298
+ nil
299
+ end
300
+
301
+ # Legacy methods - kept for backward compatibility but now use consolidated query
302
+ def view_type
303
+ @view_metadata ||= fetch_view_metadata
304
+ @view_metadata&.dig(:type)
305
+ end
306
+
307
+ def view_updatable?
308
+ @view_metadata ||= fetch_view_metadata
309
+ @view_metadata&.dig(:updatable) || false
310
+ end
311
+
312
+ def view_dependencies
313
+ @view_metadata ||= fetch_view_metadata
314
+ @view_metadata&.dig(:dependencies) || []
315
+ end
316
+
317
+ def view_definition
318
+ result = if view_type == 'materialized'
319
+ connection.exec_query(<<~SQL.squish, 'PostgreSQL Materialized View Definition')
320
+ SELECT definition FROM pg_matviews
321
+ WHERE matviewname = '#{connection.quote_string(table_name)}'
322
+ LIMIT 1
323
+ SQL
324
+ else
325
+ connection.exec_query(<<~SQL.squish, 'PostgreSQL View Definition')
326
+ SELECT view_definition FROM information_schema.views
327
+ WHERE table_name = '#{connection.quote_string(table_name)}'
328
+ LIMIT 1
329
+ SQL
330
+ end
331
+
332
+ result.rows.first&.first&.strip
333
+ rescue ActiveRecord::StatementInvalid, PG::Error
334
+ nil
335
+ end
336
+
337
+ def view_refresh_strategy
338
+ view_type == 'materialized' ? 'manual' : nil
339
+ end
340
+
341
+ def view_last_refreshed
342
+ return nil unless view_type == 'materialized'
343
+
344
+ # Get the last refresh time from pg_stat_user_tables
345
+ result = connection.exec_query(<<~SQL.squish, 'PostgreSQL Materialized View Last Refresh')
346
+ SELECT COALESCE(last_vacuum, last_autovacuum) as last_refreshed
347
+ FROM pg_stat_user_tables
348
+ WHERE relname = '#{connection.quote_string(table_name)}'
349
+ LIMIT 1
350
+ SQL
351
+
352
+ result.rows.first&.first
353
+ rescue ActiveRecord::StatementInvalid, PG::Error
354
+ nil
355
+ end
194
356
  end
195
357
  end
196
358
  end
@@ -8,7 +8,15 @@ module RailsLens
8
8
  'SQLite'
9
9
  end
10
10
 
11
- def generate_annotation(_model_class)
11
+ def generate_annotation(model_class)
12
+ if model_class && ModelDetector.view_exists?(model_class)
13
+ generate_view_annotation(model_class)
14
+ else
15
+ generate_table_annotation(model_class)
16
+ end
17
+ end
18
+
19
+ def generate_table_annotation(_model_class)
12
20
  lines = []
13
21
  lines << "table = \"#{table_name}\""
14
22
  lines << "database_dialect = \"#{database_dialect}\""
@@ -22,6 +30,27 @@ module RailsLens
22
30
  lines.join("\n")
23
31
  end
24
32
 
33
+ def generate_view_annotation(model_class)
34
+ lines = []
35
+ lines << "view = \"#{table_name}\""
36
+ lines << "database_dialect = \"#{database_dialect}\""
37
+
38
+ # Fetch all view metadata in a single query
39
+ view_info = fetch_view_metadata
40
+
41
+ if view_info
42
+ lines << "view_type = \"#{view_info[:type]}\"" if view_info[:type]
43
+ lines << "updatable = #{view_info[:updatable]}"
44
+ end
45
+
46
+ lines << ''
47
+
48
+ add_columns_toml(lines)
49
+ add_view_dependencies_toml(lines, view_info)
50
+
51
+ lines.join("\n")
52
+ end
53
+
25
54
  protected
26
55
 
27
56
  def format_column(column)
@@ -90,6 +119,90 @@ module RailsLens
90
119
  Rails.logger.debug { "SQLite error fetching pragmas: #{e.message}" }
91
120
  end
92
121
  end
122
+
123
+ def add_view_dependencies_toml(lines, view_info)
124
+ return unless view_info && view_info[:dependencies]
125
+
126
+ dependencies = view_info[:dependencies]
127
+ return if dependencies.empty?
128
+
129
+ lines << ''
130
+ lines << "view_dependencies = [#{dependencies.map { |d| "\"#{d}\"" }.join(', ')}]"
131
+ end
132
+
133
+ # SQLite-specific view methods
134
+ public
135
+
136
+ # Fetch all view metadata in a single consolidated query
137
+ def fetch_view_metadata
138
+ result = connection.exec_query(<<~SQL.squish, 'SQLite View Metadata')
139
+ SELECT sql FROM sqlite_master#{' '}
140
+ WHERE type = 'view' AND name = '#{connection.quote_string(table_name)}'
141
+ LIMIT 1
142
+ SQL
143
+
144
+ return nil if result.rows.empty?
145
+
146
+ definition = result.rows.first&.first&.strip
147
+ return nil unless definition
148
+
149
+ # Parse dependencies from the SQL definition
150
+ tables = []
151
+ definition.scan(/(?:FROM|JOIN)\s+(\w+)/i) do |match|
152
+ table_name_match = match[0]
153
+ # Exclude the view itself and common SQL keywords
154
+ if !table_name_match.downcase.in?(%w[select where order group having limit offset]) &&
155
+ tables.exclude?(table_name_match) &&
156
+ table_name_match != table_name
157
+ tables << table_name_match
158
+ end
159
+ end
160
+
161
+ {
162
+ type: 'regular', # SQLite only supports regular views
163
+ updatable: false, # SQLite views are generally read-only
164
+ dependencies: tables.sort
165
+ }
166
+ rescue ActiveRecord::StatementInvalid, SQLite3::Exception => e
167
+ Rails.logger.debug { "Failed to fetch view metadata for #{table_name}: #{e.message}" }
168
+ nil
169
+ end
170
+
171
+ # Legacy methods - kept for backward compatibility but now use consolidated query
172
+ def view_type
173
+ @view_metadata ||= fetch_view_metadata
174
+ @view_metadata&.dig(:type)
175
+ end
176
+
177
+ def view_updatable?
178
+ @view_metadata ||= fetch_view_metadata
179
+ @view_metadata&.dig(:updatable) || false
180
+ end
181
+
182
+ def view_dependencies
183
+ @view_metadata ||= fetch_view_metadata
184
+ @view_metadata&.dig(:dependencies) || []
185
+ end
186
+
187
+ def view_definition
188
+ result = connection.exec_query(<<~SQL.squish, 'SQLite View Definition')
189
+ SELECT sql FROM sqlite_master#{' '}
190
+ WHERE type = 'view' AND name = '#{connection.quote_string(table_name)}'
191
+ LIMIT 1
192
+ SQL
193
+
194
+ result.rows.first&.first&.strip
195
+ rescue ActiveRecord::StatementInvalid, SQLite3::Exception
196
+ nil
197
+ end
198
+
199
+ def view_refresh_strategy
200
+ nil # SQLite doesn't have materialized views
201
+ end
202
+
203
+ def view_last_refreshed
204
+ nil # SQLite doesn't have materialized views
205
+ end
93
206
  end
94
207
  end
95
208
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsLens
4
- VERSION = '0.2.2'
4
+ VERSION = '0.2.3'
5
5
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ # Extracts and manages metadata for database views and materialized views
5
+ class ViewMetadata
6
+ attr_reader :model_class, :connection, :table_name
7
+
8
+ def initialize(model_class)
9
+ @model_class = model_class
10
+ @connection = model_class.connection
11
+ @table_name = model_class.table_name
12
+ @adapter_name = connection.adapter_name.downcase
13
+ @adapter = create_adapter
14
+ end
15
+
16
+ def view_type
17
+ return nil unless view_exists?
18
+
19
+ @adapter&.view_type
20
+ end
21
+
22
+ def view_exists?
23
+ ModelDetector.view_exists?(model_class)
24
+ end
25
+
26
+ def materialized_view?
27
+ view_type == 'materialized'
28
+ end
29
+
30
+ def regular_view?
31
+ view_type == 'regular'
32
+ end
33
+
34
+ def updatable?
35
+ return false unless view_exists?
36
+
37
+ @adapter&.view_updatable? || false
38
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined
39
+ false
40
+ end
41
+
42
+ def dependencies
43
+ return [] unless view_exists?
44
+
45
+ @adapter&.view_dependencies || []
46
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined
47
+ []
48
+ end
49
+
50
+ def refresh_strategy
51
+ return nil unless materialized_view?
52
+
53
+ @adapter&.view_refresh_strategy
54
+ end
55
+
56
+ def last_refreshed
57
+ return nil unless materialized_view?
58
+
59
+ @adapter&.view_last_refreshed
60
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined
61
+ nil
62
+ end
63
+
64
+ def view_definition
65
+ return nil unless view_exists?
66
+
67
+ @adapter&.view_definition
68
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotDefined
69
+ nil
70
+ end
71
+
72
+ def to_h
73
+ {
74
+ view_type: view_type,
75
+ updatable: updatable?,
76
+ dependencies: dependencies,
77
+ refresh_strategy: refresh_strategy,
78
+ last_refreshed: last_refreshed,
79
+ view_definition: view_definition
80
+ }.compact
81
+ end
82
+
83
+ private
84
+
85
+ def create_adapter
86
+ case @adapter_name
87
+ when 'postgresql'
88
+ Schema::Adapters::Postgresql.new(connection, table_name)
89
+ when 'mysql', 'mysql2'
90
+ Schema::Adapters::Mysql.new(connection, table_name)
91
+ when 'sqlite', 'sqlite3'
92
+ Schema::Adapters::Sqlite3.new(connection, table_name)
93
+ else
94
+ nil
95
+ end
96
+ end
97
+ end
98
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_lens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -219,6 +219,8 @@ files:
219
219
  - lib/rails_lens/providers/performance_notes_provider.rb
220
220
  - lib/rails_lens/providers/schema_provider.rb
221
221
  - lib/rails_lens/providers/section_provider_base.rb
222
+ - lib/rails_lens/providers/view_notes_provider.rb
223
+ - lib/rails_lens/providers/view_provider.rb
222
224
  - lib/rails_lens/railtie.rb
223
225
  - lib/rails_lens/rake_bootstrapper.rb
224
226
  - lib/rails_lens/route/annotator.rb
@@ -237,6 +239,7 @@ files:
237
239
  - lib/rails_lens/tasks/routes.rake
238
240
  - lib/rails_lens/tasks/schema.rake
239
241
  - lib/rails_lens/version.rb
242
+ - lib/rails_lens/view_metadata.rb
240
243
  homepage: https://github.com/seuros/rails_lens
241
244
  licenses:
242
245
  - MIT