
Recently I struggled a bit to find an elegant and working solution to validate an associated model when destroying another model’s instance. The two models were Account and User and I needed to validate the Account whenever I delete a User.
The two models look like this - very much simplified:
class Account < ActiveRecord::Base
has_many :users, :dependent => :destroy
end
class User < ActiveRecord::Base
belongs_to :account
has_many :roles # these contain authorization roles, such as ‘admin’
end
The given rule to validate was:
An account must have at least one user that is an admin
This rule makes sure an account always has one administrator that can access all parts of the application.
When someone deletes a user within the app, and this user happens to be the only administrator, validation should prevent this from happening.
I could have implemented this in the User model, for example like this:
class User < ActiveRecord::Base
belongs_to :account
has_many :roles # these contain authorization roles, such as ‘admin’
before_destroy :make_sure_account_would_still_have_admins
private
def make_sure_account_would_still_have_admins
self.account.users.admins.count > 1
end
end
But I didn’t want to do this. Firstly I wanted to use ActiveRecord’s validations and secondly I wanted to put this logic into the Account model. After all, it’s the account that becomes invalid if there are no more administrators.
I added a custom validation and an association extension to the account model:
class Account < ActiveRecord::Base
has_many :users, :dependent => :destroy do
def admins
self.select { |user| user.has_role?('admin') }
end
end
validate :has_at_least_one_admin_user
private
def has_at_least_one_admin_user
if users.admins.count < 1
errors.add(:users, "must contain at least one user of type 'admin'.")
end
end
end
So, if an account doesn’t have a user with the ‘admin’ role, the account will be invalid.
Now I needed to get this validation triggered whenever someone tries to delete a user. The problem with this was, that the Account model would only be invalid after the user was deleted. If I used validates_associated :account, deleting the last administrator would have passed. That’s because Rails validations are run before the actual operation happens - and at this point the potentially last administrator I was going to delete still existed. E.g. if I ran @last_admin_user.destroy, the associated account would still be valid at validation time, because that user actually has not been deleted, yet.
So I thought, the solution would be to run the validation from an after_destroy callback.
Here, the part I struggled with was that after_ callbacks can only cancel transactions when they raise an error - returning false in an after_ callback is not enough. But of course, you don’t want to end up catching errors whenever you call a simple destroy on the user model.
It turns out, there’s a special error you can raise that doesn’t get passed on. It only causes the transaction to roll back. This error is:
ActiveRecord::Rollback
When I raise this error in the after_destroy callback, the transaction will be rolled back and I do not have to worry about catching errors.
The user model now looked like this:
class User < ActiveRecord::Base
belongs_to :account
has_many :roles
after_destroy :trigger_rollback, :if => "account.invalid?"
private
def trigger_rollback
account.errors.full_messages.each { |message| errors.add :account, message }
raise ActiveRecord::Rollback
end
end
This mechanism works beautifully for this use-case while keeping responsibilities in the models they belong.
This is awesome. Thanks, person!
© 2010 - VisFleet Ltd
No prawns were harmed in
the making of this website
Comments