This post originated from an RSS feed registered with Ruby Buzz
by Obie Fernandez.
Original Post: New Rails plugin: before_assignment
Feed Title: Obie On Rails (Has It Been 9 Years Already?)
Feed URL: http://jroller.com/obie/feed/entries/rss
Feed Description: Obie Fernandez talks about life as a technologist, mostly as ramblings about software development and consulting. Nowadays it's pretty much all about Ruby and Ruby on Rails.
I was writing about the belongs_to association in Rails today
when I ran into a limitation of ActiveRecord that I am not happy
with. To make a long story short, there is no way to make the
:conditions option of a relationship apply on assignment,
which disturbs my desire to fail fast.
For example, take
the following model (simplified):
class Timesheet < ActiveRecord::Base
belongs_to :approver,
:class_name => 'User',
:conditions => ['authorized = ?', true]
...
end
The conditions placed on the association apply only on reads!
Yes most people probably know that already. However, I was on a pretty
good flow, doing TDD and I wrote the following set of tests that express
my intention of how :conditions should work.
class TimesheetTest < Test::Unit::TestCase
fixtures :users
def test_only_authorized_user_may_be_associated_as_approver
sheet = Timesheet.create
sheet.approver = users(:approver)
assert_not_nil sheet.approver, "approver assignment failed"
end
def test_non_authorized_user_cannot_be_associated_as_approver
sheet = Timesheet.create
sheet.approver = users(:joe)
assert sheet.approver.nil?, "approver assignment should have failed"
end
Please don't get hung up on the incorrectness of just ignoring
a variable assignment just yet, because it was just a first cut and
of course, it failed. "Ahh", I think to myself... "Rails will happily
let me assign that non-authorized approver despite my conditions to
the contrary and when I look for it later it won't be there."
Here is the revised test which captures my intention and
the way that Rails actually works. Notice that I save the sheet
and reload the association, to make sure that the assignment definitely
did or did not 'stick':
def test_only_authorized_user_may_be_associated_as_approver
sheet = Timesheet.create
sheet.approver = users(:approver)
assert sheet.save
assert_not_nil sheet.approver(true), "approver assignment failed"
end
def test_non_authorized_user_cannot_be_associated_as_approver
sheet = Timesheet.create
sheet.approver = users(:joe)
assert sheet.save
assert sheet.approver(true).nil?, "approver assignment should have failed"
end
I'm not particularly happy about that. What I actually
want is something like this:
def test_non_authorized_user_cannot_be_associated_as_approver
sheet = Timesheet.create
begin
sheet.approver = users(:non_approver)
fail "approver assignment should have failed"
rescue ConditionsViolatedException
# expected
end
end
If a bug or malicious user causes an assignment to take place, I want
to know about it right away with a big error. This is not a task to be
left to validation -- which IMHO is for expected error states that are
okay to handle in cooperation with the user, like with error
messages and the like... No, this is a big fat bug or security hole and
I want serious consequences, like an Exception raised.
So, the question became how best to make that test pass.
I poked around associations.rb and hacked in a line
to check for the existence of a before_assignment method and invoke it
as a callback if it is there.
My first attempt to implement the callback
failed because (to my surprise), belongs_to does not take an extension
block like the array associations (has_many, habtm). In other words, the
following code does NOT work, although I think it should:
class Timesheet < ActiveRecord::Base
belongs_to :approver,
:class_name => 'User',
:foreign_key => 'approver_id',
:conditions => ['authorized = ?', true] do
def before_assignment(approver)
raise ConditionsViolatedException unless approver.authorized
end
end
end
However, using the :extend option DOES work, although it isn't documented for belongs_to!
After much discussion in #caboose and talking with Jeremy of the Rails core-team
I decided to turn my little hack into a real plugin, giving you the ability to add a
before_assignment callback. The end solution (after installing the plugin) looks like
this:
class Timesheet < ActiveRecord::Base
belongs_to :approver,
:class_name => 'User',
:foreign_key => 'approver_id',
:conditions => ['authorized_approver = ?', true],
:extend => CheckApproverExtension
end
module CheckApproverExtension
def before_assignment(approver)
raise UnauthorizedApproverException unless approver.authorized_approver
end
end
Type script/plugin install http://obiefernandez.com/svn/plugins/before_assignment
to give it a spin.
I want to do a little more work on this plugin in the future, such as figuring out
if the same sort of thing makes sense on the other side of the belongs_to relationship.
I'm also thinking of a plugin that DRYs the whole thing up by actually using the :conditions
in the way I've described above -- why shouldn't they be used to check the validity of the
assignment automatically? Of course, that's a slightly bigger task.