Changeset 8109
- Timestamp:
- 11/07/07 15:07:39 (1 year ago)
- Files:
-
- trunk/activerecord/lib/active_record/associations.rb (modified) (8 diffs)
- trunk/activerecord/lib/active_record/base.rb (modified) (6 diffs)
- trunk/activerecord/lib/active_record/calculations.rb (modified) (4 diffs)
- trunk/activerecord/test/associations/ar_joins_test.rb (modified) (1 diff)
- trunk/activerecord/test/associations/inner_join_association_test.rb (added)
Legend:
- Unmodified
- Added
- Removed
- Modified
- Copied
- Moved
trunk/activerecord/lib/active_record/associations.rb
r8102 r8109 487 487 # When eager loaded, conditions are interpolated in the context of the model class, not the model instance. Conditions are lazily interpolated 488 488 # before the actual model exists. 489 # 490 # == Adding Joins For Associations to Queries Using the :joins option 491 # 492 # ActiveRecord::Base#find provides a :joins option, which takes either a string or values accepted by the :include option. 493 # if the value is a string, the it should contain a SQL fragment containing a join clause. 494 # 495 # Non-string values of :joins will add an automatic join clause to the query in the same way that the :include option does but with two critical 496 # differences: 497 # 498 # 1. A normal (inner) join will be performed instead of the outer join generated by :include. 499 # this means that only objects which have objects attached to the association will be included in the result. 500 # For example, suppose we have the following tables (in yaml format): 501 # 502 # Authors 503 # fred: 504 # id: 1 505 # name: Fred 506 # steve: 507 # id: 2 508 # name: Steve 509 # 510 # Contributions 511 # only: 512 # id: 1 513 # author_id: 1 514 # description: Atta Boy Letter for Steve 515 # date: 2007-10-27 14:09:54 516 # 517 # and corresponding AR Classes 518 # 519 # class Author: < ActiveRecord::Base 520 # has_many :contributions 521 # end 522 # 523 # class Contribution < ActiveRecord::Base 524 # belongs_to :author 525 # end 526 # 527 # The query Author.find(:all) will return both authors, but Author.find(:all, :joins => :contributions) will 528 # only return authors who have at least one contribution, in this case only the first. 529 # This is only a degenerate case of the more typical use of :joins with a non-string value. 530 # For example to find authors who have at least one contribution before a certain date we can use: 531 # 532 # Author.find(:all, :joins => :contributions, :conditions => ["contributions.date <= ?", 1.week.ago.to_s(:db)]) 533 # 534 # 2. Only instances of the class to which the find is sent will be instantiated. ActiveRecord objects will not 535 # be instantiated for rows reached through the associations. 536 # 537 # The difference between using :joins vs :include to name associated records is that :joins allows associated tables to 538 # participate in selection criteria in the query without incurring the overhead of instantiating associated objects. 539 # This can be important when the number of associated objects in the database is large, and they will not be used, or 540 # only those associated with a paricular object or objects will be used after the query, making two queries more 541 # efficient than one. 542 # 543 # Note that while using a string value for :joins marks the result objects as read-only, the objects resulting 544 # from a call to find with a non-string :joins option value will be writable. 545 # 489 # 546 490 # == Table Aliasing 547 491 # … … 1178 1122 def find_with_associations(options = {}) 1179 1123 catch :invalid_query do 1180 if ar_joins = scope(:find, :ar_joins) 1181 options = options.dup 1182 options[:ar_joins] = ar_joins 1183 end 1184 includes = merge_includes(scope(:find, :include), options[:include]) 1185 includes = merge_includes(includes, options[:ar_joins]) 1186 join_dependency = JoinDependency.new(self, includes, options[:joins], options[:ar_joins]) 1124 join_dependency = JoinDependency.new(self, merge_includes(scope(:find, :include), options[:include]), options[:joins]) 1187 1125 rows = select_all_rows(options, join_dependency) 1188 1126 return join_dependency.instantiate(rows) … … 1438 1376 attr_reader :joins, :reflections, :table_aliases 1439 1377 1440 def initialize(base, associations, joins , ar_joins = nil)1378 def initialize(base, associations, joins) 1441 1379 @joins = [JoinBase.new(base, joins)] 1442 @ar_joins = ar_joins1443 1380 @associations = associations 1444 1381 @reflections = [] … … 1464 1401 @base_records_in_order << (@base_records_hash[primary_id] = join_base.instantiate(row)) 1465 1402 end 1466 construct(@base_records_hash[primary_id], @associations, join_associations.dup, row) unless @ar_joins1467 end 1468 remove_duplicate_results!(join_base.active_record, @base_records_in_order, @associations) unless @ar_joins1403 construct(@base_records_hash[primary_id], @associations, join_associations.dup, row) 1404 end 1405 remove_duplicate_results!(join_base.active_record, @base_records_in_order, @associations) 1469 1406 return @base_records_in_order 1470 1407 end … … 1508 1445 raise ConfigurationError, "Association named '#{ associations }' was not found; perhaps you misspelled it?" 1509 1446 @reflections << reflection 1510 @joins << (@ar_joins ? ARJoinAssociation : JoinAssociation).new(reflection, self, parent)1447 @joins << build_join_association(reflection, parent) 1511 1448 when Array 1512 1449 associations.each do |association| … … 1521 1458 raise ConfigurationError, associations.inspect 1522 1459 end 1460 end 1461 1462 # overridden in InnerJoinDependency subclass 1463 def build_join_association(reflection, parent) 1464 JoinAssociation.new(reflection, self, parent) 1523 1465 end 1524 1466 … … 1787 1729 def interpolate_sql(sql) 1788 1730 instance_eval("%@#{sql.gsub('@', '\@')}@") 1789 end 1790 1791 private 1731 end 1732 1733 private 1734 1792 1735 def join_type 1793 1736 "LEFT OUTER JOIN" 1794 1737 end 1795 1796 end 1797 class ARJoinAssociation < JoinAssociation 1738 end 1739 end 1740 1741 class InnerJoinDependency < JoinDependency # :nodoc: 1742 protected 1743 def build_join_association(reflection, parent) 1744 InnerJoinAssociation.new(reflection, self, parent) 1745 end 1746 1747 class InnerJoinAssociation < JoinAssociation 1798 1748 private 1799 1749 def join_type … … 1802 1752 end 1803 1753 end 1754 1804 1755 end 1805 1756 end trunk/activerecord/lib/active_record/base.rb
r8107 r8109 381 381 # * <tt>:limit</tt>: An integer determining the limit on the number of rows that should be returned. 382 382 # * <tt>:offset</tt>: An integer determining the offset from where the rows should be fetched. So at 5, it would skip rows 0 through 4. 383 # * <tt>:joins</tt>: Either an SQL fragment for additional joins like "LEFT JOIN comments ON comments.post_id = id".(Rarely needed).384 # or names associations in the same form used for the :include option.385 # If the value is a string, then the records will be returned read-only since they will have attributes that do not correspond to the table's columns.383 # * <tt>:joins</tt>: An SQL fragment for additional joins like "LEFT JOIN comments ON comments.post_id = id" (Rarely needed). 384 # Accepts named associations in the form of :include, which will perform an INNER JOIN on the associated table(s). 385 # The records will be returned read-only since they will have attributes that do not correspond to the table's columns. 386 386 # Pass :readonly => false to override. 387 # See adding joins for associations under Association.388 387 # * <tt>:include</tt>: Names associations that should be loaded alongside using LEFT OUTER JOINs. The symbols named refer 389 388 # to already defined associations. See eager loading under Associations. … … 431 430 def find(*args) 432 431 options = args.extract_options! 433 # Note: we extract any :joins option with a non-string value from the options, and turn it into434 # an internal option :ar_joins. This allows code called from her to find the ar_joins, and435 # it bypasses marking the result as read_only.436 # A normal string join marks the result as read-only because it contains attributes from joined tables437 # which are not in the base table and therefore prevent the result from being saved.438 # In the case of an ar_join, the JoinDependency created to instantiate the results eliminates these439 # bogus attributes. See JoinDependency#instantiate, and JoinBase#instantiate in associations.rb.440 options, ar_joins = *extract_ar_join_from_options(options)441 432 validate_find_options(options) 442 433 set_readonly_option!(options) 443 options[:ar_joins] = ar_joins if ar_joins444 434 445 435 case args.first … … 1039 1029 end 1040 1030 1041 # If options contains :joins, with a non-string value1042 # remove it from options1043 # return the updated or unchanged options, and the ar_join value or nil1044 def extract_ar_join_from_options(options)1045 new_options = options.dup1046 join_option = new_options.delete(:joins)1047 (join_option && !join_option.kind_of?(String)) ? [new_options, join_option] : [options, nil]1048 end1049 1050 1031 def find_every(options) 1051 records = scoped?(:find, :include) || options[:include] || scoped?(:find, :ar_joins) || (options[:ar_joins])?1032 records = scoped?(:find, :include) || options[:include] ? 1052 1033 find_with_associations(options) : 1053 1034 find_by_sql(construct_finder_sql(options)) … … 1247 1228 scope = scope(:find) if :auto == scope 1248 1229 join = (scope && scope[:joins]) || options[:joins] 1249 sql << " #{join} " if join 1230 case join 1231 when Symbol, Hash, Array 1232 join_dependency = ActiveRecord::Associations::ClassMethods::InnerJoinDependency.new(self, join, nil) 1233 sql << " #{join_dependency.join_associations.collect{|join| join.association_join }.join} " 1234 else 1235 sql << " #{join} " 1236 end 1250 1237 end 1251 1238 … … 1473 1460 if f = method_scoping[:find] 1474 1461 f.assert_valid_keys(VALID_FIND_OPTIONS) 1475 # see note about :joins and :ar_joins in ActiveRecord::Base#find1476 f, ar_joins = *extract_ar_join_from_options(f)1477 1462 set_readonly_option! f 1478 if ar_joins1479 f[:ar_joins] = ar_joins1480 method_scoping[:find] = f1481 end1482 1463 end 1483 1464 … … 1492 1473 if key == :conditions && merge 1493 1474 hash[method][key] = [params[key], hash[method][key]].collect{ |sql| "( %s )" % sanitize_sql(sql) }.join(" AND ") 1494 elsif ([:include, :ar_joins].include?(key))&& merge1475 elsif key == :include && merge 1495 1476 hash[method][key] = merge_includes(hash[method][key], params[key]).uniq 1496 1477 else trunk/activerecord/lib/active_record/calculations.rb
r8106 r8109 16 16 # 17 17 # * <tt>:conditions</tt>: An SQL fragment like "administrator = 1" or [ "user_name = ?", username ]. See conditions in the intro. 18 # * <tt>:joins</tt>: Either an SQL fragment for additional joins like "LEFT JOIN comments ON comments.post_id = id". (Rarely needed). 19 # or names associations in the same form used for the :include option. 20 # If the value is a string, then the records will be returned read-only since they will have attributes that do not correspond to the table's columns. 21 # Pass :readonly => false to override. 22 # See adding joins for associations under Association. 18 # * <tt>:joins</tt>: An SQL fragment for additional joins like "LEFT JOIN comments ON comments.post_id = id". (Rarely needed). 19 # The records will be returned read-only since they will have attributes that do not correspond to the table's columns. 23 20 # * <tt>:include</tt>: Named associations that should be loaded alongside using LEFT OUTER JOINs. The symbols named refer 24 21 # to already defined associations. When using named associations count returns the number DISTINCT items for the model you're counting. … … 113 110 # Person.minimum(:age, :having => 'min(age) > 17', :group => :last_name) # Selects the minimum age for any family without any minors 114 111 def calculate(operation, column_name, options = {}) 115 options, ar_joins = *extract_ar_join_from_options(options)116 112 validate_calculation_options(operation, options) 117 options[:ar_joins] = ar_joins if ar_joins118 113 column_name = options[:select] if options[:select] 119 114 column_name = '*' if column_name == :all … … 155 150 options = options.symbolize_keys 156 151 157 scope = scope(:find) 158 if scope && scope[:ar_joins] 159 scope = scope.dup 160 options = options.dup 161 options[:ar_joins] = scope.delete(:ar_joins) 162 end 152 scope = scope(:find) 163 153 merged_includes = merge_includes(scope ? scope[:include] : [], options[:include]) 164 merged_includes = merge_includes(merged_includes, options[:ar_joins])165 154 aggregate_alias = column_alias_for(operation, column_name) 166 155 … … 185 174 sql << " FROM #{connection.quote_table_name(table_name)} " 186 175 if merged_includes.any? 187 join_dependency = ActiveRecord::Associations::ClassMethods::JoinDependency.new(self, merged_includes, options[:joins] , options[:ar_joins])176 join_dependency = ActiveRecord::Associations::ClassMethods::JoinDependency.new(self, merged_includes, options[:joins]) 188 177 sql << join_dependency.join_associations.collect{|join| join.association_join }.join 189 178 end trunk/activerecord/test/associations/ar_joins_test.rb
r8054 r8109 1 require 'abstract_unit'2 require 'fixtures/post'3 require 'fixtures/comment'4 require 'fixtures/author'5 require 'fixtures/category'6 require 'fixtures/categorization'7 require 'fixtures/company'8 require 'fixtures/topic'9 require 'fixtures/reply'10 require 'fixtures/developer'11 require 'fixtures/project'12 13 class ArJoinsTest < Test::Unit::TestCase14 fixtures :authors, :posts, :comments, :categories, :categories_posts, :people,15 :developers, :projects, :developers_projects,16 :categorizations, :companies, :accounts, :topics17 18 def test_ar_joins19 authors = Author.find(:all, :joins => :posts, :conditions => ['posts.type = ?', "Post"])20 assert_not_equal(0 , authors.length)21 authors.each do |author|22 assert !(author.send(:instance_variables).include? "@posts")23 assert(!author.readonly?, "non-string join value produced read-only result.")24 end25 end26 27 def test_ar_joins_with_cascaded_two_levels28 authors = Author.find(:all, :joins=>{:posts=>:comments})29 assert_equal(2, authors.length)30 authors.each do |author|31 assert !(author.send(:instance_variables).include? "@posts")32 assert(!author.readonly?, "non-string join value produced read-only result.")33 end34 authors = Author.find(:all, :joins=>{:posts=>:comments}, :conditions => ["comments.body = ?", "go crazy" ])35 assert_equal(1, authors.length)36 authors.each do |author|37 assert !(author.send(:instance_variables).include? "@posts")38 assert(!author.readonly?, "non-string join value produced read-only result.")39 end40 end41 42 43 def test_ar_joins_with_complex_conditions44 authors = Author.find(:all, :joins=>{:posts=>[:comments, :categories]},45 :conditions => ["categories.name = ? AND posts.title = ?", "General", "So I was thinking"]46 )47 assert_equal(1, authors.length)48 authors.each do |author|49 assert !(author.send(:instance_variables).include? "@posts")50 assert(!author.readonly?, "non-string join value produced read-only result.")51 end52 assert_equal("David", authors.first.name)53 end54 55 def test_ar_join_with_has_many_and_limit_and_scoped_and_explicit_conditions56 Post.with_scope(:find => { :conditions => "1=1" }) do57 posts = authors(:david).posts.find(:all,58 :joins => :comments,59 :conditions => "comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment'",60 :limit => 261 )62 assert_equal 2, posts.size63 64 count = Post.count(65 :joins => [ :comments, :author ],66 :conditions => "authors.name = 'David' AND (comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment')",67 :limit => 268 )69 assert_equal count, posts.size70 end71 end72 73 def test_ar_join_with_scoped_order_using_association_limiting_without_explicit_scope74 posts_with_explicit_order = Post.find(:all, :conditions => 'comments.id is not null', :joins => :comments, :order => 'posts.id DESC', :limit => 2)75 posts_with_scoped_order = Post.with_scope(:find => {:order => 'posts.id DESC'}) do76 Post.find(:all, :conditions => 'comments.id is not null', :joins => :comments, :limit => 2)77 end78 assert_equal posts_with_explicit_order, posts_with_scoped_order79 end80 81 def test_scoped_find_include82 # with the include, will retrieve only developers for the given project83 scoped_developers = Developer.with_scope(:find => { :joins => :projects }) do84 Developer.find(:all, :conditions => 'projects.id = 2')85 end86 assert scoped_developers.include?(developers(:david))87 assert !scoped_developers.include?(developers(:jamis))88 assert_equal 1, scoped_developers.size89 end90 91 92 def test_nested_scoped_find_ar_join93 Developer.with_scope(:find => { :joins => :projects }) do94 Developer.with_scope(:find => { :conditions => "projects.id = 2" }) do95 assert_equal('David', Developer.find(:first).name)96 end97 end98 end99 100 def test_nested_scoped_find_merged_ar_join101 # :include's remain unique and don't "double up" when merging102 Developer.with_scope(:find => { :joins => :projects, :conditions => "projects.id = 2" }) do103 Developer.with_scope(:find => { :joins => :projects }) do104 assert_equal 1, Developer.instance_eval('current_scoped_methods')[:find][:ar_joins].length105 assert_equal('David', Developer.find(:first).name)106 end107 end108 # the nested scope doesn't remove the first :include109 Developer.with_scope(:find => { :joins => :projects, :conditions => "projects.id = 2" }) do110 Developer.with_scope(:find => { :joins => [] }) do111 assert_equal 1, Developer.instance_eval('current_scoped_methods')[:find][:ar_joins].length112 assert_equal('David', Developer.find(:first).name)113 end114 end115 # mixing array and symbol include's will merge correctly116 Developer.with_scope(:find => { :joins => [:projects], :conditions => "projects.id = 2" }) do117 Developer.with_scope(:find => { :joins => :projects }) do118 assert_equal 1, Developer.instance_eval('current_scoped_methods')[:find][:ar_joins].length119 assert_equal('David', Developer.find(:first).name)120 end121 end122 end123 124 def test_nested_scoped_find_replace_include125 Developer.with_scope(:find => { :joins => :projects }) do126 Developer.with_exclusive_scope(:find => { :joins => [] }) do127 assert_equal 0, Developer.instance_eval('current_scoped_methods')[:find][:ar_joins].length128 end129 end130 end131 132 #133 # Calculations134 #135 def test_count_with_ar_joins136 assert_equal(2, Author.count(:joins => :posts, :conditions => ['posts.type = ?', "Post"]))137 assert_equal(1, Author.count(:joins => :posts, :conditions => ['posts.type = ?', "SpecialPost"]))138 end139 140 def test_should_get_maximum_of_field_with_joins141 assert_equal 50, Account.maximum(:credit_limit, :joins=> :firm, :conditions => "companies.name != 'Summit'")142 end143 144 def test_should_get_maximum_of_field_with_scoped_include145 Account.with_scope :find => { :joins => :firm, :conditions => "companies.name != 'Summit'" } do146 assert_equal 50, Account.maximum(:credit_limit)147 end148 end149 150 def test_should_not_modify_options_when_using_ar_joins_on_count151 options = {:conditions => 'companies.id > 1', :joins => :firm}152 options_copy = options.dup153 154 Account.count(:all, options)155 assert_equal options_copy, options156 end157 158 end