rails_lens 0.2.2 → 0.2.4

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.
@@ -104,8 +104,8 @@ module RailsLens
104
104
  return [] unless adapter_name == 'PostgreSQL'
105
105
 
106
106
  connection.select_values(<<-SQL.squish)
107
- SELECT schema_name#{' '}
108
- FROM information_schema.schemata#{' '}
107
+ SELECT schema_name
108
+ FROM information_schema.schemata
109
109
  WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
110
110
  ORDER BY schema_name
111
111
  SQL
@@ -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,129 @@ 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
+ UNION ALL
257
+ -- Check for regular view
258
+ SELECT
259
+ 'regular' as view_type,
260
+ CASE WHEN v.is_updatable = 'YES' THEN true ELSE false END as is_updatable,
261
+ v.table_name as view_name
262
+ FROM information_schema.views v
263
+ WHERE v.table_name = '#{connection.quote_string(table_name)}'
264
+ ),
265
+ dependencies AS (
266
+ SELECT DISTINCT c2.relname as dependency_name
267
+ FROM pg_class c1
268
+ JOIN pg_depend d ON c1.oid = d.objid
269
+ JOIN pg_class c2 ON d.refobjid = c2.oid
270
+ WHERE c1.relname = '#{connection.quote_string(table_name)}'
271
+ AND c1.relkind IN ('v', 'm')
272
+ AND c2.relkind IN ('r', 'v', 'm')
273
+ AND d.deptype = 'n'
274
+ )
275
+ SELECT
276
+ vi.view_type,
277
+ vi.is_updatable,
278
+ COALESCE(
279
+ (SELECT array_agg(dependency_name ORDER BY dependency_name) FROM dependencies),
280
+ ARRAY[]::text[]
281
+ ) as dependencies
282
+ FROM view_info vi
283
+ LIMIT 1
284
+ SQL
285
+
286
+ return nil if result.rows.empty?
287
+
288
+ row = result.rows.first
289
+ {
290
+ type: row[0],
291
+ updatable: ['t', true].include?(row[1]),
292
+ dependencies: row[2] || []
293
+ }
294
+ rescue ActiveRecord::StatementInvalid, PG::Error => e
295
+ Rails.logger.debug { "Failed to fetch view metadata for #{table_name}: #{e.message}" }
296
+ nil
297
+ end
298
+
299
+ # Legacy methods - kept for backward compatibility but now use consolidated query
300
+ def view_type
301
+ @view_metadata ||= fetch_view_metadata
302
+ @view_metadata&.dig(:type)
303
+ end
304
+
305
+ def view_updatable?
306
+ @view_metadata ||= fetch_view_metadata
307
+ @view_metadata&.dig(:updatable) || false
308
+ end
309
+
310
+ def view_dependencies
311
+ @view_metadata ||= fetch_view_metadata
312
+ @view_metadata&.dig(:dependencies) || []
313
+ end
314
+
315
+ def view_definition
316
+ result = if view_type == 'materialized'
317
+ connection.exec_query(<<~SQL.squish, 'PostgreSQL Materialized View Definition')
318
+ SELECT definition FROM pg_matviews
319
+ WHERE matviewname = '#{connection.quote_string(table_name)}'
320
+ LIMIT 1
321
+ SQL
322
+ else
323
+ connection.exec_query(<<~SQL.squish, 'PostgreSQL View Definition')
324
+ SELECT view_definition FROM information_schema.views
325
+ WHERE table_name = '#{connection.quote_string(table_name)}'
326
+ LIMIT 1
327
+ SQL
328
+ end
329
+
330
+ result.rows.first&.first&.strip
331
+ rescue ActiveRecord::StatementInvalid, PG::Error
332
+ nil
333
+ end
334
+
335
+ def view_refresh_strategy
336
+ view_type == 'materialized' ? 'manual' : nil
337
+ end
338
+
339
+ def view_last_refreshed
340
+ return nil unless view_type == 'materialized'
341
+
342
+ # Get the last refresh time from pg_stat_user_tables
343
+ result = connection.exec_query(<<~SQL.squish, 'PostgreSQL Materialized View Last Refresh')
344
+ SELECT COALESCE(last_vacuum, last_autovacuum) as last_refreshed
345
+ FROM pg_stat_user_tables
346
+ WHERE relname = '#{connection.quote_string(table_name)}'
347
+ LIMIT 1
348
+ SQL
349
+
350
+ result.rows.first&.first
351
+ rescue ActiveRecord::StatementInvalid, PG::Error
352
+ nil
353
+ end
194
354
  end
195
355
  end
196
356
  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
@@ -61,7 +61,33 @@ module RailsLens
61
61
 
62
62
  def generate_annotation
63
63
  pipeline = AnnotationPipeline.new
64
- results = pipeline.process(model_class)
64
+
65
+ # If we have a connection set by annotate_all, use it to process all providers
66
+ if @connection
67
+ results = { schema: nil, sections: [], notes: [] }
68
+
69
+ pipeline.instance_variable_get(:@providers).each do |provider|
70
+ next unless provider.applicable?(model_class)
71
+
72
+ begin
73
+ result = provider.process(model_class, @connection)
74
+
75
+ case provider.type
76
+ when :schema
77
+ results[:schema] = result
78
+ when :section
79
+ results[:sections] << result if result
80
+ when :notes
81
+ results[:notes].concat(Array(result))
82
+ end
83
+ rescue StandardError => e
84
+ warn "Provider #{provider.class} error for #{model_class}: #{e.message}"
85
+ end
86
+ end
87
+ else
88
+ # Fall back to normal processing without connection management
89
+ results = pipeline.process(model_class)
90
+ end
65
91
 
66
92
  annotation = Annotation.new
67
93
 
@@ -105,51 +131,77 @@ module RailsLens
105
131
 
106
132
  results = { annotated: [], skipped: [], failed: [] }
107
133
 
108
- models.each do |model|
109
- # Ensure model is actually a class, not a hash or other object
110
- unless model.is_a?(Class)
111
- results[:failed] << { model: model.inspect, error: "Expected Class, got #{model.class}" }
112
- next
113
- end
134
+ # Group models by their connection pool to process each database separately
135
+ models_by_connection_pool = models.group_by do |model|
136
+ model.connection_pool
137
+ rescue StandardError
138
+ nil # Models without connection pools will be processed separately
139
+ end
114
140
 
115
- # Skip models without tables or with missing tables (but not abstract classes)
116
- unless model.abstract_class? || model.table_exists?
117
- results[:skipped] << model.name
118
- warn "Skipping #{model.name} - table does not exist" if options[:verbose]
119
- next
141
+ models_by_connection_pool.each do |connection_pool, pool_models|
142
+ if connection_pool
143
+ # Process all models for this database using a single connection
144
+ connection_pool.with_connection do |connection|
145
+ pool_models.each do |model|
146
+ process_model_with_connection(model, connection, results, options)
147
+ end
148
+ end
149
+ else
150
+ # Process models without connection pools individually
151
+ pool_models.each do |model|
152
+ process_model_with_connection(model, nil, results, options)
153
+ end
120
154
  end
155
+ end
121
156
 
122
- manager = new(model)
123
-
124
- # Determine file path based on options
125
- file_path = if options[:models_path]
126
- File.join(options[:models_path], "#{model.name.underscore}.rb")
127
- else
128
- nil # Use default model_file_path
129
- end
157
+ results
158
+ end
130
159
 
131
- # Allow external files when models_path is provided (for testing)
132
- allow_external = options[:models_path].present?
160
+ def self.process_model_with_connection(model, connection, results, options)
161
+ # Ensure model is actually a class, not a hash or other object
162
+ unless model.is_a?(Class)
163
+ results[:failed] << { model: model.inspect, error: "Expected Class, got #{model.class}" }
164
+ return
165
+ end
133
166
 
134
- if manager.annotate_file(file_path, allow_external_files: allow_external)
135
- results[:annotated] << model.name
136
- else
137
- results[:skipped] << model.name
138
- end
139
- rescue ActiveRecord::StatementInvalid => e
140
- # Handle database-related errors (missing tables, schemas, etc.)
167
+ # Skip models without tables or with missing tables (but not abstract classes)
168
+ unless model.abstract_class? || model.table_exists?
141
169
  results[:skipped] << model.name
142
- warn "Skipping #{model.name} - database error: #{e.message}" if options[:verbose]
143
- rescue StandardError => e
144
- model_name = if model.is_a?(Class) && model.respond_to?(:name)
145
- model.name
146
- else
147
- model.inspect
148
- end
149
- results[:failed] << { model: model_name, error: e.message }
170
+ warn "Skipping #{model.name} - table does not exist" if options[:verbose]
171
+ return
150
172
  end
151
173
 
152
- results
174
+ manager = new(model)
175
+
176
+ # Set the connection in the manager if provided
177
+ manager.instance_variable_set(:@connection, connection) if connection
178
+
179
+ # Determine file path based on options
180
+ file_path = if options[:models_path]
181
+ File.join(options[:models_path], "#{model.name.underscore}.rb")
182
+ else
183
+ nil # Use default model_file_path
184
+ end
185
+
186
+ # Allow external files when models_path is provided (for testing)
187
+ allow_external = options[:models_path].present?
188
+
189
+ if manager.annotate_file(file_path, allow_external_files: allow_external)
190
+ results[:annotated] << model.name
191
+ else
192
+ results[:skipped] << model.name
193
+ end
194
+ rescue ActiveRecord::StatementInvalid => e
195
+ # Handle database-related errors (missing tables, schemas, etc.)
196
+ results[:skipped] << model.name
197
+ warn "Skipping #{model.name} - database error: #{e.message}" if options[:verbose]
198
+ rescue StandardError => e
199
+ model_name = if model.is_a?(Class) && model.respond_to?(:name)
200
+ model.name
201
+ else
202
+ model.inspect
203
+ end
204
+ results[:failed] << { model: model_name, error: e.message }
153
205
  end
154
206
 
155
207
  def self.remove_all(options = {})
@@ -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.4'
5
5
  end