lib/tapioca/dsl/compilers/active_record_fixtures.rb



# typed: strict
# frozen_string_literal: true

return unless defined?(Rails) &&
  defined?(ActiveSupport::TestCase) &&
  defined?(ActiveRecord::TestFixtures)

module Tapioca
  module Dsl
    module Compilers
      # `Tapioca::Dsl::Compilers::ActiveRecordFixtures` decorates RBIs for test fixture methods
      # that are created dynamically by Rails.
      #
      # For example, given an application with a posts table, we can have a fixture file
      #
      # ~~~yaml
      # first_post:
      #   author: John
      #   title: My post
      # ~~~
      #
      # Rails will allow us to invoke `posts(:first_post)` in tests to get the fixture record.
      # The generated RBI by this compiler will produce the following
      #
      # ~~~rbi
      # # test_case.rbi
      # # typed: true
      # class ActiveSupport::TestCase
      #   sig { params(fixture_name: NilClass, other_fixtures: NilClass).returns(T::Array[Post]) }
      #   sig { params(fixture_name: T.any(String, Symbol), other_fixtures: NilClass).returns(Post) }
      #   sig { params(fixture_name: T.any(String, Symbol), other_fixtures: T.any(String, Symbol))
      #           .returns(T::Array[Post]) }
      #   def posts(fixture_name = nil, *other_fixtures); end
      # end
      # ~~~
      class ActiveRecordFixtures < Compiler
        extend T::Sig

        ConstantType = type_member { { fixed: T.class_of(ActiveSupport::TestCase) } }
        MISSING = Object.new

        sig { override.void }
        def decorate
          method_names = if fixture_loader.respond_to?(:fixture_sets)
            method_names_from_lazy_fixture_loader
          else
            method_names_from_eager_fixture_loader
          end

          method_names.select! { |name| fixture_class_mapping_from_fixture_files[name] != MISSING }
          return if method_names.empty?

          root.create_path(constant) do |mod|
            method_names.each do |name|
              create_fixture_method(mod, name.to_s)
            end
          end
        end

        class << self
          extend T::Sig

          sig { override.returns(T::Enumerable[Module]) }
          def gather_constants
            return [] unless defined?(Rails.application) && Rails.application

            [ActiveSupport::TestCase]
          end
        end

        private

        sig { returns(T::Class[ActiveRecord::TestFixtures]) }
        def fixture_loader
          @fixture_loader ||= T.let(
            Class.new do
              T.unsafe(self).include(ActiveRecord::TestFixtures)

              if respond_to?(:fixture_paths=)
                T.unsafe(self).fixture_paths = [Rails.root.join("test", "fixtures")]
              else
                T.unsafe(self).fixture_path = Rails.root.join("test", "fixtures")
              end

              # https://github.com/rails/rails/blob/7c70791470fc517deb7c640bead9f1b47efb5539/activerecord/lib/active_record/test_fixtures.rb#L46
              singleton_class.define_method(:file_fixture_path) do
                Rails.root.join("test", "fixtures", "files")
              end

              T.unsafe(self).fixtures(:all)
            end,
            T.nilable(T::Class[ActiveRecord::TestFixtures]),
          )
        end

        sig { returns(T::Array[String]) }
        def method_names_from_lazy_fixture_loader
          T.unsafe(fixture_loader).fixture_sets.keys
        end

        sig { returns(T::Array[String]) }
        def method_names_from_eager_fixture_loader
          fixture_loader.ancestors # get all ancestors from class that includes AR fixtures
            .drop(1) # drop the anonymous class itself from the array
            .reject(&:name) # only collect anonymous ancestors because fixture methods are always on an anonymous module
            .flat_map do |mod|
              mod.private_instance_methods(false).map(&:to_s) + mod.instance_methods(false).map(&:to_s)
            end
        end

        sig { params(mod: RBI::Scope, name: String).void }
        def create_fixture_method(mod, name)
          return_type = return_type_for_fixture(name)
          mod.create_method(name) do |node|
            node.add_opt_param("fixture_name", "nil")
            node.add_rest_param("other_fixtures")

            node.add_sig do |sig|
              sig.add_param("fixture_name", "NilClass")
              sig.add_param("other_fixtures", "NilClass")
              sig.return_type = "T::Array[#{return_type}]"
            end

            node.add_sig do |sig|
              sig.add_param("fixture_name", "T.any(String, Symbol)")
              sig.add_param("other_fixtures", "NilClass")
              sig.return_type = return_type
            end

            node.add_sig do |sig|
              sig.add_param("fixture_name", "T.any(String, Symbol)")
              sig.add_param("other_fixtures", "T.any(String, Symbol)")
              sig.return_type = "T::Array[#{return_type}]"
            end
          end
        end

        sig { params(fixture_name: String).returns(String) }
        def return_type_for_fixture(fixture_name)
          fixture_class_mapping_from_fixture_files[fixture_name] ||
            fixture_class_from_fixture_set(fixture_name) ||
            fixture_class_from_active_record_base_class_mapping[fixture_name] ||
            "T.untyped"
        end

        sig { params(fixture_name: String).returns(T.nilable(String)) }
        def fixture_class_from_fixture_set(fixture_name)
          # only rails 7.1+ support fixture sets so this is conditional
          return unless fixture_loader.respond_to?(:fixture_sets)

          model_name_from_fixture_set = T.unsafe(fixture_loader).fixture_sets[fixture_name]
          return unless model_name_from_fixture_set

          model_name = ActiveRecord::FixtureSet.default_fixture_model_name(model_name_from_fixture_set)
          return unless Object.const_defined?(model_name)

          model_name
        end

        sig { returns(T::Hash[String, String]) }
        def fixture_class_from_active_record_base_class_mapping
          @fixture_class_mapping ||= T.let(
            begin
              ActiveRecord::Base.descendants.each_with_object({}) do |model_class, mapping|
                class_name = model_class.name

                fixture_name = class_name.underscore.gsub("/", "_")
                fixture_name = fixture_name.pluralize if ActiveRecord::Base.pluralize_table_names

                mapping[fixture_name] = class_name

                mapping
              end
            end,
            T.nilable(T::Hash[String, String]),
          )
        end

        sig { returns(T::Hash[String, String]) }
        def fixture_class_mapping_from_fixture_files
          @fixture_file_class_mapping ||= T.let(
            begin
              fixture_paths = if T.unsafe(fixture_loader).respond_to?(:fixture_paths)
                T.unsafe(fixture_loader).fixture_paths
              else
                T.unsafe(fixture_loader).fixture_path
              end

              Array(fixture_paths).each_with_object({}) do |path, mapping|
                Dir["#{path}{.yml,/{**,*}/*.yml}"].select do |file|
                  next unless ::File.file?(file)

                  ActiveRecord::FixtureSet::File.open(file) do |fh|
                    fixture_name = file.delete_prefix(path.to_s).delete_prefix("/").delete_suffix(".yml")
                    next unless fh.model_class

                    mapping[fixture_name] = fh.model_class
                  rescue ActiveRecord::Fixture::FormatError
                    # For fixtures that are not associated to any models and just contain raw data or fixtures that
                    # contain invalid formatting, we want to skip them and avoid crashing
                    mapping[fixture_name] = MISSING
                  end
                end
              end
            end,
            T.nilable(T::Hash[String, String]),
          )
        end
      end
    end
  end
end