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) |
|---|
-
a/activerecord/lib/active_record/associations.rb
old new 1700 1700 @aliased_table_name = table_name #.tr('.', '_') # start with the table name, sub out any .'s 1701 1701 @parent_table_name = parent.active_record.table_name 1702 1702 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} 1704 1704 join_dependency.table_aliases[aliased_table_name] += 1 1705 1705 end 1706 1706 -
a/activerecord/lib/active_record/associations/has_many_through_association.rb
old new 4 4 def initialize(owner, reflection) 5 5 super 6 6 reflection.check_validity! 7 @finder_sql = construct_conditions8 construct_sql9 7 end 10 8 11 9 def find(*args) 12 10 options = args.extract_options! 13 11 14 conditions = "#{@finder_sql}"12 conditions = construct_conditions 15 13 if sanitized_conditions = sanitize_sql(options[:conditions]) 16 conditions << " AND (#{sanitized_conditions})"14 conditions = conditions.dup << " AND (#{sanitized_conditions})" 17 15 end 18 16 options[:conditions] = conditions 19 17 … … 25 23 26 24 options[:select] = construct_select(options[:select]) 27 25 options[:from] ||= construct_from 28 options[:joins] = construct_joins (options[:joins])26 options[:joins] = construct_joins + " #{options[:joins]}" 29 27 options[:include] = @reflection.source_reflection.options[:include] if options[:include].nil? 30 28 31 29 merge_options_from_reflection!(options) … … 46 44 # either is a new record. Calls create! so you can rescue errors. 47 45 # 48 46 # The :before_add and :after_add callbacks are not yet supported. 47 # Nested has_many :through is not yet supported. 49 48 def <<(*records) 50 49 return if records.empty? 51 50 through = @reflection.through_reflection 52 51 raise ActiveRecord::HasManyThroughCantAssociateNewRecords.new(@owner, through) if @owner.new_record? 53 54 52 klass = through.klass 55 53 klass.transaction do 56 54 flatten_deeper(records).each do |associate| … … 68 66 [:push, :concat].each { |method| alias_method method, :<< } 69 67 70 68 # Removes +records+ from this association. Does not destroy +records+. 69 # For nested has_many :through associations, this only deletes the 70 # first-order links. 71 71 def delete(*records) 72 72 records = flatten_deeper(records) 73 73 records.each { |associate| raise_on_type_mismatch(associate) } … … 186 186 join_attributes 187 187 end 188 188 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 else197 { reflection.primary_key_name => @owner.quoted_id }198 end199 end200 201 189 # Build SQL conditions from attributes, qualified by table name. 202 190 def construct_conditions 203 table_name = @reflection.through_reflection.quoted_table_name204 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]}" 206 194 end 207 conditions << sql_conditions if sql_conditions 208 "(" + conditions.join(') AND (') + ")" 195 @constructed_conditions 209 196 end 210 197 211 198 def construct_from … … 216 203 selected = custom_select || @reflection.options[:select] || "#{@reflection.quoted_table_name}.*" 217 204 end 218 205 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] 247 209 end 248 210 249 211 def construct_scope … … 259 221 } } 260 222 end 261 223 262 def construct_sql263 case264 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 conditions269 end270 224 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 } 277 285 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 286 305 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. 292 308 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] 295 312 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 298 328 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 } 303 335 end 304 336 end 305 337 306 def build_sti_condition307 "#{@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 308 340 end 309 341 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 311 347 312 348 def has_cached_counter? 313 349 @owner.attribute_present?(cached_counter_attribute_name) -
a/activerecord/lib/active_record/base.rb
old new 1464 1464 sql << "WHERE (#{segments.join(") AND (")}) " unless segments.empty? 1465 1465 end 1466 1466 1467 def type_condition 1467 # Takes an argument to allow for aliased table names 1468 def type_condition(table_name = quoted_table_name) 1468 1469 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}' " 1471 1472 end 1472 1473 1473 1474 " (#{type_condition}) " -
a/activerecord/lib/active_record/reflection.rb
old new 191 191 raise HasManyThroughAssociationPolymorphicError.new(active_record.name, self, source_reflection) 192 192 end 193 193 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) 195 195 raise HasManyThroughSourceAssociationMacroError.new(self) 196 196 end 197 197 end -
a/activerecord/test/cases/associations/join_model_test.rb
old new 376 376 end 377 377 end 378 378 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 } 381 443 end 382 444 383 445 def test_has_many_through_habtm -
a/activerecord/test/models/author.rb
old new 63 63 64 64 has_many :tagging, :through => :posts # through polymorphic has_one 65 65 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 67 86 has_many :post_categories, :through => :posts, :source => :categories 68 87 69 88 belongs_to :author_address, :dependent => :destroy