Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

Changeset 9230

Show
Ignore:
Timestamp:
04/06/08 00:27:12 (8 months ago)
Author:
pratik
Message:

Refactor HasManyThroughAssociation to inherit from HasManyAssociation. Association callbacks and <association>_ids= now work with hm:t. Closes #11516 [rubyruy]

Files:

Legend:

Unmodified
Added
Removed
Modified
Copied
Moved
  • trunk/activerecord/CHANGELOG

    r9229 r9230  
    11*SVN* 
     2 
     3* Refactor HasManyThroughAssociation to inherit from HasManyAssociation. Association callbacks and <association>_ids= now work with hm:t. #11516 [rubyruy] 
    24 
    35* Ensure HABTM#create and HABTM#build do not load entire association. [Pratik] 
  • trunk/activerecord/lib/active_record/associations.rb

    r9226 r9230  
    4545  end 
    4646 
     47  class HasManyThroughCantAssociateThroughHasManyReflection < ActiveRecordError #:nodoc: 
     48    def initialize(owner, reflection) 
     49      super("Cannot modify association '#{owner.class.name}##{reflection.name}' because the source reflection class '#{reflection.source_reflection.class_name}' is associated to '#{reflection.through_reflection.class_name}' via :#{reflection.source_reflection.macro}.") 
     50    end 
     51  end 
    4752  class HasManyThroughCantAssociateNewRecords < ActiveRecordError #:nodoc: 
    4853    def initialize(owner, reflection) 
     
    126131    #   ----------------------------------+-------+----------+---------- 
    127132    #   #others                           |   X   |    X     |    X 
    128     #   #others=(other,other,...)         |   X   |    X     | 
     133    #   #others=(other,other,...)         |   X   |    X     |    X 
    129134    #   #other_ids                        |   X   |    X     |    X 
    130     #   #other_ids=(id,id,...)            |   X   |    X     | 
     135    #   #other_ids=(id,id,...)            |   X   |    X     |    X 
    131136    #   #others<<                         |   X   |    X     |    X 
    132137    #   #others.push                      |   X   |    X     |    X 
    133138    #   #others.concat                    |   X   |    X     |    X 
    134     #   #others.build(attributes={})      |   X   |    X     | 
    135     #   #others.create(attributes={})     |   X   |    X     | 
     139    #   #others.build(attributes={})      |   X   |    X     |    X 
     140    #   #others.create(attributes={})     |   X   |    X     |    X 
    136141    #   #others.create!(attributes={})    |   X   |    X     |    X 
    137142    #   #others.size                      |   X   |    X     |    X 
     
    140145    #   #others.sum(args*,&block)         |   X   |    X     |    X 
    141146    #   #others.empty?                    |   X   |    X     |    X 
    142     #   #others.clear                     |   X   |    X     | 
     147    #   #others.clear                     |   X   |    X     |    X 
    143148    #   #others.delete(other,other,...)   |   X   |    X     |    X 
    144149    #   #others.delete_all                |   X   |    X     | 
     
    146151    #   #others.find(*args)               |   X   |    X     |    X 
    147152    #   #others.find_first                |   X   |          | 
    148     #   #others.uniq                      |   X   |    X     | 
     153    #   #others.uniq                      |   X   |    X     |    X 
    149154    #   #others.reset                     |   X   |    X     |    X 
    150155    # 
     
    651656      #   alongside this object by calling their destroy method.  If set to <tt>:delete_all</tt> all associated 
    652657      #   objects are deleted *without* calling their destroy method.  If set to <tt>:nullify</tt> all associated 
    653       #   objects' foreign keys are set to +NULL+ *without* calling their save callbacks. 
     658      #   objects' foreign keys are set to +NULL+ *without* calling their save callbacks. *Warning:* This option is ignored when also using 
     659      #   the <tt>through</tt> option. 
    654660      # * <tt>:finder_sql</tt>  - specify a complete SQL statement to fetch the association. This is a good way to go for complex 
    655661      #   associations that depend on multiple tables. Note: When this option is used, +find_in_collection+ is _not_ added. 
     
    694700        configure_dependency_for_has_many(reflection) 
    695701 
     702        add_multiple_associated_save_callbacks(reflection.name) 
     703        add_association_callbacks(reflection.name, reflection.options) 
     704 
    696705        if options[:through] 
    697           collection_accessor_methods(reflection, HasManyThroughAssociation, false
     706          collection_accessor_methods(reflection, HasManyThroughAssociation
    698707        else 
    699           add_multiple_associated_save_callbacks(reflection.name) 
    700           add_association_callbacks(reflection.name, reflection.options) 
    701708          collection_accessor_methods(reflection, HasManyAssociation) 
    702709        end 
  • trunk/activerecord/lib/active_record/associations/association_collection.rb

    r9229 r9230  
    1414      end 
    1515 
     16      def build(attributes = {}) 
     17        if attributes.is_a?(Array) 
     18          attributes.collect { |attr| build(attr) } 
     19        else 
     20          build_record(attributes) { |record| set_belongs_to_association_for(record) } 
     21        end 
     22      end 
     23 
    1624      # Add +records+ to this association.  Returns +self+ so method calls may be chained.   
    1725      # Since << flattens its argument list and inserts each record, +push+ and +concat+ behave identically. 
     
    5664        records = flatten_deeper(records) 
    5765        records.each { |record| raise_on_type_mismatch(record) } 
    58         records.reject! { |record| @target.delete(record) if record.new_record? } 
     66        records.reject! do |record| 
     67          if record.new_record? 
     68            callback(:before_remove, record) 
     69            @target.delete(record) 
     70            callback(:after_remove, record) 
     71          end 
     72        end 
    5973        return if records.empty? 
    6074         
  • trunk/activerecord/lib/active_record/associations/association_proxy.rb

    r9226 r9230  
    88    #     BelongsToPolymorphicAssociation 
    99    #     AssociationCollection 
     10    #       HasAndBelongsToManyAssociation 
    1011    #       HasManyAssociation 
    11     #       HasAndBelongsToManyAssociation 
    12     #     HasManyThroughAssociation 
    13     #       HasOneThroughAssociation 
     12    #         HasManyThroughAssociation 
     13    #            HasOneThroughAssociation 
    1414    # 
    1515    # Association proxies in Active Record are middlemen between the object that 
  • trunk/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb

    r9229 r9230  
    55        super 
    66        construct_sql 
    7       end 
    8  
    9       def build(attributes = {}) 
    10         build_record(attributes) 
    117      end 
    128 
  • trunk/activerecord/lib/active_record/associations/has_many_association.rb

    r9229 r9230  
    55        super 
    66        construct_sql 
    7       end 
    8  
    9       def build(attributes = {}) 
    10         if attributes.is_a?(Array) 
    11           attributes.collect { |attr| build(attr) } 
    12         else 
    13           build_record(attributes) { |record| set_belongs_to_association_for(record) } 
    14         end 
    157      end 
    168 
  • trunk/activerecord/lib/active_record/associations/has_many_through_association.rb

    r9200 r9230  
    11module ActiveRecord 
    22  module Associations 
    3     class HasManyThroughAssociation < AssociationCollection #:nodoc: 
     3    class HasManyThroughAssociation < HasManyAssociation #:nodoc: 
    44      def initialize(owner, reflection) 
    55        super 
    66        reflection.check_validity! 
    77        @finder_sql = construct_conditions 
    8         construct_sql 
    9       end 
     8      end 
     9 
    1010 
    1111      def find(*args) 
     
    3636      end 
    3737 
    38       def reset 
    39         @target = [] 
    40         @loaded = false 
    41       end 
    42  
    43       # Adds records to the association. The source record and its associates 
    44       # must have ids in order to create records associating them, so this 
    45       # will raise ActiveRecord::HasManyThroughCantAssociateNewRecords if 
    46       # either is a new record.  Calls create! so you can rescue errors. 
    47       # 
    48       # The :before_add and :after_add callbacks are not yet supported. 
    49       def <<(*records) 
    50         return if records.empty? 
    51         through = @reflection.through_reflection 
    52         raise ActiveRecord::HasManyThroughCantAssociateNewRecords.new(@owner, through) if @owner.new_record? 
    53  
    54         klass = through.klass 
    55         klass.transaction do 
    56           flatten_deeper(records).each do |associate| 
    57             raise_on_type_mismatch(associate) 
    58             raise ActiveRecord::HasManyThroughCantAssociateNewRecords.new(@owner, through) unless associate.respond_to?(:new_record?) && !associate.new_record? 
    59  
    60             @owner.send(@reflection.through_reflection.name).proxy_target << klass.send(:with_scope, :create => construct_join_attributes(associate)) { klass.create! } 
    61             @target << associate if loaded? 
    62           end 
    63         end 
    64  
    65         self 
    66       end 
    67  
    68       [:push, :concat].each { |method| alias_method method, :<< } 
    69  
    70       # Removes +records+ from this association.  Does not destroy +records+. 
    71       def delete(*records) 
    72         records = flatten_deeper(records) 
    73         records.each { |associate| raise_on_type_mismatch(associate) } 
    74  
    75         through = @reflection.through_reflection 
    76         raise ActiveRecord::HasManyThroughCantDissociateNewRecords.new(@owner, through) if @owner.new_record? 
    77  
    78         load_target 
    79  
    80         klass = through.klass 
    81         klass.transaction do 
    82           flatten_deeper(records).each do |associate| 
    83             raise_on_type_mismatch(associate) 
    84             raise ActiveRecord::HasManyThroughCantDissociateNewRecords.new(@owner, through) unless associate.respond_to?(:new_record?) && !associate.new_record? 
    85  
    86             klass.delete_all(construct_join_attributes(associate)) 
    87             @target.delete(associate) 
    88           end 
    89         end 
    90  
    91         self 
    92       end 
    93  
    94       def build(attrs = nil) 
    95         raise ActiveRecord::HasManyThroughCantAssociateNewRecords.new(@owner, @reflection.through_reflection) 
    96       end 
    9738      alias_method :new, :build 
    9839 
     
    10041        @reflection.klass.transaction do 
    10142          self << (object = @reflection.klass.send(:with_scope, :create => attrs) { @reflection.klass.create! }) 
     43          object 
     44        end 
     45      end 
     46 
     47      def create(attrs = nil) 
     48        @reflection.klass.transaction do 
     49          self << (object = @reflection.klass.send(:with_scope, :create => attrs) { @reflection.klass.create }) 
    10250          object 
    10351        end 
     
    13280      end 
    13381 
     82 
    13483      protected 
     84        def insert_record(record, force=true) 
     85          if record.new_record? 
     86            if force 
     87              record.save! 
     88            else 
     89              return false unless record.save 
     90            end 
     91          end 
     92          klass = @reflection.through_reflection.klass 
     93          @owner.send(@reflection.through_reflection.name).proxy_target << klass.send(:with_scope, :create => construct_join_attributes(record)) { klass.create! } 
     94        end 
     95 
     96        # TODO - add dependent option support 
     97        def delete_records(records) 
     98          klass = @reflection.through_reflection.klass 
     99          records.each do |associate| 
     100            klass.delete_all(construct_join_attributes(associate)) 
     101          end 
     102        end 
     103 
    135104        def method_missing(method, *args) 
    136105          if @target.respond_to?(method) || (!@reflection.klass.respond_to?(method) && Class.respond_to?(method)) 
     
    179148        # Construct attributes for :through pointing to owner and associate. 
    180149        def construct_join_attributes(associate) 
     150          # TODO: revist this to allow it for deletion, supposing dependent option is supported 
     151          raise ActiveRecord::HasManyThroughCantAssociateThroughHasManyReflection.new(@owner, @reflection) if @reflection.source_reflection.macro == :has_many 
    181152          join_attributes = construct_owner_attributes(@reflection.through_reflection).merge(@reflection.source_reflection.primary_key_name => associate.id) 
    182153          if @reflection.options[:source_type] 
  • trunk/activerecord/test/cases/associations_test.rb

    r9229 r9230  
    551551end 
    552552 
     553class HasManyThroughAssociationsTest < ActiveRecord::TestCase 
     554  fixtures :posts, :readers, :people 
     555 
     556  def test_associate_existing 
     557    assert_queries(2) { posts(:thinking);people(:david) } 
     558     
     559    assert_queries(1) do 
     560       posts(:thinking).people << people(:david) 
     561    end 
     562     
     563    assert_queries(1) do 
     564      assert posts(:thinking).people.include?(people(:david)) 
     565    end 
     566     
     567    assert posts(:thinking).reload.people(true).include?(people(:david)) 
     568  end 
     569 
     570  def test_associating_new 
     571    assert_queries(1) { posts(:thinking) } 
     572    new_person = nil # so block binding catches it 
     573     
     574    assert_queries(0) do 
     575      new_person = Person.new 
     576    end 
     577     
     578    # Associating new records always saves them 
     579    # Thus, 1 query for the new person record, 1 query for the new join table record 
     580    assert_queries(2) do 
     581      posts(:thinking).people << new_person 
     582    end 
     583     
     584    assert_queries(1) do 
     585      assert posts(:thinking).people.include?(new_person) 
     586    end 
     587     
     588    assert posts(:thinking).reload.people(true).include?(new_person) 
     589  end 
     590 
     591  def test_associate_new_by_building 
     592    assert_queries(1) { posts(:thinking) } 
     593     
     594    assert_queries(0) do 
     595      posts(:thinking).people.build(:first_name=>"Bob") 
     596      posts(:thinking).people.new(:first_name=>"Ted") 
     597    end 
     598     
     599    # Should only need to load the association once 
     600    assert_queries(1) do 
     601      assert posts(:thinking).people.collect(&:first_name).include?("Bob") 
     602      assert posts(:thinking).people.collect(&:first_name).include?("Ted") 
     603    end 
     604     
     605    # 2 queries for each new record (1 to save the record itself, 1 for the join model) 
     606    #    * 2 new records = 4 
     607    # + 1 query to save the actual post = 5 
     608    assert_queries(5) do 
     609      posts(:thinking).save 
     610    end 
     611     
     612    assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Bob") 
     613    assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Ted") 
     614  end 
     615 
     616  def test_delete_association 
     617    assert_queries(2){posts(:welcome);people(:michael); } 
     618     
     619    assert_queries(1) do 
     620      posts(:welcome).people.delete(people(:michael)) 
     621    end 
     622     
     623    assert_queries(1) do 
     624      assert posts(:welcome).people.empty? 
     625    end 
     626     
     627    assert posts(:welcome).reload.people(true).empty? 
     628  end 
     629 
     630  def test_replace_association 
     631    assert_queries(4){posts(:welcome);people(:david);people(:michael); posts(:welcome).people(true)} 
     632     
     633    # 1 query to delete the existing reader (michael) 
     634    # 1 query to associate the new reader (david) 
     635    assert_queries(2) do 
     636      posts(:welcome).people = [people(:david)] 
     637    end 
     638     
     639    assert_queries(0){ 
     640      assert posts(:welcome).people.include?(people(:david)) 
     641      assert !posts(:welcome).people.include?(people(:michael)) 
     642    } 
     643     
     644    assert posts(:welcome).reload.people(true).include?(people(:david)) 
     645    assert !posts(:welcome).reload.people(true).include?(people(:michael)) 
     646  end 
     647 
     648  def test_associate_with_create 
     649    assert_queries(1) { posts(:thinking) } 
     650     
     651    # 1 query for the new record, 1 for the join table record 
     652    # No need to update the actual collection yet! 
     653    assert_queries(2) do 
     654      posts(:thinking).people.create(:first_name=>"Jeb") 
     655    end 
     656     
     657    # *Now* we actually need the collection so it's loaded 
     658    assert_queries(1) do 
     659      assert posts(:thinking).people.collect(&:first_name).include?("Jeb") 
     660    end 
     661     
     662    assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Jeb") 
     663  end 
     664 
     665  def test_clear_associations 
     666    assert_queries(2) { posts(:welcome);posts(:welcome).people(true) } 
     667     
     668    assert_queries(1) do 
     669      posts(:welcome).people.clear 
     670    end 
     671     
     672    assert_queries(0) do 
     673      assert posts(:welcome).people.empty? 
     674    end 
     675     
     676    assert posts(:welcome).reload.people(true).empty? 
     677  end 
     678 
     679  def test_association_callback_ordering 
     680    Post.reset_log 
     681    log = Post.log 
     682    post = posts(:thinking) 
     683 
     684    post.people_with_callbacks << people(:michael) 
     685    assert_equal [ 
     686      [:added, :before, "Michael"], 
     687      [:added, :after, "Michael"] 
     688    ], log.last(2) 
     689 
     690    post.people_with_callbacks.push(people(:david), Person.create!(:first_name => "Bob"), Person.new(:first_name => "Lary")) 
     691    assert_equal [ 
     692      [:added, :before, "David"], 
     693      [:added, :after, "David"], 
     694      [:added, :before, "Bob"], 
     695      [:added, :after, "Bob"], 
     696      [:added, :before, "Lary"], 
     697      [:added, :after, "Lary"] 
     698    ],log.last(6) 
     699 
     700    post.people_with_callbacks.build(:first_name => "Ted") 
     701    assert_equal [ 
     702      [:added, :before, "Ted"], 
     703      [:added, :after, "Ted"] 
     704    ], log.last(2) 
     705 
     706    post.people_with_callbacks.create(:first_name => "Sam") 
     707    assert_equal [ 
     708      [:added, :before, "Sam"], 
     709      [:added, :after, "Sam"] 
     710    ], log.last(2) 
     711 
     712    post.people_with_callbacks = [people(:michael),people(:david), Person.new(:first_name => "Julian"), Person.create!(:first_name => "Roger")] 
     713    assert_equal (%w(Ted Bob Sam Lary) * 2).sort, log[-12..-5].collect(&:last).sort 
     714    assert_equal [ 
     715      [:added, :before, "Julian"], 
     716      [:added, :after, "Julian"], 
     717      [:added, :before, "Roger"], 
     718      [:added, :after, "Roger"] 
     719    ], log.last(4) 
     720 
     721    post.people_with_callbacks.clear 
     722    assert_equal (%w(Michael David Julian Roger) * 2).sort, log.last(8).collect(&:last).sort 
     723  end 
     724end 
     725 
    553726class HasManyAssociationsTest < ActiveRecord::TestCase 
    554   fixtures :accounts, :companies, :developers, :projects, 
     727  fixtures :accounts, :categories, :companies, :developers, :projects, 
    555728           :developers_projects, :topics, :authors, :comments, :author_addresses, 
    556729           :people, :posts 
     
    12921465  end 
    12931466 
    1294   def test_assign_ids_for_through 
    1295     assert_raise(NoMethodError) { authors(:mary).comment_ids = [123] } 
     1467  def test_modifying_a_through_a_has_many_should_raise 
     1468    [ 
     1469      lambda { authors(:mary).comment_ids = [comments(:greetings).id, comments(:more_greetings).id] }, 
     1470      lambda { authors(:mary).comments = [comments(:greetings), comments(:more_greetings)] }, 
     1471      lambda { authors(:mary).comments << Comment.create!(:body => "Yay", :post_id => 424242) }, 
     1472      lambda { authors(:mary).comments.delete(authors(:mary).comments.first) }, 
     1473    ].each {|block| assert_raise(ActiveRecord::HasManyThroughCantAssociateThroughHasManyReflection, &block) } 
     1474  end 
     1475 
     1476 
     1477  def test_assign_ids_for_through_a_belongs_to 
     1478    post = Post.new(:title => "Assigning IDs works!", :body => "You heared it here first, folks!") 
     1479    post.person_ids = [people(:david).id, people(:michael).id] 
     1480    post.save 
     1481    post.reload 
     1482    assert_equal 2, post.people.length 
     1483    assert post.people.include?(people(:david)) 
    12961484  end 
    12971485 
  • trunk/activerecord/test/cases/associations/join_model_test.rb

    r9200 r9230  
    444444  end 
    445445 
    446   def test_raise_error_when_adding_new_record_to_has_many_through 
    447     assert_raise(ActiveRecord::HasManyThroughCantAssociateNewRecords) { posts(:thinking).tags << tags(:general).clone } 
    448     assert_raise(ActiveRecord::HasManyThroughCantAssociateNewRecords) { posts(:thinking).clone.tags << tags(:general) } 
    449     assert_raise(ActiveRecord::HasManyThroughCantAssociateNewRecords) { posts(:thinking).tags.build } 
    450     assert_raise(ActiveRecord::HasManyThroughCantAssociateNewRecords) { posts(:thinking).tags.new } 
     446  def test_associating_unsaved_records_with_has_many_through 
     447    saved_post = posts(:thinking) 
     448    new_tag = Tag.new(:name => "new") 
     449 
     450    saved_post.tags << new_tag 
     451    assert !new_tag.new_record? #consistent with habtm! 
     452    assert !saved_post.new_record? 
     453    assert saved_post.tags.include?(new_tag) 
     454 
     455    assert !new_tag.new_record? 
     456    assert saved_post.reload.tags(true).include?(new_tag) 
     457 
     458 
     459    new_post = Post.new(:title => "Association replacmenet works!", :body => "You best believe it.") 
     460    saved_tag = tags(:general) 
     461 
     462    new_post.tags << saved_tag 
     463    assert new_post.new_record? 
     464    assert !saved_tag.new_record? 
     465    assert new_post.tags.include?(saved_tag) 
     466 
     467    new_post.save! 
     468    assert !new_post.new_record? 
     469    assert new_post.reload.tags(true).include?(saved_tag) 
     470 
     471    assert posts(:thinking).tags.build.new_record? 
     472    assert posts(:thinking).tags.new.new_record? 
    451473  end 
    452474 
  • trunk/activerecord/test/models/post.rb

    r9084 r9230  
    4747  has_many :readers 
    4848  has_many :people, :through => :readers 
     49  has_many :people_with_callbacks, :source=>:person, :through => :readers, 
     50              :before_add    => lambda {|owner, reader| log(:added,   :before, reader.first_name) }, 
     51              :after_add     => lambda {|owner, reader| log(:added,   :after,  reader.first_name) }, 
     52              :before_remove => lambda {|owner, reader| log(:removed, :before, reader.first_name) }, 
     53              :after_remove  => lambda {|owner, reader| log(:removed, :after,  reader.first_name) } 
     54 
     55  def self.reset_log 
     56    @log = [] 
     57  end 
     58   
     59  def self.log(message=nil, side=nil, new_record=nil) 
     60    return @log if message.nil? 
     61    @log << [message, side, new_record] 
     62  end 
    4963 
    5064  def self.what_are_you