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 +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/rails_lens/analyzers/notes.rb +114 -6
- data/lib/rails_lens/annotation_pipeline.rb +26 -21
- data/lib/rails_lens/connection.rb +4 -4
- data/lib/rails_lens/erd/visualizer.rb +50 -3
- data/lib/rails_lens/model_detector.rb +73 -0
- 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 +10 -8
- 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 +113 -1
- data/lib/rails_lens/schema/adapters/postgresql.rb +163 -1
- data/lib/rails_lens/schema/adapters/sqlite3.rb +114 -1
- 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,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
|
-
|
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)
|
@@ -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)
|
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
@@ -8,10 +8,11 @@ module RailsLens
|
|
8
8
|
end
|
9
9
|
|
10
10
|
def applicable?(model_class)
|
11
|
-
|
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
|
@@ -9,14 +9,15 @@ module RailsLens
|
|
9
9
|
end
|
10
10
|
|
11
11
|
def applicable?(model_class)
|
12
|
-
|
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
|
-
|
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 =
|
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 =
|
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 =
|
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
|
@@ -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(
|
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(
|
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(
|
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
|
data/lib/rails_lens/version.rb
CHANGED
@@ -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.
|
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
|