rails_lens 0.2.11 → 0.3.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 +28 -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 +23 -1
- data/lib/rails_lens/configuration.rb +4 -1
- data/lib/rails_lens/erd/visualizer.rb +0 -1
- 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/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 +37 -60
- data/lib/rails_lens/schema/database_annotator.rb +197 -0
- data/lib/rails_lens/version.rb +1 -1
- data/lib/rails_lens.rb +1 -1
- metadata +5 -1
|
@@ -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
|
|
@@ -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:
|
|
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
|
|
@@ -16,8 +14,19 @@ module RailsLens
|
|
|
16
14
|
return unless file_path && File.exist?(file_path)
|
|
17
15
|
|
|
18
16
|
# Only annotate files within the Rails application (unless explicitly allowed)
|
|
19
|
-
if
|
|
20
|
-
|
|
17
|
+
# For engines/gems with dummy apps, check if the file is within the parent directory
|
|
18
|
+
if !allow_external_files && defined?(Rails.root)
|
|
19
|
+
rails_root = Rails.root.to_s
|
|
20
|
+
# Check if this is a dummy app inside a gem/engine
|
|
21
|
+
parent_root = if rails_root.include?('/test/dummy')
|
|
22
|
+
File.expand_path('../..', rails_root)
|
|
23
|
+
else
|
|
24
|
+
rails_root
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless file_path.start_with?(rails_root) || file_path.start_with?(parent_root)
|
|
28
|
+
return
|
|
29
|
+
end
|
|
21
30
|
end
|
|
22
31
|
|
|
23
32
|
annotation_text = generate_annotation
|
|
@@ -129,13 +138,10 @@ module RailsLens
|
|
|
129
138
|
annotation.add_lines(section[:content].split("\n"))
|
|
130
139
|
end
|
|
131
140
|
|
|
132
|
-
# Add notes
|
|
141
|
+
# Add notes as TOML array (already in compact format from analyzers)
|
|
133
142
|
if results[:notes].any?
|
|
134
143
|
annotation.add_line('')
|
|
135
|
-
annotation.add_line('
|
|
136
|
-
results[:notes].uniq.each do |note|
|
|
137
|
-
annotation.add_line("- #{note}")
|
|
138
|
-
end
|
|
144
|
+
annotation.add_line("notes = [#{results[:notes].uniq.map { |n| "\"#{n}\"" }.join(', ')}]")
|
|
139
145
|
end
|
|
140
146
|
|
|
141
147
|
annotation.to_s
|
|
@@ -183,16 +189,8 @@ module RailsLens
|
|
|
183
189
|
end
|
|
184
190
|
end
|
|
185
191
|
|
|
186
|
-
# Get all connection pools first
|
|
187
|
-
all_pools = get_all_connection_pools(models_by_connection_pool)
|
|
188
|
-
|
|
189
|
-
# Log initial connection status (removed verbose output)
|
|
190
|
-
|
|
191
192
|
models_by_connection_pool.each do |connection_pool, pool_models|
|
|
192
193
|
if connection_pool
|
|
193
|
-
# Disconnect all OTHER connection pools before processing this one
|
|
194
|
-
disconnect_other_pools(connection_pool, all_pools, options)
|
|
195
|
-
|
|
196
194
|
# Process all models for this database using a single connection
|
|
197
195
|
connection_pool.with_connection do |connection|
|
|
198
196
|
pool_models.each do |model|
|
|
@@ -267,60 +265,39 @@ module RailsLens
|
|
|
267
265
|
end
|
|
268
266
|
|
|
269
267
|
def self.remove_all(options = {})
|
|
270
|
-
|
|
268
|
+
# Use filesystem-based removal (doesn't require database)
|
|
269
|
+
remove_all_by_filesystem(options)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def self.remove_all_by_filesystem(options = {})
|
|
273
|
+
base_path = options[:models_path] || default_models_path
|
|
271
274
|
results = { removed: [], skipped: [], failed: [] }
|
|
275
|
+
pattern = File.join(base_path, '**', '*.rb')
|
|
276
|
+
files = Dir.glob(pattern)
|
|
277
|
+
|
|
278
|
+
files.each do |file_path|
|
|
279
|
+
content = File.read(file_path)
|
|
280
|
+
next unless Annotation.extract(content)
|
|
272
281
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
results[:removed] << model.name
|
|
282
|
+
cleaned = Annotation.remove(content)
|
|
283
|
+
if cleaned == content
|
|
284
|
+
results[:skipped] << File.basename(file_path, '.rb').camelize
|
|
277
285
|
else
|
|
278
|
-
|
|
286
|
+
File.write(file_path, cleaned)
|
|
287
|
+
model_name = File.basename(file_path, '.rb').camelize
|
|
288
|
+
results[:removed] << model_name
|
|
279
289
|
end
|
|
280
290
|
rescue StandardError => e
|
|
281
|
-
results[:failed] << { model:
|
|
291
|
+
results[:failed] << { model: File.basename(file_path, '.rb').camelize, error: e.message }
|
|
282
292
|
end
|
|
283
293
|
|
|
284
294
|
results
|
|
285
295
|
end
|
|
286
296
|
|
|
287
|
-
def self.
|
|
288
|
-
|
|
289
|
-
next if pool == current_pool || pool.nil?
|
|
297
|
+
def self.default_models_path
|
|
298
|
+
return Rails.root.join('app/models') if defined?(Rails.root)
|
|
290
299
|
|
|
291
|
-
|
|
292
|
-
if pool.connected?
|
|
293
|
-
pool.disconnect!
|
|
294
|
-
end
|
|
295
|
-
rescue StandardError => e
|
|
296
|
-
warn "Failed to disconnect pool: #{e.message}"
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
def self.get_all_connection_pools(models_by_pool)
|
|
302
|
-
models_by_pool.keys.compact
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
def self.log_connection_status(all_pools, options = {})
|
|
306
|
-
return unless options[:verbose]
|
|
307
|
-
|
|
308
|
-
puts "\n=== Connection Pool Status ==="
|
|
309
|
-
all_pools.each do |pool|
|
|
310
|
-
next unless pool
|
|
311
|
-
|
|
312
|
-
begin
|
|
313
|
-
name = pool.db_config&.name || 'unknown'
|
|
314
|
-
connected = pool.connected?
|
|
315
|
-
size = pool.size
|
|
316
|
-
checked_out = pool.stat[:busy]
|
|
317
|
-
|
|
318
|
-
puts "Pool #{name}: connected=#{connected}, size=#{size}, busy=#{checked_out}"
|
|
319
|
-
rescue StandardError => e
|
|
320
|
-
puts "Pool status error: #{e.message}"
|
|
321
|
-
end
|
|
322
|
-
end
|
|
323
|
-
puts "================================\n"
|
|
300
|
+
File.join(Dir.pwd, 'app', 'models')
|
|
324
301
|
end
|
|
325
302
|
|
|
326
303
|
private
|