ruby-on-railsrubyhas-many-throughruby-on-rails-6ksuid

Rails custom ActiveRecord::Type fails when using `class_name` in has_many :through association


I'm using KSUIDs as a replacement for UUIDs in my Rails app. michaelherold/ksuid-ruby ported KSUIDs to Ruby and implemented them as ::ActiveRecord::Type::String. Everything is working great except one little bug when using has_many :through associations combined with class_name.

I was able to create two rspec tests to demonstrate the bug.

Working test

https://github.com/mattes/ksuid-ruby/blob/e545b1b251bd6430c454509475963a7845b1da0f/spec/cast1_spec.rb#L50-L58

# code excerpt from link above
class Patient < ActiveRecord::Base
  act_as_ksuid :id
  has_many :appointments
  has_many :physicians, through: :appointments
end

bundle exec rspec ./spec/cast1_spec.rb # works as expected, tests pass

Failing test

When I update Patient's appointment association to use class_name it will cause a TypeError: can't cast KSUID::Type.

https://github.com/mattes/ksuid-ruby/blob/e545b1b251bd6430c454509475963a7845b1da0f/spec/cast2_spec.rb#L46

# code excerpt from link above
class Patient < ActiveRecord::Base
  act_as_ksuid :id
  has_many :foobar, class_name: "Appointment" # <---- using class_name here
  has_many :physicians, through: :foobar
end

bundle exec rspec ./spec/cast2_spec.rb # test fails

TypeError:
  can't cast KSUID::Type
# ./spec/cast2_spec.rb:59:in `block (2 levels) in <top (required)>'

Can you help me find the problem and fix the test? To reproduce yourself run:

git clone https://github.com/mattes/ksuid-ruby.git
cd ksuid-ruby
git checkout cast_error
bundle install
bundle exec rspec ./spec/cast1_spec.rb # works
bundle exec rspec ./spec/cast2_spec.rb # fails

Solution

  • This looks like a rails bug.

    When resolving a through-association (patient.physicians) rails looks for a relation named the same as join table, and since there's none - falls back to typecasting as a string (=no typecasting needed, thus the error).

    A hack to make the example work is to add the relation:

    class Physician < ActiveRecord::Base
      act_as_ksuid :id
    
      has_many :foobar, class_name: "Appointment"
      has_many :patients, through: :foobar
    
      has_many :appointments # <= this is not used in app, but rails now can correctly resolve types
    end
    

    Rails master (6.1-alpha) has pull request 36847 merged, that fixes some cases and also outputs a more comprehensible error:

    NotImplementedError: In order to correctly type cast Patient.id, Physician needs to define a :appointments association.

    But it appears to break some other cases, so it's uncertain what will come in 6.1 release. So for now the above hack or monkey-patch with a rails version guard look like a viable solution.

    PS. your ksuid/activerecord/schema_statements adds type to PostgreSQLAdapter no matter what actual adapter is.

    PPS. you can use bundler/inline and minitest/autorun to create a self-contained example (runnable with just ruby filename.rb):

    require 'bundler/inline'
    
    gemfile(ENV['INSTALL']=='1') do
      source 'https://rubygems.org'
      gem 'activerecord', '~>6.0.2' # also tested '~>5.2', '5.0' with same result, master with different error
      gem 'sqlite3' # use , '~> 1.3.6' # for rails 5
      gem 'ksuid', github: 'mattes/ksuid-ruby', ref:'e545b1b251bd6430c454509475963a7845b1da0f'
    
      gem 'minitest'
    end
    
    require "active_record"
    require "logger"
    
    require "ksuid/activerecord"
    require "ksuid/activerecord/table_definition"
    
    # require "rails"
    # require "ksuid/activerecord/schema_statements" # commented out to not load rails only to check Rails.env, instead:
    require "active_record/connection_adapters/sqlite3_adapter"
    ::ActiveRecord::ConnectionAdapters::SQLite3Adapter::NATIVE_DATABASE_TYPES[:ksuid] = { name: "varchar", limit: 27 }
    
    # require "ksuid/activerecord/quoting" # monkey-patch that fixes the error
    
    
    ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
    ActiveRecord::Base.logger = Logger.new(IO::NULL)
    ActiveRecord::Schema.verbose = false
    
    ActiveRecord::Schema.define do
      create_table(:physicians,   force: true, id: :ksuid)
      create_table(:patients,     force: true, id: :ksuid)
      create_table(:appointments, force: true, id: :ksuid) {|t| t.ksuid :physician_id, :patient_id }
    end
    
    class Physician < ActiveRecord::Base
      act_as_ksuid :id
      has_many :foobar, class_name: "Appointment"
      has_many :patients, through: :foobar
    
      has_many :appointments # the hack
    end
    
    class Appointment < ActiveRecord::Base
      act_as_ksuids :id, :physician_id, :patient_id
      belongs_to :physician
      belongs_to :patient
    end
    
    class Patient < ActiveRecord::Base
      act_as_ksuid :id
      has_many :appointments
      has_many :physicians, through: :appointments
    end
    
    ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base)
    
    require "minitest/autorun"
    
    describe "ActiveRecord integration" do
      it "loads all associations correctly" do
        patient = Patient.create!
        physician = Physician.create!
        appointment = Appointment.create!(patient_id: patient.id, physician_id: physician.id)
        expect(patient.id.class).must_equal KSUID::Type
    
        expect(patient.physicians.first).must_equal physician
        expect(physician.patients.first).must_equal patient
      end
    end