Dave Perrett

HABTM Before_filter in Rails Models

database, habtm, quickie, rails, ruby

I’ve been playing about with Devise and CanCan for rails authentication and authorization recently - this great pair of posts from Tony Amoyal have helped a great deal.

I have a User model and a Role model, and wanted to automatically add the :user role to each user whenever roles are assigned. The roles were set in the controller as follows:

1
2
3
4
5
6
if @user.save
  @user.role_ids = params[:user][:role_ids]

  ...

end

… so I wanted to automatically add the :user role when @user.role_ids was set. Initially I tried adding a ‘before_filter’ to the User model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User < ActiveRecord::Base

  has_and_belongs_to_many :roles

  before_filter :add_default_roles

  ...

  def add_default_roles
    self[:role_ids] << Role.by_name(:user).id
  end

  ...

end

Obviously this didn’t work - setting the habtm field in this case happens after the model is saved (as you can see in the 2 lines from the controller above).

Next I tried to over-ride the roles setter in the model :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User < ActiveRecord::Base

  has_and_belongs_to_many :roles

  ...

  def role_ids=(_role_ids)
    _role_ids << Role.by_name(:user).id
    self.role_ids = _role_ids
  end

  ...

end

The obvious (in hindsight) problem with this is that it is recursive - setting self.role_ids from inside the setter calls itself again, and results in a stack level too deep error. This recursive call can be fixed by changing the line

1
self.role_ids = _role_ids

with:

1
self[:role_ids] = _role_ids

or even

1
write_attribute(:role_ids, _role_ids)

This fixes the recursion problem, but still doesn’t add the default role properly, due to some complexities with the way habtm is implemented.

After a lot of digging around I came up with a solution that works using alias_method_chain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class User < ActiveRecord::Base

  has_and_belongs_to_many :roles

  ...

  # Make sure all users have at least the :user role.
  def role_ids_with_add_user_role=(_role_ids)
    _role_ids << Role.by_name(:user).id
    self[:role_ids] = _role_ids
    self.role_ids_without_add_user_role = _role_ids
  end
  alias_method_chain :role_ids=, :add_user_role

  ...

end

After some testing this seems to do exactly what we need - when @user.role_ids is set, the role_ids_with_add_user_role function is called, and adds the :user role to the list of role ids before saving.