post-pic

Rails & Devise: X is not a subclass of User

author-avatar

Our team recently ran into a maddening issue with Ruby on Rails, the Devise gem, and Single Table Inheritance (STI). Eventually, the issue got so bad that every single backend change triggered the exception ActiveRecord::SubclassNotFound with the message Invalid single-table inheritance type: ProjectManager is not a subclass of User. The problem was, ProjectManager was a subclass of User. As it turns out, the fact that ProjectManager used STI and inherited from User was the core of the issue.

After some investigation, we figured out that autoloading in Ruby on Rails doesn’t play well with STI. The solution in the Rails documentation didn’t work for us, so we had to come up with our own.

A partial stack trace:

ActiveRecord::SubclassNotFound in UsersController#update

Invalid single-table inheritance type: ProjectManager is not a subclass of User
Extracted source (around line #241):
#239 end
#240 unless subclass == self || descendants.include?(subclass)
*241 raise SubclassNotFound, "Invalid single-table inheritance type: #{subclass.name} is not a subclass of #{name}"
#242 end
#243 subclass
#244 endExtracted source (around line #215):
#213 def discriminate_class_for_record(record)
#214 if using_single_table_inheritance?(record)
*215 find_sti_class(record[inheritance_column])
#216 else
#217 super
#218 end

 

Extracted source (around line #257):
#255 # how this "single-table" inheritance mapping is implemented.
#256 def instantiate(attributes, column_types = {}, &block)
*257 klass = discriminate_class_for_record(attributes)
#258 instantiate_instance_of(klass, attributes, column_types, &block)
#259 end
#260

Rails.root: rails_project/project
Application Trace
app/controllers/users_controller.rb:6:in `update'
Framework Trace
activerecord (6.0.3.6) lib/active_record/inheritance.rb:241:in `find_sti_class'
activerecord (6.0.3.6) lib/active_record/inheritance.rb:215:in `discriminate_class_for_record'
activerecord (6.0.3.6) lib/active_record/persistence.rb:257:in `instantiate'
activerecord (6.0.3.6) lib/active_record/querying.rb:58:in `block (2 levels) in find_by_sql'
activerecord (6.0.3.6) lib/active_record/result.rb:62:in `block in each'
activerecord (6.0.3.6) lib/active_record/result.rb:62:in `each'
activerecord (6.0.3.6) lib/active_record/result.rb:62:in `each'
activerecord (6.0.3.6) lib/active_record/querying.rb:58:in `map'
activerecord (6.0.3.6) lib/active_record/querying.rb:58:in `block in find_by_sql'
activesupport (6.0.3.6) lib/active_support/notifications/instrumenter.rb:24:in `instrument'
activerecord (6.0.3.6) lib/active_record/querying.rb:56:in `find_by_sql'
activerecord (6.0.3.6) lib/active_record/statement_cache.rb:134:in `execute'
activerecord (6.0.3.6) lib/active_record/core.rb:204:in `find_by'
devise-jwt (0.8.0) lib/devise/jwt/models/jwt_authenticatable.rb:20:in `find_for_jwt_authentication'

Why this Happened

Eventually, we were able to determine that changing backend code was reloading the ProjectManager and User classes, giving them new object_ids only within our application code but not within Devise. As a result, ProjectManager !== ProjectManager. The classes were no longer considered equal within Devise and our application code. Thus, line #240 (specifically, descendants.include?(subclass)) in the code above would return false because the traversal of descendants would produce a different ProjectManager object_id than our codebase.

The Hacky Solution

Unfortunately, we ultimately had to monkey patch the User class (or, any parent class that uses STI and devise) to override the find_sti_class method added by Devise. We were, however, able to limit this monkey patch to development since classes don’t typically reload in production.

1class User < ApplicationRecord
2  unless Rails.application.config.eager_load
3    def self.find_sti_class(type_name)
4      return User if type_name.to_s ==User5      return ProjectManager if type_name.to_s ==ProjectManager6    end
7  end
8end
9

This solution allowed the User and ProjectManager within our application code to match those within Devise, since the method was called from within our application as opposed to within Devise.

Conclusion

I find it very unfortunate that Rails offers a feature that it doesn’t fully support and actively documents issues with. This seems like poor development practice in my opinion. Hopefully in a future version either Single Table Inheritance support is removed, or autoloading actually works alongside it.

Hopefully this solution helps others.