2

Hey folks, following problem with Rails and STI:

I have following classes:

class Account < AC::Base
  has_many :users
end

class User < AC::Base
  extend STI
  belongs_to :account

  class Standard < User
    before_save :some_callback
  end

  class Other < User
  end
end

module STI
  def new(*args, &block)
    type = args.dup.extract_options!.with_indifferent_access.delete(:type)
    if type.blank? or (type = type.constantize) == self
      super(*args, &block)
    else
      type.new(*args, &block)
    end
  end
end

And now the problem: Without rewriting User.new (in module STI), the callback inside User::Standard gets never called, otherwise the account_id is always nil if I create users this way:

account.users.create([{ :type => 'User::Standard', :firstname => ... }, { :type => 'User::Other', :firstname => ... }])

If I'm using a different approach for the module like:

module STI
  def new(*args, &block)
    type = args.dup.extract_options!.with_indifferent_access.delete(:type)
    if type.blank? or (type = type.constantize) == self
      super(*args, &block)
    else
      super(*args, &block).becomes(type)
    end
  end
end

Then instance variables are not shared, because it's creating a new object. Is there any solution for this problem without moving the callbacks to the parent class and checking the type of class?

Greetz Mario

Mario Uher
  • 12,249
  • 4
  • 42
  • 68

2 Answers2

0

Maybe there's something I don't know, but I've never seen Rails STI classes defined in that manner. Normally it looks like...

app/models/user.rb:

class User < AC::Base
  belongs_to :account
end

app/models/users/standard.rb:

module Users
  class Standard < User
    before_save :some_callback
  end
end

app/models/users/other.rb:

module Users
  class Other < User
  end
end

It looks as though you are conflating class scope (where a class "lives" in relation to other classes, modules, methods, etc.) with class inheritance (denoted by "class Standard < User"). Rails STI relationships involve inheritance but do not care about scope. Perhaps you are trying to accomplish something very specific by nesting inherited classes and I am just missing it. But if not, it's possible it's causing some of your issues.

Now moving on to the callbacks specifically. The callback in Standard isn't getting called because the "account.users" relationship is using the User class, not the Standard class (but I think you already know that). There are several ways to deal with this (I will be using my class structure in the examples):

One:

class Account
  has_many :users, :class_name => Users::Standard.name
end

This will force all account.users to use the Standard class. If you need the possibility of Other users, then...

Two:

class Account
    has_many :users # Use this to look up any user
    has_many :standard_users, :class_name => Users::Standard.name # Use this to look up/create only Standards
    has_many :other_users, :class_name => Users::Other.name # Use this to look up/create only Others
end

Three:

Just call Users::Standard.create() and Users::Other.create() manually in your code.

I'm sure there are lots of other ways to accomplish this, but there are probably the simplest.

bioneuralnet
  • 5,283
  • 1
  • 24
  • 30
  • Yeah, I moved the subclasses into the User class, because otherwise the subclasses method doesn't work and because of lesser files and directories. And I don't want to have more then one association, because I want to iterate over all users in the account, independently of the user class. I don't get it, why account_id isn't assigned. – Mario Uher Dec 23 '10 at 20:50
  • In example Two, you can use multiple associations *and* lookup all users. The association :users will look up both :standard_users and :other_users. And the reason subclasses isn't working is because you're probably running it in development mode. If you were running in production mode, Rails would be able to find the subclasses. (This is a common headache when writing STI in Rails.) – bioneuralnet Dec 23 '10 at 23:58
  • Does account_id get populated if you strip out your STI.new method? – bioneuralnet Dec 24 '10 at 00:03
  • Yeah, unfortunately. It makes no sense, maybe an AC bug. – Mario Uher Dec 24 '10 at 08:03
  • Ah, I mean ActiveRecord bug ;) – Mario Uher Dec 24 '10 at 14:32
  • It's possible it's a bug. But I do know there's a lot of deep, dark meta-programming magic going on behind Rails associations (has_many, belongs_to, etc). And since "account.users.create" is utilising associations, and you're overriding #new, I'm not surprised it's breaking something. If you need Users::Standard to call a callback on create, I really think your best bet is to drop the STI module and explicitly call Users::Standard.create. – bioneuralnet Dec 24 '10 at 14:50
  • That kind of sucks because you'll have to add if/else logic to decide which subclass to call create on. So here's a "blended" option. Iterate through your hashes, constantize the type field, and call create on *that*. E.g. *user_hashes.each { |h| h[:type].constantize.create!(h) }*. Constantize basically takes a string and converts it to a Ruby constant (a class in this case). Your callbacks will be called without the STI#new override, but you will need to put the account_id attribute in the hashes yourself. – bioneuralnet Dec 24 '10 at 14:56
  • This is not the "Rails Way" ;) ... No, I solved it using the built-in becomes method and moved my instance variables to @attributes, which will be copied after calling becomes. But thanks anyway. :) I will post my current solution below. – Mario Uher Dec 24 '10 at 16:17
0

So I solved my problems after moving my instance variables to @attributes and using my second approach for the module STI:

module STI
  def new(*args, &block)
    type = args.dup.extract_options!.with_indifferent_access.delete(:type)
    if type.blank? or (type = type.constantize) == self
      super(*args, &block)
    else
      super(*args, &block).becomes(type)
    end
  end
end

class User < AR:Base
  extend STI

  belongs_to :account

  validates :password, :presence => true, :length => 8..40
  validates :password_digest, :presence => true

  def password=(password)
    @attributes['password'] = password
    self.password_digest = BCrypt::Password.create(password)
  end

  def password
    @attributes['password']
  end

  class Standard < User
    after_save :some_callback
  end
end

Now my instance variable (the password) is copied to the new User::Standard object and callbacks and validations are working. Nice! But it's a workaround, not really a fix. ;)

Mario Uher
  • 12,249
  • 4
  • 42
  • 68