propel_api 0.3.1.6 → 0.3.3

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.
@@ -13,6 +13,17 @@ module PropelApi
13
13
  include PropelApi::ConfigurationMethods
14
14
  include PropelApi::PathGenerationMethods
15
15
 
16
+ # Shared class options for dependency management
17
+ class_option :no_auto_fix,
18
+ type: :boolean,
19
+ default: false,
20
+ desc: "Skip automatic dependency cleanup (not recommended)"
21
+
22
+ class_option :dry_run,
23
+ type: :boolean,
24
+ default: false,
25
+ desc: "Show what would be destroyed without actually doing it"
26
+
16
27
  protected
17
28
 
18
29
  def model_exists?
@@ -250,37 +261,33 @@ module PropelApi
250
261
  end
251
262
  end
252
263
 
253
- def create_propel_tests(test_types: [])
264
+ # Rails-reversible shared methods - no conditional logic!
265
+ def create_propel_model_test
266
+ template "tests/model_test_template.rb.tt", "test/models/#{file_name}_test.rb"
267
+ end
268
+
269
+ def create_propel_controller_test
270
+ template "tests/controller_test_template.rb.tt", "test/controllers/#{controller_path}/#{controller_file_name}_test.rb"
271
+ end
272
+
273
+ def create_propel_integration_test
274
+ template "tests/integration_test_template.rb.tt", "test/integration/#{file_name}_api_test.rb"
275
+ end
276
+
277
+ def create_propel_fixtures
254
278
  if behavior == :revoke
255
- # Remove test files
256
- test_types.each do |test_type|
257
- case test_type
258
- when :model
259
- remove_file "test/models/#{file_name}_test.rb"
260
- when :controller
261
- remove_file "test/controllers/#{controller_path}/#{controller_file_name}_test.rb"
262
- when :integration
263
- remove_file "test/integration/#{file_name}_api_test.rb"
264
- when :fixtures
265
- remove_file "test/fixtures/#{table_name}.yml"
266
- end
267
- end
279
+ # Rails convention: Keep stub fixture for 'fixtures :all' compatibility
280
+ empty_fixture_content = "# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html\n\n# This file was emptied when #{class_name} resource was destroyed\n# Keeping stub file so Rails fixtures :all doesn't break\n"
281
+ create_file "test/fixtures/#{table_name}.yml", empty_fixture_content
268
282
  else
269
- # Generate test files
270
- test_types.each do |test_type|
271
- case test_type
272
- when :model
273
- template "tests/model_test_template.rb.tt", "test/models/#{file_name}_test.rb"
274
- when :controller
275
- template "tests/controller_test_template.rb.tt", "test/controllers/#{controller_path}/#{controller_file_name}_test.rb"
276
- when :integration
277
- template "tests/integration_test_template.rb.tt", "test/integration/#{file_name}_api_test.rb"
278
- when :fixtures
279
- template "tests/fixtures_template.yml.tt", "test/fixtures/#{table_name}.yml"
280
- end
281
- end
283
+ template "tests/fixtures_template.yml.tt", "test/fixtures/#{table_name}.yml"
282
284
  end
283
285
  end
286
+
287
+ def create_propel_controller_template
288
+ initialize_propel_api_settings
289
+ template "scaffold/facet_controller_template.rb.tt", "app/controllers/#{controller_path}/#{controller_file_name}.rb"
290
+ end
284
291
 
285
292
  def show_propel_completion_message(resource_type:, generated_files:, next_steps:)
286
293
  if behavior == :revoke
@@ -376,41 +383,937 @@ module PropelApi
376
383
  false
377
384
  end
378
385
 
379
- # Route management helper methods
380
- def insert_routes
381
- routes_file = File.join(destination_root, "config/routes.rb")
382
- return unless File.exist?(routes_file)
386
+ # Smart dependency management - automatically fixes dependencies by default
387
+ def check_for_critical_dependencies
388
+ if options[:dry_run]
389
+ show_dry_run_preview
390
+ return
391
+ end
383
392
 
384
- routes_content = File.read(routes_file)
393
+ critical_dependencies = find_critical_dependencies
385
394
 
386
- if @api_namespace.present? && @api_version.present?
387
- # Both namespace and version: insert into existing or create new structure
388
- if has_nested_namespace?(routes_content, @api_namespace, @api_version)
389
- resource_line = " resources :#{route_name}"
390
- insert_into_nested_namespace(routes_content, @api_namespace, @api_version, resource_line)
395
+ if critical_dependencies.any?
396
+ if options[:no_auto_fix]
397
+ show_dependency_solution(critical_dependencies)
391
398
  else
392
- # Create new nested namespace structure
393
- # Rails route method adds 2 spaces to each line, so we need to adjust for that
394
- route_content = "namespace :#{@api_namespace} do\n namespace :#{@api_version} do\n resources :#{route_name}\n end\nend"
395
- route route_content
399
+ # Default behavior: Auto-fix dependencies (Rails convention over configuration)
400
+ attempt_auto_fix(critical_dependencies)
396
401
  end
397
- elsif @api_namespace.present?
398
- # Only namespace: insert into existing or create new
399
- if has_single_namespace?(routes_content, @api_namespace)
400
- resource_line = " resources :#{route_name}"
401
- insert_into_single_namespace(routes_content, @api_namespace, resource_line)
402
+ end
403
+
404
+ # Note: Targeted fixture repair is handled by individual dependency fixes
405
+ # No need for system-wide fixture validation
406
+ end
407
+
408
+ def find_critical_dependencies
409
+ # Only look for actual breaking dependencies, not every mention
410
+ critical_deps = []
411
+
412
+ # 1. Look for has_many relationships pointing to this resource
413
+ Dir.glob("#{destination_root}/app/models/*.rb").each do |file|
414
+ content = File.read(file) rescue next
415
+ if content.match?(/has_many\s+:#{table_name}/)
416
+ critical_deps << {
417
+ type: :has_many_relationship,
418
+ file: file.gsub(destination_root + "/", ""),
419
+ issue: "has_many :#{table_name} relationship will break",
420
+ auto_fixable: true
421
+ }
422
+ end
423
+
424
+ # EXACT belongs_to detection - avoid false positives like :meeting_link_parent
425
+ # Only match EXACT association names: belongs_to :meeting (not :meeting_link_parent)
426
+ if content.match?(/belongs_to\s+:#{file_name}\b(?!_)/)
427
+ critical_deps << {
428
+ type: :belongs_to_relationship,
429
+ file: file.gsub(destination_root + "/", ""),
430
+ issue: "belongs_to :#{file_name} relationship will break when #{class_name} model is removed",
431
+ auto_fixable: true
432
+ }
433
+ end
434
+
435
+ # NEW: Model configuration dependencies (facets, validations, etc.)
436
+ if content.match?(/json_facet.*:#{file_name}/) || content.match?(/json_facet.*:#{file_name}_id/) || content.match?(/include:\s*\[.*:#{file_name}/)
437
+ critical_deps << {
438
+ type: :model_configuration,
439
+ file: file.gsub(destination_root + "/", ""),
440
+ issue: "Model configuration references #{file_name} in json_facet or other config",
441
+ auto_fixable: true
442
+ }
443
+ end
444
+ end
445
+
446
+ # 2. Look for test setup methods that reference this resource's fixtures
447
+ Dir.glob("#{destination_root}/test/**/*.rb").each do |file|
448
+ next if file.include?("#{file_name}_test.rb") # Skip the test we're destroying
449
+ next if file.include?("#{file_name}_controller_test.rb")
450
+ next if file.include?("#{file_name}_api_test.rb")
451
+
452
+ content = File.read(file) rescue next
453
+ # Look for fixture references: transcripts(:one), transcripts(:some_name)
454
+ if content.match?(/#{table_name}\(:[^)]+\)/)
455
+ critical_deps << {
456
+ type: :test_setup_dependency,
457
+ file: file.gsub(destination_root + "/", ""),
458
+ issue: "Test setup references #{table_name} fixtures which will no longer exist",
459
+ auto_fixable: true
460
+ }
461
+ end
462
+
463
+ # Use DRY utility for comprehensive test dependency detection
464
+ if test_reference_patterns(file_name).any? { |pattern| content.match?(Regexp.new(pattern)) }
465
+ critical_deps << {
466
+ type: :test_method_dependency,
467
+ file: file.gsub(destination_root + "/", ""),
468
+ issue: "Test methods reference destroyed #{file_name} fields/associations",
469
+ auto_fixable: true
470
+ }
471
+ end
472
+ end
473
+
474
+ # 3. Look for fixtures referencing this resource
475
+ Dir.glob("#{destination_root}/test/fixtures/*.yml").each do |file|
476
+ next if file.include?("#{table_name}.yml") # Skip the fixture we're destroying
477
+
478
+ content = File.read(file) rescue next
479
+ # Use DRY utility for exact fixture pattern detection
480
+ fixture_patterns = ["#{file_name}:", "#{file_name}_id:", "#{file_name}_type:"]
481
+ if fixture_patterns.any? { |pattern| content.include?(pattern) }
482
+ critical_deps << {
483
+ type: :fixture_reference,
484
+ file: file.gsub(destination_root + "/", ""),
485
+ issue: "Fixture references #{file_name} which will no longer exist",
486
+ auto_fixable: true
487
+ }
488
+ end
489
+
490
+ # NEW: Polymorphic fixture syntax: meeting_link_parent: one (Meeting)
491
+ if content.match?(/.*_parent:\s+\w+\s+\(#{class_name}\)/)
492
+ critical_deps << {
493
+ type: :polymorphic_fixture_reference,
494
+ file: file.gsub(destination_root + "/", ""),
495
+ issue: "Polymorphic fixture references #{class_name} which will no longer exist",
496
+ auto_fixable: true
497
+ }
498
+ end
499
+ end
500
+
501
+ # 4. Look for class constant references in test files (more common and safer to fix)
502
+ Dir.glob("#{destination_root}/test/**/*.rb").each do |file|
503
+ next if file.include?("#{file_name}_test.rb") # Skip the test we're destroying
504
+ next if file.include?("#{file_name}_controller_test.rb")
505
+ next if file.include?("#{file_name}_api_test.rb")
506
+
507
+ content = File.read(file) rescue next
508
+ # Look for is_a?(Agenda) patterns in assertions and polymorphic type checks
509
+ if content.match?(/\.is_a\?\(#{class_name}\)/)
510
+ critical_deps << {
511
+ type: :class_constant_in_test,
512
+ file: file.gsub(destination_root + "/", ""),
513
+ issue: "Test assertions reference #{class_name} class in polymorphic type checks",
514
+ auto_fixable: true
515
+ }
516
+ end
517
+ end
518
+
519
+ # 5. Look for direct class constant references (in models, controllers, serialization)
520
+ Dir.glob("#{destination_root}/app/**/*.rb").each do |file|
521
+ content = File.read(file) rescue next
522
+ # Look for direct class references: Agenda, ::Agenda, class_name: 'Agenda'
523
+ if content.match?(/\b#{class_name}\b/) && !content.match?(/class\s+#{class_name}/)
524
+ critical_deps << {
525
+ type: :class_constant_reference,
526
+ file: file.gsub(destination_root + "/", ""),
527
+ issue: "Code directly references #{class_name} class which will no longer exist",
528
+ auto_fixable: false # Too dangerous to auto-fix class references
529
+ }
530
+ end
531
+ end
532
+
533
+ # 6. Look for polymorphic parent configurations that mention this resource
534
+ Dir.glob("#{destination_root}/app/models/*.rb").each do |file|
535
+ content = File.read(file) rescue next
536
+ # Look for polymorphic parent type arrays or configurations
537
+ if content.match?(/#{class_name}/) && content.match?(/polymorphic|parent_types|commentable_types/)
538
+ critical_deps << {
539
+ type: :polymorphic_parent_config,
540
+ file: file.gsub(destination_root + "/", ""),
541
+ issue: "Polymorphic configuration references #{class_name} as valid parent type",
542
+ auto_fixable: true
543
+ }
544
+ end
545
+ end
546
+
547
+ # 7. Look for migration files that create references to this resource
548
+ Dir.glob("#{destination_root}/db/migrate/*.rb").each do |file|
549
+ content = File.read(file) rescue next
550
+ if content.match?(/t\.references\s+:#{file_name}/) || content.match?(/add_foreign_key.*#{table_name}/)
551
+ critical_deps << {
552
+ type: :migration_reference,
553
+ file: file.gsub(destination_root + "/", ""),
554
+ issue: "Migration creates references to #{table_name} table which will not exist",
555
+ auto_fixable: true # We can generate cleanup migrations
556
+ }
557
+ end
558
+ end
559
+
560
+ critical_deps
561
+ end
562
+
563
+ def show_dependency_solution(deps)
564
+ say "\n" + "="*80, :red
565
+ say "🚫 CANNOT SAFELY DESTROY #{class_name.upcase()}", :red
566
+ say "="*80, :red
567
+ say "\n💥 This would break #{deps.size} critical dependencies:", :red
568
+
569
+ deps.each do |dep|
570
+ say "\n 📁 #{dep[:file]}", :yellow
571
+ say " ❌ #{dep[:issue]}", :red
572
+ if dep[:auto_fixable]
573
+ say " ✅ Auto-fixable", :green
574
+ else
575
+ say " ⚠️ Requires manual fix", :yellow
576
+ end
577
+ end
578
+
579
+ auto_fixable_count = deps.count { |d| d[:auto_fixable] }
580
+
581
+ if auto_fixable_count > 0
582
+ say "\n🛠️ Suggested approach:", :blue
583
+ say " rails destroy propel_api:resource #{class_name}", :green
584
+ say " (Auto-fixes #{auto_fixable_count} of #{deps.size} dependencies by default)", :blue
585
+ end
586
+
587
+ say "\n💡 Preview first:", :blue
588
+ say " rails destroy propel_api:resource #{class_name} --dry-run", :cyan
589
+ say " (See exactly what would be destroyed and fixed)", :blue
590
+
591
+ say "\n" + "="*80, :red
592
+ exit 1
593
+ end
594
+
595
+ def attempt_auto_fix(deps)
596
+ say "\n🔧 Auto-fixing #{deps.size} dependencies...", :blue
597
+
598
+ deps.each do |dep|
599
+ if dep[:auto_fixable]
600
+ case dep[:type]
601
+ when :has_many_relationship
602
+ fix_has_many_relationship(dep)
603
+ when :belongs_to_relationship
604
+ fix_belongs_to_relationship(dep)
605
+ when :test_setup_dependency
606
+ fix_test_setup_dependency(dep)
607
+ when :class_constant_in_test
608
+ fix_class_constant_in_test(dep)
609
+ when :polymorphic_parent_config
610
+ fix_polymorphic_parent_config(dep)
611
+ when :migration_reference
612
+ fix_migration_reference(dep)
613
+ when :fixture_reference
614
+ fix_fixture_reference(dep)
615
+ when :model_configuration
616
+ fix_model_configuration(dep)
617
+ when :test_method_dependency
618
+ fix_test_method_dependency(dep)
619
+ when :polymorphic_fixture_reference
620
+ fix_polymorphic_fixture_reference(dep)
621
+ end
622
+ else
623
+ say "⚠️ Cannot auto-fix #{dep[:file]}: #{dep[:issue]}", :yellow
624
+ end
625
+ end
626
+ end
627
+
628
+ def fix_has_many_relationship(dep)
629
+ file_path = File.join(destination_root, dep[:file])
630
+ content = File.read(file_path)
631
+
632
+ # Much more precise pattern that preserves file structure
633
+ # Match entire line including leading whitespace but preserve surrounding newlines
634
+ patterns_to_try = [
635
+ # Pattern 1: Full line with newline
636
+ /^(\s*)has_many\s+:#{table_name}[^\n]*\n/,
637
+ # Pattern 2: Line without trailing newline (end of file)
638
+ /^(\s*)has_many\s+:#{table_name}[^\n]*$/,
639
+ # Pattern 3: Inline in comment (like our bug)
640
+ /(\s+)has_many\s+:#{table_name}[^\n]*/
641
+ ]
642
+
643
+ updated_content = content
644
+ removed = false
645
+
646
+ patterns_to_try.each do |pattern|
647
+ if updated_content.match?(pattern)
648
+ updated_content = updated_content.gsub(pattern) do |match|
649
+ # If it's an inline match, preserve the leading whitespace as a newline
650
+ if match.include?('#') && !match.start_with?('#')
651
+ # This is an inline comment case, replace with just newline
652
+ "\n"
653
+ else
654
+ # This is a full line, remove completely but preserve structure
655
+ ''
656
+ end
657
+ end
658
+ removed = true
659
+ break
660
+ end
661
+ end
662
+
663
+ if removed
664
+ # Clean up any resulting double newlines
665
+ updated_content = updated_content.gsub(/\n\n+/, "\n\n")
666
+ File.write(file_path, updated_content)
667
+ say " ✅ Removed has_many :#{table_name} from #{dep[:file]}", :green
668
+ else
669
+ say " ⚠️ Could not find has_many :#{table_name} in #{dep[:file]}", :yellow
670
+ end
671
+ end
672
+
673
+ def fix_belongs_to_relationship(dep)
674
+ file_path = File.join(destination_root, dep[:file])
675
+ content = File.read(file_path)
676
+
677
+ # Remove belongs_to relationships to the deleted resource
678
+ # belongs_to :meeting → (removed completely)
679
+ patterns_to_try = [
680
+ # Pattern 1: Full line with newline
681
+ /^(\s*)belongs_to\s+:#{file_name}[^\n]*\n/,
682
+ # Pattern 2: Line without trailing newline (end of file)
683
+ /^(\s*)belongs_to\s+:#{file_name}[^\n]*$/
684
+ ]
685
+
686
+ updated_content = content
687
+ removed = false
688
+
689
+ patterns_to_try.each do |pattern|
690
+ if updated_content.match?(pattern)
691
+ updated_content = updated_content.gsub(pattern, '')
692
+ removed = true
693
+ break
694
+ end
695
+ end
696
+
697
+ if removed
698
+ # Clean up any resulting double newlines
699
+ updated_content = updated_content.gsub(/\n\n+/, "\n\n")
700
+ File.write(file_path, updated_content)
701
+ say " ✅ Removed belongs_to :#{file_name} from #{dep[:file]}", :green
702
+ else
703
+ say " ⚠️ Could not find belongs_to :#{file_name} in #{dep[:file]}", :yellow
704
+ end
705
+ end
706
+
707
+ def fix_test_setup_dependency(dep)
708
+ file_path = File.join(destination_root, dep[:file])
709
+ content = File.read(file_path)
710
+ original_content = content.dup
711
+
712
+ # Smart semantic analysis: understand variable relationships
713
+ # Example: @agenda = agendas(:one); @section_parent = @agenda
714
+
715
+ changes_made = []
716
+ alternative_fixture = find_alternative_fixture_for_tests
717
+
718
+ # 1. Find what variables use the deleted resource (BROADER: any variable name)
719
+ # Match ANY variable assigned from destroyed fixtures: @any_name = meetings(:fixture)
720
+ resource_var_pattern = /@(\w+)\s*=\s*#{table_name}\([^)]+\)/
721
+ resource_variables = content.scan(resource_var_pattern).flatten
722
+
723
+ # 2. For each variable, find what other variables depend on it
724
+ dependent_variables = {}
725
+ resource_variables.each do |var_name|
726
+ # Look for: @section_parent = @agenda
727
+ dependent_pattern = /@(\w+)\s*=\s*@#{var_name}\b/
728
+ dependencies = content.scan(dependent_pattern).flatten
729
+ dependent_variables[var_name] = dependencies
730
+ end
731
+
732
+ # 3. Handle fixture assignments with proper Rails polymorphic pattern recognition
733
+ assignment_pattern = /^(\s*)(@\w+)\s*=\s*#{table_name}\([^)]+\)(.*?)$/
734
+
735
+ updated_content = content.gsub(assignment_pattern) do |match|
736
+ indentation = $1
737
+ variable_name = $2 # "@meeting" or "@agenda_parent" or "@commentable"
738
+ comment_part = $3 # " # was @meeting # Default polymorphic parent for tests"
739
+
740
+ # Rails polymorphic pattern recognition: _parent, _able, _ible suffixes
741
+ is_polymorphic = variable_name.match?(/_parent$|_able$|_ible$/)
742
+
743
+ if is_polymorphic && alternative_fixture
744
+ # Polymorphic association: Keep same variable name, change fixture
745
+ "#{indentation}#{variable_name} = #{alternative_fixture} # was #{table_name}(:one)#{comment_part}"
746
+ elsif alternative_fixture
747
+ # Direct association: Rename variable to match new fixture type and update assignment
748
+ # @meeting = meetings(:one) → @organization = organizations(:acme_org)
749
+ new_fixture_type = alternative_fixture.split('(').first # "organizations(:acme_org)" → "organizations"
750
+ new_variable_name = "@#{simple_singularize(new_fixture_type)}" # "@organization"
751
+ "#{indentation}#{new_variable_name} = #{alternative_fixture} # was #{variable_name} = #{table_name}(:one)#{comment_part}"
752
+ else
753
+ # No alternative available: Comment out entirely
754
+ "#{indentation}# #{variable_name} = #{table_name}(:one) - REMOVED: #{table_name} resource destroyed#{comment_part}"
755
+ end
756
+ end
757
+
758
+ if updated_content != content
759
+ changes_made << "handled #{table_name} fixture assignments (polymorphic vs direct)"
760
+ content = updated_content
761
+ end
762
+
763
+ # 4. Update dependent variables to use alternative fixtures
764
+ resource_variables.each do |var_name|
765
+ dependent_variables[var_name].each do |dependent_var|
766
+ if alternative_fixture
767
+ # @section_parent = @agenda → @section_parent = meetings(:one)
768
+ pattern = /(@#{dependent_var}\s*=\s*)@#{var_name}\b/
769
+ updated_content = content.gsub(pattern) do |match|
770
+ assignment_part = $1
771
+ "#{assignment_part}#{alternative_fixture} # was @#{var_name}"
772
+ end
773
+ if updated_content != content
774
+ changes_made << "updated @#{dependent_var} to use #{alternative_fixture.split('(').first}"
775
+ content = updated_content
776
+ end
777
+ else
778
+ # Remove the dependent assignment if no alternative available
779
+ updated_content = content.gsub(/^(\s*)@#{dependent_var}\s*=\s*@#{var_name}\b.*$\n?/, '')
780
+ if updated_content != content
781
+ changes_made << "removed @#{dependent_var} assignment"
782
+ content = updated_content
783
+ end
784
+ end
785
+ end
786
+ end
787
+
788
+ # 5. Remove any remaining references to the deleted variables
789
+ resource_variables.each do |var_name|
790
+ # Remove lines that call methods on the deleted variable
791
+ var_usage_pattern = /^.*@#{var_name}\..*$\n?/
792
+ updated_content = content.gsub(var_usage_pattern, '')
793
+ if updated_content != content
794
+ changes_made << "removed lines using @#{var_name}"
795
+ content = updated_content
796
+ end
797
+ end
798
+
799
+ # 6. Clean up formatting
800
+ updated_content = content.gsub(/\n\n+/, "\n\n")
801
+
802
+ if updated_content != original_content
803
+ File.write(file_path, updated_content)
804
+ say " ✅ Cleaned up #{dep[:file]} (#{changes_made.join(', ')})", :green
805
+ else
806
+ say " ⚠️ No changes needed in #{dep[:file]}", :blue
807
+ end
808
+ end
809
+
810
+ def find_alternative_fixture_for_tests
811
+ # Dynamically find available fixture files (works for any Rails app schema)
812
+ fixture_files = Dir.glob("#{destination_root}/test/fixtures/*.yml")
813
+
814
+ # Filter out the resource we're destroying and system fixtures
815
+ available_fixtures = fixture_files.map { |f| File.basename(f, '.yml') }
816
+ .reject { |name| name == table_name } # Skip the one we're destroying
817
+ .reject { |name| name.start_with?('noticed_') } # Skip notification system fixtures
818
+ .reject { |name| name == 'active_storage_blobs' || name == 'active_storage_attachments' } # Skip Active Storage
819
+
820
+ return nil if available_fixtures.empty?
821
+
822
+ # Prioritize by Rails naming patterns (generic approach)
823
+ # 1. Look for fixtures that commonly serve as polymorphic parents (by naming patterns)
824
+ organization_like = available_fixtures.find { |name| name.include?('organization') || name.include?('company') || name.include?('account') }
825
+ user_like = available_fixtures.find { |name| name.include?('user') || name.include?('person') || name.include?('member') }
826
+ project_like = available_fixtures.find { |name| name.include?('project') || name.include?('workspace') || name.include?('team') }
827
+
828
+ # Get the prioritized fixture file name
829
+ chosen_fixture_file = [organization_like, user_like, project_like].compact.first ||
830
+ available_fixtures.sort.first
831
+
832
+ return nil unless chosen_fixture_file
833
+
834
+ # CRITICAL FIX: Read the actual fixture file to find real fixture names
835
+ fixture_path = "#{destination_root}/test/fixtures/#{chosen_fixture_file}.yml"
836
+ begin
837
+ require 'yaml'
838
+ fixture_data = YAML.load_file(fixture_path) || {}
839
+ first_fixture_name = fixture_data.keys.first
840
+
841
+ return first_fixture_name ? "#{chosen_fixture_file}(:#{first_fixture_name})" : nil
842
+ rescue => e
843
+ # Fallback: assume :one exists (old behavior)
844
+ say " ⚠️ Could not read #{fixture_path}: #{e.message}. Using fallback.", :yellow
845
+ return "#{chosen_fixture_file}(:one)"
846
+ end
847
+ end
848
+
849
+ def fix_class_constant_in_test(dep)
850
+ file_path = File.join(destination_root, dep[:file])
851
+ content = File.read(file_path)
852
+ original_content = content.dup
853
+
854
+ # Remove this class from polymorphic type assertions
855
+ # assert(@obj.is_a?(Agenda) || @obj.is_a?(Meeting)) → assert(@obj.is_a?(Meeting))
856
+
857
+ changes_made = []
858
+
859
+ # Pattern 1: Remove from OR chains: @obj.section_parent.is_a?(Agenda) || @obj.is_a?(Other)
860
+ # Handle complex object paths like @section.section_parent.is_a?(Class)
861
+ or_pattern = /(\|\|\s*)?@[\w.]+\.is_a\?\(#{class_name}\)(\s*\|\|)?/
862
+ updated_content = content.gsub(or_pattern) do |match|
863
+ has_leading_or = $1.present?
864
+ has_trailing_or = $2.present?
865
+
866
+ if has_leading_or && has_trailing_or
867
+ # Middle of chain: || @obj.is_a?(Agenda) || → ||
868
+ ' || '
869
+ elsif has_leading_or
870
+ # End of chain: || @obj.is_a?(Agenda) → (remove)
871
+ ''
872
+ elsif has_trailing_or
873
+ # Start of chain: @obj.is_a?(Agenda) || → (remove)
874
+ ''
875
+ else
876
+ # Only item: @obj.is_a?(Agenda) → (remove completely)
877
+ ''
878
+ end
879
+ end
880
+
881
+ if updated_content != content
882
+ changes_made << "removed #{class_name} from polymorphic type assertions"
883
+ content = updated_content
884
+ end
885
+
886
+ # Pattern 2: Clean up malformed OR chains (|| ||, leading ||, trailing ||)
887
+ updated_content = content.gsub(/\|\|\s*\|\|/, '||') # Fix double ORs
888
+ updated_content = updated_content.gsub(/\(\s*\|\|/, '(') # Fix leading OR in parentheses
889
+ updated_content = updated_content.gsub(/\|\|\s*\)/, ')') # Fix trailing OR in parentheses
890
+
891
+ if updated_content != content
892
+ changes_made << "cleaned up assertion syntax"
893
+ content = updated_content
894
+ end
895
+
896
+ if changes_made.any?
897
+ File.write(file_path, content)
898
+ say " ✅ Fixed class constants in #{dep[:file]} (#{changes_made.join(', ')})", :green
899
+ else
900
+ say " ⚠️ Could not find class constant patterns in #{dep[:file]}", :yellow
901
+ end
902
+ end
903
+
904
+ def fix_polymorphic_parent_config(dep)
905
+ file_path = File.join(destination_root, dep[:file])
906
+ content = File.read(file_path)
907
+ original_content = content.dup
908
+
909
+ changes_made = []
910
+
911
+ # 1. Look for inclusion validations: validates :type, inclusion: { in: %w[Agenda Meeting] }
912
+ inclusion_pattern = /(validates\s+:\w+.*inclusion:\s*\{\s*in:\s*%w\[)([^\]]*)(.*)/
913
+ updated_content = content.gsub(inclusion_pattern) do |match|
914
+ prefix = $1
915
+ type_list = $2
916
+ suffix = $3
917
+
918
+ # Remove our class from the list
919
+ updated_list = type_list.split.reject { |type| type == class_name }.join(' ')
920
+
921
+ if updated_list != type_list
922
+ changes_made << "removed #{class_name} from validation inclusion list"
923
+ "#{prefix}#{updated_list}#{suffix}"
924
+ else
925
+ match # No change needed
926
+ end
927
+ end
928
+
929
+ # 2. Look for other array configurations that might list parent types
930
+ # VALID_PARENT_TYPES = %w[Agenda Meeting Project]
931
+ array_config_pattern = /(\w+\s*=\s*%w\[)([^\]]*)(.*)/
932
+ updated_content = content.gsub(array_config_pattern) do |match|
933
+ prefix = $1
934
+ type_list = $2
935
+ suffix = $3
936
+
937
+ if type_list.include?(class_name)
938
+ updated_list = type_list.split.reject { |type| type == class_name }.join(' ')
939
+ changes_made << "removed #{class_name} from parent type configuration"
940
+ "#{prefix}#{updated_list}#{suffix}"
941
+ else
942
+ match # No change needed
943
+ end
944
+ end
945
+
946
+ if updated_content != original_content
947
+ File.write(file_path, updated_content)
948
+ say " ✅ Updated polymorphic configurations in #{dep[:file]} (#{changes_made.join(', ')})", :green
949
+ else
950
+ say " ⚠️ Could not find polymorphic configurations to fix in #{dep[:file]}", :yellow
951
+ say " Look for #{class_name} in parent type lists and remove it manually", :cyan
952
+ end
953
+ end
954
+
955
+ def fix_migration_reference(dep)
956
+ # Generate a cleanup migration to remove foreign key constraints
957
+ # This is safer than editing existing migration files
958
+ migration_file = dep[:file]
959
+ table_with_reference = extract_table_name_from_migration_file(migration_file)
960
+
961
+ if table_with_reference
962
+ # Generate cleanup migration with CURRENT timestamp (Rails-standard sequential approach)
963
+ # This runs AFTER problematic migrations and cleans up orphaned foreign keys
964
+ base_timestamp = Time.current.utc.strftime("%Y%m%d%H%M%S")
965
+ timestamp = generate_unique_timestamp(base_timestamp)
966
+ cleanup_migration_name = "RemoveOrphanedForeignKeyFrom#{table_with_reference.classify}To#{class_name.pluralize}"
967
+ cleanup_migration_file = "#{timestamp}_#{cleanup_migration_name.underscore}.rb"
968
+ cleanup_migration_path = "db/migrate/#{cleanup_migration_file}"
969
+
970
+ cleanup_content = <<~MIGRATION
971
+ class #{cleanup_migration_name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]
972
+ def up
973
+ # Rails-standard sequential cleanup: Remove orphaned foreign key references
974
+ # This runs AFTER creation migrations that may have failed due to missing #{table_name} table
975
+
976
+ # Remove foreign key constraint if it exists (may not exist if creation migration failed)
977
+ if foreign_key_exists?(:#{table_with_reference}, :#{table_name})
978
+ remove_foreign_key :#{table_with_reference}, :#{table_name}
979
+ say "✅ Removed foreign key constraint from #{table_with_reference} to #{table_name}"
980
+ else
981
+ say "ℹ️ No foreign key constraint found from #{table_with_reference} to #{table_name} (may have failed during creation)"
982
+ end
983
+
984
+ # Remove the #{file_name}_id column if it exists
985
+ if column_exists?(:#{table_with_reference}, :#{file_name}_id)
986
+ remove_column :#{table_with_reference}, :#{file_name}_id
987
+ say "✅ Removed #{file_name}_id column from #{table_with_reference}"
988
+ else
989
+ say "ℹ️ No #{file_name}_id column found in #{table_with_reference}"
990
+ end
991
+ end
992
+
993
+ def down
994
+ # Cannot reverse removal of orphaned foreign key references
995
+ raise ActiveRecord::IrreversibleMigration, "Cannot restore foreign key to deleted #{table_name} table"
996
+ end
997
+ end
998
+ MIGRATION
999
+
1000
+ File.write(File.join(destination_root, cleanup_migration_path), cleanup_content)
1001
+ say " ✅ Generated cleanup migration: #{cleanup_migration_file}", :green
1002
+ say " Rails-standard approach: Cleans up orphaned #{file_name}_id references", :blue
1003
+ else
1004
+ say " ⚠️ Could not determine table name from #{migration_file}", :yellow
1005
+ end
1006
+ end
1007
+
1008
+ def extract_table_name_from_migration_file(migration_file)
1009
+ # Extract table name from migration file name: create_action_item_reports.rb → action_item_reports
1010
+ basename = File.basename(migration_file, '.rb')
1011
+ if basename.match?(/create_(\w+)/)
1012
+ basename.match(/create_(\w+)/)[1]
1013
+ else
1014
+ nil
1015
+ end
1016
+ end
1017
+
1018
+ def generate_unique_timestamp(base_timestamp)
1019
+ # Ensure we generate a unique 14-digit timestamp by incrementing seconds if needed
1020
+ timestamp = base_timestamp
1021
+ counter = 0
1022
+
1023
+ while Dir.glob("#{destination_root}/db/migrate/#{timestamp}_*.rb").any?
1024
+ # Increment timestamp by 1 second to avoid collision
1025
+ time = Time.strptime(base_timestamp, "%Y%m%d%H%M%S")
1026
+ timestamp = (time + counter + 1).utc.strftime("%Y%m%d%H%M%S")
1027
+ counter += 1
1028
+
1029
+ # Safety check to prevent infinite loop
1030
+ break if counter > 60
1031
+ end
1032
+
1033
+ timestamp
1034
+ end
1035
+
1036
+ def fix_fixture_reference(dep)
1037
+ file_path = File.join(destination_root, dep[:file])
1038
+ content = File.read(file_path)
1039
+ original_content = content.dup
1040
+
1041
+ changes_made = []
1042
+
1043
+ # Parse and fix YAML fixture file using simple, direct approach
1044
+ begin
1045
+ require 'yaml'
1046
+
1047
+ # Load YAML while preserving structure and comments
1048
+ yaml_data = YAML.load(content) || {}
1049
+
1050
+ # Direct removal of destroyed resource references (simplified approach)
1051
+ yaml_data.each do |fixture_name, fixture_data|
1052
+ next unless fixture_data.is_a?(Hash)
1053
+
1054
+ # Use DRY utility for exact fixture key cleanup
1055
+ [file_name, "#{file_name}_id", "#{file_name}_type"].each do |exact_key|
1056
+ if fixture_data.key?(exact_key)
1057
+ fixture_data.delete(exact_key)
1058
+ changes_made << "removed #{exact_key} reference from #{fixture_name}"
1059
+ end
1060
+ end
1061
+ end
1062
+
1063
+ if changes_made.any?
1064
+ # Write back to file while preserving YAML structure
1065
+ updated_content = YAML.dump(yaml_data)
1066
+
1067
+ # Preserve original file header if it exists
1068
+ if content.start_with?('# Read about fixtures')
1069
+ header_lines = content.lines.take_while { |line| line.strip.start_with?('#') || line.strip.empty? }
1070
+ updated_content = header_lines.join + "\n" + updated_content
1071
+ end
1072
+
1073
+ File.write(file_path, updated_content)
1074
+ say " ✅ Fixed fixture references in #{dep[:file]} (#{changes_made.join(', ')})", :green
402
1075
  else
403
- # Create new namespace
404
- # Rails route method adds 2 spaces to each line, so we need to adjust for that
405
- route_content = "namespace :#{@api_namespace} do\n resources :#{route_name}\nend"
406
- route route_content
1076
+ say " ℹ️ No #{file_name} references found in #{dep[:file]}", :blue
1077
+ end
1078
+
1079
+ rescue => e
1080
+ # Fallback to manual review if YAML parsing fails
1081
+ say " ⚠️ Could not auto-fix fixture file #{dep[:file]}: #{e.message}", :yellow
1082
+ say " Please manually remove '#{file_name}:' and '#{file_name}_id:' references", :cyan
1083
+ end
1084
+ end
1085
+
1086
+ def get_valid_fixture_keys_for_model(model_class)
1087
+ # Get valid fixture keys: database columns + Rails association names
1088
+ valid_keys = []
1089
+
1090
+ # 1. Add all actual database columns
1091
+ valid_keys += model_class.columns.map(&:name)
1092
+
1093
+ # 2. Add Rails association names (fixtures can reference associations)
1094
+ # belongs_to :organization allows fixture to use: organization: fixture_name
1095
+ model_class.reflect_on_all_associations.each do |association|
1096
+ # Only include associations where the target model actually exists
1097
+ begin
1098
+ association.class_name.constantize # Check if target model exists
1099
+ valid_keys << association.name.to_s
1100
+ rescue NameError
1101
+ # Target model doesn't exist - this IS an invalid reference
1102
+ # Don't include it in valid_keys so it gets removed
1103
+ end
1104
+ end
1105
+
1106
+ valid_keys.uniqnt w
1107
+ end
1108
+
1109
+ def show_dry_run_preview
1110
+ say "\n" + "="*70, :blue
1111
+ say "🔍 DRY RUN: What would be destroyed", :blue
1112
+ say "="*70, :blue
1113
+
1114
+ files_to_destroy = [
1115
+ "app/models/#{file_name}.rb",
1116
+ "app/controllers/#{controller_path}/#{controller_file_name}.rb",
1117
+ "test/models/#{file_name}_test.rb",
1118
+ "test/controllers/#{controller_path}/#{controller_file_name}_test.rb",
1119
+ "test/integration/#{file_name}_api_test.rb",
1120
+ "test/fixtures/#{table_name}.yml"
1121
+ ]
1122
+
1123
+ say "\n📁 Files that would be removed:", :green
1124
+ files_to_destroy.each { |file| say " ❌ #{file}", :red }
1125
+
1126
+ critical_deps = find_critical_dependencies
1127
+ if critical_deps.any?
1128
+ say "\n💥 Dependencies that would break:", :red
1129
+ critical_deps.each do |dep|
1130
+ say " 📁 #{dep[:file]} - #{dep[:issue]}", :yellow
407
1131
  end
408
1132
  else
409
- # No namespace: simple resources
1133
+ say "\n✅ No critical dependencies found - safe to destroy", :green
1134
+ end
1135
+
1136
+ say "\n" + "="*70, :blue
1137
+ exit 0
1138
+ end
1139
+
1140
+ # Database migration management methods - available to all generators
1141
+ def generate_removal_migration
1142
+ # Generate a proper Rails migration to remove the table and foreign keys
1143
+ # This follows Rails conventions and maintains database integrity
1144
+
1145
+ # Use current timestamp - Rails-standard sequential approach
1146
+ base_timestamp = Time.current.utc.strftime("%Y%m%d%H%M%S")
1147
+ timestamp = generate_unique_timestamp(base_timestamp)
1148
+ migration_name = "Remove#{class_name.pluralize}Table"
1149
+ migration_file = "#{timestamp}_#{migration_name.underscore}.rb"
1150
+ migration_path = "db/migrate/#{migration_file}"
1151
+
1152
+ # Check if table exists in database before generating removal migration
1153
+ if table_exists_in_database?
1154
+ say "Generating removal migration for #{class_name}...", :blue
1155
+
1156
+ migration_content = build_removal_migration_content(migration_name)
1157
+ # Use File.write instead of create_file to avoid Rails auto-reversal
1158
+ File.write(File.join(destination_root, migration_path), migration_content)
1159
+
1160
+ say "✅ Generated removal migration: #{migration_file}", :green
1161
+ say "💡 Run 'rails db:migrate' to execute the removal", :blue
1162
+ else
1163
+ say "Table #{table_name} doesn't exist in database - skipping removal migration", :yellow
1164
+ say "💡 Note: If you have unused creation migrations, remove them manually when safe", :blue
1165
+ end
1166
+ end
1167
+
1168
+ def table_exists_in_database?
1169
+ return false unless defined?(ActiveRecord::Base)
1170
+
1171
+ begin
1172
+ ActiveRecord::Base.connection.table_exists?(table_name)
1173
+ rescue => e
1174
+ # If we can't check (database not connected, etc.), assume it doesn't exist
1175
+ false
1176
+ end
1177
+ end
1178
+
1179
+ def build_removal_migration_content(migration_name)
1180
+ # Note: Don't duplicate foreign key removal here since cleanup migrations handle it
1181
+ # The cleanup migrations generated by fix_migration_reference already remove foreign keys safely
1182
+
1183
+ content = <<~MIGRATION
1184
+ class #{migration_name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]
1185
+ def up
1186
+ # Drop the #{table_name} table (foreign keys already removed by cleanup migrations)
1187
+ drop_table :#{table_name} if table_exists?(:#{table_name})
1188
+ end
1189
+
1190
+ def down
1191
+ # Note: This is a destructive migration.
1192
+ # Reversing it would require recreating the original table structure
1193
+ # and data, which is beyond the scope of automated generation.
1194
+ raise ActiveRecord::IrreversibleMigration, "Cannot reverse removal of #{table_name}"
1195
+ end
1196
+ end
1197
+ MIGRATION
1198
+
1199
+ content
1200
+ end
1201
+
1202
+ def find_foreign_keys_to_table
1203
+ return [] unless defined?(ActiveRecord::Base)
1204
+
1205
+ begin
1206
+ # Find all foreign keys that point to our table
1207
+ foreign_keys = []
1208
+
1209
+ ActiveRecord::Base.connection.tables.each do |table|
1210
+ next if table == table_name # Skip our own table
1211
+
1212
+ ActiveRecord::Base.connection.foreign_keys(table).each do |fk|
1213
+ if fk.to_table == table_name
1214
+ foreign_keys << {
1215
+ from_table: table,
1216
+ to_table: table_name,
1217
+ column: fk.column
1218
+ }
1219
+ end
1220
+ end
1221
+ end
1222
+
1223
+ foreign_keys
1224
+ rescue => e
1225
+ # If we can't introspect the database, return empty array
1226
+ []
1227
+ end
1228
+ end
1229
+
1230
+ def show_destroy_completion_message
1231
+ say "\n" + "="*70, :red
1232
+ say "🗑️ Resource Destroyed Successfully!", :red
1233
+ say "="*70, :red
1234
+
1235
+ say "\n📦 Files removed:", :red
1236
+ removed_files = [
1237
+ "📄 Model: app/models/#{file_name}.rb",
1238
+ "🎮 Controller: app/controllers/#{controller_path}/#{controller_file_name}.rb",
1239
+ "🧪 Tests: test/models/#{file_name}_test.rb and related",
1240
+ "📋 Fixtures: test/fixtures/#{table_name}.yml",
1241
+ "🌱 Seeds: db/seeds/#{table_name}_seeds.rb"
1242
+ ]
1243
+ removed_files.each { |file| say " #{file}", :red }
1244
+
1245
+ if table_exists_in_database?
1246
+ say "\n🗄️ Database removal:", :blue
1247
+ say " 📋 Generated removal migration for #{table_name} table", :blue
1248
+ say " 🧹 Generated cleanup migrations for orphaned foreign keys", :blue
1249
+ say " ⚙️ Run 'rails db:migrate' to execute removal and cleanup", :yellow
1250
+ else
1251
+ say "\n🗄️ Database cleanup:", :blue
1252
+ say " 🧹 Generated cleanup migrations for orphaned foreign key references", :blue
1253
+ say " ⚙️ Run 'rails db:migrate' to clean up broken references", :yellow
1254
+ say " 💡 This follows Rails-standard sequential migration approach", :cyan
1255
+ end
1256
+
1257
+ say "\n💡 Next steps (Rails-standard workflow):", :blue
1258
+ say " 1. rails db:migrate (apply cleanup migrations)", :yellow
1259
+ say " 2. Check for any manual customizations to remove", :blue
1260
+ say " 3. Manually remove old migration files when safe", :cyan
1261
+ say " (#{class_name} creation migration can be removed after cleanup)"
1262
+
1263
+ say "="*70, :red
1264
+ end
1265
+
1266
+ # Route management helper methods - using Rails' gsub_file for proper insertion
1267
+ def insert_routes
1268
+ return if route_already_exists?
1269
+
1270
+ if @api_namespace.present? && @api_version.present?
1271
+ insert_nested_namespace_route
1272
+ elsif @api_namespace.present?
1273
+ insert_single_namespace_route
1274
+ else
410
1275
  route "resources :#{route_name}"
411
1276
  end
412
1277
  end
413
1278
 
1279
+ private
1280
+
1281
+ def insert_nested_namespace_route
1282
+ routes_file = File.join(destination_root, "config/routes.rb")
1283
+ routes_content = File.read(routes_file)
1284
+
1285
+ # Look for the nested namespace pattern and insert after it (flexible whitespace matching)
1286
+ nested_pattern = /namespace\s+:#{@api_namespace}\s+do\s*\n\s*namespace\s+:#{@api_version}\s+do\s*\n/
1287
+
1288
+ if routes_content.match?(nested_pattern)
1289
+ # Use gsub_file to replace the namespace opening with namespace + our resource
1290
+ gsub_file "config/routes.rb", nested_pattern do |match|
1291
+ match + " resources :#{route_name}\n"
1292
+ end
1293
+ else
1294
+ # Create new namespace block using Rails' route method
1295
+ route "namespace :#{@api_namespace} do\n namespace :#{@api_version} do\n resources :#{route_name}\n end\nend"
1296
+ end
1297
+ end
1298
+
1299
+ def insert_single_namespace_route
1300
+ routes_file = File.join(destination_root, "config/routes.rb")
1301
+ routes_content = File.read(routes_file)
1302
+
1303
+ # Look for the single namespace pattern and insert after it
1304
+ single_pattern = /namespace\s+:#{@api_namespace}\s+do\s*\n/
1305
+
1306
+ if routes_content.match?(single_pattern)
1307
+ # Use gsub_file to replace the namespace opening with namespace + our resource
1308
+ gsub_file "config/routes.rb", single_pattern do |match|
1309
+ match + " resources :#{route_name}\n"
1310
+ end
1311
+ else
1312
+ # Create new namespace block using Rails' route method
1313
+ route "namespace :#{@api_namespace} do\n resources :#{route_name}\nend"
1314
+ end
1315
+ end
1316
+
414
1317
  def remove_routes
415
1318
  routes_file = File.join(destination_root, "config/routes.rb")
416
1319
  return unless File.exist?(routes_file)
@@ -449,58 +1352,13 @@ module PropelApi
449
1352
  end
450
1353
  end
451
1354
 
452
- def has_nested_namespace?(content, namespace, version)
453
- content.match?(/namespace\s+:#{namespace}\s+do.*?namespace\s+:#{version}\s+do/m)
454
- end
455
-
456
- def has_single_namespace?(content, namespace)
457
- content.match?(/namespace\s+:#{namespace}\s+do/)
458
- end
459
-
460
- def insert_into_nested_namespace(content, namespace, version, resource_line)
461
- # Check if resource already exists
462
- existing_pattern = /namespace\s+:#{namespace}\s+do.*?namespace\s+:#{version}\s+do.*?resources\s+:#{route_name}/m
463
-
464
- if content.match?(existing_pattern)
465
- say "Route for #{route_name} already exists in #{namespace}/#{version} namespace", :yellow
466
- return
467
- end
468
-
469
- # Find the position to insert the resource line
470
- # Look for the end of the nested namespace's opening line
471
- after_pattern = /namespace\s+:#{namespace}\s+do\s*\n\s*namespace\s+:#{version}\s+do\s*\n/
472
-
473
- if content.match?(after_pattern)
474
- # Insert the resource line with proper indentation (4 spaces for nested namespace)
475
- insert_into_file "config/routes.rb", "#{resource_line}\n", :after => after_pattern
476
- else
477
- # This shouldn't happen if has_nested_namespace? returned true, but handle it
478
- route_content = "namespace :#{namespace} do\n namespace :#{version} do\n#{resource_line}\n end\nend"
479
- route route_content
480
- end
481
- end
482
-
483
- def insert_into_single_namespace(content, namespace, resource_line)
484
- # Check if resource already exists
485
- existing_pattern = /namespace\s+:#{namespace}\s+do.*?resources\s+:#{route_name}/m
486
-
487
- if content.match?(existing_pattern)
488
- say "Route for #{route_name} already exists in #{namespace} namespace", :yellow
489
- return
490
- end
491
-
492
- # Find the position to insert the resource line
493
- # Look for the end of the namespace's opening line
494
- after_pattern = /namespace\s+:#{namespace}\s+do\s*\n/
1355
+ def route_already_exists?
1356
+ routes_file = File.join(destination_root, "config/routes.rb")
1357
+ return false unless File.exist?(routes_file)
495
1358
 
496
- if content.match?(after_pattern)
497
- # Insert the resource line with proper indentation (resource_line already has correct 2 spaces)
498
- insert_into_file "config/routes.rb", "#{resource_line}\n", :after => after_pattern
499
- else
500
- # This shouldn't happen if has_single_namespace? returned true, but handle it
501
- route_content = "namespace :#{namespace} do\n resources :#{route_name}\n end"
502
- route route_content
503
- end
1359
+ routes_content = File.read(routes_file)
1360
+ # Check for actual route declarations (not comments) - must be at start of line or after whitespace
1361
+ routes_content.match?(/^\s*resources\s+:#{Regexp.escape(route_name)}(\s|$)/)
504
1362
  end
505
1363
 
506
1364
  def cleanup_empty_namespaces(content)
@@ -597,12 +1455,74 @@ module PropelApi
597
1455
  type_field: poly_assoc[:type_field],
598
1456
  parent_type: parent_type,
599
1457
  parent_table: parent_type.underscore.pluralize,
600
- fixture_name: fixture_name
1458
+ fixture_name: fixture_name,
1459
+ fixture_id: fixture_index + 1 # Rails standard: fixtures one=1, two=2, three=3
601
1460
  }
602
1461
  end
603
1462
 
604
1463
  private
605
1464
 
1465
+ # === REUSABLE REGEX UTILITIES ===
1466
+ # DRY: Common pattern matching used across multiple fix methods
1467
+
1468
+ def exact_field_patterns(field_name)
1469
+ # EXACT symbol matches only (prevents false positives like :meeting_link_parent)
1470
+ [
1471
+ ":#{field_name}_id\\b", # :meeting_id (exact symbol)
1472
+ ":#{field_name}\\b(?!\\w)", # :meeting (exact, not :meeting_history)
1473
+ ":#{field_name}_type\\b" # :meeting_type (polymorphic)
1474
+ ]
1475
+ end
1476
+
1477
+ def test_reference_patterns(field_name)
1478
+ # Common test reference patterns across all test types
1479
+ [
1480
+ "\\.#{field_name}_id\\b", # @obj.meeting_id
1481
+ "\\.#{field_name}\\s*=", # @obj.meeting = nil
1482
+ "\\.#{field_name}\\(", # @obj.meeting()
1483
+ ":#{field_name}\\)", # assert_respond_to(@obj, :meeting)
1484
+ "#{field_name}:", # meeting: @meeting (test data)
1485
+ "#{field_name}_id:", # meeting_id: 123 (test data)
1486
+ "\"#{field_name}\"", # "meeting" (string expectations)
1487
+ "'#{field_name}'", # 'meeting' (string expectations)
1488
+ "assert_includes.*#{field_name}", # assert_includes(..., "meeting")
1489
+ ]
1490
+ end
1491
+
1492
+ def apply_content_replacements(content, field_name, class_name)
1493
+ # DRY: Common content replacement logic used across fix methods
1494
+ changes_made = []
1495
+
1496
+ # Field access cleanup
1497
+ if content.gsub!(/.*\.#{field_name}_id\b.*\n/, " # #{field_name}_id field removed when #{class_name} destroyed\n")
1498
+ changes_made << "removed #{field_name}_id field access"
1499
+ end
1500
+
1501
+ if content.gsub!(/.*\.#{field_name}\s*=.*\n/, " # #{field_name} assignment removed when #{class_name} destroyed\n")
1502
+ changes_made << "removed #{field_name} assignment"
1503
+ end
1504
+
1505
+ # Assertion cleanup
1506
+ if content.gsub!(/.*assert_respond_to.*:#{field_name}\b.*\n/, " # #{field_name} assertion removed when #{class_name} destroyed\n")
1507
+ changes_made << "removed #{field_name} assertion"
1508
+ end
1509
+
1510
+ if content.gsub!(/.*assert_includes.*["']#{field_name}["'].*\n/, " # #{field_name} expectation removed when #{class_name} destroyed\n")
1511
+ changes_made << "removed #{field_name} facet expectation"
1512
+ end
1513
+
1514
+ # Test data cleanup
1515
+ if content.gsub!(/.*#{field_name}:.*\n/, " # #{field_name}: removed when #{class_name} destroyed\n")
1516
+ changes_made << "removed #{field_name} test data"
1517
+ end
1518
+
1519
+ if content.gsub!(/.*#{field_name}_id:.*\n/, " # #{field_name}_id: removed when #{class_name} destroyed\n")
1520
+ changes_made << "removed #{field_name}_id test data"
1521
+ end
1522
+
1523
+ changes_made
1524
+ end
1525
+
606
1526
  def parse_polymorphic_parent_types
607
1527
  return {} unless defined?(options) && options[:parents]
608
1528
 
@@ -629,5 +1549,173 @@ module PropelApi
629
1549
  # Ensure we always return at least one model as fallback
630
1550
  models.empty? ? [] : models
631
1551
  end
1552
+
1553
+ # DRY: Fix model configuration using reusable utilities
1554
+ def fix_model_configuration(dep)
1555
+ file_path = File.join(destination_root, dep[:file])
1556
+ content = File.read(file_path)
1557
+ original_content = content.dup
1558
+
1559
+ changes_made = []
1560
+
1561
+ # Use DRY utility for exact field pattern matching
1562
+ exact_field_patterns(file_name).each do |pattern|
1563
+ # Apply pattern with word boundaries to prevent partial matches
1564
+ regex_pattern = /#{pattern},?\s*/
1565
+ updated_content = content.gsub(regex_pattern, '')
1566
+
1567
+ if updated_content != content
1568
+ changes_made << "removed field reference"
1569
+ content = updated_content
1570
+ end
1571
+ end
1572
+
1573
+ # Clean up trailing commas and empty arrays
1574
+ content = content.gsub(/,\s*\]/, ']').gsub(/\[\s*,/, '[').gsub(/,\s*,/, ',')
1575
+
1576
+ if content != original_content
1577
+ File.write(file_path, content)
1578
+ say " ✅ Fixed model configuration in #{dep[:file]} (#{changes_made.join(', ')})", :green
1579
+ else
1580
+ say " ℹ️ No #{file_name} configuration found in #{dep[:file]}", :blue
1581
+ end
1582
+ end
1583
+
1584
+ # DRY: Comprehensive test cleanup using reusable utilities
1585
+ def fix_test_method_dependency(dep)
1586
+ file_path = File.join(destination_root, dep[:file])
1587
+ content = File.read(file_path)
1588
+ original_content = content.dup
1589
+
1590
+ changes_made = []
1591
+
1592
+ # 1. Remove test methods that test destroyed associations (IMPROVED: proper method structure)
1593
+ # Match complete test method from declaration to matching end with proper indentation
1594
+ test_method_pattern = /^(\s*)test\s+"[^"]*#{file_name}[^"]*"\s+do\s*$.*?^\1end\s*$/m
1595
+
1596
+ updated_content = content.gsub(test_method_pattern) do |match|
1597
+ indentation = $1 || " " # Preserve original indentation
1598
+ changes_made << "removed test method testing #{file_name} association"
1599
+ "#{indentation}# Test removed: #{file_name} association no longer exists after resource destruction\n"
1600
+ end
1601
+ content = updated_content if updated_content != content
1602
+
1603
+ # 2. Apply common content replacements using DRY utility
1604
+ replacement_changes = apply_content_replacements(content, file_name, class_name)
1605
+ changes_made.concat(replacement_changes)
1606
+
1607
+ if content != original_content
1608
+ File.write(file_path, content)
1609
+ say " ✅ Enhanced test cleanup in #{dep[:file]} (#{changes_made.join(', ')})", :green
1610
+ else
1611
+ say " ℹ️ No #{file_name} test dependencies found in #{dep[:file]}", :blue
1612
+ end
1613
+ end
1614
+
1615
+ # NEW: Fix polymorphic fixture references (parent: one (DestroyedClass))
1616
+ def fix_polymorphic_fixture_reference(dep)
1617
+ file_path = File.join(destination_root, dep[:file])
1618
+ content = File.read(file_path)
1619
+ original_content = content.dup
1620
+
1621
+ changes_made = []
1622
+
1623
+ begin
1624
+ require 'yaml'
1625
+
1626
+ # Load YAML while preserving structure and comments
1627
+ yaml_data = YAML.load(content) || {}
1628
+
1629
+ # Find and replace polymorphic references while preserving Rails syntax
1630
+ changes_to_apply = []
1631
+ alternative_fixture = find_alternative_fixture_for_tests
1632
+
1633
+ yaml_data.each do |fixture_name, fixture_data|
1634
+ next unless fixture_data.is_a?(Hash)
1635
+
1636
+ # Look for polymorphic patterns: some_parent: one (DestroyedClass)
1637
+ fixture_data.each do |key, value|
1638
+ if key.end_with?('_parent') && value.to_s.include?(class_name)
1639
+ changes_to_apply << {
1640
+ fixture_name: fixture_name,
1641
+ key: key,
1642
+ old_value: value.to_s,
1643
+ new_value: alternative_fixture ? convert_to_polymorphic_syntax(alternative_fixture) : nil
1644
+ }
1645
+ end
1646
+ end
1647
+ end
1648
+
1649
+ # Apply changes safely - preserve Rails polymorphic syntax format
1650
+ changes_to_apply.each do |change|
1651
+ fixture_data = yaml_data[change[:fixture_name]]
1652
+
1653
+ if change[:new_value]
1654
+ # Replace with alternative while preserving Rails format: parent_name (Type)
1655
+ fixture_data[change[:key]] = change[:new_value]
1656
+ changes_made << "replaced #{change[:key]} with #{change[:new_value]} in #{change[:fixture_name]}"
1657
+ else
1658
+ # Comment out if no alternative available
1659
+ fixture_data.delete(change[:key])
1660
+ changes_made << "removed #{change[:key]} reference from #{change[:fixture_name]} (no alternative available)"
1661
+ end
1662
+ end
1663
+
1664
+ if changes_made.any?
1665
+ # Write back to file while preserving YAML structure
1666
+ updated_content = YAML.dump(yaml_data)
1667
+
1668
+ # Preserve original file header if it exists
1669
+ if content.start_with?('# Read about fixtures')
1670
+ header_lines = content.lines.take_while { |line| line.strip.start_with?('#') || line.strip.empty? }
1671
+ updated_content = header_lines.join + "\n" + updated_content
1672
+ end
1673
+
1674
+ File.write(file_path, updated_content)
1675
+ say " ✅ Fixed polymorphic fixture references in #{dep[:file]} (#{changes_made.join(', ')})", :green
1676
+ else
1677
+ say " ℹ️ No polymorphic #{class_name} references found in #{dep[:file]}", :blue
1678
+ end
1679
+
1680
+ rescue => e
1681
+ say " ⚠️ Could not auto-fix polymorphic fixture #{dep[:file]}: #{e.message}", :yellow
1682
+ say " Please manually review polymorphic references to #{class_name}", :cyan
1683
+ end
1684
+ end
1685
+
1686
+ private
1687
+
1688
+ def simple_singularize(word)
1689
+ # Simple singularization for common Rails patterns (fallback if ActiveSupport not available)
1690
+ return word.singularize if word.respond_to?(:singularize)
1691
+
1692
+ # Basic singularization rules
1693
+ case word
1694
+ when /ies$/
1695
+ word.sub(/ies$/, 'y')
1696
+ when /s$/
1697
+ word.sub(/s$/, '')
1698
+ else
1699
+ word
1700
+ end
1701
+ end
1702
+
1703
+ def convert_to_polymorphic_syntax(alternative_fixture)
1704
+ # Convert "organizations(:acme_org)" to "acme_org (Organization)"
1705
+ # Extract fixture type and name using regex
1706
+ if match = alternative_fixture.match(/^(\w+)\(:(\w+)\)$/)
1707
+ fixture_type = match[1] # "organizations"
1708
+ fixture_name = match[2] # "acme_org"
1709
+
1710
+ # Singularize and capitalize type
1711
+ singular_type = simple_singularize(fixture_type).capitalize
1712
+
1713
+ # Return Rails polymorphic format
1714
+ "#{fixture_name} (#{singular_type})"
1715
+ else
1716
+ # Fallback if pattern doesn't match
1717
+ alternative_fixture
1718
+ end
1719
+ end
632
1720
  end
633
1721
  end