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
@@ -415,23 +415,69 @@ module RailsLens
415
415
  end
416
416
 
417
417
  def table_exists_with_connection?(model, connection)
418
- connection.table_exists?(model.table_name)
418
+ table_name = model.table_name
419
+
420
+ # Handle schema-qualified table names for PostgreSQL (e.g., 'audit.audit_logs')
421
+ if connection.adapter_name == 'PostgreSQL' && table_name.include?('.')
422
+ with_schema_in_search_path(connection, table_name) do |unqualified_name|
423
+ connection.table_exists?(unqualified_name) || connection.views.include?(unqualified_name)
424
+ end
425
+ else
426
+ # Check both tables and views
427
+ return true if connection.table_exists?(table_name)
428
+ return true if connection.views.include?(table_name)
429
+
430
+ # Fallback for SQLite: direct sqlite_master query for views
431
+ if connection.adapter_name.downcase.include?('sqlite')
432
+ check_sqlite_view(connection, table_name)
433
+ else
434
+ false
435
+ end
436
+ end
419
437
  rescue StandardError
420
438
  false
421
439
  end
422
440
 
423
441
  def columns_empty_with_connection?(model, connection)
424
- connection.columns(model.table_name).empty?
442
+ table_name = model.table_name
443
+
444
+ if connection.adapter_name == 'PostgreSQL' && table_name.include?('.')
445
+ with_schema_in_search_path(connection, table_name) do |unqualified_name|
446
+ connection.columns(unqualified_name).empty?
447
+ end
448
+ else
449
+ connection.columns(table_name).empty?
450
+ end
425
451
  rescue StandardError
426
452
  true
427
453
  end
428
454
 
429
455
  def get_column_count_with_connection(model, connection)
430
- connection.columns(model.table_name).size
456
+ table_name = model.table_name
457
+
458
+ if connection.adapter_name == 'PostgreSQL' && table_name.include?('.')
459
+ with_schema_in_search_path(connection, table_name) do |unqualified_name|
460
+ connection.columns(unqualified_name).size
461
+ end
462
+ else
463
+ connection.columns(table_name).size
464
+ end
431
465
  rescue StandardError
432
466
  0
433
467
  end
434
468
 
469
+ # Helper to execute block with schema in PostgreSQL search_path
470
+ def with_schema_in_search_path(connection, qualified_table_name)
471
+ schema_name, unqualified_name = qualified_table_name.split('.', 2)
472
+ original_search_path = connection.schema_search_path
473
+ begin
474
+ connection.schema_search_path = "#{schema_name}, #{original_search_path}"
475
+ yield unqualified_name
476
+ ensure
477
+ connection.schema_search_path = original_search_path
478
+ end
479
+ end
480
+
435
481
  def has_sti_column?(model)
436
482
  return false unless model.table_exists?
437
483
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ # Base class for model sources
5
+ # Model sources provide pluggable discovery of different model types
6
+ # (e.g., ActiveRecord, ActiveCypher, etc.)
7
+ #
8
+ # Gems can register their own model source by defining:
9
+ # GemName::RailsLensModelSource < RailsLens::ModelSource
10
+ #
11
+ # Example:
12
+ # module MyOrm
13
+ # class ModelSource < ::RailsLens::ModelSource
14
+ # def self.models(options = {})
15
+ # # Return array of model classes
16
+ # end
17
+ #
18
+ # def self.file_patterns
19
+ # ['app/my_models/**/*.rb']
20
+ # end
21
+ #
22
+ # def self.annotate_model(model, options = {})
23
+ # # Return { status: :annotated/:skipped/:failed, model: name, ... }
24
+ # end
25
+ #
26
+ # def self.remove_annotation(model)
27
+ # # Return { status: :removed/:skipped, model: name, ... }
28
+ # end
29
+ # end
30
+ #
31
+ # RailsLensModelSource = ModelSource
32
+ # end
33
+ #
34
+ class ModelSource
35
+ class << self
36
+ # Return array of model classes to annotate
37
+ # @param options [Hash] Options passed from CLI
38
+ # @return [Array<Class>] Array of model classes
39
+ def models(_options = {})
40
+ raise NotImplementedError, "#{name} must implement .models"
41
+ end
42
+
43
+ # Return file patterns for annotation removal
44
+ # Used when removing annotations by filesystem scan
45
+ # @return [Array<String>] Glob patterns relative to Rails.root
46
+ def file_patterns
47
+ raise NotImplementedError, "#{name} must implement .file_patterns"
48
+ end
49
+
50
+ # Annotate a single model
51
+ # @param model [Class] The model class to annotate
52
+ # @param options [Hash] Options passed from CLI
53
+ # @return [Hash] Result with :status (:annotated, :skipped, :failed), :model, :file, :message
54
+ def annotate_model(_model, _options = {})
55
+ raise NotImplementedError, "#{name} must implement .annotate_model"
56
+ end
57
+
58
+ # Remove annotation from a single model
59
+ # @param model [Class] The model class
60
+ # @return [Hash] Result with :status (:removed, :skipped), :model, :file
61
+ def remove_annotation(_model)
62
+ raise NotImplementedError, "#{name} must implement .remove_annotation"
63
+ end
64
+
65
+ # Human-readable name for this source (used in logging)
66
+ # @return [String]
67
+ def source_name
68
+ name.demodulize.sub(/Source$/, '').underscore.humanize
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ # Discovers and loads model sources from gems
5
+ # Gems can register sources in two ways:
6
+ # 1. Define GemName::RailsLensModelSource (auto-discovery)
7
+ # 2. Call RailsLens::ModelSourceLoader.register(SourceClass) explicitly
8
+ class ModelSourceLoader
9
+ @registered_sources = []
10
+
11
+ class << self
12
+ # Register a model source explicitly
13
+ # Use this when gem naming doesn't follow conventions
14
+ # @param source [Class] Model source class
15
+ def register(source)
16
+ return unless valid_source?(source)
17
+
18
+ @registered_sources ||= []
19
+ @registered_sources << source unless @registered_sources.include?(source)
20
+ end
21
+
22
+ # Load all available model sources
23
+ # @return [Array<Class>] Array of model source classes
24
+ def load_sources
25
+ sources = []
26
+
27
+ # Always include ActiveRecord source first
28
+ sources << ModelSources::ActiveRecordSource
29
+
30
+ # Include explicitly registered sources
31
+ @registered_sources ||= []
32
+ sources.concat(@registered_sources)
33
+
34
+ # Load gem-provided sources via auto-discovery if enabled
35
+ sources.concat(load_gem_sources) if config_enabled?
36
+
37
+ # Deduplicate in case a source was both registered and auto-discovered
38
+ sources.uniq
39
+ end
40
+
41
+ # List all loaded sources (for debugging/info)
42
+ # @return [Array<Hash>] Source info with name and class
43
+ def list_sources
44
+ load_sources.map do |source|
45
+ {
46
+ name: source.source_name,
47
+ class: source.name,
48
+ patterns: source.file_patterns
49
+ }
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def config_enabled?
56
+ config = RailsLens.config.model_sources
57
+ config && config[:enabled]
58
+ end
59
+
60
+ def load_gem_sources
61
+ sources = []
62
+
63
+ Gem.loaded_specs.each_key do |gem_name|
64
+ source = find_source_for_gem(gem_name)
65
+ sources << source if source && valid_source?(source)
66
+ end
67
+
68
+ sources
69
+ end
70
+
71
+ def find_source_for_gem(gem_name)
72
+ # Skip gems that might cause autoload issues
73
+ return nil if %w[digest openssl uri net json].include?(gem_name)
74
+
75
+ # Convert gem name to constant (e.g., 'activecypher' -> 'ActiveCypher')
76
+ # Use ActiveSupport's camelize for proper Rails-style conversion
77
+ gem_constant_name = gem_name.gsub('-', '_').camelize
78
+
79
+ # Check if gem constant exists without triggering autoload
80
+ return nil unless Object.const_defined?(gem_constant_name, false)
81
+
82
+ gem_constant = Object.const_get(gem_constant_name)
83
+ return nil unless gem_constant.is_a?(Module)
84
+
85
+ # Check if it has a RailsLensModelSource
86
+ return nil unless gem_constant.const_defined?('RailsLensModelSource', false)
87
+
88
+ gem_constant.const_get('RailsLensModelSource')
89
+ rescue NameError
90
+ nil
91
+ rescue StandardError => e
92
+ log_error("Error loading model source from #{gem_name}: #{e.message}")
93
+ nil
94
+ end
95
+
96
+ def valid_source?(klass)
97
+ return false unless klass.is_a?(Class)
98
+
99
+ required_methods = %i[models file_patterns annotate_model remove_annotation]
100
+ required_methods.all? { |m| klass.respond_to?(m) }
101
+ end
102
+
103
+ def log_error(message)
104
+ error_reporting = RailsLens.config.model_sources[:error_reporting] || :warn
105
+
106
+ case error_reporting
107
+ when :silent
108
+ # Do nothing
109
+ when :warn
110
+ RailsLens.logger.warn "[RailsLens ModelSources] #{message}"
111
+ when :verbose
112
+ RailsLens.logger.error "[RailsLens ModelSources] #{message}"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module ModelSources
5
+ # Built-in model source for ActiveRecord models
6
+ class ActiveRecordSource < ModelSource
7
+ class << self
8
+ def models(options = {})
9
+ # Convert models option to include option for ModelDetector
10
+ opts = options.dup
11
+ opts[:include] = opts.delete(:models) if opts[:models]
12
+
13
+ models = ModelDetector.detect_models(opts)
14
+
15
+ # Filter abstract classes based on options
16
+ if opts[:include_abstract]
17
+ # Include all models
18
+ elsif opts[:abstract_only]
19
+ models = models.select(&:abstract_class?)
20
+ else
21
+ # Default: exclude abstract classes
22
+ models = models.reject(&:abstract_class?)
23
+ end
24
+
25
+ models
26
+ end
27
+
28
+ def file_patterns
29
+ ['app/models/**/*.rb']
30
+ end
31
+
32
+ def annotate_model(model, options = {})
33
+ # Use the optimized connection-pooled annotation
34
+ results = { annotated: [], skipped: [], failed: [] }
35
+
36
+ # Group this single model by connection pool for consistency
37
+ begin
38
+ pool = model.connection_pool
39
+ pool.with_connection do |connection|
40
+ Schema::AnnotationManager.process_model_with_connection(model, connection, results, options)
41
+ end
42
+ rescue StandardError
43
+ # Fallback without connection management
44
+ Schema::AnnotationManager.process_model_with_connection(model, nil, results, options)
45
+ end
46
+
47
+ if results[:annotated].include?(model.name)
48
+ { status: :annotated, model: model.name, file: model_file_path(model) }
49
+ elsif results[:failed].any? { |f| f[:model] == model.name }
50
+ failure = results[:failed].find { |f| f[:model] == model.name }
51
+ { status: :failed, model: model.name, message: failure[:error] }
52
+ else
53
+ { status: :skipped, model: model.name }
54
+ end
55
+ end
56
+
57
+ def remove_annotation(model)
58
+ manager = Schema::AnnotationManager.new(model)
59
+ if manager.remove_annotations
60
+ { status: :removed, model: model.name, file: model_file_path(model) }
61
+ else
62
+ { status: :skipped, model: model.name }
63
+ end
64
+ rescue StandardError => e
65
+ { status: :failed, model: model.name, message: e.message }
66
+ end
67
+
68
+ def source_name
69
+ 'ActiveRecord'
70
+ end
71
+
72
+ private
73
+
74
+ def model_file_path(model)
75
+ return nil unless model.name
76
+
77
+ const_source_location = Object.const_source_location(model.name)
78
+ return const_source_location.first if const_source_location
79
+
80
+ if defined?(Rails.root)
81
+ Rails.root.join('app', 'models', "#{model.name.underscore}.rb").to_s
82
+ end
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ # Compact note codes for LLM-readable annotations
5
+ # Format: "column_name:CODE" or "association:CODE"
6
+ module NoteCodes
7
+ # Column constraint codes
8
+ NOT_NULL = 'NOT_NULL'
9
+ DEFAULT = 'DEFAULT'
10
+ LIMIT = 'LIMIT'
11
+
12
+ # Index codes
13
+ INDEX = 'INDEX'
14
+ POLY_INDEX = 'POLY_INDEX'
15
+ COMP_INDEX = 'COMP_INDEX'
16
+ REDUND_IDX = 'REDUND_IDX'
17
+
18
+ # Type codes
19
+ USE_DECIMAL = 'USE_DECIMAL'
20
+ USE_INTEGER = 'USE_INTEGER'
21
+
22
+ # Association codes
23
+ INVERSE_OF = 'INVERSE_OF'
24
+ N_PLUS_ONE = 'N_PLUS_ONE'
25
+ COUNTER_CACHE = 'COUNTER_CACHE'
26
+ FK_CONSTRAINT = 'FK_CONSTRAINT'
27
+
28
+ # Best practices codes
29
+ NO_TIMESTAMPS = 'NO_TIMESTAMPS'
30
+ PARTIAL_TS = 'PARTIAL_TS'
31
+ STORAGE = 'STORAGE'
32
+
33
+ # STI codes
34
+ STI_INDEX = 'STI_INDEX'
35
+ STI_NOT_NULL = 'STI_NOT_NULL'
36
+
37
+ # View codes
38
+ VIEW_READONLY = 'VIEW_READONLY'
39
+ ADD_READONLY = 'ADD_READONLY'
40
+ MATVIEW_STALE = 'MATVIEW_STALE'
41
+ ADD_REFRESH = 'ADD_REFRESH'
42
+ NESTED_VIEW = 'NESTED_VIEW'
43
+ VIEW_PROTECT = 'VIEW_PROTECT'
44
+
45
+ # Structure codes
46
+ MISSING = 'MISSING'
47
+ DEPTH_CACHE = 'DEPTH_CACHE'
48
+
49
+ class << self
50
+ # Build a compact note string
51
+ # @param subject [String, nil] column/association name (nil for model-level)
52
+ # @param code [String] note code constant
53
+ # @return [String] formatted note
54
+ def note(subject, code)
55
+ subject ? "#{subject}:#{code}" : code
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Providers
5
+ class CallbacksProvider < SectionProviderBase
6
+ def analyzer_class
7
+ Analyzers::Callbacks
8
+ end
9
+
10
+ def applicable?(model_class)
11
+ # Only applicable to non-abstract models with callbacks
12
+ return false if model_class.abstract_class?
13
+
14
+ # Check if model has any callbacks defined (Rails 8+ uses unified chains)
15
+ RailsLens::Analyzers::Callbacks::CALLBACK_CHAINS.any? do |chain_name|
16
+ chain_method = "_#{chain_name}_callbacks"
17
+ model_class.respond_to?(chain_method) && model_class.public_send(chain_method).present?
18
+ end
19
+ rescue StandardError
20
+ false
21
+ end
22
+ end
23
+ end
24
+ end
@@ -13,7 +13,7 @@ module RailsLens
13
13
  return nil if results[:annotations].empty?
14
14
 
15
15
  {
16
- title: '== Extensions',
16
+ title: '[extensions]',
17
17
  content: results[:annotations].join("\n")
18
18
  }
19
19
  end
@@ -14,7 +14,7 @@ module RailsLens
14
14
  return nil unless view_metadata.view_exists?
15
15
 
16
16
  {
17
- title: '== View Information',
17
+ title: '[view]',
18
18
  content: generate_view_content(view_metadata)
19
19
  }
20
20
  end
@@ -25,39 +25,25 @@ module RailsLens
25
25
  lines = []
26
26
 
27
27
  # View type (regular or materialized)
28
- if view_metadata.view_type
29
- lines << "View Type: #{view_metadata.view_type}"
30
- end
28
+ lines << "type = \"#{view_metadata.view_type}\"" if view_metadata.view_type
31
29
 
32
30
  # Updatable status
33
- lines << "Updatable: #{view_metadata.updatable? ? 'Yes' : 'No'}"
31
+ lines << "updatable = #{view_metadata.updatable?}"
34
32
 
35
33
  # Dependencies
36
34
  dependencies = view_metadata.dependencies
37
35
  if dependencies.any?
38
- lines << "Dependencies: #{dependencies.join(', ')}"
36
+ lines << "dependencies = [#{dependencies.map { |d| "\"#{d}\"" }.join(', ')}]"
39
37
  end
40
38
 
41
39
  # Refresh strategy for materialized views
42
40
  if view_metadata.materialized_view? && view_metadata.refresh_strategy
43
- lines << "Refresh Strategy: #{view_metadata.refresh_strategy}"
41
+ lines << "refresh_strategy = \"#{view_metadata.refresh_strategy}\""
44
42
  end
45
43
 
46
44
  # Last refreshed timestamp for materialized views
47
45
  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}"
46
+ lines << "last_refreshed = \"#{view_metadata.last_refreshed}\""
61
47
  end
62
48
 
63
49
  lines.join("\n")
@@ -175,6 +175,42 @@ module RailsLens
175
175
  connection.supports_comments?
176
176
  end
177
177
 
178
+ def show_triggers?
179
+ RailsLens.config.schema[:format_options][:show_triggers]
180
+ end
181
+
182
+ def fetch_triggers
183
+ # Override in database-specific adapters
184
+ []
185
+ end
186
+
187
+ def add_triggers_toml(lines)
188
+ triggers = fetch_triggers
189
+ return if triggers.empty?
190
+
191
+ lines << ''
192
+ lines << 'triggers = ['
193
+ triggers.each_with_index do |trigger, i|
194
+ line = ' { '
195
+ attrs = []
196
+ attrs << "name = \"#{trigger[:name]}\""
197
+ attrs << "event = \"#{trigger[:event]}\""
198
+ attrs << "timing = \"#{trigger[:timing]}\""
199
+ attrs << "function = \"#{trigger[:function]}\""
200
+ attrs << "for_each = \"#{trigger[:for_each]}\"" if trigger[:for_each]
201
+ attrs << "condition = \"#{escape_toml_string(trigger[:condition])}\"" if trigger[:condition]
202
+ line += attrs.join(', ')
203
+ line += ' }'
204
+ line += ',' if i < triggers.length - 1
205
+ lines << line
206
+ end
207
+ lines << ']'
208
+ end
209
+
210
+ def escape_toml_string(str)
211
+ str.to_s.gsub('\\', '\\\\').gsub('"', '\\"')
212
+ end
213
+
178
214
  # Structured formatting methods
179
215
  def add_columns_structured(lines)
180
216
  lines << 'COLUMNS:'
@@ -252,8 +288,9 @@ module RailsLens
252
288
  attrs = []
253
289
  attrs << "name = \"#{column.name}\""
254
290
  attrs << "type = \"#{column.type}\""
255
- attrs << 'primary_key = true' if primary_key?(column)
256
- attrs << "nullable = #{column.null}"
291
+ attrs << 'pk = true' if primary_key?(column)
292
+ # Only add null = false when NOT nullable (omit when nullable for brevity)
293
+ attrs << 'null = false' unless column.null
257
294
  attrs << "default = #{format_toml_value(column.default)}" if column.default && show_defaults?
258
295
  line += attrs.join(', ')
259
296
  line += ' }'
@@ -13,30 +13,24 @@ module RailsLens
13
13
 
14
14
  def generate_annotation
15
15
  lines = []
16
- lines << '== Database Information'
17
- lines << "Adapter: #{adapter_name}"
18
- lines << "Database: #{database_name}"
19
- lines << "Version: #{database_version}"
20
- lines << "Encoding: #{database_encoding}" if respond_to?(:database_encoding)
21
- lines << "Collation: #{database_collation}" if respond_to?(:database_collation)
22
- lines << ''
16
+ lines << '[database_info]'
17
+ lines << "adapter = \"#{adapter_name}\""
18
+ lines << "database = \"#{database_name}\""
19
+ lines << "version = \"#{database_version}\""
20
+ enc = database_encoding
21
+ lines << "encoding = \"#{enc}\"" if enc
22
+ coll = database_collation
23
+ lines << "collation = \"#{coll}\"" if coll
23
24
 
24
25
  # Add extensions for PostgreSQL
25
26
  if adapter_name == 'PostgreSQL' && extensions.any?
26
- lines << 'Enabled Extensions:'
27
- extensions.each do |ext|
28
- lines << " - #{ext['name']} (#{ext['version']})"
29
- end
30
- lines << ''
27
+ ext_list = extensions.map { |e| "{ name = \"#{e['name']}\", version = \"#{e['version']}\" }" }
28
+ lines << "extensions = [#{ext_list.join(', ')}]"
31
29
  end
32
30
 
33
31
  # Add schemas for PostgreSQL
34
32
  if adapter_name == 'PostgreSQL' && schemas.any?
35
- lines << 'Database Schemas:'
36
- schemas.each do |schema|
37
- lines << " - #{schema}"
38
- end
39
- lines << ''
33
+ lines << "schemas = [#{schemas.map { |s| "\"#{s}\"" }.join(', ')}]"
40
34
  end
41
35
 
42
36
  lines.join("\n")
@@ -40,6 +40,7 @@ module RailsLens
40
40
  add_columns_toml(lines)
41
41
  add_indexes_toml(lines) if show_indexes?
42
42
  add_foreign_keys_toml(lines) if show_foreign_keys?
43
+ add_triggers_toml(lines) if show_triggers?
43
44
  add_partitions_toml(lines) if has_partitions?
44
45
 
45
46
  lines.join("\n")
@@ -385,6 +386,80 @@ module RailsLens
385
386
  def view_last_refreshed
386
387
  nil # MySQL doesn't have materialized views
387
388
  end
389
+
390
+ # Fetch triggers for the table
391
+ def fetch_triggers
392
+ quoted_table = connection.quote(unqualified_table_name)
393
+ result = connection.exec_query(<<~SQL.squish, 'MySQL Table Triggers')
394
+ SELECT
395
+ TRIGGER_NAME AS name,
396
+ ACTION_TIMING AS timing,
397
+ EVENT_MANIPULATION AS event,
398
+ 'ROW' AS for_each,
399
+ ACTION_STATEMENT AS action_statement
400
+ FROM information_schema.TRIGGERS
401
+ WHERE TRIGGER_SCHEMA = DATABASE()
402
+ AND EVENT_OBJECT_TABLE = #{quoted_table}
403
+ ORDER BY TRIGGER_NAME
404
+ SQL
405
+
406
+ result.rows.map do |row|
407
+ {
408
+ name: row[0],
409
+ timing: row[1],
410
+ event: row[2],
411
+ for_each: row[3],
412
+ function: extract_function_call(row[4]),
413
+ definition: row[4]
414
+ }
415
+ end
416
+ rescue ActiveRecord::StatementInvalid, Mysql2::Error => e
417
+ RailsLens.logger.debug { "Failed to fetch triggers for #{table_name}: #{e.message}" }
418
+ []
419
+ end
420
+
421
+ # Extract function/procedure call from trigger action statement
422
+ def extract_function_call(action_statement)
423
+ return nil unless action_statement
424
+
425
+ # Match CALL with optional schema prefix and backticks: CALL `schema`.`proc` or CALL schema.proc or CALL proc
426
+ match = action_statement.match(/CALL\s+(?:`?(\w+)`?\.)?`?(\w+)`?/i)
427
+ if match
428
+ schema_part = match[1]
429
+ proc_name = match[2]
430
+ schema_part ? "#{schema_part}.#{proc_name}" : proc_name
431
+ else
432
+ 'inline'
433
+ end
434
+ end
435
+
436
+ # Fetch all user-defined functions
437
+ def self.fetch_functions(connection)
438
+ result = connection.exec_query(<<~SQL.squish, 'MySQL Functions')
439
+ SELECT
440
+ ROUTINE_NAME AS name,
441
+ ROUTINE_SCHEMA AS `schema`,
442
+ DATA_TYPE AS return_type,
443
+ ROUTINE_COMMENT AS description
444
+ FROM information_schema.ROUTINES
445
+ WHERE ROUTINE_SCHEMA = DATABASE()
446
+ AND ROUTINE_TYPE = 'FUNCTION'
447
+ ORDER BY ROUTINE_NAME
448
+ SQL
449
+
450
+ result.rows.map do |row|
451
+ {
452
+ name: row[0],
453
+ schema: row[1],
454
+ language: 'SQL',
455
+ return_type: row[2],
456
+ description: row[3]
457
+ }
458
+ end
459
+ rescue ActiveRecord::StatementInvalid, Mysql2::Error => e
460
+ RailsLens.logger.debug { "Failed to fetch functions: #{e.message}" }
461
+ []
462
+ end
388
463
  end
389
464
  end
390
465
  end