Tyler Rick
2006-Sep-13  01:16 UTC
Validating a has_and_belongs_to_many collection/association BEFORE it gets saved
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:
1. 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?)
2. 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
--------------------------------------------------------------
--~--~---------~--~----~------------~-------~--~----~
You received this message because you are subscribed to the Google Groups
"Ruby on Rails: Talk" group.
To post to this group, send email to
rubyonrails-talk-/JYPxA39Uh5TLH3MbocFFw@public.gmane.org
To unsubscribe from this group, send email to
rubyonrails-talk-unsubscribe-/JYPxA39Uh5TLH3MbocFFw@public.gmane.org
For more options, visit this group at
http://groups.google.com/group/rubyonrails-talk
-~----------~----~----~----~------~----~------~--~---
5MileRadius
2006-Sep-13  04:19 UTC
Re: Validating a has_and_belongs_to_many collection/association BEFORE it gets saved
I believe what may be throwing you off is the fact the (3rd) people instance added to the room object is cancelled due to the thrown exception in before_adding_person(person) method. In other words, you execute room.people << people(:person3) which causes the Room#before_adding_person method to execute prior to adding this to the association collection. In the process of the code executing it decides too many people are in the room and therefore throws an exception cancelling the 3rd person added to the person collection. You can verify the above by executing assert_equal 2, room.people.size As for why you can save the Room object (via room.save) I believe that''s due to the fact that well, you still only have 2 people in the Room#people collection. The last thing you are pointing out is the room.errors.on(:people) is empty. This is a guess - I assume this is due to the fact the successful completion of the room.save method clears out the error collection. That would make sense to me as I would not want an error hanging around until I have to force it to be cleared. It should exist only to reflect the success/failure of the prior operation. --~--~---------~--~----~------------~-------~--~----~ You received this message because you are subscribed to the Google Groups "Ruby on Rails: Talk" group. To post to this group, send email to rubyonrails-talk-/JYPxA39Uh5TLH3MbocFFw@public.gmane.org To unsubscribe from this group, send email to rubyonrails-talk-unsubscribe-/JYPxA39Uh5TLH3MbocFFw@public.gmane.org For more options, visit this group at http://groups.google.com/group/rubyonrails-talk -~----------~----~----~----~------~----~------~--~---
Tyler Rick
2006-Sep-13  22:22 UTC
Re: Validating a has_and_belongs_to_many collection/association BEFORE it gets saved
Thanks, your explanation makes a lot of sense...
And I think you''re right about the save() method clearing out the
errors
collection each time. (I see a call to errors.clear in the source for 
ActiveRecord::Validations#valid?() ... I''m *assuming* valid?() gets 
called each time save() is called.)
________________________________________________________________
The solution I ended up going with works something like this...
* Override people() and people=() so that they access/modify a temporary 
collection called @unsaved_people
* Add validation that checks the validity of @unsaved_people and pushes 
an error if it''s invalid.
* This means that the model will never get saved if @unsaved_people is 
invalid. It also means that if before_save() ever gets called, we know 
that the validation was soccessful.
* before_save() is where we actually save the people collection to the 
database. There we have to call "original_people=" to tell Rails to 
actually save it to the database rather than to our temporary collection.
It''s sort of complicated behind the scenes, but I think it''s
working
exactly how I want it to work now. Once it''s set up, it''s
actually
really intuitive to use: It lets you think of *all* attributes in your 
model as unsaved until you explicitly call save(). (Any attempts to 
modify the people collection will be deferred until we explicitly tell 
the Room model to save itself.)
Can anyone think of any potential side effects or problems with this 
approach that I haven''t thought of?
The code and tests for this solution are below:
--------------------------------------------------------------
# app/models/room.rb
class Room < ActiveRecord::Base
  has_and_belongs_to_many :people, :before_add => :before_adding_person
  attr_accessor :unsaved_people
  alias_method :original_people, :people
  alias_method :original_people=, :people  alias_method :original_reload,
:reload
  def people
    if self.unsaved_people.nil?
      initialize_unsaved_people
    end
    self.unsaved_people
  end
  def people=(people)
    self.unsaved_people = people
  end
  def validate
    if people.size > maximum_occupancy
      errors.add :people, "There are too many people in this room"
    end
  end
  def before_save
    self.original_people = self.unsaved_people
  end
  # Just in case they try to bypass our new accessor and call original_people
directly...
  def before_adding_person(person)
    if self.original_people.size + [person].size > maximum_occupancy
      raise "There are too many people in this room"
    end
  end
  def reload
    original_reload
    initialize_unsaved_people    # If we didn''t do this, then when we
called
      # reload, it would still have the same (possibly invalid) value of 
      # unsaved_people that it had before the reload.
  end
  def initialize_unsaved_people
    self.unsaved_people = self.original_people.clone
      # /\ We initialize it to original_people in case they just loaded 
      #  the object from the database, in which case we want unsaved_people 
      #  to start out with the "saved people".
      # If they just constructed a *new* object, this will work then too, 
      #  because self.original_people.clone will return an empty array, [].
      # Important: If we don''t use clone, then it does an assignment by
      #  reference and any changes to unsaved_people will also change 
      #  *original_people* (not what we want!)!
  end
end
--------------------------------------------------------------
# test/unit/room_maximum_occupancy_test_4.rb
class RoomMaximumOccupancyTest < Test::Unit::TestCase
  fixtures :people
  def test_maximum_occupancy
    room = Room.new(:maximum_occupancy => 2)
    assert_equal [], room.people
    assert_equal [], room.original_people
    assert_not_equal room.unsaved_people.object_id,
                     room.original_people.object_id
    assert_nothing_raised { room.people << people(:person1) }
    assert_nothing_raised { room.people << people(:person2) }
    assert_equal 0, Room.count_by_sql("select count(*) from
people_rooms")
      # /\ Still not saved to the association table!
    assert_equal 0, room.original_people.size
    assert_equal 2, room.people.size    # 2 because this looks at 
unsaved_people
    assert room.save    # Only *here* is it actually saved to the 
association table!
    assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms")
    assert_equal 2, room.people.size
    assert_equal 2, room.original_people.size
    assert_nothing_raised { room.people << people(:person3) }
    assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms")
      # /\ person3 is not yet saved to the association table
    assert_equal false, room.valid?
    assert_equal "There are too many people in this room", 
room.errors.on(:people)
    assert_equal false, room.save
    assert_equal 2, Room.count_by_sql("select count(*) from
people_rooms")
      # /\ It''s still not there, because it didn''t pass the
validation.
    assert_equal "There are too many people in this room", 
room.errors.on(:people)
    assert_equal 3, room.people.size   
      # /\ Just like with normal attributes that fail validation... the
      # attribute still contains the invalid data in memory but we 
refuse to
      # *save* until it is changed to something that is *valid*.
    room.reload
    assert_equal 2, room.people.size
    assert_equal 2, room.original_people.size
    # If they try to go around our accessors and use the original 
accessors,
    # then (and only then) will the exception be raised in 
before_adding_person...
    assert_raise RuntimeError do
      room.original_people << people(:person3)
    end
  end
end
_______________________________________________________________
5MileRadius wrote:> I believe what may be throwing you off is the fact the (3rd) people
> instance added to the room object is cancelled due to the thrown
> exception in before_adding_person(person) method.
>
> In other words, you execute   room.people << people(:person3)   which
> causes the Room#before_adding_person method to execute prior to adding
> this to the association collection.  In the process of the code
> executing it decides too many people are in the room and therefore
> throws an exception cancelling the 3rd person added to the person
> collection.
>
> You can verify the above by executing   assert_equal 2,
> room.people.size
>
> As for why you can save the Room object (via  room.save) I believe
> that''s due to the fact that well, you still only have 2 people in
the
> Room#people collection.
>
> The last thing you are pointing out is the room.errors.on(:people) is
> empty.  This is a guess - I assume this is due to the fact the
> successful completion of the room.save method clears out the error
> collection.  That would make sense to me as I would not want an error
> hanging around until I have to force it to be cleared.  It should exist
> only to reflect the success/failure of the prior operation.
--~--~---------~--~----~------------~-------~--~----~
You received this message because you are subscribed to the Google Groups
"Ruby on Rails: Talk" group.
To post to this group, send email to
rubyonrails-talk-/JYPxA39Uh5TLH3MbocFFw@public.gmane.org
To unsubscribe from this group, send email to
rubyonrails-talk-unsubscribe-/JYPxA39Uh5TLH3MbocFFw@public.gmane.org
For more options, visit this group at
http://groups.google.com/group/rubyonrails-talk
-~----------~----~----~----~------~----~------~--~---