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

Ticket #6461: 0001-Nested-has_many-through-associations.patch

File 0001-Nested-has_many-through-associations.patch, 23.8 kB (added by shoe, 8 months ago)

Nested has_many :through, ported to edge

  • a/activerecord/lib/active_record/associations.rb

    old new  
    17001700              @aliased_table_name = table_name #.tr('.', '_') # start with the table name, sub out any .'s 
    17011701              @parent_table_name  = parent.active_record.table_name 
    17021702 
    1703               if !parent.table_joins.blank? && parent.table_joins.to_s.downcase =~ %r{join(\s+\w+)?\s+#{aliased_table_name.downcase}\son} 
     1703              if !parent.table_joins.blank? && parent.table_joins.to_s.downcase =~ %r{join(\s+\w+)?\s+"?#{aliased_table_name.downcase}"?\s+on} 
    17041704                join_dependency.table_aliases[aliased_table_name] += 1 
    17051705              end 
    17061706 
  • a/activerecord/lib/active_record/associations/has_many_through_association.rb

    old new  
    44      def initialize(owner, reflection) 
    55        super 
    66        reflection.check_validity! 
    7         @finder_sql = construct_conditions 
    8         construct_sql 
    97      end 
    108 
    119      def find(*args) 
    1210        options = args.extract_options! 
    1311 
    14         conditions = "#{@finder_sql}" 
     12        conditions = construct_conditions 
    1513        if sanitized_conditions = sanitize_sql(options[:conditions]) 
    16           conditions << " AND (#{sanitized_conditions})" 
     14          conditions = conditions.dup << " AND (#{sanitized_conditions})" 
    1715        end 
    1816        options[:conditions] = conditions 
    1917 
     
    2523 
    2624        options[:select]  = construct_select(options[:select]) 
    2725        options[:from]  ||= construct_from 
    28         options[:joins]   = construct_joins(options[:joins]) 
     26        options[:joins]   = construct_joins + " #{options[:joins]}" 
    2927        options[:include] = @reflection.source_reflection.options[:include] if options[:include].nil? 
    3028 
    3129        merge_options_from_reflection!(options) 
     
    4644      # either is a new record.  Calls create! so you can rescue errors. 
    4745      # 
    4846      # The :before_add and :after_add callbacks are not yet supported. 
     47      # Nested has_many :through is not yet supported. 
    4948      def <<(*records) 
    5049        return if records.empty? 
    5150        through = @reflection.through_reflection 
    5251        raise ActiveRecord::HasManyThroughCantAssociateNewRecords.new(@owner, through) if @owner.new_record? 
    53  
    5452        klass = through.klass 
    5553        klass.transaction do 
    5654          flatten_deeper(records).each do |associate| 
     
    6866      [:push, :concat].each { |method| alias_method method, :<< } 
    6967 
    7068      # Removes +records+ from this association.  Does not destroy +records+. 
     69      # For nested has_many :through associations, this only deletes the 
     70      # first-order links. 
    7171      def delete(*records) 
    7272        records = flatten_deeper(records) 
    7373        records.each { |associate| raise_on_type_mismatch(associate) } 
     
    186186          join_attributes 
    187187        end 
    188188 
    189         # Associate attributes pointing to owner, quoted. 
    190         def construct_quoted_owner_attributes(reflection) 
    191           if as = reflection.options[:as] 
    192             { "#{as}_id" => @owner.quoted_id, 
    193               "#{as}_type" => reflection.klass.quote_value( 
    194                 @owner.class.base_class.name.to_s, 
    195                 reflection.klass.columns_hash["#{as}_type"]) } 
    196           else 
    197             { reflection.primary_key_name => @owner.quoted_id } 
    198           end 
    199         end 
    200  
    201189        # Build SQL conditions from attributes, qualified by table name. 
    202190        def construct_conditions 
    203           table_name = @reflection.through_reflection.quoted_table_name 
    204           conditions = construct_quoted_owner_attributes(@reflection.through_reflection).map do |attr, value| 
    205             "#{table_name}.#{attr} = #{value}" 
     191          if @constructed_conditions.nil? 
     192            @join_components ||= construct_join_components 
     193            @constructed_conditions = "#{@join_components[:remote_key]} = #{@owner.quoted_id} #{@join_components[:conditions]}" 
    206194          end 
    207           conditions << sql_conditions if sql_conditions 
    208           "(" + conditions.join(') AND (') + ")" 
     195          @constructed_conditions 
    209196        end 
    210197 
    211198        def construct_from 
     
    216203          selected = custom_select || @reflection.options[:select] || "#{@reflection.quoted_table_name}.*" 
    217204        end 
    218205 
    219         def construct_joins(custom_joins = nil) 
    220           polymorphic_join = nil 
    221           if @reflection.source_reflection.macro == :belongs_to 
    222             reflection_primary_key = @reflection.klass.primary_key 
    223             source_primary_key     = @reflection.source_reflection.primary_key_name 
    224             if @reflection.options[:source_type] 
    225               polymorphic_join = "AND %s.%s = %s" % [ 
    226                 @reflection.through_reflection.quoted_table_name, "#{@reflection.source_reflection.options[:foreign_type]}", 
    227                 @owner.class.quote_value(@reflection.options[:source_type]) 
    228               ] 
    229             end 
    230           else 
    231             reflection_primary_key = @reflection.source_reflection.primary_key_name 
    232             source_primary_key     = @reflection.klass.primary_key 
    233             if @reflection.source_reflection.options[:as] 
    234               polymorphic_join = "AND %s.%s = %s" % [ 
    235                 @reflection.quoted_table_name, "#{@reflection.source_reflection.options[:as]}_type", 
    236                 @owner.class.quote_value(@reflection.through_reflection.klass.name) 
    237               ] 
    238             end 
    239           end 
    240  
    241           "INNER JOIN %s ON %s.%s = %s.%s %s #{@reflection.options[:joins]} #{custom_joins}" % [ 
    242             @reflection.through_reflection.table_name, 
    243             @reflection.table_name, reflection_primary_key, 
    244             @reflection.through_reflection.table_name, source_primary_key, 
    245             polymorphic_join 
    246           ] 
     206        def construct_joins 
     207          @join_components ||= construct_join_components 
     208          @join_components[:joins] 
    247209        end 
    248210 
    249211        def construct_scope 
     
    259221             } } 
    260222        end 
    261223 
    262         def construct_sql 
    263           case 
    264             when @reflection.options[:finder_sql] 
    265               @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) 
    266  
    267               @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{@owner.quoted_id}" 
    268               @finder_sql << " AND (#{conditions})" if conditions 
    269           end 
    270224 
    271           if @reflection.options[:counter_sql] 
    272             @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) 
    273           elsif @reflection.options[:finder_sql] 
    274             # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ 
    275             @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } 
    276             @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) 
     225        # Given any belongs_to or has_many (including has_many 
     226        # :through) association, return the essential components of a 
     227        # join corresponding to that association, namely: 
     228        # 
     229        # joins: any additional joins required to get from the 
     230        #    association's table (reflection.table_name) to the table 
     231        #    that's actually joining to the active record's table 
     232        # 
     233        # remote_key: the name of the key in the join table (qualified 
     234        #    by table name) which will join to a field of the active 
     235        #    record's table 
     236        # 
     237        # local_key: the name of the key in the local table (not 
     238        #    qualified by table name) which will take part in the join 
     239        # 
     240        # conditions: any additional conditions (e.g. filtering by 
     241        #    type for a polymorphic association, or a :conditions 
     242        #    clause explicitly given in the association), including a 
     243        #    leading AND 
     244        # 
     245        def construct_join_components(reflection = @reflection, association_class = reflection.klass, table_ids = {association_class.table_name => 1}) 
     246 
     247          if reflection.macro == :has_many && reflection.through_reflection 
     248            # Construct the join components of the source association, 
     249            # so that we have a path from the eventual target table of 
     250            # the association up to the table named in :through, and 
     251            # all tables involved are allocated table IDs. 
     252            source_join_components = construct_join_components(reflection.source_reflection, reflection.klass, table_ids) 
     253            # Determine the alias of the :through table; this will be 
     254            # the last table assigned when constructing the source 
     255            # join components above. 
     256            through_table_alias = through_table_name = reflection.through_reflection.table_name 
     257            through_table_alias += "_#{table_ids[through_table_name]}" unless table_ids[through_table_name] == 1 
     258            # Construct the join components of the through 
     259            # association, so that we have a path to the active 
     260            # record's table. 
     261            through_join_components = construct_join_components(reflection.through_reflection, reflection.through_reflection.klass, table_ids) 
     262 
     263            # Source local key should be the through table primary key 
     264            # if the source reflection is a :has_many association 
     265            source_local_key = reflection.source_reflection.macro == :belongs_to ? 
     266              source_join_components[:local_key] : reflection.through_reflection.klass.primary_key 
     267 
     268            # Any subsequent joins / filters on owner attributes will 
     269            # act on the through association, so that's what we return 
     270            # for the conditions/keys of the overall association. 
     271            conditions = through_join_components[:conditions].dup 
     272            conditions << " AND #{interpolate_sql(reflection.klass.send(:sanitize_sql, reflection.options[:conditions]))}" if reflection.options[:conditions] 
     273 
     274            # CHECKME: Is it really correct for source_join_components[:conditions] 
     275            #   to go into :joins instead of :conditions? 
     276            # I guess it's equivalent for INNER JOIN, right? 
     277 
     278            through_table_key = quote_pair(through_table_alias, source_local_key) 
     279            { 
     280              :joins => "#{source_join_components[:joins]} INNER JOIN #{table_alias_for(through_table_name, through_table_alias)} ON (#{source_join_components[:remote_key]} = #{through_table_key} #{source_join_components[:conditions]}) #{through_join_components[:joins]} #{reflection.options[:joins]}", 
     281              :remote_key => through_join_components[:remote_key], 
     282              :local_key => through_join_components[:local_key], 
     283              :conditions => conditions 
     284            } 
    277285          else 
    278             @counter_sql = @finder_sql 
    279           end 
    280         end 
    281  
    282         def conditions 
    283           @conditions = build_conditions unless defined?(@conditions) 
    284           @conditions 
    285         end 
     286            # reflection is not has_many :through; it's a standard 
     287            # has_many / belongs_to instead 
     288 
     289            # Determine the alias used for remote_table_name, if 
     290            # any. In all cases this will already have been assigned 
     291            # an ID in table_ids (either through being involved in a 
     292            # previous join, or - if it's the first table in the query 
     293            # - as the default value of table_ids) 
     294            remote_table_alias = remote_table_name = association_class.table_name 
     295            remote_table_alias += "_#{table_ids[remote_table_name]}" unless table_ids[remote_table_name] == 1 
     296 
     297            # Assign a new alias for the local table. 
     298            local_table_alias = local_table_name = reflection.active_record.table_name 
     299            if table_ids[local_table_name] 
     300              table_id = table_ids[local_table_name] += 1 
     301              local_table_alias += "_#{table_id}" 
     302            else 
     303              table_ids[local_table_name] = 1 
     304            end 
    286305 
    287         def build_conditions 
    288           association_conditions = @reflection.options[:conditions] 
    289           through_conditions = @reflection.through_reflection.options[:conditions] 
    290           source_conditions = @reflection.source_reflection.options[:conditions] 
    291           uses_sti = !@reflection.through_reflection.klass.descends_from_active_record? 
     306            conditions = '' 
     307            # Add filter for single-table inheritance, if applicable. 
    292308 
    293           if association_conditions || through_conditions || source_conditions || uses_sti 
    294             all = [] 
     309            conditions << " AND #{association_class.send(:type_condition)}" if association_class.finder_needs_type_condition? 
     310            # Add custom conditions 
     311            conditions << " AND (#{interpolate_sql(association_class.send(:sanitize_sql, reflection.options[:conditions]))})" if reflection.options[:conditions] 
    295312 
    296             [association_conditions, through_conditions, source_conditions].each do |conditions| 
    297               all << interpolate_sql(sanitize_sql(conditions)) if conditions 
     313            if reflection.macro == :belongs_to 
     314              if reflection.options[:polymorphic] 
     315                local_table_key = quote_pair(local_table_alias, reflection.options[:foreign_type]) 
     316                conditions << " AND #{local_table_key} = #{reflection.active_record.quote_value(association_class.base_class.name.to_s)}" 
     317              end 
     318              remote_key = quote_pair(remote_table_alias, association_class.primary_key) 
     319              local_key = reflection.primary_key_name 
     320            else 
     321              # Association is has_many (without :through) 
     322              if reflection.options[:as] 
     323                remote_table_key = quote_pair(remote_table_alias, "#{reflection.options[:as]}_type") 
     324                conditions << " AND #{remote_table_key} = #{reflection.active_record.quote_value(reflection.active_record.base_class.name.to_s)}" 
     325              end 
     326              remote_key = quote_pair(remote_table_alias, reflection.primary_key_name) 
     327              local_key = reflection.klass.primary_key 
    298328            end 
    299  
    300             all << build_sti_condition if uses_sti 
    301  
    302             all.map { |sql| "(#{sql})" } * ' AND ' 
     329            { 
     330              :joins => "#{reflection.options[:joins]}", 
     331              :remote_key => remote_key, 
     332              :local_key => local_key, 
     333              :conditions => conditions 
     334            } 
    303335          end 
    304336        end 
    305337 
    306         def build_sti_condition 
    307           "#{@reflection.through_reflection.quoted_table_name}.#{@reflection.through_reflection.klass.inheritance_column} = #{@reflection.klass.quote_value(@reflection.through_reflection.klass.name.demodulize)}" 
     338        def table_alias_for(table_name, table_alias) 
     339          "#{@reflection.klass.connection.quote_table_name(table_name)} #{table_alias if table_name != table_alias}".strip 
    308340        end 
    309341 
    310         alias_method :sql_conditions, :conditions 
     342        def quote_pair(table_name, key) 
     343          conn = @reflection.klass.connection 
     344          "#{conn.quote_table_name(table_name)}.#{conn.quote_column_name(key)}" 
     345        end 
     346        private :table_alias_for, :quote_pair 
    311347 
    312348        def has_cached_counter? 
    313349          @owner.attribute_present?(cached_counter_attribute_name) 
  • a/activerecord/lib/active_record/base.rb

    old new  
    14641464          sql << "WHERE (#{segments.join(") AND (")}) " unless segments.empty? 
    14651465        end 
    14661466 
    1467         def type_condition 
     1467        # Takes an argument to allow for aliased table names 
     1468        def type_condition(table_name = quoted_table_name) 
    14681469          quoted_inheritance_column = connection.quote_column_name(inheritance_column) 
    1469           type_condition = subclasses.inject("#{quoted_table_name}.#{quoted_inheritance_column} = '#{name.demodulize}' ") do |condition, subclass| 
    1470             condition << "OR #{quoted_table_name}.#{quoted_inheritance_column} = '#{subclass.name.demodulize}' " 
     1470          type_condition = subclasses.inject("#{table_name}.#{quoted_inheritance_column} = '#{name.demodulize}' ") do |condition, subclass| 
     1471            condition << "OR #{table_name}.#{quoted_inheritance_column} = '#{subclass.name.demodulize}' " 
    14711472          end 
    14721473 
    14731474          " (#{type_condition}) " 
  • a/activerecord/lib/active_record/reflection.rb

    old new  
    191191            raise HasManyThroughAssociationPolymorphicError.new(active_record.name, self, source_reflection) 
    192192          end 
    193193           
    194           unless [:belongs_to, :has_many].include?(source_reflection.macro) && source_reflection.options[:through].nil? 
     194          unless [:belongs_to, :has_many].include?(source_reflection.macro) 
    195195            raise HasManyThroughSourceAssociationMacroError.new(self) 
    196196          end 
    197197        end 
  • a/activerecord/test/cases/associations/join_model_test.rb

    old new  
    376376    end 
    377377  end 
    378378 
    379   def test_has_many_through_has_many_through 
    380     assert_raise(ActiveRecord::HasManyThroughSourceAssociationMacroError) { authors(:david).tags } 
     379  def test_local_nested_through_associations 
     380    author = authors(:david) 
     381 
     382    assert_equal [categorizations(:david_welcome_general), categorizations(:mary_thinking_general)], author.similar_categorizations 
     383    assert_equal [posts(:welcome), posts(:thinking)], author.similar_posts 
     384  end 
     385 
     386  def test_remote_nested_through_associations 
     387    author = authors(:david) 
     388 
     389    # polymorphic 
     390    assert_equal [tags(:general)], author.tags.uniq.sort_by { |t| t.id } 
     391    assert_equal [], author.invalid_tags 
     392 
     393    # non-polymorphic 
     394    assert_equal [author, authors(:mary)], author.similar_authors.uniq.sort_by { |t| t.id } 
     395  end 
     396 
     397  def test_local_and_remote_nested_through_associations 
     398    author = authors(:david) 
     399 
     400    # polymorphic 
     401    assert_equal [ 
     402      taggings(:welcome_general), 
     403      taggings(:thinking_general), 
     404      taggings(:fake), 
     405      taggings(:godfather), 
     406      taggings(:orphaned)], author.tag_taggings.uniq.sort_by { |t| t.id } 
     407 
     408    expected_posts = [ 
     409      posts(:welcome), 
     410      posts(:thinking), 
     411      posts(:sti_comments), 
     412      posts(:sti_post_and_comments), 
     413      posts(:sti_habtm), 
     414      posts(:eager_other) 
     415    ] 
     416    assert_equal expected_posts, author.posts_of_similar_authors.uniq.sort_by { |t| t.id } 
     417  end 
     418 
     419  def test_multiple_table_references_in_nested_through_associations 
     420    author = authors(:david) 
     421 
     422    # polymorphic 
     423    assert_equal [tags(:general)], author.tag_tagging_tags.uniq.sort_by { |t| t.id } 
     424 
     425    assert_equal [categorizations(:david_welcome_general), categorizations(:mary_thinking_general)], author.categorizations_of_similar_posts.uniq.sort_by { |t| t.id } 
     426    assert_equal [author, authors(:mary)], author.similar_authors_2.uniq.sort_by { |t| t.id } 
     427 
     428    expected_posts = [ 
     429      posts(:welcome), 
     430      posts(:thinking), 
     431      posts(:sti_comments), 
     432      posts(:sti_post_and_comments), 
     433      posts(:sti_habtm), 
     434      posts(:eager_other) 
     435    ] 
     436    assert_equal expected_posts, author.posts_of_similar_authors_2.uniq.sort_by { |t| t.id } 
     437  end 
     438   
     439  def test_independence_of_repeated_has_many_through_finds 
     440    author = authors(:david) 
     441    assert_equal [taggings(:welcome_general)], author.taggings.find(:all, :conditions => ['taggings.taggable_id = ?', 1]) 
     442    assert_equal [taggings(:welcome_general), taggings(:thinking_general)], author.taggings.find(:all).uniq.sort_by { |t| t.id } 
    381443  end 
    382444 
    383445  def test_has_many_through_habtm 
  • a/activerecord/test/models/author.rb

    old new  
    6363 
    6464  has_many :tagging,  :through => :posts # through polymorphic has_one 
    6565  has_many :taggings, :through => :posts, :source => :taggings # through polymorphic has_many 
    66   has_many :tags,     :through => :posts # through has_many :through 
     66 
     67  # Local nested through 
     68  has_many :similar_categorizations, :through => :categories, :source => :categorizations 
     69  has_many :similar_posts, :through => :similar_categorizations, :source => :post 
     70 
     71  # Remote (source) nested through   
     72  has_many :tags, :through => :posts # polymorphic 
     73  has_many :invalid_tags, :through => :posts # polymorphic 
     74  has_many :similar_authors, :through => :categories, :source => :authors 
     75 
     76  # Local and remote (source) nested through 
     77  has_many :tag_taggings, :through => :tags, :source => :taggings # polymorphic 
     78  has_many :posts_of_similar_authors, :through => :similar_authors, :source => :posts 
     79 
     80  # Multiple table references, nested through 
     81  has_many :tag_tagging_tags, :through => :tag_taggings, :source => :tag # polymorphic 
     82  has_many :categorizations_of_similar_posts, :through => :similar_posts, :source => :categorizations 
     83  has_many :similar_authors_2, :through => :posts_of_similar_authors, :source => :authors 
     84  has_many :posts_of_similar_authors_2, :through => :similar_authors_2, :source => :posts # 2 multiple table reference 
     85 
    6786  has_many :post_categories, :through => :posts, :source => :categories 
    6887 
    6988  belongs_to :author_address, :dependent => :destroy