Shopify / Shopify/tapioca

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

Open
#2,250 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Ruby
Stars
873
Forks
164
Avg merge
4d 27m
Merged PRs (30d)
9

Description

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

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with tapioca/dsl/compilers/active_model_attributes, then compare its behavior with the ActiveModelInClassValidator extension shown in the issue. Verify the ProcessingJob example generates a processor signature using T.any(T.class_of(ProcessorFoo), T.class_of(ProcessorBar)); the change is done when this works without the extension.

Written by the indexing model from the issue text.

Assessment

Tech stack
ruby
Domain
tooling
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.