Validate that Has_And_Belongs_to_Many relationship in Rails
OK, big props to “Andre in LA” who in my last article about self-referential joins asked a great question in two parts with the gist being
“How do I add a check for adding a friend twice and a test for it?”he then mentioned that
“validates_uniqueness_of :friend_id, :scope => :user_id does not seem to produce an error when adding the same friend twice…and also for a user adding himself as a friend?”
Damn fine investigative work, Andre in LA, and one of the biggest points of frustration for me when building CulturePopp. In fact, in CulturePopp, because I wanted to build that thing in an afternoon I didn’t take the time to actually place this check where it belongs, in the model, I instead added the logic for managing this in my views. I’m still ashamed. :) The reason being, that kind of logic belongs with the model, after all, that’s where you validate them on creation and update, right? right. That’s what I thought with HABTM as well, but that’s not exactly correct. The reason that the validator did not catch Andre’s addition is because the validators for models only fire with you’re creating or updating the actual data representation of the model itself. Since the following does not affect the User class directly…
@user.friends << @other_user
No User validators are EVER going to be called! That’s why in Rails 0.13 we got what are known as Association Callbacks. There are four of them and they allow you to execute methods either before or after you remove or add an association. The four available are:
before_add after_add before_remove after_remove
You can think of them as before/after filters for your model associations and they go right on with the association constructor. So, if you’ve got your miapp User model open, modify it to look like this and we’ll discuss it when you’re done…
class User < ActiveRecord::Base
has_and_belongs_to_many :roles
has_and_belongs_to_many :friends,
:class_name => "User",
:join_table => "friends_users",
:association_foreign_key => "friend_id",
:foreign_key => "user_id",
:before_add => :adding_relationship
def adding_relationship(friend)
if self.friends.include? friend
raise Exception.new(friend.name+" is already your friend.")
elsif friend.id == self.id
raise Exception.new("You cannot befriend yourself.")
end
end
end
Ok, so we added in the friends relationship from the last article (don’t worry, I’m uploading an updated project at the end of the article) adding in a “before_add” callback.
There are a couple of very important things to note here. First, I’m using a symbol for the method name in the habtm constructor. If you use a string you will cause an error to be throw, so remember these callbacks expect a symbol on the right side of the arrow. Second, to achieve Andre’s desired affect which was to block this behavior altogether we need to raise a new exception which is the only way to prevent the code from completing its task of adding the relationship in the database. I take that information from the documentation so if I’ve got that wrong, please someone comment and let me know because it makes the test… AWESOME and fun to write!!!!
Now, you’ve got a system that will check before adding a friend to your User model if it is the same user or a friend that you’ve already got. But how do we test if an Exception is thrown? Well, you just catch the Exception and make sure it’s the one you’re expecting! So, let’s write a test! Here is our updated Unit test for the User model…
require File.dirname(__FILE__) + '/../test_helper'
class UserTest < Test::Unit::TestCase
fixtures :users, :friends_users
def setup
@bob = User.find(1)
@nancy = User.find(2)
end
def test_nancy_likes_bob
assert @nancy.friends.count > 0, "Nancy has no friends"
begin
@nancy.friends << @bob
rescue Exception
assert_equal @bob.name+" is already your friend.", $!.to_s
end
end
def test_bob_likes_nancy
assert @bob.friends.count > 0, "Bob has no friends"
begin
@bob.friends << @nancy
rescue Exception
assert_equal @nancy.name+" is already your friend.", $!.to_s
end
end
end
Pretty cool, right? So, first we check to make sure that each of our users has a friend. Then we want to break them! We could easily replace the begin/rescue block with a throw/catch block but I didn’t… so go ahead if you want to! In this example I purposely create an error then I rescue the error so that my test doesn’t bomb out. Rather than just sitting back and being content that an error has been thrown, we do something far more useful, we actually look for the exact error message that we threw. That’s pretty awesome in my book.
So, there you have it. I wouldn’t say it’s the cleanest or easiest thing to implement but pretty cool when you think about it and know what to expect. Please feel free to share your tests and tips, leave questions or thoughts in the comments and click here to download the updated miapp project.
UPDATE
I've just had a great email, the ones that I actually enjoy reading, from a guy named Duncan who can be found here: whomwah.com who chimes in with a nice alternative to raising random exceptions. The bonus is that by raising the Rails specific exceptions you can catch them the way that you're already doing with your models! It's a great tip and I thought I'd share it with you... This comes from Duncan and I've not run it so use at your own peril! :)
def adding_relationship(friend)
if self.friends.include? friend
errors.add_to_base(friend.name+" is already your friend.")
raise ActiveRecord::RecordInvalid, self
elsif friend.id == self.id
errors.add_to_base("You cannot befriend yourself.")
raise ActiveRecord::RecordInvalid, self
end
end
Duncan then implements like so...
obj.friends << friend obj.save! rescue ActiveRecord::RecordInvalid => e render :action => :new endThanks to Duncan for sharing!
articleStats
Here are some silly little facts about this Validate that Has_And_Belongs_to_Many relationship in Rails...
article Links
These are the links that appear in this article. They probably don't make sense out of context... but just in case. :)
about self-referential joinsCulturePopp
miapp
click here to download the updated miapp project.
whomwah.com
Thank you for demonstrating fixtures, tests, and "hacking" the ActiveRecord in a simple enough way for my overloaded brain to understand.
Now my next question: what was the flow of your learning ruby+rails? How did you first discover/know about the callbacks for the habtm method? How did you learn about the ".include?" method?
The reason I am asking is this: I have an understanding of how the Rails framework works, of simple Ruby scripting, but every time I face a real-world application, I end up looking for/needing things which I am not sure exist and often unclear of what they would be called and stop before I can jump to the next level of understanding/complexity/fluency in Ruby.
Thanks again,
-- Andre in LA