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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +88 -72
- data/lib/rails_lens/analyzers/association_analyzer.rb +3 -10
- data/lib/rails_lens/analyzers/best_practices_analyzer.rb +11 -36
- data/lib/rails_lens/analyzers/callbacks.rb +302 -0
- data/lib/rails_lens/analyzers/column_analyzer.rb +6 -6
- data/lib/rails_lens/analyzers/composite_keys.rb +2 -5
- data/lib/rails_lens/analyzers/database_constraints.rb +4 -6
- data/lib/rails_lens/analyzers/delegated_types.rb +4 -7
- data/lib/rails_lens/analyzers/enums.rb +5 -11
- data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +2 -2
- data/lib/rails_lens/analyzers/generated_columns.rb +4 -6
- data/lib/rails_lens/analyzers/index_analyzer.rb +4 -10
- data/lib/rails_lens/analyzers/inheritance.rb +30 -31
- data/lib/rails_lens/analyzers/notes.rb +29 -39
- data/lib/rails_lens/analyzers/performance_analyzer.rb +3 -26
- data/lib/rails_lens/annotation_pipeline.rb +1 -0
- data/lib/rails_lens/cli.rb +1 -0
- data/lib/rails_lens/commands.rb +26 -4
- data/lib/rails_lens/configuration.rb +10 -2
- data/lib/rails_lens/erd/visualizer.rb +0 -1
- data/lib/rails_lens/extension_loader.rb +5 -4
- data/lib/rails_lens/extensions/closure_tree_ext.rb +11 -11
- data/lib/rails_lens/mailer/annotator.rb +3 -3
- data/lib/rails_lens/model_detector.rb +49 -3
- data/lib/rails_lens/model_source.rb +72 -0
- data/lib/rails_lens/model_source_loader.rb +117 -0
- data/lib/rails_lens/model_sources/active_record_source.rb +89 -0
- data/lib/rails_lens/note_codes.rb +59 -0
- data/lib/rails_lens/providers/callbacks_provider.rb +24 -0
- data/lib/rails_lens/providers/extensions_provider.rb +1 -1
- data/lib/rails_lens/providers/view_provider.rb +6 -20
- data/lib/rails_lens/schema/adapters/base.rb +39 -2
- data/lib/rails_lens/schema/adapters/database_info.rb +11 -17
- data/lib/rails_lens/schema/adapters/mysql.rb +75 -0
- data/lib/rails_lens/schema/adapters/postgresql.rb +123 -3
- data/lib/rails_lens/schema/annotation_manager.rb +105 -50
- data/lib/rails_lens/schema/database_annotator.rb +197 -0
- data/lib/rails_lens/tasks/annotate.rake +42 -1
- data/lib/rails_lens/version.rb +1 -1
- data/lib/rails_lens.rb +1 -1
- metadata +8 -1
|
@@ -415,23 +415,69 @@ module RailsLens
|
|
|
415
415
|
end
|
|
416
416
|
|
|
417
417
|
def table_exists_with_connection?(model, connection)
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -14,7 +14,7 @@ module RailsLens
|
|
|
14
14
|
return nil unless view_metadata.view_exists?
|
|
15
15
|
|
|
16
16
|
{
|
|
17
|
-
title: '
|
|
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 << "
|
|
31
|
+
lines << "updatable = #{view_metadata.updatable?}"
|
|
34
32
|
|
|
35
33
|
# Dependencies
|
|
36
34
|
dependencies = view_metadata.dependencies
|
|
37
35
|
if dependencies.any?
|
|
38
|
-
lines << "
|
|
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 << "
|
|
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 << "
|
|
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 << '
|
|
256
|
-
|
|
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 << '
|
|
17
|
-
lines << "
|
|
18
|
-
lines << "
|
|
19
|
-
lines << "
|
|
20
|
-
|
|
21
|
-
lines << "
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
extensions.
|
|
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 << '
|
|
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
|