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

Ticket #6461: nested_has_many_through_with_correct_primary_keys.diff

File nested_has_many_through_with_correct_primary_keys.diff, 18.7 kB (added by jcoglan, 1 year ago)

Update of Matt's previous patch that also fixes #7621

  • activerecord/lib/active_record/associations.rb

    old new  
    15701570              @aliased_table_name = table_name #.tr('.', '_') # start with the table name, sub out any .'s 
    15711571              @parent_table_name  = parent.active_record.table_name 
    15721572 
    1573               if !parent.table_joins.blank? && parent.table_joins.to_s.downcase =~ %r{join(\s+\w+)?\s+#{aliased_table_name.downcase}\son} 
     1573              if !parent.table_joins.blank? && parent.table_joins.to_s.downcase =~ %r{join(\s+\w+)?\s+#{aliased_table_name.downcase}\s+on} 
    15741574                join_dependency.table_aliases[aliased_table_name] += 1 
    15751575              end 
    15761576               
  • 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) 
     
    161159          join_attributes 
    162160        end 
    163161 
    164         # Associate attributes pointing to owner, quoted. 
    165         def construct_quoted_owner_attributes(reflection) 
    166           if as = reflection.options[:as] 
    167             { "#{as}_id" => @owner.quoted_id, 
    168               "#{as}_type" => reflection.klass.quote_value( 
    169                 @owner.class.base_class.name.to_s, 
    170                 reflection.klass.columns_hash["#{as}_type"]) } 
    171           else 
    172             { reflection.primary_key_name => @owner.quoted_id } 
    173           end 
    174         end 
    175  
    176162        # Build SQL conditions from attributes, qualified by table name. 
    177163        def construct_conditions 
    178           table_name = @reflection.through_reflection.table_name 
    179           conditions = construct_quoted_owner_attributes(@reflection.through_reflection).map do |attr, value| 
    180             "#{table_name}.#{attr} = #{value}" 
     164          if @constructed_conditions.nil? 
     165            @join_components ||= construct_join_components 
     166            @constructed_conditions = "#{@join_components[:remote_key]} = #{@owner.quoted_id} #{@join_components[:conditions]}" 
    181167          end 
    182           conditions << sql_conditions if sql_conditions 
    183           "(" + conditions.join(') AND (') + ")" 
     168          @constructed_conditions 
    184169        end 
    185170 
    186171        def construct_from 
     
    191176          selected = custom_select || @reflection.options[:select] || "#{@reflection.table_name}.*" 
    192177        end 
    193178 
    194         def construct_joins(custom_joins = nil) 
    195           polymorphic_join = nil 
    196           if @reflection.through_reflection.options[:as] || @reflection.source_reflection.macro == :belongs_to 
    197             reflection_primary_key = @reflection.klass.primary_key 
    198             source_primary_key     = @reflection.source_reflection.primary_key_name 
    199             if @reflection.options[:source_type] 
    200               polymorphic_join = "AND %s.%s = %s" % [ 
    201                 @reflection.through_reflection.table_name, "#{@reflection.source_reflection.options[:foreign_type]}", 
    202                 @owner.class.quote_value(@reflection.options[:source_type]) 
    203               ] 
    204             end 
    205           else 
    206             reflection_primary_key = @reflection.source_reflection.primary_key_name 
    207             source_primary_key     = @reflection.klass.primary_key 
    208             if @reflection.source_reflection.options[:as] 
    209               polymorphic_join = "AND %s.%s = %s" % [ 
    210                 @reflection.table_name, "#{@reflection.source_reflection.options[:as]}_type", 
    211                 @owner.class.quote_value(@reflection.through_reflection.klass.name) 
    212               ] 
    213             end 
    214           end 
    215  
    216           "INNER JOIN %s ON %s.%s = %s.%s %s #{@reflection.options[:joins]} #{custom_joins}" % [ 
    217             @reflection.through_reflection.table_name, 
    218             @reflection.table_name, reflection_primary_key, 
    219             @reflection.through_reflection.table_name, source_primary_key, 
    220             polymorphic_join 
    221           ] 
     179        def construct_joins 
     180          @join_components ||= construct_join_components 
     181          @join_components[:joins] 
    222182        end 
    223183 
    224184        def construct_scope 
     
    228188                         :joins       => construct_joins, 
    229189                         :select      => construct_select } } 
    230190        end 
     191         
     192        # Given any belongs_to or has_many (including has_many :through) association, 
     193        # return the essential components of a join corresponding to that association, namely: 
     194        # joins: any additional joins required to get from the association's table (reflection.table_name) 
     195        #    to the table that's actually joining to the active record's table 
     196        # remote_key: the name of the key in the join table (qualified by table name) which will join 
     197        #    to a field of the active record's table 
     198        # local_key: the name of the key in the local table (not qualified by table name) which will 
     199        #    take part in the join 
     200        # conditions: any additional conditions (e.g. filtering by type for a polymorphic association, 
     201        #    or a :conditions clause explicitly given in the association), including a leading AND 
     202        def construct_join_components(reflection = @reflection, association_class = reflection.klass, table_ids = {association_class.table_name => 1}) 
     203         
     204          if reflection.macro == :has_many and reflection.through_reflection 
     205            # Construct the join components of the source association, so that we have a path from 
     206            # the eventual target table of the association up to the table named in :through, and 
     207            # all tables involved are allocated table IDs. 
     208            source_join_components = construct_join_components(reflection.source_reflection, reflection.klass, table_ids) 
     209            # Determine the alias of the :through table; this will be the last table assigned 
     210            # when constructing the source join components above. 
     211            through_table_alias = through_table_name = reflection.through_reflection.table_name 
     212            through_table_alias += "_#{table_ids[through_table_name]}" unless table_ids[through_table_name] == 1 
    231213 
    232         def construct_sql 
    233           case 
    234             when @reflection.options[:finder_sql] 
    235               @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) 
     214            # Construct the join components of the through association, so that we have a path to 
     215            # the active record's table. 
     216            through_join_components = construct_join_components(reflection.through_reflection, reflection.through_reflection.klass, table_ids) 
    236217 
    237               @finder_sql = "#{@reflection.klass.table_name}.#{@reflection.primary_key_name} = #{@owner.quoted_id}" 
    238               @finder_sql << " AND (#{conditions})" if conditions 
    239           end 
     218            # Source local key should be the through table primary key if the source reflection is a :has_many association 
     219            source_local_key = reflection.source_reflection.macro == :belongs_to ? 
     220                source_join_components[:local_key] : 
     221                reflection.through_reflection.klass.primary_key 
    240222 
    241           if @reflection.options[:counter_sql] 
    242             @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) 
    243           elsif @reflection.options[:finder_sql] 
    244             # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ 
    245             @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } 
    246             @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) 
     223            # Any subsequent joins / filters on owner attributes will act on the through association, 
     224            # so that's what we return for the conditions/keys of the overall association. 
     225            conditions = through_join_components[:conditions] 
     226            conditions += " AND #{interpolate_sql(reflection.klass.send(:sanitize_sql, reflection.options[:conditions]))}" if reflection.options[:conditions] 
     227            { 
     228              :joins => "#{source_join_components[:joins]} INNER JOIN #{table_name_with_alias(through_table_name, through_table_alias)} ON (#{source_join_components[:remote_key]} = #{through_table_alias}.#{source_local_key}#{source_join_components[:conditions]}) #{through_join_components[:joins]} #{reflection.options[:joins]}", 
     229              :remote_key => through_join_components[:remote_key], 
     230              :local_key => through_join_components[:local_key], 
     231              :conditions => conditions 
     232            } 
    247233          else 
    248             @counter_sql = @finder_sql 
     234            # reflection is not has_many :through; it's a standard has_many / belongs_to instead 
     235             
     236            # Determine the alias used for remote_table_name, if any. In all cases this will already 
     237            # have been assigned an ID in table_ids (either through being involved in a previous join, 
     238            # or - if it's the first table in the query - as the default value of table_ids) 
     239            remote_table_alias = remote_table_name = association_class.table_name 
     240            remote_table_alias += "_#{table_ids[remote_table_name]}" unless table_ids[remote_table_name] == 1 
     241 
     242            # Assign a new alias for the local table. 
     243            local_table_alias = local_table_name = reflection.active_record.table_name 
     244            if table_ids[local_table_name] 
     245              table_id = table_ids[local_table_name] += 1 
     246              local_table_alias += "_#{table_id}" 
     247            else 
     248              table_ids[local_table_name] = 1 
     249            end 
     250             
     251            conditions = '' 
     252            # Add filter for single-table inheritance, if applicable. 
     253            conditions += " AND #{remote_table_alias}.#{association_class.inheritance_column} = #{association_class.quote_value(association_class.name.demodulize)}" unless association_class.descends_from_active_record? 
     254            # Add custom conditions 
     255            conditions += " AND (#{interpolate_sql(association_class.send(:sanitize_sql, reflection.options[:conditions]))})" if reflection.options[:conditions] 
     256             
     257            if reflection.macro == :belongs_to 
     258              if reflection.options[:polymorphic] 
     259                conditions += " AND #{local_table_alias}.#{reflection.options[:foreign_type]} = #{reflection.active_record.quote_value(association_class.base_class.name.to_s)}" 
     260              end 
     261              { 
     262                :joins => reflection.options[:joins], 
     263                :remote_key => "#{remote_table_alias}.#{association_class.primary_key}", 
     264                :local_key => reflection.primary_key_name, 
     265                :conditions => conditions 
     266              } 
     267            else 
     268              # Association is has_many (without :through) 
     269              if reflection.options[:as] 
     270                conditions += " AND #{remote_table_alias}.#{reflection.options[:as]}_type = #{reflection.active_record.quote_value(reflection.active_record.base_class.name.to_s)}" 
     271              end 
     272              { 
     273                :joins => "#{reflection.options[:joins]}", 
     274                :remote_key => "#{remote_table_alias}.#{reflection.primary_key_name}", 
     275                :local_key => reflection.klass.primary_key, 
     276                :conditions => conditions 
     277              } 
     278            end 
    249279          end 
    250280        end 
    251281 
    252         def conditions 
    253           @conditions ||= [ 
    254             (interpolate_sql(@reflection.klass.send(:sanitize_sql, @reflection.options[:conditions])) if @reflection.options[:conditions]), 
    255             (interpolate_sql(@reflection.active_record.send(:sanitize_sql, @reflection.through_reflection.options[:conditions])) if @reflection.through_reflection.options[:conditions]), 
    256             ("#{@reflection.through_reflection.table_name}.#{@reflection.through_reflection.klass.inheritance_column} = #{@reflection.klass.quote_value(@reflection.through_reflection.klass.name.demodulize)}" unless @reflection.through_reflection.klass.descends_from_active_record?) 
    257           ].compact.collect { |condition| "(#{condition})" }.join(' AND ') unless (!@reflection.options[:conditions] && !@reflection.through_reflection.options[:conditions] && @reflection.through_reflection.klass.descends_from_active_record?) 
     282        def table_name_with_alias(table_name, table_alias) 
     283          table_name == table_alias ? table_name : "#{table_name} #{table_alias}" 
    258284        end 
    259285 
    260         alias_method :sql_conditions, :conditions 
    261286    end 
    262287  end 
    263288end 
  • activerecord/lib/active_record/reflection.rb

    old new  
    187187            raise HasManyThroughAssociationPolymorphicError.new(active_record.name, self, source_reflection) 
    188188          end 
    189189           
    190           unless [:belongs_to, :has_many].include?(source_reflection.macro) && source_reflection.options[:through].nil? 
     190          unless [:belongs_to, :has_many].include?(source_reflection.macro) 
    191191            raise HasManyThroughSourceAssociationMacroError.new(self) 
    192192          end 
    193193        end 
  • activerecord/test/associations/join_model_test.rb

    old new  
    368368    end 
    369369  end 
    370370 
    371   def test_has_many_through_has_many_through 
    372     assert_raise(ActiveRecord::HasManyThroughSourceAssociationMacroError) { authors(:david).tags } 
     371  def test_local_nested_through_associations 
     372    author = authors(:david) 
     373 
     374    assert_equal [categorizations(:david_welcome_general), categorizations(:mary_thinking_general)], author.similar_categorizations 
     375    assert_equal [posts(:welcome), posts(:thinking)], author.similar_posts 
    373376  end 
    374377 
     378  def test_remote_nested_through_associations 
     379    author = authors(:david) 
     380 
     381    # polymorphic 
     382    assert_equal [tags(:general)], author.tags.uniq.sort_by { |t| t.id } 
     383    assert_equal [], author.invalid_tags 
     384 
     385    # non-polymorphic 
     386    assert_equal [author, authors(:mary)], author.similar_authors.uniq.sort_by { |t| t.id } 
     387  end 
     388 
     389  def test_local_and_remote_nested_through_associations 
     390    author = authors(:david) 
     391 
     392    # polymorphic 
     393    assert_equal [taggings(:welcome_general), taggings(:thinking_general), taggings(:fake), taggings(:godfather)], author.tag_taggings.uniq.sort_by { |t| t.id } 
     394 
     395    expected_posts = [ 
     396      posts(:welcome), 
     397      posts(:thinking), 
     398      posts(:sti_comments), 
     399      posts(:sti_post_and_comments), 
     400      posts(:sti_habtm), 
     401      posts(:eager_other) 
     402    ] 
     403    assert_equal expected_posts, author.posts_of_similar_authors.uniq.sort_by { |t| t.id } 
     404  end 
     405 
     406  def test_multiple_table_references_in_nested_through_associations 
     407    author = authors(:david) 
     408 
     409    # polymorphic 
     410    assert_equal [tags(:general)], author.tag_tagging_tags.uniq.sort_by { |t| t.id } 
     411 
     412    assert_equal [categorizations(:david_welcome_general), categorizations(:mary_thinking_general)], author.categorizations_of_similar_posts.uniq.sort_by { |t| t.id } 
     413    assert_equal [author, authors(:mary)], author.similar_authors_2.uniq.sort_by { |t| t.id } 
     414 
     415    expected_posts = [ 
     416      posts(:welcome), 
     417      posts(:thinking), 
     418      posts(:sti_comments), 
     419      posts(:sti_post_and_comments), 
     420      posts(:sti_habtm), 
     421      posts(:eager_other) 
     422    ] 
     423    assert_equal expected_posts, author.posts_of_similar_authors_2.uniq.sort_by { |t| t.id } 
     424  end 
     425   
     426  def test_independence_of_repeated_has_many_through_finds 
     427    author = authors(:david) 
     428    assert_equal [taggings(:welcome_general)], author.taggings.find(:all, :conditions => ['taggings.taggable_id = ?', 1]) 
     429    assert_equal [taggings(:welcome_general), taggings(:thinking_general)], author.taggings.find(:all).uniq.sort_by { |t| t.id } 
     430  end 
     431 
    375432  def test_has_many_through_habtm 
    376433    assert_raise(ActiveRecord::HasManyThroughSourceAssociationMacroError) { authors(:david).post_categories } 
    377434  end 
  • activerecord/test/fixtures/author.rb

    old new  
    5656 
    5757  has_many :tagging,  :through => :posts # through polymorphic has_one 
    5858  has_many :taggings, :through => :posts, :source => :taggings # through polymorphic has_many 
    59   has_many :tags,     :through => :posts # through has_many :through 
     59 
     60  # Local nested through 
     61  has_many :similar_categorizations, :through => :categories, :source => :categorizations 
     62  has_many :similar_posts, :through => :similar_categorizations, :source => :post 
     63 
     64  # Remote (source) nested through   
     65  has_many :tags, :through => :posts # polymorphic 
     66  has_many :invalid_tags, :through => :posts # polymorphic 
     67  has_many :similar_authors, :through => :categories, :source => :authors 
     68 
     69  # Local and remote (source) nested through 
     70  has_many :tag_taggings, :through => :tags, :source => :taggings # polymorphic 
     71  has_many :posts_of_similar_authors, :through => :similar_authors, :source => :posts 
     72 
     73  # Multiple table references, nested through 
     74  has_many :tag_tagging_tags, :through => :tag_taggings, :source => :tag # polymorphic 
     75  has_many :categorizations_of_similar_posts, :through => :similar_posts, :source => :categorizations 
     76  has_many :similar_authors_2, :through => :posts_of_similar_authors, :source => :authors 
     77  has_many :posts_of_similar_authors_2, :through => :similar_authors_2, :source => :posts # 2 multiple table reference 
     78 
    6079  has_many :post_categories, :through => :posts, :source => :categories 
    6180 
    6281  belongs_to :author_address