Create a dynamic subclass in Rails that works with STI

346 views Asked by At

I want to combine the two following things:

My STI structure:

Assume a Rails model User. There are only two types of users, namely Admin and Customer. The STI column is type, the table is called users but in practice, we will never have and User objects around, just Admins and Customers.

  • User (used for storing all data and implementing common functionality, but never instanciated)
    • Admin
    • Customer < User

Dynamically extend models:

In some cases, it is handy to dynamically extend a model class, to temporarely have an object with more capabilities to carry out a specific action. Assume my create controller action checks for an additional field "Send welcome mail" (which is provided by the new form). The attribute is virtual, thus we can use it in the form as if it was a normal column. This can be realized with:

extended_user_class = Class.new(User)
extended_user_class.send(:include, MyAwesomeMixins)
extended_user_class.class_eval do
  my_virtual_attribute :send_welcome_mail, default: true
end

model = extended_user_class.new
# send model to the view and it "just works"

Combining both

Unfortunately combining both of these techniques does not work in the way described above, because Rails STI appears to be broken by anonymous classes:

[1] pry(main)> User.where(id: 1).to_sql
=> "SELECT `users`.* FROM `users` WHERE `users`.`id` = 1"
[2] pry(main)> Class.new(User).where(id: 1).to_sql
=> "SELECT `users`.* FROM `users` WHERE `users`.`type` IS NULL AND `users`.`id` = 1"

EDIT: To reproduce the above, must be in production env.

The second statement is wrong because it filters by type. The first statement is the intended behavior.

So, apearently anonymous classes are bad for STI. Redefining the constant User as described in https://dev.to/factorial/a-trick-with-ruby-anonymous-classes-11pp is likely to break the application - I want the extended class to only be used when called explicitely (extended_user_class) - User has to be unaffected.

Can this be achieved in Ruby 3 / Rails 6?

1

There are 1 answers

0
Kalsan On

Two potential solutions:

Singleton class

Experimenting showed that singleton classes (also known as Eigenclasses) would likely provide a solution, however there could be a performance loss of down to 25%.

So the part dynamically extending the model would become:

  1. Operate on the normal User class
  2. Instantiate a new User
  3. Add the virtual attributes to that one instance by using its singleton class

Patch the anonymous class to pretend that it is the superclass

In this solution, the anonymous class is extended with a mixin that delegates the following three class methods to superclass:

  • finder_needs_type_condition?
  • descendants
  • name

For instance:

def name
  superclass.name
end

This way, the anonymous class becomes transparent and behaves like the class it inherits from. This solution is a rather ugly hack but it appears to work in practice. It is uglier but faster than the singleton class solution.

There is one caveat however:

User.all == [admin1, admin2, customer1]
admin1.class == Admin
extended_admin1 = extend_using_patch(admin1)
extended_admin1.class.name == "User"

As can be seen, the anonymous class extended_admin1 extends User, not Admin.