rails_lens 0.2.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,25 +11,40 @@ 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 = []
23
+
24
+ # Get connection name
25
+ begin
26
+ connection_name = conn.pool.db_config.name
27
+ lines << "connection = \"#{connection_name}\""
28
+ rescue StandardError
29
+ lines << 'connection = "unknown"'
30
+ end
31
+
21
32
  lines << "database_dialect = \"#{adapter_name}\""
22
33
 
23
- # Add basic database information
34
+ # Add database version information
35
+ begin
36
+ db_version = conn.database_version
37
+ lines << "database_version = \"#{db_version}\""
38
+ rescue StandardError
39
+ lines << 'database_version = "unknown"'
40
+ end
41
+
42
+ # Add database name if available
24
43
  begin
25
- db_name = begin
26
- connection.database_version
27
- rescue StandardError
28
- 'unknown'
29
- end
30
- lines << "database_version = \"#{db_name}\""
44
+ db_name = conn.current_database
45
+ lines << "database_name = \"#{db_name}\"" if db_name
31
46
  rescue StandardError
32
- # Skip if can't get version
47
+ # Skip if can't get database name
33
48
  end
34
49
 
35
50
  lines << ''
@@ -38,8 +53,8 @@ module RailsLens
38
53
 
39
54
  lines.join("\n")
40
55
  else
41
- # Add schema information for regular models
42
- adapter = Connection.adapter_for(model_class)
56
+ # Add schema information for regular models (tables or views)
57
+ adapter = Connection.adapter_for(model_class, conn)
43
58
  adapter.generate_annotation(model_class)
44
59
  end
45
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)
@@ -117,7 +146,7 @@ module RailsLens
117
146
  rescue ActiveRecord::StatementInvalid => e
118
147
  Rails.logger.debug { "Failed to fetch storage engine for #{table_name}: #{e.message}" }
119
148
  nil
120
- rescue Mysql2::Error => e
149
+ rescue => e
121
150
  Rails.logger.debug { "MySQL error fetching storage engine: #{e.message}" }
122
151
  nil
123
152
  end
@@ -137,7 +166,7 @@ module RailsLens
137
166
  rescue ActiveRecord::StatementInvalid => e
138
167
  Rails.logger.debug { "Failed to fetch charset for #{table_name}: #{e.message}" }
139
168
  nil
140
- rescue Mysql2::Error => e
169
+ rescue => e
141
170
  Rails.logger.debug { "MySQL error fetching charset: #{e.message}" }
142
171
  nil
143
172
  end
@@ -155,7 +184,7 @@ module RailsLens
155
184
  rescue ActiveRecord::StatementInvalid => e
156
185
  Rails.logger.debug { "Failed to fetch collation for #{table_name}: #{e.message}" }
157
186
  nil
158
- rescue Mysql2::Error => e
187
+ rescue => e
159
188
  Rails.logger.debug { "MySQL error fetching collation: #{e.message}" }
160
189
  nil
161
190
  end
@@ -202,7 +231,7 @@ module RailsLens
202
231
  # Table doesn't exist or no permission to query information_schema
203
232
  Rails.logger.debug { "Failed to check partitions for #{table_name}: #{e.message}" }
204
233
  false
205
- rescue Mysql2::Error => e
234
+ rescue => e
206
235
  # MySQL specific errors (connection issues, etc)
207
236
  Rails.logger.debug { "MySQL error checking partitions: #{e.message}" }
208
237
  false
@@ -231,7 +260,7 @@ module RailsLens
231
260
  rescue ActiveRecord::StatementInvalid => e
232
261
  # Permission denied or table doesn't exist
233
262
  Rails.logger.debug { "Failed to fetch partitions for #{table_name}: #{e.message}" }
234
- rescue Mysql2::Error => e
263
+ rescue => e
235
264
  # MySQL specific errors
236
265
  Rails.logger.debug { "MySQL error fetching partitions: #{e.message}" }
237
266
  end
@@ -269,10 +298,93 @@ module RailsLens
269
298
  rescue ActiveRecord::StatementInvalid => e
270
299
  # Permission denied or table doesn't exist
271
300
  Rails.logger.debug { "Failed to fetch partitions for #{table_name}: #{e.message}" }
272
- rescue Mysql2::Error => e
301
+ rescue => e
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