tapioca icon indicating copy to clipboard operation
tapioca copied to clipboard

ActiveModel::Attribute generates non nilable attribute that should be nilable

Open doits opened this issue 7 months ago • 4 comments

I have the following class (whole content):

# app/models/vacancy.rb

# typed: true
# frozen_string_literal: true

class Vacancy < ApplicationRecord
  attribute :startlatest, :datetime
end

I generates the DSL to have a non nilable startlatest:

# sorbet/rbi/dsl/vacancy.rbi

class Vacancy
  include GeneratedAttributeMethods
  # [...]

  private

  module GeneratedAttributeMethods
    # [...]

    sig { returns(::ActiveSupport::TimeWithZone) }
    def startlatest; end

    sig { params(value: ::ActiveSupport::TimeWithZone).returns(::ActiveSupport::TimeWithZone) }
    def startlatest=(value); end

    # [...]
  end
end

Why does it assume the attribute is not nilable in this case? Shouldn't it be nilable?

Tapioca v0.16.11

doits avatar May 16 '25 09:05 doits

Hi, @doits! Thanks for opening this issue. You said "I generates the DSL to have a non nilable startlatest" -- what does that mean? What command did you use to generate RBI?

egiurleo avatar May 21 '25 18:05 egiurleo

Oh sorry, it was a typo. It should have been "It generates ...". So the command is:

bundle exec tapioca dsl Vacancy

doits avatar May 21 '25 18:05 doits

It is a little bit more complicated than I thought. To trigger the behaviour, the following must all apply:

  • The model must be an ActiveRecord::Base-model
  • A database table for the model must exist
  • The attribute must not have a column in the database

In my case all of these apply (I have stuff saved in the database for that model, but the attribute in question is a virtual one), that is why I stumbled upon this.


Here's a step by step instruction how you can replicate and see the behaviour:

  1. create a new rails app and add required gems

    rails new sorbet_test
    cd sorbet_test
    bundle add tapioca --version='~> 0.17'
    
  2. init sorbet

    bundle exec tapioca init
    
  3. create the model

    cat << EOF > app/models/vacancy.rb
    class Vacancy < ApplicationRecord
      attribute :startlatest, :datetime
    end
    EOF
    
  4. generate the dsl

    bundle exec tapioca dsl Vacancy
    
  5. 👀 notice sorbet/rbi/dsl/vacancy.rbi does not have a method for startlatest ❓ --> it should have one that is nilable

  6. Create the model's table in database

    echo 'ActiveRecord::Migration.create_table(:vacancies)' | bundle exec rails c
    
  7. regenerate the dsl

    bundle exec tapioca dsl Vacancy
    
  8. 👁️ notice sorbet/rbi/dsl/vacancy.rbi does have a method for startlatest that is not nilable ❌ --> it should have one that is nilable

        sig { returns(::ActiveSupport::TimeWithZone) }
        def startlatest; end
    
  9. add a the startlatest column into db (which is nullable by default)

    echo 'ActiveRecord::Migration.add_column(:vacancies, :startlatest, :datetime)' | bundle exec rails c
    
  10. regenerate the dsl

    bundle exec tapioca dsl Vacancy
    
  11. 👁️ notice sorbet/rbi/dsl/vacancy.rbi does have a method for startlatest that is nilable ✅

        sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
        def startlatest; end
    

doits avatar Sep 11 '25 07:09 doits

I think I found a solution after reading https://github.com/Shopify/tapioca/blob/main/manual/compiler_activerecordcolumns.md, which seems to be the compiler that adds this methods. Setting ActiveRecordColumnTypes to nilable makes all columns nilable by default:

# sorbet/tapioca/config.yml
dsl:
  compiler_options:
    ActiveRecordColumnTypes: nilable

Now it generates what I think it should:

    sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
    def startlatest; end

doits avatar Sep 11 '25 08:09 doits