Skip to content

Commit a42fdcd

Browse files
authored
feat: add database view annotation support (#7)
- Add ViewMetadata class for view detection and metadata extraction - Implement view-specific adapters for PostgreSQL, MySQL, and SQLite - Support regular views and materialized views with proper annotations - Add ViewNotesProvider for view-specific analysis and recommendations
1 parent 1998038 commit a42fdcd

30 files changed

Lines changed: 1430 additions & 19 deletions

lib/rails_lens/analyzers/notes.rb

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,22 @@ def analyze
2929

3030
notes = []
3131

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
3848

3949
notes.compact.uniq
4050
rescue ActiveRecord::StatementInvalid => e
@@ -47,6 +57,54 @@ def analyze
4757

4858
private
4959

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+
50108
def analyze_indexes
51109
notes = []
52110

@@ -320,6 +378,56 @@ def uuid_columns
320378
column.type == :uuid || (column.type == :string && column.name.match?(/uuid|guid/))
321379
end
322380
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
323431
end
324432
end
325433
end

lib/rails_lens/annotation_pipeline.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def register_default_providers
6565

6666
# Section providers (additional structured content)
6767
register(Providers::ExtensionsProvider.new) if RailsLens.config.extensions[:enabled]
68+
register(Providers::ViewProvider.new)
6869
register(Providers::InheritanceProvider.new)
6970
register(Providers::EnumsProvider.new)
7071
register(Providers::DelegatedTypesProvider.new)
@@ -75,6 +76,7 @@ def register_default_providers
7576
# Notes providers (analysis and recommendations)
7677
return unless RailsLens.config.schema[:include_notes]
7778

79+
register(Providers::ViewNotesProvider.new)
7880
register(Providers::IndexNotesProvider.new)
7981
register(Providers::ForeignKeyNotesProvider.new)
8082
register(Providers::AssociationNotesProvider.new)

lib/rails_lens/erd/visualizer.rb

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ def generate_mermaid(models)
6666
# Additional safety check: Skip abstract models that might have slipped through
6767
next if model.abstract_class?
6868

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
7174

7275
model_display_name = format_model_name(model)
7376

@@ -105,12 +108,19 @@ def generate_mermaid(models)
105108
end
106109
end
107110

111+
# Add visual styling for views vs tables
112+
add_visual_styling(output, models)
113+
108114
# Add relationships
109115
output << ' %% Relationships'
110116
models.each do |model|
111117
# Skip abstract models in relationship generation too
112118
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
114124

115125
add_model_relationships(output, model, models)
116126
end
@@ -253,6 +263,43 @@ def add_theme_configuration(output)
253263
output << ' }}%%'
254264
end
255265

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+
256303
def group_models_by_database(models)
257304
grouped = Hash.new { |h, k| h[k] = [] }
258305

lib/rails_lens/model_detector.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,81 @@ def sti_child_models
3737
concrete_models.select { |model| model.superclass != ActiveRecord::Base && concrete_models.include?(model.superclass) }
3838
end
3939

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+
4061
private
4162

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+
42115
def eager_load_models
43116
# Zeitwerk is always available in Rails 7+
44117
Zeitwerk::Loader.eager_load_all

lib/rails_lens/providers/extension_notes_provider.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ def type
88
end
99

1010
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)
1213
end
1314

1415
def process(model_class)

lib/rails_lens/providers/index_notes_provider.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ def type
88
end
99

1010
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)
1213
end
1314

1415
def process(model_class)

lib/rails_lens/providers/notes_provider_base.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ def type
99
end
1010

1111
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)
1314
end
1415

1516
def analyzer_class

lib/rails_lens/providers/schema_provider.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def process(model_class)
5151

5252
lines.join("\n")
5353
else
54-
# Add schema information for regular models
54+
# Add schema information for regular models (tables or views)
5555
adapter = Connection.adapter_for(model_class)
5656
adapter.generate_annotation(model_class)
5757
end

0 commit comments

Comments
 (0)