Agile software development using Kanban & Scrum. We code Flex & Ruby on Rails in Auckland, New Zealand.

  • A pattern for handling validations in an associated model …or how I discovered ActiveRecord::Rollback

    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.

    Validating in the account model

    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.

    Triggering the account validation from the user model

    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.

    1. joqueneth reblogged this from vworkdev and added:
      This is awesome. Thanks, person!
    2. vworkdev posted this