rails_lens 0.2.12 → 0.5.0

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +88 -72
  4. data/lib/rails_lens/analyzers/association_analyzer.rb +3 -10
  5. data/lib/rails_lens/analyzers/best_practices_analyzer.rb +11 -36
  6. data/lib/rails_lens/analyzers/callbacks.rb +302 -0
  7. data/lib/rails_lens/analyzers/column_analyzer.rb +6 -6
  8. data/lib/rails_lens/analyzers/composite_keys.rb +2 -5
  9. data/lib/rails_lens/analyzers/database_constraints.rb +4 -6
  10. data/lib/rails_lens/analyzers/delegated_types.rb +4 -7
  11. data/lib/rails_lens/analyzers/enums.rb +5 -11
  12. data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +2 -2
  13. data/lib/rails_lens/analyzers/generated_columns.rb +4 -6
  14. data/lib/rails_lens/analyzers/index_analyzer.rb +4 -10
  15. data/lib/rails_lens/analyzers/inheritance.rb +30 -31
  16. data/lib/rails_lens/analyzers/notes.rb +29 -39
  17. data/lib/rails_lens/analyzers/performance_analyzer.rb +3 -26
  18. data/lib/rails_lens/annotation_pipeline.rb +1 -0
  19. data/lib/rails_lens/cli.rb +1 -0
  20. data/lib/rails_lens/commands.rb +26 -4
  21. data/lib/rails_lens/configuration.rb +10 -2
  22. data/lib/rails_lens/erd/visualizer.rb +0 -1
  23. data/lib/rails_lens/extension_loader.rb +5 -4
  24. data/lib/rails_lens/extensions/closure_tree_ext.rb +11 -11
  25. data/lib/rails_lens/mailer/annotator.rb +3 -3
  26. data/lib/rails_lens/model_detector.rb +49 -3
  27. data/lib/rails_lens/model_source.rb +72 -0
  28. data/lib/rails_lens/model_source_loader.rb +117 -0
  29. data/lib/rails_lens/model_sources/active_record_source.rb +89 -0
  30. data/lib/rails_lens/note_codes.rb +59 -0
  31. data/lib/rails_lens/providers/callbacks_provider.rb +24 -0
  32. data/lib/rails_lens/providers/extensions_provider.rb +1 -1
  33. data/lib/rails_lens/providers/view_provider.rb +6 -20
  34. data/lib/rails_lens/schema/adapters/base.rb +39 -2
  35. data/lib/rails_lens/schema/adapters/database_info.rb +11 -17
  36. data/lib/rails_lens/schema/adapters/mysql.rb +75 -0
  37. data/lib/rails_lens/schema/adapters/postgresql.rb +123 -3
  38. data/lib/rails_lens/schema/annotation_manager.rb +105 -50
  39. data/lib/rails_lens/schema/database_annotator.rb +197 -0
  40. data/lib/rails_lens/tasks/annotate.rake +42 -1
  41. data/lib/rails_lens/version.rb +1 -1
  42. data/lib/rails_lens.rb +1 -1
  43. metadata +8 -1
@@ -29,6 +29,7 @@ module RailsLens
29
29
  add_indexes_toml(lines) if show_indexes?
30
30
  add_foreign_keys_toml(lines) if show_foreign_keys?
31
31
  add_check_constraints_toml(lines) if show_check_constraints?
32
+ add_triggers_toml(lines) if show_triggers?
32
33
  add_table_comment_toml(lines) if show_comments?
33
34
 
34
35
  lines.join("\n")
@@ -294,23 +295,24 @@ module RailsLens
294
295
 
295
296
  # Fetch all view metadata in a single consolidated query
296
297
  def fetch_view_metadata
298
+ target_schema = schema_name || 'public'
297
299
  result = connection.exec_query(<<~SQL.squish, 'PostgreSQL View Metadata')
298
300
  WITH view_info AS (
299
- -- Check for materialized view
300
301
  SELECT
301
302
  'materialized' as view_type,
302
303
  false as is_updatable,
303
304
  mv.matviewname as view_name
304
305
  FROM pg_matviews mv
305
306
  WHERE mv.matviewname = '#{connection.quote_string(unqualified_table_name)}'
307
+ AND mv.schemaname = '#{connection.quote_string(target_schema)}'
306
308
  UNION ALL
307
- -- Check for regular view
308
309
  SELECT
309
310
  'regular' as view_type,
310
311
  CASE WHEN v.is_updatable = 'YES' THEN true ELSE false END as is_updatable,
311
312
  v.table_name as view_name
312
313
  FROM information_schema.views v
313
314
  WHERE v.table_name = '#{connection.quote_string(unqualified_table_name)}'
315
+ AND v.table_schema = '#{connection.quote_string(target_schema)}'
314
316
  ),
315
317
  dependencies AS (
316
318
  SELECT DISTINCT c2.relname as dependency_name
@@ -336,10 +338,19 @@ module RailsLens
336
338
  return nil if result.rows.empty?
337
339
 
338
340
  row = result.rows.first
341
+ # Parse PostgreSQL array string (e.g., "{table1,table2}" or "{}")
342
+ deps_raw = row[2]
343
+ deps = if deps_raw.is_a?(Array)
344
+ deps_raw
345
+ elsif deps_raw.is_a?(String) && deps_raw.start_with?('{')
346
+ deps_raw.gsub(/[{}]/, '').split(',').map(&:strip).reject(&:empty?)
347
+ else
348
+ []
349
+ end
339
350
  {
340
351
  type: row[0],
341
352
  updatable: ['t', true].include?(row[1]),
342
- dependencies: row[2] || []
353
+ dependencies: deps
343
354
  }
344
355
  rescue ActiveRecord::StatementInvalid, PG::Error => e
345
356
  RailsLens.logger.debug { "Failed to fetch view metadata for #{table_name}: #{e.message}" }
@@ -401,6 +412,115 @@ module RailsLens
401
412
  rescue ActiveRecord::StatementInvalid, PG::Error
402
413
  nil
403
414
  end
415
+
416
+ # Fetch triggers for the table, excluding extension-owned triggers
417
+ def fetch_triggers
418
+ result = connection.exec_query(<<~SQL.squish, 'PostgreSQL Table Triggers')
419
+ SELECT
420
+ t.tgname AS name,
421
+ CASE t.tgtype::integer & 66
422
+ WHEN 2 THEN 'BEFORE'
423
+ WHEN 64 THEN 'INSTEAD OF'
424
+ ELSE 'AFTER'
425
+ END AS timing,
426
+ CASE t.tgtype::integer & 60
427
+ WHEN 4 THEN 'INSERT'
428
+ WHEN 8 THEN 'DELETE'
429
+ WHEN 16 THEN 'UPDATE'
430
+ WHEN 20 THEN 'INSERT OR UPDATE'
431
+ WHEN 24 THEN 'UPDATE OR DELETE'
432
+ WHEN 12 THEN 'INSERT OR DELETE'
433
+ WHEN 28 THEN 'INSERT OR UPDATE OR DELETE'
434
+ WHEN 32 THEN 'TRUNCATE'
435
+ ELSE 'UNKNOWN'
436
+ END AS event,
437
+ CASE WHEN t.tgtype::integer & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS for_each,
438
+ p.proname AS function_name,
439
+ n.nspname AS function_schema,
440
+ pg_get_triggerdef(t.oid) AS definition
441
+ FROM pg_trigger t
442
+ JOIN pg_class c ON t.tgrelid = c.oid
443
+ JOIN pg_namespace cn ON c.relnamespace = cn.oid
444
+ JOIN pg_proc p ON t.tgfoid = p.oid
445
+ JOIN pg_namespace n ON p.pronamespace = n.oid
446
+ WHERE c.relname = '#{connection.quote_string(unqualified_table_name)}'
447
+ AND cn.nspname = '#{connection.quote_string(schema_name || 'public')}'
448
+ AND NOT t.tgisinternal
449
+ -- Exclude triggers owned by extensions
450
+ AND NOT EXISTS (
451
+ SELECT 1 FROM pg_depend d
452
+ JOIN pg_extension e ON d.refobjid = e.oid
453
+ WHERE d.objid = t.oid
454
+ AND d.deptype = 'e'
455
+ )
456
+ ORDER BY t.tgname
457
+ SQL
458
+
459
+ result.rows.map do |row|
460
+ {
461
+ name: row[0],
462
+ timing: row[1],
463
+ event: row[2],
464
+ for_each: row[3],
465
+ function: row[5] == 'public' ? row[4] : "#{row[5]}.#{row[4]}",
466
+ definition: row[6],
467
+ condition: extract_trigger_condition(row[6])
468
+ }
469
+ end
470
+ rescue ActiveRecord::StatementInvalid, PG::Error => e
471
+ RailsLens.logger.debug { "Failed to fetch triggers for #{table_name}: #{e.message}" }
472
+ []
473
+ end
474
+
475
+ # Extract WHEN condition from trigger definition if present
476
+ def extract_trigger_condition(definition)
477
+ return nil unless definition
478
+
479
+ match = definition.match(/WHEN\s*\((.+?)\)\s*EXECUTE/i)
480
+ match ? match[1] : nil
481
+ end
482
+
483
+ # Fetch all user-defined functions, excluding extension-owned functions
484
+ # This is a database-level operation, returns all functions in user schemas
485
+ def self.fetch_functions(connection)
486
+ result = connection.exec_query(<<~SQL.squish, 'PostgreSQL Functions')
487
+ SELECT
488
+ p.proname AS name,
489
+ n.nspname AS schema,
490
+ l.lanname AS language,
491
+ CASE
492
+ WHEN p.prorettype = 'trigger'::regtype THEN 'trigger'
493
+ ELSE pg_catalog.format_type(p.prorettype, NULL)
494
+ END AS return_type,
495
+ pg_get_functiondef(p.oid) AS definition,
496
+ obj_description(p.oid, 'pg_proc') AS description
497
+ FROM pg_proc p
498
+ JOIN pg_namespace n ON p.pronamespace = n.oid
499
+ JOIN pg_language l ON p.prolang = l.oid
500
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
501
+ AND NOT EXISTS (
502
+ SELECT 1 FROM pg_depend d
503
+ JOIN pg_extension e ON d.refobjid = e.oid
504
+ WHERE d.objid = p.oid
505
+ AND d.deptype = 'e'
506
+ )
507
+ ORDER BY n.nspname, p.proname
508
+ SQL
509
+
510
+ result.rows.map do |row|
511
+ {
512
+ name: row[0],
513
+ schema: row[1],
514
+ language: row[2],
515
+ return_type: row[3],
516
+ definition: row[4],
517
+ description: row[5]
518
+ }
519
+ end
520
+ rescue ActiveRecord::StatementInvalid, PG::Error => e
521
+ RailsLens.logger.debug { "Failed to fetch functions: #{e.message}" }
522
+ []
523
+ end
404
524
  end
405
525
  end
406
526
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../file_insertion_helper'
4
-
5
3
  module RailsLens
6
4
  module Schema
7
5
  class AnnotationManager
@@ -140,19 +138,63 @@ module RailsLens
140
138
  annotation.add_lines(section[:content].split("\n"))
141
139
  end
142
140
 
143
- # Add notes
141
+ # Add notes as TOML array (already in compact format from analyzers)
144
142
  if results[:notes].any?
145
143
  annotation.add_line('')
146
- annotation.add_line('== Notes')
147
- results[:notes].uniq.each do |note|
148
- annotation.add_line("- #{note}")
149
- end
144
+ annotation.add_line("notes = [#{results[:notes].uniq.map { |n| "\"#{n}\"" }.join(', ')}]")
150
145
  end
151
146
 
152
147
  annotation.to_s
153
148
  end
154
149
 
155
150
  def self.annotate_all(options = {})
151
+ results = { annotated: [], skipped: [], failed: [] }
152
+
153
+ # Iterate through all model sources
154
+ ModelSourceLoader.load_sources.each do |source|
155
+ puts "Annotating #{source.source_name} models..." if options[:verbose]
156
+ source_results = annotate_source(source, options)
157
+ merge_results(results, source_results)
158
+ end
159
+
160
+ results
161
+ end
162
+
163
+ # Annotate models from a specific source
164
+ def self.annotate_source(source, options = {})
165
+ results = { annotated: [], skipped: [], failed: [] }
166
+
167
+ begin
168
+ models = source.models(options)
169
+ puts " Found #{models.size} #{source.source_name} models" if options[:verbose]
170
+
171
+ models.each do |model|
172
+ result = source.annotate_model(model, options)
173
+ case result[:status]
174
+ when :annotated
175
+ results[:annotated] << result[:model]
176
+ when :skipped
177
+ results[:skipped] << result[:model]
178
+ when :failed
179
+ results[:failed] << { model: result[:model], error: result[:message] }
180
+ end
181
+ end
182
+ rescue StandardError => e
183
+ puts " Error processing #{source.source_name} source: #{e.message}" if options[:verbose]
184
+ end
185
+
186
+ results
187
+ end
188
+
189
+ # Merge source results into main results
190
+ def self.merge_results(main, source)
191
+ main[:annotated].concat(source[:annotated] || [])
192
+ main[:skipped].concat(source[:skipped] || [])
193
+ main[:failed].concat(source[:failed] || [])
194
+ end
195
+
196
+ # Original ActiveRecord-specific annotation logic (used by ActiveRecordSource)
197
+ def self.annotate_active_record_models(options = {})
156
198
  # Convert models option to include option for ModelDetector
157
199
  if options[:models]
158
200
  options[:include] = options[:models]
@@ -194,16 +236,8 @@ module RailsLens
194
236
  end
195
237
  end
196
238
 
197
- # Get all connection pools first
198
- all_pools = get_all_connection_pools(models_by_connection_pool)
199
-
200
- # Log initial connection status (removed verbose output)
201
-
202
239
  models_by_connection_pool.each do |connection_pool, pool_models|
203
240
  if connection_pool
204
- # Disconnect all OTHER connection pools before processing this one
205
- disconnect_other_pools(connection_pool, all_pools, options)
206
-
207
241
  # Process all models for this database using a single connection
208
242
  connection_pool.with_connection do |connection|
209
243
  pool_models.each do |model|
@@ -278,60 +312,81 @@ module RailsLens
278
312
  end
279
313
 
280
314
  def self.remove_all(options = {})
281
- models = ModelDetector.detect_models(options)
282
315
  results = { removed: [], skipped: [], failed: [] }
283
316
 
284
- models.each do |model|
285
- manager = new(model)
286
- if manager.remove_annotations
287
- results[:removed] << model.name
288
- else
289
- results[:skipped] << model.name
290
- end
291
- rescue StandardError => e
292
- results[:failed] << { model: model.name, error: e.message }
317
+ # Iterate through all model sources
318
+ ModelSourceLoader.load_sources.each do |source|
319
+ puts "Removing annotations from #{source.source_name} models..." if options[:verbose]
320
+ source_results = remove_source(source, options)
321
+ merge_remove_results(results, source_results)
293
322
  end
294
323
 
295
324
  results
296
325
  end
297
326
 
298
- def self.disconnect_other_pools(current_pool, all_pools, options = {})
299
- all_pools.each do |pool|
300
- next if pool == current_pool || pool.nil?
327
+ # Remove annotations from a specific source
328
+ def self.remove_source(source, options = {})
329
+ results = { removed: [], skipped: [], failed: [] }
301
330
 
302
- begin
303
- if pool.connected?
304
- pool.disconnect!
331
+ begin
332
+ models = source.models(options.merge(include_abstract: true))
333
+ puts " Found #{models.size} #{source.source_name} models" if options[:verbose]
334
+
335
+ models.each do |model|
336
+ result = source.remove_annotation(model)
337
+ case result[:status]
338
+ when :removed
339
+ results[:removed] << result[:model]
340
+ when :skipped
341
+ results[:skipped] << result[:model]
342
+ when :failed
343
+ results[:failed] << { model: result[:model], error: result[:message] }
305
344
  end
306
- rescue StandardError => e
307
- warn "Failed to disconnect pool: #{e.message}"
308
345
  end
346
+ rescue StandardError => e
347
+ puts " Error removing from #{source.source_name} source: #{e.message}" if options[:verbose]
309
348
  end
310
- end
311
349
 
312
- def self.get_all_connection_pools(models_by_pool)
313
- models_by_pool.keys.compact
350
+ results
314
351
  end
315
352
 
316
- def self.log_connection_status(all_pools, options = {})
317
- return unless options[:verbose]
353
+ # Merge removal results into main results
354
+ def self.merge_remove_results(main, source)
355
+ main[:removed].concat(source[:removed] || [])
356
+ main[:skipped].concat(source[:skipped] || [])
357
+ main[:failed].concat(source[:failed] || [])
358
+ end
318
359
 
319
- puts "\n=== Connection Pool Status ==="
320
- all_pools.each do |pool|
321
- next unless pool
360
+ # Original filesystem-based removal (kept for backwards compatibility)
361
+ def self.remove_all_by_filesystem(options = {})
362
+ base_path = options[:models_path] || default_models_path
363
+ results = { removed: [], skipped: [], failed: [] }
364
+ pattern = File.join(base_path, '**', '*.rb')
365
+ files = Dir.glob(pattern)
322
366
 
323
- begin
324
- name = pool.db_config&.name || 'unknown'
325
- connected = pool.connected?
326
- size = pool.size
327
- checked_out = pool.stat[:busy]
367
+ files.each do |file_path|
368
+ content = File.read(file_path)
369
+ next unless Annotation.extract(content)
328
370
 
329
- puts "Pool #{name}: connected=#{connected}, size=#{size}, busy=#{checked_out}"
330
- rescue StandardError => e
331
- puts "Pool status error: #{e.message}"
371
+ cleaned = Annotation.remove(content)
372
+ if cleaned == content
373
+ results[:skipped] << File.basename(file_path, '.rb').camelize
374
+ else
375
+ File.write(file_path, cleaned)
376
+ model_name = File.basename(file_path, '.rb').camelize
377
+ results[:removed] << model_name
332
378
  end
379
+ rescue StandardError => e
380
+ results[:failed] << { model: File.basename(file_path, '.rb').camelize, error: e.message }
333
381
  end
334
- puts "================================\n"
382
+
383
+ results
384
+ end
385
+
386
+ def self.default_models_path
387
+ return Rails.root.join('app/models') if defined?(Rails.root)
388
+
389
+ File.join(Dir.pwd, 'app', 'models')
335
390
  end
336
391
 
337
392
  private
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Schema
5
+ # Annotates abstract base classes (ApplicationRecord, etc.) with database-level objects
6
+ # like functions, sequences, types, etc.
7
+ class DatabaseAnnotator
8
+ attr_reader :base_class
9
+
10
+ def initialize(base_class)
11
+ @base_class = base_class
12
+ end
13
+
14
+ def annotate_file(file_path = nil)
15
+ file_path ||= model_file_path
16
+ return unless file_path && File.exist?(file_path)
17
+
18
+ annotation_text = generate_annotation
19
+ return if annotation_text.empty?
20
+
21
+ # Remove existing annotations
22
+ content = File.read(file_path)
23
+ Annotation.remove(content) if Annotation.extract(content)
24
+
25
+ # Use Prism-based insertion
26
+ class_name = base_class.name.split('::').last
27
+ if FileInsertionHelper.insert_at_class_definition(file_path, class_name, annotation_text)
28
+ true
29
+ else
30
+ false
31
+ end
32
+ end
33
+
34
+ def remove_annotations(file_path = nil)
35
+ file_path ||= model_file_path
36
+ return unless file_path && File.exist?(file_path)
37
+
38
+ content = File.read(file_path)
39
+ cleaned_content = Annotation.remove(content)
40
+
41
+ if cleaned_content == content
42
+ false
43
+ else
44
+ File.write(file_path, cleaned_content)
45
+ true
46
+ end
47
+ end
48
+
49
+ def generate_annotation
50
+ annotation = Annotation.new
51
+
52
+ # Detect adapter
53
+ adapter_name = base_class.connection.adapter_name
54
+
55
+ # Always add database dialect for abstract classes
56
+ annotation.add_line("database_dialect = \"#{adapter_name}\"")
57
+
58
+ case adapter_name
59
+ when /PostgreSQL/i
60
+ add_postgresql_functions(annotation)
61
+ when /MySQL/i
62
+ add_mysql_functions(annotation)
63
+ when /SQLite/i
64
+ # SQLite doesn't have stored functions
65
+ end
66
+
67
+ annotation.to_s
68
+ end
69
+
70
+ # Class methods for batch operations
71
+ def self.annotate_all(options = {})
72
+ results = { annotated: [], skipped: [], failed: [] }
73
+
74
+ abstract_classes = detect_abstract_base_classes
75
+
76
+ abstract_classes.each do |klass|
77
+ annotator = new(klass)
78
+
79
+ begin
80
+ if annotator.annotate_file
81
+ results[:annotated] << klass.name
82
+ else
83
+ results[:skipped] << klass.name
84
+ end
85
+ rescue StandardError => e
86
+ results[:failed] << { model: klass.name, error: e.message }
87
+ end
88
+ end
89
+
90
+ results
91
+ end
92
+
93
+ def self.remove_all(options = {})
94
+ results = { removed: [], skipped: [], failed: [] }
95
+
96
+ begin
97
+ abstract_classes = detect_abstract_base_classes
98
+ rescue StandardError => e
99
+ RailsLens.logger.error { "Failed to detect abstract base classes: #{e.message}" }
100
+ return results
101
+ end
102
+
103
+ abstract_classes.each do |klass|
104
+ annotator = new(klass)
105
+ if annotator.remove_annotations
106
+ results[:removed] << klass.name
107
+ else
108
+ results[:skipped] << klass.name
109
+ end
110
+ rescue StandardError => e
111
+ results[:failed] << { model: klass.name, error: e.message }
112
+ end
113
+
114
+ results
115
+ end
116
+
117
+ def self.detect_abstract_base_classes
118
+ return [] unless defined?(Rails)
119
+
120
+ classes = []
121
+
122
+ # Load all models
123
+ Rails.application.eager_load!
124
+
125
+ # Find abstract base classes that inherit from ActiveRecord::Base
126
+ ActiveRecord::Base.descendants.each do |klass|
127
+ next unless klass.abstract_class?
128
+ next if klass == ActiveRecord::Base
129
+
130
+ classes << klass
131
+ end
132
+
133
+ classes
134
+ end
135
+
136
+ private_class_method :detect_abstract_base_classes
137
+
138
+ private
139
+
140
+ def add_postgresql_functions(annotation)
141
+ return unless RailsLens.config.schema[:format_options][:show_functions]
142
+
143
+ require_relative 'adapters/postgresql'
144
+ functions = Adapters::Postgresql.fetch_functions(base_class.connection)
145
+ add_functions_annotation(annotation, functions)
146
+ end
147
+
148
+ def add_mysql_functions(annotation)
149
+ return unless RailsLens.config.schema[:format_options][:show_functions]
150
+
151
+ require_relative 'adapters/mysql'
152
+ functions = Adapters::Mysql.fetch_functions(base_class.connection)
153
+ add_functions_annotation(annotation, functions)
154
+ end
155
+
156
+ def add_functions_annotation(annotation, functions)
157
+ return if functions.empty?
158
+
159
+ annotation.add_line('[database_functions]')
160
+ annotation.add_line('functions = [')
161
+
162
+ functions.each_with_index do |func, index|
163
+ line = ' { '
164
+ attrs = []
165
+ attrs << "name = \"#{escape_toml_string(func[:name])}\""
166
+ attrs << "schema = \"#{escape_toml_string(func[:schema])}\""
167
+ attrs << "language = \"#{escape_toml_string(func[:language])}\""
168
+ attrs << "return_type = \"#{escape_toml_string(func[:return_type])}\""
169
+ attrs << "description = \"#{escape_toml_string(func[:description])}\"" if func[:description]
170
+ line += attrs.join(', ')
171
+ line += ' }'
172
+ line += ',' if index < functions.length - 1
173
+ annotation.add_line(line)
174
+ end
175
+
176
+ annotation.add_line(']')
177
+ end
178
+
179
+ def escape_toml_string(str)
180
+ return '' unless str
181
+
182
+ str.to_s.gsub('\\', '\\\\').gsub('"', '\\"')
183
+ end
184
+
185
+ def model_file_path
186
+ return nil unless base_class.name
187
+
188
+ # Convert class name to file path (e.g., ApplicationRecord -> application_record.rb)
189
+ file_name = "#{base_class.name.underscore}.rb"
190
+
191
+ # Look in app/models
192
+ path = Rails.root.join('app', 'models', file_name) if defined?(Rails.root)
193
+ path if path && File.exist?(path)
194
+ end
195
+ end
196
+ end
197
+ end
@@ -21,9 +21,38 @@ namespace :rails_lens do
21
21
  end
22
22
  end
23
23
 
24
+ desc 'Remove all annotations from models'
25
+ task remove: :environment do
26
+ require 'rails_lens/schema/annotation_manager'
27
+
28
+ results = RailsLens::Schema::AnnotationManager.remove_all
29
+
30
+ puts "Removed annotations from #{results[:removed].length} models" if results[:removed].any?
31
+ puts "Skipped #{results[:skipped].length} models (no annotations)" if results[:skipped].any?
32
+ if results[:failed].any?
33
+ puts "Failed to remove annotations from #{results[:failed].length} models:"
34
+ results[:failed].each do |failure|
35
+ puts " - #{failure[:model]}: #{failure[:error]}"
36
+ end
37
+ end
38
+ end
39
+
40
+ desc 'List registered model sources'
41
+ task sources: :environment do
42
+ require 'rails_lens'
43
+
44
+ puts 'Registered model sources:'
45
+ RailsLens::ModelSourceLoader.list_sources.each do |source|
46
+ puts " - #{source[:name]} (#{source[:class]})"
47
+ source[:patterns].each do |pattern|
48
+ puts " #{pattern}"
49
+ end
50
+ end
51
+ end
52
+
24
53
  desc 'Annotate all Rails files (models, routes, and mailers)'
25
54
  task all: :environment do
26
- # Annotate models
55
+ # Annotate models (includes all registered model sources)
27
56
  Rake::Task['rails_lens:annotate'].invoke
28
57
 
29
58
  # Annotate routes
@@ -32,4 +61,16 @@ namespace :rails_lens do
32
61
  # Annotate mailers
33
62
  Rake::Task['rails_lens:mailers:annotate'].invoke
34
63
  end
64
+
65
+ desc 'Remove all annotations from Rails files (models, routes, and mailers)'
66
+ task remove_all: :environment do
67
+ # Remove model annotations (includes all registered model sources)
68
+ Rake::Task['rails_lens:remove'].invoke
69
+
70
+ # Remove route annotations
71
+ Rake::Task['rails_lens:routes:remove'].invoke
72
+
73
+ # Remove mailer annotations
74
+ Rake::Task['rails_lens:mailers:remove'].invoke
75
+ end
35
76
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsLens
4
- VERSION = '0.2.12'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/rails_lens.rb CHANGED
@@ -60,7 +60,7 @@ module RailsLens
60
60
 
61
61
  current_value = config.send(section)
62
62
  if current_value.is_a?(Hash)
63
- config.send("#{section}=", current_value.merge(settings.symbolize_keys))
63
+ config.send("#{section}=", current_value.deep_merge(settings.symbolize_keys))
64
64
  else
65
65
  config.send("#{section}=", settings.symbolize_keys)
66
66
  end