Sorbet not finding `has_many` association methods

1.3k Views Asked by At

I have a Rails model which has_many items:

class Plan < ApplicationRecord
  extend T::Sig

  has_many :items, dependent: :destroy

  before_save do
    # hyper simple test method to illustrat problem
    puts items
  end
end

However Sorbet can't seem to relate to the has_many :items. When I run Sorbet typechecking I get the following error:

$ srb tc
app/models/plan.rb:11: Method items does not exist on T.class_of(Plan) https://srb.help/7003
    11 |    items
            ^^^^^
  Did you mean:
    sorbet/rails-rbi/models/plan.rbi:86: Plan::GeneratedAssociationMethods#items
    86 |  def items; end

The answer to Sorbet's question is yes - I do mean that method. Where is the confusion coming from? Why doesn't the definition of .items in the RBI file satisfy Sorbet's need to know where this method is defined?

2

There are 2 best solutions below

2
On

Ok so this turned out to be a misunderstanding of Rails (Ruby?) rather than Sorbet. Skipping ahead this is actually a point to sorbet because it helped spot and solve this problem.

The problem is that when you pass a block to before_save, the block gets called on the class (Plan), not the instance (plan). Instead, an instance is passed into it.

So taking the original code:

class Plan < ApplicationRecord
  extend T::Sig

  has_many :items, dependent: :destroy

  before_save do
    # hyper simple test method to illustrate problem
    puts items
  end
end

This would result in the execution of Plan.before_save(plan). Where plan is the instance of Plan. So in the example above, items is being pulled out of thin air and won't work.

Two syntaxes which do work

class Plan < ApplicationRecord
  extend T::Sig

  has_many :items, dependent: :destroy

  before_save do |plan| # <= notice the argument
    puts plan.items
  end
end

will work. And so will:

class Plan < ApplicationRecord
  extend T::Sig

  has_many :items, dependent: :destroy
  before_save :put_items

  def put_items
    puts items
  end
end

I'm not quite sure what makes the second one work, whether it's Ruby magic or Rails magic but sometimes there's just too much magic for my liking.

0
On

The reason for this is the proc passed to before_save is run in the context of an instance of the class rather than the class itself which Sorbet isn't able to understand by default. Sorbet provides T.bind for this case which allows you to tell Sorbet to treat self as a different type within the block

class Plan < ApplicationRecord
  extend T::Sig

  has_many :items, dependent: :destroy

  before_save do
    T.bind(self, Plan) # treats `self` as `Plan` instead of `T.class_of(Plan)`
    
    # hyper simple test method to illustrate problem
    puts items # type-checks fine
  end
end