# typed: true class ModelRbiFormatter MODEL_RELATION_SHARED_MODULE_SUFFIX = "ModelRelationShared" MODEL_CLASS_MODULE_SUFFIX = "ClassMethods" MODEL_INSTANCE_MODULE_SUFFIX = "InstanceMethods" def initialize(model_class, available_classes) @model_class = model_class @available_classes = available_classes @columns_hash = model_class.table_exists? ? model_class.columns_hash : {} @generated_sigs = ActiveSupport::HashWithIndifferentAccess.new @generated_class_sigs = ActiveSupport::HashWithIndifferentAccess.new @generated_scope_sigs = ActiveSupport::HashWithIndifferentAccess.new @generated_querying_sigs = ActiveSupport::HashWithIndifferentAccess.new begin # Load all dynamic instance methods of this model by instantiating a fake model @model_class.new unless @model_class.abstract_class? rescue StandardError puts "Note: Unable to create new instance of #{model_class.name}" end end def generate_rbi puts "-- Generate sigs for #{@model_class.name} --" populate_activerecord_querying_methods populate_named_scope_methods populate_generated_column_methods populate_generated_association_methods populate_generated_enum_methods @buffer = [] @buffer << draw_file_header_and_base_classes @buffer << draw_module_header("#{@model_class.name}::#{MODEL_INSTANCE_MODULE_SUFFIX}") @model_class.instance_methods.sort.each do |method_name| expected_sig = @generated_sigs[method_name] next unless expected_sig.present? method_obj = @model_class.instance_method(method_name) draw_method(method_name, method_obj, expected_sig) end @buffer << draw_module_or_class_footer @buffer << draw_module_header("#{@model_class.name}::#{MODEL_CLASS_MODULE_SUFFIX}") @model_class.methods.sort.each do |method_name| expected_sig = @generated_class_sigs[method_name] next unless expected_sig.present? method_obj = @model_class.method(method_name) draw_method(method_name, method_obj, expected_sig) end @buffer << draw_module_or_class_footer # ::MODEL_RELATION_SHARED_MODULE_SUFFIX is a fake module added so that # when a method is defined in this module, it'll be added to both the Model class # as a class method and to its relation as an instance method. # # We need to define the module after the other classes # to work around Sorbet loading order bug # https://sorbet-ruby.slack.com/archives/CHN2L03NH/p1556065791047300 @buffer << draw_module_header("#{@model_class.name}::#{MODEL_RELATION_SHARED_MODULE_SUFFIX}") # For simplicity, generate both in the same module for now. # We don't need to define two fake modules to share methods between and ({}. merge(@generated_scope_sigs). merge(@generated_querying_sigs) ).each do |method_name, expected_sig| method_obj = @model_class.method(method_name) if @model_class.methods.include?(method_name.to_sym) # this is not a class method because it is added to a module draw_method(method_name, method_obj, expected_sig) end @buffer << draw_module_or_class_footer @buffer.join("\n") end def draw_method(method_name, method_obj, expected_sig) if !method_obj.present? # not very actionable because this could be a method in a newer version of Rails # puts "Skip method '#{method_name}' because there is no matching method object." return end @buffer << generate_method_sig(method_name, expected_sig).indent(2) end def populate_activerecord_querying_methods # All is a named scope that most method from ActiveRecord::Querying delegate to # rails/activerecord/lib/active_record/querying.rb:21 @generated_scope_sigs["all"] = { ret: "#{@model_class.name}::Relation" } # It's not possible to typedef all methods in ActiveRecord::Querying module to have the # matching type. By generating model-specific sig, we can typedef these methods to return # ::Relation class. # rails/activerecord/lib/active_record/querying.rb model_query_relation_methods = [ :select, :reselect, :order, :reorder, :group, :limit, :offset, :joins, :left_joins, :left_outer_joins, :where, :rewhere, :preload, :extract_associated, :eager_load, :includes, :from, :lock, :readonly, :extending, :or, :having, :create_with, :distinct, :references, :none, :unscope, :optimizer_hints, :merge, :except, :only, ] model_query_relation_methods.each do |method_name| @generated_querying_sigs[method_name.to_s] = { args: [ {name: :args, arg_type: :rest, value_type: 'T.untyped'}, {name: :block, arg_type: :block, value_type: 'T.nilable(T.proc.void)'}, ], ret: "#{@model_class.name}::Relation", } end end def populate_named_scope_methods @model_class.methods.sort.each do |method_name| method_obj = @model_class.method(method_name) next unless method_obj.present? && method_obj.source_location.present? # we detect sscopes defined in a model by 2 criteria: # - they don't have an owner name # - they are defined in 'activerecord/lib/active_record/scoping/named.rb' # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/scoping/named.rb next unless method_obj.owner.name == nil source_file = method_obj.source_location[0] next unless source_file.include?('lib/active_record/scoping/named.rb') @generated_scope_sigs[method_name] = { args: [ name: :args, arg_type: :rest, value_type: 'T.untyped' ], ret: "#{@model_class.name}::Relation", } end end def populate_generated_column_methods @columns_hash.each do |column_name, column_def| if @model_class.defined_enums.has_key?(column_name) # enum attribute is treated differently assignable_type = "T.any(Integer, String, Symbol)" assignable_type = "T.nilable(#{assignable_type})" if column_def.null @generated_sigs.merge!({ "#{column_name}" => { ret: "String" }, "#{column_name}=" => { args: [ name: :value, arg_type: :req, value_type: assignable_type], }, }) else column_type = type_for_column_def(column_def) @generated_sigs.merge!({ "#{column_name}" => { ret: column_type }, "#{column_name}=" => { args: [ name: :value, arg_type: :req, value_type: column_type ], }, }) end if column_def.type == :boolean @generated_sigs["#{column_name}?"] = { ret: "T::Boolean", args: [ name: :args, arg_type: :rest, value_type: 'T.untyped' ], } end end end def populate_generated_association_methods @model_class.reflections.each do |assoc_name, reflection| reflection.collection? ? populate_collection_assoc_getter_setter(assoc_name, reflection) : populate_single_assoc_getter_setter(assoc_name, reflection) end end def populate_single_assoc_getter_setter(assoc_name, reflection) # TODO allow people to specify the possible values of polymorphic associations assoc_class = assoc_should_be_untyped?(reflection) ? "T.untyped" : reflection.class_name assoc_type = "T.nilable(#{assoc_class})" if reflection.belongs_to? # if this is a belongs_to connection, we may be able to detect whether # this field is required & use a stronger type column_def = @columns_hash[reflection.foreign_key.to_s] if column_def assoc_type = assoc_class if !column_def.null end end @generated_sigs.merge!({ "#{assoc_name}" => { ret: assoc_type }, "#{assoc_name}=" => { args: [ name: :value, arg_type: :req, value_type: assoc_type ], }, }) end def populate_collection_assoc_getter_setter(assoc_name, reflection) # TODO allow people to specify the possible values of polymorphic associations assoc_class = assoc_should_be_untyped?(reflection) ? "T.untyped" : reflection.class_name relation_class = relation_should_be_untyped?(reflection) ? "ActiveRecord::Associations::CollectionProxy[T.untyped]" : "#{assoc_class}::CollectionProxy" @generated_sigs.merge!({ "#{assoc_name}" => { ret: relation_class }, "#{assoc_name}=" => { args: [ name: :value, arg_type: :req, value_type: "T.any(T::Array[#{assoc_class}], #{relation_class})" ], }, }) end def populate_generated_enum_methods @model_class.defined_enums.each do |enum_name, enum_hash| @generated_class_sigs["#{enum_name.pluralize}"] = { ret: "T::Hash[T.any(String, Symbol), Integer]"} enum_hash.keys.each do |enum_val| @generated_sigs["#{enum_val}?"] = { ret: "T::Boolean" } @generated_scope_sigs["#{enum_val}"] = { args: [ name: :args, arg_type: :rest, value_type: 'T.untyped' ], ret: "#{@model_class.name}::Relation", } end end end def assoc_should_be_untyped?(reflection) polymorphic_assoc?(reflection) || !Object.const_defined?(reflection.class_name) end def relation_should_be_untyped?(reflection) # only type the relation we'll generate assoc_should_be_untyped?(reflection) || !@available_classes.include?(reflection.class_name) end def polymorphic_assoc?(reflection) reflection.through_reflection? ? polymorphic_assoc?(reflection.source_reflection) : reflection.polymorphic? end def draw_file_header_and_base_classes # We define a custom ::Relation class so that it can be extended # to contain custom scopes for each models <<~MESSAGE # This is an autogenerated file for dynamic methods in #{@model_class.name} # Please rerun rake rails_rbi:models to regenerate. # typed: strong class #{@model_class.name}::Relation < ActiveRecord::Relation include #{@model_class.name}::#{MODEL_RELATION_SHARED_MODULE_SUFFIX} extend T::Generic Elem = type_member(fixed: #{@model_class.name}) end class #{@model_class.name}::CollectionProxy < ActiveRecord::Associations::CollectionProxy include #{@model_class.name}::#{MODEL_RELATION_SHARED_MODULE_SUFFIX} extend T::Generic Elem = type_member(fixed: #{@model_class.name}) end class #{@model_class.name} < #{@model_class.superclass} extend T::Sig extend T::Generic extend #{@model_class.name}::#{MODEL_RELATION_SHARED_MODULE_SUFFIX} extend #{@model_class.name}::ClassMethods include #{@model_class.name}::InstanceMethods Elem = type_template(fixed: #{@model_class.name}) end MESSAGE end def draw_module_or_class_footer <<~MESSAGE end MESSAGE end def draw_module_header(name) <<~MESSAGE module #{name} extend T::Sig MESSAGE end def generate_column_methods(buffer) @columns_hash.each do |column_name, column_def| buffer << draw_column_methods(column_name, column_def) end end def type_for_column_def(column_def) cast_type = ActiveRecord::Base.connection.lookup_cast_type_from_column(column_def) strict_type = active_record_type_to_sorbet_type(cast_type) if column_def.respond_to?(:array?) && column_def.array? strict_type = "T::Array[#{strict_type}]" end column_def.null ? "T.nilable(#{strict_type})" : strict_type end def active_record_type_to_sorbet_type(klass) case klass when ActiveRecord::Type::Boolean "T::Boolean" when ActiveRecord::Type::DateTime DateTime when ActiveRecord::Type::Date Date when ActiveRecord::Type::Decimal BigDecimal when ActiveRecord::Type::Float Float when ActiveRecord::Type::Time Time when ActiveRecord::Type::BigInteger, ActiveRecord::Type::Integer, ActiveRecord::Type::DecimalWithoutScale, ActiveRecord::Type::UnsignedInteger Integer when ActiveRecord::Enum::EnumType, ActiveRecord::Type::Binary, ActiveRecord::Type::String, ActiveRecord::Type::Text String else # Json type is only supported in Rails 5.2.3 and above case when Object.const_defined?('ActiveRecord::Type::Json') && klass.is_a?(ActiveRecord::Type::Json) "T.any(Array, T::Boolean, Float, Hash, Integer, String)" else "T.untyped" end end end def generate_method_sig(method_name, generated_method_def) # generated_method_def: # { # . ret: # args: [ name: :value, arg_type: :req, value_type: "T.any(T::Array[#{assoc_class}], ActiveRecord::Relation" ] # } # # Generate something like this # # sig {returns(T.nilable(String))} # .def email; end # sig {params(record: T.nilable(String)).void} # def email=(record); end param_sig = "" param_def = "" if generated_method_def[:args] sig_args_string = generated_method_def[:args].map { |arg_def| "#{arg_def[:name]}: #{arg_def[:value_type]}" }.join(", ") param_sig = "params(#{sig_args_string})." param_def = generated_method_def[:args].map { |arg_def| prefix = "" prefix = "*" if arg_def[:arg_type] == :rest prefix = "**" if arg_def[:arg_type] == :keyrest "#{prefix}#{arg_def[:name]}" }.join(", ") end return_type = generated_method_def[:ret] ? "returns(#{generated_method_def[:ret]})" : "void" <<~MESSAGE sig { #{param_sig}#{return_type} } def #{method_name}(#{param_def}); end MESSAGE end end