Hi all,
I’m wondering if there is any way to add validation to a
“has_and_belongs_to_many collection attribute” (Room.people in my
example) so that it limits the number of objects in the collection to
some maximum?
I’m running into two problems here:
-
When I do the assignment to my collection (room.people = whatever),
it IMMEDIATELY saves it in my join table (people_rooms) rather than
waiting until I call room.save. (Shouldn’t there be some way to
explicitly defer the save?) -
I thought maybe I could get around this by using habtm’s :before_add
option … but it seems that any errors added there end up being
ignored/lost. Plus, the only way to abort the save seems to be to raise
an exception … (which I don’t really want to do).
Hopefully an example will help illustrate the problem. Here’s the
simplest test case I could come up with…
app/models/room.rb (1st attempt)
class Room < ActiveRecord::Base
has_and_belongs_to_many :people
def validate
if people.size > maximum_occupancy
errors.add :people, “There are too many people in this room”
end
end
end
app/models/person.rb
class Person < ActiveRecord::Base
has_and_belongs_to_many :room
end
Here are some tests to show what’s going on. Unless otherwise noted
(with a comment “# FAILS”) all the tests should pass if you run them.
(Well, the ones marked FAILS “should” pass too, in my opinion, but
obviously they don’t… )
test/unit/room_maximum_occupancy_test_1.rb
class RoomMaximumOccupancyTest < Test::Unit::TestCase
fixtures :people
def test_maximum_occupancy
room = Room.new(:maximum_occupancy => 2)
assert_equal 0, Room.count_by_sql(“select count(*) from
people_rooms”)
assert_equal 0, room.people.size
room.people << people(:person1)
room.people << people(:person2)
assert room.save
assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms") # Makes sense, since I just saved it
assert_equal 2, room.people.size
# Now try to add a 3rd person. It shouldn't let us, due to the
room.people << people(:person3)
#assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms") # FAILS due to the fact that it saves it in people_rooms
before we even call room.save !
# Good, the validation works, mostly...
assert_equal false, room.save
# Good, it has the error ...
assert_equal "There are too many people in this room",
room.errors.on(:people)
# … but it’s too late!! It didn’t prevent the invalid data from
getting in there!
#assert_equal 2, room.people.size # FAILS.
end
end
I saw this in the Rails rdoc
(http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html)
and it looked promising:
* collection.build(attributes = {}) - returns a new object of the
collection type that has been instantiated with attributes and linked to
this object through a foreign key but has not yet been saved.
… but even though this seems to work as advertised, it doesn’t seem to
help if you already have your child objects instantiated and want to
simply assign them to the collection and link them to the parent object
WITHOUT SAVING THEM.
For example, I can do this:
room.people.build(:name => ‘person3’)
But there doesn’t seem to be an equivalent for when I already have
Person objects. It would be nice if I could do something like this:
room.people.build_with_existing_person(Person.find(3))
or
room.people.append_without_saving([Person.find(2), Person.find(3)])
test/unit/room_maximum_occupancy_test_2.rb
class RoomMaximumOccupancyTest < Test::Unit::TestCase
fixtures :people
def test_maximum_occupancy_using_build
room = Room.new(:maximum_occupancy => 2)
assert_equal 0, Room.count_by_sql(“select count(*) from
people_rooms”)
assert_equal 0, room.people.size
room.people.build(:name => 'person1')
room.people.build(:name => 'person2')
assert room.save
assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms")
assert_equal 2, room.people.size
room.people.build(:name => 'person3')
# Good, it prevented it from being saved to the database ...
assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms")
# … but it still added it to the collection stored in memory!
#assert_equal 2, room.people.size # Still FAILs. It thinks it has
3, even though the 3rd one is invalid.
assert_equal false, room.save
assert_equal "There are too many people in this room",
room.errors.on(:people)
# If we reload from what is stored in memory, it will still just
have the 2 valid people…
room.reload
assert_equal 2, room.people.size
end
end
Here’s my second attempt, using :before_add…
app/models/room.rb (2nd attempt)
class Room < ActiveRecord::Base
has_and_belongs_to_many :people, :before_add => :before_adding_person
def before_adding_person(person)
if self.people.size + [person].size > maximum_occupancy
errors.add :people, “There are too many people in this room”
raise “There are too many people in this room”
end
end
end
cat test/unit/room_maximum_occupancy_test_3.rb
require File.dirname(FILE) + ‘/…/test_helper’
class RoomMaximumOccupancyTest < Test::Unit::TestCase
fixtures :people
def test_maximum_occupancy
room = Room.new(:maximum_occupancy => 2)
assert_equal 0, Room.count_by_sql(“select count(*) from
people_rooms”)
assert_equal 0, room.people.size
assert_nothing_raised { room.people << people(:person1) }
assert_nothing_raised { room.people << people(:person2) }
assert room.save
assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms")
assert_equal 2, room.people.size
assert_raise RuntimeError do
room.people << people(:person3)
end
assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms")
assert_equal "There are too many people in this room",
room.errors.on(:people) # Passes (for now!)
# But as soon as I go to save it, it clears out the errors array!!
Arg!
room.save
#assert_equal “There are too many people in this room”,
room.errors.on(:people) # FAILS
#assert_equal false, room.valid? # FAILS
#assert_equal false, room.save # FAILS
assert_equal 2, room.people.size
end
end
Is there a way to do what I’m trying to do?
Should I just undo the invalid data that was inserted in my validate()
method as soon as I detect that it’s invalid? (By “undo” I just mean
start removing objects from room.people until its size no longer exceeds
the maximum.) I think that would WORK. But it seems like it would be a
better design if we could PREVENT there from being invalid data rather
than cleaning up after we detect that there IS invalid data…
Another option that was suggested to me was to override the setter
method (people=) to make it cache the new collection in memory (unsaved)
and then only save it if the validation PASSES. But I’m not even sure
how to do that, now that I think of it. You’d have to override Rails’
save() method and probably a half dozen other methods … just seems
like something that Rails should be doing for me…
Or… I could push the errors from before_adding_person() onto a
separate array that WOULDN’T get automatically flushed when you save…
and then have validate() automatically add those errors back onto the
main errors array…
Any ideas/advice would be greatly appreciated.
Thanks,
Tyler
-= APPENDIX =-
A similar question was raised in this thread:
http://lists.rubyonrails.org/pipermail/rails/2006-April/031010.html, so
I know I’m not the only one wanting to do this… but it looks like no
one really came up with a solution for that post…
Second, if you just replace the body with the errors.add, do you
see the
error ?no, and the object that should not have been saved is saved.
Here’s the migration I used, in case anyone wants to try my tests:
db/migrate/001_create_rooms_and_people.rb
class CreateRoomsAndPeople < ActiveRecord::Migration
def self.up
create_table :people do |t|
t.column :name, :string
end
create_table :rooms do |t|
t.column :name, :string
t.column :maximum_occupancy, :integer
end
create_table :people_rooms do |t|
t.column :person_id, :integer
t.column :room_id, :integer
end
end
def self.down
drop_table :people
drop_table :rooms
drop_table :people_rooms
end
end
test/fixtures/people.yml
person1:
id: 1
person2:
id: 2
person3:
id: 3