tapioca icon indicating copy to clipboard operation
tapioca copied to clipboard

Suggestion: Generate T.any signatures when an ActiveModel::Attribute has a known type

Open lavoiesl opened this issue 9 months ago • 2 comments

We use a lot of class restrictions like:

class ProcessingJob < ApplicationJob
  PROCESSORS = [
    ProcessorFoo,
    ProcessorBar,
  ]

  attribute :processor, :class
  validates :processor, presence: true, inclusion: { in: PROCESSORS }
end

And this extension:

# typed: ignore
# frozen_string_literal: true

require "active_model/attributes"
require "tapioca/dsl/compilers/active_model_attributes"
require_relative "active_model_presence_validator"

module Tapioca
  module Compilers
    module ActiveModelInClassValidator
      prepend ActiveModelPresenceValidator

      def attribute_methods_for_constant
        return super unless constant.is_a?(ActiveModel::Validations::ClassMethods)

        attribute_methods = super.to_h # Convert to hash for easier manipulation

        attribute_methods.each do |method, _|
          constant
            .validators_on(method)
            .grep(ActiveModel::Validations::InclusionValidator)
            .each do |validator|
              type = inclusion_validator_type(validator)
              next unless type

              attribute_methods[method] = type.to_s
              attribute_methods["#{method}="] = type.to_s
              break
            end
        end

        attribute_methods.to_a
      end

      private

      def inclusion_validator_type(validator)
        delimiter = validator.options[:in] || validator.options[:within]

        if delimiter.is_a?(Proc) && delimiter.arity == 0
          delimiter = delimiter.call
        end

        if delimiter.is_a?(Array) && delimiter.all?(Module)
          return if delimiter.empty? # Absurd, but don't raise

          types = delimiter.map do |klass|
            klass = klass.safe_constantize if klass.is_a?(String) # rubocop:disable Sorbet/ConstantsFromStrings
            T.class_of(klass)
          end

          type = if types.size == 1
            types.first
          else
            T.any(*types)
          end

          T.nilable(type)
        end
      end
    end

    Dsl::Compilers::ActiveModelAttributes.prepend(ActiveModelInClassValidator)
  end
end

Which generates this signature:

sig { returns(T.any(T.class_of(ProcessorFoo), T.class_of(ProcessorBar)) }
def processor
end

FWIW, the class type is also in-house:

module ActiveModel
  module Type
    class ClassType < ::ActiveModel::Type::Value
      sig { override.returns(Symbol) }
      def type
        :class
      end

      sig { override.params(value: T.nilable(T::Class[T.anything])).returns(T.nilable(String)) }
      def serialize(value)
        value&.name
      end

      sig do
        override
          .params(input: T.nilable(T.any(String, T::Class[T.anything])))
          .returns(T.nilable(T::Class[T.anything]))
      end
      def cast(input)
        return input if input.is_a?(::Class)
        return if input.blank?

        input.safe_constantize
      end
    end
  end
end

lavoiesl avatar Apr 09 '25 18:04 lavoiesl

See also non-nilable signatures: https://github.com/Shopify/tapioca/issues/2249 And explicit type signatures: https://github.com/Shopify/tapioca/issues/2251

lavoiesl avatar Apr 09 '25 18:04 lavoiesl

Highly related: https://github.com/Shopify/tapioca/issues/2272

danny-pflughoeft avatar May 12 '25 22:05 danny-pflughoeft