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
|
@@ -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
|
|
@@ -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('
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
if
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
327
|
+
# Remove annotations from a specific source
|
|
328
|
+
def self.remove_source(source, options = {})
|
|
329
|
+
results = { removed: [], skipped: [], failed: [] }
|
|
301
330
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
313
|
-
models_by_pool.keys.compact
|
|
350
|
+
results
|
|
314
351
|
end
|
|
315
352
|
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
data/lib/rails_lens/version.rb
CHANGED
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.
|
|
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
|