From b689834bcf2730353d066277f43047f10abb8d30 Mon Sep 17 00:00:00 2001 From: Bodaniel Jeanes Date: Sun, 26 Sep 2010 22:17:18 +1000 Subject: Initial nested_has_many_through support [#1152] --- activerecord/lib/active_record/associations.rb | 1 + .../associations/has_many_through_association.rb | 2 + .../associations/nested_has_many_through.rb | 156 +++++++++++++++++++++ activerecord/lib/active_record/reflection.rb | 6 +- .../test/cases/associations/join_model_test.rb | 8 -- .../nested_has_many_through_associations_test.rb | 43 ++++++ activerecord/test/fixtures/books.yml | 2 + activerecord/test/models/author.rb | 14 +- activerecord/test/models/book.rb | 2 + activerecord/test/schema/schema.rb | 1 + 10 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 activerecord/lib/active_record/associations/nested_has_many_through.rb create mode 100644 activerecord/test/cases/associations/nested_has_many_through_associations_test.rb diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 565ebf8197..812abf5a55 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -111,6 +111,7 @@ module ActiveRecord autoload :HasAndBelongsToManyAssociation, 'active_record/associations/has_and_belongs_to_many_association' autoload :HasManyAssociation, 'active_record/associations/has_many_association' autoload :HasManyThroughAssociation, 'active_record/associations/has_many_through_association' + autoload :NestedHasManyThroughAssociation, 'active_record/associations/nested_has_many_through_association' autoload :HasOneAssociation, 'active_record/associations/has_one_association' autoload :HasOneThroughAssociation, 'active_record/associations/has_one_through_association' diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 97883d8393..964c381c0d 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -1,4 +1,5 @@ require "active_record/associations/through_association_scope" +require "active_record/associations/nested_has_many_through" require 'active_support/core_ext/object/blank' module ActiveRecord @@ -6,6 +7,7 @@ module ActiveRecord module Associations class HasManyThroughAssociation < HasManyAssociation #:nodoc: include ThroughAssociationScope + include NestedHasManyThrough alias_method :new, :build diff --git a/activerecord/lib/active_record/associations/nested_has_many_through.rb b/activerecord/lib/active_record/associations/nested_has_many_through.rb new file mode 100644 index 0000000000..2d03b81128 --- /dev/null +++ b/activerecord/lib/active_record/associations/nested_has_many_through.rb @@ -0,0 +1,156 @@ +module ActiveRecord + module Associations + module NestedHasManyThrough + def self.included(klass) + klass.alias_method_chain :construct_conditions, :nesting + klass.alias_method_chain :construct_joins, :nesting + end + + def construct_joins_with_nesting(custom_joins = nil) + if nested? + @nested_join_attributes ||= construct_nested_join_attributes + "#{construct_nested_join_attributes[:joins]} #{@reflection.options[:joins]} #{custom_joins}" + else + construct_joins_without_nesting(custom_joins) + end + end + + def construct_conditions_with_nesting + if nested? + @nested_join_attributes ||= construct_nested_join_attributes + if @reflection.through_reflection && @reflection.through_reflection.macro == :belongs_to + "#{@nested_join_attributes[:remote_key]} = #{belongs_to_quoted_key} #{@nested_join_attributes[:conditions]}" + else + "#{@nested_join_attributes[:remote_key]} = #{@owner.quoted_id} #{@nested_join_attributes[:conditions]}" + end + else + construct_conditions_without_nesting + end + end + + protected + + # Given any belongs_to or has_many (including has_many :through) association, + # return the essential components of a join corresponding to that association, namely: + # + # * :joins: any additional joins required to get from the association's table + # (reflection.table_name) to the table that's actually joining to the active record's table + # * :remote_key: the name of the key in the join table (qualified by table name) which will join + # to a field of the active record's table + # * :local_key: the name of the key in the local table (not qualified by table name) which will + # take part in the join + # * :conditions: any additional conditions (e.g. filtering by type for a polymorphic association, + # or a :conditions clause explicitly given in the association), including a leading AND + def construct_nested_join_attributes(reflection = @reflection, association_class = reflection.klass, table_ids = {association_class.table_name => 1}) + if (reflection.macro == :has_many || reflection.macro == :has_one) && reflection.through_reflection + construct_has_many_through_attributes(reflection, table_ids) + else + construct_has_many_or_belongs_to_attributes(reflection, association_class, table_ids) + end + end + + def construct_has_many_through_attributes(reflection, table_ids) + # Construct the join components of the source association, so that we have a path from + # the eventual target table of the association up to the table named in :through, and + # all tables involved are allocated table IDs. + source_attrs = construct_nested_join_attributes(reflection.source_reflection, reflection.klass, table_ids) + + # Determine the alias of the :through table; this will be the last table assigned + # when constructing the source join components above. + through_table_alias = through_table_name = reflection.through_reflection.table_name + through_table_alias += "_#{table_ids[through_table_name]}" unless table_ids[through_table_name] == 1 + + # Construct the join components of the through association, so that we have a path to + # the active record's table. + through_attrs = construct_nested_join_attributes(reflection.through_reflection, reflection.through_reflection.klass, table_ids) + + # Any subsequent joins / filters on owner attributes will act on the through association, + # so that's what we return for the conditions/keys of the overall association. + conditions = through_attrs[:conditions] + conditions += " AND #{interpolate_sql(reflection.klass.send(:sanitize_sql, reflection.options[:conditions]))}" if reflection.options[:conditions] + + { + :joins => "%s INNER JOIN %s ON ( %s = %s.%s %s) %s %s" % [ + source_attrs[:joins], + through_table_name == through_table_alias ? through_table_name : "#{through_table_name} #{through_table_alias}", + source_attrs[:remote_key], + through_table_alias, source_attrs[:local_key], + source_attrs[:conditions], + through_attrs[:joins], + reflection.options[:joins] + ], + :remote_key => through_attrs[:remote_key], + :local_key => through_attrs[:local_key], + :conditions => conditions + } + end + + # reflection is not has_many :through; it's a standard has_many / belongs_to instead + # TODO: see if we can defer to rails code here a bit more + def construct_has_many_or_belongs_to_attributes(reflection, association_class, table_ids) + # Determine the alias used for remote_table_name, if any. In all cases this will already + # have been assigned an ID in table_ids (either through being involved in a previous join, + # or - if it's the first table in the query - as the default value of table_ids) + remote_table_alias = remote_table_name = association_class.table_name + remote_table_alias += "_#{table_ids[remote_table_name]}" unless table_ids[remote_table_name] == 1 + + # Assign a new alias for the local table. + local_table_alias = local_table_name = reflection.active_record.table_name + if table_ids[local_table_name] + table_id = table_ids[local_table_name] += 1 + local_table_alias += "_#{table_id}" + else + table_ids[local_table_name] = 1 + end + + conditions = '' + # Add type_condition, if applicable + conditions += " AND #{association_class.send(:type_condition).to_sql}" if association_class.finder_needs_type_condition? + # Add custom conditions + conditions += " AND (#{interpolate_sql(association_class.send(:sanitize_sql, reflection.options[:conditions]))})" if reflection.options[:conditions] + + if reflection.macro == :belongs_to + if reflection.options[:polymorphic] + conditions += " AND #{local_table_alias}.#{reflection.options[:foreign_type]} = #{reflection.active_record.quote_value(association_class.base_class.name.to_s)}" + end + { + :joins => reflection.options[:joins], + :remote_key => "#{remote_table_alias}.#{association_class.primary_key}", + :local_key => reflection.primary_key_name, + :conditions => conditions + } + else + # Association is has_many (without :through) + if reflection.options[:as] + conditions += " AND #{remote_table_alias}.#{reflection.options[:as]}_type = #{reflection.active_record.quote_value(reflection.active_record.base_class.name.to_s)}" + end + { + :joins => "#{reflection.options[:joins]}", + :remote_key => "#{remote_table_alias}.#{reflection.primary_key_name}", + :local_key => reflection.klass.primary_key, + :conditions => conditions + } + end + end + + def belongs_to_quoted_key + attribute = @reflection.through_reflection.primary_key_name + column = @owner.column_for_attribute attribute + + @owner.send(:quote_value, @owner.send(attribute), column) + end + + def nested? + through_source_reflection? || through_through_reflection? + end + + def through_source_reflection? + @reflection.source_reflection && @reflection.source_reflection.options[:through] + end + + def through_through_reflection? + @reflection.through_reflection && @reflection.through_reflection.options[:through] + end + end + end +end diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index db18fb7c0f..ae90d30b42 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -378,9 +378,9 @@ module ActiveRecord raise HasManyThroughAssociationPolymorphicError.new(active_record.name, self, source_reflection) end - unless [:belongs_to, :has_many, :has_one].include?(source_reflection.macro) && source_reflection.options[:through].nil? - raise HasManyThroughSourceAssociationMacroError.new(self) - end + # unless [:belongs_to, :has_many, :has_one].include?(source_reflection.macro) && source_reflection.options[:through].nil? + # raise HasManyThroughSourceAssociationMacroError.new(self) + # end check_validity_of_inverse! end diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index f131dc01f6..0b1a3db1e4 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -394,14 +394,6 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase end end - def test_has_many_through_has_many_through - assert_raise(ActiveRecord::HasManyThroughSourceAssociationMacroError) { authors(:david).tags } - end - - def test_has_many_through_habtm - assert_raise(ActiveRecord::HasManyThroughSourceAssociationMacroError) { authors(:david).post_categories } - end - def test_eager_load_has_many_through_has_many author = Author.find :first, :conditions => ['name = ?', 'David'], :include => :comments, :order => 'comments.id' SpecialComment.new; VerySpecialComment.new diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb new file mode 100644 index 0000000000..36de709ffc --- /dev/null +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -0,0 +1,43 @@ +require "cases/helper" +require 'models/author' +require 'models/post' +require 'models/person' +require 'models/reference' +require 'models/job' +require 'models/reader' +require 'models/comment' +require 'models/tag' +require 'models/tagging' +require 'models/owner' +require 'models/pet' +require 'models/toy' +require 'models/contract' +require 'models/company' +require 'models/developer' +require 'models/subscriber' +require 'models/book' +require 'models/subscription' + +class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase + fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings + + def test_has_many_through_a_has_many_through_association_on_source_reflection + author = authors(:david) + assert_equal [tags(:general), tags(:general)], author.tags + end + + def test_has_many_through_a_has_many_through_association_on_through_reflection + author = authors(:david) + assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], author.subscribers + end + + def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection + author = authors(:david) + assert_equal [tags(:general)], author.distinct_tags + end + + def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection + author = authors(:david) + assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers + end +end \ No newline at end of file diff --git a/activerecord/test/fixtures/books.yml b/activerecord/test/fixtures/books.yml index 473663ff5b..fb48645456 100644 --- a/activerecord/test/fixtures/books.yml +++ b/activerecord/test/fixtures/books.yml @@ -1,7 +1,9 @@ awdr: + author_id: 1 id: 1 name: "Agile Web Development with Rails" rfr: + author_id: 1 id: 2 name: "Ruby for Rails" diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 34bfd2d881..94810e2f34 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -83,14 +83,20 @@ class Author < ActiveRecord::Base has_many :author_favorites has_many :favorite_authors, :through => :author_favorites, :order => 'name' - has_many :tagging, :through => :posts # through polymorphic has_one - has_many :taggings, :through => :posts, :source => :taggings # through polymorphic has_many - has_many :tags, :through => :posts # through has_many :through + has_many :tagging, :through => :posts # through polymorphic has_one + has_many :taggings, :through => :posts, :source => :taggings # through polymorphic has_many + has_many :tags, :through => :posts # through has_many :through (on source reflection + polymorphic) + has_many :distinct_tags, :through => :posts, :source => :tags, :select => "DISTINCT tags.*", :order => "tags.name" has_many :post_categories, :through => :posts, :source => :categories + has_many :books + has_many :subscriptions, :through => :books + has_many :subscribers, :through => :subscriptions # through has_many :through (on through reflection) + has_many :distinct_subscribers, :through => :subscriptions, :source => :subscriber, :select => "DISTINCT subscribers.*", :order => "subscribers.nick" + has_one :essay, :primary_key => :name, :as => :writer - belongs_to :author_address, :dependent => :destroy + belongs_to :author_address, :dependent => :destroy belongs_to :author_address_extra, :dependent => :delete, :class_name => "AuthorAddress" scope :relation_include_posts, includes(:posts) diff --git a/activerecord/test/models/book.rb b/activerecord/test/models/book.rb index 1e030b4f59..d27d0af77c 100644 --- a/activerecord/test/models/book.rb +++ b/activerecord/test/models/book.rb @@ -1,4 +1,6 @@ class Book < ActiveRecord::Base + has_many :authors + has_many :citations, :foreign_key => 'book1_id' has_many :references, :through => :citations, :source => :reference_of, :uniq => true diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index ea62833d81..dbd5da45eb 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -71,6 +71,7 @@ ActiveRecord::Schema.define do end create_table :books, :force => true do |t| + t.integer :author_id t.column :name, :string end -- cgit v1.2.3 From 14c4881f9c7bf4eae61e548542ee309c013e1fca Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 30 Sep 2010 22:01:03 +0100 Subject: Prevent test_has_many_through_a_has_many_through_association_on_through_reflection failing for me due to ordering of the results --- activerecord/test/models/author.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 94810e2f34..1efb4fc095 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -84,14 +84,14 @@ class Author < ActiveRecord::Base has_many :favorite_authors, :through => :author_favorites, :order => 'name' has_many :tagging, :through => :posts # through polymorphic has_one - has_many :taggings, :through => :posts, :source => :taggings # through polymorphic has_many + has_many :taggings, :through => :posts, :source => :taggings # through polymorphic has_many TODO: Why is the :source needed? has_many :tags, :through => :posts # through has_many :through (on source reflection + polymorphic) has_many :distinct_tags, :through => :posts, :source => :tags, :select => "DISTINCT tags.*", :order => "tags.name" has_many :post_categories, :through => :posts, :source => :categories has_many :books has_many :subscriptions, :through => :books - has_many :subscribers, :through => :subscriptions # through has_many :through (on through reflection) + has_many :subscribers, :through => :subscriptions, :order => "subscribers.nick" # through has_many :through (on through reflection) has_many :distinct_subscribers, :through => :subscriptions, :source => :subscriber, :select => "DISTINCT subscribers.*", :order => "subscribers.nick" has_one :essay, :primary_key => :name, :as => :writer -- cgit v1.2.3 From 4f69a61107d9d59f96bf249ef077483e90babe72 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 1 Oct 2010 13:10:41 +0100 Subject: Started implementing nested :through associations by using the existing structure of ThroughAssociationScope rather than layering a module over the top --- .../associations/has_many_through_association.rb | 2 +- .../associations/through_association_scope.rb | 49 ++++++++++++++-------- activerecord/lib/active_record/reflection.rb | 15 +++++++ .../nested_has_many_through_associations_test.rb | 26 ++++++------ 4 files changed, 60 insertions(+), 32 deletions(-) diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 964c381c0d..ee892d373c 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -7,7 +7,7 @@ module ActiveRecord module Associations class HasManyThroughAssociation < HasManyAssociation #:nodoc: include ThroughAssociationScope - include NestedHasManyThrough + # include NestedHasManyThrough alias_method :new, :build diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index cabb33c4a8..c433c9e66e 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -19,8 +19,8 @@ module ActiveRecord # Build SQL conditions from attributes, qualified by table name. def construct_conditions - table_name = @reflection.through_reflection.quoted_table_name - conditions = construct_quoted_owner_attributes(@reflection.through_reflection).map do |attr, value| + table_name = @reflection.final_through_reflection.quoted_table_name + conditions = construct_quoted_owner_attributes(@reflection.final_through_reflection).map do |attr, value| "#{table_name}.#{attr} = #{value}" end conditions << sql_conditions if sql_conditions @@ -49,35 +49,48 @@ module ActiveRecord distinct = "DISTINCT " if @reflection.options[:uniq] selected = custom_select || @reflection.options[:select] || "#{distinct}#{@reflection.quoted_table_name}.*" end - + def construct_joins(custom_joins = nil) + "#{construct_through_joins(@reflection)} #{@reflection.options[:joins]} #{custom_joins}" + end + + def construct_through_joins(reflection) polymorphic_join = nil - if @reflection.source_reflection.macro == :belongs_to - reflection_primary_key = @reflection.klass.primary_key - source_primary_key = @reflection.source_reflection.primary_key_name - if @reflection.options[:source_type] + if reflection.source_reflection.macro == :belongs_to + reflection_primary_key = reflection.klass.primary_key + source_primary_key = reflection.source_reflection.primary_key_name + if reflection.options[:source_type] polymorphic_join = "AND %s.%s = %s" % [ - @reflection.through_reflection.quoted_table_name, "#{@reflection.source_reflection.options[:foreign_type]}", - @owner.class.quote_value(@reflection.options[:source_type]) + reflection.through_reflection.quoted_table_name, "#{@reflection.source_reflection.options[:foreign_type]}", + @owner.class.quote_value(reflection.options[:source_type]) ] end else - reflection_primary_key = @reflection.source_reflection.primary_key_name - source_primary_key = @reflection.through_reflection.klass.primary_key - if @reflection.source_reflection.options[:as] + reflection_primary_key = reflection.source_reflection.primary_key_name + source_primary_key = reflection.through_reflection.klass.primary_key + if reflection.source_reflection.options[:as] polymorphic_join = "AND %s.%s = %s" % [ - @reflection.quoted_table_name, "#{@reflection.source_reflection.options[:as]}_type", - @owner.class.quote_value(@reflection.through_reflection.klass.name) + reflection.quoted_table_name, "#{@reflection.source_reflection.options[:as]}_type", + @owner.class.quote_value(reflection.through_reflection.klass.name) ] end end - "INNER JOIN %s ON %s.%s = %s.%s %s #{@reflection.options[:joins]} #{custom_joins}" % [ - @reflection.through_reflection.quoted_table_name, - @reflection.quoted_table_name, reflection_primary_key, - @reflection.through_reflection.quoted_table_name, source_primary_key, + joins = "INNER JOIN %s ON %s.%s = %s.%s %s" % [ + reflection.through_reflection.quoted_table_name, + reflection.quoted_table_name, reflection_primary_key, + reflection.through_reflection.quoted_table_name, source_primary_key, polymorphic_join ] + + # If the reflection we are going :through goes itself :through another reflection, then + # we must recursively get the joins to make that happen too. + if reflection.through_reflection.through_reflection + joins << " " + joins << construct_through_joins(reflection.through_reflection) + end + + joins end # Construct attributes for associate pointing to owner. diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index ae90d30b42..888ddcdd5b 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -352,6 +352,21 @@ module ActiveRecord def through_reflection @through_reflection ||= active_record.reflect_on_association(options[:through]) end + + # A :through reflection may have a :through reflection itself. This method returns the through + # reflection which is furthest away, i.e. the last in the chain, so the first which does not + # have its own :through reflection. + def final_through_reflection + @final_through_reflection ||= begin + reflection = through_reflection + + while reflection.through_reflection + reflection = reflection.through_reflection + end + + reflection + end + end # Gets an array of possible :through source reflection names: # diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 36de709ffc..539e6e000a 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -21,23 +21,23 @@ require 'models/subscription' class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings - def test_has_many_through_a_has_many_through_association_on_source_reflection - author = authors(:david) - assert_equal [tags(:general), tags(:general)], author.tags - end +# def test_has_many_through_a_has_many_through_association_on_source_reflection +# author = authors(:david) +# assert_equal [tags(:general), tags(:general)], author.tags +# end def test_has_many_through_a_has_many_through_association_on_through_reflection author = authors(:david) assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], author.subscribers end - def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection - author = authors(:david) - assert_equal [tags(:general)], author.distinct_tags - end +# def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection +# author = authors(:david) +# assert_equal [tags(:general)], author.distinct_tags +# end - def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection - author = authors(:david) - assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers - end -end \ No newline at end of file +# def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection +# author = authors(:david) +# assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers +# end +end -- cgit v1.2.3 From 34ee586e993ad9e466b81f376fa92feb5d312b4c Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 2 Oct 2010 18:50:17 +0100 Subject: Integrate nested support into ThroughAssociationScope, using my concept of generating a 'chain' of reflections to be joined. It seems to work at the moment, all existing tests are passing. There may be further complications as we add more test cases for nested associations, though. --- .../associations/has_many_through_association.rb | 1 - .../associations/nested_has_many_through.rb | 2 + .../associations/through_association_scope.rb | 89 +++++++++++++--------- activerecord/lib/active_record/reflection.rb | 50 +++++++++--- .../nested_has_many_through_associations_test.rb | 24 +++--- 5 files changed, 107 insertions(+), 59 deletions(-) diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index ee892d373c..313d9da621 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -7,7 +7,6 @@ module ActiveRecord module Associations class HasManyThroughAssociation < HasManyAssociation #:nodoc: include ThroughAssociationScope - # include NestedHasManyThrough alias_method :new, :build diff --git a/activerecord/lib/active_record/associations/nested_has_many_through.rb b/activerecord/lib/active_record/associations/nested_has_many_through.rb index 2d03b81128..d699a60edb 100644 --- a/activerecord/lib/active_record/associations/nested_has_many_through.rb +++ b/activerecord/lib/active_record/associations/nested_has_many_through.rb @@ -1,3 +1,5 @@ +# TODO: Remove in the end, when its functionality is fully integrated in ThroughAssociationScope. + module ActiveRecord module Associations module NestedHasManyThrough diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index c433c9e66e..c5453fa79f 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -1,3 +1,5 @@ +require 'enumerator' + module ActiveRecord # = Active Record Through Association Scope module Associations @@ -19,8 +21,9 @@ module ActiveRecord # Build SQL conditions from attributes, qualified by table name. def construct_conditions - table_name = @reflection.final_through_reflection.quoted_table_name - conditions = construct_quoted_owner_attributes(@reflection.final_through_reflection).map do |attr, value| + reflection = @reflection.through_reflection_chain.last + table_name = reflection.quoted_table_name + conditions = construct_quoted_owner_attributes(reflection).map do |attr, value| "#{table_name}.#{attr} = #{value}" end conditions << sql_conditions if sql_conditions @@ -51,43 +54,57 @@ module ActiveRecord end def construct_joins(custom_joins = nil) - "#{construct_through_joins(@reflection)} #{@reflection.options[:joins]} #{custom_joins}" + # puts @reflection.through_reflection_chain.map(&:inspect) + + "#{construct_through_joins} #{@reflection.options[:joins]} #{custom_joins}" end - def construct_through_joins(reflection) - polymorphic_join = nil - if reflection.source_reflection.macro == :belongs_to - reflection_primary_key = reflection.klass.primary_key - source_primary_key = reflection.source_reflection.primary_key_name - if reflection.options[:source_type] - polymorphic_join = "AND %s.%s = %s" % [ - reflection.through_reflection.quoted_table_name, "#{@reflection.source_reflection.options[:foreign_type]}", - @owner.class.quote_value(reflection.options[:source_type]) - ] - end - else - reflection_primary_key = reflection.source_reflection.primary_key_name - source_primary_key = reflection.through_reflection.klass.primary_key - if reflection.source_reflection.options[:as] - polymorphic_join = "AND %s.%s = %s" % [ - reflection.quoted_table_name, "#{@reflection.source_reflection.options[:as]}_type", - @owner.class.quote_value(reflection.through_reflection.klass.name) - ] - end - end - - joins = "INNER JOIN %s ON %s.%s = %s.%s %s" % [ - reflection.through_reflection.quoted_table_name, - reflection.quoted_table_name, reflection_primary_key, - reflection.through_reflection.quoted_table_name, source_primary_key, - polymorphic_join - ] + def construct_through_joins + joins = [] - # If the reflection we are going :through goes itself :through another reflection, then - # we must recursively get the joins to make that happen too. - if reflection.through_reflection.through_reflection - joins << " " - joins << construct_through_joins(reflection.through_reflection) + # Iterate over each pair in the through reflection chain, joining them together + @reflection.through_reflection_chain.each_cons(2) do |left, right| + polymorphic_join = nil + + case + when left.options[:as] + left_primary_key = left.primary_key_name + right_primary_key = right.klass.primary_key + + polymorphic_join = "AND %s.%s = %s" % [ + left.quoted_table_name, "#{left.options[:as]}_type", + @owner.class.quote_value(right.klass.name) + ] + when left.source_reflection.macro == :belongs_to + left_primary_key = left.klass.primary_key + right_primary_key = left.source_reflection.primary_key_name + + if left.options[:source_type] + polymorphic_join = "AND %s.%s = %s" % [ + right.quoted_table_name, + left.source_reflection.options[:foreign_type].to_s, + @owner.class.quote_value(left.options[:source_type]) + ] + end + else + left_primary_key = left.source_reflection.primary_key_name + right_primary_key = right.klass.primary_key + + if left.source_reflection.options[:as] + polymorphic_join = "AND %s.%s = %s" % [ + left.quoted_table_name, + "#{left.source_reflection.options[:as]}_type", + @owner.class.quote_value(right.klass.name) + ] + end + end + + joins << "INNER JOIN %s ON %s.%s = %s.%s %s" % [ + right.quoted_table_name, + left.quoted_table_name, left_primary_key, + right.quoted_table_name, right_primary_key, + polymorphic_join + ] end joins diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 888ddcdd5b..b7cd466e13 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -131,6 +131,14 @@ module ActiveRecord @sanitized_conditions ||= klass.send(:sanitize_sql, options[:conditions]) if options[:conditions] end + # TODO: Remove these in the final patch. I am just using them for debugging etc. + def inspect + "#<#{code_name}>" + end + def code_name + "#{active_record.name}.#{macro} :#{name}" + end + private def derive_class_name name.to_s.camelize @@ -241,6 +249,10 @@ module ActiveRecord def through_reflection false end + + def through_reflection_chain + [self] + end def through_reflection_primary_key_name end @@ -304,6 +316,16 @@ module ActiveRecord def belongs_to? macro == :belongs_to end + + # TODO: Remove for final patch. Just here for debugging. + def inspect + str = "#<#{code_name}, @source_reflection=" + str << (source_reflection.respond_to?(:code_name) ? source_reflection.code_name : source_reflection.inspect) + str << ", @through_reflection=" + str << (through_reflection.respond_to?(:code_name) ? through_reflection.code_name : through_reflection.inspect) + str << ">" + str + end private def derive_class_name @@ -353,18 +375,24 @@ module ActiveRecord @through_reflection ||= active_record.reflect_on_association(options[:through]) end - # A :through reflection may have a :through reflection itself. This method returns the through - # reflection which is furthest away, i.e. the last in the chain, so the first which does not - # have its own :through reflection. - def final_through_reflection - @final_through_reflection ||= begin - reflection = through_reflection - - while reflection.through_reflection - reflection = reflection.through_reflection + # TODO: Documentation + def through_reflection_chain + @through_reflection_chain ||= begin + if source_reflection.through_reflection + # If the source reflection goes through another reflection, then the chain must start + # by getting us to the source reflection. + chain = source_reflection.through_reflection_chain + else + # If the source reflection does not go through another reflection, then we can get + # to this reflection directly, and so start the chain here + chain = [self] end - reflection + # Recursively build the rest of the chain + chain += through_reflection.through_reflection_chain + + # Finally return the completed chain + chain end end @@ -393,6 +421,8 @@ module ActiveRecord raise HasManyThroughAssociationPolymorphicError.new(active_record.name, self, source_reflection) end + # TODO: Presumably remove the HasManyThroughSourceAssociationMacroError class and delete these lines. + # Think about whether there are any cases which should still be disallowed. # unless [:belongs_to, :has_many, :has_one].include?(source_reflection.macro) && source_reflection.options[:through].nil? # raise HasManyThroughSourceAssociationMacroError.new(self) # end diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 539e6e000a..938643b1b3 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -21,23 +21,23 @@ require 'models/subscription' class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings -# def test_has_many_through_a_has_many_through_association_on_source_reflection -# author = authors(:david) -# assert_equal [tags(:general), tags(:general)], author.tags -# end + def test_has_many_through_a_has_many_through_association_on_source_reflection + author = authors(:david) + assert_equal [tags(:general), tags(:general)], author.tags + end def test_has_many_through_a_has_many_through_association_on_through_reflection author = authors(:david) assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], author.subscribers end -# def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection -# author = authors(:david) -# assert_equal [tags(:general)], author.distinct_tags -# end + def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection + author = authors(:david) + assert_equal [tags(:general)], author.distinct_tags + end -# def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection -# author = authors(:david) -# assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers -# end + def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection + author = authors(:david) + assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers + end end -- cgit v1.2.3 From a34391c3b495bad268204bdf4f6b3483a61abcd5 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 2 Oct 2010 21:45:46 +0100 Subject: Add support for table aliasing, with a test that needs aliasing in order to work correctly. This test incidentally provides a more complicated test case (4 inner joins, 2 using polymorphism). --- .../associations/through_association_scope.rb | 50 ++++++++++++++++------ .../nested_has_many_through_associations_test.rb | 5 +++ activerecord/test/fixtures/authors.yml | 4 ++ activerecord/test/fixtures/posts.yml | 14 ++++++ activerecord/test/fixtures/taggings.yml | 12 ++++++ activerecord/test/fixtures/tags.yml | 2 +- activerecord/test/models/author.rb | 3 +- 7 files changed, 75 insertions(+), 15 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index c5453fa79f..90ebadda89 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -22,9 +22,8 @@ module ActiveRecord # Build SQL conditions from attributes, qualified by table name. def construct_conditions reflection = @reflection.through_reflection_chain.last - table_name = reflection.quoted_table_name conditions = construct_quoted_owner_attributes(reflection).map do |attr, value| - "#{table_name}.#{attr} = #{value}" + "#{table_aliases[reflection]}.#{attr} = #{value}" end conditions << sql_conditions if sql_conditions "(" + conditions.join(') AND (') + ")" @@ -67,21 +66,23 @@ module ActiveRecord polymorphic_join = nil case - when left.options[:as] + when left.source_reflection.nil? left_primary_key = left.primary_key_name right_primary_key = right.klass.primary_key - polymorphic_join = "AND %s.%s = %s" % [ - left.quoted_table_name, "#{left.options[:as]}_type", - @owner.class.quote_value(right.klass.name) - ] + if left.options[:as] + polymorphic_join = "AND %s.%s = %s" % [ + table_aliases[left], "#{left.options[:as]}_type", + @owner.class.quote_value(right.klass.name) + ] + end when left.source_reflection.macro == :belongs_to left_primary_key = left.klass.primary_key right_primary_key = left.source_reflection.primary_key_name if left.options[:source_type] polymorphic_join = "AND %s.%s = %s" % [ - right.quoted_table_name, + table_aliases[right], left.source_reflection.options[:foreign_type].to_s, @owner.class.quote_value(left.options[:source_type]) ] @@ -92,22 +93,45 @@ module ActiveRecord if left.source_reflection.options[:as] polymorphic_join = "AND %s.%s = %s" % [ - left.quoted_table_name, + table_aliases[left], "#{left.source_reflection.options[:as]}_type", @owner.class.quote_value(right.klass.name) ] end end + if right.quoted_table_name == table_aliases[right] + table = right.quoted_table_name + else + table = "#{right.quoted_table_name} #{table_aliases[right]}" + end + joins << "INNER JOIN %s ON %s.%s = %s.%s %s" % [ - right.quoted_table_name, - left.quoted_table_name, left_primary_key, - right.quoted_table_name, right_primary_key, + table, + table_aliases[left], left_primary_key, + table_aliases[right], right_primary_key, polymorphic_join ] end - joins + joins.join(" ") + end + + def table_aliases + @table_aliases ||= begin + tally = {} + @reflection.through_reflection_chain.inject({}) do |aliases, reflection| + if tally[reflection.table_name].nil? + tally[reflection.table_name] = 1 + aliases[reflection] = reflection.quoted_table_name + else + tally[reflection.table_name] += 1 + aliased_table_name = reflection.table_name + "_#{tally[reflection.table_name]}" + aliases[reflection] = reflection.klass.connection.quote_table_name(aliased_table_name) + end + aliases + end + end end # Construct attributes for associate pointing to owner. diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 938643b1b3..925a9598fb 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -40,4 +40,9 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase author = authors(:david) assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers end + + def test_nested_has_many_through_with_a_table_referenced_multiple_times + author = authors(:bob) + assert_equal [posts(:misc_by_bob), posts(:misc_by_mary)], author.similar_posts.sort_by(&:id) + end end diff --git a/activerecord/test/fixtures/authors.yml b/activerecord/test/fixtures/authors.yml index de2ec7d38b..6f13ec4dac 100644 --- a/activerecord/test/fixtures/authors.yml +++ b/activerecord/test/fixtures/authors.yml @@ -7,3 +7,7 @@ david: mary: id: 2 name: Mary + +bob: + id: 3 + name: Bob diff --git a/activerecord/test/fixtures/posts.yml b/activerecord/test/fixtures/posts.yml index f817493190..ca6d4c2fe1 100644 --- a/activerecord/test/fixtures/posts.yml +++ b/activerecord/test/fixtures/posts.yml @@ -50,3 +50,17 @@ eager_other: title: eager loading with OR'd conditions body: hello type: Post + +misc_by_bob: + id: 8 + author_id: 3 + title: misc post by bob + body: hello + type: Post + +misc_by_mary: + id: 9 + author_id: 2 + title: misc post by mary + body: hello + type: Post diff --git a/activerecord/test/fixtures/taggings.yml b/activerecord/test/fixtures/taggings.yml index 3db6a4c079..7cc7198ded 100644 --- a/activerecord/test/fixtures/taggings.yml +++ b/activerecord/test/fixtures/taggings.yml @@ -26,3 +26,15 @@ godfather: orphaned: id: 5 tag_id: 1 + +misc_post_by_bob: + id: 6 + tag_id: 2 + taggable_id: 8 + taggable_type: Post + +misc_post_by_mary: + id: 7 + tag_id: 2 + taggable_id: 9 + taggable_type: Post diff --git a/activerecord/test/fixtures/tags.yml b/activerecord/test/fixtures/tags.yml index 7610fd38b9..6cb886dc46 100644 --- a/activerecord/test/fixtures/tags.yml +++ b/activerecord/test/fixtures/tags.yml @@ -4,4 +4,4 @@ general: misc: id: 2 - name: Misc \ No newline at end of file + name: Misc diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 1efb4fc095..1fbd729b60 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -84,8 +84,9 @@ class Author < ActiveRecord::Base has_many :favorite_authors, :through => :author_favorites, :order => 'name' has_many :tagging, :through => :posts # through polymorphic has_one - has_many :taggings, :through => :posts, :source => :taggings # through polymorphic has_many TODO: Why is the :source needed? + has_many :taggings, :through => :posts # through polymorphic has_many has_many :tags, :through => :posts # through has_many :through (on source reflection + polymorphic) + has_many :similar_posts, :through => :tags, :source => :tagged_posts has_many :distinct_tags, :through => :posts, :source => :tags, :select => "DISTINCT tags.*", :order => "tags.name" has_many :post_categories, :through => :posts, :source => :categories -- cgit v1.2.3 From 43711083dd34252877bab9df43d3db0fd42feeb2 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 3 Oct 2010 11:56:32 +0100 Subject: Fix the tests (I have actually verified that these are also the 'right' fixes, rather than just making the tests pass again) --- .../associations/cascaded_eager_loading_test.rb | 18 +++++++++--------- activerecord/test/cases/associations/eager_test.rb | 4 ++-- activerecord/test/cases/batches_test.rb | 2 +- activerecord/test/cases/finder_test.rb | 2 +- activerecord/test/cases/json_serialization_test.rb | 2 +- activerecord/test/cases/relations_test.rb | 20 ++++++++++---------- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index b93e49613d..8bb8f3e359 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -13,17 +13,17 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase def test_eager_association_loading_with_cascaded_two_levels authors = Author.find(:all, :include=>{:posts=>:comments}, :order=>"authors.id") - assert_equal 2, authors.size + assert_equal 3, authors.size assert_equal 5, authors[0].posts.size - assert_equal 1, authors[1].posts.size + assert_equal 2, authors[1].posts.size assert_equal 9, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} end def test_eager_association_loading_with_cascaded_two_levels_and_one_level authors = Author.find(:all, :include=>[{:posts=>:comments}, :categorizations], :order=>"authors.id") - assert_equal 2, authors.size + assert_equal 3, authors.size assert_equal 5, authors[0].posts.size - assert_equal 1, authors[1].posts.size + assert_equal 2, authors[1].posts.size assert_equal 9, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} assert_equal 1, authors[0].categorizations.size assert_equal 2, authors[1].categorizations.size @@ -54,15 +54,15 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase def test_eager_association_loading_with_cascaded_two_levels_with_two_has_many_associations authors = Author.find(:all, :include=>{:posts=>[:comments, :categorizations]}, :order=>"authors.id") - assert_equal 2, authors.size + assert_equal 3, authors.size assert_equal 5, authors[0].posts.size - assert_equal 1, authors[1].posts.size + assert_equal 2, authors[1].posts.size assert_equal 9, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} end def test_eager_association_loading_with_cascaded_two_levels_and_self_table_reference authors = Author.find(:all, :include=>{:posts=>[:comments, :author]}, :order=>"authors.id") - assert_equal 2, authors.size + assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal authors(:david).name, authors[0].name assert_equal [authors(:david).name], authors[0].posts.collect{|post| post.author.name}.uniq @@ -130,9 +130,9 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase def test_eager_association_loading_where_first_level_returns_nil authors = Author.find(:all, :include => {:post_about_thinking => :comments}, :order => 'authors.id DESC') - assert_equal [authors(:mary), authors(:david)], authors + assert_equal [authors(:bob), authors(:mary), authors(:david)], authors assert_no_queries do - authors[1].post_about_thinking.comments.first + authors[2].post_about_thinking.comments.first end end end diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index 40859d425f..1669c4d5f4 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -53,8 +53,8 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_with_ordering list = Post.find(:all, :include => :comments, :order => "posts.id DESC") - [:eager_other, :sti_habtm, :sti_post_and_comments, :sti_comments, - :authorless, :thinking, :welcome + [:misc_by_mary, :misc_by_bob, :eager_other, :sti_habtm, :sti_post_and_comments, + :sti_comments, :authorless, :thinking, :welcome ].each_with_index do |post, index| assert_equal posts(post), list[index] end diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb index dcc49e12ca..70883ad30f 100644 --- a/activerecord/test/cases/batches_test.rb +++ b/activerecord/test/cases/batches_test.rb @@ -24,7 +24,7 @@ class EachTest < ActiveRecord::TestCase end def test_each_should_execute_if_id_is_in_select - assert_queries(4) do + assert_queries(5) do Post.find_each(:select => "id, title, type", :batch_size => 2) do |post| assert_kind_of Post, post end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 26b5096255..e73f58fdc7 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -127,7 +127,7 @@ class FinderTest < ActiveRecord::TestCase assert_equal [[0,3],[1,1],[1,2]], first_three_posts.map { |p| [p.author_id, p.id] } assert_equal [[1,4],[1,5],[1,6]], second_three_posts.map { |p| [p.author_id, p.id] } - assert_equal [[2,7]], last_posts.map { |p| [p.author_id, p.id] } + assert_equal [[2,7],[2,9],[3,8]], last_posts.map { |p| [p.author_id, p.id] } end diff --git a/activerecord/test/cases/json_serialization_test.rb b/activerecord/test/cases/json_serialization_test.rb index 5da7f9e1b9..430be003ac 100644 --- a/activerecord/test/cases/json_serialization_test.rb +++ b/activerecord/test/cases/json_serialization_test.rb @@ -196,7 +196,7 @@ class DatabaseConnectedJsonEncodingTest < ActiveRecord::TestCase ) ['"name":"David"', '"posts":[', '{"id":1}', '{"id":2}', '{"id":4}', - '{"id":5}', '{"id":6}', '"name":"Mary"', '"posts":[{"id":7}]'].each do |fragment| + '{"id":5}', '{"id":6}', '"name":"Mary"', '"posts":[', '{"id":7}', '{"id":9}'].each do |fragment| assert json.include?(fragment), json end end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index d642aeed8b..fec5e6731f 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -434,7 +434,7 @@ class RelationTest < ActiveRecord::TestCase def test_last authors = Author.scoped - assert_equal authors(:mary), authors.last + assert_equal authors(:bob), authors.last end def test_destroy_all @@ -507,22 +507,22 @@ class RelationTest < ActiveRecord::TestCase def test_count posts = Post.scoped - assert_equal 7, posts.count - assert_equal 7, posts.count(:all) - assert_equal 7, posts.count(:id) + assert_equal 9, posts.count + assert_equal 9, posts.count(:all) + assert_equal 9, posts.count(:id) assert_equal 1, posts.where('comments_count > 1').count - assert_equal 5, posts.where(:comments_count => 0).count + assert_equal 7, posts.where(:comments_count => 0).count end def test_count_with_distinct posts = Post.scoped assert_equal 3, posts.count(:comments_count, :distinct => true) - assert_equal 7, posts.count(:comments_count, :distinct => false) + assert_equal 9, posts.count(:comments_count, :distinct => false) assert_equal 3, posts.select(:comments_count).count(:distinct => true) - assert_equal 7, posts.select(:comments_count).count(:distinct => false) + assert_equal 9, posts.select(:comments_count).count(:distinct => false) end def test_count_explicit_columns @@ -532,7 +532,7 @@ class RelationTest < ActiveRecord::TestCase assert_equal [0], posts.select('comments_count').where('id is not null').group('id').order('id').count.values.uniq assert_equal 0, posts.where('id is not null').select('comments_count').count - assert_equal 7, posts.select('comments_count').count('id') + assert_equal 9, posts.select('comments_count').count('id') assert_equal 0, posts.select('comments_count').count assert_equal 0, posts.count(:comments_count) assert_equal 0, posts.count('comments_count') @@ -547,12 +547,12 @@ class RelationTest < ActiveRecord::TestCase def test_size posts = Post.scoped - assert_queries(1) { assert_equal 7, posts.size } + assert_queries(1) { assert_equal 9, posts.size } assert ! posts.loaded? best_posts = posts.where(:comments_count => 0) best_posts.to_a # force load - assert_no_queries { assert_equal 5, best_posts.size } + assert_no_queries { assert_equal 7, best_posts.size } end def test_count_complex_chained_relations -- cgit v1.2.3 From 3cc35633872a0072764d8edb20f1fc4e14adf729 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 5 Oct 2010 20:20:27 +0100 Subject: A failing test for a nested has many through association loaded via Foo.joins(:bar) --- .../cases/associations/nested_has_many_through_associations_test.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 925a9598fb..a5d3f27702 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -45,4 +45,10 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase author = authors(:bob) assert_equal [posts(:misc_by_bob), posts(:misc_by_mary)], author.similar_posts.sort_by(&:id) end + + def test_nested_has_many_through_as_a_join + # All authors with subscribers where one of the subscribers' nick is 'alterself' + authors = Author.joins(:subscribers).where('subscribers.nick' => 'alterself') + assert_equal [authors(:david)], authors + end end -- cgit v1.2.3 From f2b41914d6be935182d37e0c0d491352ac3de043 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Wed, 6 Oct 2010 12:06:51 +0100 Subject: Refactoring JoinDependency and friends. This improves the code (IMO) including adding some explanatory comments, but more importantly structures it in such a way as to allow a JoinAssociation to produce an arbitrary number of actual joins, which will be necessary for nested has many through support. Also added 3 tests covering functionality which existed but was not previously covered. --- activerecord/lib/active_record/associations.rb | 490 +++++++++++++-------- .../lib/active_record/relation/finder_methods.rb | 7 +- .../lib/active_record/relation/query_methods.rb | 13 +- .../associations/cascaded_eager_loading_test.rb | 8 +- activerecord/test/cases/associations/eager_test.rb | 8 +- .../associations/inner_join_association_test.rb | 24 +- .../test/cases/associations/join_model_test.rb | 4 +- activerecord/test/cases/finder_test.rb | 2 +- activerecord/test/fixtures/comments.yml | 6 + activerecord/test/models/comment.rb | 3 + 10 files changed, 356 insertions(+), 209 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 812abf5a55..67c204f154 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1834,10 +1834,10 @@ module ActiveRecord end class JoinDependency # :nodoc: - attr_reader :joins, :reflections, :table_aliases + attr_reader :join_parts, :reflections, :table_aliases def initialize(base, associations, joins) - @joins = [JoinBase.new(base, joins)] + @join_parts = [JoinBase.new(base, joins)] @associations = associations @reflections = [] @base_records_hash = {} @@ -1850,17 +1850,17 @@ module ActiveRecord def graft(*associations) associations.each do |association| join_associations.detect {|a| association == a} || - build(association.reflection.name, association.find_parent_in(self) || join_base, association.join_class) + build(association.reflection.name, association.find_parent_in(self) || join_base, association.join_type) end self end def join_associations - @joins.last(@joins.length - 1) + join_parts.last(join_parts.length - 1) end def join_base - @joins[0] + join_parts.first end def count_aliases_from_table_joins(name) @@ -1918,22 +1918,24 @@ module ActiveRecord protected - def build(associations, parent = nil, join_class = Arel::InnerJoin) - parent ||= @joins.last + def build(associations, parent = nil, join_type = Arel::InnerJoin) + parent ||= join_parts.last case associations when Symbol, String reflection = parent.reflections[associations.to_s.intern] or raise ConfigurationError, "Association named '#{ associations }' was not found; perhaps you misspelled it?" @reflections << reflection - @joins << build_join_association(reflection, parent).with_join_class(join_class) + join_association = build_join_association(reflection, parent) + join_association.join_type = join_type + @join_parts << join_association when Array associations.each do |association| - build(association, parent, join_class) + build(association, parent, join_type) end when Hash associations.keys.sort{|a,b|a.to_s<=>b.to_s}.each do |name| - build(name, parent, join_class) - build(associations[name], nil, join_class) + build(name, parent, join_type) + build(associations[name], nil, join_type) end else raise ConfigurationError, associations.inspect @@ -1950,91 +1952,111 @@ module ActiveRecord JoinAssociation.new(reflection, self, parent) end - def construct(parent, associations, joins, row) + def construct(parent, associations, join_parts, row) case associations when Symbol, String - join = joins.detect{|j| j.reflection.name.to_s == associations.to_s && j.parent_table_name == parent.class.table_name } - raise(ConfigurationError, "No such association") if join.nil? + join_part = join_parts.detect { |j| + j.reflection.name.to_s == associations.to_s && + j.parent_table_name == parent.class.table_name } + raise(ConfigurationError, "No such association") if join_part.nil? - joins.delete(join) - construct_association(parent, join, row) + join_parts.delete(join_part) + construct_association(parent, join_part, row) when Array associations.each do |association| - construct(parent, association, joins, row) + construct(parent, association, join_parts, row) end when Hash associations.sort_by { |k,_| k.to_s }.each do |name, assoc| - join = joins.detect{|j| j.reflection.name.to_s == name.to_s && j.parent_table_name == parent.class.table_name } - raise(ConfigurationError, "No such association") if join.nil? - - association = construct_association(parent, join, row) - joins.delete(join) - construct(association, assoc, joins, row) if association + join_part = join_parts.detect{ |j| + j.reflection.name.to_s == name.to_s && + j.parent_table_name == parent.class.table_name } + raise(ConfigurationError, "No such association") if join_part.nil? + + association = construct_association(parent, join_part, row) + join_parts.delete(join_part) + construct(association, assoc, join_parts, row) if association end else raise ConfigurationError, associations.inspect end end - def construct_association(record, join, row) - return if record.id.to_s != join.parent.record_id(row).to_s + def construct_association(record, join_part, row) + return if record.id.to_s != join_part.parent.record_id(row).to_s - macro = join.reflection.macro + macro = join_part.reflection.macro if macro == :has_one - return if record.instance_variable_defined?("@#{join.reflection.name}") - association = join.instantiate(row) unless row[join.aliased_primary_key].nil? - set_target_and_inverse(join, association, record) + return if record.instance_variable_defined?("@#{join_part.reflection.name}") + association = join_part.instantiate(row) unless row[join_part.aliased_primary_key].nil? + set_target_and_inverse(join_part, association, record) else - return if row[join.aliased_primary_key].nil? - association = join.instantiate(row) + return if row[join_part.aliased_primary_key].nil? + association = join_part.instantiate(row) case macro when :has_many, :has_and_belongs_to_many - collection = record.send(join.reflection.name) + collection = record.send(join_part.reflection.name) collection.loaded collection.target.push(association) collection.__send__(:set_inverse_instance, association, record) when :belongs_to - set_target_and_inverse(join, association, record) + set_target_and_inverse(join_part, association, record) else - raise ConfigurationError, "unknown macro: #{join.reflection.macro}" + raise ConfigurationError, "unknown macro: #{join_part.reflection.macro}" end end association end - def set_target_and_inverse(join, association, record) - association_proxy = record.send("set_#{join.reflection.name}_target", association) + def set_target_and_inverse(join_part, association, record) + association_proxy = record.send("set_#{join_part.reflection.name}_target", association) association_proxy.__send__(:set_inverse_instance, association, record) end - - class JoinBase # :nodoc: - attr_reader :active_record, :table_joins - delegate :table_name, :column_names, :primary_key, :reflections, :sanitize_sql, :arel_engine, :to => :active_record - - def initialize(active_record, joins = nil) + + # A JoinPart represents a part of a JoinDependency. It is an abstract class, inherited + # by JoinBase and JoinAssociation. A JoinBase represents the Active Record which + # everything else is being joined onto. A JoinAssociation represents an association which + # is joining to the base. A JoinAssociation may result in more than one actual join + # operations (for example a has_and_belongs_to_many JoinAssociation would result in + # two; one for the join table and one for the target table). + class JoinPart # :nodoc: + # The Active Record class which this join part is associated 'about'; for a JoinBase + # this is the actual base model, for a JoinAssociation this is the target model of the + # association. + attr_reader :active_record + + delegate :table_name, :column_names, :primary_key, :reflections, :sanitize_sql, :arel_engine, :to => :active_record + + def initialize(active_record) @active_record = active_record @cached_record = {} - @table_joins = joins end - + def ==(other) - other.class == self.class && - other.active_record == active_record && - other.table_joins == table_joins + raise NotImplementedError end - + + # An Arel::Table for the active_record + def table + raise NotImplementedError + end + + # The prefix to be used when aliasing columns in the active_record's table def aliased_prefix - "t0" + raise NotImplementedError end - + + # The alias for the active_record's table + def aliased_table_name + raise NotImplementedError + end + + # The alias for the primary key of the active_record's table def aliased_primary_key "#{aliased_prefix}_r0" end - - def aliased_table_name - active_record.table_name - end - + + # An array of [column_name, alias] pairs for the table def column_names_with_alias unless defined?(@column_names_with_alias) @column_names_with_alias = [] @@ -2060,33 +2082,74 @@ module ActiveRecord end end - class JoinAssociation < JoinBase # :nodoc: - attr_reader :reflection, :parent, :aliased_table_name, :aliased_prefix, :aliased_join_table_name, :parent_table_name, :join_class - delegate :options, :klass, :through_reflection, :source_reflection, :to => :reflection + class JoinBase < JoinPart # :nodoc: + # Extra joins provided when the JoinDependency was created + attr_reader :table_joins + + def initialize(active_record, joins = nil) + super(active_record) + @table_joins = joins + end + + def ==(other) + other.class == self.class && + other.active_record == active_record && + other.table_joins == table_joins + end + + def aliased_prefix + "t0" + end + + def table + Arel::Table.new(table_name, :engine => arel_engine, :columns => active_record.columns) + end + + def aliased_table_name + active_record.table_name + end + end + + class JoinAssociation < JoinPart # :nodoc: + # The reflection of the association represented + attr_reader :reflection + + # The JoinDependency object which this JoinAssociation exists within. This is mainly + # relevant for generating aliases which do not conflict with other joins which are + # part of the query. + attr_reader :join_dependency + + # A JoinBase instance representing the active record we are joining onto. + # (So in Author.has_many :posts, the Author would be that base record.) + attr_reader :parent + + # What type of join will be generated, either Arel::InnerJoin (default) or Arel::OuterJoin + attr_accessor :join_type + + # These implement abstract methods from the superclass + attr_reader :aliased_prefix, :aliased_table_name + + delegate :options, :through_reflection, :source_reflection, :to => :reflection + delegate :table, :table_name, :to => :parent, :prefix => true def initialize(reflection, join_dependency, parent = nil) reflection.check_validity! + if reflection.options[:polymorphic] raise EagerLoadPolymorphicError.new(reflection) end super(reflection.klass) - @join_dependency = join_dependency - @parent = parent - @reflection = reflection - @aliased_prefix = "t#{ join_dependency.joins.size }" - @parent_table_name = parent.active_record.table_name - @aliased_table_name = aliased_table_name_for(table_name) - @join = nil - @join_class = Arel::InnerJoin - - if reflection.macro == :has_and_belongs_to_many - @aliased_join_table_name = aliased_table_name_for(reflection.options[:join_table], "_join") - end - - if [:has_many, :has_one].include?(reflection.macro) && reflection.options[:through] - @aliased_join_table_name = aliased_table_name_for(reflection.through_reflection.klass.table_name, "_join") - end + + @reflection = reflection + @join_dependency = join_dependency + @parent = parent + @join_type = Arel::InnerJoin + + # This must be done eagerly upon initialisation because the alias which is produced + # depends on the state of the join dependency, but we want it to work the same way + # every time. + allocate_aliases end def ==(other) @@ -2096,63 +2159,29 @@ module ActiveRecord end def find_parent_in(other_join_dependency) - other_join_dependency.joins.detect do |join| - self.parent == join + other_join_dependency.join_parts.detect do |join_part| + self.parent == join_part end end - - def with_join_class(join_class) - @join_class = join_class - self - end - - def association_join - return @join if @join - - aliased_table = Arel::Table.new(table_name, :as => @aliased_table_name, - :engine => arel_engine, - :columns => klass.columns) - - parent_table = Arel::Table.new(parent.table_name, :as => parent.aliased_table_name, - :engine => arel_engine, - :columns => parent.active_record.columns) - - @join = send("build_#{reflection.macro}", aliased_table, parent_table) - - unless klass.descends_from_active_record? - sti_column = aliased_table[klass.inheritance_column] - sti_condition = sti_column.eq(klass.sti_name) - klass.descendants.each {|subclass| sti_condition = sti_condition.or(sti_column.eq(subclass.sti_name)) } - - @join << sti_condition - end - - [through_reflection, reflection].each do |ref| - if ref && ref.options[:conditions] - @join << interpolate_sql(sanitize_sql(ref.options[:conditions], aliased_table_name)) - end - end - - @join + + def join_to(relation) + send("join_#{reflection.macro}_to", relation) end - def relation - aliased = Arel::Table.new(table_name, :as => @aliased_table_name, - :engine => arel_engine, - :columns => klass.columns) - - if reflection.macro == :has_and_belongs_to_many - [Arel::Table.new(options[:join_table], :as => aliased_join_table_name, :engine => arel_engine), aliased] - elsif reflection.options[:through] - [Arel::Table.new(through_reflection.klass.table_name, :as => aliased_join_table_name, :engine => arel_engine), aliased] - else - aliased - end + def join_relation(joining_relation) + self.join_type = Arel::OuterJoin + joining_relation.joins(self) end - - def join_relation(joining_relation, join = nil) - joining_relation.joins(self.with_join_class(Arel::OuterJoin)) + + def table + @table ||= Arel::Table.new( + table_name, :as => aliased_table_name, + :engine => arel_engine, :columns => active_record.columns + ) end + + # More semantic name given we are talking about associations + alias_method :target_table, :table protected @@ -2186,7 +2215,7 @@ module ActiveRecord end def table_name_and_alias - table_alias_for table_name, @aliased_table_name + table_alias_for table_name, aliased_table_name end def interpolate_sql(sql) @@ -2194,74 +2223,169 @@ module ActiveRecord end private + + def allocate_aliases + @aliased_prefix = "t#{ join_dependency.join_parts.size }" + @aliased_table_name = aliased_table_name_for(table_name) + + if reflection.macro == :has_and_belongs_to_many + @aliased_join_table_name = aliased_table_name_for(reflection.options[:join_table], "_join") + elsif [:has_many, :has_one].include?(reflection.macro) && reflection.options[:through] + @aliased_join_table_name = aliased_table_name_for(reflection.through_reflection.klass.table_name, "_join") + end + end + + def process_conditions(conditions, table_name) + Arel.sql(interpolate_sql(sanitize_sql(conditions, table_name))) + end + + def join_target_table(relation, *conditions) + relation = relation.join(target_table, join_type) + + # If the target table is an STI model then we must be sure to only include records of + # its type and its sub-types. + unless active_record.descends_from_active_record? + sti_column = target_table[active_record.inheritance_column] + + sti_condition = sti_column.eq(active_record.sti_name) + active_record.descendants.each do |subclass| + sti_condition = sti_condition.or(sti_column.eq(subclass.sti_name)) + end + + conditions << sti_condition + end + + # If the reflection has conditions, add them + if options[:conditions] + conditions << process_conditions(options[:conditions], aliased_table_name) + end + + relation = relation.on(*conditions) + end - def build_has_and_belongs_to_many(aliased_table, parent_table) - join_table = Arel::Table.new(options[:join_table], :as => aliased_join_table_name, :engine => arel_engine) - fk = options[:foreign_key] || reflection.active_record.to_s.foreign_key - klass_fk = options[:association_foreign_key] || klass.to_s.foreign_key - - [ - join_table[fk].eq(parent_table[reflection.active_record.primary_key]), - aliased_table[klass.primary_key].eq(join_table[klass_fk]) - ] + def join_has_and_belongs_to_many_to(relation) + join_table = Arel::Table.new( + options[:join_table], :engine => arel_engine, + :as => @aliased_join_table_name + ) + + fk = options[:foreign_key] || reflection.active_record.to_s.foreign_key + klass_fk = options[:association_foreign_key] || reflection.klass.to_s.foreign_key + + relation = relation.join(join_table, join_type) + relation = relation.on( + join_table[fk]. + eq(parent_table[reflection.active_record.primary_key]) + ) + + join_target_table( + relation, + target_table[reflection.klass.primary_key]. + eq(join_table[klass_fk]) + ) end - def build_has_many(aliased_table, parent_table) + def join_has_many_to(relation) if reflection.options[:through] - join_table = Arel::Table.new(through_reflection.klass.table_name, - :as => aliased_join_table_name, - :engine => arel_engine) - jt_foreign_key = jt_as_extra = jt_source_extra = jt_sti_extra = nil - first_key = second_key = nil - - if through_reflection.options[:as] # has_many :through against a polymorphic join - as_key = through_reflection.options[:as].to_s - jt_foreign_key = as_key + '_id' - jt_as_extra = join_table[as_key + '_type'].eq(parent.active_record.base_class.name) - else - jt_foreign_key = through_reflection.primary_key_name - end - - case source_reflection.macro - when :has_many - second_key = options[:foreign_key] || primary_key + join_has_many_through_to(relation) + elsif reflection.options[:as] + join_has_many_polymorphic_to(relation) + else + foreign_key = options[:foreign_key] || reflection.active_record.name.foreign_key + primary_key = options[:primary_key] || parent.primary_key + + join_target_table( + relation, + target_table[foreign_key]. + eq(parent_table[primary_key]) + ) + end + end + alias :join_has_one_to :join_has_many_to + + def join_has_many_through_to(relation) + join_table = Arel::Table.new( + through_reflection.klass.table_name, :engine => arel_engine, + :as => @aliased_join_table_name + ) + + jt_conditions = [] + jt_foreign_key = first_key = second_key = nil + + if through_reflection.options[:as] # has_many :through against a polymorphic join + as_key = through_reflection.options[:as].to_s + jt_foreign_key = as_key + '_id' + + jt_conditions << + join_table[as_key + '_type']. + eq(parent.active_record.base_class.name) + else + jt_foreign_key = through_reflection.primary_key_name + end - if source_reflection.options[:as] - first_key = "#{source_reflection.options[:as]}_id" - else - first_key = through_reflection.klass.base_class.to_s.foreign_key - end + case source_reflection.macro + when :has_many + second_key = options[:foreign_key] || primary_key - unless through_reflection.klass.descends_from_active_record? - jt_sti_extra = join_table[through_reflection.active_record.inheritance_column].eq(through_reflection.klass.sti_name) - end - when :belongs_to - first_key = primary_key - if reflection.options[:source_type] - second_key = source_reflection.association_foreign_key - jt_source_extra = join_table[reflection.source_reflection.options[:foreign_type]].eq(reflection.options[:source_type]) - else - second_key = source_reflection.primary_key_name - end + if source_reflection.options[:as] + first_key = "#{source_reflection.options[:as]}_id" + else + first_key = through_reflection.klass.base_class.to_s.foreign_key end - [ - [parent_table[parent.primary_key].eq(join_table[jt_foreign_key]), jt_as_extra, jt_source_extra, jt_sti_extra].compact, - aliased_table[first_key].eq(join_table[second_key]) - ] - elsif reflection.options[:as] - id_rel = aliased_table["#{reflection.options[:as]}_id"].eq(parent_table[parent.primary_key]) - type_rel = aliased_table["#{reflection.options[:as]}_type"].eq(parent.active_record.base_class.name) - [id_rel, type_rel] - else - foreign_key = options[:foreign_key] || reflection.active_record.name.foreign_key - [aliased_table[foreign_key].eq(parent_table[reflection.options[:primary_key] || parent.primary_key])] + unless through_reflection.klass.descends_from_active_record? + jt_conditions << + join_table[through_reflection.active_record.inheritance_column]. + eq(through_reflection.klass.sti_name) + end + when :belongs_to + first_key = primary_key + + if reflection.options[:source_type] + second_key = source_reflection.association_foreign_key + + jt_conditions << + join_table[reflection.source_reflection.options[:foreign_type]]. + eq(reflection.options[:source_type]) + else + second_key = source_reflection.primary_key_name + end + end + + jt_conditions << + parent_table[parent.primary_key]. + eq(join_table[jt_foreign_key]) + + if through_reflection.options[:conditions] + jt_conditions << process_conditions(through_reflection.options[:conditions], aliased_table_name) end + + relation = relation.join(join_table, join_type).on(*jt_conditions) + + join_target_table( + relation, + target_table[first_key].eq(join_table[second_key]) + ) + end + + def join_has_many_polymorphic_to(relation) + join_target_table( + relation, + target_table["#{reflection.options[:as]}_id"]. + eq(parent_table[parent.primary_key]), + target_table["#{reflection.options[:as]}_type"]. + eq(parent.active_record.base_class.name) + ) end - alias :build_has_one :build_has_many - def build_belongs_to(aliased_table, parent_table) - [aliased_table[options[:primary_key] || reflection.klass.primary_key].eq(parent_table[options[:foreign_key] || reflection.primary_key_name])] + def join_belongs_to_to(relation) + foreign_key = options[:foreign_key] || reflection.primary_key_name + primary_key = options[:primary_key] || reflection.klass.primary_key + + join_target_table( + relation, + target_table[primary_key].eq(parent_table[foreign_key]) + ) end end end diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index ede1c8821e..5034caf084 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -343,8 +343,11 @@ module ActiveRecord end def column_aliases(join_dependency) - join_dependency.joins.collect{|join| join.column_names_with_alias.collect{|column_name, aliased_name| - "#{connection.quote_table_name join.aliased_table_name}.#{connection.quote_column_name column_name} AS #{aliased_name}"}}.flatten.join(", ") + join_dependency.join_parts.collect { |join_part| + join_part.column_names_with_alias.collect{ |column_name, aliased_name| + "#{connection.quote_table_name join_part.aliased_table_name}.#{connection.quote_column_name column_name} AS #{aliased_name}" + } + }.flatten.join(", ") end def using_limitable_reflections?(reflections) diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 2e0a2effc2..acc42faf7d 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -230,19 +230,8 @@ module ActiveRecord @implicit_readonly = true unless association_joins.empty? && stashed_association_joins.empty? - to_join = [] - join_dependency.join_associations.each do |association| - if (association_relation = association.relation).is_a?(Array) - to_join << [association_relation.first, association.join_class, association.association_join.first] - to_join << [association_relation.last, association.join_class, association.association_join.last] - else - to_join << [association_relation, association.join_class, association.association_join] - end - end - - to_join.uniq.each do |left, join_class, right| - relation = relation.join(left, join_class).on(*right) + relation = association.join_to(relation) end relation.join(custom_joins) diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index 8bb8f3e359..0e9c8a2639 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -16,7 +16,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal 2, authors[1].posts.size - assert_equal 9, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} + assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} end def test_eager_association_loading_with_cascaded_two_levels_and_one_level @@ -24,7 +24,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal 2, authors[1].posts.size - assert_equal 9, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} + assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} assert_equal 1, authors[0].categorizations.size assert_equal 2, authors[1].categorizations.size end @@ -35,7 +35,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end authors = Author.joins(:posts).eager_load(:comments).where(:posts => {:taggings_count => 1}).all assert_equal 1, assert_no_queries { authors.size } - assert_equal 9, assert_no_queries { authors[0].comments.size } + assert_equal 10, assert_no_queries { authors[0].comments.size } end def test_eager_association_loading_grafts_stashed_associations_to_correct_parent @@ -57,7 +57,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase assert_equal 3, authors.size assert_equal 5, authors[0].posts.size assert_equal 2, authors[1].posts.size - assert_equal 9, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} + assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} end def test_eager_association_loading_with_cascaded_two_levels_and_self_table_reference diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index 1669c4d5f4..2ff0714e9f 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -174,7 +174,7 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_eager_association_loading_with_belongs_to comments = Comment.find(:all, :include => :post) - assert_equal 10, comments.length + assert_equal 11, comments.length titles = comments.map { |c| c.post.title } assert titles.include?(posts(:welcome).title) assert titles.include?(posts(:sti_post_and_comments).title) @@ -532,7 +532,7 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_eager_has_many_with_association_inheritance post = Post.find(4, :include => [ :special_comments ]) post.special_comments.each do |special_comment| - assert_equal "SpecialComment", special_comment.class.to_s + assert special_comment.is_a?(SpecialComment) end end @@ -726,8 +726,8 @@ class EagerAssociationTest < ActiveRecord::TestCase posts = assert_queries(2) do Post.find(:all, :joins => :comments, :include => :author, :order => 'comments.id DESC') end - assert_equal posts(:eager_other), posts[0] - assert_equal authors(:mary), assert_no_queries { posts[0].author} + assert_equal posts(:eager_other), posts[1] + assert_equal authors(:mary), assert_no_queries { posts[1].author} end def test_eager_loading_with_conditions_on_joined_table_preloads diff --git a/activerecord/test/cases/associations/inner_join_association_test.rb b/activerecord/test/cases/associations/inner_join_association_test.rb index 4ba867dc7c..780eabc443 100644 --- a/activerecord/test/cases/associations/inner_join_association_test.rb +++ b/activerecord/test/cases/associations/inner_join_association_test.rb @@ -4,9 +4,12 @@ require 'models/comment' require 'models/author' require 'models/category' require 'models/categorization' +require 'models/tagging' +require 'models/tag' class InnerJoinAssociationTest < ActiveRecord::TestCase - fixtures :authors, :posts, :comments, :categories, :categories_posts, :categorizations + fixtures :authors, :posts, :comments, :categories, :categories_posts, :categorizations, + :taggings, :tags def test_construct_finder_sql_applies_aliases_tables_on_association_conditions result = Author.joins(:thinking_posts, :welcome_posts).to_a @@ -62,4 +65,23 @@ class InnerJoinAssociationTest < ActiveRecord::TestCase authors_with_welcoming_post_titles = Author.calculate(:count, 'authors.id', :joins => :posts, :distinct => true, :conditions => "posts.title like 'Welcome%'") assert_equal real_count, authors_with_welcoming_post_titles, "inner join and conditions should have only returned authors posting titles starting with 'Welcome'" end + + def test_find_with_sti_join + scope = Post.joins(:special_comments).where(:id => posts(:sti_comments).id) + + # The join should match SpecialComment and its subclasses only + assert scope.where("comments.type" => "Comment").empty? + assert !scope.where("comments.type" => "SpecialComment").empty? + assert !scope.where("comments.type" => "SubSpecialComment").empty? + end + + def test_find_with_conditions_on_reflection + assert !posts(:welcome).comments.empty? + assert Post.joins(:nonexistant_comments).where(:id => posts(:welcome).id).empty? # [sic!] + end + + def test_find_with_conditions_on_through_reflection + assert !posts(:welcome).tags.empty? + assert Post.joins(:misc_tags).where(:id => posts(:welcome).id).empty? + end end diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index 0b1a3db1e4..4b7a8b494d 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -398,7 +398,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase author = Author.find :first, :conditions => ['name = ?', 'David'], :include => :comments, :order => 'comments.id' SpecialComment.new; VerySpecialComment.new assert_no_queries do - assert_equal [1,2,3,5,6,7,8,9,10], author.comments.collect(&:id) + assert_equal [1,2,3,5,6,7,8,9,10,12], author.comments.collect(&:id) end end @@ -500,7 +500,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase def test_has_many_through_collection_size_doesnt_load_target_if_not_loaded author = authors(:david) - assert_equal 9, author.comments.size + assert_equal 10, author.comments.size assert !author.comments.loaded? end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index e73f58fdc7..0476fc94df 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -259,7 +259,7 @@ class FinderTest < ActiveRecord::TestCase end def test_find_on_association_proxy_conditions - assert_equal [1, 2, 3, 5, 6, 7, 8, 9, 10], Comment.find_all_by_post_id(authors(:david).posts).map(&:id).sort + assert_equal [1, 2, 3, 5, 6, 7, 8, 9, 10, 12], Comment.find_all_by_post_id(authors(:david).posts).map(&:id).sort end def test_find_on_hash_conditions_with_range diff --git a/activerecord/test/fixtures/comments.yml b/activerecord/test/fixtures/comments.yml index 97d77f8b9a..ddbb823c49 100644 --- a/activerecord/test/fixtures/comments.yml +++ b/activerecord/test/fixtures/comments.yml @@ -57,3 +57,9 @@ eager_other_comment1: post_id: 7 body: go crazy type: SpecialComment + +sub_special_comment: + id: 12 + post_id: 4 + body: Sub special comment + type: SubSpecialComment diff --git a/activerecord/test/models/comment.rb b/activerecord/test/models/comment.rb index 9f6e2d3b71..88061b2145 100644 --- a/activerecord/test/models/comment.rb +++ b/activerecord/test/models/comment.rb @@ -23,6 +23,9 @@ class SpecialComment < Comment end end +class SubSpecialComment < SpecialComment +end + class VerySpecialComment < Comment def self.what_are_you 'a very special comment...' -- cgit v1.2.3 From ab5a9335020eff0da35b62b86a62ed8587a4d598 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 9 Oct 2010 22:00:33 +0100 Subject: Add support for nested through associations in JoinAssociation. Hence Foo.joins(:bar) will work for through associations. There is some duplicated code now, which will be refactored. --- activerecord/lib/active_record/associations.rb | 174 ++++++++++----------- .../associations/through_association_scope.rb | 4 + .../nested_has_many_through_associations_test.rb | 59 ++++++- activerecord/test/fixtures/ratings.yml | 14 ++ activerecord/test/models/comment.rb | 1 + activerecord/test/models/job.rb | 2 + activerecord/test/models/person.rb | 3 + activerecord/test/models/post.rb | 2 + activerecord/test/models/rating.rb | 3 + activerecord/test/models/reference.rb | 2 + activerecord/test/schema/schema.rb | 5 + 11 files changed, 175 insertions(+), 94 deletions(-) create mode 100644 activerecord/test/fixtures/ratings.yml create mode 100644 activerecord/test/models/rating.rb diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 67c204f154..688c05c545 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2129,7 +2129,7 @@ module ActiveRecord # These implement abstract methods from the superclass attr_reader :aliased_prefix, :aliased_table_name - delegate :options, :through_reflection, :source_reflection, :to => :reflection + delegate :options, :through_reflection, :source_reflection, :through_reflection_chain, :to => :reflection delegate :table, :table_name, :to => :parent, :prefix => true def initialize(reflection, join_dependency, parent = nil) @@ -2185,13 +2185,13 @@ module ActiveRecord protected - def aliased_table_name_for(name, suffix = nil) + def aliased_table_name_for(name, aliased_name, suffix = nil) if @join_dependency.table_aliases[name].zero? @join_dependency.table_aliases[name] = @join_dependency.count_aliases_from_table_joins(name) end if !@join_dependency.table_aliases[name].zero? # We need an alias - name = active_record.connection.table_alias_for "#{pluralize(reflection.name)}_#{parent_table_name}#{suffix}" + name = active_record.connection.table_alias_for "#{aliased_name}_#{parent_table_name}#{suffix}" @join_dependency.table_aliases[name] += 1 if @join_dependency.table_aliases[name] == 1 # First time we've seen this name # Also need to count the aliases from the table_aliases to avoid incorrect count @@ -2226,12 +2226,26 @@ module ActiveRecord def allocate_aliases @aliased_prefix = "t#{ join_dependency.join_parts.size }" - @aliased_table_name = aliased_table_name_for(table_name) + @aliased_table_name = aliased_table_name_for(table_name, pluralize(reflection.name)) - if reflection.macro == :has_and_belongs_to_many - @aliased_join_table_name = aliased_table_name_for(reflection.options[:join_table], "_join") - elsif [:has_many, :has_one].include?(reflection.macro) && reflection.options[:through] - @aliased_join_table_name = aliased_table_name_for(reflection.through_reflection.klass.table_name, "_join") + case reflection.macro + when :has_and_belongs_to_many + @aliased_join_table_name = aliased_table_name_for( + reflection.options[:join_table], + pluralize(reflection.name), "_join" + ) + when :has_many, :has_one + # Add the target table name which was already generated. We don't want to generate + # it again as that would lead to an unnecessary alias. + @aliased_through_table_names = [@aliased_table_name] + + # Generate the rest in the original order + @aliased_through_table_names += through_reflection_chain[1..-1].map do |reflection| + aliased_table_name_for(reflection.table_name, pluralize(reflection.name), "_join") + end + + # Now reverse the list, as we will use it in that order + @aliased_through_table_names.reverse! end end @@ -2284,99 +2298,81 @@ module ActiveRecord eq(join_table[klass_fk]) ) end - - def join_has_many_to(relation) - if reflection.options[:through] - join_has_many_through_to(relation) - elsif reflection.options[:as] - join_has_many_polymorphic_to(relation) - else - foreign_key = options[:foreign_key] || reflection.active_record.name.foreign_key - primary_key = options[:primary_key] || parent.primary_key - - join_target_table( - relation, - target_table[foreign_key]. - eq(parent_table[primary_key]) - ) - end - end - alias :join_has_one_to :join_has_many_to - def join_has_many_through_to(relation) - join_table = Arel::Table.new( - through_reflection.klass.table_name, :engine => arel_engine, - :as => @aliased_join_table_name - ) + def join_has_many_to(relation) + # Chain usually starts with target, but we want to end with it here (just makes it + # easier to understand the joins that are generated) + chain = through_reflection_chain.reverse - jt_conditions = [] - jt_foreign_key = first_key = second_key = nil - - if through_reflection.options[:as] # has_many :through against a polymorphic join - as_key = through_reflection.options[:as].to_s - jt_foreign_key = as_key + '_id' + foreign_table = parent_table + + chain.zip(@aliased_through_table_names).each do |reflection, aliased_table_name| + table = Arel::Table.new( + reflection.table_name, :engine => arel_engine, + :as => aliased_table_name, :columns => reflection.klass.columns + ) - jt_conditions << - join_table[as_key + '_type']. - eq(parent.active_record.base_class.name) - else - jt_foreign_key = through_reflection.primary_key_name - end - - case source_reflection.macro - when :has_many - second_key = options[:foreign_key] || primary_key - - if source_reflection.options[:as] - first_key = "#{source_reflection.options[:as]}_id" + conditions = [] + + if reflection.source_reflection.nil? + case reflection.macro + when :belongs_to + key = reflection.options[:primary_key] || + reflection.klass.primary_key + foreign_key = reflection.primary_key_name + when :has_many, :has_one + key = reflection.primary_key_name + foreign_key = reflection.options[:primary_key] || + reflection.active_record.primary_key + + if reflection.options[:as] + conditions << + table["#{reflection.options[:as]}_type"]. + eq(reflection.active_record.base_class.name) + end + end + elsif reflection.source_reflection.macro == :belongs_to + key = reflection.klass.primary_key + foreign_key = reflection.source_reflection.primary_key_name + + if reflection.options[:source_type] + conditions << + foreign_table[reflection.source_reflection.options[:foreign_type]]. + eq(reflection.options[:source_type]) + end else - first_key = through_reflection.klass.base_class.to_s.foreign_key + key = reflection.source_reflection.primary_key_name + foreign_key = reflection.source_reflection.klass.primary_key end - - unless through_reflection.klass.descends_from_active_record? - jt_conditions << - join_table[through_reflection.active_record.inheritance_column]. - eq(through_reflection.klass.sti_name) + + conditions << table[key].eq(foreign_table[foreign_key]) + + if reflection.options[:conditions] + conditions << process_conditions(reflection.options[:conditions], aliased_table_name) end - when :belongs_to - first_key = primary_key - if reflection.options[:source_type] - second_key = source_reflection.association_foreign_key + # If the target table is an STI model then we must be sure to only include records of + # its type and its sub-types. + unless reflection.klass.descends_from_active_record? + sti_column = table[reflection.klass.inheritance_column] - jt_conditions << - join_table[reflection.source_reflection.options[:foreign_type]]. - eq(reflection.options[:source_type]) - else - second_key = source_reflection.primary_key_name + sti_condition = sti_column.eq(reflection.klass.sti_name) + reflection.klass.descendants.each do |subclass| + sti_condition = sti_condition.or(sti_column.eq(subclass.sti_name)) + end + + conditions << sti_condition end + + relation = relation.join(table, join_type).on(*conditions) + + # The current table in this iteration becomes the foreign table in the next + foreign_table = table end - jt_conditions << - parent_table[parent.primary_key]. - eq(join_table[jt_foreign_key]) - - if through_reflection.options[:conditions] - jt_conditions << process_conditions(through_reflection.options[:conditions], aliased_table_name) - end - - relation = relation.join(join_table, join_type).on(*jt_conditions) - - join_target_table( - relation, - target_table[first_key].eq(join_table[second_key]) - ) - end - - def join_has_many_polymorphic_to(relation) - join_target_table( - relation, - target_table["#{reflection.options[:as]}_id"]. - eq(parent_table[parent.primary_key]), - target_table["#{reflection.options[:as]}_type"]. - eq(parent.active_record.base_class.name) - ) + relation end + alias :join_has_one_to :join_has_many_to def join_belongs_to_to(relation) foreign_key = options[:foreign_key] || reflection.primary_key_name diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 90ebadda89..8406f5fd20 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -73,6 +73,8 @@ module ActiveRecord if left.options[:as] polymorphic_join = "AND %s.%s = %s" % [ table_aliases[left], "#{left.options[:as]}_type", + # TODO: Why right.klass.name? Rather than left.active_record.name? + # TODO: Also should maybe use the base_class (see related code in JoinAssociation) @owner.class.quote_value(right.klass.name) ] end @@ -117,6 +119,8 @@ module ActiveRecord joins.join(" ") end + # TODO: Use the same aliasing strategy (and code?) as JoinAssociation (as this is the + # documented behaviour) def table_aliases @table_aliases ||= begin tally = {} diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index a5d3f27702..ba75b70941 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -17,18 +17,37 @@ require 'models/developer' require 'models/subscriber' require 'models/book' require 'models/subscription' +require 'models/rating' + +# NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which +# are just one level deep. But it's all the same thing really, as the "nested" code is being +# written in a generic way which applies to "non-nested" HMT associations too. So let's just shove +# all useful tests in here for now and then work out where they ought to live properly later. class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase - fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings + fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, + :people, :readers, :references, :jobs, :ratings, :comments def test_has_many_through_a_has_many_through_association_on_source_reflection author = authors(:david) assert_equal [tags(:general), tags(:general)], author.tags + + # Only David has a Post tagged with General + authors = Author.joins(:tags).where('tags.id' => tags(:general).id) + assert_equal [authors(:david)], authors.uniq + + # This ensures that the polymorphism of taggings is being observed correctly + authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') + assert authors.empty? end def test_has_many_through_a_has_many_through_association_on_through_reflection author = authors(:david) assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], author.subscribers + + # All authors with subscribers where one of the subscribers' nick is 'alterself' + authors = Author.joins(:subscribers).where('subscribers.nick' => 'alterself') + assert_equal [authors(:david)], authors end def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection @@ -44,11 +63,41 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_nested_has_many_through_with_a_table_referenced_multiple_times author = authors(:bob) assert_equal [posts(:misc_by_bob), posts(:misc_by_mary)], author.similar_posts.sort_by(&:id) + + # Mary and Bob both have posts in misc, but they are the only ones. + authors = Author.joins(:similar_posts).where('posts.id' => posts(:misc_by_bob).id) + assert_equal [authors(:mary), authors(:bob)], authors.uniq.sort_by(&:id) + + # Check the polymorphism of taggings is being observed correctly (in both joins) + authors = Author.joins(:similar_posts).where('taggings.taggable_type' => 'FakeModel') + assert authors.empty? + authors = Author.joins(:similar_posts).where('taggings_authors_join.taggable_type' => 'FakeModel') + assert authors.empty? end - def test_nested_has_many_through_as_a_join - # All authors with subscribers where one of the subscribers' nick is 'alterself' - authors = Author.joins(:subscribers).where('subscribers.nick' => 'alterself') - assert_equal [authors(:david)], authors + def test_has_many_through_with_foreign_key_option_on_through_reflection + assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts + assert_equal [authors(:david)], references(:david_unicyclist).agents_posts_authors + + references = Reference.joins(:agents_posts_authors).where('authors.id' => authors(:david).id) + assert_equal [references(:david_unicyclist)], references + end + + def test_has_many_through_with_foreign_key_option_on_source_reflection + assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents + + jobs = Job.joins(:agents) + assert_equal [jobs(:unicyclist), jobs(:unicyclist)], jobs + end + + def test_has_many_through_with_sti_on_through_reflection + ratings = posts(:sti_comments).special_comments_ratings.sort_by(&:id) + assert_equal [ratings(:special_comment_rating), ratings(:sub_special_comment_rating)], ratings + + # Ensure STI is respected in the join + scope = Post.joins(:special_comments_ratings).where(:id => posts(:sti_comments).id) + assert scope.where("comments.type" => "Comment").empty? + assert !scope.where("comments.type" => "SpecialComment").empty? + assert !scope.where("comments.type" => "SubSpecialComment").empty? end end diff --git a/activerecord/test/fixtures/ratings.yml b/activerecord/test/fixtures/ratings.yml new file mode 100644 index 0000000000..34e208efa3 --- /dev/null +++ b/activerecord/test/fixtures/ratings.yml @@ -0,0 +1,14 @@ +normal_comment_rating: + id: 1 + comment_id: 8 + value: 1 + +special_comment_rating: + id: 2 + comment_id: 6 + value: 1 + +sub_special_comment_rating: + id: 3 + comment_id: 12 + value: 1 diff --git a/activerecord/test/models/comment.rb b/activerecord/test/models/comment.rb index 88061b2145..1a3fb42b66 100644 --- a/activerecord/test/models/comment.rb +++ b/activerecord/test/models/comment.rb @@ -7,6 +7,7 @@ class Comment < ActiveRecord::Base :conditions => { "posts.author_id" => 1 } belongs_to :post, :counter_cache => true + has_many :ratings def self.what_are_you 'a comment...' diff --git a/activerecord/test/models/job.rb b/activerecord/test/models/job.rb index 3333a02e27..46b1d87aa1 100644 --- a/activerecord/test/models/job.rb +++ b/activerecord/test/models/job.rb @@ -2,4 +2,6 @@ class Job < ActiveRecord::Base has_many :references has_many :people, :through => :references belongs_to :ideal_reference, :class_name => 'Reference' + + has_many :agents, :through => :people end diff --git a/activerecord/test/models/person.rb b/activerecord/test/models/person.rb index 951ec93c53..d35c51b660 100644 --- a/activerecord/test/models/person.rb +++ b/activerecord/test/models/person.rb @@ -13,6 +13,9 @@ class Person < ActiveRecord::Base belongs_to :primary_contact, :class_name => 'Person' has_many :agents, :class_name => 'Person', :foreign_key => 'primary_contact_id' belongs_to :number1_fan, :class_name => 'Person' + + has_many :agents_posts, :through => :agents, :source => :posts + has_many :agents_posts_authors, :through => :agents_posts, :source => :author scope :males, :conditions => { :gender => 'M' } scope :females, :conditions => { :gender => 'F' } diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index a3cb9c724a..f3b78c3647 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -46,6 +46,8 @@ class Post < ActiveRecord::Base has_one :very_special_comment_with_post, :class_name => "VerySpecialComment", :include => :post has_many :special_comments has_many :nonexistant_comments, :class_name => 'Comment', :conditions => 'comments.id < 0' + + has_many :special_comments_ratings, :through => :special_comments, :source => :ratings has_and_belongs_to_many :categories has_and_belongs_to_many :special_categories, :join_table => "categories_posts", :association_foreign_key => 'category_id' diff --git a/activerecord/test/models/rating.rb b/activerecord/test/models/rating.rb new file mode 100644 index 0000000000..12c4b5affa --- /dev/null +++ b/activerecord/test/models/rating.rb @@ -0,0 +1,3 @@ +class Rating < ActiveRecord::Base + belongs_to :comment +end diff --git a/activerecord/test/models/reference.rb b/activerecord/test/models/reference.rb index 4a17c936f5..2feb15d706 100644 --- a/activerecord/test/models/reference.rb +++ b/activerecord/test/models/reference.rb @@ -1,6 +1,8 @@ class Reference < ActiveRecord::Base belongs_to :person belongs_to :job + + has_many :agents_posts_authors, :through => :person end class BadReference < ActiveRecord::Base diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index dbd5da45eb..2fa9a4521e 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -449,6 +449,11 @@ ActiveRecord::Schema.define do t.string :type end + create_table :ratings, :force => true do |t| + t.integer :comment_id + t.integer :value + end + create_table :readers, :force => true do |t| t.integer :post_id, :null => false t.integer :person_id, :null => false -- cgit v1.2.3 From 3aba73fed1afc93cf64f9da90d5ad2d51c99df9a Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 10 Oct 2010 00:53:04 +0100 Subject: Refactoring to remove duplication introduced by the last commit --- activerecord/lib/active_record/associations.rb | 305 ++++++++++++------------- 1 file changed, 145 insertions(+), 160 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 688c05c545..397159d35e 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2126,8 +2126,7 @@ module ActiveRecord # What type of join will be generated, either Arel::InnerJoin (default) or Arel::OuterJoin attr_accessor :join_type - # These implement abstract methods from the superclass - attr_reader :aliased_prefix, :aliased_table_name + attr_reader :aliased_prefix delegate :options, :through_reflection, :source_reflection, :through_reflection_chain, :to => :reflection delegate :table, :table_name, :to => :parent, :prefix => true @@ -2141,15 +2140,13 @@ module ActiveRecord super(reflection.klass) - @reflection = reflection - @join_dependency = join_dependency - @parent = parent - @join_type = Arel::InnerJoin + @reflection = reflection + @join_dependency = join_dependency + @parent = parent + @join_type = Arel::InnerJoin + @aliased_prefix = "t#{ join_dependency.join_parts.size }" - # This must be done eagerly upon initialisation because the alias which is produced - # depends on the state of the join dependency, but we want it to work the same way - # every time. - allocate_aliases + setup_tables end def ==(other) @@ -2165,7 +2162,71 @@ module ActiveRecord end def join_to(relation) - send("join_#{reflection.macro}_to", relation) + # The chain starts with the target table, but we want to end with it here (makes + # more sense in this context) + chain = through_reflection_chain.reverse + + foreign_table = parent_table + + chain.zip(@tables).each do |reflection, table| + conditions = [] + + if reflection.source_reflection.nil? + case reflection.macro + when :belongs_to + key = reflection.options[:primary_key] || + reflection.klass.primary_key + foreign_key = reflection.primary_key_name + when :has_many, :has_one + key = reflection.primary_key_name + foreign_key = reflection.options[:primary_key] || + reflection.active_record.primary_key + + conditions << polymorphic_conditions(reflection, table) + when :has_and_belongs_to_many + # For habtm, we need to deal with the join table at the same time as the + # target table (because unlike a :through association, there is no reflection + # to represent the join table) + table, join_table = table + + join_key = reflection.options[:foreign_key] || + reflection.active_record.to_s.foreign_key + join_foreign_key = reflection.active_record.primary_key + + relation = relation.join(join_table, join_type).on( + join_table[join_key]. + eq(foreign_table[join_foreign_key]) + ) + + # We've done the first join now, so update the foreign_table for the second + foreign_table = join_table + + key = reflection.klass.primary_key + foreign_key = reflection.options[:association_foreign_key] || + reflection.klass.to_s.foreign_key + end + elsif reflection.source_reflection.macro == :belongs_to + key = reflection.klass.primary_key + foreign_key = reflection.source_reflection.primary_key_name + + conditions << source_type_conditions(reflection, foreign_table) + else + key = reflection.source_reflection.primary_key_name + foreign_key = reflection.source_reflection.klass.primary_key + end + + conditions << table[key].eq(foreign_table[foreign_key]) + + conditions << reflection_conditions(reflection, table) + conditions << sti_conditions(reflection, table) + + relation = relation.join(table, join_type).on(*conditions.compact) + + # The current table in this iteration becomes the foreign table in the next + foreign_table = table + end + + relation end def join_relation(joining_relation) @@ -2174,15 +2235,17 @@ module ActiveRecord end def table - @table ||= Arel::Table.new( - table_name, :as => aliased_table_name, - :engine => arel_engine, :columns => active_record.columns - ) + if reflection.macro == :has_and_belongs_to_many + @tables.last.first + else + @tables.last + end + end + + def aliased_table_name + table.table_alias || table.name end - # More semantic name given we are talking about associations - alias_method :target_table, :table - protected def aliased_table_name_for(name, aliased_name, suffix = nil) @@ -2224,167 +2287,89 @@ module ActiveRecord private - def allocate_aliases - @aliased_prefix = "t#{ join_dependency.join_parts.size }" - @aliased_table_name = aliased_table_name_for(table_name, pluralize(reflection.name)) - - case reflection.macro - when :has_and_belongs_to_many - @aliased_join_table_name = aliased_table_name_for( + # Generate aliases and Arel::Table instances for each of the tables which we will + # later generate joins for. We must do this in advance in order to correctly allocate + # the proper alias. + def setup_tables + @tables = through_reflection_chain.map do |reflection| + suffix = reflection == self.reflection ? nil : '_join' + + aliased_table_name = aliased_table_name_for( + reflection.table_name, + pluralize(reflection.name), + suffix + ) + + table = Arel::Table.new( + reflection.table_name, :engine => arel_engine, + :as => aliased_table_name, :columns => reflection.klass.columns + ) + + # For habtm, we have two Arel::Table instances related to a single reflection, so + # we just store them as a pair in the array. + if reflection.macro == :has_and_belongs_to_many + aliased_join_table_name = aliased_table_name_for( reflection.options[:join_table], pluralize(reflection.name), "_join" ) - when :has_many, :has_one - # Add the target table name which was already generated. We don't want to generate - # it again as that would lead to an unnecessary alias. - @aliased_through_table_names = [@aliased_table_name] - # Generate the rest in the original order - @aliased_through_table_names += through_reflection_chain[1..-1].map do |reflection| - aliased_table_name_for(reflection.table_name, pluralize(reflection.name), "_join") - end + join_table = Arel::Table.new( + reflection.options[:join_table], :engine => arel_engine, + :as => aliased_join_table_name + ) - # Now reverse the list, as we will use it in that order - @aliased_through_table_names.reverse! - end - end - - def process_conditions(conditions, table_name) - Arel.sql(interpolate_sql(sanitize_sql(conditions, table_name))) - end - - def join_target_table(relation, *conditions) - relation = relation.join(target_table, join_type) - - # If the target table is an STI model then we must be sure to only include records of - # its type and its sub-types. - unless active_record.descends_from_active_record? - sti_column = target_table[active_record.inheritance_column] - - sti_condition = sti_column.eq(active_record.sti_name) - active_record.descendants.each do |subclass| - sti_condition = sti_condition.or(sti_column.eq(subclass.sti_name)) + [table, join_table] + else + table end - - conditions << sti_condition end - # If the reflection has conditions, add them - if options[:conditions] - conditions << process_conditions(options[:conditions], aliased_table_name) - end + # The joins are generated from the through_reflection_chain in reverse order, so + # reverse the tables too (but it's important to generate the aliases in the 'forward' + # order, which is why we only do the reversal now. + @tables.reverse! - relation = relation.on(*conditions) + @tables end - - def join_has_and_belongs_to_many_to(relation) - join_table = Arel::Table.new( - options[:join_table], :engine => arel_engine, - :as => @aliased_join_table_name - ) - - fk = options[:foreign_key] || reflection.active_record.to_s.foreign_key - klass_fk = options[:association_foreign_key] || reflection.klass.to_s.foreign_key - - relation = relation.join(join_table, join_type) - relation = relation.on( - join_table[fk]. - eq(parent_table[reflection.active_record.primary_key]) - ) - - join_target_table( - relation, - target_table[reflection.klass.primary_key]. - eq(join_table[klass_fk]) - ) + + def reflection_conditions(reflection, table) + if reflection.options[:conditions] + Arel.sql(interpolate_sql(sanitize_sql( + reflection.options[:conditions], + table.table_alias || table.name + ))) + end end - def join_has_many_to(relation) - # Chain usually starts with target, but we want to end with it here (just makes it - # easier to understand the joins that are generated) - chain = through_reflection_chain.reverse - - foreign_table = parent_table - - chain.zip(@aliased_through_table_names).each do |reflection, aliased_table_name| - table = Arel::Table.new( - reflection.table_name, :engine => arel_engine, - :as => aliased_table_name, :columns => reflection.klass.columns - ) + def sti_conditions(reflection, table) + unless reflection.klass.descends_from_active_record? + sti_column = table[reflection.klass.inheritance_column] - conditions = [] + condition = sti_column.eq(reflection.klass.sti_name) - if reflection.source_reflection.nil? - case reflection.macro - when :belongs_to - key = reflection.options[:primary_key] || - reflection.klass.primary_key - foreign_key = reflection.primary_key_name - when :has_many, :has_one - key = reflection.primary_key_name - foreign_key = reflection.options[:primary_key] || - reflection.active_record.primary_key - - if reflection.options[:as] - conditions << - table["#{reflection.options[:as]}_type"]. - eq(reflection.active_record.base_class.name) - end - end - elsif reflection.source_reflection.macro == :belongs_to - key = reflection.klass.primary_key - foreign_key = reflection.source_reflection.primary_key_name - - if reflection.options[:source_type] - conditions << - foreign_table[reflection.source_reflection.options[:foreign_type]]. - eq(reflection.options[:source_type]) - end - else - key = reflection.source_reflection.primary_key_name - foreign_key = reflection.source_reflection.klass.primary_key - end - - conditions << table[key].eq(foreign_table[foreign_key]) - - if reflection.options[:conditions] - conditions << process_conditions(reflection.options[:conditions], aliased_table_name) + reflection.klass.descendants.each do |subclass| + condition = condition.or(sti_column.eq(subclass.sti_name)) end - # If the target table is an STI model then we must be sure to only include records of - # its type and its sub-types. - unless reflection.klass.descends_from_active_record? - sti_column = table[reflection.klass.inheritance_column] - - sti_condition = sti_column.eq(reflection.klass.sti_name) - reflection.klass.descendants.each do |subclass| - sti_condition = sti_condition.or(sti_column.eq(subclass.sti_name)) - end - - conditions << sti_condition - end - - relation = relation.join(table, join_type).on(*conditions) - - # The current table in this iteration becomes the foreign table in the next - foreign_table = table + condition end - - relation end - alias :join_has_one_to :join_has_many_to - - def join_belongs_to_to(relation) - foreign_key = options[:foreign_key] || reflection.primary_key_name - primary_key = options[:primary_key] || reflection.klass.primary_key - - join_target_table( - relation, - target_table[primary_key].eq(parent_table[foreign_key]) - ) - end - end + + def source_type_conditions(reflection, foreign_table) + if reflection.options[:source_type] + foreign_table[reflection.source_reflection.options[:foreign_type]]. + eq(reflection.options[:source_type]) + end + end + + def polymorphic_conditions(reflection, table) + if reflection.options[:as] + table["#{reflection.options[:as]}_type"]. + eq(reflection.active_record.base_class.name) + end + end end + end end end end -- cgit v1.2.3 From 7aea695815821df332913bae4b47714a525009a3 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 13:33:51 +0100 Subject: A load of tests that need to be written --- .../nested_has_many_through_associations_test.rb | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index ba75b70941..8a4ab627bb 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -28,6 +28,26 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments + # Through associations can either use the has_many or has_one macros. + # + # has_many + # - Source reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many + # - Through reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many + # + # has_one + # - Source reflection can be has_one or belongs_to + # - Through reflection can be has_one or belongs_to + # + # Additionally, the source reflection and/or through reflection may be subject to + # polymorphism and/or STI. + # + # When testing these, we need to make sure it works via loading the association directly, or + # joining the association, or including the association. We also need to ensure that associations + # are readonly where relevant. + + # has_many through + # Source: has_many through + # Through: has_many def test_has_many_through_a_has_many_through_association_on_source_reflection author = authors(:david) assert_equal [tags(:general), tags(:general)], author.tags @@ -41,6 +61,9 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert authors.empty? end + # has_many through + # Source: has_many + # Through: has_many through def test_has_many_through_a_has_many_through_association_on_through_reflection author = authors(:david) assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], author.subscribers @@ -49,6 +72,46 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase authors = Author.joins(:subscribers).where('subscribers.nick' => 'alterself') assert_equal [authors(:david)], authors end + + # TODO: has_many through + # Source: has_one through + # Through: has_one + + # TODO: has_many through + # Source: has_one + # Through: has_one through + + # TODO: has_many through + # Source: has_many through + # Through: has_one + + # TODO: has_many through + # Source: has_many + # Through: has_one through + + # TODO: has_many through + # Source: has_and_belongs_to_many + # Through: has_many + + # TODO: has_many through + # Source: has_many + # Through: has_and_belongs_to_many + + # TODO: has_many through + # Source: belongs_to + # Through: has_many through + + # TODO: has_many through + # Source: has_many through + # Through: belongs_to + + # TODO: has_one through + # Source: has_one through + # Through: has_one + + # TODO: has_one through + # Source: belongs_to + # Through: has_one through def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection author = authors(:david) -- cgit v1.2.3 From 1777600e6e11e553ad97b7bc89e4b19e992eb3d3 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 16:40:24 +0100 Subject: Support has_one through assocs as the source association --- .../associations/through_association_scope.rb | 31 ++++++++++++------- .../nested_has_many_through_associations_test.rb | 35 ++++++++++++++++++++-- activerecord/test/fixtures/member_details.yml | 3 ++ activerecord/test/fixtures/members.yml | 2 ++ activerecord/test/fixtures/memberships.yml | 6 ++-- activerecord/test/fixtures/sponsors.yml | 9 ++++-- activerecord/test/models/member.rb | 5 +++- 7 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 activerecord/test/fixtures/member_details.yml diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 8406f5fd20..81e29f047b 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -53,7 +53,7 @@ module ActiveRecord end def construct_joins(custom_joins = nil) - # puts @reflection.through_reflection_chain.map(&:inspect) + # p @reflection.through_reflection_chain "#{construct_through_joins} #{@reflection.options[:joins]} #{custom_joins}" end @@ -67,16 +67,27 @@ module ActiveRecord case when left.source_reflection.nil? - left_primary_key = left.primary_key_name - right_primary_key = right.klass.primary_key + # TODO: Perhaps need to pay attention to left.options[:primary_key] and + # left.options[:foreign_key] in places here - if left.options[:as] - polymorphic_join = "AND %s.%s = %s" % [ - table_aliases[left], "#{left.options[:as]}_type", - # TODO: Why right.klass.name? Rather than left.active_record.name? - # TODO: Also should maybe use the base_class (see related code in JoinAssociation) - @owner.class.quote_value(right.klass.name) - ] + case left.macro + when :belongs_to + left_primary_key = left.klass.primary_key + right_primary_key = right.primary_key_name + when :has_many, :has_one + left_primary_key = left.primary_key_name + right_primary_key = right.klass.primary_key + + if left.options[:as] + polymorphic_join = "AND %s.%s = %s" % [ + table_aliases[left], "#{left.options[:as]}_type", + # TODO: Why right.klass.name? Rather than left.active_record.name? + # TODO: Also should maybe use the base_class (see related code in JoinAssociation) + @owner.class.quote_value(right.klass.name) + ] + end + when :has_and_belongs_to_many + raise NotImplementedError end when left.source_reflection.macro == :belongs_to left_primary_key = left.klass.primary_key diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 8a4ab627bb..4fab426696 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -18,6 +18,9 @@ require 'models/subscriber' require 'models/book' require 'models/subscription' require 'models/rating' +require 'models/member' +require 'models/member_detail' +require 'models/member_type' # NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which # are just one level deep. But it's all the same thing really, as the "nested" code is being @@ -26,7 +29,8 @@ require 'models/rating' class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, - :people, :readers, :references, :jobs, :ratings, :comments + :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, + :member_types # Through associations can either use the has_many or has_one macros. # @@ -56,6 +60,9 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase authors = Author.joins(:tags).where('tags.id' => tags(:general).id) assert_equal [authors(:david)], authors.uniq + authors = Author.includes(:tags) + assert_equal [tags(:general), tags(:general)], authors.first.tags + # This ensures that the polymorphism of taggings is being observed correctly authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') assert authors.empty? @@ -71,11 +78,24 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # All authors with subscribers where one of the subscribers' nick is 'alterself' authors = Author.joins(:subscribers).where('subscribers.nick' => 'alterself') assert_equal [authors(:david)], authors + + # TODO: Make this work + # authors = Author.includes(:subscribers) + # assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], authors.first.subscribers end - # TODO: has_many through + # has_many through # Source: has_one through # Through: has_one + def test_has_many_through_has_one_with_has_one_through_source_reflection + assert_equal [member_types(:founding)], members(:groucho).nested_member_types + + members = Member.joins(:nested_member_types).where('member_types.id' => member_types(:founding).id) + assert_equal [members(:groucho)], members + + members = Member.includes(:nested_member_types) + assert_equal [member_types(:founding)], members.first.nested_member_types + end # TODO: has_many through # Source: has_one @@ -105,9 +125,18 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # Source: has_many through # Through: belongs_to - # TODO: has_one through + # has_one through # Source: has_one through # Through: has_one + def test_has_one_through_has_one_with_has_one_through_source_reflection + assert_equal member_types(:founding), members(:groucho).nested_member_type + + members = Member.joins(:nested_member_type).where('member_types.id' => member_types(:founding).id) + assert_equal [members(:groucho)], members + + members = Member.includes(:nested_member_type) + assert_equal member_types(:founding), members.first.nested_member_type + end # TODO: has_one through # Source: belongs_to diff --git a/activerecord/test/fixtures/member_details.yml b/activerecord/test/fixtures/member_details.yml new file mode 100644 index 0000000000..020932246a --- /dev/null +++ b/activerecord/test/fixtures/member_details.yml @@ -0,0 +1,3 @@ +groucho: + id: 1 + member_id: 1 diff --git a/activerecord/test/fixtures/members.yml b/activerecord/test/fixtures/members.yml index 6db945e61d..824840b7e5 100644 --- a/activerecord/test/fixtures/members.yml +++ b/activerecord/test/fixtures/members.yml @@ -1,6 +1,8 @@ groucho: + id: 1 name: Groucho Marx member_type_id: 1 some_other_guy: + id: 2 name: Englebert Humperdink member_type_id: 2 diff --git a/activerecord/test/fixtures/memberships.yml b/activerecord/test/fixtures/memberships.yml index b9722dbc8a..eed8b22af8 100644 --- a/activerecord/test/fixtures/memberships.yml +++ b/activerecord/test/fixtures/memberships.yml @@ -1,20 +1,20 @@ membership_of_boring_club: joined_on: <%= 3.weeks.ago.to_s(:db) %> club: boring_club - member: groucho + member_id: 1 favourite: false type: CurrentMembership membership_of_favourite_club: joined_on: <%= 3.weeks.ago.to_s(:db) %> club: moustache_club - member: groucho + member_id: 1 favourite: true type: Membership other_guys_membership: joined_on: <%= 4.weeks.ago.to_s(:db) %> club: boring_club - member: some_other_guy + member_id: 2 favourite: false type: CurrentMembership diff --git a/activerecord/test/fixtures/sponsors.yml b/activerecord/test/fixtures/sponsors.yml index 42df8957d1..bfc6b238b1 100644 --- a/activerecord/test/fixtures/sponsors.yml +++ b/activerecord/test/fixtures/sponsors.yml @@ -1,9 +1,12 @@ moustache_club_sponsor_for_groucho: sponsor_club: moustache_club - sponsorable: groucho (Member) + sponsorable_id: 1 + sponsorable_type: Member boring_club_sponsor_for_groucho: sponsor_club: boring_club - sponsorable: some_other_guy (Member) + sponsorable_id: 2 + sponsorable_type: Member crazy_club_sponsor_for_groucho: sponsor_club: crazy_club - sponsorable: some_other_guy (Member) \ No newline at end of file + sponsorable_id: 2 + sponsorable_type: Member diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index 255fb569d7..b8b22d0fde 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -9,4 +9,7 @@ class Member < ActiveRecord::Base has_one :member_detail has_one :organization, :through => :member_detail belongs_to :member_type -end \ No newline at end of file + + has_many :nested_member_types, :through => :member_detail, :source => :member_type + has_one :nested_member_type, :through => :member_detail, :source => :member_type +end -- cgit v1.2.3 From 6a016a551109ed2ef78fff8f74aef6b1f4ae96a9 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 16:53:22 +0100 Subject: Add test_has_many_through_has_one_through --- .../nested_has_many_through_associations_test.rb | 16 ++++++++++++++-- activerecord/test/models/member.rb | 3 +++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 4fab426696..c1e8a4b1bf 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -21,6 +21,8 @@ require 'models/rating' require 'models/member' require 'models/member_detail' require 'models/member_type' +require 'models/sponsor' +require 'models/club' # NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which # are just one level deep. But it's all the same thing really, as the "nested" code is being @@ -30,7 +32,7 @@ require 'models/member_type' class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, - :member_types + :member_types, :sponsors, :clubs # Through associations can either use the has_many or has_one macros. # @@ -97,9 +99,19 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [member_types(:founding)], members.first.nested_member_types end - # TODO: has_many through + # has_many through # Source: has_one # Through: has_one through + def test_has_many_through_has_one_through + assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members(:groucho).nested_sponsors + + members = Member.joins(:nested_sponsors).where('sponsors.id' => sponsors(:moustache_club_sponsor_for_groucho).id) + assert_equal [members(:groucho)], members + + # TODO: Make this work + # members = Member.includes(:nested_sponsors) + # assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members.first.nested_sponsors + end # TODO: has_many through # Source: has_many through diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index b8b22d0fde..c2dd9efe3b 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -12,4 +12,7 @@ class Member < ActiveRecord::Base has_many :nested_member_types, :through => :member_detail, :source => :member_type has_one :nested_member_type, :through => :member_detail, :source => :member_type + + has_many :nested_sponsors, :through => :sponsor_club, :source => :sponsor + has_one :nested_sponsor, :through => :sponsor_club, :source => :sponsor end -- cgit v1.2.3 From 61073861856110b4a842a4d5e1033698fd52901f Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 16:54:43 +0100 Subject: Rename some tests for consistency --- .../cases/associations/nested_has_many_through_associations_test.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index c1e8a4b1bf..bc0fb8582d 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -54,7 +54,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # has_many through # Source: has_many through # Through: has_many - def test_has_many_through_a_has_many_through_association_on_source_reflection + def test_has_many_through_has_many_with_has_many_through_source_reflection author = authors(:david) assert_equal [tags(:general), tags(:general)], author.tags @@ -73,7 +73,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # has_many through # Source: has_many # Through: has_many through - def test_has_many_through_a_has_many_through_association_on_through_reflection + def test_has_many_through_has_many_through_with_has_many_source_reflection author = authors(:david) assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], author.subscribers @@ -102,7 +102,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # has_many through # Source: has_one # Through: has_one through - def test_has_many_through_has_one_through + def test_has_many_through_has_one_through_with_has_one_source_reflection assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members(:groucho).nested_sponsors members = Member.joins(:nested_sponsors).where('sponsors.id' => sponsors(:moustache_club_sponsor_for_groucho).id) -- cgit v1.2.3 From dc39aceb94fa810f8d7e263c0293f325fbf9a109 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 17:27:10 +0100 Subject: Adding test_has_many_through_has_one_with_has_many_through_source_reflection and modifying ThroughAssociationScope to make it work correctly. --- .../associations/through_association_scope.rb | 2 +- .../nested_has_many_through_associations_test.rb | 21 +++++++++++++++++++-- activerecord/test/fixtures/member_details.yml | 5 +++++ activerecord/test/models/member.rb | 2 ++ activerecord/test/models/member_detail.rb | 2 ++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 81e29f047b..09f92332cf 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -73,7 +73,7 @@ module ActiveRecord case left.macro when :belongs_to left_primary_key = left.klass.primary_key - right_primary_key = right.primary_key_name + right_primary_key = left.primary_key_name when :has_many, :has_one left_primary_key = left.primary_key_name right_primary_key = right.klass.primary_key diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index bc0fb8582d..4b5ce6313a 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -23,6 +23,7 @@ require 'models/member_detail' require 'models/member_type' require 'models/sponsor' require 'models/club' +require 'models/organization' # NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which # are just one level deep. But it's all the same thing really, as the "nested" code is being @@ -32,7 +33,7 @@ require 'models/club' class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, - :member_types, :sponsors, :clubs + :member_types, :sponsors, :clubs, :organizations # Through associations can either use the has_many or has_one macros. # @@ -113,9 +114,25 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members.first.nested_sponsors end - # TODO: has_many through + # has_many through # Source: has_many through # Through: has_one + def test_has_many_through_has_one_with_has_many_through_source_reflection + assert_equal [member_details(:groucho), member_details(:some_other_guy)], + members(:groucho).organization_member_details + + members = Member.joins(:organization_member_details). + where('member_details.id' => member_details(:groucho).id) + assert_equal [members(:groucho), members(:some_other_guy)], members + + members = Member.joins(:organization_member_details). + where('member_details.id' => 9) + assert members.empty? + + members = Member.includes(:organization_member_details) + assert_equal [member_details(:groucho), member_details(:some_other_guy)], + members.first.organization_member_details + end # TODO: has_many through # Source: has_many diff --git a/activerecord/test/fixtures/member_details.yml b/activerecord/test/fixtures/member_details.yml index 020932246a..e1fe695a9b 100644 --- a/activerecord/test/fixtures/member_details.yml +++ b/activerecord/test/fixtures/member_details.yml @@ -1,3 +1,8 @@ groucho: id: 1 member_id: 1 + organization: nsa +some_other_guy: + id: 2 + member_id: 2 + organization: nsa diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index c2dd9efe3b..1113ef3e28 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -15,4 +15,6 @@ class Member < ActiveRecord::Base has_many :nested_sponsors, :through => :sponsor_club, :source => :sponsor has_one :nested_sponsor, :through => :sponsor_club, :source => :sponsor + + has_many :organization_member_details, :through => :member_detail end diff --git a/activerecord/test/models/member_detail.rb b/activerecord/test/models/member_detail.rb index 94f59e5794..0f53b69ced 100644 --- a/activerecord/test/models/member_detail.rb +++ b/activerecord/test/models/member_detail.rb @@ -2,4 +2,6 @@ class MemberDetail < ActiveRecord::Base belongs_to :member belongs_to :organization has_one :member_type, :through => :member + + has_many :organization_member_details, :through => :organization, :source => :member_details end -- cgit v1.2.3 From 56064aa4b014233ae54413628679b7f7fa5d6f77 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 17:32:52 +0100 Subject: Add test_has_many_through_has_one_through_with_has_many_source_reflection --- .../nested_has_many_through_associations_test.rb | 19 ++++++++++++++++++- activerecord/test/models/member.rb | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 4b5ce6313a..7c4bffaddd 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -134,9 +134,26 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase members.first.organization_member_details end - # TODO: has_many through + # has_many through # Source: has_many # Through: has_one through + def test_has_many_through_has_one_through_with_has_many_source_reflection + assert_equal [member_details(:groucho), member_details(:some_other_guy)], + members(:groucho).organization_member_details_2 + + members = Member.joins(:organization_member_details_2). + where('member_details.id' => member_details(:groucho).id) + assert_equal [members(:groucho), members(:some_other_guy)], members + + members = Member.joins(:organization_member_details_2). + where('member_details.id' => 9) + assert members.empty? + + # TODO: Make this work + # members = Member.includes(:organization_member_details_2) + # assert_equal [member_details(:groucho), member_details(:some_other_guy)], + # members.first.organization_member_details_2 + end # TODO: has_many through # Source: has_and_belongs_to_many diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index 1113ef3e28..44c10cc4a4 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -17,4 +17,5 @@ class Member < ActiveRecord::Base has_one :nested_sponsor, :through => :sponsor_club, :source => :sponsor has_many :organization_member_details, :through => :member_detail + has_many :organization_member_details_2, :through => :organization, :source => :member_details end -- cgit v1.2.3 From c37a5e7acde436b359043a67b7daace8be6f08c6 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 18:16:31 +0100 Subject: Add a commented, failing test for using a habtm in a has many through association. I want to refactor how aliasing works first. --- .../associations/through_association_scope.rb | 96 +++++++++++----------- .../has_and_belongs_to_many_associations_test.rb | 8 +- .../nested_has_many_through_associations_test.rb | 9 +- activerecord/test/fixtures/categories_posts.yml | 8 ++ activerecord/test/models/author.rb | 8 +- 5 files changed, 74 insertions(+), 55 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 09f92332cf..8c5b95439e 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -65,52 +65,56 @@ module ActiveRecord @reflection.through_reflection_chain.each_cons(2) do |left, right| polymorphic_join = nil - case - when left.source_reflection.nil? - # TODO: Perhaps need to pay attention to left.options[:primary_key] and - # left.options[:foreign_key] in places here - - case left.macro - when :belongs_to - left_primary_key = left.klass.primary_key - right_primary_key = left.primary_key_name - when :has_many, :has_one - left_primary_key = left.primary_key_name - right_primary_key = right.klass.primary_key - - if left.options[:as] - polymorphic_join = "AND %s.%s = %s" % [ - table_aliases[left], "#{left.options[:as]}_type", - # TODO: Why right.klass.name? Rather than left.active_record.name? - # TODO: Also should maybe use the base_class (see related code in JoinAssociation) - @owner.class.quote_value(right.klass.name) - ] - end - when :has_and_belongs_to_many - raise NotImplementedError - end - when left.source_reflection.macro == :belongs_to - left_primary_key = left.klass.primary_key - right_primary_key = left.source_reflection.primary_key_name - - if left.options[:source_type] - polymorphic_join = "AND %s.%s = %s" % [ - table_aliases[right], - left.source_reflection.options[:foreign_type].to_s, - @owner.class.quote_value(left.options[:source_type]) - ] - end - else - left_primary_key = left.source_reflection.primary_key_name - right_primary_key = right.klass.primary_key - - if left.source_reflection.options[:as] - polymorphic_join = "AND %s.%s = %s" % [ - table_aliases[left], - "#{left.source_reflection.options[:as]}_type", - @owner.class.quote_value(right.klass.name) - ] - end + if left.source_reflection.nil? + # TODO: Perhaps need to pay attention to left.options[:primary_key] and + # left.options[:foreign_key] in places here + + case left.macro + when :belongs_to + left_primary_key = left.klass.primary_key + right_primary_key = left.primary_key_name + when :has_many, :has_one + left_primary_key = left.primary_key_name + right_primary_key = right.klass.primary_key + + if left.options[:as] + polymorphic_join = "AND %s.%s = %s" % [ + table_aliases[left], "#{left.options[:as]}_type", + # TODO: Why right.klass.name? Rather than left.active_record.name? + # TODO: Also should maybe use the base_class (see related code in JoinAssociation) + @owner.class.quote_value(right.klass.name) + ] + end + when :has_and_belongs_to_many + raise NotImplementedError + end + else + case left.source_reflection.macro + when :belongs_to + left_primary_key = left.klass.primary_key + right_primary_key = left.source_reflection.primary_key_name + + if left.options[:source_type] + polymorphic_join = "AND %s.%s = %s" % [ + table_aliases[right], + left.source_reflection.options[:foreign_type].to_s, + @owner.class.quote_value(left.options[:source_type]) + ] + end + when :has_many, :has_one + left_primary_key = left.source_reflection.primary_key_name + right_primary_key = right.klass.primary_key + + if left.source_reflection.options[:as] + polymorphic_join = "AND %s.%s = %s" % [ + table_aliases[left], + "#{left.source_reflection.options[:as]}_type", + @owner.class.quote_value(right.klass.name) + ] + end + when :has_and_belongs_to_many + raise NotImplementedError + end end if right.quoted_table_name == table_aliases[right] diff --git a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb index 7e070e1746..e67cbcc1a8 100644 --- a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb @@ -713,13 +713,13 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_find_grouped all_posts_from_category1 = Post.find(:all, :conditions => "category_id = 1", :joins => :categories) grouped_posts_of_category1 = Post.find(:all, :conditions => "category_id = 1", :group => "author_id", :select => 'count(posts.id) as posts_count', :joins => :categories) - assert_equal 4, all_posts_from_category1.size - assert_equal 1, grouped_posts_of_category1.size + assert_equal 5, all_posts_from_category1.size + assert_equal 2, grouped_posts_of_category1.size end def test_find_scoped_grouped - assert_equal 4, categories(:general).posts_grouped_by_title.size - assert_equal 1, categories(:technology).posts_grouped_by_title.size + assert_equal 5, categories(:general).posts_grouped_by_title.size + assert_equal 2, categories(:technology).posts_grouped_by_title.size end def test_find_scoped_grouped_having diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 7c4bffaddd..835a573978 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -24,6 +24,7 @@ require 'models/member_type' require 'models/sponsor' require 'models/club' require 'models/organization' +require 'models/category' # NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which # are just one level deep. But it's all the same thing really, as the "nested" code is being @@ -33,7 +34,7 @@ require 'models/organization' class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, - :member_types, :sponsors, :clubs, :organizations + :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts # Through associations can either use the has_many or has_one macros. # @@ -155,9 +156,13 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # members.first.organization_member_details_2 end - # TODO: has_many through + # has_many through # Source: has_and_belongs_to_many # Through: has_many + # TODO: Enable and implement this, and finish off the test + # def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection + # assert_equal [categories(:general), categories(:technology)], authors(:bob).post_categories + # end # TODO: has_many through # Source: has_many diff --git a/activerecord/test/fixtures/categories_posts.yml b/activerecord/test/fixtures/categories_posts.yml index 9b67ab4fa4..3b41510cb1 100644 --- a/activerecord/test/fixtures/categories_posts.yml +++ b/activerecord/test/fixtures/categories_posts.yml @@ -21,3 +21,11 @@ sti_test_sti_habtm: general_hello: category_id: 1 post_id: 4 + +general_misc_by_bob: + category_id: 1 + post_id: 8 + +technology_misc_by_bob: + category_id: 2 + post_id: 8 diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 1fbd729b60..584164f19a 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -83,9 +83,9 @@ class Author < ActiveRecord::Base has_many :author_favorites has_many :favorite_authors, :through => :author_favorites, :order => 'name' - has_many :tagging, :through => :posts # through polymorphic has_one - has_many :taggings, :through => :posts # through polymorphic has_many - has_many :tags, :through => :posts # through has_many :through (on source reflection + polymorphic) + has_many :tagging, :through => :posts + has_many :taggings, :through => :posts + has_many :tags, :through => :posts has_many :similar_posts, :through => :tags, :source => :tagged_posts has_many :distinct_tags, :through => :posts, :source => :tags, :select => "DISTINCT tags.*", :order => "tags.name" has_many :post_categories, :through => :posts, :source => :categories @@ -100,6 +100,8 @@ class Author < ActiveRecord::Base belongs_to :author_address, :dependent => :destroy belongs_to :author_address_extra, :dependent => :delete, :class_name => "AuthorAddress" + has_many :post_categories, :through => :posts, :source => :categories + scope :relation_include_posts, includes(:posts) scope :relation_include_tags, includes(:tags) -- cgit v1.2.3 From e8874318b7a025ffd30df1a53c403eb9d8912c9f Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 19:49:32 +0100 Subject: Extract aliasing code from JoinDependency and JoinAssociation into a separate AliasTracker class. This can then be used by ThroughAssociationScope as well. --- activerecord/lib/active_record/associations.rb | 56 +++++------------- .../active_record/associations/alias_tracker.rb | 68 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 41 deletions(-) create mode 100644 activerecord/lib/active_record/associations/alias_tracker.rb diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 397159d35e..d0d1eeec45 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -114,6 +114,7 @@ module ActiveRecord autoload :NestedHasManyThroughAssociation, 'active_record/associations/nested_has_many_through_association' autoload :HasOneAssociation, 'active_record/associations/has_one_association' autoload :HasOneThroughAssociation, 'active_record/associations/has_one_through_association' + autoload :AliasTracker, 'active_record/associations/alias_tracker' # Clears out the association cache. def clear_association_cache #:nodoc: @@ -1834,7 +1835,7 @@ module ActiveRecord end class JoinDependency # :nodoc: - attr_reader :join_parts, :reflections, :table_aliases + attr_reader :join_parts, :reflections, :alias_tracker def initialize(base, associations, joins) @join_parts = [JoinBase.new(base, joins)] @@ -1842,8 +1843,8 @@ module ActiveRecord @reflections = [] @base_records_hash = {} @base_records_in_order = [] - @table_aliases = Hash.new(0) - @table_aliases[base.table_name] = 1 + @alias_tracker = AliasTracker.new(joins) + @alias_tracker.aliased_name_for(base.table_name) # Updates the count for base.table_name to 1 build(associations) end @@ -1863,17 +1864,6 @@ module ActiveRecord join_parts.first end - def count_aliases_from_table_joins(name) - # quoted_name should be downcased as some database adapters (Oracle) return quoted name in uppercase - quoted_name = join_base.active_record.connection.quote_table_name(name.downcase).downcase - join_sql = join_base.table_joins.to_s.downcase - join_sql.blank? ? 0 : - # Table names - join_sql.scan(/join(?:\s+\w+)?\s+#{quoted_name}\son/).size + - # Table aliases - join_sql.scan(/join(?:\s+\w+)?\s+\S+\s+#{quoted_name}\son/).size - end - def instantiate(rows) rows.each_with_index do |row, i| primary_id = join_base.record_id(row) @@ -2130,6 +2120,7 @@ module ActiveRecord delegate :options, :through_reflection, :source_reflection, :through_reflection_chain, :to => :reflection delegate :table, :table_name, :to => :parent, :prefix => true + delegate :alias_tracker, :to => :join_dependency def initialize(reflection, join_dependency, parent = nil) reflection.check_validity! @@ -2248,24 +2239,10 @@ module ActiveRecord protected - def aliased_table_name_for(name, aliased_name, suffix = nil) - if @join_dependency.table_aliases[name].zero? - @join_dependency.table_aliases[name] = @join_dependency.count_aliases_from_table_joins(name) - end - - if !@join_dependency.table_aliases[name].zero? # We need an alias - name = active_record.connection.table_alias_for "#{aliased_name}_#{parent_table_name}#{suffix}" - @join_dependency.table_aliases[name] += 1 - if @join_dependency.table_aliases[name] == 1 # First time we've seen this name - # Also need to count the aliases from the table_aliases to avoid incorrect count - @join_dependency.table_aliases[name] += @join_dependency.count_aliases_from_table_joins(name) - end - table_index = @join_dependency.table_aliases[name] - name = name[0..active_record.connection.table_alias_length-3] + "_#{table_index}" if table_index > 1 - else - @join_dependency.table_aliases[name] += 1 - end - + def table_alias_for(reflection) + name = pluralize(reflection.name) + name << "_#{parent_table_name}" + name << "_join" if reflection != self.reflection name end @@ -2273,12 +2250,12 @@ module ActiveRecord ActiveRecord::Base.pluralize_table_names ? table_name.to_s.pluralize : table_name end - def table_alias_for(table_name, table_alias) + def table_name_and_alias_for(table_name, table_alias) "#{table_name} #{table_alias if table_name != table_alias}".strip end def table_name_and_alias - table_alias_for table_name, aliased_table_name + table_name_and_alias_for(table_name, aliased_table_name) end def interpolate_sql(sql) @@ -2292,12 +2269,9 @@ module ActiveRecord # the proper alias. def setup_tables @tables = through_reflection_chain.map do |reflection| - suffix = reflection == self.reflection ? nil : '_join' - - aliased_table_name = aliased_table_name_for( + aliased_table_name = alias_tracker.aliased_name_for( reflection.table_name, - pluralize(reflection.name), - suffix + table_alias_for(reflection) ) table = Arel::Table.new( @@ -2308,9 +2282,9 @@ module ActiveRecord # For habtm, we have two Arel::Table instances related to a single reflection, so # we just store them as a pair in the array. if reflection.macro == :has_and_belongs_to_many - aliased_join_table_name = aliased_table_name_for( + aliased_join_table_name = alias_tracker.aliased_name_for( reflection.options[:join_table], - pluralize(reflection.name), "_join" + table_alias_for(reflection) ) join_table = Arel::Table.new( diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb new file mode 100644 index 0000000000..f48efabec2 --- /dev/null +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -0,0 +1,68 @@ +require 'active_support/core_ext/string/conversions' + +module ActiveRecord + module Associations + # Keeps track of table aliases for ActiveRecord::Associations::ClassMethods::JoinDependency + class AliasTracker # :nodoc: + # other_sql is some other sql which might conflict with the aliases we assign here. Therefore + # we store other_sql so that we can scan it before assigning a specific name. + def initialize(other_sql) + @aliases = Hash.new + @other_sql = other_sql.to_s.downcase + end + + def aliased_name_for(table_name, aliased_name = nil) + aliased_name ||= table_name + + initialize_count_for(table_name) if @aliases[table_name].nil? + + if @aliases[table_name].zero? + # If it's zero, we can have our table_name + @aliases[table_name] = 1 + table_name + else + # Otherwise, we need to use an alias + aliased_name = connection.table_alias_for(aliased_name) + + initialize_count_for(aliased_name) if @aliases[aliased_name].nil? + + # Update the count + @aliases[aliased_name] += 1 + + if @aliases[aliased_name] > 1 + "#{truncate(aliased_name)}_#{@aliases[aliased_name]}" + else + aliased_name + end + end + end + + private + + def initialize_count_for(name) + @aliases[name] = 0 + + unless @other_sql.blank? + # quoted_name should be downcased as some database adapters (Oracle) return quoted name in uppercase + quoted_name = connection.quote_table_name(name.downcase).downcase + + # Table names + @aliases[name] += @other_sql.scan(/join(?:\s+\w+)?\s+#{quoted_name}\son/).size + + # Table aliases + @aliases[name] += @other_sql.scan(/join(?:\s+\w+)?\s+\S+\s+#{quoted_name}\son/).size + end + + @aliases[name] + end + + def truncate(name) + name[0..connection.table_alias_length-3] + end + + def connection + ActiveRecord::Base.connection + end + end + end +end -- cgit v1.2.3 From 3f2e25805d56440a4ef2a7a9ae6b99be04e6357b Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 12 Oct 2010 23:42:30 +0100 Subject: Some small tweaks on the last commit --- activerecord/lib/active_record/associations.rb | 22 +++++----------------- .../active_record/associations/alias_tracker.rb | 9 +++++++-- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index d0d1eeec45..41f882743c 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2239,25 +2239,13 @@ module ActiveRecord protected - def table_alias_for(reflection) - name = pluralize(reflection.name) + def table_alias_for(reflection, join = false) + name = alias_tracker.pluralize(reflection.name) name << "_#{parent_table_name}" - name << "_join" if reflection != self.reflection + name << "_join" if join name end - def pluralize(table_name) - ActiveRecord::Base.pluralize_table_names ? table_name.to_s.pluralize : table_name - end - - def table_name_and_alias_for(table_name, table_alias) - "#{table_name} #{table_alias if table_name != table_alias}".strip - end - - def table_name_and_alias - table_name_and_alias_for(table_name, aliased_table_name) - end - def interpolate_sql(sql) instance_eval("%@#{sql.gsub('@', '\@')}@", __FILE__, __LINE__) end @@ -2271,7 +2259,7 @@ module ActiveRecord @tables = through_reflection_chain.map do |reflection| aliased_table_name = alias_tracker.aliased_name_for( reflection.table_name, - table_alias_for(reflection) + table_alias_for(reflection, reflection != self.reflection) ) table = Arel::Table.new( @@ -2284,7 +2272,7 @@ module ActiveRecord if reflection.macro == :has_and_belongs_to_many aliased_join_table_name = alias_tracker.aliased_name_for( reflection.options[:join_table], - table_alias_for(reflection) + table_alias_for(reflection, true) ) join_table = Arel::Table.new( diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index f48efabec2..10e90ec117 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -2,11 +2,12 @@ require 'active_support/core_ext/string/conversions' module ActiveRecord module Associations - # Keeps track of table aliases for ActiveRecord::Associations::ClassMethods::JoinDependency + # Keeps track of table aliases for ActiveRecord::Associations::ClassMethods::JoinDependency and + # ActiveRecord::Associations::ThroughAssociationScope class AliasTracker # :nodoc: # other_sql is some other sql which might conflict with the aliases we assign here. Therefore # we store other_sql so that we can scan it before assigning a specific name. - def initialize(other_sql) + def initialize(other_sql = nil) @aliases = Hash.new @other_sql = other_sql.to_s.downcase end @@ -36,6 +37,10 @@ module ActiveRecord end end end + + def pluralize(table_name) + ActiveRecord::Base.pluralize_table_names ? table_name.to_s.pluralize : table_name + end private -- cgit v1.2.3 From 199db8c8c006a5f3bcbbe2a32d39444a741c5843 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Wed, 13 Oct 2010 00:05:04 +0100 Subject: Hook ThroughAssociationScope up to use the AliasTracker class --- .../associations/through_association_scope.rb | 39 +++++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 8c5b95439e..d73f35c2db 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -20,6 +20,7 @@ module ActiveRecord end # Build SQL conditions from attributes, qualified by table name. + # TODO: Conditions on joins def construct_conditions reflection = @reflection.through_reflection_chain.last conditions = construct_quoted_owner_attributes(reflection).map do |attr, value| @@ -134,24 +135,44 @@ module ActiveRecord joins.join(" ") end - # TODO: Use the same aliasing strategy (and code?) as JoinAssociation (as this is the - # documented behaviour) + def alias_tracker + @alias_tracker ||= AliasTracker.new + end + def table_aliases @table_aliases ||= begin - tally = {} @reflection.through_reflection_chain.inject({}) do |aliases, reflection| - if tally[reflection.table_name].nil? - tally[reflection.table_name] = 1 - aliases[reflection] = reflection.quoted_table_name + table_alias = quote_table_name(alias_tracker.aliased_name_for( + reflection.table_name, + table_alias_for(reflection, reflection != @reflection) + )) + + if reflection.macro == :has_and_belongs_to_many + join_table_alias = quote_table_name(alias_tracker.aliased_name_for( + reflection.options[:join_table], + table_alias_for(reflection, true) + )) + + aliases[reflection] = [table_alias, join_table_alias] else - tally[reflection.table_name] += 1 - aliased_table_name = reflection.table_name + "_#{tally[reflection.table_name]}" - aliases[reflection] = reflection.klass.connection.quote_table_name(aliased_table_name) + aliases[reflection] = table_alias end + aliases end end end + + def table_alias_for(reflection, join = false) + name = alias_tracker.pluralize(reflection.name) + name << "_#{@reflection.name}" + name << "_join" if join + name + end + + def quote_table_name(table_name) + @reflection.klass.connection.quote_table_name(table_name) + end # Construct attributes for associate pointing to owner. def construct_owner_attributes(reflection) -- cgit v1.2.3 From 781ad0f8fee209bcf10c5e52daae246477d49ea7 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Wed, 13 Oct 2010 01:29:09 +0100 Subject: First bit of support for habtm in through assocs - test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection now passes --- activerecord/lib/active_record/associations.rb | 45 +++++++++++++++++----- .../associations/through_association_scope.rb | 45 +++++++++++++++------- .../has_and_belongs_to_many_associations_test.rb | 2 +- .../nested_has_many_through_associations_test.rb | 13 +++++-- activerecord/test/fixtures/categories.yml | 5 +++ activerecord/test/fixtures/categories_posts.yml | 4 +- 6 files changed, 84 insertions(+), 30 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 41f882743c..2a72fa95c9 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2180,6 +2180,7 @@ module ActiveRecord # to represent the join table) table, join_table = table + # TODO: Can join_key just be reflection.primary_key_name ? join_key = reflection.options[:foreign_key] || reflection.active_record.to_s.foreign_key join_foreign_key = reflection.active_record.primary_key @@ -2192,18 +2193,37 @@ module ActiveRecord # We've done the first join now, so update the foreign_table for the second foreign_table = join_table + # TODO: Can foreign_key be reflection.association_foreign_key? key = reflection.klass.primary_key foreign_key = reflection.options[:association_foreign_key] || reflection.klass.to_s.foreign_key end - elsif reflection.source_reflection.macro == :belongs_to - key = reflection.klass.primary_key - foreign_key = reflection.source_reflection.primary_key_name - - conditions << source_type_conditions(reflection, foreign_table) else - key = reflection.source_reflection.primary_key_name - foreign_key = reflection.source_reflection.klass.primary_key + case reflection.source_reflection.macro + when :belongs_to + key = reflection.klass.primary_key + foreign_key = reflection.source_reflection.primary_key_name + + conditions << source_type_conditions(reflection, foreign_table) + when :has_many, :has_one + key = reflection.source_reflection.primary_key_name + foreign_key = reflection.source_reflection.klass.primary_key + when :has_and_belongs_to_many + table, join_table = table + + join_key = reflection.source_reflection.primary_key_name + join_foreign_key = reflection.source_reflection.klass.primary_key + + relation = relation.join(join_table, join_type).on( + join_table[join_key]. + eq(foreign_table[join_foreign_key]) + ) + + foreign_table = join_table + + key = reflection.klass.primary_key + foreign_key = reflection.source_reflection.association_foreign_key + end end conditions << table[key].eq(foreign_table[foreign_key]) @@ -2269,14 +2289,19 @@ module ActiveRecord # For habtm, we have two Arel::Table instances related to a single reflection, so # we just store them as a pair in the array. - if reflection.macro == :has_and_belongs_to_many + if reflection.macro == :has_and_belongs_to_many || + (reflection.source_reflection && + reflection.source_reflection.macro == :has_and_belongs_to_many) + + join_table_name = (reflection.source_reflection || reflection).options[:join_table] + aliased_join_table_name = alias_tracker.aliased_name_for( - reflection.options[:join_table], + join_table_name, table_alias_for(reflection, true) ) join_table = Arel::Table.new( - reflection.options[:join_table], :engine => arel_engine, + join_table_name, :engine => arel_engine, :as => aliased_join_table_name ) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index d73f35c2db..6cc2fe2559 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -65,6 +65,7 @@ module ActiveRecord # Iterate over each pair in the through reflection chain, joining them together @reflection.through_reflection_chain.each_cons(2) do |left, right| polymorphic_join = nil + left_table, right_table = table_aliases[left], table_aliases[right] if left.source_reflection.nil? # TODO: Perhaps need to pay attention to left.options[:primary_key] and @@ -114,20 +115,31 @@ module ActiveRecord ] end when :has_and_belongs_to_many - raise NotImplementedError + join_table, left_table = left_table + + left_primary_key = left.klass.primary_key + join_primary_key = left.source_reflection.association_foreign_key + + joins << "INNER JOIN %s ON %s.%s = %s.%s" % [ + table_name_and_alias( + quote_table_name(left.source_reflection.options[:join_table]), + join_table + ), + left_table, left_primary_key, + join_table, join_primary_key + ] + + left_table = join_table + + left_primary_key = left.source_reflection.primary_key_name + right_primary_key = right.klass.primary_key end end - if right.quoted_table_name == table_aliases[right] - table = right.quoted_table_name - else - table = "#{right.quoted_table_name} #{table_aliases[right]}" - end - joins << "INNER JOIN %s ON %s.%s = %s.%s %s" % [ - table, - table_aliases[left], left_primary_key, - table_aliases[right], right_primary_key, + table_name_and_alias(right.quoted_table_name, right_table), + left_table, left_primary_key, + right_table, right_primary_key, polymorphic_join ] end @@ -147,13 +159,16 @@ module ActiveRecord table_alias_for(reflection, reflection != @reflection) )) - if reflection.macro == :has_and_belongs_to_many + if reflection.macro == :has_and_belongs_to_many || + (reflection.source_reflection && + reflection.source_reflection.macro == :has_and_belongs_to_many) + join_table_alias = quote_table_name(alias_tracker.aliased_name_for( - reflection.options[:join_table], + (reflection.source_reflection || reflection).options[:join_table], table_alias_for(reflection, true) )) - aliases[reflection] = [table_alias, join_table_alias] + aliases[reflection] = [join_table_alias, table_alias] else aliases[reflection] = table_alias end @@ -173,6 +188,10 @@ module ActiveRecord def quote_table_name(table_name) @reflection.klass.connection.quote_table_name(table_name) end + + def table_name_and_alias(table_name, table_alias) + "#{table_name} #{table_alias if table_alias != table_name}".strip + end # Construct attributes for associate pointing to owner. def construct_owner_attributes(reflection) diff --git a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb index e67cbcc1a8..c6777d0cb3 100644 --- a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb @@ -719,7 +719,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase def test_find_scoped_grouped assert_equal 5, categories(:general).posts_grouped_by_title.size - assert_equal 2, categories(:technology).posts_grouped_by_title.size + assert_equal 1, categories(:technology).posts_grouped_by_title.size end def test_find_scoped_grouped_having diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 835a573978..964112b006 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -159,10 +159,15 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # has_many through # Source: has_and_belongs_to_many # Through: has_many - # TODO: Enable and implement this, and finish off the test - # def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection - # assert_equal [categories(:general), categories(:technology)], authors(:bob).post_categories - # end + def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection + assert_equal [categories(:general), categories(:cooking)], authors(:bob).post_categories + + authors = Author.joins(:post_categories).where('categories.id' => categories(:cooking).id) + assert_equal [authors(:bob)], authors + + authors = Author.includes(:post_categories) + assert_equal [categories(:general), categories(:cooking)], authors[2].post_categories + end # TODO: has_many through # Source: has_many diff --git a/activerecord/test/fixtures/categories.yml b/activerecord/test/fixtures/categories.yml index b0770a093d..3e75e733a6 100644 --- a/activerecord/test/fixtures/categories.yml +++ b/activerecord/test/fixtures/categories.yml @@ -12,3 +12,8 @@ sti_test: id: 3 name: Special category type: SpecialCategory + +cooking: + id: 4 + name: Cooking + type: Category diff --git a/activerecord/test/fixtures/categories_posts.yml b/activerecord/test/fixtures/categories_posts.yml index 3b41510cb1..c6f0d885f5 100644 --- a/activerecord/test/fixtures/categories_posts.yml +++ b/activerecord/test/fixtures/categories_posts.yml @@ -26,6 +26,6 @@ general_misc_by_bob: category_id: 1 post_id: 8 -technology_misc_by_bob: - category_id: 2 +cooking_misc_by_bob: + category_id: 4 post_id: 8 -- cgit v1.2.3 From 5d8bb060909339d858151ca24bf764c642bf2b12 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Wed, 13 Oct 2010 17:55:41 +0100 Subject: Refactoring ThroughAssociationScope#construct_through_joins --- .../associations/through_association_scope.rb | 116 +++++++++++---------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 6cc2fe2559..25fde49650 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -64,8 +64,7 @@ module ActiveRecord # Iterate over each pair in the through reflection chain, joining them together @reflection.through_reflection_chain.each_cons(2) do |left, right| - polymorphic_join = nil - left_table, right_table = table_aliases[left], table_aliases[right] + right_table_and_alias = table_name_and_alias(right.quoted_table_name, table_aliases[right]) if left.source_reflection.nil? # TODO: Perhaps need to pay attention to left.options[:primary_key] and @@ -73,75 +72,56 @@ module ActiveRecord case left.macro when :belongs_to - left_primary_key = left.klass.primary_key - right_primary_key = left.primary_key_name + joins << inner_join_sql( + right_table_and_alias, + table_aliases[left], left.klass.primary_key, + table_aliases[right], left.primary_key_name + ) when :has_many, :has_one - left_primary_key = left.primary_key_name - right_primary_key = right.klass.primary_key - - if left.options[:as] - polymorphic_join = "AND %s.%s = %s" % [ - table_aliases[left], "#{left.options[:as]}_type", - # TODO: Why right.klass.name? Rather than left.active_record.name? - # TODO: Also should maybe use the base_class (see related code in JoinAssociation) - @owner.class.quote_value(right.klass.name) - ] - end + joins << inner_join_sql( + right_table_and_alias, + table_aliases[left], left.primary_key_name, + table_aliases[right], right.klass.primary_key, + polymorphic_conditions(left, left.options[:as]) + ) when :has_and_belongs_to_many raise NotImplementedError end else case left.source_reflection.macro when :belongs_to - left_primary_key = left.klass.primary_key - right_primary_key = left.source_reflection.primary_key_name - - if left.options[:source_type] - polymorphic_join = "AND %s.%s = %s" % [ - table_aliases[right], - left.source_reflection.options[:foreign_type].to_s, - @owner.class.quote_value(left.options[:source_type]) - ] - end + joins << inner_join_sql( + right_table_and_alias, + table_aliases[left], left.klass.primary_key, + table_aliases[right], left.source_reflection.primary_key_name, + source_type_conditions(left) + ) when :has_many, :has_one - left_primary_key = left.source_reflection.primary_key_name - right_primary_key = right.klass.primary_key - - if left.source_reflection.options[:as] - polymorphic_join = "AND %s.%s = %s" % [ - table_aliases[left], - "#{left.source_reflection.options[:as]}_type", - @owner.class.quote_value(right.klass.name) - ] - end + joins << inner_join_sql( + right_table_and_alias, + table_aliases[left], left.source_reflection.primary_key_name, + table_aliases[right], right.klass.primary_key, + polymorphic_conditions(left, left.source_reflection.options[:as]) + ) when :has_and_belongs_to_many - join_table, left_table = left_table + join_table, left_table = table_aliases[left] - left_primary_key = left.klass.primary_key - join_primary_key = left.source_reflection.association_foreign_key - - joins << "INNER JOIN %s ON %s.%s = %s.%s" % [ + joins << inner_join_sql( table_name_and_alias( quote_table_name(left.source_reflection.options[:join_table]), join_table ), - left_table, left_primary_key, - join_table, join_primary_key - ] - - left_table = join_table + left_table, left.klass.primary_key, + join_table, left.source_reflection.association_foreign_key + ) - left_primary_key = left.source_reflection.primary_key_name - right_primary_key = right.klass.primary_key + joins << inner_join_sql( + right_table_and_alias, + join_table, left.source_reflection.primary_key_name, + table_aliases[right], right.klass.primary_key + ) end end - - joins << "INNER JOIN %s ON %s.%s = %s.%s %s" % [ - table_name_and_alias(right.quoted_table_name, right_table), - left_table, left_primary_key, - right_table, right_primary_key, - polymorphic_join - ] end joins.join(" ") @@ -192,6 +172,34 @@ module ActiveRecord def table_name_and_alias(table_name, table_alias) "#{table_name} #{table_alias if table_alias != table_name}".strip end + + def inner_join_sql(table, on_left_table, on_left_key, on_right_table, on_right_key, conds = nil) + "INNER JOIN %s ON %s.%s = %s.%s %s" % [ + table, + on_left_table, on_left_key, + on_right_table, on_right_key, + conds + ] + end + + def polymorphic_conditions(reflection, interface_name) + if interface_name + "AND %s.%s = %s" % [ + table_aliases[reflection], "#{interface_name}_type", + @owner.class.quote_value(reflection.active_record.base_class.name) + ] + end + end + + def source_type_conditions(reflection) + if reflection.options[:source_type] + "AND %s.%s = %s" % [ + table_aliases[reflection.through_reflection], + reflection.source_reflection.options[:foreign_type].to_s, + @owner.class.quote_value(reflection.options[:source_type]) + ] + end + end # Construct attributes for associate pointing to owner. def construct_owner_attributes(reflection) -- cgit v1.2.3 From 212fdd8ba9624f61421a7a950283537a3d39ac18 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Wed, 13 Oct 2010 18:36:51 +0100 Subject: Add test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection and make it work --- .../associations/through_association_scope.rb | 31 +++++++++++++++++++--- .../nested_has_many_through_associations_test.rb | 12 ++++++++- activerecord/test/models/category.rb | 2 ++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 25fde49650..582474355e 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -23,8 +23,15 @@ module ActiveRecord # TODO: Conditions on joins def construct_conditions reflection = @reflection.through_reflection_chain.last + + if reflection.macro == :has_and_belongs_to_many + table_alias = table_aliases[reflection].first + else + table_alias = table_aliases[reflection] + end + conditions = construct_quoted_owner_attributes(reflection).map do |attr, value| - "#{table_aliases[reflection]}.#{attr} = #{value}" + "#{table_alias}.#{attr} = #{value}" end conditions << sql_conditions if sql_conditions "(" + conditions.join(') AND (') + ")" @@ -97,12 +104,30 @@ module ActiveRecord source_type_conditions(left) ) when :has_many, :has_one + if right.macro == :has_and_belongs_to_many + join_table, right_table = table_aliases[right] + right_table_and_alias = table_name_and_alias(right.quoted_table_name, right_table) + else + right_table = table_aliases[right] + end + joins << inner_join_sql( right_table_and_alias, - table_aliases[left], left.source_reflection.primary_key_name, - table_aliases[right], right.klass.primary_key, + table_aliases[left], left.source_reflection.primary_key_name, + right_table, right.klass.primary_key, polymorphic_conditions(left, left.source_reflection.options[:as]) ) + + if right.macro == :has_and_belongs_to_many + joins << inner_join_sql( + table_name_and_alias( + quote_table_name(right.options[:join_table]), + join_table + ), + right_table, right.klass.primary_key, + join_table, right.association_foreign_key + ) + end when :has_and_belongs_to_many join_table, left_table = table_aliases[left] diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 964112b006..4e7e766b14 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -169,9 +169,19 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [categories(:general), categories(:cooking)], authors[2].post_categories end - # TODO: has_many through + # has_many through # Source: has_many # Through: has_and_belongs_to_many + def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection + assert_equal [comments(:greetings), comments(:more_greetings)], categories(:technology).post_comments + + categories = Category.joins(:post_comments).where('comments.id' => comments(:more_greetings).id) + assert_equal [categories(:general), categories(:technology)], categories + + # TODO: Make this work + # categories = Category.includes(:post_comments) + # assert_equal [comments(:greetings), comments(:more_greetings)], categories[1].post_comments + end # TODO: has_many through # Source: belongs_to diff --git a/activerecord/test/models/category.rb b/activerecord/test/models/category.rb index 48415846dd..c933943813 100644 --- a/activerecord/test/models/category.rb +++ b/activerecord/test/models/category.rb @@ -23,6 +23,8 @@ class Category < ActiveRecord::Base has_many :categorizations has_many :authors, :through => :categorizations, :select => 'authors.*, categorizations.post_id' + + has_many :post_comments, :through => :posts, :source => :comments end class SpecialCategory < Category -- cgit v1.2.3 From 22782e2cc131863b72e457636f9a995a6ae50136 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 14 Oct 2010 12:31:35 +0100 Subject: Fix bug in previous refactoring --- .../active_record/associations/through_association_scope.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 582474355e..c3f12fee2b 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -89,7 +89,7 @@ module ActiveRecord right_table_and_alias, table_aliases[left], left.primary_key_name, table_aliases[right], right.klass.primary_key, - polymorphic_conditions(left, left.options[:as]) + polymorphic_conditions(left, left) ) when :has_and_belongs_to_many raise NotImplementedError @@ -115,7 +115,7 @@ module ActiveRecord right_table_and_alias, table_aliases[left], left.source_reflection.primary_key_name, right_table, right.klass.primary_key, - polymorphic_conditions(left, left.source_reflection.options[:as]) + polymorphic_conditions(left, left.source_reflection) ) if right.macro == :has_and_belongs_to_many @@ -207,11 +207,11 @@ module ActiveRecord ] end - def polymorphic_conditions(reflection, interface_name) - if interface_name + def polymorphic_conditions(reflection, polymorphic_reflection) + if polymorphic_reflection.options[:as] "AND %s.%s = %s" % [ - table_aliases[reflection], "#{interface_name}_type", - @owner.class.quote_value(reflection.active_record.base_class.name) + table_aliases[reflection], "#{polymorphic_reflection.options[:as]}_type", + @owner.class.quote_value(polymorphic_reflection.active_record.base_class.name) ] end end -- cgit v1.2.3 From bc821a56114ae6f6d0b595475ad9e71f01f46f35 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 14 Oct 2010 12:59:16 +0100 Subject: Added test_has_many_through_has_many_with_has_many_through_habtm_source_reflection and make it pass --- .../associations/through_association_scope.rb | 8 ++++++-- activerecord/test/cases/associations/join_model_test.rb | 2 +- .../nested_has_many_through_associations_test.rb | 17 ++++++++++++++++- activerecord/test/fixtures/categorizations.yml | 6 ++++++ activerecord/test/models/author.rb | 1 + 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index c3f12fee2b..a52672eecd 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -92,7 +92,11 @@ module ActiveRecord polymorphic_conditions(left, left) ) when :has_and_belongs_to_many - raise NotImplementedError + joins << inner_join_sql( + right_table_and_alias, + table_aliases[left].first, left.primary_key_name, + table_aliases[right], right.klass.primary_key + ) end else case left.source_reflection.macro @@ -106,7 +110,7 @@ module ActiveRecord when :has_many, :has_one if right.macro == :has_and_belongs_to_many join_table, right_table = table_aliases[right] - right_table_and_alias = table_name_and_alias(right.quoted_table_name, right_table) + right_table_and_alias = table_name_and_alias(right.quoted_table_name, right_table) else right_table = table_aliases[right] end diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index 4b7a8b494d..385505b109 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -304,7 +304,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase end def test_has_many_going_through_join_model_with_custom_foreign_key - assert_equal [], posts(:thinking).authors + assert_equal [authors(:bob)], posts(:thinking).authors assert_equal [authors(:mary)], posts(:authorless).authors end diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 4e7e766b14..26c31ef761 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -25,6 +25,7 @@ require 'models/sponsor' require 'models/club' require 'models/organization' require 'models/category' +require 'models/categorization' # NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which # are just one level deep. But it's all the same thing really, as the "nested" code is being @@ -34,7 +35,8 @@ require 'models/category' class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, - :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts + :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts, + :categorizations # Through associations can either use the has_many or has_one macros. # @@ -183,6 +185,19 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # assert_equal [comments(:greetings), comments(:more_greetings)], categories[1].post_comments end + # has_many through + # Source: has_many through a habtm + # Through: has_many through + def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection + assert_equal [comments(:greetings), comments(:more_greetings)], authors(:bob).category_post_comments + + authors = Author.joins(:category_post_comments).where('comments.id' => comments(:does_it_hurt).id) + assert_equal [authors(:david), authors(:mary)], authors + + comments = Author.joins(:category_post_comments) + assert_equal [comments(:greetings), comments(:more_greetings)], comments[2].category_post_comments + end + # TODO: has_many through # Source: belongs_to # Through: has_many through diff --git a/activerecord/test/fixtures/categorizations.yml b/activerecord/test/fixtures/categorizations.yml index c5b6fc9a51..62e5bd111a 100644 --- a/activerecord/test/fixtures/categorizations.yml +++ b/activerecord/test/fixtures/categorizations.yml @@ -15,3 +15,9 @@ mary_thinking_general: author_id: 2 post_id: 2 category_id: 1 + +bob_misc_by_bob_technology: + id: 4 + author_id: 3 + post_id: 8 + category_id: 2 diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 584164f19a..f2f373af8c 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -101,6 +101,7 @@ class Author < ActiveRecord::Base belongs_to :author_address_extra, :dependent => :delete, :class_name => "AuthorAddress" has_many :post_categories, :through => :posts, :source => :categories + has_many :category_post_comments, :through => :categories, :source => :post_comments scope :relation_include_posts, includes(:posts) scope :relation_include_tags, includes(:tags) -- cgit v1.2.3 From 7963c30ebaeb511f7ddacc99ae2c7a530059ae6b Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 14 Oct 2010 13:07:28 +0100 Subject: Add test_has_many_through_has_many_through_with_belongs_to_source_reflection (which already works) --- .../nested_has_many_through_associations_test.rb | 13 ++++++++++++- activerecord/test/models/author.rb | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 26c31ef761..7e1fc60cb9 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -198,9 +198,20 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [comments(:greetings), comments(:more_greetings)], comments[2].category_post_comments end - # TODO: has_many through + # has_many through # Source: belongs_to # Through: has_many through + def test_has_many_through_has_many_through_with_belongs_to_source_reflection + author = authors(:david) + assert_equal [tags(:general), tags(:general)], author.tagging_tags + + authors = Author.joins(:tagging_tags).where('tags.id' => tags(:general).id) + assert_equal [authors(:david)], authors.uniq + + # TODO: Make this work + # authors = Author.includes(:tagging_tags) + # assert_equal [tags(:general), tags(:general)], authors.first.tagging_tags + end # TODO: has_many through # Source: has_many through diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index f2f373af8c..b5f702018a 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -89,6 +89,7 @@ class Author < ActiveRecord::Base has_many :similar_posts, :through => :tags, :source => :tagged_posts has_many :distinct_tags, :through => :posts, :source => :tags, :select => "DISTINCT tags.*", :order => "tags.name" has_many :post_categories, :through => :posts, :source => :categories + has_many :tagging_tags, :through => :taggings, :source => :tag has_many :books has_many :subscriptions, :through => :books -- cgit v1.2.3 From 25acd19da5f75a425218740fbb187b18bbb060ce Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 14 Oct 2010 13:16:47 +0100 Subject: Add test_has_many_through_belongs_to_with_has_many_through_source_reflection (which already passes) --- .../nested_has_many_through_associations_test.rb | 13 ++++++++++++- activerecord/test/models/categorization.rb | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 7e1fc60cb9..6212eed0eb 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -213,9 +213,20 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # assert_equal [tags(:general), tags(:general)], authors.first.tagging_tags end - # TODO: has_many through + # has_many through # Source: has_many through # Through: belongs_to + def test_has_many_through_belongs_to_with_has_many_through_source_reflection + assert_equal [taggings(:welcome_general), taggings(:thinking_general)], + categorizations(:david_welcome_general).post_taggings + + categorizations = Categorization.joins(:post_taggings).where('taggings.id' => taggings(:welcome_general).id) + assert_equal [categorizations(:david_welcome_general)], categorizations + + categorizations = Categorization.includes(:post_taggings) + assert_equal [taggings(:welcome_general), taggings(:thinking_general)], + categorizations.first.post_taggings + end # has_one through # Source: has_one through diff --git a/activerecord/test/models/categorization.rb b/activerecord/test/models/categorization.rb index 10594323ff..bddc1e5f0c 100644 --- a/activerecord/test/models/categorization.rb +++ b/activerecord/test/models/categorization.rb @@ -2,4 +2,6 @@ class Categorization < ActiveRecord::Base belongs_to :post belongs_to :category belongs_to :author -end \ No newline at end of file + + has_many :post_taggings, :through => :author, :source => :taggings +end -- cgit v1.2.3 From 002985fb66ae63f157db84f83520c3c256c04f77 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 14 Oct 2010 13:44:32 +0100 Subject: Add test_has_one_through_has_one_through_with_belongs_to_source_reflection --- .../nested_has_many_through_associations_test.rb | 15 +++++++++++++-- activerecord/test/fixtures/clubs.yml | 4 +++- activerecord/test/fixtures/members.yml | 3 +++ activerecord/test/fixtures/memberships.yml | 7 +++++++ activerecord/test/models/club.rb | 3 ++- activerecord/test/models/member.rb | 2 ++ activerecord/test/schema/schema.rb | 1 + 7 files changed, 31 insertions(+), 4 deletions(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 6212eed0eb..0bd19c10e0 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -26,6 +26,7 @@ require 'models/club' require 'models/organization' require 'models/category' require 'models/categorization' +require 'models/membership' # NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which # are just one level deep. But it's all the same thing really, as the "nested" code is being @@ -36,7 +37,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts, - :categorizations + :categorizations, :memberships # Through associations can either use the has_many or has_one macros. # @@ -241,9 +242,19 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal member_types(:founding), members.first.nested_member_type end - # TODO: has_one through + # has_one through # Source: belongs_to # Through: has_one through + def test_has_one_through_has_one_through_with_belongs_to_source_reflection + assert_equal categories(:general), members(:groucho).club_category + + members = Member.joins(:club_category).where('categories.id' => categories(:technology).id) + assert_equal [members(:blarpy_winkup)], members + + # TODO: Make this work + # members = Member.includes(:club_category) + # assert_equal categories(:general), members.first.club_category + end def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection author = authors(:david) diff --git a/activerecord/test/fixtures/clubs.yml b/activerecord/test/fixtures/clubs.yml index 1986d28229..82e439e8e5 100644 --- a/activerecord/test/fixtures/clubs.yml +++ b/activerecord/test/fixtures/clubs.yml @@ -1,6 +1,8 @@ boring_club: name: Banana appreciation society + category_id: 1 moustache_club: name: Moustache and Eyebrow Fancier Club crazy_club: - name: Skull and bones \ No newline at end of file + name: Skull and bones + category_id: 2 diff --git a/activerecord/test/fixtures/members.yml b/activerecord/test/fixtures/members.yml index 824840b7e5..f3bbf0dac6 100644 --- a/activerecord/test/fixtures/members.yml +++ b/activerecord/test/fixtures/members.yml @@ -6,3 +6,6 @@ some_other_guy: id: 2 name: Englebert Humperdink member_type_id: 2 +blarpy_winkup: + id: 3 + name: Blarpy Winkup diff --git a/activerecord/test/fixtures/memberships.yml b/activerecord/test/fixtures/memberships.yml index eed8b22af8..60eb641054 100644 --- a/activerecord/test/fixtures/memberships.yml +++ b/activerecord/test/fixtures/memberships.yml @@ -18,3 +18,10 @@ other_guys_membership: member_id: 2 favourite: false type: CurrentMembership + +blarpy_winkup_crazy_club: + joined_on: <%= 4.weeks.ago.to_s(:db) %> + club: crazy_club + member_id: 3 + favourite: false + type: CurrentMembership diff --git a/activerecord/test/models/club.rb b/activerecord/test/models/club.rb index 6e7cdd643a..83d6b1b15a 100644 --- a/activerecord/test/models/club.rb +++ b/activerecord/test/models/club.rb @@ -4,10 +4,11 @@ class Club < ActiveRecord::Base has_many :current_memberships has_one :sponsor has_one :sponsored_member, :through => :sponsor, :source => :sponsorable, :source_type => "Member" + belongs_to :category private def private_method "I'm sorry sir, this is a *private* club, not a *pirate* club" end -end \ No newline at end of file +end diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index 44c10cc4a4..bed62f8b7f 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -18,4 +18,6 @@ class Member < ActiveRecord::Base has_many :organization_member_details, :through => :member_detail has_many :organization_member_details_2, :through => :organization, :source => :member_details + + has_one :club_category, :through => :club, :source => :category end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 2fa9a4521e..8b9c56b895 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -113,6 +113,7 @@ ActiveRecord::Schema.define do create_table :clubs, :force => true do |t| t.string :name + t.integer :category_id end create_table :collections, :force => true do |t| -- cgit v1.2.3 From 11508db1be0016bb1a9893c7b2062845233f78e0 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 14 Oct 2010 13:52:38 +0100 Subject: Remove unnecessary requires from nested_has_many_through_associations_test.rb --- .../cases/associations/nested_has_many_through_associations_test.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 0bd19c10e0..eea1c4e54c 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -8,12 +8,6 @@ require 'models/reader' require 'models/comment' require 'models/tag' require 'models/tagging' -require 'models/owner' -require 'models/pet' -require 'models/toy' -require 'models/contract' -require 'models/company' -require 'models/developer' require 'models/subscriber' require 'models/book' require 'models/subscription' -- cgit v1.2.3 From 06c64eb60611bdeeb55e35a4819ba65d74dbadc3 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 15 Oct 2010 15:46:19 +0100 Subject: Support preloading nested through associations (using the default multi-query strategy) --- .../lib/active_record/association_preload.rb | 137 +++++++------- .../nested_has_many_through_associations_test.rb | 201 ++++++++++++++------- 2 files changed, 216 insertions(+), 122 deletions(-) diff --git a/activerecord/lib/active_record/association_preload.rb b/activerecord/lib/active_record/association_preload.rb index e6b367790b..664b0a7d59 100644 --- a/activerecord/lib/active_record/association_preload.rb +++ b/activerecord/lib/active_record/association_preload.rb @@ -202,93 +202,108 @@ module ActiveRecord set_association_collection_records(id_to_record_map, reflection.name, associated_records, 'the_parent_record_id') end - def preload_has_one_association(records, reflection, preload_options={}) - return if records.first.send("loaded_#{reflection.name}?") - id_to_record_map, ids = construct_id_map(records, reflection.options[:primary_key]) - options = reflection.options - records.each {|record| record.send("set_#{reflection.name}_target", nil)} - if options[:through] - through_records = preload_through_records(records, reflection, options[:through]) - - unless through_records.empty? - through_reflection = reflections[options[:through]] - through_primary_key = through_reflection.primary_key_name - source = reflection.source_reflection.name - through_records.first.class.preload_associations(through_records, source) - if through_reflection.macro == :belongs_to - id_to_record_map = construct_id_map(records, through_primary_key).first - through_primary_key = through_reflection.klass.primary_key - end - - through_records.each do |through_record| - add_preloaded_record_to_collection(id_to_record_map[through_record[through_primary_key].to_s], - reflection.name, through_record.send(source)) - end - end + def preload_has_one_or_has_many_association(records, reflection, preload_options={}) + if reflection.macro == :has_many + return if records.first.send(reflection.name).loaded? + records.each { |record| record.send(reflection.name).loaded } else - set_association_single_records(id_to_record_map, reflection.name, find_associated_records(ids, reflection, preload_options), reflection.primary_key_name) + return if records.first.send("loaded_#{reflection.name}?") + records.each {|record| record.send("set_#{reflection.name}_target", nil)} end - end - - def preload_has_many_association(records, reflection, preload_options={}) - return if records.first.send(reflection.name).loaded? + options = reflection.options - - primary_key_name = reflection.through_reflection_primary_key_name - id_to_record_map, ids = construct_id_map(records, primary_key_name || reflection.options[:primary_key]) - records.each {|record| record.send(reflection.name).loaded} - + if options[:through] - through_records = preload_through_records(records, reflection, options[:through]) - unless through_records.empty? + records_with_through_records = preload_through_records(records, reflection, options[:through]) + all_through_records = records_with_through_records.map(&:last).flatten + + unless all_through_records.empty? source = reflection.source_reflection.name - through_records.first.class.preload_associations(through_records, source, options) - through_records.each do |through_record| - through_record_id = through_record[reflection.through_reflection_primary_key].to_s - add_preloaded_records_to_collection(id_to_record_map[through_record_id], reflection.name, through_record.send(source)) + all_through_records.first.class.preload_associations(all_through_records, source, options) + + records_with_through_records.each do |record, through_records| + source_records = through_records.map(&source).flatten.compact + + case reflection.macro + when :has_many, :has_and_belongs_to_many + add_preloaded_records_to_collection([record], reflection.name, source_records) + when :has_one, :belongs_to + add_preloaded_record_to_collection([record], reflection.name, source_records.first) + end end end - else - set_association_collection_records(id_to_record_map, reflection.name, find_associated_records(ids, reflection, preload_options), - reflection.primary_key_name) + id_to_record_map, ids = construct_id_map(records, reflection.options[:primary_key]) + associated_records = find_associated_records(ids, reflection, preload_options) + + if reflection.macro == :has_many + set_association_collection_records( + id_to_record_map, reflection.name, + associated_records, reflection.primary_key_name + ) + else + set_association_single_records( + id_to_record_map, reflection.name, + associated_records, reflection.primary_key_name + ) + end end end + + alias_method :preload_has_one_association, :preload_has_one_or_has_many_association + alias_method :preload_has_many_association, :preload_has_one_or_has_many_association def preload_through_records(records, reflection, through_association) through_reflection = reflections[through_association] - through_records = [] + # If the same through record is loaded twice, we want to return exactly the same + # object in the result, rather than two separate instances representing the same + # record. This is so that we can preload the source association for each record, + # and always be able to access the preloaded association regardless of where we + # refer to the record. + # + # Suffices to say, if AR had an identity map built in then this would be unnecessary. + identity_map = {} + + options = {} + if reflection.options[:source_type] interface = reflection.source_reflection.options[:foreign_type] - preload_options = {:conditions => ["#{connection.quote_column_name interface} = ?", reflection.options[:source_type]]} - + options[:conditions] = ["#{connection.quote_column_name interface} = ?", reflection.options[:source_type]] records.compact! - records.first.class.preload_associations(records, through_association, preload_options) + else + if reflection.options[:conditions] + options[:include] = reflection.options[:include] || + reflection.options[:source] + options[:conditions] = reflection.options[:conditions] + end + + options[:order] = reflection.options[:order] + end + + records.first.class.preload_associations(records, through_association, options) - # Dont cache the association - we would only be caching a subset - records.each do |record| + records.map do |record| + if reflection.options[:source_type] + # Dont cache the association - we would only be caching a subset proxy = record.send(through_association) - + if proxy.respond_to?(:target) - through_records.concat Array.wrap(proxy.target) + through_records = proxy.target proxy.reset else # this is a has_one :through reflection - through_records << proxy if proxy + through_records = proxy end + else + through_records = record.send(through_association) end - else - options = {} - options[:include] = reflection.options[:include] || reflection.options[:source] if reflection.options[:conditions] - options[:order] = reflection.options[:order] - options[:conditions] = reflection.options[:conditions] - records.first.class.preload_associations(records, through_association, options) - - records.each do |record| - through_records.concat Array.wrap(record.send(through_association)) + + through_records = Array.wrap(through_records).map do |through_record| + identity_map[through_record] ||= through_record end + + [record, through_records] end - through_records end def preload_belongs_to_association(records, reflection, preload_options={}) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index eea1c4e54c..32b03bf076 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -54,70 +54,98 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase # Source: has_many through # Through: has_many def test_has_many_through_has_many_with_has_many_through_source_reflection - author = authors(:david) - assert_equal [tags(:general), tags(:general)], author.tags + general = tags(:general) + + assert_equal [general, general], authors(:david).tags # Only David has a Post tagged with General authors = Author.joins(:tags).where('tags.id' => tags(:general).id) assert_equal [authors(:david)], authors.uniq - authors = Author.includes(:tags) - assert_equal [tags(:general), tags(:general)], authors.first.tags - # This ensures that the polymorphism of taggings is being observed correctly authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') assert authors.empty? + + assert_queries(5) do + authors = Author.includes(:tags).to_a + end + + assert_no_queries do + assert_equal [general, general], authors.first.tags + end end # has_many through # Source: has_many # Through: has_many through def test_has_many_through_has_many_through_with_has_many_source_reflection + luke, david = subscribers(:first), subscribers(:second) + author = authors(:david) - assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], author.subscribers + assert_equal [luke, david, david], author.subscribers # All authors with subscribers where one of the subscribers' nick is 'alterself' authors = Author.joins(:subscribers).where('subscribers.nick' => 'alterself') assert_equal [authors(:david)], authors - # TODO: Make this work - # authors = Author.includes(:subscribers) - # assert_equal [subscribers(:first), subscribers(:second), subscribers(:second)], authors.first.subscribers + assert_queries(4) do + authors = Author.includes(:subscribers).to_a + end + + assert_no_queries do + assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick) + end + + # TODO: Add eager loading test using LEFT OUTER JOIN end # has_many through # Source: has_one through # Through: has_one def test_has_many_through_has_one_with_has_one_through_source_reflection - assert_equal [member_types(:founding)], members(:groucho).nested_member_types + founding = member_types(:founding) + + assert_equal [founding], members(:groucho).nested_member_types - members = Member.joins(:nested_member_types).where('member_types.id' => member_types(:founding).id) + members = Member.joins(:nested_member_types).where('member_types.id' => founding.id) assert_equal [members(:groucho)], members - members = Member.includes(:nested_member_types) - assert_equal [member_types(:founding)], members.first.nested_member_types + assert_queries(4) do + members = Member.includes(:nested_member_types).to_a + end + + assert_no_queries do + assert_equal [founding], members.first.nested_member_types + end end # has_many through # Source: has_one # Through: has_one through def test_has_many_through_has_one_through_with_has_one_source_reflection - assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members(:groucho).nested_sponsors + mustache = sponsors(:moustache_club_sponsor_for_groucho) - members = Member.joins(:nested_sponsors).where('sponsors.id' => sponsors(:moustache_club_sponsor_for_groucho).id) + assert_equal [mustache], members(:groucho).nested_sponsors + + members = Member.joins(:nested_sponsors).where('sponsors.id' => mustache.id) assert_equal [members(:groucho)], members - # TODO: Make this work - # members = Member.includes(:nested_sponsors) - # assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members.first.nested_sponsors + assert_queries(4) do + members = Member.includes(:nested_sponsors).to_a + end + + assert_no_queries do + assert_equal [mustache], members.first.nested_sponsors + end end # has_many through # Source: has_many through # Through: has_one def test_has_many_through_has_one_with_has_many_through_source_reflection - assert_equal [member_details(:groucho), member_details(:some_other_guy)], - members(:groucho).organization_member_details + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + assert_equal [groucho_details, other_details], members(:groucho).organization_member_details members = Member.joins(:organization_member_details). where('member_details.id' => member_details(:groucho).id) @@ -127,127 +155,178 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase where('member_details.id' => 9) assert members.empty? - members = Member.includes(:organization_member_details) - assert_equal [member_details(:groucho), member_details(:some_other_guy)], - members.first.organization_member_details + assert_queries(4) do + members = Member.includes(:organization_member_details).to_a + end + + assert_no_queries do + assert_equal [groucho_details, other_details], members.first.organization_member_details + end end # has_many through # Source: has_many # Through: has_one through def test_has_many_through_has_one_through_with_has_many_source_reflection - assert_equal [member_details(:groucho), member_details(:some_other_guy)], - members(:groucho).organization_member_details_2 + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + assert_equal [groucho_details, other_details], members(:groucho).organization_member_details_2 members = Member.joins(:organization_member_details_2). - where('member_details.id' => member_details(:groucho).id) + where('member_details.id' => groucho_details.id) assert_equal [members(:groucho), members(:some_other_guy)], members members = Member.joins(:organization_member_details_2). where('member_details.id' => 9) assert members.empty? - # TODO: Make this work - # members = Member.includes(:organization_member_details_2) - # assert_equal [member_details(:groucho), member_details(:some_other_guy)], - # members.first.organization_member_details_2 + assert_queries(4) do + members = Member.includes(:organization_member_details_2).to_a + end + + assert_no_queries do + assert_equal [groucho_details, other_details], members.first.organization_member_details_2 + end end # has_many through # Source: has_and_belongs_to_many # Through: has_many def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection - assert_equal [categories(:general), categories(:cooking)], authors(:bob).post_categories + general, cooking = categories(:general), categories(:cooking) + + assert_equal [general, cooking], authors(:bob).post_categories - authors = Author.joins(:post_categories).where('categories.id' => categories(:cooking).id) + authors = Author.joins(:post_categories).where('categories.id' => cooking.id) assert_equal [authors(:bob)], authors - authors = Author.includes(:post_categories) - assert_equal [categories(:general), categories(:cooking)], authors[2].post_categories + assert_queries(3) do + authors = Author.includes(:post_categories).to_a + end + + assert_no_queries do + assert_equal [general, cooking], authors[2].post_categories + end end # has_many through # Source: has_many # Through: has_and_belongs_to_many def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection - assert_equal [comments(:greetings), comments(:more_greetings)], categories(:technology).post_comments + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_equal [greetings, more], categories(:technology).post_comments - categories = Category.joins(:post_comments).where('comments.id' => comments(:more_greetings).id) + categories = Category.joins(:post_comments).where('comments.id' => more.id) assert_equal [categories(:general), categories(:technology)], categories - # TODO: Make this work - # categories = Category.includes(:post_comments) - # assert_equal [comments(:greetings), comments(:more_greetings)], categories[1].post_comments + assert_queries(3) do + categories = Category.includes(:post_comments).to_a + end + + assert_no_queries do + assert_equal [greetings, more], categories[1].post_comments + end end # has_many through # Source: has_many through a habtm # Through: has_many through def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection - assert_equal [comments(:greetings), comments(:more_greetings)], authors(:bob).category_post_comments + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_equal [greetings, more], authors(:bob).category_post_comments authors = Author.joins(:category_post_comments).where('comments.id' => comments(:does_it_hurt).id) assert_equal [authors(:david), authors(:mary)], authors - comments = Author.joins(:category_post_comments) - assert_equal [comments(:greetings), comments(:more_greetings)], comments[2].category_post_comments + assert_queries(5) do + authors = Author.includes(:category_post_comments).to_a + end + + assert_no_queries do + assert_equal [greetings, more], authors[2].category_post_comments + end end # has_many through # Source: belongs_to # Through: has_many through def test_has_many_through_has_many_through_with_belongs_to_source_reflection - author = authors(:david) - assert_equal [tags(:general), tags(:general)], author.tagging_tags + general = tags(:general) + + assert_equal [general, general], authors(:david).tagging_tags authors = Author.joins(:tagging_tags).where('tags.id' => tags(:general).id) assert_equal [authors(:david)], authors.uniq - # TODO: Make this work - # authors = Author.includes(:tagging_tags) - # assert_equal [tags(:general), tags(:general)], authors.first.tagging_tags + assert_queries(5) do + authors = Author.includes(:tagging_tags).to_a + end + + assert_no_queries do + assert_equal [general, general], authors.first.tagging_tags + end end # has_many through # Source: has_many through # Through: belongs_to def test_has_many_through_belongs_to_with_has_many_through_source_reflection - assert_equal [taggings(:welcome_general), taggings(:thinking_general)], - categorizations(:david_welcome_general).post_taggings + welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general) + + assert_equal [welcome_general, thinking_general], categorizations(:david_welcome_general).post_taggings - categorizations = Categorization.joins(:post_taggings).where('taggings.id' => taggings(:welcome_general).id) + categorizations = Categorization.joins(:post_taggings).where('taggings.id' => welcome_general.id) assert_equal [categorizations(:david_welcome_general)], categorizations - categorizations = Categorization.includes(:post_taggings) - assert_equal [taggings(:welcome_general), taggings(:thinking_general)], - categorizations.first.post_taggings + assert_queries(4) do + categorizations = Categorization.includes(:post_taggings).to_a + end + + assert_no_queries do + assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings + end end # has_one through # Source: has_one through # Through: has_one def test_has_one_through_has_one_with_has_one_through_source_reflection - assert_equal member_types(:founding), members(:groucho).nested_member_type + founding = member_types(:founding) + + assert_equal founding, members(:groucho).nested_member_type - members = Member.joins(:nested_member_type).where('member_types.id' => member_types(:founding).id) + members = Member.joins(:nested_member_type).where('member_types.id' => founding.id) assert_equal [members(:groucho)], members - members = Member.includes(:nested_member_type) - assert_equal member_types(:founding), members.first.nested_member_type + assert_queries(4) do + members = Member.includes(:nested_member_type).to_a + end + + assert_no_queries do + assert_equal founding, members.first.nested_member_type + end end # has_one through # Source: belongs_to # Through: has_one through def test_has_one_through_has_one_through_with_belongs_to_source_reflection - assert_equal categories(:general), members(:groucho).club_category + general = categories(:general) + + assert_equal general, members(:groucho).club_category members = Member.joins(:club_category).where('categories.id' => categories(:technology).id) assert_equal [members(:blarpy_winkup)], members - # TODO: Make this work - # members = Member.includes(:club_category) - # assert_equal categories(:general), members.first.club_category + assert_queries(4) do + members = Member.includes(:club_category).to_a + end + + assert_no_queries do + assert_equal general, members.first.club_category + end end def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection -- cgit v1.2.3 From 1e2525bfe0248d873d6d6026f45102853a1c95cd Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 15 Oct 2010 16:21:42 +0100 Subject: Add assertions for nested through associations loaded by includes with conditions (uses the single-query strategy). Currently one failure to fix. --- .../nested_has_many_through_associations_test.rb | 156 ++++++++++----------- 1 file changed, 75 insertions(+), 81 deletions(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 32b03bf076..4d5152ed5d 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -58,18 +58,16 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [general, general], authors(:david).tags - # Only David has a Post tagged with General - authors = Author.joins(:tags).where('tags.id' => tags(:general).id) - assert_equal [authors(:david)], authors.uniq + assert_includes_and_joins_equal( + Author.where('tags.id' => tags(:general).id), + [authors(:david)], :tags + ) # This ensures that the polymorphism of taggings is being observed correctly authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') assert authors.empty? - assert_queries(5) do - authors = Author.includes(:tags).to_a - end - + authors = assert_queries(5) { Author.includes(:tags).to_a } assert_no_queries do assert_equal [general, general], authors.first.tags end @@ -85,13 +83,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [luke, david, david], author.subscribers # All authors with subscribers where one of the subscribers' nick is 'alterself' - authors = Author.joins(:subscribers).where('subscribers.nick' => 'alterself') - assert_equal [authors(:david)], authors - - assert_queries(4) do - authors = Author.includes(:subscribers).to_a - end + assert_includes_and_joins_equal( + Author.where('subscribers.nick' => 'alterself'), + [authors(:david)], :subscribers + ) + authors = assert_queries(4) { Author.includes(:subscribers).to_a } assert_no_queries do assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick) end @@ -107,13 +104,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [founding], members(:groucho).nested_member_types - members = Member.joins(:nested_member_types).where('member_types.id' => founding.id) - assert_equal [members(:groucho)], members - - assert_queries(4) do - members = Member.includes(:nested_member_types).to_a - end + assert_includes_and_joins_equal( + Member.where('member_types.id' => founding.id), + [members(:groucho)], :nested_member_types + ) + members = assert_queries(4) { Member.includes(:nested_member_types).to_a } assert_no_queries do assert_equal [founding], members.first.nested_member_types end @@ -127,13 +123,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [mustache], members(:groucho).nested_sponsors - members = Member.joins(:nested_sponsors).where('sponsors.id' => mustache.id) - assert_equal [members(:groucho)], members - - assert_queries(4) do - members = Member.includes(:nested_sponsors).to_a - end + assert_includes_and_joins_equal( + Member.where('sponsors.id' => mustache.id), + [members(:groucho)], :nested_sponsors + ) + members = assert_queries(4) { Member.includes(:nested_sponsors).to_a } assert_no_queries do assert_equal [mustache], members.first.nested_sponsors end @@ -147,18 +142,16 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [groucho_details, other_details], members(:groucho).organization_member_details - members = Member.joins(:organization_member_details). - where('member_details.id' => member_details(:groucho).id) - assert_equal [members(:groucho), members(:some_other_guy)], members + assert_includes_and_joins_equal( + Member.where('member_details.id' => member_details(:groucho).id), + [members(:groucho), members(:some_other_guy)], :organization_member_details + ) members = Member.joins(:organization_member_details). where('member_details.id' => 9) assert members.empty? - assert_queries(4) do - members = Member.includes(:organization_member_details).to_a - end - + members = assert_queries(4) { Member.includes(:organization_member_details).to_a } assert_no_queries do assert_equal [groucho_details, other_details], members.first.organization_member_details end @@ -172,18 +165,16 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [groucho_details, other_details], members(:groucho).organization_member_details_2 - members = Member.joins(:organization_member_details_2). - where('member_details.id' => groucho_details.id) - assert_equal [members(:groucho), members(:some_other_guy)], members + assert_includes_and_joins_equal( + Member.where('member_details.id' => groucho_details.id), + [members(:groucho), members(:some_other_guy)], :organization_member_details_2 + ) members = Member.joins(:organization_member_details_2). where('member_details.id' => 9) assert members.empty? - assert_queries(4) do - members = Member.includes(:organization_member_details_2).to_a - end - + members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a } assert_no_queries do assert_equal [groucho_details, other_details], members.first.organization_member_details_2 end @@ -197,13 +188,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [general, cooking], authors(:bob).post_categories - authors = Author.joins(:post_categories).where('categories.id' => cooking.id) - assert_equal [authors(:bob)], authors - - assert_queries(3) do - authors = Author.includes(:post_categories).to_a - end + assert_includes_and_joins_equal( + Author.where('categories.id' => cooking.id), + [authors(:bob)], :post_categories + ) + authors = assert_queries(3) { Author.includes(:post_categories).to_a } assert_no_queries do assert_equal [general, cooking], authors[2].post_categories end @@ -217,13 +207,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [greetings, more], categories(:technology).post_comments - categories = Category.joins(:post_comments).where('comments.id' => more.id) - assert_equal [categories(:general), categories(:technology)], categories - - assert_queries(3) do - categories = Category.includes(:post_comments).to_a - end + assert_includes_and_joins_equal( + Category.where('comments.id' => more.id), + [categories(:general), categories(:technology)], :post_comments + ) + categories = assert_queries(3) { Category.includes(:post_comments).to_a } assert_no_queries do assert_equal [greetings, more], categories[1].post_comments end @@ -237,13 +226,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [greetings, more], authors(:bob).category_post_comments - authors = Author.joins(:category_post_comments).where('comments.id' => comments(:does_it_hurt).id) - assert_equal [authors(:david), authors(:mary)], authors - - assert_queries(5) do - authors = Author.includes(:category_post_comments).to_a - end + assert_includes_and_joins_equal( + Author.where('comments.id' => comments(:does_it_hurt).id), + [authors(:david), authors(:mary)], :category_post_comments + ) + authors = assert_queries(5) { Author.includes(:category_post_comments).to_a } assert_no_queries do assert_equal [greetings, more], authors[2].category_post_comments end @@ -257,13 +245,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [general, general], authors(:david).tagging_tags - authors = Author.joins(:tagging_tags).where('tags.id' => tags(:general).id) - assert_equal [authors(:david)], authors.uniq - - assert_queries(5) do - authors = Author.includes(:tagging_tags).to_a - end + assert_includes_and_joins_equal( + Author.where('tags.id' => tags(:general).id), + [authors(:david)], :tagging_tags + ) + authors = assert_queries(5) { Author.includes(:tagging_tags).to_a } assert_no_queries do assert_equal [general, general], authors.first.tagging_tags end @@ -277,13 +264,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal [welcome_general, thinking_general], categorizations(:david_welcome_general).post_taggings - categorizations = Categorization.joins(:post_taggings).where('taggings.id' => welcome_general.id) - assert_equal [categorizations(:david_welcome_general)], categorizations - - assert_queries(4) do - categorizations = Categorization.includes(:post_taggings).to_a - end + assert_includes_and_joins_equal( + Categorization.where('taggings.id' => welcome_general.id), + [categorizations(:david_welcome_general)], :post_taggings + ) + categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a } assert_no_queries do assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings end @@ -297,13 +283,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal founding, members(:groucho).nested_member_type - members = Member.joins(:nested_member_type).where('member_types.id' => founding.id) - assert_equal [members(:groucho)], members - - assert_queries(4) do - members = Member.includes(:nested_member_type).to_a - end + assert_includes_and_joins_equal( + Member.where('member_types.id' => founding.id), + [members(:groucho)], :nested_member_type + ) + members = assert_queries(4) { Member.includes(:nested_member_type).to_a } assert_no_queries do assert_equal founding, members.first.nested_member_type end @@ -317,13 +302,12 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_equal general, members(:groucho).club_category - members = Member.joins(:club_category).where('categories.id' => categories(:technology).id) - assert_equal [members(:blarpy_winkup)], members - - assert_queries(4) do - members = Member.includes(:club_category).to_a - end + assert_includes_and_joins_equal( + Member.where('categories.id' => categories(:technology).id), + [members(:blarpy_winkup)], :club_category + ) + members = assert_queries(4) { Member.includes(:club_category).to_a } assert_no_queries do assert_equal general, members.first.club_category end @@ -379,4 +363,14 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert !scope.where("comments.type" => "SpecialComment").empty? assert !scope.where("comments.type" => "SubSpecialComment").empty? end + + private + + def assert_includes_and_joins_equal(query, expected, association) + actual = assert_queries(1) { query.joins(association).to_a.uniq } + assert_equal expected, actual + + actual = assert_queries(1) { query.includes(association).to_a.uniq } + assert_equal expected, actual + end end -- cgit v1.2.3 From d619e399380cd840f9f5ec88bb3d823fbb1f4d08 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 15 Oct 2010 16:27:13 +0100 Subject: Fix small bug which was shown by the last commit --- activerecord/lib/active_record/associations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 2a72fa95c9..22a693540e 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2246,7 +2246,7 @@ module ActiveRecord end def table - if reflection.macro == :has_and_belongs_to_many + if @tables.last.is_a?(Array) @tables.last.first else @tables.last -- cgit v1.2.3 From edc176d33be9499f4c096779c5b4711b5daf0c06 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 15 Oct 2010 17:46:09 +0100 Subject: Make sure nested through associations are read only --- activerecord/lib/active_record/associations.rb | 6 ++++ .../associations/has_many_through_association.rb | 10 ++++++ .../associations/has_one_through_association.rb | 2 ++ .../associations/through_association_scope.rb | 27 +++++++++----- activerecord/lib/active_record/reflection.rb | 4 +++ .../nested_has_many_through_associations_test.rb | 42 ++++++++++++++++++++++ 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 22a693540e..1111033435 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -64,6 +64,12 @@ module ActiveRecord super("Cannot dissociate new records through '#{owner.class.name}##{reflection.name}' on '#{reflection.source_reflection.class_name rescue nil}##{reflection.source_reflection.name rescue nil}'. Both records must have an id in order to delete the has_many :through record associating them.") end end + + class HasManyThroughNestedAssociationsAreReadonly < ActiveRecordError #:nodoc + def initialize(owner, reflection) + super("Cannot modify association '#{owner.class.name}##{reflection.name}' because it goes through more than one other association.") + end + end class HasAndBelongsToManyAssociationWithPrimaryKeyError < ActiveRecordError #:nodoc: def initialize(reflection) diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 313d9da621..f0ad166802 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -8,6 +8,11 @@ module ActiveRecord class HasManyThroughAssociation < HasManyAssociation #:nodoc: include ThroughAssociationScope + def build(attributes = {}, &block) + ensure_not_nested + super + end + alias_method :new, :build def create!(attrs = nil) @@ -37,6 +42,7 @@ module ActiveRecord protected def create_record(attrs, force = true) + ensure_not_nested ensure_owner_is_not_new transaction do @@ -60,6 +66,8 @@ module ActiveRecord end def insert_record(record, force = true, validate = true) + ensure_not_nested + if record.new_record? if force record.save! @@ -75,6 +83,8 @@ module ActiveRecord # TODO - add dependent option support def delete_records(records) + ensure_not_nested + klass = @reflection.through_reflection.klass records.each do |associate| klass.delete_all(construct_join_attributes(associate)) diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb index fba0a2bfcc..8153eb7c57 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -14,6 +14,8 @@ module ActiveRecord private def create_through_record(new_value) #nodoc: + ensure_not_nested + klass = @reflection.through_reflection.klass current_object = @owner.send(@reflection.through_reflection.name) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index a52672eecd..51ab8869ed 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -8,15 +8,18 @@ module ActiveRecord protected def construct_scope - { :create => construct_owner_attributes(@reflection), - :find => { :conditions => construct_conditions, - :joins => construct_joins, - :include => @reflection.options[:include] || @reflection.source_reflection.options[:include], - :select => construct_select, - :order => @reflection.options[:order], - :limit => @reflection.options[:limit], - :readonly => @reflection.options[:readonly], - } } + scope = {} + scope[:find] = { + :conditions => construct_conditions, + :joins => construct_joins, + :include => @reflection.options[:include] || @reflection.source_reflection.options[:include], + :select => construct_select, + :order => @reflection.options[:order], + :limit => @reflection.options[:limit], + :readonly => @reflection.options[:readonly] + } + scope[:create] = construct_owner_attributes(@reflection) unless @reflection.nested? + scope end # Build SQL conditions from attributes, qualified by table name. @@ -299,6 +302,12 @@ module ActiveRecord end alias_method :sql_conditions, :conditions + + def ensure_not_nested + if @reflection.nested? + raise HasManyThroughNestedAssociationsAreReadonly.new(@owner, @reflection) + end + end end end end diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index b7cd466e13..ee63fcfce2 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -395,6 +395,10 @@ module ActiveRecord chain end end + + def nested? + through_reflection_chain.length > 2 + end # Gets an array of possible :through source reflection names: # diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 4d5152ed5d..03ec4281d8 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -363,6 +363,48 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert !scope.where("comments.type" => "SpecialComment").empty? assert !scope.where("comments.type" => "SubSpecialComment").empty? end + + def test_nested_has_many_through_writers_should_raise_error + david = authors(:david) + subscriber = subscribers(:first) + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers = [subscriber] + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscriber_ids = [subscriber.id] + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers << subscriber + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.delete(subscriber) + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.clear + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.build + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.create + end + end + + def test_nested_has_one_through_writers_should_raise_error + groucho = members(:groucho) + founding = member_types(:founding) + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + groucho.nested_member_type = founding + end + end private -- cgit v1.2.3 From 78b8c51cb3b0c629152f3bbaf6d8bcf988cc936e Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 17 Oct 2010 23:29:56 +0100 Subject: Refactoring: replace the mix of variables like @finder_sql, @counter_sql, etc with just a single scope hash (created on initialization of the proxy). This is now used consistently across all associations. Therefore, all you have to do to ensure finding/counting etc is done correctly is implement the scope correctly. --- .../associations/association_collection.rb | 66 ++++++++++------------ .../associations/association_proxy.rb | 19 +++++++ .../associations/belongs_to_association.rb | 20 ++++--- .../belongs_to_polymorphic_association.rb | 22 ++++---- .../has_and_belongs_to_many_association.rb | 37 ++++++------ .../associations/has_many_association.rb | 56 ++++++++---------- .../associations/has_many_through_association.rb | 16 +----- .../associations/has_one_association.rb | 39 ++++++------- .../associations/has_one_through_association.rb | 2 +- .../associations/through_association_scope.rb | 11 ++-- .../lib/active_record/autosave_association.rb | 4 +- 11 files changed, 138 insertions(+), 154 deletions(-) diff --git a/activerecord/lib/active_record/associations/association_collection.rb b/activerecord/lib/active_record/associations/association_collection.rb index cb2d9e0a79..896e18af01 100644 --- a/activerecord/lib/active_record/associations/association_collection.rb +++ b/activerecord/lib/active_record/associations/association_collection.rb @@ -19,11 +19,6 @@ module ActiveRecord # If you need to work on all current children, new and existing records, # +load_target+ and the +loaded+ flag are your friends. class AssociationCollection < AssociationProxy #:nodoc: - def initialize(owner, reflection) - super - construct_sql - end - delegate :group, :order, :limit, :joins, :where, :preload, :eager_load, :includes, :from, :lock, :readonly, :having, :to => :scoped def select(select = nil) @@ -36,7 +31,7 @@ module ActiveRecord end def scoped - with_scope(construct_scope) { @reflection.klass.scoped } + with_scope(@scope) { @reflection.klass.scoped } end def find(*args) @@ -58,9 +53,7 @@ module ActiveRecord merge_options_from_reflection!(options) construct_find_options!(options) - find_scope = construct_scope[:find].slice(:conditions, :order) - - with_scope(:find => find_scope) do + with_scope(:find => @scope[:find].slice(:conditions, :order)) do relation = @reflection.klass.send(:construct_finder_arel, options, @reflection.klass.send(:current_scoped_methods)) case args.first @@ -178,17 +171,18 @@ module ActiveRecord end end - # Count all records using SQL. If the +:counter_sql+ option is set for the association, it will - # be used for the query. If no +:counter_sql+ was supplied, but +:finder_sql+ was set, the - # descendant's +construct_sql+ method will have set :counter_sql automatically. - # Otherwise, construct options and pass them with scope to the target class's +count+. + # Count all records using SQL. If the +:counter_sql+ or +:finder_sql+ option is set for the + # association, it will be used for the query. Otherwise, construct options and pass them with + # scope to the target class's +count+. def count(column_name = nil, options = {}) column_name, options = nil, column_name if column_name.is_a?(Hash) - if @reflection.options[:counter_sql] && !options.blank? - raise ArgumentError, "If finder_sql/counter_sql is used then options cannot be passed" - elsif @reflection.options[:counter_sql] - @reflection.klass.count_by_sql(@counter_sql) + if @reflection.options[:counter_sql] || @reflection.options[:finder_sql] + unless options.blank? + raise ArgumentError, "If finder_sql/counter_sql is used then options cannot be passed" + end + + @reflection.klass.count_by_sql(custom_counter_sql) else if @reflection.options[:uniq] @@ -197,7 +191,7 @@ module ActiveRecord options.merge!(:distinct => true) end - value = @reflection.klass.send(:with_scope, construct_scope) { @reflection.klass.count(column_name, options) } + value = @reflection.klass.send(:with_scope, @scope) { @reflection.klass.count(column_name, options) } limit = @reflection.options[:limit] offset = @reflection.options[:offset] @@ -377,18 +371,6 @@ module ActiveRecord def construct_find_options!(options) end - def construct_counter_sql - if @reflection.options[:counter_sql] - @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) - elsif @reflection.options[:finder_sql] - # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ - @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT\b(\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } - @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) - else - @counter_sql = @finder_sql - end - end - def load_target if !@owner.new_record? || foreign_key_present begin @@ -434,9 +416,9 @@ module ActiveRecord elsif @reflection.klass.scopes[method] @_named_scopes_cache ||= {} @_named_scopes_cache[method] ||= {} - @_named_scopes_cache[method][args] ||= with_scope(construct_scope) { @reflection.klass.send(method, *args) } + @_named_scopes_cache[method][args] ||= with_scope(@scope) { @reflection.klass.send(method, *args) } else - with_scope(construct_scope) do + with_scope(@scope) do if block_given? @reflection.klass.send(method, *args) { |*block_args| yield(*block_args) } else @@ -446,9 +428,19 @@ module ActiveRecord end end - # overloaded in derived Association classes to provide useful scoping depending on association type. - def construct_scope - {} + def custom_counter_sql + if @reflection.options[:counter_sql] + counter_sql = @reflection.options[:counter_sql] + else + # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ + counter_sql = @reflection.options[:finder_sql].sub(/SELECT\b(\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } + end + + interpolate_sql(counter_sql) + end + + def custom_finder_sql + interpolate_sql(@reflection.options[:finder_sql]) end def reset_target! @@ -462,7 +454,7 @@ module ActiveRecord def find_target records = if @reflection.options[:finder_sql] - @reflection.klass.find_by_sql(@finder_sql) + @reflection.klass.find_by_sql(custom_finder_sql) else find(:all) end @@ -494,7 +486,7 @@ module ActiveRecord ensure_owner_is_not_new scoped_where = scoped.where_values_hash - create_scope = scoped_where ? construct_scope[:create].merge(scoped_where) : construct_scope[:create] + create_scope = scoped_where ? @scope[:create].merge(scoped_where) : @scope[:create] record = @reflection.klass.send(:with_scope, :create => create_scope) do @reflection.build_association(attrs) end diff --git a/activerecord/lib/active_record/associations/association_proxy.rb b/activerecord/lib/active_record/associations/association_proxy.rb index f333f4d603..0c12c3737d 100644 --- a/activerecord/lib/active_record/associations/association_proxy.rb +++ b/activerecord/lib/active_record/associations/association_proxy.rb @@ -61,6 +61,7 @@ module ActiveRecord reflection.check_validity! Array.wrap(reflection.options[:extend]).each { |ext| proxy_extend(ext) } reset + construct_scope end # Returns the owner of the proxy. @@ -203,6 +204,24 @@ module ActiveRecord @reflection.klass.send :with_scope, *args, &block end + # Construct the scope used for find/create queries on the target + def construct_scope + @scope = { + :find => construct_find_scope, + :create => construct_create_scope + } + end + + # Implemented by subclasses + def construct_find_scope + raise NotImplementedError + end + + # Implemented by (some) subclasses + def construct_create_scope + {} + end + private # Forwards any missing method call to the \target. def method_missing(method, *args) diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 2eb56e5cd3..34b6cd5576 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -50,19 +50,21 @@ module ActiveRecord "find" end - options = @reflection.options.dup - (options.keys - [:select, :include, :readonly]).each do |key| - options.delete key - end - options[:conditions] = conditions + options = @reflection.options.dup.slice(:select, :include, :readonly) - the_target = @reflection.klass.send(find_method, - @owner[@reflection.primary_key_name], - options - ) if @owner[@reflection.primary_key_name] + the_target = with_scope(:find => @scope[:find]) do + @reflection.klass.send(find_method, + @owner[@reflection.primary_key_name], + options + ) if @owner[@reflection.primary_key_name] + end set_inverse_instance(the_target, @owner) the_target end + + def construct_find_scope + { :conditions => conditions } + end def foreign_key_present !@owner[@reflection.primary_key_name].nil? diff --git a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb index e429806b0c..a0df860623 100644 --- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb @@ -44,20 +44,20 @@ module ActiveRecord end end + def construct_find_scope + { :conditions => conditions } + end + def find_target return nil if association_class.nil? - target = - if @reflection.options[:conditions] - association_class.find( - @owner[@reflection.primary_key_name], - :select => @reflection.options[:select], - :conditions => conditions, - :include => @reflection.options[:include] - ) - else - association_class.find(@owner[@reflection.primary_key_name], :select => @reflection.options[:select], :include => @reflection.options[:include]) - end + target = association_class.send(:with_scope, :find => @scope[:find]) do + association_class.find( + @owner[@reflection.primary_key_name], + :select => @reflection.options[:select], + :include => @reflection.options[:include] + ) + end set_inverse_instance(target, @owner) target end diff --git a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb index eb65234dfb..1fc9aba5cf 100644 --- a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb +++ b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb @@ -24,7 +24,7 @@ module ActiveRecord protected def construct_find_options!(options) - options[:joins] = Arel::SqlLiteral.new @join_sql + options[:joins] = Arel::SqlLiteral.new(@scope[:find][:joins]) options[:readonly] = finding_with_ambiguous_select?(options[:select] || @reflection.options[:select]) options[:select] ||= (@reflection.options[:select] || Arel::SqlLiteral.new('*')) end @@ -80,27 +80,26 @@ module ActiveRecord ).delete end end + + def construct_joins + "INNER JOIN #{@owner.connection.quote_table_name @reflection.options[:join_table]} ON #{@reflection.quoted_table_name}.#{@reflection.klass.primary_key} = #{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.association_foreign_key}" + end - def construct_sql - if @reflection.options[:finder_sql] - @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) - else - @finder_sql = "#{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.primary_key_name} = #{owner_quoted_id} " - @finder_sql << " AND (#{conditions})" if conditions - end - - @join_sql = "INNER JOIN #{@owner.connection.quote_table_name @reflection.options[:join_table]} ON #{@reflection.quoted_table_name}.#{@reflection.klass.primary_key} = #{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.association_foreign_key}" - - construct_counter_sql + def construct_conditions + sql = "#{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.primary_key_name} = #{owner_quoted_id} " + sql << " AND (#{conditions})" if conditions + sql end - def construct_scope - { :find => { :conditions => @finder_sql, - :joins => @join_sql, - :readonly => false, - :order => @reflection.options[:order], - :include => @reflection.options[:include], - :limit => @reflection.options[:limit] } } + def construct_find_scope + { + :conditions => construct_conditions, + :joins => construct_joins, + :readonly => false, + :order => @reflection.options[:order], + :include => @reflection.options[:include], + :limit => @reflection.options[:limit] + } end # Join tables with additional columns on top of the two foreign keys must be considered diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index 978fc74560..830a82980d 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -6,14 +6,10 @@ module ActiveRecord # If the association has a :through option further specialization # is provided by its child HasManyThroughAssociation. class HasManyAssociation < AssociationCollection #:nodoc: - def initialize(owner, reflection) - @finder_sql = nil - super - end protected def owner_quoted_id if @reflection.options[:primary_key] - quote_value(@owner.send(@reflection.options[:primary_key])) + @owner.class.quote_value(@owner.send(@reflection.options[:primary_key])) else @owner.quoted_id end @@ -35,10 +31,10 @@ module ActiveRecord def count_records count = if has_cached_counter? @owner.send(:read_attribute, cached_counter_attribute_name) - elsif @reflection.options[:counter_sql] - @reflection.klass.count_by_sql(@counter_sql) + elsif @reflection.options[:counter_sql] || @reflection.options[:finder_sql] + @reflection.klass.count_by_sql(custom_counter_sql) else - @reflection.klass.count(:conditions => @counter_sql, :include => @reflection.options[:include]) + @reflection.klass.count(@scope[:find].slice(:conditions, :joins, :include)) end # If there's nothing in the database and @target has no new records @@ -87,36 +83,32 @@ module ActiveRecord false end - def construct_sql - case - when @reflection.options[:finder_sql] - @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) - - when @reflection.options[:as] - @finder_sql = - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " + - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" - @finder_sql << " AND (#{conditions})" if conditions - - else - @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}" - @finder_sql << " AND (#{conditions})" if conditions + def construct_conditions + if @reflection.options[:as] + sql = + "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " + + "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" + else + sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}" end + sql << " AND (#{conditions})" if conditions + sql + end - construct_counter_sql + def construct_find_scope + { + :conditions => construct_conditions, + :readonly => false, + :order => @reflection.options[:order], + :limit => @reflection.options[:limit], + :include => @reflection.options[:include] + } end - def construct_scope + def construct_create_scope create_scoping = {} set_belongs_to_association_for(create_scoping) - { - :find => { :conditions => @finder_sql, - :readonly => false, - :order => @reflection.options[:order], - :limit => @reflection.options[:limit], - :include => @reflection.options[:include]}, - :create => create_scoping - } + create_scoping end def we_can_set_the_inverse_on_this?(record) diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index f0ad166802..419a3d385e 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -93,21 +93,7 @@ module ActiveRecord def find_target return [] unless target_reflection_has_associated_record? - with_scope(construct_scope) { @reflection.klass.find(:all) } - end - - def construct_sql - case - when @reflection.options[:finder_sql] - @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) - - @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}" - @finder_sql << " AND (#{conditions})" if conditions - else - @finder_sql = construct_conditions - end - - construct_counter_sql + with_scope(@scope) { @reflection.klass.find(:all) } end def has_cached_counter? diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index a6e6bfa356..17901387e9 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -2,11 +2,6 @@ module ActiveRecord # = Active Record Belongs To Has One Association module Associations class HasOneAssociation < AssociationProxy #:nodoc: - def initialize(owner, reflection) - super - construct_sql - end - def create(attrs = {}, replace_existing = true) new_record(replace_existing) do |reflection| attrs = merge_with_conditions(attrs) @@ -79,33 +74,31 @@ module ActiveRecord private def find_target - options = @reflection.options.dup - (options.keys - [:select, :order, :include, :readonly]).each do |key| - options.delete key - end - options[:conditions] = @finder_sql + options = @reflection.options.dup.slice(:select, :order, :include, :readonly) - the_target = @reflection.klass.find(:first, options) + the_target = with_scope(:find => @scope[:find]) do + @reflection.klass.find(:first, options) + end set_inverse_instance(the_target, @owner) the_target end - def construct_sql - case - when @reflection.options[:as] - @finder_sql = - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " + - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" - else - @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}" + def construct_find_scope + if @reflection.options[:as] + sql = + "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " + + "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" + else + sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}" end - @finder_sql << " AND (#{conditions})" if conditions + sql << " AND (#{conditions})" if conditions + { :conditions => sql } end - def construct_scope + def construct_create_scope create_scoping = {} set_belongs_to_association_for(create_scoping) - { :create => create_scoping } + create_scoping end def new_record(replace_existing) @@ -113,7 +106,7 @@ module ActiveRecord # instance. Otherwise, if the target has not previously been loaded # elsewhere, the instance we create will get orphaned. load_target if replace_existing - record = @reflection.klass.send(:with_scope, :create => construct_scope[:create]) do + record = @reflection.klass.send(:with_scope, :create => @scope[:create]) do yield @reflection end diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb index 8153eb7c57..de962e01b6 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -35,7 +35,7 @@ module ActiveRecord private def find_target - with_scope(construct_scope) { @reflection.klass.find(:first) } + with_scope(@scope) { @reflection.klass.find(:first) } end end end diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 51ab8869ed..261b4037a3 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -7,9 +7,8 @@ module ActiveRecord protected - def construct_scope - scope = {} - scope[:find] = { + def construct_find_scope + { :conditions => construct_conditions, :joins => construct_joins, :include => @reflection.options[:include] || @reflection.source_reflection.options[:include], @@ -18,8 +17,10 @@ module ActiveRecord :limit => @reflection.options[:limit], :readonly => @reflection.options[:readonly] } - scope[:create] = construct_owner_attributes(@reflection) unless @reflection.nested? - scope + end + + def construct_create_scope + @reflection.nested? ? {} : construct_owner_attributes(@reflection) end # Build SQL conditions from attributes, qualified by table name. diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index 21a9a1f2cb..f3f89fe7c3 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -313,8 +313,8 @@ module ActiveRecord end end - # reconstruct the SQL queries now that we know the owner's id - association.send(:construct_sql) if association.respond_to?(:construct_sql) + # reconstruct the scope now that we know the owner's id + association.send(:construct_scope) if association.respond_to?(:construct_scope) end end -- cgit v1.2.3 From 9ec07348749675110843c44f680da79223218db2 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 00:27:40 +0100 Subject: Properly support conditions on any of the reflections involved in a nested through association --- activerecord/lib/active_record/associations.rb | 15 ++- .../associations/through_association_scope.rb | 127 ++++++++++----------- activerecord/lib/active_record/reflection.rb | 53 ++++++++- .../associations/cascaded_eager_loading_test.rb | 6 +- activerecord/test/cases/associations/eager_test.rb | 4 +- .../nested_has_many_through_associations_test.rb | 40 ++++++- activerecord/test/cases/batches_test.rb | 2 +- activerecord/test/cases/finder_test.rb | 6 +- activerecord/test/cases/relations_test.rb | 18 +-- activerecord/test/fixtures/posts.yml | 14 +++ activerecord/test/fixtures/taggings.yml | 28 +++++ activerecord/test/fixtures/tags.yml | 4 + activerecord/test/models/author.rb | 7 +- activerecord/test/models/post.rb | 5 + activerecord/test/models/tagging.rb | 3 +- activerecord/test/schema/schema.rb | 1 + 16 files changed, 235 insertions(+), 98 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 1111033435..75e5eb8ee4 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2164,8 +2164,10 @@ module ActiveRecord chain = through_reflection_chain.reverse foreign_table = parent_table + index = 0 - chain.zip(@tables).each do |reflection, table| + chain.each do |reflection| + table = @tables[index] conditions = [] if reflection.source_reflection.nil? @@ -2234,13 +2236,14 @@ module ActiveRecord conditions << table[key].eq(foreign_table[foreign_key]) - conditions << reflection_conditions(reflection, table) + conditions << reflection_conditions(index, table) conditions << sti_conditions(reflection, table) - relation = relation.join(table, join_type).on(*conditions.compact) + relation = relation.join(table, join_type).on(*conditions.flatten.compact) # The current table in this iteration becomes the foreign table in the next foreign_table = table + index += 1 end relation @@ -2325,10 +2328,10 @@ module ActiveRecord @tables end - def reflection_conditions(reflection, table) - if reflection.options[:conditions] + def reflection_conditions(index, table) + @reflection.through_conditions.reverse[index].map do |condition| Arel.sql(interpolate_sql(sanitize_sql( - reflection.options[:conditions], + condition, table.table_alias || table.name ))) end diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 261b4037a3..feb0a93360 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -24,7 +24,6 @@ module ActiveRecord end # Build SQL conditions from attributes, qualified by table name. - # TODO: Conditions on joins def construct_conditions reflection = @reflection.through_reflection_chain.last @@ -34,11 +33,12 @@ module ActiveRecord table_alias = table_aliases[reflection] end - conditions = construct_quoted_owner_attributes(reflection).map do |attr, value| + parts = construct_quoted_owner_attributes(reflection).map do |attr, value| "#{table_alias}.#{attr} = #{value}" end - conditions << sql_conditions if sql_conditions - "(" + conditions.join(') AND (') + ")" + parts += reflection_conditions(0) + + "(" + parts.join(') AND (') + ")" end # Associate attributes pointing to owner, quoted. @@ -55,23 +55,21 @@ module ActiveRecord end end - def construct_from - @reflection.table_name - end - def construct_select(custom_select = nil) distinct = "DISTINCT " if @reflection.options[:uniq] selected = custom_select || @reflection.options[:select] || "#{distinct}#{@reflection.quoted_table_name}.*" end def construct_joins(custom_joins = nil) - # p @reflection.through_reflection_chain + # TODO: Remove this at the end + #p @reflection.through_reflection_chain + #p @reflection.through_conditions "#{construct_through_joins} #{@reflection.options[:joins]} #{custom_joins}" end def construct_through_joins - joins = [] + joins, right_index = [], 1 # Iterate over each pair in the through reflection chain, joining them together @reflection.through_reflection_chain.each_cons(2) do |left, right| @@ -86,20 +84,23 @@ module ActiveRecord joins << inner_join_sql( right_table_and_alias, table_aliases[left], left.klass.primary_key, - table_aliases[right], left.primary_key_name + table_aliases[right], left.primary_key_name, + reflection_conditions(right_index) ) when :has_many, :has_one joins << inner_join_sql( right_table_and_alias, table_aliases[left], left.primary_key_name, table_aliases[right], right.klass.primary_key, - polymorphic_conditions(left, left) + polymorphic_conditions(left, left), + reflection_conditions(right_index) ) when :has_and_belongs_to_many joins << inner_join_sql( right_table_and_alias, table_aliases[left].first, left.primary_key_name, - table_aliases[right], right.klass.primary_key + table_aliases[right], right.klass.primary_key, + reflection_conditions(right_index) ) end else @@ -109,7 +110,8 @@ module ActiveRecord right_table_and_alias, table_aliases[left], left.klass.primary_key, table_aliases[right], left.source_reflection.primary_key_name, - source_type_conditions(left) + source_type_conditions(left), + reflection_conditions(right_index) ) when :has_many, :has_one if right.macro == :has_and_belongs_to_many @@ -123,7 +125,8 @@ module ActiveRecord right_table_and_alias, table_aliases[left], left.source_reflection.primary_key_name, right_table, right.klass.primary_key, - polymorphic_conditions(left, left.source_reflection) + polymorphic_conditions(left, left.source_reflection), + reflection_conditions(right_index) ) if right.macro == :has_and_belongs_to_many @@ -151,10 +154,13 @@ module ActiveRecord joins << inner_join_sql( right_table_and_alias, join_table, left.source_reflection.primary_key_name, - table_aliases[right], right.klass.primary_key + table_aliases[right], right.klass.primary_key, + reflection_conditions(right_index) ) end end + + right_index += 1 end joins.join(" ") @@ -206,18 +212,45 @@ module ActiveRecord "#{table_name} #{table_alias if table_alias != table_name}".strip end - def inner_join_sql(table, on_left_table, on_left_key, on_right_table, on_right_key, conds = nil) - "INNER JOIN %s ON %s.%s = %s.%s %s" % [ - table, - on_left_table, on_left_key, - on_right_table, on_right_key, - conds - ] + def inner_join_sql(table, on_left_table, on_left_key, on_right_table, on_right_key, *conditions) + conditions << "#{on_left_table}.#{on_left_key} = #{on_right_table}.#{on_right_key}" + conditions = conditions.flatten.compact + conditions = conditions.map { |sql| "(#{sql})" } * ' AND ' + + "INNER JOIN #{table} ON #{conditions}" + end + + def reflection_conditions(index) + reflection = @reflection.through_reflection_chain[index] + reflection_conditions = @reflection.through_conditions[index] + + conditions = [] + + if reflection.options[:as].nil? && # reflection.klass is a Module if :as is used + reflection.klass.finder_needs_type_condition? + conditions << reflection.klass.send(:type_condition).to_sql + end + + reflection_conditions.each do |condition| + sanitized_condition = reflection.klass.send(:sanitize_sql, condition) + interpolated_condition = interpolate_sql(sanitized_condition) + + if condition.is_a?(Hash) + interpolated_condition.gsub!( + @reflection.quoted_table_name, + reflection.quoted_table_name + ) + end + + conditions << interpolated_condition + end + + conditions end def polymorphic_conditions(reflection, polymorphic_reflection) if polymorphic_reflection.options[:as] - "AND %s.%s = %s" % [ + "%s.%s = %s" % [ table_aliases[reflection], "#{polymorphic_reflection.options[:as]}_type", @owner.class.quote_value(polymorphic_reflection.active_record.base_class.name) ] @@ -226,7 +259,7 @@ module ActiveRecord def source_type_conditions(reflection) if reflection.options[:source_type] - "AND %s.%s = %s" % [ + "%s.%s = %s" % [ table_aliases[reflection.through_reflection], reflection.source_reflection.options[:foreign_type].to_s, @owner.class.quote_value(reflection.options[:source_type]) @@ -245,6 +278,8 @@ module ActiveRecord end # Construct attributes for :through pointing to owner and associate. + # This method is used when adding records to the association. Since this only makes sense for + # non-nested through associations, that's the only case we have to worry about here. def construct_join_attributes(associate) # TODO: revisit this to allow it for deletion, supposing dependent option is supported raise ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection.new(@owner, @reflection) if [:has_one, :has_many].include?(@reflection.source_reflection.macro) @@ -261,48 +296,6 @@ module ActiveRecord join_attributes end - - def conditions - @conditions = build_conditions unless defined?(@conditions) - @conditions - end - - def build_conditions - association_conditions = @reflection.options[:conditions] - through_conditions = build_through_conditions - source_conditions = @reflection.source_reflection.options[:conditions] - uses_sti = !@reflection.through_reflection.klass.descends_from_active_record? - - if association_conditions || through_conditions || source_conditions || uses_sti - all = [] - - [association_conditions, source_conditions].each do |conditions| - all << interpolate_sql(sanitize_sql(conditions)) if conditions - end - - all << through_conditions if through_conditions - all << build_sti_condition if uses_sti - - all.map { |sql| "(#{sql})" } * ' AND ' - end - end - - def build_through_conditions - conditions = @reflection.through_reflection.options[:conditions] - if conditions.is_a?(Hash) - interpolate_sql(@reflection.through_reflection.klass.send(:sanitize_sql, conditions)).gsub( - @reflection.quoted_table_name, - @reflection.through_reflection.quoted_table_name) - elsif conditions - interpolate_sql(sanitize_sql(conditions)) - end - end - - def build_sti_condition - @reflection.through_reflection.klass.send(:type_condition).to_sql - end - - alias_method :sql_conditions, :conditions def ensure_not_nested if @reflection.nested? diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index ee63fcfce2..d8bd6c9873 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -253,6 +253,10 @@ module ActiveRecord def through_reflection_chain [self] end + + def through_conditions + [Array.wrap(options[:conditions])] + end def through_reflection_primary_key_name end @@ -378,9 +382,9 @@ module ActiveRecord # TODO: Documentation def through_reflection_chain @through_reflection_chain ||= begin - if source_reflection.through_reflection - # If the source reflection goes through another reflection, then the chain must start - # by getting us to the source reflection. + if source_reflection.source_reflection + # If the source reflection has its own source reflection, then the chain must start + # by getting us to that source reflection. chain = source_reflection.through_reflection_chain else # If the source reflection does not go through another reflection, then we can get @@ -396,6 +400,49 @@ module ActiveRecord end end + # Consider the following example: + # + # class Person + # has_many :articles + # has_many :comment_tags, :through => :articles + # end + # + # class Article + # has_many :comments + # has_many :comment_tags, :through => :comments, :source => :tags + # end + # + # class Comment + # has_many :tags + # end + # + # There may be conditions on Person.comment_tags, Article.comment_tags and/or Comment.tags, + # but only Comment.tags will be represented in the through_reflection_chain. So this method + # creates an array of conditions corresponding to the through_reflection_chain. Each item in + # the through_conditions array corresponds to an item in the through_reflection_chain, and is + # itself an array of conditions from an arbitrary number of relevant reflections. + def through_conditions + @through_conditions ||= begin + # Initialize the first item - which corresponds to this reflection - either by recursing + # into the souce reflection (if it is itself a through reflection), or by grabbing the + # source reflection conditions. + if source_reflection.source_reflection + conditions = source_reflection.through_conditions + else + conditions = [Array.wrap(source_reflection.options[:conditions])] + end + + # Add to it the conditions from this reflection if necessary. + conditions.first << options[:conditions] if options[:conditions] + + # Recursively fill out the rest of the array from the through reflection + conditions += through_reflection.through_conditions + + # And return + conditions + end + end + def nested? through_reflection_chain.length > 2 end diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index 0e9c8a2639..5b24d49a7d 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -15,7 +15,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase authors = Author.find(:all, :include=>{:posts=>:comments}, :order=>"authors.id") assert_equal 3, authors.size assert_equal 5, authors[0].posts.size - assert_equal 2, authors[1].posts.size + assert_equal 3, authors[1].posts.size assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} end @@ -23,7 +23,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase authors = Author.find(:all, :include=>[{:posts=>:comments}, :categorizations], :order=>"authors.id") assert_equal 3, authors.size assert_equal 5, authors[0].posts.size - assert_equal 2, authors[1].posts.size + assert_equal 3, authors[1].posts.size assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} assert_equal 1, authors[0].categorizations.size assert_equal 2, authors[1].categorizations.size @@ -56,7 +56,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase authors = Author.find(:all, :include=>{:posts=>[:comments, :categorizations]}, :order=>"authors.id") assert_equal 3, authors.size assert_equal 5, authors[0].posts.size - assert_equal 2, authors[1].posts.size + assert_equal 3, authors[1].posts.size assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} end diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index 2ff0714e9f..6b910ae2a0 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -53,8 +53,8 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_with_ordering list = Post.find(:all, :include => :comments, :order => "posts.id DESC") - [:misc_by_mary, :misc_by_bob, :eager_other, :sti_habtm, :sti_post_and_comments, - :sti_comments, :authorless, :thinking, :welcome + [:other_by_mary, :other_by_bob, :misc_by_mary, :misc_by_bob, :eager_other, + :sti_habtm, :sti_post_and_comments, :sti_comments, :authorless, :thinking, :welcome ].each_with_index do |post, index| assert_equal posts(post), list[index] end diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 03ec4281d8..c39ec5d139 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -92,8 +92,6 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert_no_queries do assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick) end - - # TODO: Add eager loading test using LEFT OUTER JOIN end # has_many through @@ -325,7 +323,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_nested_has_many_through_with_a_table_referenced_multiple_times author = authors(:bob) - assert_equal [posts(:misc_by_bob), posts(:misc_by_mary)], author.similar_posts.sort_by(&:id) + assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)], author.similar_posts.sort_by(&:id) # Mary and Bob both have posts in misc, but they are the only ones. authors = Author.joins(:similar_posts).where('posts.id' => posts(:misc_by_bob).id) @@ -406,6 +404,42 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase end end + def test_nested_has_many_through_with_conditions_on_through_associations + blue, bob = tags(:blue), authors(:bob) + + assert_equal [blue], bob.misc_post_first_blue_tags + + # Pointless condition to force single-query loading + assert_includes_and_joins_equal( + Author.where('tags.id = tags.id'), + [bob], :misc_post_first_blue_tags + ) + + assert Author.where('tags.id' => 100).joins(:misc_post_first_blue_tags).empty? + + authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a } + assert_no_queries do + assert_equal [blue], authors[2].misc_post_first_blue_tags + end + end + + def test_nested_has_many_through_with_conditions_on_source_associations + blue, bob = tags(:blue), authors(:bob) + + assert_equal [blue], bob.misc_post_first_blue_tags_2 + + # Pointless condition to force single-query loading + assert_includes_and_joins_equal( + Author.where('tags.id = tags.id'), + [bob], :misc_post_first_blue_tags_2 + ) + + authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a } + assert_no_queries do + assert_equal [blue], authors[2].misc_post_first_blue_tags_2 + end + end + private def assert_includes_and_joins_equal(query, expected, association) diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb index 70883ad30f..9e72ac4250 100644 --- a/activerecord/test/cases/batches_test.rb +++ b/activerecord/test/cases/batches_test.rb @@ -24,7 +24,7 @@ class EachTest < ActiveRecord::TestCase end def test_each_should_execute_if_id_is_in_select - assert_queries(5) do + assert_queries(6) do Post.find_each(:select => "id, title, type", :batch_size => 2) do |post| assert_kind_of Post, post end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 0476fc94df..4c9475f1cd 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -123,11 +123,13 @@ class FinderTest < ActiveRecord::TestCase def test_find_all_with_limit_and_offset_and_multiple_order_clauses first_three_posts = Post.find :all, :order => 'author_id, id', :limit => 3, :offset => 0 second_three_posts = Post.find :all, :order => ' author_id,id ', :limit => 3, :offset => 3 - last_posts = Post.find :all, :order => ' author_id, id ', :limit => 3, :offset => 6 + third_three_posts = Post.find :all, :order => ' author_id, id ', :limit => 3, :offset => 6 + last_posts = Post.find :all, :order => ' author_id, id ', :limit => 3, :offset => 9 assert_equal [[0,3],[1,1],[1,2]], first_three_posts.map { |p| [p.author_id, p.id] } assert_equal [[1,4],[1,5],[1,6]], second_three_posts.map { |p| [p.author_id, p.id] } - assert_equal [[2,7],[2,9],[3,8]], last_posts.map { |p| [p.author_id, p.id] } + assert_equal [[2,7],[2,9],[2,11]], third_three_posts.map { |p| [p.author_id, p.id] } + assert_equal [[3,8],[3,10]], last_posts.map { |p| [p.author_id, p.id] } end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index df4e84ca29..0d88c8eded 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -501,22 +501,22 @@ class RelationTest < ActiveRecord::TestCase def test_count posts = Post.scoped - assert_equal 9, posts.count - assert_equal 9, posts.count(:all) - assert_equal 9, posts.count(:id) + assert_equal 11, posts.count + assert_equal 11, posts.count(:all) + assert_equal 11, posts.count(:id) assert_equal 1, posts.where('comments_count > 1').count - assert_equal 7, posts.where(:comments_count => 0).count + assert_equal 9, posts.where(:comments_count => 0).count end def test_count_with_distinct posts = Post.scoped assert_equal 3, posts.count(:comments_count, :distinct => true) - assert_equal 9, posts.count(:comments_count, :distinct => false) + assert_equal 11, posts.count(:comments_count, :distinct => false) assert_equal 3, posts.select(:comments_count).count(:distinct => true) - assert_equal 9, posts.select(:comments_count).count(:distinct => false) + assert_equal 11, posts.select(:comments_count).count(:distinct => false) end def test_count_explicit_columns @@ -526,7 +526,7 @@ class RelationTest < ActiveRecord::TestCase assert_equal [0], posts.select('comments_count').where('id is not null').group('id').order('id').count.values.uniq assert_equal 0, posts.where('id is not null').select('comments_count').count - assert_equal 9, posts.select('comments_count').count('id') + assert_equal 11, posts.select('comments_count').count('id') assert_equal 0, posts.select('comments_count').count assert_equal 0, posts.count(:comments_count) assert_equal 0, posts.count('comments_count') @@ -541,12 +541,12 @@ class RelationTest < ActiveRecord::TestCase def test_size posts = Post.scoped - assert_queries(1) { assert_equal 9, posts.size } + assert_queries(1) { assert_equal 11, posts.size } assert ! posts.loaded? best_posts = posts.where(:comments_count => 0) best_posts.to_a # force load - assert_no_queries { assert_equal 7, best_posts.size } + assert_no_queries { assert_equal 9, best_posts.size } end def test_count_complex_chained_relations diff --git a/activerecord/test/fixtures/posts.yml b/activerecord/test/fixtures/posts.yml index ca6d4c2fe1..264ca164f0 100644 --- a/activerecord/test/fixtures/posts.yml +++ b/activerecord/test/fixtures/posts.yml @@ -64,3 +64,17 @@ misc_by_mary: title: misc post by mary body: hello type: Post + +other_by_bob: + id: 10 + author_id: 3 + title: other post by bob + body: hello + type: Post + +other_by_mary: + id: 11 + author_id: 2 + title: other post by mary + body: hello + type: Post diff --git a/activerecord/test/fixtures/taggings.yml b/activerecord/test/fixtures/taggings.yml index 7cc7198ded..a337cce019 100644 --- a/activerecord/test/fixtures/taggings.yml +++ b/activerecord/test/fixtures/taggings.yml @@ -38,3 +38,31 @@ misc_post_by_mary: tag_id: 2 taggable_id: 9 taggable_type: Post + +misc_by_bob_blue_first: + id: 8 + tag_id: 3 + taggable_id: 8 + taggable_type: Post + comment: first + +misc_by_bob_blue_second: + id: 9 + tag_id: 3 + taggable_id: 8 + taggable_type: Post + comment: second + +other_by_bob_blue: + id: 10 + tag_id: 3 + taggable_id: 10 + taggable_type: Post + comment: first + +other_by_mary_blue: + id: 11 + tag_id: 3 + taggable_id: 11 + taggable_type: Post + comment: first diff --git a/activerecord/test/fixtures/tags.yml b/activerecord/test/fixtures/tags.yml index 6cb886dc46..d4b7c9a4d5 100644 --- a/activerecord/test/fixtures/tags.yml +++ b/activerecord/test/fixtures/tags.yml @@ -5,3 +5,7 @@ general: misc: id: 2 name: Misc + +blue: + id: 3 + name: Blue diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index b5f702018a..c0e082836d 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -86,7 +86,7 @@ class Author < ActiveRecord::Base has_many :tagging, :through => :posts has_many :taggings, :through => :posts has_many :tags, :through => :posts - has_many :similar_posts, :through => :tags, :source => :tagged_posts + has_many :similar_posts, :through => :tags, :source => :tagged_posts, :uniq => true has_many :distinct_tags, :through => :posts, :source => :tags, :select => "DISTINCT tags.*", :order => "tags.name" has_many :post_categories, :through => :posts, :source => :categories has_many :tagging_tags, :through => :taggings, :source => :tag @@ -103,6 +103,11 @@ class Author < ActiveRecord::Base has_many :post_categories, :through => :posts, :source => :categories has_many :category_post_comments, :through => :categories, :source => :post_comments + + has_many :misc_posts, :class_name => 'Post', :conditions => "posts.title LIKE 'misc post%'" + has_many :misc_post_first_blue_tags, :through => :misc_posts, :source => :first_blue_tags + + has_many :misc_post_first_blue_tags_2, :through => :posts, :source => :first_blue_tags_2, :conditions => "posts.title LIKE 'misc post%'" scope :relation_include_posts, includes(:posts) scope :relation_include_tags, includes(:tags) diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index f3b78c3647..281586b438 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -64,6 +64,11 @@ class Post < ActiveRecord::Base has_many :funky_tags, :through => :taggings, :source => :tag has_many :super_tags, :through => :taggings has_one :tagging, :as => :taggable + + has_many :first_taggings, :as => :taggable, :class_name => 'Tagging', :conditions => "taggings.comment = 'first'" + has_many :first_blue_tags, :through => :first_taggings, :source => :tag, :conditions => "tags.name = 'Blue'" + + has_many :first_blue_tags_2, :through => :taggings, :source => :blue_tag, :conditions => "taggings.comment = 'first'" has_many :invalid_taggings, :as => :taggable, :class_name => "Tagging", :conditions => 'taggings.id < 0' has_many :invalid_tags, :through => :invalid_taggings, :source => :tag diff --git a/activerecord/test/models/tagging.rb b/activerecord/test/models/tagging.rb index a1fa1a9750..c92df88e71 100644 --- a/activerecord/test/models/tagging.rb +++ b/activerecord/test/models/tagging.rb @@ -6,5 +6,6 @@ class Tagging < ActiveRecord::Base belongs_to :tag, :include => :tagging belongs_to :super_tag, :class_name => 'Tag', :foreign_key => 'super_tag_id' belongs_to :invalid_tag, :class_name => 'Tag', :foreign_key => 'tag_id' + belongs_to :blue_tag, :class_name => 'Tag', :foreign_key => :tag_id, :conditions => "tags.name = 'Blue'" belongs_to :taggable, :polymorphic => true, :counter_cache => true -end \ No newline at end of file +end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 8b9c56b895..ee129162a6 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -537,6 +537,7 @@ ActiveRecord::Schema.define do t.column :super_tag_id, :integer t.column :taggable_type, :string t.column :taggable_id, :integer + t.string :comment end create_table :tags, :force => true do |t| -- cgit v1.2.3 From 596cc3b2329a9cc4a30c95c157ce36b2d08975df Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 12:47:19 +0100 Subject: Respect the :primary_key option on the through_reflection of (non-nested) through associations --- .../associations/has_many_association.rb | 6 +++--- .../associations/has_one_association.rb | 6 +++--- .../associations/through_association_scope.rb | 4 ++-- .../has_many_through_associations_test.rb | 19 ++++++++++++++++++- .../has_one_through_associations_test.rb | 20 +++++++++++++++++++- activerecord/test/fixtures/essays.yml | 6 ++++++ activerecord/test/models/author.rb | 12 +++++++++++- activerecord/test/models/essay.rb | 1 + activerecord/test/schema/schema.rb | 2 ++ 9 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 activerecord/test/fixtures/essays.yml diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index 830a82980d..7eaa05ee36 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -7,9 +7,9 @@ module ActiveRecord # is provided by its child HasManyThroughAssociation. class HasManyAssociation < AssociationCollection #:nodoc: protected - def owner_quoted_id - if @reflection.options[:primary_key] - @owner.class.quote_value(@owner.send(@reflection.options[:primary_key])) + def owner_quoted_id(reflection = @reflection) + if reflection.options[:primary_key] + @owner.class.quote_value(@owner.send(reflection.options[:primary_key])) else @owner.quoted_id end diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index 17901387e9..c6bcfec275 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -64,9 +64,9 @@ module ActiveRecord end protected - def owner_quoted_id - if @reflection.options[:primary_key] - @owner.class.quote_value(@owner.send(@reflection.options[:primary_key])) + def owner_quoted_id(reflection = @reflection) + if reflection.options[:primary_key] + @owner.class.quote_value(@owner.send(reflection.options[:primary_key])) else @owner.quoted_id end diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index feb0a93360..86ceb1a204 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -44,14 +44,14 @@ module ActiveRecord # Associate attributes pointing to owner, quoted. def construct_quoted_owner_attributes(reflection) if as = reflection.options[:as] - { "#{as}_id" => owner_quoted_id, + { "#{as}_id" => owner_quoted_id(reflection), "#{as}_type" => reflection.klass.quote_value( @owner.class.base_class.name.to_s, reflection.klass.columns_hash["#{as}_type"]) } elsif reflection.macro == :belongs_to { reflection.klass.primary_key => @owner.class.quote_value(@owner[reflection.primary_key_name]) } else - { reflection.primary_key_name => owner_quoted_id } + { reflection.primary_key_name => owner_quoted_id(reflection) } end end diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index 4b9f49f1ec..5a2e6b26aa 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -17,11 +17,14 @@ require 'models/developer' require 'models/subscriber' require 'models/book' require 'models/subscription' +require 'models/essay' +require 'models/category' class HasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :posts, :readers, :people, :comments, :authors, :owners, :pets, :toys, :jobs, :references, :companies, - :subscribers, :books, :subscriptions, :developers + :subscribers, :books, :subscriptions, :developers, + :essays, :categories # Dummies to force column loads so query counts are clean. def setup @@ -449,4 +452,18 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase comment = post.comments.build assert author.comments.include?(comment) end + + def test_has_many_through_polymorphic_with_primary_key_option_on_through_reflection + assert_equal [categories(:general)], authors(:david).essay_categories + + authors = Author.joins(:essay_categories).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + end + + def test_has_many_through_with_primary_key_option_on_through_reflection + assert_equal [categories(:general)], authors(:david).essay_categories_2 + + authors = Author.joins(:essay_categories_2).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + end end diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb index 5d153147f5..8805968869 100644 --- a/activerecord/test/cases/associations/has_one_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -9,9 +9,13 @@ require 'models/member_detail' require 'models/minivan' require 'models/dashboard' require 'models/speedometer' +require 'models/category' +require 'models/author' +require 'models/essay' class HasOneThroughAssociationsTest < ActiveRecord::TestCase - fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, :dashboards, :speedometers + fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, + :dashboards, :speedometers, :categories, :authors, :essays def setup @member = members(:groucho) @@ -212,4 +216,18 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase minivan.dashboard end end + + def test_has_one_through_polymorphic_with_primary_key_option_on_through_reflection + assert_equal categories(:general), authors(:david).essay_category + + authors = Author.joins(:essay_category).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + end + + def test_has_one_through_with_primary_key_option_on_through_reflection + assert_equal categories(:general), authors(:david).essay_category_2 + + authors = Author.joins(:essay_category_2).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + end end diff --git a/activerecord/test/fixtures/essays.yml b/activerecord/test/fixtures/essays.yml new file mode 100644 index 0000000000..8c96a469e6 --- /dev/null +++ b/activerecord/test/fixtures/essays.yml @@ -0,0 +1,6 @@ +david_modest_proposal: + name: A Modest Proposal + writer_type: Author + writer_id: David + category_id: 1 + author_id: David diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index c0e082836d..1ba01d6b6b 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -95,8 +95,18 @@ class Author < ActiveRecord::Base has_many :subscriptions, :through => :books has_many :subscribers, :through => :subscriptions, :order => "subscribers.nick" # through has_many :through (on through reflection) has_many :distinct_subscribers, :through => :subscriptions, :source => :subscriber, :select => "DISTINCT subscribers.*", :order => "subscribers.nick" - + has_one :essay, :primary_key => :name, :as => :writer + has_one :essay_category, :through => :essay, :source => :category + + has_one :essay_2, :primary_key => :name, :class_name => 'Essay', :foreign_key => :author_id + has_one :essay_category_2, :through => :essay_2, :source => :category + + has_many :essays, :primary_key => :name, :as => :writer + has_many :essay_categories, :through => :essays, :source => :category + + has_many :essays_2, :primary_key => :name, :class_name => 'Essay', :foreign_key => :author_id + has_many :essay_categories_2, :through => :essays_2, :source => :category belongs_to :author_address, :dependent => :destroy belongs_to :author_address_extra, :dependent => :delete, :class_name => "AuthorAddress" diff --git a/activerecord/test/models/essay.rb b/activerecord/test/models/essay.rb index 6c28f5e49b..6a62042863 100644 --- a/activerecord/test/models/essay.rb +++ b/activerecord/test/models/essay.rb @@ -1,3 +1,4 @@ class Essay < ActiveRecord::Base belongs_to :writer, :primary_key => :name, :polymorphic => true + belongs_to :category end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index ee129162a6..b5bf9a7349 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -214,6 +214,8 @@ ActiveRecord::Schema.define do t.string :name t.string :writer_id t.string :writer_type + t.integer :category_id + t.integer :author_id end create_table :events, :force => true do |t| -- cgit v1.2.3 From 01838636c6136d9a649ace71db61bb7990f9bd82 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 14:14:06 +0100 Subject: Support for :primary_key option on the source reflection of a through association, where the source is a has_one or has_many --- activerecord/lib/active_record/associations.rb | 10 ++++------ .../active_record/associations/through_association_scope.rb | 4 ++-- activerecord/lib/active_record/reflection.rb | 8 ++++++++ .../cases/associations/has_many_through_associations_test.rb | 12 +++++++++--- .../cases/associations/has_one_through_associations_test.rb | 12 +++++++++--- activerecord/test/fixtures/essays.yml | 2 +- activerecord/test/fixtures/owners.yml | 1 + activerecord/test/models/author.rb | 2 ++ activerecord/test/models/essay.rb | 3 ++- activerecord/test/schema/schema.rb | 5 +++-- 10 files changed, 41 insertions(+), 18 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 75e5eb8ee4..29f1c7b81d 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2173,13 +2173,11 @@ module ActiveRecord if reflection.source_reflection.nil? case reflection.macro when :belongs_to - key = reflection.options[:primary_key] || - reflection.klass.primary_key + key = reflection.association_primary_key foreign_key = reflection.primary_key_name when :has_many, :has_one key = reflection.primary_key_name - foreign_key = reflection.options[:primary_key] || - reflection.active_record.primary_key + foreign_key = reflection.active_record_primary_key conditions << polymorphic_conditions(reflection, table) when :has_and_belongs_to_many @@ -2209,13 +2207,13 @@ module ActiveRecord else case reflection.source_reflection.macro when :belongs_to - key = reflection.klass.primary_key + key = reflection.source_reflection.association_primary_key foreign_key = reflection.source_reflection.primary_key_name conditions << source_type_conditions(reflection, foreign_table) when :has_many, :has_one key = reflection.source_reflection.primary_key_name - foreign_key = reflection.source_reflection.klass.primary_key + foreign_key = reflection.source_reflection.active_record_primary_key when :has_and_belongs_to_many table, join_table = table diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 86ceb1a204..2b2229f01f 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -108,7 +108,7 @@ module ActiveRecord when :belongs_to joins << inner_join_sql( right_table_and_alias, - table_aliases[left], left.klass.primary_key, + table_aliases[left], left.source_reflection.association_primary_key, table_aliases[right], left.source_reflection.primary_key_name, source_type_conditions(left), reflection_conditions(right_index) @@ -124,7 +124,7 @@ module ActiveRecord joins << inner_join_sql( right_table_and_alias, table_aliases[left], left.source_reflection.primary_key_name, - right_table, right.klass.primary_key, + right_table, left.source_reflection.active_record_primary_key, polymorphic_conditions(left, left.source_reflection), reflection_conditions(right_index) ) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index d8bd6c9873..a46597e497 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -217,6 +217,14 @@ module ActiveRecord def association_foreign_key @association_foreign_key ||= @options[:association_foreign_key] || class_name.foreign_key end + + def association_primary_key + @association_primary_key ||= @options[:primary_key] || klass.primary_key + end + + def active_record_primary_key + @active_record_primary_key ||= @options[:primary_key] || active_record.primary_key + end def counter_cache_column if options[:counter_cache] == true diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index 5a2e6b26aa..713c492f5e 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -19,12 +19,13 @@ require 'models/book' require 'models/subscription' require 'models/essay' require 'models/category' +require 'models/owner' class HasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :posts, :readers, :people, :comments, :authors, :owners, :pets, :toys, :jobs, :references, :companies, :subscribers, :books, :subscriptions, :developers, - :essays, :categories + :essays, :categories, :owners # Dummies to force column loads so query counts are clean. def setup @@ -453,14 +454,19 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase assert author.comments.include?(comment) end - def test_has_many_through_polymorphic_with_primary_key_option_on_through_reflection + def test_has_many_through_polymorphic_with_primary_key_option assert_equal [categories(:general)], authors(:david).essay_categories authors = Author.joins(:essay_categories).where('categories.id' => categories(:general).id) assert_equal authors(:david), authors.first + + assert_equal [owners(:blackbeard)], authors(:david).essay_owners + + authors = Author.joins(:essay_owners).where("owners.name = 'blackbeard'") + assert_equal authors(:david), authors.first end - def test_has_many_through_with_primary_key_option_on_through_reflection + def test_has_many_through_with_primary_key_option assert_equal [categories(:general)], authors(:david).essay_categories_2 authors = Author.joins(:essay_categories_2).where('categories.id' => categories(:general).id) diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb index 8805968869..39e14b4bfd 100644 --- a/activerecord/test/cases/associations/has_one_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -12,10 +12,11 @@ require 'models/speedometer' require 'models/category' require 'models/author' require 'models/essay' +require 'models/owner' class HasOneThroughAssociationsTest < ActiveRecord::TestCase fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, - :dashboards, :speedometers, :categories, :authors, :essays + :dashboards, :speedometers, :categories, :authors, :essays, :owners def setup @member = members(:groucho) @@ -217,14 +218,19 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase end end - def test_has_one_through_polymorphic_with_primary_key_option_on_through_reflection + def test_has_one_through_polymorphic_with_primary_key_option assert_equal categories(:general), authors(:david).essay_category authors = Author.joins(:essay_category).where('categories.id' => categories(:general).id) assert_equal authors(:david), authors.first + + assert_equal owners(:blackbeard), authors(:david).essay_owner + + authors = Author.joins(:essay_owner).where("owners.name = 'blackbeard'") + assert_equal authors(:david), authors.first end - def test_has_one_through_with_primary_key_option_on_through_reflection + def test_has_one_through_with_primary_key_option assert_equal categories(:general), authors(:david).essay_category_2 authors = Author.joins(:essay_category_2).where('categories.id' => categories(:general).id) diff --git a/activerecord/test/fixtures/essays.yml b/activerecord/test/fixtures/essays.yml index 8c96a469e6..9d15d82359 100644 --- a/activerecord/test/fixtures/essays.yml +++ b/activerecord/test/fixtures/essays.yml @@ -2,5 +2,5 @@ david_modest_proposal: name: A Modest Proposal writer_type: Author writer_id: David - category_id: 1 + category_id: General author_id: David diff --git a/activerecord/test/fixtures/owners.yml b/activerecord/test/fixtures/owners.yml index d5493a84b7..2d21ce433c 100644 --- a/activerecord/test/fixtures/owners.yml +++ b/activerecord/test/fixtures/owners.yml @@ -1,6 +1,7 @@ blackbeard: owner_id: 1 name: blackbeard + essay_id: A Modest Proposal ashley: owner_id: 2 diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 1ba01d6b6b..53b3b80950 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -98,12 +98,14 @@ class Author < ActiveRecord::Base has_one :essay, :primary_key => :name, :as => :writer has_one :essay_category, :through => :essay, :source => :category + has_one :essay_owner, :through => :essay, :source => :owner has_one :essay_2, :primary_key => :name, :class_name => 'Essay', :foreign_key => :author_id has_one :essay_category_2, :through => :essay_2, :source => :category has_many :essays, :primary_key => :name, :as => :writer has_many :essay_categories, :through => :essays, :source => :category + has_many :essay_owners, :through => :essays, :source => :owner has_many :essays_2, :primary_key => :name, :class_name => 'Essay', :foreign_key => :author_id has_many :essay_categories_2, :through => :essays_2, :source => :category diff --git a/activerecord/test/models/essay.rb b/activerecord/test/models/essay.rb index 6a62042863..ec4b982b5b 100644 --- a/activerecord/test/models/essay.rb +++ b/activerecord/test/models/essay.rb @@ -1,4 +1,5 @@ class Essay < ActiveRecord::Base belongs_to :writer, :primary_key => :name, :polymorphic => true - belongs_to :category + belongs_to :category, :primary_key => :name + has_one :owner, :primary_key => :name end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index b5bf9a7349..de3baaf4ab 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -214,8 +214,8 @@ ActiveRecord::Schema.define do t.string :name t.string :writer_id t.string :writer_type - t.integer :category_id - t.integer :author_id + t.string :category_id + t.string :author_id end create_table :events, :force => true do |t| @@ -369,6 +369,7 @@ ActiveRecord::Schema.define do t.string :name t.column :updated_at, :datetime t.column :happy_at, :datetime + t.string :essay_id end -- cgit v1.2.3 From 9ff5fdeda99b3d8c5148d4c40956842518d1c788 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 14:56:59 +0100 Subject: Remove unused methods --- activerecord/lib/active_record/reflection.rb | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index a46597e497..6078191773 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -266,9 +266,6 @@ module ActiveRecord [Array.wrap(options[:conditions])] end - def through_reflection_primary_key_name - end - def source_reflection nil end @@ -489,14 +486,6 @@ module ActiveRecord check_validity_of_inverse! end - def through_reflection_primary_key - through_reflection.belongs_to? ? through_reflection.klass.primary_key : through_reflection.primary_key_name - end - - def through_reflection_primary_key_name - through_reflection.primary_key_name if through_reflection.belongs_to? - end - private def derive_class_name # get the class_name of the belongs_to association of the through reflection -- cgit v1.2.3 From 0ceb34295501a797c9e549c581ecee17f837f01c Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 15:24:30 +0100 Subject: Bugfix/refactoring --- activerecord/lib/active_record/associations.rb | 12 ++++++------ .../active_record/associations/through_association_scope.rb | 10 +++++----- activerecord/lib/active_record/reflection.rb | 9 +++++++++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 29f1c7b81d..9e000f2aae 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2207,18 +2207,18 @@ module ActiveRecord else case reflection.source_reflection.macro when :belongs_to - key = reflection.source_reflection.association_primary_key - foreign_key = reflection.source_reflection.primary_key_name + key = reflection.association_primary_key + foreign_key = reflection.primary_key_name conditions << source_type_conditions(reflection, foreign_table) when :has_many, :has_one - key = reflection.source_reflection.primary_key_name + key = reflection.primary_key_name foreign_key = reflection.source_reflection.active_record_primary_key when :has_and_belongs_to_many table, join_table = table - join_key = reflection.source_reflection.primary_key_name - join_foreign_key = reflection.source_reflection.klass.primary_key + join_key = reflection.primary_key_name + join_foreign_key = reflection.klass.primary_key relation = relation.join(join_table, join_type).on( join_table[join_key]. @@ -2228,7 +2228,7 @@ module ActiveRecord foreign_table = join_table key = reflection.klass.primary_key - foreign_key = reflection.source_reflection.association_foreign_key + foreign_key = reflection.association_foreign_key end end diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 2b2229f01f..1365851337 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -108,8 +108,8 @@ module ActiveRecord when :belongs_to joins << inner_join_sql( right_table_and_alias, - table_aliases[left], left.source_reflection.association_primary_key, - table_aliases[right], left.source_reflection.primary_key_name, + table_aliases[left], left.association_primary_key, + table_aliases[right], left.primary_key_name, source_type_conditions(left), reflection_conditions(right_index) ) @@ -123,7 +123,7 @@ module ActiveRecord joins << inner_join_sql( right_table_and_alias, - table_aliases[left], left.source_reflection.primary_key_name, + table_aliases[left], left.primary_key_name, right_table, left.source_reflection.active_record_primary_key, polymorphic_conditions(left, left.source_reflection), reflection_conditions(right_index) @@ -148,12 +148,12 @@ module ActiveRecord join_table ), left_table, left.klass.primary_key, - join_table, left.source_reflection.association_foreign_key + join_table, left.association_foreign_key ) joins << inner_join_sql( right_table_and_alias, - join_table, left.source_reflection.primary_key_name, + join_table, left.primary_key_name, table_aliases[right], right.klass.primary_key, reflection_conditions(right_index) ) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 6078191773..7ce2bbb8ae 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -357,6 +357,8 @@ module ActiveRecord # Holds all the meta-data about a :through association as it was specified # in the Active Record class. class ThroughReflection < AssociationReflection #:nodoc: + delegate :primary_key_name, :association_foreign_key, :to => :source_reflection + # Gets the source of the through reflection. It checks both a singularized # and pluralized form for :belongs_to or :has_many. # @@ -451,6 +453,13 @@ module ActiveRecord def nested? through_reflection_chain.length > 2 end + + # We want to use the klass from this reflection, rather than just delegate straight to + # the source_reflection, because the source_reflection may be polymorphic. We still + # need to respect the source_reflection's :primary_key option, though. + def association_primary_key + @association_primary_key ||= source_reflection.options[:primary_key] || klass.primary_key + end # Gets an array of possible :through source reflection names: # -- cgit v1.2.3 From 915ea5ea826d48107e4c1953c7a32cf26727d10e Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 16:13:06 +0100 Subject: Support the :primary_key option on a through reflection in a nested through association --- .../associations/through_association_scope.rb | 7 ++----- .../nested_has_many_through_associations_test.rb | 17 ++++++++++++++++- activerecord/test/fixtures/authors.yml | 2 ++ activerecord/test/models/author.rb | 3 +++ activerecord/test/models/organization.rb | 8 +++++++- activerecord/test/schema/schema.rb | 2 ++ 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 1365851337..649bbd206a 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -76,14 +76,11 @@ module ActiveRecord right_table_and_alias = table_name_and_alias(right.quoted_table_name, table_aliases[right]) if left.source_reflection.nil? - # TODO: Perhaps need to pay attention to left.options[:primary_key] and - # left.options[:foreign_key] in places here - case left.macro when :belongs_to joins << inner_join_sql( right_table_and_alias, - table_aliases[left], left.klass.primary_key, + table_aliases[left], left.association_primary_key, table_aliases[right], left.primary_key_name, reflection_conditions(right_index) ) @@ -91,7 +88,7 @@ module ActiveRecord joins << inner_join_sql( right_table_and_alias, table_aliases[left], left.primary_key_name, - table_aliases[right], right.klass.primary_key, + table_aliases[right], right.association_primary_key, polymorphic_conditions(left, left), reflection_conditions(right_index) ) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index c39ec5d139..3a4601b032 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -21,6 +21,7 @@ require 'models/organization' require 'models/category' require 'models/categorization' require 'models/membership' +require 'models/essay' # NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which # are just one level deep. But it's all the same thing really, as the "nested" code is being @@ -31,7 +32,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts, - :categorizations, :memberships + :categorizations, :memberships, :essays # Through associations can either use the has_many or has_one macros. # @@ -440,6 +441,20 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase end end + def test_nested_has_many_through_with_foreign_key_option_on_the_source_reflection_through_reflection + assert_equal [categories(:general)], organizations(:nsa).author_essay_categories + + organizations = Organization.joins(:author_essay_categories). + where('categories.id' => categories(:general).id) + assert_equal [organizations(:nsa)], organizations + + assert_equal categories(:general), organizations(:nsa).author_owned_essay_category + + organizations = Organization.joins(:author_owned_essay_category). + where('categories.id' => categories(:general).id) + assert_equal [organizations(:nsa)], organizations + end + private def assert_includes_and_joins_equal(query, expected, association) diff --git a/activerecord/test/fixtures/authors.yml b/activerecord/test/fixtures/authors.yml index 6f13ec4dac..832236a486 100644 --- a/activerecord/test/fixtures/authors.yml +++ b/activerecord/test/fixtures/authors.yml @@ -3,6 +3,8 @@ david: name: David author_address_id: 1 author_address_extra_id: 2 + organization_id: No Such Agency + owned_essay_id: A Modest Proposal mary: id: 2 diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 53b3b80950..dd8a20ce9b 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -110,6 +110,9 @@ class Author < ActiveRecord::Base has_many :essays_2, :primary_key => :name, :class_name => 'Essay', :foreign_key => :author_id has_many :essay_categories_2, :through => :essays_2, :source => :category + belongs_to :owned_essay, :primary_key => :name, :class_name => 'Essay' + has_one :owned_essay_category, :through => :owned_essay, :source => :category + belongs_to :author_address, :dependent => :destroy belongs_to :author_address_extra, :dependent => :delete, :class_name => "AuthorAddress" diff --git a/activerecord/test/models/organization.rb b/activerecord/test/models/organization.rb index 1da342a0bd..c18c28c696 100644 --- a/activerecord/test/models/organization.rb +++ b/activerecord/test/models/organization.rb @@ -2,5 +2,11 @@ class Organization < ActiveRecord::Base has_many :member_details has_many :members, :through => :member_details + has_many :authors, :primary_key => :name + has_many :author_essay_categories, :through => :authors, :source => :essay_categories + + has_one :author, :primary_key => :name + has_one :author_owned_essay_category, :through => :author, :source => :owned_essay_category + scope :clubs, { :from => 'clubs' } -end \ No newline at end of file +end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index de3baaf4ab..c77651a782 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -44,6 +44,8 @@ ActiveRecord::Schema.define do t.string :name, :null => false t.integer :author_address_id t.integer :author_address_extra_id + t.string :organization_id + t.string :owned_essay_id end create_table :author_addresses, :force => true do |t| -- cgit v1.2.3 From b00db54e746ab0f1664d7f160b46beb49587b370 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 16:23:53 +0100 Subject: Small refactoring --- activerecord/lib/active_record/associations.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 9e000f2aae..028157d7e9 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2186,9 +2186,7 @@ module ActiveRecord # to represent the join table) table, join_table = table - # TODO: Can join_key just be reflection.primary_key_name ? - join_key = reflection.options[:foreign_key] || - reflection.active_record.to_s.foreign_key + join_key = reflection.primary_key_name join_foreign_key = reflection.active_record.primary_key relation = relation.join(join_table, join_type).on( @@ -2199,10 +2197,8 @@ module ActiveRecord # We've done the first join now, so update the foreign_table for the second foreign_table = join_table - # TODO: Can foreign_key be reflection.association_foreign_key? key = reflection.klass.primary_key - foreign_key = reflection.options[:association_foreign_key] || - reflection.klass.to_s.foreign_key + foreign_key = reflection.association_foreign_key end else case reflection.source_reflection.macro -- cgit v1.2.3 From 1f7415ab3a5b433ecfb0c10d66343a894d73914a Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 16:26:35 +0100 Subject: Fix broken test --- activerecord/test/cases/json_serialization_test.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/activerecord/test/cases/json_serialization_test.rb b/activerecord/test/cases/json_serialization_test.rb index 430be003ac..8664d63e8f 100644 --- a/activerecord/test/cases/json_serialization_test.rb +++ b/activerecord/test/cases/json_serialization_test.rb @@ -181,7 +181,11 @@ class DatabaseConnectedJsonEncodingTest < ActiveRecord::TestCase def test_should_allow_except_option_for_list_of_authors ActiveRecord::Base.include_root_in_json = false authors = [@david, @mary] - assert_equal %([{"id":1},{"id":2}]), ActiveSupport::JSON.encode(authors, :except => [:name, :author_address_id, :author_address_extra_id]) + encoded = ActiveSupport::JSON.encode(authors, :except => [ + :name, :author_address_id, :author_address_extra_id, + :organization_id, :owned_essay_id + ]) + assert_equal %([{"id":1},{"id":2}]), encoded ensure ActiveRecord::Base.include_root_in_json = true end -- cgit v1.2.3 From 82b889f7d37249adaa606558d4c05356b3e84d9a Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 17:22:42 +0100 Subject: Add explicit tests for the nested through association changes in reflection.rb --- activerecord/lib/active_record/reflection.rb | 15 ++++++- activerecord/test/cases/reflection_test.rb | 64 ++++++++++++++++++++++++++++ activerecord/test/models/author.rb | 1 + activerecord/test/models/post.rb | 1 + activerecord/test/models/tagging.rb | 1 + 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 7ce2bbb8ae..3448cc506c 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -450,15 +450,26 @@ module ActiveRecord end end + # A through association is nested iff there would be more than one join table def nested? - through_reflection_chain.length > 2 + through_reflection_chain.length > 2 || + through_reflection.macro == :has_and_belongs_to_many end # We want to use the klass from this reflection, rather than just delegate straight to # the source_reflection, because the source_reflection may be polymorphic. We still # need to respect the source_reflection's :primary_key option, though. def association_primary_key - @association_primary_key ||= source_reflection.options[:primary_key] || klass.primary_key + @association_primary_key ||= begin + # Get the "actual" source reflection if the immediate source reflection has a + # source reflection itself + source_reflection = self.source_reflection + while source_reflection.source_reflection + source_reflection = source_reflection.source_reflection + end + + source_reflection.options[:primary_key] || klass.primary_key + end end # Gets an array of possible :through source reflection names: diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index eeb619ac2f..a85ba623e1 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -7,6 +7,16 @@ require 'models/subscriber' require 'models/ship' require 'models/pirate' require 'models/price_estimate' +require 'models/essay' +require 'models/author' +require 'models/organization' +require 'models/post' +require 'models/tagging' +require 'models/category' +require 'models/book' +require 'models/subscriber' +require 'models/subscription' +require 'models/tag' class ReflectionTest < ActiveRecord::TestCase include ActiveRecord::Reflection @@ -190,6 +200,60 @@ class ReflectionTest < ActiveRecord::TestCase def test_has_many_through_reflection assert_kind_of ThroughReflection, Subscriber.reflect_on_association(:books) end + + def test_through_reflection_chain + expected = [ + Author.reflect_on_association(:essay_categories), + Author.reflect_on_association(:essays), + Organization.reflect_on_association(:authors) + ] + actual = Organization.reflect_on_association(:author_essay_categories).through_reflection_chain + + assert_equal expected, actual + end + + def test_through_conditions + expected = [ + ["tags.name = 'Blue'"], + ["taggings.comment = 'first'"], + ["posts.title LIKE 'misc post%'"] + ] + actual = Author.reflect_on_association(:misc_post_first_blue_tags).through_conditions + assert_equal expected, actual + + expected = [ + ["tags.name = 'Blue'", "taggings.comment = 'first'", "posts.title LIKE 'misc post%'"], + [], + [] + ] + actual = Author.reflect_on_association(:misc_post_first_blue_tags_2).through_conditions + assert_equal expected, actual + end + + def test_nested? + assert !Author.reflect_on_association(:comments).nested? + assert Author.reflect_on_association(:tags).nested? + + # Only goes :through once, but the through_reflection is a has_and_belongs_to_many, so this is + # a nested through association + assert Category.reflect_on_association(:post_comments).nested? + end + + def test_association_primary_key + # Normal association + assert_equal "id", Author.reflect_on_association(:posts).association_primary_key.to_s + assert_equal "name", Author.reflect_on_association(:essay).association_primary_key.to_s + + # Through association (uses the :primary_key option from the source reflection) + assert_equal "nick", Author.reflect_on_association(:subscribers).association_primary_key.to_s + assert_equal "name", Author.reflect_on_association(:essay_category).association_primary_key.to_s + assert_equal "custom_primary_key", Author.reflect_on_association(:tags_with_primary_key).association_primary_key.to_s # nested + end + + def test_active_record_primary_key + assert_equal "nick", Subscriber.reflect_on_association(:subscriptions).active_record_primary_key.to_s + assert_equal "name", Author.reflect_on_association(:essay).active_record_primary_key.to_s + end def test_collection_association assert Pirate.reflect_on_association(:birds).collection? diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index dd8a20ce9b..7dcfbd268b 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -90,6 +90,7 @@ class Author < ActiveRecord::Base has_many :distinct_tags, :through => :posts, :source => :tags, :select => "DISTINCT tags.*", :order => "tags.name" has_many :post_categories, :through => :posts, :source => :categories has_many :tagging_tags, :through => :taggings, :source => :tag + has_many :tags_with_primary_key, :through => :posts has_many :books has_many :subscriptions, :through => :books diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index 281586b438..7c919a55eb 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -63,6 +63,7 @@ class Post < ActiveRecord::Base has_many :misc_tags, :through => :taggings, :source => :tag, :conditions => "tags.name = 'Misc'" has_many :funky_tags, :through => :taggings, :source => :tag has_many :super_tags, :through => :taggings + has_many :tags_with_primary_key, :through => :taggings, :source => :tag_with_primary_key has_one :tagging, :as => :taggable has_many :first_taggings, :as => :taggable, :class_name => 'Tagging', :conditions => "taggings.comment = 'first'" diff --git a/activerecord/test/models/tagging.rb b/activerecord/test/models/tagging.rb index c92df88e71..c6ff8d390b 100644 --- a/activerecord/test/models/tagging.rb +++ b/activerecord/test/models/tagging.rb @@ -7,5 +7,6 @@ class Tagging < ActiveRecord::Base belongs_to :super_tag, :class_name => 'Tag', :foreign_key => 'super_tag_id' belongs_to :invalid_tag, :class_name => 'Tag', :foreign_key => 'tag_id' belongs_to :blue_tag, :class_name => 'Tag', :foreign_key => :tag_id, :conditions => "tags.name = 'Blue'" + belongs_to :tag_with_primary_key, :class_name => 'Tag', :foreign_key => :tag_id, :primary_key => :custom_primary_key belongs_to :taggable, :polymorphic => true, :counter_cache => true end -- cgit v1.2.3 From 7ee33b80a2048ec3801f02018b0ea81d2abe0011 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 17:29:19 +0100 Subject: Remove various comments and code which were just being used during the development of nested through association support (OMFGZ, I might just have nearly finished this\! --- activerecord/lib/active_record/associations.rb | 8 -- .../associations/nested_has_many_through.rb | 158 --------------------- .../associations/through_association_scope.rb | 4 - activerecord/lib/active_record/reflection.rb | 24 ---- .../nested_has_many_through_associations_test.rb | 5 - 5 files changed, 199 deletions(-) delete mode 100644 activerecord/lib/active_record/associations/nested_has_many_through.rb diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 028157d7e9..44d3258c40 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -39,14 +39,6 @@ module ActiveRecord end end - class HasManyThroughSourceAssociationMacroError < ActiveRecordError #:nodoc: - def initialize(reflection) - through_reflection = reflection.through_reflection - source_reflection = reflection.source_reflection - super("Invalid source reflection macro :#{source_reflection.macro}#{" :through" if source_reflection.options[:through]} for has_many #{reflection.name.inspect}, :through => #{through_reflection.name.inspect}. Use :source to specify the source reflection.") - end - end - class HasManyThroughCantAssociateThroughHasOneOrManyReflection < ActiveRecordError #:nodoc: def initialize(owner, reflection) 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}.") diff --git a/activerecord/lib/active_record/associations/nested_has_many_through.rb b/activerecord/lib/active_record/associations/nested_has_many_through.rb deleted file mode 100644 index d699a60edb..0000000000 --- a/activerecord/lib/active_record/associations/nested_has_many_through.rb +++ /dev/null @@ -1,158 +0,0 @@ -# TODO: Remove in the end, when its functionality is fully integrated in ThroughAssociationScope. - -module ActiveRecord - module Associations - module NestedHasManyThrough - def self.included(klass) - klass.alias_method_chain :construct_conditions, :nesting - klass.alias_method_chain :construct_joins, :nesting - end - - def construct_joins_with_nesting(custom_joins = nil) - if nested? - @nested_join_attributes ||= construct_nested_join_attributes - "#{construct_nested_join_attributes[:joins]} #{@reflection.options[:joins]} #{custom_joins}" - else - construct_joins_without_nesting(custom_joins) - end - end - - def construct_conditions_with_nesting - if nested? - @nested_join_attributes ||= construct_nested_join_attributes - if @reflection.through_reflection && @reflection.through_reflection.macro == :belongs_to - "#{@nested_join_attributes[:remote_key]} = #{belongs_to_quoted_key} #{@nested_join_attributes[:conditions]}" - else - "#{@nested_join_attributes[:remote_key]} = #{@owner.quoted_id} #{@nested_join_attributes[:conditions]}" - end - else - construct_conditions_without_nesting - end - end - - protected - - # Given any belongs_to or has_many (including has_many :through) association, - # return the essential components of a join corresponding to that association, namely: - # - # * :joins: any additional joins required to get from the association's table - # (reflection.table_name) to the table that's actually joining to the active record's table - # * :remote_key: the name of the key in the join table (qualified by table name) which will join - # to a field of the active record's table - # * :local_key: the name of the key in the local table (not qualified by table name) which will - # take part in the join - # * :conditions: any additional conditions (e.g. filtering by type for a polymorphic association, - # or a :conditions clause explicitly given in the association), including a leading AND - def construct_nested_join_attributes(reflection = @reflection, association_class = reflection.klass, table_ids = {association_class.table_name => 1}) - if (reflection.macro == :has_many || reflection.macro == :has_one) && reflection.through_reflection - construct_has_many_through_attributes(reflection, table_ids) - else - construct_has_many_or_belongs_to_attributes(reflection, association_class, table_ids) - end - end - - def construct_has_many_through_attributes(reflection, table_ids) - # Construct the join components of the source association, so that we have a path from - # the eventual target table of the association up to the table named in :through, and - # all tables involved are allocated table IDs. - source_attrs = construct_nested_join_attributes(reflection.source_reflection, reflection.klass, table_ids) - - # Determine the alias of the :through table; this will be the last table assigned - # when constructing the source join components above. - through_table_alias = through_table_name = reflection.through_reflection.table_name - through_table_alias += "_#{table_ids[through_table_name]}" unless table_ids[through_table_name] == 1 - - # Construct the join components of the through association, so that we have a path to - # the active record's table. - through_attrs = construct_nested_join_attributes(reflection.through_reflection, reflection.through_reflection.klass, table_ids) - - # Any subsequent joins / filters on owner attributes will act on the through association, - # so that's what we return for the conditions/keys of the overall association. - conditions = through_attrs[:conditions] - conditions += " AND #{interpolate_sql(reflection.klass.send(:sanitize_sql, reflection.options[:conditions]))}" if reflection.options[:conditions] - - { - :joins => "%s INNER JOIN %s ON ( %s = %s.%s %s) %s %s" % [ - source_attrs[:joins], - through_table_name == through_table_alias ? through_table_name : "#{through_table_name} #{through_table_alias}", - source_attrs[:remote_key], - through_table_alias, source_attrs[:local_key], - source_attrs[:conditions], - through_attrs[:joins], - reflection.options[:joins] - ], - :remote_key => through_attrs[:remote_key], - :local_key => through_attrs[:local_key], - :conditions => conditions - } - end - - # reflection is not has_many :through; it's a standard has_many / belongs_to instead - # TODO: see if we can defer to rails code here a bit more - def construct_has_many_or_belongs_to_attributes(reflection, association_class, table_ids) - # Determine the alias used for remote_table_name, if any. In all cases this will already - # have been assigned an ID in table_ids (either through being involved in a previous join, - # or - if it's the first table in the query - as the default value of table_ids) - remote_table_alias = remote_table_name = association_class.table_name - remote_table_alias += "_#{table_ids[remote_table_name]}" unless table_ids[remote_table_name] == 1 - - # Assign a new alias for the local table. - local_table_alias = local_table_name = reflection.active_record.table_name - if table_ids[local_table_name] - table_id = table_ids[local_table_name] += 1 - local_table_alias += "_#{table_id}" - else - table_ids[local_table_name] = 1 - end - - conditions = '' - # Add type_condition, if applicable - conditions += " AND #{association_class.send(:type_condition).to_sql}" if association_class.finder_needs_type_condition? - # Add custom conditions - conditions += " AND (#{interpolate_sql(association_class.send(:sanitize_sql, reflection.options[:conditions]))})" if reflection.options[:conditions] - - if reflection.macro == :belongs_to - if reflection.options[:polymorphic] - conditions += " AND #{local_table_alias}.#{reflection.options[:foreign_type]} = #{reflection.active_record.quote_value(association_class.base_class.name.to_s)}" - end - { - :joins => reflection.options[:joins], - :remote_key => "#{remote_table_alias}.#{association_class.primary_key}", - :local_key => reflection.primary_key_name, - :conditions => conditions - } - else - # Association is has_many (without :through) - if reflection.options[:as] - conditions += " AND #{remote_table_alias}.#{reflection.options[:as]}_type = #{reflection.active_record.quote_value(reflection.active_record.base_class.name.to_s)}" - end - { - :joins => "#{reflection.options[:joins]}", - :remote_key => "#{remote_table_alias}.#{reflection.primary_key_name}", - :local_key => reflection.klass.primary_key, - :conditions => conditions - } - end - end - - def belongs_to_quoted_key - attribute = @reflection.through_reflection.primary_key_name - column = @owner.column_for_attribute attribute - - @owner.send(:quote_value, @owner.send(attribute), column) - end - - def nested? - through_source_reflection? || through_through_reflection? - end - - def through_source_reflection? - @reflection.source_reflection && @reflection.source_reflection.options[:through] - end - - def through_through_reflection? - @reflection.through_reflection && @reflection.through_reflection.options[:through] - end - end - end -end diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index 649bbd206a..abe7af418d 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -61,10 +61,6 @@ module ActiveRecord end def construct_joins(custom_joins = nil) - # TODO: Remove this at the end - #p @reflection.through_reflection_chain - #p @reflection.through_conditions - "#{construct_through_joins} #{@reflection.options[:joins]} #{custom_joins}" end diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 3448cc506c..1ea892895f 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -131,14 +131,6 @@ module ActiveRecord @sanitized_conditions ||= klass.send(:sanitize_sql, options[:conditions]) if options[:conditions] end - # TODO: Remove these in the final patch. I am just using them for debugging etc. - def inspect - "#<#{code_name}>" - end - def code_name - "#{active_record.name}.#{macro} :#{name}" - end - private def derive_class_name name.to_s.camelize @@ -325,16 +317,6 @@ module ActiveRecord def belongs_to? macro == :belongs_to end - - # TODO: Remove for final patch. Just here for debugging. - def inspect - str = "#<#{code_name}, @source_reflection=" - str << (source_reflection.respond_to?(:code_name) ? source_reflection.code_name : source_reflection.inspect) - str << ", @through_reflection=" - str << (through_reflection.respond_to?(:code_name) ? through_reflection.code_name : through_reflection.inspect) - str << ">" - str - end private def derive_class_name @@ -497,12 +479,6 @@ module ActiveRecord raise HasManyThroughAssociationPolymorphicError.new(active_record.name, self, source_reflection) end - # TODO: Presumably remove the HasManyThroughSourceAssociationMacroError class and delete these lines. - # Think about whether there are any cases which should still be disallowed. - # unless [:belongs_to, :has_many, :has_one].include?(source_reflection.macro) && source_reflection.options[:through].nil? - # raise HasManyThroughSourceAssociationMacroError.new(self) - # end - check_validity_of_inverse! end diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 3a4601b032..23fa1709ce 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -23,11 +23,6 @@ require 'models/categorization' require 'models/membership' require 'models/essay' -# NOTE: Some of these tests might not really test "nested" HMT associations, as opposed to ones which -# are just one level deep. But it's all the same thing really, as the "nested" code is being -# written in a generic way which applies to "non-nested" HMT associations too. So let's just shove -# all useful tests in here for now and then work out where they ought to live properly later. - class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, -- cgit v1.2.3 From 2aa9388746412bc88be6a1728ecfbcc8ceacbb30 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 17:40:14 +0100 Subject: Add some comments for ThroughReflection#through_reflection_chain --- activerecord/lib/active_record/reflection.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 1ea892895f..824674ee1d 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -368,7 +368,16 @@ module ActiveRecord @through_reflection ||= active_record.reflect_on_association(options[:through]) end - # TODO: Documentation + # Returns an array of AssociationReflection objects which are involved in this through + # association. Each item in the array corresponds to a table which will be part of the + # query for this association. + # + # If the source reflection is itself a ThroughReflection, then we don't include self in + # the chain, but just defer to the source reflection. + # + # The chain is built by recursively calling through_reflection_chain on the source + # reflection and the through reflection. The base case for the recursion is a normal + # association, which just returns [self] for its through_reflection_chain. def through_reflection_chain @through_reflection_chain ||= begin if source_reflection.source_reflection -- cgit v1.2.3 From 2c7183c0260ca105c6440b31f60ac010891b69a9 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 18:16:19 +0100 Subject: Add some API documentation about nested through associations --- activerecord/lib/active_record/associations.rb | 60 ++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 44d3258c40..379a4eb1ef 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -487,6 +487,49 @@ module ActiveRecord # @group.avatars << Avatar.new # this would work if User belonged_to Avatar rather than the other way around # @group.avatars.delete(@group.avatars.last) # so would this # + # === Nested Associations + # + # You can actually specify *any* association with the :through option, including an + # association which has a :through option itself. For example: + # + # class Author < ActiveRecord::Base + # has_many :posts + # has_many :comments, :through => :posts + # has_many :commenters, :through => :comments + # end + # + # class Post < ActiveRecord::Base + # has_many :comments + # end + # + # class Comment < ActiveRecord::Base + # belongs_to :commenter + # end + # + # @author = Author.first + # @author.commenters # => People who commented on posts written by the author + # + # An equivalent way of setting up this association this would be: + # + # class Author < ActiveRecord::Base + # has_many :posts + # has_many :commenters, :through => :posts + # end + # + # class Post < ActiveRecord::Base + # has_many :comments + # has_many :commenters, :through => :comments + # end + # + # class Comment < ActiveRecord::Base + # belongs_to :commenter + # end + # + # When using nested association, you will not be able to modify the association because there + # is not enough information to know what modification to make. For example, if you tries to + # add a Commenter in the example above, there would be no way to tell how to set up the + # intermediate Post and Comment objects. + # # === Polymorphic Associations # # Polymorphic associations on models are not restricted on what types of models they @@ -934,10 +977,11 @@ module ActiveRecord # [:as] # Specifies a polymorphic interface (See belongs_to). # [:through] - # Specifies a join model through which to perform the query. Options for :class_name - # and :foreign_key are ignored, as the association uses the source reflection. You - # can only use a :through query through a belongs_to, has_one - # or has_many association on the join model. The collection of join models + # Specifies a join model through which to perform the query. Options for :class_name, + # :primary_key and :foreign_key are ignored, as the association uses the + # source reflection. You can use a :through association through any other, + # association, but if other :through associations are involved then the resulting + # association will be read-only. Otherwise, the collection of join models # can be managed via the collection API. For example, new join models are created for # newly associated objects, and if some are gone their rows are deleted (directly, # no destroy callbacks are triggered). @@ -1061,10 +1105,10 @@ module ActiveRecord # you want to do a join but not include the joined columns. Do not forget to include the # primary and foreign keys, otherwise it will raise an error. # [:through] - # Specifies a Join Model through which to perform the query. Options for :class_name - # and :foreign_key are ignored, as the association uses the source reflection. You - # can only use a :through query through a has_one or belongs_to - # association on the join model. + # Specifies a Join Model through which to perform the query. Options for :class_name, + # :primary_key, and :foreign_key are ignored, as the association uses the + # source reflection. You can only use a :through query through a has_one + # or belongs_to association on the join model. # [:source] # Specifies the source association name used by has_one :through queries. # Only use it if the name cannot be inferred from the association. -- cgit v1.2.3 From 8aa69490833796ac6f373de746d6fc79dfc5482a Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 18:21:32 +0100 Subject: Add a CHANGELOG entry about nested through associations --- activerecord/CHANGELOG | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index 75657cb6ee..191890aefc 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,7 @@ *Rails 3.1.0 (unreleased)* +* Associations with a :through option can now use *any* association as the through or source association, including other associations which have a :through option and has_and_belongs_to_many associations #1812 [Jon Leighton] + * The following code: Model.limit(10).scoping { Model.count } -- cgit v1.2.3 From cf7c475ef187e88044cba139cc2e1dbf5f180b15 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 18:29:10 +0100 Subject: Remove obsolete autoload --- activerecord/lib/active_record/associations.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 379a4eb1ef..98a101fac4 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -109,7 +109,6 @@ module ActiveRecord autoload :HasAndBelongsToManyAssociation, 'active_record/associations/has_and_belongs_to_many_association' autoload :HasManyAssociation, 'active_record/associations/has_many_association' autoload :HasManyThroughAssociation, 'active_record/associations/has_many_through_association' - autoload :NestedHasManyThroughAssociation, 'active_record/associations/nested_has_many_through_association' autoload :HasOneAssociation, 'active_record/associations/has_one_association' autoload :HasOneThroughAssociation, 'active_record/associations/has_one_through_association' autoload :AliasTracker, 'active_record/associations/alias_tracker' -- cgit v1.2.3 From 7b84477598137c6261bf2aeb5ce0d1b17e4b2b3c Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 18:32:02 +0100 Subject: Fix typo --- activerecord/lib/active_record/associations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 98a101fac4..25be9a52ff 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -525,7 +525,7 @@ module ActiveRecord # end # # When using nested association, you will not be able to modify the association because there - # is not enough information to know what modification to make. For example, if you tries to + # is not enough information to know what modification to make. For example, if you tried to # add a Commenter in the example above, there would be no way to tell how to set up the # intermediate Post and Comment objects. # -- cgit v1.2.3 From fcabfa428e57af115aca56f5c9aba99afae2cf7c Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 18:43:09 +0100 Subject: Remove obsolete require to active_record/associations/nested_has_many_through --- .../lib/active_record/associations/has_many_through_association.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 419a3d385e..2c9fa3b447 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -1,5 +1,4 @@ require "active_record/associations/through_association_scope" -require "active_record/associations/nested_has_many_through" require 'active_support/core_ext/object/blank' module ActiveRecord -- cgit v1.2.3 From d15de7d97f7080d8d3bc47bef89aa8a922f04c67 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 19:25:44 +0100 Subject: Add explicit ordering to nested_has_many_through_associations_test.rb as this was causing failures under postgres --- .../nested_has_many_through_associations_test.rb | 69 ++++++++++++---------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb index 23fa1709ce..274ecdaba7 100644 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -76,7 +76,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase luke, david = subscribers(:first), subscribers(:second) author = authors(:david) - assert_equal [luke, david, david], author.subscribers + assert_equal [luke, david, david], author.subscribers.order('subscribers.nick') # All authors with subscribers where one of the subscribers' nick is 'alterself' assert_includes_and_joins_equal( @@ -134,10 +134,11 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_has_one_with_has_many_through_source_reflection groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) - assert_equal [groucho_details, other_details], members(:groucho).organization_member_details + assert_equal [groucho_details, other_details], + members(:groucho).organization_member_details.order('member_details.id') assert_includes_and_joins_equal( - Member.where('member_details.id' => member_details(:groucho).id), + Member.where('member_details.id' => member_details(:groucho).id).order('member_details.id'), [members(:groucho), members(:some_other_guy)], :organization_member_details ) @@ -145,9 +146,9 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase where('member_details.id' => 9) assert members.empty? - members = assert_queries(4) { Member.includes(:organization_member_details).to_a } + members = assert_queries(4) { Member.includes(:organization_member_details).to_a.sort_by(&:id) } assert_no_queries do - assert_equal [groucho_details, other_details], members.first.organization_member_details + assert_equal [groucho_details, other_details], members.first.organization_member_details.sort_by(&:id) end end @@ -157,10 +158,11 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_has_one_through_with_has_many_source_reflection groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) - assert_equal [groucho_details, other_details], members(:groucho).organization_member_details_2 + assert_equal [groucho_details, other_details], + members(:groucho).organization_member_details_2.order('member_details.id') assert_includes_and_joins_equal( - Member.where('member_details.id' => groucho_details.id), + Member.where('member_details.id' => groucho_details.id).order('member_details.id'), [members(:groucho), members(:some_other_guy)], :organization_member_details_2 ) @@ -168,9 +170,9 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase where('member_details.id' => 9) assert members.empty? - members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a } + members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a.sort_by(&:id) } assert_no_queries do - assert_equal [groucho_details, other_details], members.first.organization_member_details_2 + assert_equal [groucho_details, other_details], members.first.organization_member_details_2.sort_by(&:id) end end @@ -180,16 +182,16 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection general, cooking = categories(:general), categories(:cooking) - assert_equal [general, cooking], authors(:bob).post_categories + assert_equal [general, cooking], authors(:bob).post_categories.order('categories.id') assert_includes_and_joins_equal( Author.where('categories.id' => cooking.id), [authors(:bob)], :post_categories ) - authors = assert_queries(3) { Author.includes(:post_categories).to_a } + authors = assert_queries(3) { Author.includes(:post_categories).to_a.sort_by(&:id) } assert_no_queries do - assert_equal [general, cooking], authors[2].post_categories + assert_equal [general, cooking], authors[2].post_categories.sort_by(&:id) end end @@ -199,16 +201,16 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection greetings, more = comments(:greetings), comments(:more_greetings) - assert_equal [greetings, more], categories(:technology).post_comments + assert_equal [greetings, more], categories(:technology).post_comments.order('comments.id') assert_includes_and_joins_equal( - Category.where('comments.id' => more.id), + Category.where('comments.id' => more.id).order('comments.id'), [categories(:general), categories(:technology)], :post_comments ) - categories = assert_queries(3) { Category.includes(:post_comments).to_a } + categories = assert_queries(3) { Category.includes(:post_comments).to_a.sort_by(&:id) } assert_no_queries do - assert_equal [greetings, more], categories[1].post_comments + assert_equal [greetings, more], categories[1].post_comments.sort_by(&:id) end end @@ -218,16 +220,16 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection greetings, more = comments(:greetings), comments(:more_greetings) - assert_equal [greetings, more], authors(:bob).category_post_comments + assert_equal [greetings, more], authors(:bob).category_post_comments.order('comments.id') assert_includes_and_joins_equal( - Author.where('comments.id' => comments(:does_it_hurt).id), + Author.where('comments.id' => comments(:does_it_hurt).id).order('comments.id'), [authors(:david), authors(:mary)], :category_post_comments ) - authors = assert_queries(5) { Author.includes(:category_post_comments).to_a } + authors = assert_queries(5) { Author.includes(:category_post_comments).to_a.sort_by(&:id) } assert_no_queries do - assert_equal [greetings, more], authors[2].category_post_comments + assert_equal [greetings, more], authors[2].category_post_comments.sort_by(&:id) end end @@ -256,16 +258,17 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_belongs_to_with_has_many_through_source_reflection welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general) - assert_equal [welcome_general, thinking_general], categorizations(:david_welcome_general).post_taggings + assert_equal [welcome_general, thinking_general], + categorizations(:david_welcome_general).post_taggings.order('taggings.id') assert_includes_and_joins_equal( - Categorization.where('taggings.id' => welcome_general.id), + Categorization.where('taggings.id' => welcome_general.id).order('taggings.id'), [categorizations(:david_welcome_general)], :post_taggings ) - categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a } + categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a.sort_by(&:id) } assert_no_queries do - assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings + assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings.sort_by(&:id) end end @@ -282,7 +285,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase [members(:groucho)], :nested_member_type ) - members = assert_queries(4) { Member.includes(:nested_member_type).to_a } + members = assert_queries(4) { Member.includes(:nested_member_type).to_a.sort_by(&:id) } assert_no_queries do assert_equal founding, members.first.nested_member_type end @@ -301,7 +304,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase [members(:blarpy_winkup)], :club_category ) - members = assert_queries(4) { Member.includes(:club_category).to_a } + members = assert_queries(4) { Member.includes(:club_category).to_a.sort_by(&:id) } assert_no_queries do assert_equal general, members.first.club_category end @@ -314,12 +317,14 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection author = authors(:david) - assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers + assert_equal [subscribers(:first), subscribers(:second)], + author.distinct_subscribers.order('subscribers.nick') end def test_nested_has_many_through_with_a_table_referenced_multiple_times author = authors(:bob) - assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)], author.similar_posts.sort_by(&:id) + assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)], + author.similar_posts.sort_by(&:id) # Mary and Bob both have posts in misc, but they are the only ones. authors = Author.joins(:similar_posts).where('posts.id' => posts(:misc_by_bob).id) @@ -333,7 +338,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase end def test_has_many_through_with_foreign_key_option_on_through_reflection - assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts + assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts.order('posts.id') assert_equal [authors(:david)], references(:david_unicyclist).agents_posts_authors references = Reference.joins(:agents_posts_authors).where('authors.id' => authors(:david).id) @@ -341,7 +346,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase end def test_has_many_through_with_foreign_key_option_on_source_reflection - assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents + assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents.order('people.id') jobs = Job.joins(:agents) assert_equal [jobs(:unicyclist), jobs(:unicyclist)], jobs @@ -413,7 +418,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase assert Author.where('tags.id' => 100).joins(:misc_post_first_blue_tags).empty? - authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a } + authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a.sort_by(&:id) } assert_no_queries do assert_equal [blue], authors[2].misc_post_first_blue_tags end @@ -430,7 +435,7 @@ class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase [bob], :misc_post_first_blue_tags_2 ) - authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a } + authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a.sort_by(&:id) } assert_no_queries do assert_equal [blue], authors[2].misc_post_first_blue_tags_2 end -- cgit v1.2.3 From 383d545c88266ef579f225bc8bfb3d3b807ca5bc Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 19:33:25 +0100 Subject: Add explicit ordering in relations_test.rb, as the lack of this was causing failures against postgres --- activerecord/test/cases/relations_test.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 0d88c8eded..0b143224be 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -251,27 +251,27 @@ class RelationTest < ActiveRecord::TestCase def test_find_with_preloaded_associations assert_queries(2) do - posts = Post.preload(:comments) + posts = Post.preload(:comments).order('posts.id') assert posts.first.comments.first end assert_queries(2) do - posts = Post.preload(:comments).to_a + posts = Post.preload(:comments).order('posts.id').to_a assert posts.first.comments.first end assert_queries(2) do - posts = Post.preload(:author) + posts = Post.preload(:author).order('posts.id') assert posts.first.author end assert_queries(2) do - posts = Post.preload(:author).to_a + posts = Post.preload(:author).order('posts.id').to_a assert posts.first.author end assert_queries(3) do - posts = Post.preload(:author, :comments).to_a + posts = Post.preload(:author, :comments).order('posts.id').to_a assert posts.first.author assert posts.first.comments.first end @@ -279,22 +279,22 @@ class RelationTest < ActiveRecord::TestCase def test_find_with_included_associations assert_queries(2) do - posts = Post.includes(:comments) + posts = Post.includes(:comments).order('posts.id') assert posts.first.comments.first end assert_queries(2) do - posts = Post.scoped.includes(:comments) + posts = Post.scoped.includes(:comments).order('posts.id') assert posts.first.comments.first end assert_queries(2) do - posts = Post.includes(:author) + posts = Post.includes(:author).order('posts.id') assert posts.first.author end assert_queries(3) do - posts = Post.includes(:author, :comments).to_a + posts = Post.includes(:author, :comments).order('posts.id').to_a assert posts.first.author assert posts.first.comments.first end -- cgit v1.2.3 From 8e53e058acea471eab7a1609dc150aa9fdbfa833 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Tue, 19 Oct 2010 21:41:00 +0100 Subject: Rename nested_has_many_through_associations_test.rb because it does not only concern has_many associations --- .../nested_has_many_through_associations_test.rb | 467 --------------------- .../nested_through_associations_test.rb | 467 +++++++++++++++++++++ 2 files changed, 467 insertions(+), 467 deletions(-) delete mode 100644 activerecord/test/cases/associations/nested_has_many_through_associations_test.rb create mode 100644 activerecord/test/cases/associations/nested_through_associations_test.rb diff --git a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb deleted file mode 100644 index 274ecdaba7..0000000000 --- a/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb +++ /dev/null @@ -1,467 +0,0 @@ -require "cases/helper" -require 'models/author' -require 'models/post' -require 'models/person' -require 'models/reference' -require 'models/job' -require 'models/reader' -require 'models/comment' -require 'models/tag' -require 'models/tagging' -require 'models/subscriber' -require 'models/book' -require 'models/subscription' -require 'models/rating' -require 'models/member' -require 'models/member_detail' -require 'models/member_type' -require 'models/sponsor' -require 'models/club' -require 'models/organization' -require 'models/category' -require 'models/categorization' -require 'models/membership' -require 'models/essay' - -class NestedHasManyThroughAssociationsTest < ActiveRecord::TestCase - fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, - :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, - :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts, - :categorizations, :memberships, :essays - - # Through associations can either use the has_many or has_one macros. - # - # has_many - # - Source reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many - # - Through reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many - # - # has_one - # - Source reflection can be has_one or belongs_to - # - Through reflection can be has_one or belongs_to - # - # Additionally, the source reflection and/or through reflection may be subject to - # polymorphism and/or STI. - # - # When testing these, we need to make sure it works via loading the association directly, or - # joining the association, or including the association. We also need to ensure that associations - # are readonly where relevant. - - # has_many through - # Source: has_many through - # Through: has_many - def test_has_many_through_has_many_with_has_many_through_source_reflection - general = tags(:general) - - assert_equal [general, general], authors(:david).tags - - assert_includes_and_joins_equal( - Author.where('tags.id' => tags(:general).id), - [authors(:david)], :tags - ) - - # This ensures that the polymorphism of taggings is being observed correctly - authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') - assert authors.empty? - - authors = assert_queries(5) { Author.includes(:tags).to_a } - assert_no_queries do - assert_equal [general, general], authors.first.tags - end - end - - # has_many through - # Source: has_many - # Through: has_many through - def test_has_many_through_has_many_through_with_has_many_source_reflection - luke, david = subscribers(:first), subscribers(:second) - - author = authors(:david) - assert_equal [luke, david, david], author.subscribers.order('subscribers.nick') - - # All authors with subscribers where one of the subscribers' nick is 'alterself' - assert_includes_and_joins_equal( - Author.where('subscribers.nick' => 'alterself'), - [authors(:david)], :subscribers - ) - - authors = assert_queries(4) { Author.includes(:subscribers).to_a } - assert_no_queries do - assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick) - end - end - - # has_many through - # Source: has_one through - # Through: has_one - def test_has_many_through_has_one_with_has_one_through_source_reflection - founding = member_types(:founding) - - assert_equal [founding], members(:groucho).nested_member_types - - assert_includes_and_joins_equal( - Member.where('member_types.id' => founding.id), - [members(:groucho)], :nested_member_types - ) - - members = assert_queries(4) { Member.includes(:nested_member_types).to_a } - assert_no_queries do - assert_equal [founding], members.first.nested_member_types - end - end - - # has_many through - # Source: has_one - # Through: has_one through - def test_has_many_through_has_one_through_with_has_one_source_reflection - mustache = sponsors(:moustache_club_sponsor_for_groucho) - - assert_equal [mustache], members(:groucho).nested_sponsors - - assert_includes_and_joins_equal( - Member.where('sponsors.id' => mustache.id), - [members(:groucho)], :nested_sponsors - ) - - members = assert_queries(4) { Member.includes(:nested_sponsors).to_a } - assert_no_queries do - assert_equal [mustache], members.first.nested_sponsors - end - end - - # has_many through - # Source: has_many through - # Through: has_one - def test_has_many_through_has_one_with_has_many_through_source_reflection - groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) - - assert_equal [groucho_details, other_details], - members(:groucho).organization_member_details.order('member_details.id') - - assert_includes_and_joins_equal( - Member.where('member_details.id' => member_details(:groucho).id).order('member_details.id'), - [members(:groucho), members(:some_other_guy)], :organization_member_details - ) - - members = Member.joins(:organization_member_details). - where('member_details.id' => 9) - assert members.empty? - - members = assert_queries(4) { Member.includes(:organization_member_details).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [groucho_details, other_details], members.first.organization_member_details.sort_by(&:id) - end - end - - # has_many through - # Source: has_many - # Through: has_one through - def test_has_many_through_has_one_through_with_has_many_source_reflection - groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) - - assert_equal [groucho_details, other_details], - members(:groucho).organization_member_details_2.order('member_details.id') - - assert_includes_and_joins_equal( - Member.where('member_details.id' => groucho_details.id).order('member_details.id'), - [members(:groucho), members(:some_other_guy)], :organization_member_details_2 - ) - - members = Member.joins(:organization_member_details_2). - where('member_details.id' => 9) - assert members.empty? - - members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [groucho_details, other_details], members.first.organization_member_details_2.sort_by(&:id) - end - end - - # has_many through - # Source: has_and_belongs_to_many - # Through: has_many - def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection - general, cooking = categories(:general), categories(:cooking) - - assert_equal [general, cooking], authors(:bob).post_categories.order('categories.id') - - assert_includes_and_joins_equal( - Author.where('categories.id' => cooking.id), - [authors(:bob)], :post_categories - ) - - authors = assert_queries(3) { Author.includes(:post_categories).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [general, cooking], authors[2].post_categories.sort_by(&:id) - end - end - - # has_many through - # Source: has_many - # Through: has_and_belongs_to_many - def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection - greetings, more = comments(:greetings), comments(:more_greetings) - - assert_equal [greetings, more], categories(:technology).post_comments.order('comments.id') - - assert_includes_and_joins_equal( - Category.where('comments.id' => more.id).order('comments.id'), - [categories(:general), categories(:technology)], :post_comments - ) - - categories = assert_queries(3) { Category.includes(:post_comments).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [greetings, more], categories[1].post_comments.sort_by(&:id) - end - end - - # has_many through - # Source: has_many through a habtm - # Through: has_many through - def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection - greetings, more = comments(:greetings), comments(:more_greetings) - - assert_equal [greetings, more], authors(:bob).category_post_comments.order('comments.id') - - assert_includes_and_joins_equal( - Author.where('comments.id' => comments(:does_it_hurt).id).order('comments.id'), - [authors(:david), authors(:mary)], :category_post_comments - ) - - authors = assert_queries(5) { Author.includes(:category_post_comments).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [greetings, more], authors[2].category_post_comments.sort_by(&:id) - end - end - - # has_many through - # Source: belongs_to - # Through: has_many through - def test_has_many_through_has_many_through_with_belongs_to_source_reflection - general = tags(:general) - - assert_equal [general, general], authors(:david).tagging_tags - - assert_includes_and_joins_equal( - Author.where('tags.id' => tags(:general).id), - [authors(:david)], :tagging_tags - ) - - authors = assert_queries(5) { Author.includes(:tagging_tags).to_a } - assert_no_queries do - assert_equal [general, general], authors.first.tagging_tags - end - end - - # has_many through - # Source: has_many through - # Through: belongs_to - def test_has_many_through_belongs_to_with_has_many_through_source_reflection - welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general) - - assert_equal [welcome_general, thinking_general], - categorizations(:david_welcome_general).post_taggings.order('taggings.id') - - assert_includes_and_joins_equal( - Categorization.where('taggings.id' => welcome_general.id).order('taggings.id'), - [categorizations(:david_welcome_general)], :post_taggings - ) - - categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings.sort_by(&:id) - end - end - - # has_one through - # Source: has_one through - # Through: has_one - def test_has_one_through_has_one_with_has_one_through_source_reflection - founding = member_types(:founding) - - assert_equal founding, members(:groucho).nested_member_type - - assert_includes_and_joins_equal( - Member.where('member_types.id' => founding.id), - [members(:groucho)], :nested_member_type - ) - - members = assert_queries(4) { Member.includes(:nested_member_type).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal founding, members.first.nested_member_type - end - end - - # has_one through - # Source: belongs_to - # Through: has_one through - def test_has_one_through_has_one_through_with_belongs_to_source_reflection - general = categories(:general) - - assert_equal general, members(:groucho).club_category - - assert_includes_and_joins_equal( - Member.where('categories.id' => categories(:technology).id), - [members(:blarpy_winkup)], :club_category - ) - - members = assert_queries(4) { Member.includes(:club_category).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal general, members.first.club_category - end - end - - def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection - author = authors(:david) - assert_equal [tags(:general)], author.distinct_tags - end - - def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection - author = authors(:david) - assert_equal [subscribers(:first), subscribers(:second)], - author.distinct_subscribers.order('subscribers.nick') - end - - def test_nested_has_many_through_with_a_table_referenced_multiple_times - author = authors(:bob) - assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)], - author.similar_posts.sort_by(&:id) - - # Mary and Bob both have posts in misc, but they are the only ones. - authors = Author.joins(:similar_posts).where('posts.id' => posts(:misc_by_bob).id) - assert_equal [authors(:mary), authors(:bob)], authors.uniq.sort_by(&:id) - - # Check the polymorphism of taggings is being observed correctly (in both joins) - authors = Author.joins(:similar_posts).where('taggings.taggable_type' => 'FakeModel') - assert authors.empty? - authors = Author.joins(:similar_posts).where('taggings_authors_join.taggable_type' => 'FakeModel') - assert authors.empty? - end - - def test_has_many_through_with_foreign_key_option_on_through_reflection - assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts.order('posts.id') - assert_equal [authors(:david)], references(:david_unicyclist).agents_posts_authors - - references = Reference.joins(:agents_posts_authors).where('authors.id' => authors(:david).id) - assert_equal [references(:david_unicyclist)], references - end - - def test_has_many_through_with_foreign_key_option_on_source_reflection - assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents.order('people.id') - - jobs = Job.joins(:agents) - assert_equal [jobs(:unicyclist), jobs(:unicyclist)], jobs - end - - def test_has_many_through_with_sti_on_through_reflection - ratings = posts(:sti_comments).special_comments_ratings.sort_by(&:id) - assert_equal [ratings(:special_comment_rating), ratings(:sub_special_comment_rating)], ratings - - # Ensure STI is respected in the join - scope = Post.joins(:special_comments_ratings).where(:id => posts(:sti_comments).id) - assert scope.where("comments.type" => "Comment").empty? - assert !scope.where("comments.type" => "SpecialComment").empty? - assert !scope.where("comments.type" => "SubSpecialComment").empty? - end - - def test_nested_has_many_through_writers_should_raise_error - david = authors(:david) - subscriber = subscribers(:first) - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - david.subscribers = [subscriber] - end - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - david.subscriber_ids = [subscriber.id] - end - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - david.subscribers << subscriber - end - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - david.subscribers.delete(subscriber) - end - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - david.subscribers.clear - end - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - david.subscribers.build - end - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - david.subscribers.create - end - end - - def test_nested_has_one_through_writers_should_raise_error - groucho = members(:groucho) - founding = member_types(:founding) - - assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do - groucho.nested_member_type = founding - end - end - - def test_nested_has_many_through_with_conditions_on_through_associations - blue, bob = tags(:blue), authors(:bob) - - assert_equal [blue], bob.misc_post_first_blue_tags - - # Pointless condition to force single-query loading - assert_includes_and_joins_equal( - Author.where('tags.id = tags.id'), - [bob], :misc_post_first_blue_tags - ) - - assert Author.where('tags.id' => 100).joins(:misc_post_first_blue_tags).empty? - - authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [blue], authors[2].misc_post_first_blue_tags - end - end - - def test_nested_has_many_through_with_conditions_on_source_associations - blue, bob = tags(:blue), authors(:bob) - - assert_equal [blue], bob.misc_post_first_blue_tags_2 - - # Pointless condition to force single-query loading - assert_includes_and_joins_equal( - Author.where('tags.id = tags.id'), - [bob], :misc_post_first_blue_tags_2 - ) - - authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a.sort_by(&:id) } - assert_no_queries do - assert_equal [blue], authors[2].misc_post_first_blue_tags_2 - end - end - - def test_nested_has_many_through_with_foreign_key_option_on_the_source_reflection_through_reflection - assert_equal [categories(:general)], organizations(:nsa).author_essay_categories - - organizations = Organization.joins(:author_essay_categories). - where('categories.id' => categories(:general).id) - assert_equal [organizations(:nsa)], organizations - - assert_equal categories(:general), organizations(:nsa).author_owned_essay_category - - organizations = Organization.joins(:author_owned_essay_category). - where('categories.id' => categories(:general).id) - assert_equal [organizations(:nsa)], organizations - end - - private - - def assert_includes_and_joins_equal(query, expected, association) - actual = assert_queries(1) { query.joins(association).to_a.uniq } - assert_equal expected, actual - - actual = assert_queries(1) { query.includes(association).to_a.uniq } - assert_equal expected, actual - end -end diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb new file mode 100644 index 0000000000..bfc290e877 --- /dev/null +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -0,0 +1,467 @@ +require "cases/helper" +require 'models/author' +require 'models/post' +require 'models/person' +require 'models/reference' +require 'models/job' +require 'models/reader' +require 'models/comment' +require 'models/tag' +require 'models/tagging' +require 'models/subscriber' +require 'models/book' +require 'models/subscription' +require 'models/rating' +require 'models/member' +require 'models/member_detail' +require 'models/member_type' +require 'models/sponsor' +require 'models/club' +require 'models/organization' +require 'models/category' +require 'models/categorization' +require 'models/membership' +require 'models/essay' + +class NestedThroughAssociationsTest < ActiveRecord::TestCase + fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, + :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, + :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts, + :categorizations, :memberships, :essays + + # Through associations can either use the has_many or has_one macros. + # + # has_many + # - Source reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many + # - Through reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many + # + # has_one + # - Source reflection can be has_one or belongs_to + # - Through reflection can be has_one or belongs_to + # + # Additionally, the source reflection and/or through reflection may be subject to + # polymorphism and/or STI. + # + # When testing these, we need to make sure it works via loading the association directly, or + # joining the association, or including the association. We also need to ensure that associations + # are readonly where relevant. + + # has_many through + # Source: has_many through + # Through: has_many + def test_has_many_through_has_many_with_has_many_through_source_reflection + general = tags(:general) + + assert_equal [general, general], authors(:david).tags + + assert_includes_and_joins_equal( + Author.where('tags.id' => tags(:general).id), + [authors(:david)], :tags + ) + + # This ensures that the polymorphism of taggings is being observed correctly + authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') + assert authors.empty? + + authors = assert_queries(5) { Author.includes(:tags).to_a } + assert_no_queries do + assert_equal [general, general], authors.first.tags + end + end + + # has_many through + # Source: has_many + # Through: has_many through + def test_has_many_through_has_many_through_with_has_many_source_reflection + luke, david = subscribers(:first), subscribers(:second) + + author = authors(:david) + assert_equal [luke, david, david], author.subscribers.order('subscribers.nick') + + # All authors with subscribers where one of the subscribers' nick is 'alterself' + assert_includes_and_joins_equal( + Author.where('subscribers.nick' => 'alterself'), + [authors(:david)], :subscribers + ) + + authors = assert_queries(4) { Author.includes(:subscribers).to_a } + assert_no_queries do + assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick) + end + end + + # has_many through + # Source: has_one through + # Through: has_one + def test_has_many_through_has_one_with_has_one_through_source_reflection + founding = member_types(:founding) + + assert_equal [founding], members(:groucho).nested_member_types + + assert_includes_and_joins_equal( + Member.where('member_types.id' => founding.id), + [members(:groucho)], :nested_member_types + ) + + members = assert_queries(4) { Member.includes(:nested_member_types).to_a } + assert_no_queries do + assert_equal [founding], members.first.nested_member_types + end + end + + # has_many through + # Source: has_one + # Through: has_one through + def test_has_many_through_has_one_through_with_has_one_source_reflection + mustache = sponsors(:moustache_club_sponsor_for_groucho) + + assert_equal [mustache], members(:groucho).nested_sponsors + + assert_includes_and_joins_equal( + Member.where('sponsors.id' => mustache.id), + [members(:groucho)], :nested_sponsors + ) + + members = assert_queries(4) { Member.includes(:nested_sponsors).to_a } + assert_no_queries do + assert_equal [mustache], members.first.nested_sponsors + end + end + + # has_many through + # Source: has_many through + # Through: has_one + def test_has_many_through_has_one_with_has_many_through_source_reflection + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + assert_equal [groucho_details, other_details], + members(:groucho).organization_member_details.order('member_details.id') + + assert_includes_and_joins_equal( + Member.where('member_details.id' => member_details(:groucho).id).order('member_details.id'), + [members(:groucho), members(:some_other_guy)], :organization_member_details + ) + + members = Member.joins(:organization_member_details). + where('member_details.id' => 9) + assert members.empty? + + members = assert_queries(4) { Member.includes(:organization_member_details).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [groucho_details, other_details], members.first.organization_member_details.sort_by(&:id) + end + end + + # has_many through + # Source: has_many + # Through: has_one through + def test_has_many_through_has_one_through_with_has_many_source_reflection + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + assert_equal [groucho_details, other_details], + members(:groucho).organization_member_details_2.order('member_details.id') + + assert_includes_and_joins_equal( + Member.where('member_details.id' => groucho_details.id).order('member_details.id'), + [members(:groucho), members(:some_other_guy)], :organization_member_details_2 + ) + + members = Member.joins(:organization_member_details_2). + where('member_details.id' => 9) + assert members.empty? + + members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [groucho_details, other_details], members.first.organization_member_details_2.sort_by(&:id) + end + end + + # has_many through + # Source: has_and_belongs_to_many + # Through: has_many + def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection + general, cooking = categories(:general), categories(:cooking) + + assert_equal [general, cooking], authors(:bob).post_categories.order('categories.id') + + assert_includes_and_joins_equal( + Author.where('categories.id' => cooking.id), + [authors(:bob)], :post_categories + ) + + authors = assert_queries(3) { Author.includes(:post_categories).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [general, cooking], authors[2].post_categories.sort_by(&:id) + end + end + + # has_many through + # Source: has_many + # Through: has_and_belongs_to_many + def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_equal [greetings, more], categories(:technology).post_comments.order('comments.id') + + assert_includes_and_joins_equal( + Category.where('comments.id' => more.id).order('comments.id'), + [categories(:general), categories(:technology)], :post_comments + ) + + categories = assert_queries(3) { Category.includes(:post_comments).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [greetings, more], categories[1].post_comments.sort_by(&:id) + end + end + + # has_many through + # Source: has_many through a habtm + # Through: has_many through + def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_equal [greetings, more], authors(:bob).category_post_comments.order('comments.id') + + assert_includes_and_joins_equal( + Author.where('comments.id' => comments(:does_it_hurt).id).order('comments.id'), + [authors(:david), authors(:mary)], :category_post_comments + ) + + authors = assert_queries(5) { Author.includes(:category_post_comments).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [greetings, more], authors[2].category_post_comments.sort_by(&:id) + end + end + + # has_many through + # Source: belongs_to + # Through: has_many through + def test_has_many_through_has_many_through_with_belongs_to_source_reflection + general = tags(:general) + + assert_equal [general, general], authors(:david).tagging_tags + + assert_includes_and_joins_equal( + Author.where('tags.id' => tags(:general).id), + [authors(:david)], :tagging_tags + ) + + authors = assert_queries(5) { Author.includes(:tagging_tags).to_a } + assert_no_queries do + assert_equal [general, general], authors.first.tagging_tags + end + end + + # has_many through + # Source: has_many through + # Through: belongs_to + def test_has_many_through_belongs_to_with_has_many_through_source_reflection + welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general) + + assert_equal [welcome_general, thinking_general], + categorizations(:david_welcome_general).post_taggings.order('taggings.id') + + assert_includes_and_joins_equal( + Categorization.where('taggings.id' => welcome_general.id).order('taggings.id'), + [categorizations(:david_welcome_general)], :post_taggings + ) + + categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings.sort_by(&:id) + end + end + + # has_one through + # Source: has_one through + # Through: has_one + def test_has_one_through_has_one_with_has_one_through_source_reflection + founding = member_types(:founding) + + assert_equal founding, members(:groucho).nested_member_type + + assert_includes_and_joins_equal( + Member.where('member_types.id' => founding.id), + [members(:groucho)], :nested_member_type + ) + + members = assert_queries(4) { Member.includes(:nested_member_type).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal founding, members.first.nested_member_type + end + end + + # has_one through + # Source: belongs_to + # Through: has_one through + def test_has_one_through_has_one_through_with_belongs_to_source_reflection + general = categories(:general) + + assert_equal general, members(:groucho).club_category + + assert_includes_and_joins_equal( + Member.where('categories.id' => categories(:technology).id), + [members(:blarpy_winkup)], :club_category + ) + + members = assert_queries(4) { Member.includes(:club_category).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal general, members.first.club_category + end + end + + def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection + author = authors(:david) + assert_equal [tags(:general)], author.distinct_tags + end + + def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection + author = authors(:david) + assert_equal [subscribers(:first), subscribers(:second)], + author.distinct_subscribers.order('subscribers.nick') + end + + def test_nested_has_many_through_with_a_table_referenced_multiple_times + author = authors(:bob) + assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)], + author.similar_posts.sort_by(&:id) + + # Mary and Bob both have posts in misc, but they are the only ones. + authors = Author.joins(:similar_posts).where('posts.id' => posts(:misc_by_bob).id) + assert_equal [authors(:mary), authors(:bob)], authors.uniq.sort_by(&:id) + + # Check the polymorphism of taggings is being observed correctly (in both joins) + authors = Author.joins(:similar_posts).where('taggings.taggable_type' => 'FakeModel') + assert authors.empty? + authors = Author.joins(:similar_posts).where('taggings_authors_join.taggable_type' => 'FakeModel') + assert authors.empty? + end + + def test_has_many_through_with_foreign_key_option_on_through_reflection + assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts.order('posts.id') + assert_equal [authors(:david)], references(:david_unicyclist).agents_posts_authors + + references = Reference.joins(:agents_posts_authors).where('authors.id' => authors(:david).id) + assert_equal [references(:david_unicyclist)], references + end + + def test_has_many_through_with_foreign_key_option_on_source_reflection + assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents.order('people.id') + + jobs = Job.joins(:agents) + assert_equal [jobs(:unicyclist), jobs(:unicyclist)], jobs + end + + def test_has_many_through_with_sti_on_through_reflection + ratings = posts(:sti_comments).special_comments_ratings.sort_by(&:id) + assert_equal [ratings(:special_comment_rating), ratings(:sub_special_comment_rating)], ratings + + # Ensure STI is respected in the join + scope = Post.joins(:special_comments_ratings).where(:id => posts(:sti_comments).id) + assert scope.where("comments.type" => "Comment").empty? + assert !scope.where("comments.type" => "SpecialComment").empty? + assert !scope.where("comments.type" => "SubSpecialComment").empty? + end + + def test_nested_has_many_through_writers_should_raise_error + david = authors(:david) + subscriber = subscribers(:first) + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers = [subscriber] + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscriber_ids = [subscriber.id] + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers << subscriber + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.delete(subscriber) + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.clear + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.build + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.create + end + end + + def test_nested_has_one_through_writers_should_raise_error + groucho = members(:groucho) + founding = member_types(:founding) + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + groucho.nested_member_type = founding + end + end + + def test_nested_has_many_through_with_conditions_on_through_associations + blue, bob = tags(:blue), authors(:bob) + + assert_equal [blue], bob.misc_post_first_blue_tags + + # Pointless condition to force single-query loading + assert_includes_and_joins_equal( + Author.where('tags.id = tags.id'), + [bob], :misc_post_first_blue_tags + ) + + assert Author.where('tags.id' => 100).joins(:misc_post_first_blue_tags).empty? + + authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [blue], authors[2].misc_post_first_blue_tags + end + end + + def test_nested_has_many_through_with_conditions_on_source_associations + blue, bob = tags(:blue), authors(:bob) + + assert_equal [blue], bob.misc_post_first_blue_tags_2 + + # Pointless condition to force single-query loading + assert_includes_and_joins_equal( + Author.where('tags.id = tags.id'), + [bob], :misc_post_first_blue_tags_2 + ) + + authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a.sort_by(&:id) } + assert_no_queries do + assert_equal [blue], authors[2].misc_post_first_blue_tags_2 + end + end + + def test_nested_has_many_through_with_foreign_key_option_on_the_source_reflection_through_reflection + assert_equal [categories(:general)], organizations(:nsa).author_essay_categories + + organizations = Organization.joins(:author_essay_categories). + where('categories.id' => categories(:general).id) + assert_equal [organizations(:nsa)], organizations + + assert_equal categories(:general), organizations(:nsa).author_owned_essay_category + + organizations = Organization.joins(:author_owned_essay_category). + where('categories.id' => categories(:general).id) + assert_equal [organizations(:nsa)], organizations + end + + private + + def assert_includes_and_joins_equal(query, expected, association) + actual = assert_queries(1) { query.joins(association).to_a.uniq } + assert_equal expected, actual + + actual = assert_queries(1) { query.includes(association).to_a.uniq } + assert_equal expected, actual + end +end -- cgit v1.2.3 From 9a1a32ac2b8a526f543367bc7e8258bbd7e6a164 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 31 Oct 2010 11:21:28 +0000 Subject: Fix naughty trailing whitespace --- .../lib/active_record/association_preload.rb | 30 ++-- activerecord/lib/active_record/associations.rb | 98 +++++------ .../active_record/associations/alias_tracker.rb | 26 +-- .../associations/has_many_through_association.rb | 4 +- .../associations/has_one_through_association.rb | 2 +- .../associations/through_association_scope.rb | 64 ++++---- activerecord/lib/active_record/reflection.rb | 42 ++--- .../has_many_through_associations_test.rb | 10 +- .../has_one_through_associations_test.rb | 12 +- .../nested_through_associations_test.rb | 180 ++++++++++----------- activerecord/test/cases/reflection_test.rb | 18 +-- activerecord/test/models/author.rb | 6 +- activerecord/test/models/categorization.rb | 2 +- activerecord/test/models/category.rb | 2 +- activerecord/test/models/job.rb | 2 +- activerecord/test/models/member.rb | 8 +- activerecord/test/models/member_detail.rb | 2 +- activerecord/test/models/organization.rb | 2 +- activerecord/test/models/person.rb | 2 +- activerecord/test/models/post.rb | 4 +- activerecord/test/models/reference.rb | 2 +- 21 files changed, 259 insertions(+), 259 deletions(-) diff --git a/activerecord/lib/active_record/association_preload.rb b/activerecord/lib/active_record/association_preload.rb index c3ccb93ffd..8e7416472f 100644 --- a/activerecord/lib/active_record/association_preload.rb +++ b/activerecord/lib/active_record/association_preload.rb @@ -210,9 +210,9 @@ module ActiveRecord return if records.first.send("loaded_#{reflection.name}?") records.each {|record| record.send("set_#{reflection.name}_target", nil)} end - + options = reflection.options - + if options[:through] records_with_through_records = preload_through_records(records, reflection, options[:through]) all_through_records = records_with_through_records.map(&:last).flatten @@ -220,10 +220,10 @@ module ActiveRecord unless all_through_records.empty? source = reflection.source_reflection.name all_through_records.first.class.preload_associations(all_through_records, source, options) - + records_with_through_records.each do |record, through_records| source_records = through_records.map(&source).flatten.compact - + case reflection.macro when :has_many, :has_and_belongs_to_many add_preloaded_records_to_collection([record], reflection.name, source_records) @@ -235,7 +235,7 @@ module ActiveRecord else id_to_record_map, ids = construct_id_map(records, reflection.options[:primary_key]) associated_records = find_associated_records(ids, reflection, preload_options) - + if reflection.macro == :has_many set_association_collection_records( id_to_record_map, reflection.name, @@ -249,7 +249,7 @@ module ActiveRecord end end end - + alias_method :preload_has_one_association, :preload_has_one_or_has_many_association alias_method :preload_has_many_association, :preload_has_one_or_has_many_association @@ -259,12 +259,12 @@ module ActiveRecord # record. This is so that we can preload the source association for each record, # and always be able to access the preloaded association regardless of where we # refer to the record. - # + # # Suffices to say, if AR had an identity map built in then this would be unnecessary. identity_map = {} - + options = {} - + if reflection.options[:source_type] interface = reflection.source_reflection.options[:foreign_type] options[:conditions] = ["#{connection.quote_column_name interface} = ?", reflection.options[:source_type]] @@ -272,20 +272,20 @@ module ActiveRecord else if reflection.options[:conditions] options[:include] = reflection.options[:include] || - reflection.options[:source] + reflection.options[:source] options[:conditions] = reflection.options[:conditions] end - + options[:order] = reflection.options[:order] end - + records.first.class.preload_associations(records, through_association, options) records.map do |record| if reflection.options[:source_type] # Dont cache the association - we would only be caching a subset proxy = record.send(through_association) - + if proxy.respond_to?(:target) through_records = proxy.target proxy.reset @@ -295,11 +295,11 @@ module ActiveRecord else through_records = record.send(through_association) end - + through_records = Array.wrap(through_records).map do |through_record| identity_map[through_record] ||= through_record end - + [record, through_records] end end diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 37dbff4061..f061c8da0f 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -56,7 +56,7 @@ module ActiveRecord super("Cannot dissociate new records through '#{owner.class.name}##{reflection.name}' on '#{reflection.source_reflection.class_name rescue nil}##{reflection.source_reflection.name rescue nil}'. Both records must have an id in order to delete the has_many :through record associating them.") end end - + class HasManyThroughNestedAssociationsAreReadonly < ActiveRecordError #:nodoc def initialize(owner, reflection) super("Cannot modify association '#{owner.class.name}##{reflection.name}' because it goes through more than one other association.") @@ -487,7 +487,7 @@ module ActiveRecord # @group.avatars.delete(@group.avatars.last) # so would this # # === Nested Associations - # + # # You can actually specify *any* association with the :through option, including an # association which has a :through option itself. For example: # @@ -496,15 +496,15 @@ module ActiveRecord # has_many :comments, :through => :posts # has_many :commenters, :through => :comments # end - # + # # class Post < ActiveRecord::Base # has_many :comments # end - # + # # class Comment < ActiveRecord::Base # belongs_to :commenter # end - # + # # @author = Author.first # @author.commenters # => People who commented on posts written by the author # @@ -514,19 +514,19 @@ module ActiveRecord # has_many :posts # has_many :commenters, :through => :posts # end - # + # # class Post < ActiveRecord::Base # has_many :comments # has_many :commenters, :through => :comments # end - # + # # class Comment < ActiveRecord::Base # belongs_to :commenter # end # # When using nested association, you will not be able to modify the association because there # is not enough information to know what modification to make. For example, if you tried to - # add a Commenter in the example above, there would be no way to tell how to set up the + # add a Commenter in the example above, there would be no way to tell how to set up the # intermediate Post and Comment objects. # # === Polymorphic Associations @@ -2183,9 +2183,9 @@ module ActiveRecord # What type of join will be generated, either Arel::InnerJoin (default) or Arel::OuterJoin attr_accessor :join_type - + attr_reader :aliased_prefix - + delegate :options, :through_reflection, :source_reflection, :through_reflection_chain, :to => :reflection delegate :table, :table_name, :to => :parent, :prefix => true delegate :alias_tracker, :to => :join_dependency @@ -2198,13 +2198,13 @@ module ActiveRecord end super(reflection.klass) - + @reflection = reflection @join_dependency = join_dependency @parent = parent @join_type = Arel::InnerJoin @aliased_prefix = "t#{ join_dependency.join_parts.size }" - + setup_tables end @@ -2221,17 +2221,17 @@ module ActiveRecord end def join_to(relation) - # The chain starts with the target table, but we want to end with it here (makes + # The chain starts with the target table, but we want to end with it here (makes # more sense in this context) chain = through_reflection_chain.reverse - + foreign_table = parent_table index = 0 - + chain.each do |reflection| table = @tables[index] conditions = [] - + if reflection.source_reflection.nil? case reflection.macro when :belongs_to @@ -2240,25 +2240,25 @@ module ActiveRecord when :has_many, :has_one key = reflection.primary_key_name foreign_key = reflection.active_record_primary_key - + conditions << polymorphic_conditions(reflection, table) when :has_and_belongs_to_many # For habtm, we need to deal with the join table at the same time as the # target table (because unlike a :through association, there is no reflection # to represent the join table) table, join_table = table - + join_key = reflection.primary_key_name join_foreign_key = reflection.active_record.primary_key - + relation = relation.join(join_table, join_type).on( join_table[join_key]. eq(foreign_table[join_foreign_key]) ) - + # We've done the first join now, so update the foreign_table for the second foreign_table = join_table - + key = reflection.klass.primary_key foreign_key = reflection.association_foreign_key end @@ -2267,41 +2267,41 @@ module ActiveRecord when :belongs_to key = reflection.association_primary_key foreign_key = reflection.primary_key_name - + conditions << source_type_conditions(reflection, foreign_table) when :has_many, :has_one key = reflection.primary_key_name foreign_key = reflection.source_reflection.active_record_primary_key when :has_and_belongs_to_many table, join_table = table - + join_key = reflection.primary_key_name join_foreign_key = reflection.klass.primary_key - + relation = relation.join(join_table, join_type).on( join_table[join_key]. eq(foreign_table[join_foreign_key]) ) - + foreign_table = join_table - + key = reflection.klass.primary_key foreign_key = reflection.association_foreign_key end end - + conditions << table[key].eq(foreign_table[foreign_key]) - + conditions << reflection_conditions(index, table) conditions << sti_conditions(reflection, table) - + relation = relation.join(table, join_type).on(*conditions.flatten.compact) - + # The current table in this iteration becomes the foreign table in the next foreign_table = table index += 1 end - + relation end @@ -2317,11 +2317,11 @@ module ActiveRecord @tables.last end end - + def aliased_table_name table.table_alias || table.name end - + protected def table_alias_for(reflection, join = false) @@ -2336,7 +2336,7 @@ module ActiveRecord end private - + # Generate aliases and Arel::Table instances for each of the tables which we will # later generate joins for. We must do this in advance in order to correctly allocate # the proper alias. @@ -2346,44 +2346,44 @@ module ActiveRecord reflection.table_name, table_alias_for(reflection, reflection != self.reflection) ) - + table = Arel::Table.new( reflection.table_name, :engine => arel_engine, :as => aliased_table_name, :columns => reflection.klass.columns ) - + # For habtm, we have two Arel::Table instances related to a single reflection, so # we just store them as a pair in the array. if reflection.macro == :has_and_belongs_to_many || (reflection.source_reflection && reflection.source_reflection.macro == :has_and_belongs_to_many) - + join_table_name = (reflection.source_reflection || reflection).options[:join_table] - + aliased_join_table_name = alias_tracker.aliased_name_for( join_table_name, table_alias_for(reflection, true) ) - + join_table = Arel::Table.new( join_table_name, :engine => arel_engine, :as => aliased_join_table_name ) - + [table, join_table] else table end end - + # The joins are generated from the through_reflection_chain in reverse order, so # reverse the tables too (but it's important to generate the aliases in the 'forward' # order, which is why we only do the reversal now. @tables.reverse! - + @tables end - + def reflection_conditions(index, table) @reflection.through_conditions.reverse[index].map do |condition| Arel.sql(interpolate_sql(sanitize_sql( @@ -2392,28 +2392,28 @@ module ActiveRecord ))) end end - + def sti_conditions(reflection, table) unless reflection.klass.descends_from_active_record? sti_column = table[reflection.klass.inheritance_column] - + condition = sti_column.eq(reflection.klass.sti_name) - + reflection.klass.descendants.each do |subclass| condition = condition.or(sti_column.eq(subclass.sti_name)) end - + condition end end - + def source_type_conditions(reflection, foreign_table) if reflection.options[:source_type] foreign_table[reflection.source_reflection.options[:foreign_type]]. eq(reflection.options[:source_type]) end end - + def polymorphic_conditions(reflection, table) if reflection.options[:as] table["#{reflection.options[:as]}_type"]. diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index 10e90ec117..64582188b6 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -11,10 +11,10 @@ module ActiveRecord @aliases = Hash.new @other_sql = other_sql.to_s.downcase end - + def aliased_name_for(table_name, aliased_name = nil) aliased_name ||= table_name - + initialize_count_for(table_name) if @aliases[table_name].nil? if @aliases[table_name].zero? @@ -24,12 +24,12 @@ module ActiveRecord else # Otherwise, we need to use an alias aliased_name = connection.table_alias_for(aliased_name) - + initialize_count_for(aliased_name) if @aliases[aliased_name].nil? - + # Update the count @aliases[aliased_name] += 1 - + if @aliases[aliased_name] > 1 "#{truncate(aliased_name)}_#{@aliases[aliased_name]}" else @@ -41,30 +41,30 @@ module ActiveRecord def pluralize(table_name) ActiveRecord::Base.pluralize_table_names ? table_name.to_s.pluralize : table_name end - + private - + def initialize_count_for(name) @aliases[name] = 0 - + unless @other_sql.blank? # quoted_name should be downcased as some database adapters (Oracle) return quoted name in uppercase quoted_name = connection.quote_table_name(name.downcase).downcase - + # Table names @aliases[name] += @other_sql.scan(/join(?:\s+\w+)?\s+#{quoted_name}\son/).size - + # Table aliases @aliases[name] += @other_sql.scan(/join(?:\s+\w+)?\s+\S+\s+#{quoted_name}\son/).size end - + @aliases[name] end - + def truncate(name) name[0..connection.table_alias_length-3] end - + def connection ActiveRecord::Base.connection end diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 2c9fa3b447..c45f2ee224 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -66,7 +66,7 @@ module ActiveRecord def insert_record(record, force = true, validate = true) ensure_not_nested - + if record.new_record? if force record.save! @@ -83,7 +83,7 @@ module ActiveRecord # TODO - add dependent option support def delete_records(records) ensure_not_nested - + klass = @reflection.through_reflection.klass records.each do |associate| klass.delete_all(construct_join_attributes(associate)) diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb index de962e01b6..e9dc32efd3 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -15,7 +15,7 @@ module ActiveRecord def create_through_record(new_value) #nodoc: ensure_not_nested - + klass = @reflection.through_reflection.klass current_object = @owner.send(@reflection.through_reflection.name) diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb index abe7af418d..07ce6f1597 100644 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ b/activerecord/lib/active_record/associations/through_association_scope.rb @@ -18,7 +18,7 @@ module ActiveRecord :readonly => @reflection.options[:readonly] } end - + def construct_create_scope @reflection.nested? ? {} : construct_owner_attributes(@reflection) end @@ -26,18 +26,18 @@ module ActiveRecord # Build SQL conditions from attributes, qualified by table name. def construct_conditions reflection = @reflection.through_reflection_chain.last - + if reflection.macro == :has_and_belongs_to_many table_alias = table_aliases[reflection].first else table_alias = table_aliases[reflection] end - + parts = construct_quoted_owner_attributes(reflection).map do |attr, value| "#{table_alias}.#{attr} = #{value}" end parts += reflection_conditions(0) - + "(" + parts.join(') AND (') + ")" end @@ -59,18 +59,18 @@ module ActiveRecord distinct = "DISTINCT " if @reflection.options[:uniq] selected = custom_select || @reflection.options[:select] || "#{distinct}#{@reflection.quoted_table_name}.*" end - + def construct_joins(custom_joins = nil) "#{construct_through_joins} #{@reflection.options[:joins]} #{custom_joins}" end def construct_through_joins joins, right_index = [], 1 - + # Iterate over each pair in the through reflection chain, joining them together @reflection.through_reflection_chain.each_cons(2) do |left, right| right_table_and_alias = table_name_and_alias(right.quoted_table_name, table_aliases[right]) - + if left.source_reflection.nil? case left.macro when :belongs_to @@ -113,7 +113,7 @@ module ActiveRecord else right_table = table_aliases[right] end - + joins << inner_join_sql( right_table_and_alias, table_aliases[left], left.primary_key_name, @@ -121,7 +121,7 @@ module ActiveRecord polymorphic_conditions(left, left.source_reflection), reflection_conditions(right_index) ) - + if right.macro == :has_and_belongs_to_many joins << inner_join_sql( table_name_and_alias( @@ -134,7 +134,7 @@ module ActiveRecord end when :has_and_belongs_to_many join_table, left_table = table_aliases[left] - + joins << inner_join_sql( table_name_and_alias( quote_table_name(left.source_reflection.options[:join_table]), @@ -143,7 +143,7 @@ module ActiveRecord left_table, left.klass.primary_key, join_table, left.association_foreign_key ) - + joins << inner_join_sql( right_table_and_alias, join_table, left.primary_key_name, @@ -152,10 +152,10 @@ module ActiveRecord ) end end - + right_index += 1 end - + joins.join(" ") end @@ -170,77 +170,77 @@ module ActiveRecord reflection.table_name, table_alias_for(reflection, reflection != @reflection) )) - + if reflection.macro == :has_and_belongs_to_many || (reflection.source_reflection && reflection.source_reflection.macro == :has_and_belongs_to_many) - + join_table_alias = quote_table_name(alias_tracker.aliased_name_for( (reflection.source_reflection || reflection).options[:join_table], table_alias_for(reflection, true) )) - + aliases[reflection] = [join_table_alias, table_alias] else aliases[reflection] = table_alias end - + aliases end end end - + def table_alias_for(reflection, join = false) name = alias_tracker.pluralize(reflection.name) name << "_#{@reflection.name}" name << "_join" if join name end - + def quote_table_name(table_name) @reflection.klass.connection.quote_table_name(table_name) end - + def table_name_and_alias(table_name, table_alias) "#{table_name} #{table_alias if table_alias != table_name}".strip end - + def inner_join_sql(table, on_left_table, on_left_key, on_right_table, on_right_key, *conditions) conditions << "#{on_left_table}.#{on_left_key} = #{on_right_table}.#{on_right_key}" conditions = conditions.flatten.compact conditions = conditions.map { |sql| "(#{sql})" } * ' AND ' - + "INNER JOIN #{table} ON #{conditions}" end - + def reflection_conditions(index) reflection = @reflection.through_reflection_chain[index] reflection_conditions = @reflection.through_conditions[index] - + conditions = [] - + if reflection.options[:as].nil? && # reflection.klass is a Module if :as is used reflection.klass.finder_needs_type_condition? conditions << reflection.klass.send(:type_condition).to_sql end - + reflection_conditions.each do |condition| sanitized_condition = reflection.klass.send(:sanitize_sql, condition) interpolated_condition = interpolate_sql(sanitized_condition) - + if condition.is_a?(Hash) interpolated_condition.gsub!( @reflection.quoted_table_name, reflection.quoted_table_name ) end - + conditions << interpolated_condition end - + conditions end - + def polymorphic_conditions(reflection, polymorphic_reflection) if polymorphic_reflection.options[:as] "%s.%s = %s" % [ @@ -249,7 +249,7 @@ module ActiveRecord ] end end - + def source_type_conditions(reflection) if reflection.options[:source_type] "%s.%s = %s" % [ @@ -289,7 +289,7 @@ module ActiveRecord join_attributes end - + def ensure_not_nested if @reflection.nested? raise HasManyThroughNestedAssociationsAreReadonly.new(@owner, @reflection) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 6eb2057f66..ba37fed3c7 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -209,11 +209,11 @@ module ActiveRecord def association_foreign_key @association_foreign_key ||= @options[:association_foreign_key] || class_name.foreign_key end - + def association_primary_key @association_primary_key ||= @options[:primary_key] || klass.primary_key end - + def active_record_primary_key @active_record_primary_key ||= @options[:primary_key] || active_record.primary_key end @@ -249,11 +249,11 @@ module ActiveRecord def through_reflection false end - + def through_reflection_chain [self] end - + def through_conditions [Array.wrap(options[:conditions])] end @@ -340,7 +340,7 @@ module ActiveRecord # in the Active Record class. class ThroughReflection < AssociationReflection #:nodoc: delegate :primary_key_name, :association_foreign_key, :to => :source_reflection - + # Gets the source of the through reflection. It checks both a singularized # and pluralized form for :belongs_to or :has_many. # @@ -367,14 +367,14 @@ module ActiveRecord def through_reflection @through_reflection ||= active_record.reflect_on_association(options[:through]) end - + # Returns an array of AssociationReflection objects which are involved in this through # association. Each item in the array corresponds to a table which will be part of the # query for this association. - # + # # If the source reflection is itself a ThroughReflection, then we don't include self in # the chain, but just defer to the source reflection. - # + # # The chain is built by recursively calling through_reflection_chain on the source # reflection and the through reflection. The base case for the recursion is a normal # association, which just returns [self] for its through_reflection_chain. @@ -389,31 +389,31 @@ module ActiveRecord # to this reflection directly, and so start the chain here chain = [self] end - + # Recursively build the rest of the chain chain += through_reflection.through_reflection_chain - + # Finally return the completed chain chain end end - + # Consider the following example: - # + # # class Person # has_many :articles # has_many :comment_tags, :through => :articles # end - # + # # class Article # has_many :comments # has_many :comment_tags, :through => :comments, :source => :tags # end - # + # # class Comment # has_many :tags # end - # + # # There may be conditions on Person.comment_tags, Article.comment_tags and/or Comment.tags, # but only Comment.tags will be represented in the through_reflection_chain. So this method # creates an array of conditions corresponding to the through_reflection_chain. Each item in @@ -429,24 +429,24 @@ module ActiveRecord else conditions = [Array.wrap(source_reflection.options[:conditions])] end - + # Add to it the conditions from this reflection if necessary. conditions.first << options[:conditions] if options[:conditions] - + # Recursively fill out the rest of the array from the through reflection conditions += through_reflection.through_conditions - + # And return conditions end end - + # A through association is nested iff there would be more than one join table def nested? through_reflection_chain.length > 2 || through_reflection.macro == :has_and_belongs_to_many end - + # We want to use the klass from this reflection, rather than just delegate straight to # the source_reflection, because the source_reflection may be polymorphic. We still # need to respect the source_reflection's :primary_key option, though. @@ -458,7 +458,7 @@ module ActiveRecord while source_reflection.source_reflection source_reflection = source_reflection.source_reflection end - + source_reflection.options[:primary_key] || klass.primary_key end end diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index 713c492f5e..4e398751d2 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -456,19 +456,19 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_polymorphic_with_primary_key_option assert_equal [categories(:general)], authors(:david).essay_categories - + authors = Author.joins(:essay_categories).where('categories.id' => categories(:general).id) assert_equal authors(:david), authors.first - + assert_equal [owners(:blackbeard)], authors(:david).essay_owners - + authors = Author.joins(:essay_owners).where("owners.name = 'blackbeard'") assert_equal authors(:david), authors.first end - + def test_has_many_through_with_primary_key_option assert_equal [categories(:general)], authors(:david).essay_categories_2 - + authors = Author.joins(:essay_categories_2).where('categories.id' => categories(:general).id) assert_equal authors(:david), authors.first end diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb index 39e14b4bfd..1cf8c0539d 100644 --- a/activerecord/test/cases/associations/has_one_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -217,22 +217,22 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase minivan.dashboard end end - + def test_has_one_through_polymorphic_with_primary_key_option assert_equal categories(:general), authors(:david).essay_category - + authors = Author.joins(:essay_category).where('categories.id' => categories(:general).id) assert_equal authors(:david), authors.first - + assert_equal owners(:blackbeard), authors(:david).essay_owner - + authors = Author.joins(:essay_owner).where("owners.name = 'blackbeard'") assert_equal authors(:david), authors.first end - + def test_has_one_through_with_primary_key_option assert_equal categories(:general), authors(:david).essay_category_2 - + authors = Author.joins(:essay_category_2).where('categories.id' => categories(:general).id) assert_equal authors(:david), authors.first end diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index bfc290e877..db7c8b6c45 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -30,18 +30,18 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase :categorizations, :memberships, :essays # Through associations can either use the has_many or has_one macros. - # + # # has_many # - Source reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many # - Through reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many - # + # # has_one # - Source reflection can be has_one or belongs_to # - Through reflection can be has_one or belongs_to - # + # # Additionally, the source reflection and/or through reflection may be subject to # polymorphism and/or STI. - # + # # When testing these, we need to make sure it works via loading the association directly, or # joining the association, or including the association. We also need to ensure that associations # are readonly where relevant. @@ -51,18 +51,18 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase # Through: has_many def test_has_many_through_has_many_with_has_many_through_source_reflection general = tags(:general) - + assert_equal [general, general], authors(:david).tags - + assert_includes_and_joins_equal( Author.where('tags.id' => tags(:general).id), [authors(:david)], :tags ) - + # This ensures that the polymorphism of taggings is being observed correctly authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') assert authors.empty? - + authors = assert_queries(5) { Author.includes(:tags).to_a } assert_no_queries do assert_equal [general, general], authors.first.tags @@ -74,236 +74,236 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase # Through: has_many through def test_has_many_through_has_many_through_with_has_many_source_reflection luke, david = subscribers(:first), subscribers(:second) - + author = authors(:david) assert_equal [luke, david, david], author.subscribers.order('subscribers.nick') - + # All authors with subscribers where one of the subscribers' nick is 'alterself' assert_includes_and_joins_equal( Author.where('subscribers.nick' => 'alterself'), [authors(:david)], :subscribers ) - + authors = assert_queries(4) { Author.includes(:subscribers).to_a } assert_no_queries do assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick) end end - + # has_many through # Source: has_one through # Through: has_one def test_has_many_through_has_one_with_has_one_through_source_reflection founding = member_types(:founding) - + assert_equal [founding], members(:groucho).nested_member_types - + assert_includes_and_joins_equal( Member.where('member_types.id' => founding.id), [members(:groucho)], :nested_member_types ) - + members = assert_queries(4) { Member.includes(:nested_member_types).to_a } assert_no_queries do assert_equal [founding], members.first.nested_member_types end end - + # has_many through # Source: has_one # Through: has_one through def test_has_many_through_has_one_through_with_has_one_source_reflection mustache = sponsors(:moustache_club_sponsor_for_groucho) - + assert_equal [mustache], members(:groucho).nested_sponsors - + assert_includes_and_joins_equal( Member.where('sponsors.id' => mustache.id), [members(:groucho)], :nested_sponsors ) - + members = assert_queries(4) { Member.includes(:nested_sponsors).to_a } assert_no_queries do assert_equal [mustache], members.first.nested_sponsors end end - + # has_many through # Source: has_many through # Through: has_one def test_has_many_through_has_one_with_has_many_through_source_reflection groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) - + assert_equal [groucho_details, other_details], members(:groucho).organization_member_details.order('member_details.id') - + assert_includes_and_joins_equal( Member.where('member_details.id' => member_details(:groucho).id).order('member_details.id'), [members(:groucho), members(:some_other_guy)], :organization_member_details ) - + members = Member.joins(:organization_member_details). where('member_details.id' => 9) assert members.empty? - + members = assert_queries(4) { Member.includes(:organization_member_details).to_a.sort_by(&:id) } assert_no_queries do assert_equal [groucho_details, other_details], members.first.organization_member_details.sort_by(&:id) end end - + # has_many through # Source: has_many # Through: has_one through def test_has_many_through_has_one_through_with_has_many_source_reflection groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) - + assert_equal [groucho_details, other_details], members(:groucho).organization_member_details_2.order('member_details.id') - + assert_includes_and_joins_equal( Member.where('member_details.id' => groucho_details.id).order('member_details.id'), [members(:groucho), members(:some_other_guy)], :organization_member_details_2 ) - + members = Member.joins(:organization_member_details_2). where('member_details.id' => 9) assert members.empty? - + members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a.sort_by(&:id) } assert_no_queries do assert_equal [groucho_details, other_details], members.first.organization_member_details_2.sort_by(&:id) end end - + # has_many through # Source: has_and_belongs_to_many # Through: has_many def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection general, cooking = categories(:general), categories(:cooking) - + assert_equal [general, cooking], authors(:bob).post_categories.order('categories.id') - + assert_includes_and_joins_equal( Author.where('categories.id' => cooking.id), [authors(:bob)], :post_categories ) - + authors = assert_queries(3) { Author.includes(:post_categories).to_a.sort_by(&:id) } assert_no_queries do assert_equal [general, cooking], authors[2].post_categories.sort_by(&:id) end end - + # has_many through # Source: has_many # Through: has_and_belongs_to_many def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection greetings, more = comments(:greetings), comments(:more_greetings) - + assert_equal [greetings, more], categories(:technology).post_comments.order('comments.id') - + assert_includes_and_joins_equal( Category.where('comments.id' => more.id).order('comments.id'), [categories(:general), categories(:technology)], :post_comments ) - + categories = assert_queries(3) { Category.includes(:post_comments).to_a.sort_by(&:id) } assert_no_queries do assert_equal [greetings, more], categories[1].post_comments.sort_by(&:id) end end - + # has_many through # Source: has_many through a habtm # Through: has_many through def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection greetings, more = comments(:greetings), comments(:more_greetings) - + assert_equal [greetings, more], authors(:bob).category_post_comments.order('comments.id') - + assert_includes_and_joins_equal( Author.where('comments.id' => comments(:does_it_hurt).id).order('comments.id'), [authors(:david), authors(:mary)], :category_post_comments ) - + authors = assert_queries(5) { Author.includes(:category_post_comments).to_a.sort_by(&:id) } assert_no_queries do assert_equal [greetings, more], authors[2].category_post_comments.sort_by(&:id) end end - + # has_many through # Source: belongs_to # Through: has_many through def test_has_many_through_has_many_through_with_belongs_to_source_reflection general = tags(:general) - + assert_equal [general, general], authors(:david).tagging_tags - + assert_includes_and_joins_equal( Author.where('tags.id' => tags(:general).id), [authors(:david)], :tagging_tags ) - + authors = assert_queries(5) { Author.includes(:tagging_tags).to_a } assert_no_queries do assert_equal [general, general], authors.first.tagging_tags end end - + # has_many through # Source: has_many through # Through: belongs_to def test_has_many_through_belongs_to_with_has_many_through_source_reflection welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general) - + assert_equal [welcome_general, thinking_general], categorizations(:david_welcome_general).post_taggings.order('taggings.id') - + assert_includes_and_joins_equal( Categorization.where('taggings.id' => welcome_general.id).order('taggings.id'), [categorizations(:david_welcome_general)], :post_taggings ) - + categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a.sort_by(&:id) } assert_no_queries do assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings.sort_by(&:id) end end - + # has_one through # Source: has_one through # Through: has_one def test_has_one_through_has_one_with_has_one_through_source_reflection founding = member_types(:founding) - + assert_equal founding, members(:groucho).nested_member_type - + assert_includes_and_joins_equal( Member.where('member_types.id' => founding.id), [members(:groucho)], :nested_member_type ) - + members = assert_queries(4) { Member.includes(:nested_member_type).to_a.sort_by(&:id) } assert_no_queries do assert_equal founding, members.first.nested_member_type end end - + # has_one through # Source: belongs_to # Through: has_one through def test_has_one_through_has_one_through_with_belongs_to_source_reflection general = categories(:general) - + assert_equal general, members(:groucho).club_category - + assert_includes_and_joins_equal( Member.where('categories.id' => categories(:technology).id), [members(:blarpy_winkup)], :club_category ) - + members = assert_queries(4) { Member.includes(:club_category).to_a.sort_by(&:id) } assert_no_queries do assert_equal general, members.first.club_category @@ -320,34 +320,34 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase assert_equal [subscribers(:first), subscribers(:second)], author.distinct_subscribers.order('subscribers.nick') end - + def test_nested_has_many_through_with_a_table_referenced_multiple_times author = authors(:bob) assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)], author.similar_posts.sort_by(&:id) - + # Mary and Bob both have posts in misc, but they are the only ones. authors = Author.joins(:similar_posts).where('posts.id' => posts(:misc_by_bob).id) assert_equal [authors(:mary), authors(:bob)], authors.uniq.sort_by(&:id) - + # Check the polymorphism of taggings is being observed correctly (in both joins) authors = Author.joins(:similar_posts).where('taggings.taggable_type' => 'FakeModel') assert authors.empty? authors = Author.joins(:similar_posts).where('taggings_authors_join.taggable_type' => 'FakeModel') assert authors.empty? end - + def test_has_many_through_with_foreign_key_option_on_through_reflection assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts.order('posts.id') assert_equal [authors(:david)], references(:david_unicyclist).agents_posts_authors - + references = Reference.joins(:agents_posts_authors).where('authors.id' => authors(:david).id) assert_equal [references(:david_unicyclist)], references end - + def test_has_many_through_with_foreign_key_option_on_source_reflection assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents.order('people.id') - + jobs = Job.joins(:agents) assert_equal [jobs(:unicyclist), jobs(:unicyclist)], jobs end @@ -355,7 +355,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_with_sti_on_through_reflection ratings = posts(:sti_comments).special_comments_ratings.sort_by(&:id) assert_equal [ratings(:special_comment_rating), ratings(:sub_special_comment_rating)], ratings - + # Ensure STI is respected in the join scope = Post.joins(:special_comments_ratings).where(:id => posts(:sti_comments).id) assert scope.where("comments.type" => "Comment").empty? @@ -366,101 +366,101 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase def test_nested_has_many_through_writers_should_raise_error david = authors(:david) subscriber = subscribers(:first) - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do david.subscribers = [subscriber] end - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do david.subscriber_ids = [subscriber.id] end - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do david.subscribers << subscriber end - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do david.subscribers.delete(subscriber) end - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do david.subscribers.clear end - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do david.subscribers.build end - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do david.subscribers.create end end - + def test_nested_has_one_through_writers_should_raise_error groucho = members(:groucho) founding = member_types(:founding) - + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do groucho.nested_member_type = founding end end - + def test_nested_has_many_through_with_conditions_on_through_associations blue, bob = tags(:blue), authors(:bob) - + assert_equal [blue], bob.misc_post_first_blue_tags - + # Pointless condition to force single-query loading assert_includes_and_joins_equal( Author.where('tags.id = tags.id'), [bob], :misc_post_first_blue_tags ) - + assert Author.where('tags.id' => 100).joins(:misc_post_first_blue_tags).empty? - + authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a.sort_by(&:id) } assert_no_queries do assert_equal [blue], authors[2].misc_post_first_blue_tags end end - + def test_nested_has_many_through_with_conditions_on_source_associations blue, bob = tags(:blue), authors(:bob) - + assert_equal [blue], bob.misc_post_first_blue_tags_2 - + # Pointless condition to force single-query loading assert_includes_and_joins_equal( Author.where('tags.id = tags.id'), [bob], :misc_post_first_blue_tags_2 ) - + authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a.sort_by(&:id) } assert_no_queries do assert_equal [blue], authors[2].misc_post_first_blue_tags_2 end end - + def test_nested_has_many_through_with_foreign_key_option_on_the_source_reflection_through_reflection assert_equal [categories(:general)], organizations(:nsa).author_essay_categories - + organizations = Organization.joins(:author_essay_categories). where('categories.id' => categories(:general).id) assert_equal [organizations(:nsa)], organizations - + assert_equal categories(:general), organizations(:nsa).author_owned_essay_category - + organizations = Organization.joins(:author_owned_essay_category). where('categories.id' => categories(:general).id) assert_equal [organizations(:nsa)], organizations end - + private - + def assert_includes_and_joins_equal(query, expected, association) actual = assert_queries(1) { query.joins(association).to_a.uniq } assert_equal expected, actual - + actual = assert_queries(1) { query.includes(association).to_a.uniq } assert_equal expected, actual end diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index a85ba623e1..66fe754046 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -200,7 +200,7 @@ class ReflectionTest < ActiveRecord::TestCase def test_has_many_through_reflection assert_kind_of ThroughReflection, Subscriber.reflect_on_association(:books) end - + def test_through_reflection_chain expected = [ Author.reflect_on_association(:essay_categories), @@ -208,10 +208,10 @@ class ReflectionTest < ActiveRecord::TestCase Organization.reflect_on_association(:authors) ] actual = Organization.reflect_on_association(:author_essay_categories).through_reflection_chain - + assert_equal expected, actual end - + def test_through_conditions expected = [ ["tags.name = 'Blue'"], @@ -220,7 +220,7 @@ class ReflectionTest < ActiveRecord::TestCase ] actual = Author.reflect_on_association(:misc_post_first_blue_tags).through_conditions assert_equal expected, actual - + expected = [ ["tags.name = 'Blue'", "taggings.comment = 'first'", "posts.title LIKE 'misc post%'"], [], @@ -229,27 +229,27 @@ class ReflectionTest < ActiveRecord::TestCase actual = Author.reflect_on_association(:misc_post_first_blue_tags_2).through_conditions assert_equal expected, actual end - + def test_nested? assert !Author.reflect_on_association(:comments).nested? assert Author.reflect_on_association(:tags).nested? - + # Only goes :through once, but the through_reflection is a has_and_belongs_to_many, so this is # a nested through association assert Category.reflect_on_association(:post_comments).nested? end - + def test_association_primary_key # Normal association assert_equal "id", Author.reflect_on_association(:posts).association_primary_key.to_s assert_equal "name", Author.reflect_on_association(:essay).association_primary_key.to_s - + # Through association (uses the :primary_key option from the source reflection) assert_equal "nick", Author.reflect_on_association(:subscribers).association_primary_key.to_s assert_equal "name", Author.reflect_on_association(:essay_category).association_primary_key.to_s assert_equal "custom_primary_key", Author.reflect_on_association(:tags_with_primary_key).association_primary_key.to_s # nested end - + def test_active_record_primary_key assert_equal "nick", Subscriber.reflect_on_association(:subscriptions).active_record_primary_key.to_s assert_equal "name", Author.reflect_on_association(:essay).active_record_primary_key.to_s diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 7dcfbd268b..43bfd93e60 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -96,7 +96,7 @@ class Author < ActiveRecord::Base has_many :subscriptions, :through => :books has_many :subscribers, :through => :subscriptions, :order => "subscribers.nick" # through has_many :through (on through reflection) has_many :distinct_subscribers, :through => :subscriptions, :source => :subscriber, :select => "DISTINCT subscribers.*", :order => "subscribers.nick" - + has_one :essay, :primary_key => :name, :as => :writer has_one :essay_category, :through => :essay, :source => :category has_one :essay_owner, :through => :essay, :source => :owner @@ -107,7 +107,7 @@ class Author < ActiveRecord::Base has_many :essays, :primary_key => :name, :as => :writer has_many :essay_categories, :through => :essays, :source => :category has_many :essay_owners, :through => :essays, :source => :owner - + has_many :essays_2, :primary_key => :name, :class_name => 'Essay', :foreign_key => :author_id has_many :essay_categories_2, :through => :essays_2, :source => :category @@ -119,7 +119,7 @@ class Author < ActiveRecord::Base has_many :post_categories, :through => :posts, :source => :categories has_many :category_post_comments, :through => :categories, :source => :post_comments - + has_many :misc_posts, :class_name => 'Post', :conditions => "posts.title LIKE 'misc post%'" has_many :misc_post_first_blue_tags, :through => :misc_posts, :source => :first_blue_tags diff --git a/activerecord/test/models/categorization.rb b/activerecord/test/models/categorization.rb index bddc1e5f0c..8e2fa96498 100644 --- a/activerecord/test/models/categorization.rb +++ b/activerecord/test/models/categorization.rb @@ -2,6 +2,6 @@ class Categorization < ActiveRecord::Base belongs_to :post belongs_to :category belongs_to :author - + has_many :post_taggings, :through => :author, :source => :taggings end diff --git a/activerecord/test/models/category.rb b/activerecord/test/models/category.rb index c933943813..95825c72ef 100644 --- a/activerecord/test/models/category.rb +++ b/activerecord/test/models/category.rb @@ -23,7 +23,7 @@ class Category < ActiveRecord::Base has_many :categorizations has_many :authors, :through => :categorizations, :select => 'authors.*, categorizations.post_id' - + has_many :post_comments, :through => :posts, :source => :comments end diff --git a/activerecord/test/models/job.rb b/activerecord/test/models/job.rb index 46b1d87aa1..f7b0e787b1 100644 --- a/activerecord/test/models/job.rb +++ b/activerecord/test/models/job.rb @@ -2,6 +2,6 @@ class Job < ActiveRecord::Base has_many :references has_many :people, :through => :references belongs_to :ideal_reference, :class_name => 'Reference' - + has_many :agents, :through => :people end diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index bed62f8b7f..fbf0b80164 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -9,15 +9,15 @@ class Member < ActiveRecord::Base has_one :member_detail has_one :organization, :through => :member_detail belongs_to :member_type - + has_many :nested_member_types, :through => :member_detail, :source => :member_type has_one :nested_member_type, :through => :member_detail, :source => :member_type - + has_many :nested_sponsors, :through => :sponsor_club, :source => :sponsor has_one :nested_sponsor, :through => :sponsor_club, :source => :sponsor - + has_many :organization_member_details, :through => :member_detail has_many :organization_member_details_2, :through => :organization, :source => :member_details - + has_one :club_category, :through => :club, :source => :category end diff --git a/activerecord/test/models/member_detail.rb b/activerecord/test/models/member_detail.rb index 0f53b69ced..fe619f8732 100644 --- a/activerecord/test/models/member_detail.rb +++ b/activerecord/test/models/member_detail.rb @@ -2,6 +2,6 @@ class MemberDetail < ActiveRecord::Base belongs_to :member belongs_to :organization has_one :member_type, :through => :member - + has_many :organization_member_details, :through => :organization, :source => :member_details end diff --git a/activerecord/test/models/organization.rb b/activerecord/test/models/organization.rb index c18c28c696..4a4111833f 100644 --- a/activerecord/test/models/organization.rb +++ b/activerecord/test/models/organization.rb @@ -4,7 +4,7 @@ class Organization < ActiveRecord::Base has_many :authors, :primary_key => :name has_many :author_essay_categories, :through => :authors, :source => :essay_categories - + has_one :author, :primary_key => :name has_one :author_owned_essay_category, :through => :author, :source => :owned_essay_category diff --git a/activerecord/test/models/person.rb b/activerecord/test/models/person.rb index d35c51b660..5a5b6f9626 100644 --- a/activerecord/test/models/person.rb +++ b/activerecord/test/models/person.rb @@ -13,7 +13,7 @@ class Person < ActiveRecord::Base belongs_to :primary_contact, :class_name => 'Person' has_many :agents, :class_name => 'Person', :foreign_key => 'primary_contact_id' belongs_to :number1_fan, :class_name => 'Person' - + has_many :agents_posts, :through => :agents, :source => :posts has_many :agents_posts_authors, :through => :agents_posts, :source => :author diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index 68d2b79a3b..e9c8c02e45 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -46,7 +46,7 @@ class Post < ActiveRecord::Base has_one :very_special_comment_with_post, :class_name => "VerySpecialComment", :include => :post has_many :special_comments has_many :nonexistant_comments, :class_name => 'Comment', :conditions => 'comments.id < 0' - + has_many :special_comments_ratings, :through => :special_comments, :source => :ratings has_and_belongs_to_many :categories @@ -65,7 +65,7 @@ class Post < ActiveRecord::Base has_many :super_tags, :through => :taggings has_many :tags_with_primary_key, :through => :taggings, :source => :tag_with_primary_key has_one :tagging, :as => :taggable - + has_many :first_taggings, :as => :taggable, :class_name => 'Tagging', :conditions => "taggings.comment = 'first'" has_many :first_blue_tags, :through => :first_taggings, :source => :tag, :conditions => "tags.name = 'Blue'" diff --git a/activerecord/test/models/reference.rb b/activerecord/test/models/reference.rb index 2feb15d706..87d4a71963 100644 --- a/activerecord/test/models/reference.rb +++ b/activerecord/test/models/reference.rb @@ -1,7 +1,7 @@ class Reference < ActiveRecord::Base belongs_to :person belongs_to :job - + has_many :agents_posts_authors, :through => :person end -- cgit v1.2.3 From 026dbd28d9dbaddb7cdd7d6fdc1349cc2f43242f Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 31 Oct 2010 11:16:16 +0000 Subject: Fix bug with 0bb85ed9ffa9808926b46e8f7e59cab5b85ac19f which missed out a fixtures declaration in cascaded_eager_loading_test.rb --- activerecord/test/cases/associations/cascaded_eager_loading_test.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index 81e99036af..d997385266 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -10,7 +10,8 @@ require 'models/reply' require 'models/person' class CascadedEagerLoadingTest < ActiveRecord::TestCase - fixtures :authors, :mixins, :companies, :posts, :topics, :accounts, :comments, :categorizations, :people + fixtures :authors, :mixins, :companies, :posts, :topics, :accounts, :comments, + :categorizations, :people, :categories def test_eager_association_loading_with_cascaded_two_levels authors = Author.find(:all, :include=>{:posts=>:comments}, :order=>"authors.id") -- cgit v1.2.3 From 083d6f267611472b8acfb9801e64971ee6d19994 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 31 Oct 2010 11:27:58 +0000 Subject: Update new tests in cascaded_eager_loading_test.rb to work with the modified fixtures in this branch --- .../test/cases/associations/cascaded_eager_loading_test.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index d997385266..dbc3bcf758 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -51,24 +51,24 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase categories = Category.joins(:categorizations).includes([{:posts=>:comments}, :authors]) assert_nothing_raised do - assert_equal 2, categories.count - assert_equal 2, categories.all.uniq.size # Must uniq since instantiating with inner joins will get dupes + assert_equal 3, categories.count + assert_equal 3, categories.all.uniq.size # Must uniq since instantiating with inner joins will get dupes end end def test_cascaded_eager_association_loading_with_duplicated_includes categories = Category.includes(:categorizations).includes(:categorizations => :author).where("categorizations.id is not null") assert_nothing_raised do - assert_equal 2, categories.count - assert_equal 2, categories.all.size + assert_equal 3, categories.count + assert_equal 3, categories.all.size end end def test_cascaded_eager_association_loading_with_twice_includes_edge_cases categories = Category.includes(:categorizations => :author).includes(:categorizations => :post).where("posts.id is not null") assert_nothing_raised do - assert_equal 2, categories.count - assert_equal 2, categories.all.size + assert_equal 3, categories.count + assert_equal 3, categories.all.size end end -- cgit v1.2.3 From 73c0b390b3a1ea9487c3f667352463a90af6dd71 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 4 Mar 2011 22:29:40 +0000 Subject: When preloading has_and_belongs_to_many associations, we should only instantiate one AR object per actual record in the database. (Even when IM is off.) --- .../associations/preloader/has_and_belongs_to_many.rb | 6 ++++-- activerecord/test/cases/associations/eager_test.rb | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb b/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb index e794f05340..24be279449 100644 --- a/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb +++ b/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb @@ -31,10 +31,12 @@ module ActiveRecord private # Once we have used the join table column (in super), we manually instantiate the - # actual records + # actual records, ensuring that we don't create more than one instances of the same + # record def associated_records_by_owner + records = {} super.each do |owner_key, rows| - rows.map! { |row| klass.instantiate(row) } + rows.map! { |row| records[row[klass.primary_key]] ||= klass.instantiate(row) } end end diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index ed6337b596..40c82f2fb8 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -525,6 +525,22 @@ class EagerAssociationTest < ActiveRecord::TestCase assert posts[1].categories.include?(categories(:general)) end + # This is only really relevant when the identity map is off. Since the preloader for habtm + # gets raw row hashes from the database and then instantiates them, this test ensures that + # it only instantiates one actual object per record from the database. + def test_has_and_belongs_to_many_should_not_instantiate_same_records_multiple_times + welcome = posts(:welcome) + categories = Category.includes(:posts) + + general = categories.find { |c| c == categories(:general) } + technology = categories.find { |c| c == categories(:technology) } + + post1 = general.posts.to_a.find { |p| p == posts(:welcome) } + post2 = technology.posts.to_a.find { |p| p == posts(:welcome) } + + assert_equal post1.object_id, post2.object_id + end + def test_eager_with_has_many_and_limit_and_conditions_on_the_eagers posts = authors(:david).posts.find(:all, :include => :comments, -- cgit v1.2.3 From b5b5558d2f5347707862b8eeb1816da7c02a1d90 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 4 Mar 2011 21:21:34 +0000 Subject: Fix a couple of tests in join_model_test.rb which were failing when the identity map is turned off --- activerecord/lib/active_record/associations/association.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index 86904ea2bc..67752da2a5 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -191,8 +191,8 @@ module ActiveRecord else attributes[reflection.foreign_key] = owner[reflection.active_record_primary_key] - if options[:as] - attributes["#{options[:as]}_type"] = owner.class.base_class.name + if reflection.options[:as] + attributes[reflection.type] = owner.class.base_class.name end end attributes -- cgit v1.2.3 From 4206eff1895dccadcbec471798bfbd129404cc94 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 4 Mar 2011 22:36:44 +0000 Subject: Stop identity-mapping the through records in the preloader since I fixed the underlying problem in the habtm preloader. --- .../lib/active_record/associations/preloader/through_association.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index 30558ae29c..ad6374d09a 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -33,13 +33,8 @@ module ActiveRecord through_options ).run - # TODO: Verify that this is actually necessary and not just a symptom of an - # underlying inefficiency - identity_map = {} - Hash[owners.map do |owner| through_records = Array.wrap(owner.send(through_reflection.name)) - through_records.map! { |record| identity_map[record] ||= record } # Dont cache the association - we would only be caching a subset if reflection.options[:source_type] && through_reflection.collection? -- cgit v1.2.3 From ddf83d14f1c7ddae07a285a8ad7c45f652edc843 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 5 Mar 2011 20:10:24 +0000 Subject: Add a test for STI on the through where the through is nested, and change the code which support this --- .../associations/through_association.rb | 35 ++++++++++++++-------- activerecord/lib/active_record/reflection.rb | 3 ++ .../nested_through_associations_test.rb | 9 ++++++ activerecord/test/fixtures/taggings.yml | 10 +++++++ activerecord/test/models/post.rb | 1 + activerecord/test/models/rating.rb | 1 + 6 files changed, 46 insertions(+), 13 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index ed24373cba..8e9259a28d 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -10,8 +10,20 @@ module ActiveRecord protected + # We merge in these scopes for two reasons: + # + # 1. To get the scope_for_create on through reflection when building associated objects + # 2. To get the type conditions for any STI classes in the chain + # + # TODO: Don't actually do this. Getting the creation attributes for a non-nested through + # is a special case. The rest (STI conditions) should be handled by the reflection + # itself. def target_scope - super.merge(through_reflection.klass.scoped) + scope = super + through_reflection_chain[1..-1].each do |reflection| + scope = scope.merge(reflection.klass.scoped) + end + scope end def association_scope @@ -227,21 +239,18 @@ module ActiveRecord def reflection_conditions(index) reflection = through_reflection_chain[index] - conditions = through_conditions[index].dup - - # TODO: maybe this should go in Reflection#through_conditions directly? - unless reflection.klass.descends_from_active_record? - conditions << reflection.klass.send(:type_condition) - end + conditions = through_conditions[index] unless conditions.empty? - conditions.map! do |condition| - condition = reflection.klass.send(:sanitize_sql, interpolate(condition), reflection.table_name) - condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) - condition - end + Arel::Nodes::And.new(process_conditions(conditions, reflection)) + end + end - Arel::Nodes::And.new(conditions) + def process_conditions(conditions, reflection) + conditions.map do |condition| + condition = reflection.klass.send(:sanitize_sql, interpolate(condition), reflection.table_name) + condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) + condition end end diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index e3e2cac042..7ae9bfc928 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -416,6 +416,9 @@ module ActiveRecord else # If the source reflection does not go through another reflection, then we can get # to this reflection directly, and so start the chain here + # + # It is important to use self, rather than the source_reflection, because self + # may has a :source_type option which needs to be used. chain = [self] end diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index a4ac69782a..0dd407f342 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -425,6 +425,15 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase assert !scope.where("comments.type" => "SubSpecialComment").empty? end + def test_has_many_through_with_sti_on_nested_through_reflection + taggings = posts(:sti_comments).special_comments_ratings_taggings + assert_equal [taggings(:special_comment_rating)], taggings + + scope = Post.joins(:special_comments_ratings_taggings).where(:id => posts(:sti_comments).id) + assert scope.where("comments.type" => "Comment").empty? + assert !scope.where("comments.type" => "SpecialComment").empty? + end + def test_nested_has_many_through_writers_should_raise_error david = authors(:david) subscriber = subscribers(:first) diff --git a/activerecord/test/fixtures/taggings.yml b/activerecord/test/fixtures/taggings.yml index a337cce019..d339c12b25 100644 --- a/activerecord/test/fixtures/taggings.yml +++ b/activerecord/test/fixtures/taggings.yml @@ -66,3 +66,13 @@ other_by_mary_blue: taggable_id: 11 taggable_type: Post comment: first + +special_comment_rating: + id: 12 + taggable_id: 2 + taggable_type: Rating + +normal_comment_rating: + id: 13 + taggable_id: 1 + taggable_type: Rating diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index b39325f949..c3843fd264 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -50,6 +50,7 @@ class Post < ActiveRecord::Base has_many :nonexistant_comments, :class_name => 'Comment', :conditions => 'comments.id < 0' has_many :special_comments_ratings, :through => :special_comments, :source => :ratings + has_many :special_comments_ratings_taggings, :through => :special_comments_ratings, :source => :taggings has_and_belongs_to_many :categories has_and_belongs_to_many :special_categories, :join_table => "categories_posts", :association_foreign_key => 'category_id' diff --git a/activerecord/test/models/rating.rb b/activerecord/test/models/rating.rb index 12c4b5affa..25a52c4ad7 100644 --- a/activerecord/test/models/rating.rb +++ b/activerecord/test/models/rating.rb @@ -1,3 +1,4 @@ class Rating < ActiveRecord::Base belongs_to :comment + has_many :taggings, :as => :taggable end -- cgit v1.2.3 From 7fddb942624478a23173dfa379f7ade6a0fc9218 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 5 Mar 2011 22:07:30 +0000 Subject: Push source_type and polymorphic conditions out of ThroughAssociation and JoinDependency::JoinAssociation and into the reflection instead. --- .../join_dependency/join_association.rb | 18 ------------------ .../associations/through_association.rb | 17 ----------------- activerecord/lib/active_record/reflection.rb | 21 +++++++++++---------- activerecord/test/cases/reflection_test.rb | 4 ++-- 4 files changed, 13 insertions(+), 47 deletions(-) diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index 890e77fca9..5da3416023 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -76,8 +76,6 @@ module ActiveRecord when :has_many, :has_one key = reflection.foreign_key foreign_key = reflection.active_record_primary_key - - conditions << polymorphic_conditions(reflection, table) when :has_and_belongs_to_many # For habtm, we need to deal with the join table at the same time as the # target table (because unlike a :through association, there is no reflection @@ -103,8 +101,6 @@ module ActiveRecord when :belongs_to key = reflection.association_primary_key foreign_key = reflection.foreign_key - - conditions << source_type_conditions(reflection, foreign_table) when :has_many, :has_one key = reflection.foreign_key foreign_key = reflection.source_reflection.active_record_primary_key @@ -239,20 +235,6 @@ module ActiveRecord end end - def source_type_conditions(reflection, foreign_table) - if reflection.options[:source_type] - foreign_table[reflection.source_reflection.foreign_type]. - eq(reflection.options[:source_type]) - end - end - - def polymorphic_conditions(reflection, table) - if reflection.options[:as] - table[reflection.type]. - eq(reflection.active_record.base_class.name) - end - end - end end end diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index 8e9259a28d..11263d5def 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -89,7 +89,6 @@ module ActiveRecord right_table, left_table[left.foreign_key], right_table[right.association_primary_key], - polymorphic_conditions(left, left), reflection_conditions(right_index) ) when :has_and_belongs_to_many @@ -107,7 +106,6 @@ module ActiveRecord right_table, left_table[left.association_primary_key], right_table[left.foreign_key], - source_type_conditions(left), reflection_conditions(right_index) ) when :has_many, :has_one @@ -119,7 +117,6 @@ module ActiveRecord right_table, left_table[left.foreign_key], right_table[left.source_reflection.active_record_primary_key], - polymorphic_conditions(left, left.source_reflection), reflection_conditions(right_index) ) @@ -254,20 +251,6 @@ module ActiveRecord end end - def polymorphic_conditions(reflection, polymorphic_reflection) - if polymorphic_reflection.options[:as] - tables[reflection][polymorphic_reflection.type]. - eq(polymorphic_reflection.active_record.base_class.name) - end - end - - def source_type_conditions(reflection) - if reflection.options[:source_type] - tables[reflection.through_reflection][reflection.foreign_type]. - eq(reflection.options[:source_type]) - end - end - # TODO: Think about this in the context of nested associations def stale_state if through_reflection.macro == :belongs_to diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 7ae9bfc928..82f648b873 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -270,7 +270,9 @@ module ActiveRecord end def through_conditions - [Array.wrap(options[:conditions])] + through_conditions = [Array.wrap(options[:conditions])] + through_conditions.first << { type => active_record.base_class.name } if options[:as] + through_conditions end def source_reflection @@ -453,20 +455,19 @@ module ActiveRecord # itself an array of conditions from an arbitrary number of relevant reflections. def through_conditions @through_conditions ||= begin - # Initialize the first item - which corresponds to this reflection - either by recursing - # into the souce reflection (if it is itself a through reflection), or by grabbing the - # source reflection conditions. - if source_reflection.source_reflection - conditions = source_reflection.through_conditions - else - conditions = [Array.wrap(source_reflection.options[:conditions])] - end + conditions = source_reflection.through_conditions # Add to it the conditions from this reflection if necessary. conditions.first << options[:conditions] if options[:conditions] + through_conditions = through_reflection.through_conditions + + if options[:source_type] + through_conditions.first << { foreign_type => options[:source_type] } + end + # Recursively fill out the rest of the array from the through reflection - conditions += through_reflection.through_conditions + conditions += through_conditions # And return conditions diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index 9529ae56b8..baaa08a359 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -216,7 +216,7 @@ class ReflectionTest < ActiveRecord::TestCase def test_through_conditions expected = [ ["tags.name = 'Blue'"], - ["taggings.comment = 'first'"], + ["taggings.comment = 'first'", {"taggable_type"=>"Post"}], ["posts.title LIKE 'misc post%'"] ] actual = Author.reflect_on_association(:misc_post_first_blue_tags).through_conditions @@ -224,7 +224,7 @@ class ReflectionTest < ActiveRecord::TestCase expected = [ ["tags.name = 'Blue'", "taggings.comment = 'first'", "posts.title LIKE 'misc post%'"], - [], + [{"taggable_type"=>"Post"}], [] ] actual = Author.reflect_on_association(:misc_post_first_blue_tags_2).through_conditions -- cgit v1.2.3 From b7f1b3641afe0ff4f3cd344815c6f7bb58821e9e Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 5 Mar 2011 22:32:49 +0000 Subject: Use Base#type_condition in JoinAssociation --- .../associations/join_dependency/join_association.rb | 19 ++++--------------- activerecord/lib/active_record/base.rb | 4 ++-- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index 5da3416023..fc8e75b10d 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -123,9 +123,11 @@ module ActiveRecord end conditions << table[key].eq(foreign_table[foreign_key]) - conditions << reflection_conditions(index, table) - conditions << sti_conditions(reflection, table) + + if reflection.klass.finder_needs_type_condition? + conditions << reflection.klass.send(:type_condition, table) + end ands = relation.create_and(conditions.flatten.compact) @@ -222,19 +224,6 @@ module ActiveRecord end end - def sti_conditions(reflection, table) - unless reflection.klass.descends_from_active_record? - sti_column = table[reflection.klass.inheritance_column] - sti_condition = sti_column.eq(reflection.klass.sti_name) - subclasses = reflection.klass.descendants - - # TODO: use IN (...), or possibly AR::Base#type_condition - subclasses.inject(sti_condition) { |attr,subclass| - attr.or(sti_column.eq(subclass.sti_name)) - } - end - end - end end end diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index b3204b2bda..baf82bedd3 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -973,8 +973,8 @@ module ActiveRecord #:nodoc: relation end - def type_condition - sti_column = arel_table[inheritance_column.to_sym] + def type_condition(table = arel_table) + sti_column = table[inheritance_column.to_sym] sti_names = ([self] + descendants).map { |model| model.sti_name } sti_column.in(sti_names) -- cgit v1.2.3 From d02c326a8b5fdcfdb28ba91ee4dbc9c9edf6bf18 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 6 Mar 2011 18:14:39 +0000 Subject: Refactor ThroughAssociation#tables to just be a flat array of tables in the order that they should be joined together. --- .../associations/through_association.rb | 145 ++++++++++----------- 1 file changed, 70 insertions(+), 75 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index 11263d5def..0857d8e253 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -27,7 +27,7 @@ module ActiveRecord end def association_scope - scope = super.joins(construct_joins) + scope = join_to(super) scope = scope.where(reflection_conditions(0)) unless options[:include] @@ -57,98 +57,98 @@ module ActiveRecord end def construct_owner_conditions - reflection = through_reflection_chain.last - - if reflection.macro == :has_and_belongs_to_many - table = tables[reflection].first - else - table = Array.wrap(tables[reflection]).first - end - - super(table, reflection) + super(tables.last, through_reflection_chain.last) end - def construct_joins - joins, right_index = [], 1 + def join_to(scope) + joins = [] + tables = tables().dup # FIXME: Ugly - # Iterate over each pair in the through reflection chain, joining them together - through_reflection_chain.each_cons(2) do |left, right| - left_table, right_table = tables[left], tables[right] + foreign_reflection = through_reflection_chain.first + foreign_table = tables.shift - if left.source_reflection.nil? - case left.macro + through_reflection_chain[1..-1].each_with_index do |reflection, i| + i += 1 + table = tables.shift + + if foreign_reflection.source_reflection.nil? + case foreign_reflection.macro when :belongs_to joins << inner_join( - right_table, - left_table[left.association_primary_key], - right_table[left.foreign_key], - reflection_conditions(right_index) + table, + foreign_table[foreign_reflection.association_primary_key], + table[foreign_reflection.foreign_key], + reflection_conditions(i) ) when :has_many, :has_one joins << inner_join( - right_table, - left_table[left.foreign_key], - right_table[right.association_primary_key], - reflection_conditions(right_index) + table, + foreign_table[foreign_reflection.foreign_key], + table[reflection.association_primary_key], + reflection_conditions(i) ) when :has_and_belongs_to_many + join_table = foreign_table + joins << inner_join( - right_table, - left_table.first[left.foreign_key], - right_table[right.klass.primary_key], - reflection_conditions(right_index) + table, + join_table[foreign_reflection.foreign_key], + table[reflection.klass.primary_key], + reflection_conditions(i) ) end else - case left.source_reflection.macro + case foreign_reflection.source_reflection.macro when :belongs_to joins << inner_join( - right_table, - left_table[left.association_primary_key], - right_table[left.foreign_key], - reflection_conditions(right_index) + table, + foreign_table[foreign_reflection.association_primary_key], + table[foreign_reflection.foreign_key], + reflection_conditions(i) ) when :has_many, :has_one - if right.macro == :has_and_belongs_to_many - join_table, right_table = tables[right] - end - joins << inner_join( - right_table, - left_table[left.foreign_key], - right_table[left.source_reflection.active_record_primary_key], - reflection_conditions(right_index) + table, + foreign_table[foreign_reflection.foreign_key], + table[foreign_reflection.source_reflection.active_record_primary_key], + reflection_conditions(i) ) - if right.macro == :has_and_belongs_to_many + if reflection.macro == :has_and_belongs_to_many + join_table = tables.shift + joins << inner_join( join_table, - right_table[right.klass.primary_key], - join_table[right.association_foreign_key] + table[reflection.klass.primary_key], + join_table[reflection.association_foreign_key] ) + + # hack to make it become the foreign_table + table = join_table end when :has_and_belongs_to_many - join_table, left_table = tables[left] + join_table, table = table, tables.shift joins << inner_join( join_table, - left_table[left.klass.primary_key], - join_table[left.association_foreign_key] + foreign_table[foreign_reflection.klass.primary_key], + join_table[foreign_reflection.association_foreign_key] ) joins << inner_join( - right_table, - join_table[left.foreign_key], - right_table[right.klass.primary_key], - reflection_conditions(right_index) + table, + join_table[foreign_reflection.foreign_key], + table[reflection.klass.primary_key], + reflection_conditions(i) ) end end - right_index += 1 + foreign_reflection = reflection + foreign_table = table end - joins + scope.joins(joins) end # Construct attributes for :through pointing to owner and associate. This is used by the @@ -191,31 +191,26 @@ module ActiveRecord @alias_tracker ||= AliasTracker.new end - # TODO: It is decidedly icky to have an array for habtm entries, and no array for others def tables @tables ||= begin - Hash[ - through_reflection_chain.map do |reflection| - table = alias_tracker.aliased_table_for( - reflection.table_name, - table_alias_for(reflection, reflection != self.reflection) + tables = [] + through_reflection_chain.each do |reflection| + tables << alias_tracker.aliased_table_for( + reflection.table_name, + table_alias_for(reflection, reflection != self.reflection) + ) + + if reflection.macro == :has_and_belongs_to_many || + (reflection.source_reflection && + reflection.source_reflection.macro == :has_and_belongs_to_many) + + tables << alias_tracker.aliased_table_for( + (reflection.source_reflection || reflection).options[:join_table], + table_alias_for(reflection, true) ) - - if reflection.macro == :has_and_belongs_to_many || - (reflection.source_reflection && - reflection.source_reflection.macro == :has_and_belongs_to_many) - - join_table = alias_tracker.aliased_table_for( - (reflection.source_reflection || reflection).options[:join_table], - table_alias_for(reflection, true) - ) - - [reflection, [join_table, table]] - else - [reflection, table] - end end - ] + end + tables end end -- cgit v1.2.3 From 5dc1fb39dde569c2633e067cdc895ddc56dd3482 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sun, 6 Mar 2011 23:38:10 +0000 Subject: Refactor ThroughAssociation#join_to to be much smaller, and independent of construct_owner_conditions. --- .../associations/through_association.rb | 112 ++++++--------------- activerecord/lib/active_record/reflection.rb | 6 ++ 2 files changed, 34 insertions(+), 84 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index 0857d8e253..5768915eaf 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -28,7 +28,6 @@ module ActiveRecord def association_scope scope = join_to(super) - scope = scope.where(reflection_conditions(0)) unless options[:include] scope = scope.includes(source_options[:include]) @@ -57,95 +56,42 @@ module ActiveRecord end def construct_owner_conditions - super(tables.last, through_reflection_chain.last) end def join_to(scope) joins = [] tables = tables().dup # FIXME: Ugly - foreign_reflection = through_reflection_chain.first - foreign_table = tables.shift - - through_reflection_chain[1..-1].each_with_index do |reflection, i| - i += 1 - table = tables.shift - - if foreign_reflection.source_reflection.nil? - case foreign_reflection.macro - when :belongs_to - joins << inner_join( - table, - foreign_table[foreign_reflection.association_primary_key], - table[foreign_reflection.foreign_key], - reflection_conditions(i) - ) - when :has_many, :has_one - joins << inner_join( - table, - foreign_table[foreign_reflection.foreign_key], - table[reflection.association_primary_key], - reflection_conditions(i) - ) - when :has_and_belongs_to_many - join_table = foreign_table - - joins << inner_join( - table, - join_table[foreign_reflection.foreign_key], - table[reflection.klass.primary_key], - reflection_conditions(i) - ) - end + through_reflection_chain.each_with_index do |reflection, i| + table, foreign_table = tables.shift, tables.first + + if reflection.source_macro == :has_and_belongs_to_many + join_table = tables.shift + + joins << inner_join( + join_table, + table[reflection.active_record_primary_key]. + eq(join_table[reflection.association_foreign_key]) + ) + + table, foreign_table = join_table, tables.first + end + + if reflection.source_macro == :belongs_to + key = reflection.association_primary_key + foreign_key = reflection.foreign_key else - case foreign_reflection.source_reflection.macro - when :belongs_to - joins << inner_join( - table, - foreign_table[foreign_reflection.association_primary_key], - table[foreign_reflection.foreign_key], - reflection_conditions(i) - ) - when :has_many, :has_one - joins << inner_join( - table, - foreign_table[foreign_reflection.foreign_key], - table[foreign_reflection.source_reflection.active_record_primary_key], - reflection_conditions(i) - ) - - if reflection.macro == :has_and_belongs_to_many - join_table = tables.shift - - joins << inner_join( - join_table, - table[reflection.klass.primary_key], - join_table[reflection.association_foreign_key] - ) - - # hack to make it become the foreign_table - table = join_table - end - when :has_and_belongs_to_many - join_table, table = table, tables.shift - - joins << inner_join( - join_table, - foreign_table[foreign_reflection.klass.primary_key], - join_table[foreign_reflection.association_foreign_key] - ) - - joins << inner_join( - table, - join_table[foreign_reflection.foreign_key], - table[reflection.klass.primary_key], - reflection_conditions(i) - ) - end + key = reflection.foreign_key + foreign_key = reflection.association_primary_key end - foreign_reflection = reflection - foreign_table = table + if reflection == through_reflection_chain.last + constraint = table[key].eq owner[foreign_key] + scope = scope.where(constraint).where(reflection_conditions(i)) + else + constraint = table[key].eq foreign_table[foreign_key] + joins << inner_join(foreign_table, constraint, reflection_conditions(i)) + end end scope.joins(joins) @@ -221,9 +167,7 @@ module ActiveRecord name end - def inner_join(table, left_column, right_column, *conditions) - conditions << left_column.eq(right_column) - + def inner_join(table, *conditions) table.create_join( table, table.create_on(table.create_and(conditions.flatten.compact))) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 82f648b873..5199886f79 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -279,6 +279,8 @@ module ActiveRecord nil end + alias :source_macro :macro + def has_inverse? @options[:inverse_of] end @@ -474,6 +476,10 @@ module ActiveRecord end end + def source_macro + source_reflection.source_macro + end + # A through association is nested iff there would be more than one join table def nested? through_reflection_chain.length > 2 || -- cgit v1.2.3 From cee3f9b36d01e6d54e0bd4c2fd06bee369bfff12 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Mon, 7 Mar 2011 00:04:06 +0000 Subject: Referencing a table via the ON condition in a join should force that table to be eager-loaded via a JOIN rather than via subsequent queries. --- activerecord/lib/active_record/relation.rb | 13 ++++++++++++- activerecord/test/cases/relations_test.rb | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index f939bedc81..5af20bf38b 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -407,8 +407,19 @@ module ActiveRecord private def references_eager_loaded_tables? + joined_tables = arel.join_sources.map do |join| + if join.is_a?(Arel::Nodes::StringJoin) + tables_in_string(join.left) + else + [join.left.table_name, join.left.table_alias] + end + end + + joined_tables += [table.name, table.table_alias] + # always convert table names to downcase as in Oracle quoted table names are in uppercase - joined_tables = (tables_in_string(arel.join_sql) + [table.name, table.table_alias]).compact.map{ |t| t.downcase }.uniq + joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq + (tables_in_string(to_sql) - joined_tables).any? end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 54537f11a8..ac586e2a63 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -850,4 +850,19 @@ class RelationTest < ActiveRecord::TestCase def test_primary_key assert_equal "id", Post.scoped.primary_key end + + def test_eager_loading_with_conditions_on_joins + scope = Post.includes(:comments) + + # This references the comments table, and so it should cause the comments to be eager + # loaded via a JOIN, rather than by subsequent queries. + scope = scope.joins( + Post.arel_table.create_join( + Post.arel_table, + Post.arel_table.create_on(Comment.arel_table[:id].eq(3)) + ) + ) + + assert scope.eager_loading? + end end -- cgit v1.2.3 From bb063b2f1b3e5b6fb2a4732cb696929f1652c555 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Mon, 7 Mar 2011 20:58:32 +0000 Subject: Fix test_has_many_association_through_a_has_many_association_with_nonstandard_primary_keys --- activerecord/lib/active_record/associations/through_association.rb | 2 +- activerecord/lib/active_record/reflection.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index 5768915eaf..ae2c8b65ed 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -82,7 +82,7 @@ module ActiveRecord foreign_key = reflection.foreign_key else key = reflection.foreign_key - foreign_key = reflection.association_primary_key + foreign_key = reflection.active_record_primary_key end if reflection == through_reflection_chain.last diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 5199886f79..0a9855ec25 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -372,7 +372,7 @@ module ActiveRecord # Holds all the meta-data about a :through association as it was specified # in the Active Record class. class ThroughReflection < AssociationReflection #:nodoc: - delegate :foreign_key, :foreign_type, :association_foreign_key, :to => :source_reflection + delegate :foreign_key, :foreign_type, :association_foreign_key, :active_record_primary_key, :to => :source_reflection # Gets the source of the through reflection. It checks both a singularized # and pluralized form for :belongs_to or :has_many. -- cgit v1.2.3 From 6490d65234b89d4d28308b72b13d4834fd44bbb3 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 10 Mar 2011 19:04:00 +0000 Subject: Move the code which builds a scope for through associations into a generic AssociationScope class which is capable of building a scope for any association. --- activerecord/lib/active_record/associations.rb | 7 +- .../lib/active_record/associations/association.rb | 44 +----- .../associations/association_scope.rb | 149 +++++++++++++++++++++ .../associations/collection_association.rb | 13 -- .../has_and_belongs_to_many_association.rb | 22 --- .../associations/has_many_association.rb | 2 - .../associations/has_one_association.rb | 6 - .../associations/through_association.rb | 128 ------------------ activerecord/lib/active_record/reflection.rb | 6 +- 9 files changed, 162 insertions(+), 215 deletions(-) create mode 100644 activerecord/lib/active_record/associations/association_scope.rb diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index ec5b41a3e7..90745112b1 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -140,9 +140,10 @@ module ActiveRecord autoload :HasAndBelongsToMany, 'active_record/associations/builder/has_and_belongs_to_many' end - autoload :Preloader, 'active_record/associations/preloader' - autoload :JoinDependency, 'active_record/associations/join_dependency' - autoload :AliasTracker, 'active_record/associations/alias_tracker' + autoload :Preloader, 'active_record/associations/preloader' + autoload :JoinDependency, 'active_record/associations/join_dependency' + autoload :AssociationScope, 'active_record/associations/association_scope' + autoload :AliasTracker, 'active_record/associations/alias_tracker' # Clears out the association cache. def clear_association_cache #:nodoc: diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index 25b4b9d90d..27c446b12c 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -93,23 +93,9 @@ module ActiveRecord # by scope.scoping { ... } or with_scope { ... } etc, which affects the scope which # actually gets built. def construct_scope - @association_scope = association_scope if klass - end - - def association_scope - scope = klass.unscoped - scope = scope.create_with(creation_attributes) - scope = scope.apply_finder_options(options.slice(:readonly, :include)) - scope = scope.where(interpolate(options[:conditions])) - if select = select_value - scope = scope.select(select) + if klass + @association_scope = AssociationScope.new(self).scope end - scope = scope.extending(*Array.wrap(options[:extend])) - scope.where(construct_owner_conditions) - end - - def aliased_table - klass.arel_table end # Set the inverse association, if possible @@ -174,42 +160,24 @@ module ActiveRecord end end - def select_value - options[:select] - end - - # Implemented by (some) subclasses def creation_attributes - { } - end - - # Returns a hash linking the owner to the association represented by the reflection - def construct_owner_attributes(reflection = reflection) attributes = {} - if reflection.macro == :belongs_to - attributes[reflection.association_primary_key] = owner[reflection.foreign_key] - else + + if [:has_one, :has_many].include?(reflection.macro) && !options[:through] attributes[reflection.foreign_key] = owner[reflection.active_record_primary_key] if reflection.options[:as] attributes[reflection.type] = owner.class.base_class.name end end - attributes - end - # Builds an array of arel nodes from the owner attributes hash - def construct_owner_conditions(table = aliased_table, reflection = reflection) - conditions = construct_owner_attributes(reflection).map do |attr, value| - table[attr].eq(value) - end - table.create_and(conditions) + attributes end # Sets the owner attributes on the given record def set_owner_attributes(record) if owner.persisted? - construct_owner_attributes.each { |key, value| record[key] = value } + creation_attributes.each { |key, value| record[key] = value } end end diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb new file mode 100644 index 0000000000..5df2e964be --- /dev/null +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -0,0 +1,149 @@ +module ActiveRecord + module Associations + class AssociationScope #:nodoc: + attr_reader :association, :alias_tracker + + delegate :klass, :owner, :reflection, :interpolate, :to => :association + delegate :through_reflection_chain, :through_conditions, :options, :source_options, :to => :reflection + + def initialize(association) + @association = association + @alias_tracker = AliasTracker.new + end + + def scope + scope = klass.unscoped + scope = scope.extending(*Array.wrap(options[:extend])) + + # It's okay to just apply all these like this. The options will only be present if the + # association supports that option; this is enforced by the association builder. + scope = scope.apply_finder_options(options.slice( + :readonly, :include, :order, :limit, :joins, :group, :having, :offset)) + + if options[:through] && !options[:include] + scope = scope.includes(source_options[:include]) + end + + if select = select_value + scope = scope.select(select) + end + + add_constraints(scope) + end + + private + + def select_value + select_value = options[:select] + + if reflection.collection? + select_value ||= options[:uniq] && "DISTINCT #{reflection.quoted_table_name}.*" + end + + if reflection.macro == :has_and_belongs_to_many + select_value ||= reflection.klass.arel_table[Arel.star] + end + + select_value + end + + def add_constraints(scope) + tables = construct_tables + + through_reflection_chain.each_with_index do |reflection, i| + table, foreign_table = tables.shift, tables.first + + if reflection.source_macro == :has_and_belongs_to_many + join_table = tables.shift + + scope = scope.joins(inner_join( + join_table, reflection, + table[reflection.active_record_primary_key]. + eq(join_table[reflection.association_foreign_key]) + )) + + table, foreign_table = join_table, tables.first + end + + if reflection.source_macro == :belongs_to + key = reflection.association_primary_key + foreign_key = reflection.foreign_key + else + key = reflection.foreign_key + foreign_key = reflection.active_record_primary_key + end + + if reflection == through_reflection_chain.last + scope = scope.where(table[key].eq(owner[foreign_key])) + + through_conditions[i].each do |condition| + if options[:through] && condition.is_a?(Hash) + condition = { table.name => condition } + end + + scope = scope.where(interpolate(condition)) + end + else + constraint = table[key].eq foreign_table[foreign_key] + + join = inner_join(foreign_table, reflection, constraint, *through_conditions[i]) + scope = scope.joins(join) + end + end + + scope + end + + def construct_tables + tables = [] + through_reflection_chain.each do |reflection| + tables << alias_tracker.aliased_table_for( + table_name_for(reflection), + table_alias_for(reflection, reflection != self.reflection) + ) + + if reflection.source_macro == :has_and_belongs_to_many + tables << alias_tracker.aliased_table_for( + (reflection.source_reflection || reflection).options[:join_table], + table_alias_for(reflection, true) + ) + end + end + tables + end + + def table_name_for(reflection) + if reflection == self.reflection + # If this is a polymorphic belongs_to, we want to get the klass from the + # association because it depends on the polymorphic_type attribute of + # the owner + klass.table_name + else + reflection.table_name + end + end + + def table_alias_for(reflection, join = false) + name = alias_tracker.pluralize(reflection.name) + name << "_#{self.reflection.name}" + name << "_join" if join + name + end + + def inner_join(table, reflection, *conditions) + conditions = sanitize_conditions(reflection, conditions) + table.create_join(table, table.create_on(conditions)) + end + + def sanitize_conditions(reflection, conditions) + conditions = conditions.map do |condition| + condition = reflection.klass.send(:sanitize_sql, interpolate(condition), reflection.table_name) + condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) + condition + end + + conditions.length == 1 ? conditions.first : Arel::Nodes::And.new(conditions) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index f3761bd2c7..9f4fc44cc6 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -331,11 +331,6 @@ module ActiveRecord @scopes_cache[method][args] ||= scoped.readonly(nil).send(method, *args) end - def association_scope - options = reflection.options.slice(:order, :limit, :joins, :group, :having, :offset) - super.apply_finder_options(options) - end - def load_target if find_target? targets = [] @@ -373,14 +368,6 @@ module ActiveRecord private - def select_value - super || uniq_select_value - end - - def uniq_select_value - options[:uniq] && "DISTINCT #{reflection.quoted_table_name}.*" - end - def custom_counter_sql if options[:counter_sql] interpolate(options[:counter_sql]) diff --git a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb index 028630977d..217213808b 100644 --- a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb +++ b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb @@ -26,10 +26,6 @@ module ActiveRecord record end - def association_scope - super.joins(construct_joins) - end - private def count_records @@ -48,24 +44,6 @@ module ActiveRecord end end - def construct_joins - right = join_table - left = reflection.klass.arel_table - - condition = left[reflection.klass.primary_key].eq( - right[reflection.association_foreign_key]) - - right.create_join(right, right.create_on(condition)) - end - - def construct_owner_conditions - super(join_table) - end - - def select_value - super || reflection.klass.arel_table[Arel.star] - end - def invertible_for?(record) false end diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index cebf3e477a..78c5c4b870 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -94,8 +94,6 @@ module ActiveRecord end end end - - alias creation_attributes construct_owner_attributes end end end diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index e13f97125f..1d2e8667e4 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -39,14 +39,8 @@ module ActiveRecord end end - def association_scope - super.order(options[:order]) - end - private - alias creation_attributes construct_owner_attributes - # The reason that the save param for replace is false, if for create (not just build), # is because the setting of the foreign keys is actually handled by the scoping when # the record is instantiated, and so they are set straight away and do not need to be diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index ae2c8b65ed..408237c689 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -1,5 +1,3 @@ -require 'enumerator' - module ActiveRecord # = Active Record Through Association module Associations @@ -26,77 +24,8 @@ module ActiveRecord scope end - def association_scope - scope = join_to(super) - - unless options[:include] - scope = scope.includes(source_options[:include]) - end - - scope - end - private - # This scope affects the creation of the associated records (not the join records). At the - # moment we only support creating on a :through association when the source reflection is a - # belongs_to. Thus it's not necessary to set a foreign key on the associated record(s), so - # this scope has can legitimately be empty. - def creation_attributes - { } - end - - # TODO: Needed? - def aliased_through_table - name = through_reflection.table_name - - reflection.table_name == name ? - through_reflection.klass.arel_table.alias(name + "_join") : - through_reflection.klass.arel_table - end - - def construct_owner_conditions - end - - def join_to(scope) - joins = [] - tables = tables().dup # FIXME: Ugly - - through_reflection_chain.each_with_index do |reflection, i| - table, foreign_table = tables.shift, tables.first - - if reflection.source_macro == :has_and_belongs_to_many - join_table = tables.shift - - joins << inner_join( - join_table, - table[reflection.active_record_primary_key]. - eq(join_table[reflection.association_foreign_key]) - ) - - table, foreign_table = join_table, tables.first - end - - if reflection.source_macro == :belongs_to - key = reflection.association_primary_key - foreign_key = reflection.foreign_key - else - key = reflection.foreign_key - foreign_key = reflection.active_record_primary_key - end - - if reflection == through_reflection_chain.last - constraint = table[key].eq owner[foreign_key] - scope = scope.where(constraint).where(reflection_conditions(i)) - else - constraint = table[key].eq foreign_table[foreign_key] - joins << inner_join(foreign_table, constraint, reflection_conditions(i)) - end - end - - scope.joins(joins) - end - # Construct attributes for :through pointing to owner and associate. This is used by the # methods which create and delete records on the association. # @@ -133,63 +62,6 @@ module ActiveRecord end end - def alias_tracker - @alias_tracker ||= AliasTracker.new - end - - def tables - @tables ||= begin - tables = [] - through_reflection_chain.each do |reflection| - tables << alias_tracker.aliased_table_for( - reflection.table_name, - table_alias_for(reflection, reflection != self.reflection) - ) - - if reflection.macro == :has_and_belongs_to_many || - (reflection.source_reflection && - reflection.source_reflection.macro == :has_and_belongs_to_many) - - tables << alias_tracker.aliased_table_for( - (reflection.source_reflection || reflection).options[:join_table], - table_alias_for(reflection, true) - ) - end - end - tables - end - end - - def table_alias_for(reflection, join = false) - name = alias_tracker.pluralize(reflection.name) - name << "_#{self.reflection.name}" - name << "_join" if join - name - end - - def inner_join(table, *conditions) - table.create_join( - table, - table.create_on(table.create_and(conditions.flatten.compact))) - end - - def reflection_conditions(index) - reflection = through_reflection_chain[index] - conditions = through_conditions[index] - - unless conditions.empty? - Arel::Nodes::And.new(process_conditions(conditions, reflection)) - end - end - - def process_conditions(conditions, reflection) - conditions.map do |condition| - condition = reflection.klass.send(:sanitize_sql, interpolate(condition), reflection.table_name) - condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) - condition - end - end - # TODO: Think about this in the context of nested associations def stale_state if through_reflection.macro == :belongs_to diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 0a9855ec25..8c73e49e74 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -270,9 +270,9 @@ module ActiveRecord end def through_conditions - through_conditions = [Array.wrap(options[:conditions])] - through_conditions.first << { type => active_record.base_class.name } if options[:as] - through_conditions + conditions = [options[:conditions]].compact + conditions << { type => active_record.base_class.name } if options[:as] + [conditions] end def source_reflection -- cgit v1.2.3 From 2d3d9e3531d0d49a94ded10b993640053bd76c03 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 10 Mar 2011 19:28:26 +0000 Subject: Rename Reflection#through_reflection_chain and #through_options to Reflection#chain and Reflection#options as they now no longer relate solely to through associations. --- .../associations/association_scope.rb | 12 ++--- .../join_dependency/join_association.rb | 16 +++--- .../associations/through_association.rb | 5 +- activerecord/lib/active_record/reflection.rb | 60 ++++++++++++---------- activerecord/test/cases/reflection_test.rb | 10 ++-- 5 files changed, 53 insertions(+), 50 deletions(-) diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index 5df2e964be..cf859bbdee 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -4,7 +4,7 @@ module ActiveRecord attr_reader :association, :alias_tracker delegate :klass, :owner, :reflection, :interpolate, :to => :association - delegate :through_reflection_chain, :through_conditions, :options, :source_options, :to => :reflection + delegate :chain, :conditions, :options, :source_options, :to => :reflection def initialize(association) @association = association @@ -50,7 +50,7 @@ module ActiveRecord def add_constraints(scope) tables = construct_tables - through_reflection_chain.each_with_index do |reflection, i| + chain.each_with_index do |reflection, i| table, foreign_table = tables.shift, tables.first if reflection.source_macro == :has_and_belongs_to_many @@ -73,10 +73,10 @@ module ActiveRecord foreign_key = reflection.active_record_primary_key end - if reflection == through_reflection_chain.last + if reflection == chain.last scope = scope.where(table[key].eq(owner[foreign_key])) - through_conditions[i].each do |condition| + conditions[i].each do |condition| if options[:through] && condition.is_a?(Hash) condition = { table.name => condition } end @@ -86,7 +86,7 @@ module ActiveRecord else constraint = table[key].eq foreign_table[foreign_key] - join = inner_join(foreign_table, reflection, constraint, *through_conditions[i]) + join = inner_join(foreign_table, reflection, constraint, *conditions[i]) scope = scope.joins(join) end end @@ -96,7 +96,7 @@ module ActiveRecord def construct_tables tables = [] - through_reflection_chain.each do |reflection| + chain.each do |reflection| tables << alias_tracker.aliased_table_for( table_name_for(reflection), table_alias_for(reflection, reflection != self.reflection) diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index fc8e75b10d..f26881a6c6 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -22,7 +22,7 @@ module ActiveRecord attr_reader :tables - delegate :options, :through_reflection, :source_reflection, :through_reflection_chain, :to => :reflection + delegate :options, :through_reflection, :source_reflection, :chain, :to => :reflection delegate :table, :table_name, :to => :parent, :prefix => :parent delegate :alias_tracker, :to => :join_dependency @@ -57,14 +57,12 @@ module ActiveRecord end def join_to(relation) - # The chain starts with the target table, but we want to end with it here (makes - # more sense in this context) - chain = through_reflection_chain.reverse - foreign_table = parent_table index = 0 - chain.each do |reflection| + # The chain starts with the target table, but we want to end with it here (makes + # more sense in this context), so we reverse + chain.reverse.each do |reflection| table = tables[index] conditions = [] @@ -178,7 +176,7 @@ module ActiveRecord # later generate joins for. We must do this in advance in order to correctly allocate # the proper alias. def setup_tables - @tables = through_reflection_chain.map do |reflection| + @tables = chain.map do |reflection| table = alias_tracker.aliased_table_for( reflection.table_name, table_alias_for(reflection, reflection != self.reflection) @@ -200,7 +198,7 @@ module ActiveRecord end end - # The joins are generated from the through_reflection_chain in reverse order, so + # The joins are generated from the chain in reverse order, so # reverse the tables too (but it's important to generate the aliases in the 'forward' # order, which is why we only do the reversal now. @tables.reverse! @@ -219,7 +217,7 @@ module ActiveRecord end def reflection_conditions(index, table) - reflection.through_conditions.reverse[index].map do |condition| + reflection.conditions.reverse[index].map do |condition| process_conditions(condition, table.table_alias || table.name) end end diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index 408237c689..c0c92e8d72 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -3,8 +3,7 @@ module ActiveRecord module Associations module ThroughAssociation #:nodoc: - delegate :source_options, :through_options, :source_reflection, :through_reflection, - :through_reflection_chain, :through_conditions, :to => :reflection + delegate :source_reflection, :through_reflection, :chain, :to => :reflection protected @@ -18,7 +17,7 @@ module ActiveRecord # itself. def target_scope scope = super - through_reflection_chain[1..-1].each do |reflection| + chain[1..-1].each do |reflection| scope = scope.merge(reflection.klass.scoped) end scope diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 8c73e49e74..1f3ace93a9 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -262,23 +262,28 @@ module ActiveRecord end def through_reflection - false + nil + end + + def source_reflection + nil end - def through_reflection_chain + # A chain of reflections from this one back to the owner. For more see the explanation in + # ThroughReflection. + def chain [self] end - def through_conditions + # An array of arrays of conditions. Each item in the outside array corresponds to a reflection + # in the #chain. The inside arrays are simply conditions (and each condition may itself be + # a hash, array, arel predicate, etc...) + def conditions conditions = [options[:conditions]].compact conditions << { type => active_record.base_class.name } if options[:as] [conditions] end - def source_reflection - nil - end - alias :source_macro :macro def has_inverse? @@ -401,33 +406,34 @@ module ActiveRecord @through_reflection ||= active_record.reflect_on_association(options[:through]) end - # Returns an array of AssociationReflection objects which are involved in this through - # association. Each item in the array corresponds to a table which will be part of the - # query for this association. + # Returns an array of reflections which are involved in this association. Each item in the + # array corresponds to a table which will be part of the query for this association. # # If the source reflection is itself a ThroughReflection, then we don't include self in # the chain, but just defer to the source reflection. # - # The chain is built by recursively calling through_reflection_chain on the source - # reflection and the through reflection. The base case for the recursion is a normal - # association, which just returns [self] for its through_reflection_chain. - def through_reflection_chain - @through_reflection_chain ||= begin + # The chain is built by recursively calling #chain on the source reflection and the through + # reflection. The base case for the recursion is a normal association, which just returns + # [self] as its #chain. + def chain + @chain ||= begin if source_reflection.source_reflection # If the source reflection has its own source reflection, then the chain must start # by getting us to that source reflection. - chain = source_reflection.through_reflection_chain + chain = source_reflection.chain else # If the source reflection does not go through another reflection, then we can get # to this reflection directly, and so start the chain here # # It is important to use self, rather than the source_reflection, because self # may has a :source_type option which needs to be used. + # + # FIXME: Not sure this is correct justification now that we have #conditions chain = [self] end # Recursively build the rest of the chain - chain += through_reflection.through_reflection_chain + chain += through_reflection.chain # Finally return the completed chain chain @@ -451,18 +457,18 @@ module ActiveRecord # end # # There may be conditions on Person.comment_tags, Article.comment_tags and/or Comment.tags, - # but only Comment.tags will be represented in the through_reflection_chain. So this method - # creates an array of conditions corresponding to the through_reflection_chain. Each item in - # the through_conditions array corresponds to an item in the through_reflection_chain, and is - # itself an array of conditions from an arbitrary number of relevant reflections. - def through_conditions - @through_conditions ||= begin - conditions = source_reflection.through_conditions + # but only Comment.tags will be represented in the #chain. So this method creates an array + # of conditions corresponding to the chain. Each item in the #conditions array corresponds + # to an item in the #chain, and is itself an array of conditions from an arbitrary number + # of relevant reflections, plus any :source_type or polymorphic :as constraints. + def conditions + @conditions ||= begin + conditions = source_reflection.conditions # Add to it the conditions from this reflection if necessary. conditions.first << options[:conditions] if options[:conditions] - through_conditions = through_reflection.through_conditions + through_conditions = through_reflection.conditions if options[:source_type] through_conditions.first << { foreign_type => options[:source_type] } @@ -476,14 +482,14 @@ module ActiveRecord end end + # The macro used by the source association def source_macro source_reflection.source_macro end # A through association is nested iff there would be more than one join table def nested? - through_reflection_chain.length > 2 || - through_reflection.macro == :has_and_belongs_to_many + chain.length > 2 || through_reflection.macro == :has_and_belongs_to_many end # We want to use the klass from this reflection, rather than just delegate straight to diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index baaa08a359..4b881969cc 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -202,24 +202,24 @@ class ReflectionTest < ActiveRecord::TestCase assert_kind_of ThroughReflection, Subscriber.reflect_on_association(:books) end - def test_through_reflection_chain + def test_chain expected = [ Author.reflect_on_association(:essay_categories), Author.reflect_on_association(:essays), Organization.reflect_on_association(:authors) ] - actual = Organization.reflect_on_association(:author_essay_categories).through_reflection_chain + actual = Organization.reflect_on_association(:author_essay_categories).chain assert_equal expected, actual end - def test_through_conditions + def test_conditions expected = [ ["tags.name = 'Blue'"], ["taggings.comment = 'first'", {"taggable_type"=>"Post"}], ["posts.title LIKE 'misc post%'"] ] - actual = Author.reflect_on_association(:misc_post_first_blue_tags).through_conditions + actual = Author.reflect_on_association(:misc_post_first_blue_tags).conditions assert_equal expected, actual expected = [ @@ -227,7 +227,7 @@ class ReflectionTest < ActiveRecord::TestCase [{"taggable_type"=>"Post"}], [] ] - actual = Author.reflect_on_association(:misc_post_first_blue_tags_2).through_conditions + actual = Author.reflect_on_association(:misc_post_first_blue_tags_2).conditions assert_equal expected, actual end -- cgit v1.2.3 From aef3629c6ebae013333e11911934dfff28de875a Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Thu, 10 Mar 2011 23:55:29 +0000 Subject: Refactor JoinAssociation --- .../join_dependency/join_association.rb | 154 ++++++++------------- 1 file changed, 54 insertions(+), 100 deletions(-) diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index f26881a6c6..750f03a120 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -57,88 +57,46 @@ module ActiveRecord end def join_to(relation) + tables = @tables.dup foreign_table = parent_table - index = 0 # The chain starts with the target table, but we want to end with it here (makes # more sense in this context), so we reverse - chain.reverse.each do |reflection| - table = tables[index] - conditions = [] - - if reflection.source_reflection.nil? - case reflection.macro - when :belongs_to - key = reflection.association_primary_key - foreign_key = reflection.foreign_key - when :has_many, :has_one - key = reflection.foreign_key - foreign_key = reflection.active_record_primary_key - when :has_and_belongs_to_many - # For habtm, we need to deal with the join table at the same time as the - # target table (because unlike a :through association, there is no reflection - # to represent the join table) - table, join_table = table - - join_key = reflection.foreign_key - join_foreign_key = reflection.active_record.primary_key - - relation = relation.join(join_table, join_type).on( - join_table[join_key]. - eq(foreign_table[join_foreign_key]) - ) - - # We've done the first join now, so update the foreign_table for the second - foreign_table = join_table - - key = reflection.klass.primary_key - foreign_key = reflection.association_foreign_key - end + chain.reverse.each_with_index do |reflection, i| + table = tables.shift + + case reflection.source_macro + when :belongs_to + key = reflection.association_primary_key + foreign_key = reflection.foreign_key + when :has_and_belongs_to_many + # Join the join table first... + relation = relation.from(join( + table, + table[reflection.foreign_key]. + eq(foreign_table[reflection.active_record_primary_key]) + )) + + foreign_table, table = table, tables.shift + + key = reflection.association_primary_key + foreign_key = reflection.association_foreign_key else - case reflection.source_reflection.macro - when :belongs_to - key = reflection.association_primary_key - foreign_key = reflection.foreign_key - when :has_many, :has_one - key = reflection.foreign_key - foreign_key = reflection.source_reflection.active_record_primary_key - when :has_and_belongs_to_many - table, join_table = table - - join_key = reflection.foreign_key - join_foreign_key = reflection.klass.primary_key - - relation = relation.join(join_table, join_type).on( - join_table[join_key]. - eq(foreign_table[join_foreign_key]) - ) - - foreign_table = join_table - - key = reflection.klass.primary_key - foreign_key = reflection.association_foreign_key - end + key = reflection.foreign_key + foreign_key = reflection.active_record_primary_key end + conditions = self.conditions[i].dup conditions << table[key].eq(foreign_table[foreign_key]) - conditions << reflection_conditions(index, table) if reflection.klass.finder_needs_type_condition? conditions << reflection.klass.send(:type_condition, table) end - ands = relation.create_and(conditions.flatten.compact) - - join = relation.create_join( - table, - relation.create_on(ands), - join_type) - - relation = relation.from(join) + relation = relation.from(join(table, *conditions)) # The current table in this iteration becomes the foreign table in the next foreign_table = table - index += 1 end relation @@ -150,18 +108,18 @@ module ActiveRecord end def table - if tables.last.is_a?(Array) - tables.last.first - else - tables.last - end + tables.last end def aliased_table_name table.table_alias || table.name end - protected + def conditions + @conditions ||= reflection.conditions.reverse + end + + private def table_alias_for(reflection, join = false) name = alias_tracker.pluralize(reflection.name) @@ -170,55 +128,51 @@ module ActiveRecord name end - private - # Generate aliases and Arel::Table instances for each of the tables which we will # later generate joins for. We must do this in advance in order to correctly allocate # the proper alias. def setup_tables - @tables = chain.map do |reflection| - table = alias_tracker.aliased_table_for( + @tables = [] + chain.each do |reflection| + @tables << alias_tracker.aliased_table_for( reflection.table_name, table_alias_for(reflection, reflection != self.reflection) ) - # For habtm, we have two Arel::Table instances related to a single reflection, so - # we just store them as a pair in the array. - if reflection.macro == :has_and_belongs_to_many || - (reflection.source_reflection && reflection.source_reflection.macro == :has_and_belongs_to_many) - - join_table = alias_tracker.aliased_table_for( + if reflection.source_macro == :has_and_belongs_to_many + @tables << alias_tracker.aliased_table_for( (reflection.source_reflection || reflection).options[:join_table], table_alias_for(reflection, true) ) - - [table, join_table] - else - table end end - # The joins are generated from the chain in reverse order, so - # reverse the tables too (but it's important to generate the aliases in the 'forward' - # order, which is why we only do the reversal now. + # We construct the tables in the forward order so that the aliases are generated + # correctly, but then reverse the array because that is the order in which we will + # iterate the chain. @tables.reverse! end - def process_conditions(conditions, table_name) - if conditions.respond_to?(:to_proc) - conditions = instance_eval(&conditions) - end - - Arel.sql(sanitize_sql(conditions, table_name)) + def join(table, *conditions) + conditions = sanitize_conditions(table, conditions) + table.create_join(table, table.create_on(conditions), join_type) end - def sanitize_sql(condition, table_name) - active_record.send(:sanitize_sql, condition, table_name) + def sanitize_conditions(table, conditions) + conditions = conditions.map do |condition| + condition = active_record.send(:sanitize_sql, interpolate(condition), table.table_alias || table.name) + condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) + condition + end + + conditions.length == 1 ? conditions.first : Arel::Nodes::And.new(conditions) end - def reflection_conditions(index, table) - reflection.conditions.reverse[index].map do |condition| - process_conditions(condition, table.table_alias || table.name) + def interpolate(conditions) + if conditions.respond_to?(:to_proc) + instance_eval(&conditions) + else + conditions end end -- cgit v1.2.3 From e18679ab0428797369027fc549ef964c8c2038ba Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 11 Mar 2011 00:47:18 +0000 Subject: Abstract some common code from AssociationScope and JoinDependency::JoinAssociation into a JoinHelper module --- activerecord/lib/active_record/associations.rb | 1 + .../associations/association_scope.rb | 56 ++++--------------- .../join_dependency/join_association.rb | 65 ++++------------------ .../lib/active_record/associations/join_helper.rb | 58 +++++++++++++++++++ 4 files changed, 83 insertions(+), 97 deletions(-) create mode 100644 activerecord/lib/active_record/associations/join_helper.rb diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 90745112b1..08fb6bf3c4 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -144,6 +144,7 @@ module ActiveRecord autoload :JoinDependency, 'active_record/associations/join_dependency' autoload :AssociationScope, 'active_record/associations/association_scope' autoload :AliasTracker, 'active_record/associations/alias_tracker' + autoload :JoinHelper, 'active_record/associations/join_helper' # Clears out the association cache. def clear_association_cache #:nodoc: diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index cf859bbdee..b9bbeed4d5 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -1,10 +1,12 @@ module ActiveRecord module Associations class AssociationScope #:nodoc: + include JoinHelper + attr_reader :association, :alias_tracker delegate :klass, :owner, :reflection, :interpolate, :to => :association - delegate :chain, :conditions, :options, :source_options, :to => :reflection + delegate :chain, :conditions, :options, :source_options, :active_record, :to => :reflection def initialize(association) @association = association @@ -56,8 +58,8 @@ module ActiveRecord if reflection.source_macro == :has_and_belongs_to_many join_table = tables.shift - scope = scope.joins(inner_join( - join_table, reflection, + scope = scope.joins(join( + join_table, table[reflection.active_record_primary_key]. eq(join_table[reflection.association_foreign_key]) )) @@ -84,32 +86,19 @@ module ActiveRecord scope = scope.where(interpolate(condition)) end else - constraint = table[key].eq foreign_table[foreign_key] - - join = inner_join(foreign_table, reflection, constraint, *conditions[i]) - scope = scope.joins(join) + scope = scope.joins(join( + foreign_table, + table[key].eq(foreign_table[foreign_key]), + *conditions[i] + )) end end scope end - def construct_tables - tables = [] - chain.each do |reflection| - tables << alias_tracker.aliased_table_for( - table_name_for(reflection), - table_alias_for(reflection, reflection != self.reflection) - ) - - if reflection.source_macro == :has_and_belongs_to_many - tables << alias_tracker.aliased_table_for( - (reflection.source_reflection || reflection).options[:join_table], - table_alias_for(reflection, true) - ) - end - end - tables + def alias_suffix + reflection.name end def table_name_for(reflection) @@ -123,27 +112,6 @@ module ActiveRecord end end - def table_alias_for(reflection, join = false) - name = alias_tracker.pluralize(reflection.name) - name << "_#{self.reflection.name}" - name << "_join" if join - name - end - - def inner_join(table, reflection, *conditions) - conditions = sanitize_conditions(reflection, conditions) - table.create_join(table, table.create_on(conditions)) - end - - def sanitize_conditions(reflection, conditions) - conditions = conditions.map do |condition| - condition = reflection.klass.send(:sanitize_sql, interpolate(condition), reflection.table_name) - condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) - condition - end - - conditions.length == 1 ? conditions.first : Arel::Nodes::And.new(conditions) - end end end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index 750f03a120..7dc6beeede 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -2,6 +2,8 @@ module ActiveRecord module Associations class JoinDependency # :nodoc: class JoinAssociation < JoinPart # :nodoc: + include JoinHelper + # The reflection of the association represented attr_reader :reflection @@ -26,6 +28,8 @@ module ActiveRecord delegate :table, :table_name, :to => :parent, :prefix => :parent delegate :alias_tracker, :to => :join_dependency + alias :alias_suffix :parent_table_name + def initialize(reflection, join_dependency, parent = nil) reflection.check_validity! @@ -40,8 +44,7 @@ module ActiveRecord @parent = parent @join_type = Arel::InnerJoin @aliased_prefix = "t#{ join_dependency.join_parts.size }" - - setup_tables + @tables = construct_tables.reverse end def ==(other) @@ -86,14 +89,17 @@ module ActiveRecord foreign_key = reflection.active_record_primary_key end - conditions = self.conditions[i].dup - conditions << table[key].eq(foreign_table[foreign_key]) + conditions = self.conditions[i] if reflection.klass.finder_needs_type_condition? - conditions << reflection.klass.send(:type_condition, table) + conditions += [reflection.klass.send(:type_condition, table)] end - relation = relation.from(join(table, *conditions)) + relation = relation.from(join( + table, + table[key].eq(foreign_table[foreign_key]), + *conditions + )) # The current table in this iteration becomes the foreign table in the next foreign_table = table @@ -121,53 +127,6 @@ module ActiveRecord private - def table_alias_for(reflection, join = false) - name = alias_tracker.pluralize(reflection.name) - name << "_#{parent_table_name}" - name << "_join" if join - name - end - - # Generate aliases and Arel::Table instances for each of the tables which we will - # later generate joins for. We must do this in advance in order to correctly allocate - # the proper alias. - def setup_tables - @tables = [] - chain.each do |reflection| - @tables << alias_tracker.aliased_table_for( - reflection.table_name, - table_alias_for(reflection, reflection != self.reflection) - ) - - if reflection.source_macro == :has_and_belongs_to_many - @tables << alias_tracker.aliased_table_for( - (reflection.source_reflection || reflection).options[:join_table], - table_alias_for(reflection, true) - ) - end - end - - # We construct the tables in the forward order so that the aliases are generated - # correctly, but then reverse the array because that is the order in which we will - # iterate the chain. - @tables.reverse! - end - - def join(table, *conditions) - conditions = sanitize_conditions(table, conditions) - table.create_join(table, table.create_on(conditions), join_type) - end - - def sanitize_conditions(table, conditions) - conditions = conditions.map do |condition| - condition = active_record.send(:sanitize_sql, interpolate(condition), table.table_alias || table.name) - condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) - condition - end - - conditions.length == 1 ? conditions.first : Arel::Nodes::And.new(conditions) - end - def interpolate(conditions) if conditions.respond_to?(:to_proc) instance_eval(&conditions) diff --git a/activerecord/lib/active_record/associations/join_helper.rb b/activerecord/lib/active_record/associations/join_helper.rb new file mode 100644 index 0000000000..6474f39503 --- /dev/null +++ b/activerecord/lib/active_record/associations/join_helper.rb @@ -0,0 +1,58 @@ +module ActiveRecord + module Associations + # Helper class module which gets mixed into JoinDependency::JoinAssociation and AssociationScope + module JoinHelper #:nodoc: + + def join_type + Arel::InnerJoin + end + + private + + def construct_tables + tables = [] + chain.each do |reflection| + tables << alias_tracker.aliased_table_for( + table_name_for(reflection), + table_alias_for(reflection, reflection != self.reflection) + ) + + if reflection.source_macro == :has_and_belongs_to_many + tables << alias_tracker.aliased_table_for( + (reflection.source_reflection || reflection).options[:join_table], + table_alias_for(reflection, true) + ) + end + end + tables + end + + def table_name_for(reflection) + reflection.table_name + end + + def table_alias_for(reflection, join = false) + name = alias_tracker.pluralize(reflection.name) + name << "_#{alias_suffix}" + name << "_join" if join + name + end + + def join(table, *conditions) + table.create_join(table, table.create_on(sanitize(conditions)), join_type) + end + + def sanitize(conditions) + table = conditions.first.left.relation + + conditions = conditions.map do |condition| + condition = active_record.send(:sanitize_sql, interpolate(condition), table.table_alias || table.name) + condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) + condition + end + + conditions.length == 1 ? conditions.first : Arel::Nodes::And.new(conditions) + end + end + end +end -- cgit v1.2.3 From 39a6f4f25d958783c73377ac52886c9edc19632e Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 11 Mar 2011 00:51:57 +0000 Subject: Simplify implementation of ThroughReflection#chain --- activerecord/lib/active_record/reflection.rb | 24 ++---------------------- activerecord/test/cases/reflection_test.rb | 2 +- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 1f3ace93a9..e801bc4afa 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -409,33 +409,13 @@ module ActiveRecord # Returns an array of reflections which are involved in this association. Each item in the # array corresponds to a table which will be part of the query for this association. # - # If the source reflection is itself a ThroughReflection, then we don't include self in - # the chain, but just defer to the source reflection. - # # The chain is built by recursively calling #chain on the source reflection and the through # reflection. The base case for the recursion is a normal association, which just returns # [self] as its #chain. def chain @chain ||= begin - if source_reflection.source_reflection - # If the source reflection has its own source reflection, then the chain must start - # by getting us to that source reflection. - chain = source_reflection.chain - else - # If the source reflection does not go through another reflection, then we can get - # to this reflection directly, and so start the chain here - # - # It is important to use self, rather than the source_reflection, because self - # may has a :source_type option which needs to be used. - # - # FIXME: Not sure this is correct justification now that we have #conditions - chain = [self] - end - - # Recursively build the rest of the chain - chain += through_reflection.chain - - # Finally return the completed chain + chain = source_reflection.chain + through_reflection.chain + chain[0] = self # Use self so we don't lose the information from :source_type chain end end diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index 4b881969cc..918bc0a842 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -204,7 +204,7 @@ class ReflectionTest < ActiveRecord::TestCase def test_chain expected = [ - Author.reflect_on_association(:essay_categories), + Organization.reflect_on_association(:author_essay_categories), Author.reflect_on_association(:essays), Organization.reflect_on_association(:authors) ] -- cgit v1.2.3 From 02a43f9f4585d3c932e12b60ef23543f9c534a2e Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 12 Mar 2011 08:42:57 +0000 Subject: Resolve some TODO comments which I decided did not need anything done --- activerecord/lib/active_record/associations/alias_tracker.rb | 2 +- .../lib/active_record/associations/through_association.rb | 11 ++++------- activerecord/lib/active_record/relation/query_methods.rb | 1 - 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index 6fc2bfdb31..634dee2289 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -16,7 +16,7 @@ module ActiveRecord def aliased_table_for(table_name, aliased_name = nil) table_alias = aliased_name_for(table_name, aliased_name) - if table_alias == table_name # TODO: Is this conditional necessary? + if table_alias == table_name Arel::Table.new(table_name) else Arel::Table.new(table_name).alias(table_alias) diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index c0c92e8d72..e6ab628719 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -9,12 +9,8 @@ module ActiveRecord # We merge in these scopes for two reasons: # - # 1. To get the scope_for_create on through reflection when building associated objects - # 2. To get the type conditions for any STI classes in the chain - # - # TODO: Don't actually do this. Getting the creation attributes for a non-nested through - # is a special case. The rest (STI conditions) should be handled by the reflection - # itself. + # 1. To get the default_scope conditions for any of the other reflections in the chain + # 2. To get the type conditions for any STI models in the chain def target_scope scope = super chain[1..-1].each do |reflection| @@ -61,7 +57,8 @@ module ActiveRecord end end - # TODO: Think about this in the context of nested associations + # Note: this does not capture all cases, for example it would be crazy to try to + # properly support stale-checking for nested associations. def stale_state if through_reflection.macro == :belongs_to owner[through_reflection.foreign_key].to_s diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 0c7a9ec56d..9470e7c6c5 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -260,7 +260,6 @@ module ActiveRecord join_list ) - # TODO: Necessary? join_nodes.each do |join| join_dependency.alias_tracker.aliased_name_for(join.left.name.downcase) end -- cgit v1.2.3 From 37d93ea16046add35fecd8c279e868869ee744a5 Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Sat, 12 Mar 2011 09:32:20 +0000 Subject: Fix tests under postgres - we should always put conditions in the WHERE part not in ON constraints because postgres requires that the table has been joined before the condition references it. --- .../active_record/associations/association_scope.rb | 13 ++++++++----- .../associations/join_dependency/join_association.rb | 19 +++++++++++-------- .../lib/active_record/associations/join_helper.rb | 8 +++----- .../associations/nested_through_associations_test.rb | 2 +- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index b9bbeed4d5..ab102b2b8f 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -86,11 +86,14 @@ module ActiveRecord scope = scope.where(interpolate(condition)) end else - scope = scope.joins(join( - foreign_table, - table[key].eq(foreign_table[foreign_key]), - *conditions[i] - )) + constraint = table[key].eq(foreign_table[foreign_key]) + join = join(foreign_table, constraint) + + scope = scope.joins(join) + + unless conditions[i].empty? + scope = scope.where(sanitize(conditions[i], table)) + end end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index 7dc6beeede..4121a5b378 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -74,7 +74,7 @@ module ActiveRecord foreign_key = reflection.foreign_key when :has_and_belongs_to_many # Join the join table first... - relation = relation.from(join( + relation.from(join( table, table[reflection.foreign_key]. eq(foreign_table[reflection.active_record_primary_key]) @@ -89,17 +89,20 @@ module ActiveRecord foreign_key = reflection.active_record_primary_key end - conditions = self.conditions[i] + constraint = table[key].eq(foreign_table[foreign_key]) if reflection.klass.finder_needs_type_condition? - conditions += [reflection.klass.send(:type_condition, table)] + constraint = table.create_and([ + constraint, + reflection.klass.send(:type_condition, table) + ]) end - relation = relation.from(join( - table, - table[key].eq(foreign_table[foreign_key]), - *conditions - )) + relation.from(join(table, constraint)) + + unless conditions[i].empty? + relation.where(sanitize(conditions[i], table)) + end # The current table in this iteration becomes the foreign table in the next foreign_table = table diff --git a/activerecord/lib/active_record/associations/join_helper.rb b/activerecord/lib/active_record/associations/join_helper.rb index 6474f39503..eae546e76e 100644 --- a/activerecord/lib/active_record/associations/join_helper.rb +++ b/activerecord/lib/active_record/associations/join_helper.rb @@ -38,13 +38,11 @@ module ActiveRecord name end - def join(table, *conditions) - table.create_join(table, table.create_on(sanitize(conditions)), join_type) + def join(table, constraint) + table.create_join(table, table.create_on(constraint), join_type) end - def sanitize(conditions) - table = conditions.first.left.relation - + def sanitize(conditions, table) conditions = conditions.map do |condition| condition = active_record.send(:sanitize_sql, interpolate(condition), table.table_alias || table.name) condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index 0dd407f342..dd450a2a8e 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -272,7 +272,7 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection_preload_via_joins assert_includes_and_joins_equal( - Author.where('comments.id' => comments(:does_it_hurt).id).order('comments.id'), + Author.where('comments.id' => comments(:does_it_hurt).id).order('authors.id'), [authors(:david), authors(:mary)], :category_post_comments ) end -- cgit v1.2.3 From 833a90594e9fe25819c5111c853adc91eb6071c2 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Mon, 14 Mar 2011 15:15:40 -0700 Subject: RegexpWithNamedGroups is only used for path_info, so only apply to path info --- actionpack/lib/action_dispatch/routing/route.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actionpack/lib/action_dispatch/routing/route.rb b/actionpack/lib/action_dispatch/routing/route.rb index eae9d4ea6d..0f90b2169d 100644 --- a/actionpack/lib/action_dispatch/routing/route.rb +++ b/actionpack/lib/action_dispatch/routing/route.rb @@ -23,7 +23,8 @@ module ActionDispatch conditions[:path_info] = ::Rack::Mount::Strexp.compile(path, requirements, SEPARATORS, anchor) end - @conditions = Hash[conditions.map { |k,v| [k, Rack::Mount::RegexpWithNamedGroups.new(v)] }] + @conditions = conditions.dup + @conditions[:path_info] = Rack::Mount::RegexpWithNamedGroups.new(@conditions[:path_info]) if @conditions[:path_info] @conditions.delete_if{ |k,v| k != :path_info && !valid_condition?(k) } @requirements.delete_if{ |k,v| !valid_condition?(k) } end -- cgit v1.2.3 From 9ba2d422f58e17b9e3d2a91d9979017aa36d13bd Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Mon, 14 Mar 2011 16:17:05 -0700 Subject: stop splatting arguments so that we can understand wtf is going on --- actionpack/lib/action_dispatch/routing/mapper.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index f67708722b..e7b267da81 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -365,8 +365,9 @@ module ActionDispatch # # See Scoping#defaults for its scope equivalent. def match(path, options=nil) - mapping = Mapping.new(@set, @scope, path, options || {}).to_route - @set.add_route(*mapping) + mapping = Mapping.new(@set, @scope, path, options || {}) + app, conditions, requirements, defaults, as, anchor = mapping.to_route + @set.add_route(app, conditions, requirements, defaults, as, anchor) self end -- cgit v1.2.3 From 9f2706d770a2a5513d54dac9d0fb536f787a434b Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Mon, 14 Mar 2011 18:26:30 -0700 Subject: use a list to represent the supported verbs for a route object --- actionpack/lib/action_dispatch/routing/mapper.rb | 4 ++-- actionpack/lib/action_dispatch/routing/route.rb | 20 +++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index e7b267da81..cc6b8aa82d 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -195,8 +195,8 @@ module ActionDispatch def request_method_condition if via = @options[:via] - via = Array(via).map { |m| m.to_s.dasherize.upcase } - { :request_method => %r[^#{via.join('|')}$] } + list = Array(via).map { |m| m.to_s.dasherize.upcase } + { :request_method => list } else { } end diff --git a/actionpack/lib/action_dispatch/routing/route.rb b/actionpack/lib/action_dispatch/routing/route.rb index 0f90b2169d..a049510182 100644 --- a/actionpack/lib/action_dispatch/routing/route.rb +++ b/actionpack/lib/action_dispatch/routing/route.rb @@ -12,6 +12,8 @@ module ActionDispatch @defaults = defaults @name = name + # FIXME: we should not be doing this much work in a constructor. + @requirements = requirements.merge(defaults) @requirements.delete(:controller) if @requirements[:controller].is_a?(Regexp) @requirements.delete_if { |k, v| @@ -23,22 +25,22 @@ module ActionDispatch conditions[:path_info] = ::Rack::Mount::Strexp.compile(path, requirements, SEPARATORS, anchor) end + @verbs = conditions[:request_method] || [] + @conditions = conditions.dup + + # Rack-Mount requires that :request_method be a regular expression. + # :request_method represents the HTTP verb that matches this route. + # + # Here we munge values before they get sent on to rack-mount. + @conditions[:request_method] = %r[^#{verb}$] unless @verbs.empty? @conditions[:path_info] = Rack::Mount::RegexpWithNamedGroups.new(@conditions[:path_info]) if @conditions[:path_info] @conditions.delete_if{ |k,v| k != :path_info && !valid_condition?(k) } @requirements.delete_if{ |k,v| !valid_condition?(k) } end def verb - if method = conditions[:request_method] - case method - when Regexp - source = method.source.upcase - source =~ /\A\^[-A-Z|]+\$\Z/ ? source[1..-2] : source - else - method.to_s.upcase - end - end + @verbs.join '|' end def segment_keys -- cgit v1.2.3 From 7bd70dcd6c62fb94e3298e926f69a4e1946739cc Mon Sep 17 00:00:00 2001 From: Norman Clarke Date: Tue, 15 Mar 2011 10:06:53 -0300 Subject: Add messages to plain assertions. Signed-off-by: Santiago Pastorino --- activemodel/lib/active_model/lint.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb index 957d1b9d70..35e216ac6b 100644 --- a/activemodel/lib/active_model/lint.rb +++ b/activemodel/lib/active_model/lint.rb @@ -23,7 +23,7 @@ module ActiveModel def test_to_key assert model.respond_to?(:to_key), "The model should respond to to_key" def model.persisted?() false end - assert model.to_key.nil? + assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false" end # == Responds to to_param @@ -40,7 +40,7 @@ module ActiveModel assert model.respond_to?(:to_param), "The model should respond to to_param" def model.to_key() [1] end def model.persisted?() false end - assert model.to_param.nil? + assert model.to_param.nil?, "to_param should return nil when `persited?` returns false" end # == Responds to valid? -- cgit v1.2.3 From e8458d37c56a94fae5f4c762b1767f67e10ddb43 Mon Sep 17 00:00:00 2001 From: Andrew White Date: Wed, 16 Mar 2011 10:28:40 +0000 Subject: Fix typo in assertion message --- activemodel/lib/active_model/lint.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb index 35e216ac6b..b71ef4b22e 100644 --- a/activemodel/lib/active_model/lint.rb +++ b/activemodel/lib/active_model/lint.rb @@ -40,7 +40,7 @@ module ActiveModel assert model.respond_to?(:to_param), "The model should respond to to_param" def model.to_key() [1] end def model.persisted?() false end - assert model.to_param.nil?, "to_param should return nil when `persited?` returns false" + assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false" end # == Responds to valid? -- cgit v1.2.3 From 0eae62525696b57fe7fc4bbb0bf9c0bc7ee4e480 Mon Sep 17 00:00:00 2001 From: Josh Kalderimis Date: Wed, 16 Mar 2011 19:03:25 +0100 Subject: fixes an issue with number_to_human when converting values which are less than 1 but greater than -1 [#6576 state:resolved] Signed-off-by: Santiago Pastorino --- actionpack/lib/action_view/helpers/number_helper.rb | 2 +- actionpack/test/template/number_helper_test.rb | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_view/helpers/number_helper.rb b/actionpack/lib/action_view/helpers/number_helper.rb index 4e44843c4b..05a9c5b4f1 100644 --- a/actionpack/lib/action_view/helpers/number_helper.rb +++ b/actionpack/lib/action_view/helpers/number_helper.rb @@ -472,7 +472,7 @@ module ActionView end.keys.map{|e_name| inverted_du[e_name] }.sort_by{|e| -e} number_exponent = number != 0 ? Math.log10(number.abs).floor : 0 - display_exponent = unit_exponents.find{|e| number_exponent >= e } + display_exponent = unit_exponents.find{ |e| number_exponent >= e } || 0 number /= 10 ** display_exponent unit = case units diff --git a/actionpack/test/template/number_helper_test.rb b/actionpack/test/template/number_helper_test.rb index c1b4bab903..c8d50ebf75 100644 --- a/actionpack/test/template/number_helper_test.rb +++ b/actionpack/test/template/number_helper_test.rb @@ -195,7 +195,9 @@ class NumberHelperTest < ActionView::TestCase def test_number_to_human assert_equal '-123', number_to_human(-123) - assert_equal '0', number_to_human(0) + assert_equal '-0.5', number_to_human(-0.5) + assert_equal '0', number_to_human(0) + assert_equal '0.5', number_to_human(0.5) assert_equal '123', number_to_human(123) assert_equal '1.23 Thousand', number_to_human(1234) assert_equal '12.3 Thousand', number_to_human(12345) -- cgit v1.2.3 From 9abc94c44516afdcfe4a3b202c336c9578fd6d0d Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Wed, 16 Mar 2011 18:54:34 +0000 Subject: oracle, y u defy me --- activerecord/test/cases/associations/join_model_test.rb | 2 +- activerecord/test/cases/reflection_test.rb | 10 +++++----- activerecord/test/models/author.rb | 6 ++++-- activerecord/test/models/post.rb | 8 ++++---- activerecord/test/models/tagging.rb | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index 543eff7d8b..f54d69e7fa 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -305,7 +305,7 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase end def test_has_many_through_with_custom_primary_key_on_has_many_source - assert_equal [authors(:david), authors(:bob)], posts(:thinking).authors_using_custom_pk.order(:id) + assert_equal [authors(:david), authors(:bob)], posts(:thinking).authors_using_custom_pk.order('authors.id') end def test_both_scoped_and_explicit_joins_should_be_respected diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index 918bc0a842..97d9669483 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -215,16 +215,16 @@ class ReflectionTest < ActiveRecord::TestCase def test_conditions expected = [ - ["tags.name = 'Blue'"], - ["taggings.comment = 'first'", {"taggable_type"=>"Post"}], - ["posts.title LIKE 'misc post%'"] + [{ :tags => { :name => 'Blue' } }], + [{ :taggings => { :comment => 'first' } }, { "taggable_type" => "Post" }], + [{ :posts => { :title => ['misc post by bob', 'misc post by mary'] } }] ] actual = Author.reflect_on_association(:misc_post_first_blue_tags).conditions assert_equal expected, actual expected = [ - ["tags.name = 'Blue'", "taggings.comment = 'first'", "posts.title LIKE 'misc post%'"], - [{"taggable_type"=>"Post"}], + [{ :tags => { :name => 'Blue' } }, { :taggings => { :comment => 'first' } }, { :posts => { :title => ['misc post by bob', 'misc post by mary'] } }], + [{ "taggable_type" => "Post" }], [] ] actual = Author.reflect_on_association(:misc_post_first_blue_tags_2).conditions diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index f362f70dec..e0cbc44265 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -131,10 +131,12 @@ class Author < ActiveRecord::Base has_many :post_categories, :through => :posts, :source => :categories has_many :category_post_comments, :through => :categories, :source => :post_comments - has_many :misc_posts, :class_name => 'Post', :conditions => "posts.title LIKE 'misc post%'" + has_many :misc_posts, :class_name => 'Post', + :conditions => { :posts => { :title => ['misc post by bob', 'misc post by mary'] } } has_many :misc_post_first_blue_tags, :through => :misc_posts, :source => :first_blue_tags - has_many :misc_post_first_blue_tags_2, :through => :posts, :source => :first_blue_tags_2, :conditions => "posts.title LIKE 'misc post%'" + has_many :misc_post_first_blue_tags_2, :through => :posts, :source => :first_blue_tags_2, + :conditions => { :posts => { :title => ['misc post by bob', 'misc post by mary'] } } scope :relation_include_posts, includes(:posts) scope :relation_include_tags, includes(:tags) diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index c3843fd264..82894a3d57 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -73,16 +73,16 @@ class Post < ActiveRecord::Base has_many :tags_with_destroy, :through => :taggings, :source => :tag, :dependent => :destroy has_many :tags_with_nullify, :through => :taggings, :source => :tag, :dependent => :nullify - has_many :misc_tags, :through => :taggings, :source => :tag, :conditions => "tags.name = 'Misc'" + has_many :misc_tags, :through => :taggings, :source => :tag, :conditions => { :tags => { :name => 'Misc' } } has_many :funky_tags, :through => :taggings, :source => :tag has_many :super_tags, :through => :taggings has_many :tags_with_primary_key, :through => :taggings, :source => :tag_with_primary_key has_one :tagging, :as => :taggable - has_many :first_taggings, :as => :taggable, :class_name => 'Tagging', :conditions => "taggings.comment = 'first'" - has_many :first_blue_tags, :through => :first_taggings, :source => :tag, :conditions => "tags.name = 'Blue'" + has_many :first_taggings, :as => :taggable, :class_name => 'Tagging', :conditions => { :taggings => { :comment => 'first' } } + has_many :first_blue_tags, :through => :first_taggings, :source => :tag, :conditions => { :tags => { :name => 'Blue' } } - has_many :first_blue_tags_2, :through => :taggings, :source => :blue_tag, :conditions => "taggings.comment = 'first'" + has_many :first_blue_tags_2, :through => :taggings, :source => :blue_tag, :conditions => { :taggings => { :comment => 'first' } } has_many :invalid_taggings, :as => :taggable, :class_name => "Tagging", :conditions => 'taggings.id < 0' has_many :invalid_tags, :through => :invalid_taggings, :source => :tag diff --git a/activerecord/test/models/tagging.rb b/activerecord/test/models/tagging.rb index 17e627a60e..ef323df158 100644 --- a/activerecord/test/models/tagging.rb +++ b/activerecord/test/models/tagging.rb @@ -6,7 +6,7 @@ class Tagging < ActiveRecord::Base belongs_to :tag, :include => :tagging belongs_to :super_tag, :class_name => 'Tag', :foreign_key => 'super_tag_id' belongs_to :invalid_tag, :class_name => 'Tag', :foreign_key => 'tag_id' - belongs_to :blue_tag, :class_name => 'Tag', :foreign_key => :tag_id, :conditions => "tags.name = 'Blue'" + belongs_to :blue_tag, :class_name => 'Tag', :foreign_key => :tag_id, :conditions => { :tags => { :name => 'Blue' } } belongs_to :tag_with_primary_key, :class_name => 'Tag', :foreign_key => :tag_id, :primary_key => :custom_primary_key belongs_to :interpolated_tag, :class_name => 'Tag', :foreign_key => :tag_id, :conditions => proc { "1 = #{1}" } belongs_to :taggable, :polymorphic => true, :counter_cache => true -- cgit v1.2.3 From cb44e99de46e924bdceab9294fbaea9732e9dba6 Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Wed, 16 Mar 2011 19:17:47 -0300 Subject: Add test for define_attribute_method using as name an invalid identifier --- activemodel/test/cases/attribute_methods_test.rb | 29 +++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index b001adb35a..4cfec66015 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -36,6 +36,21 @@ private end end +class ModelWithWeirdNamesAttributes + include ActiveModel::AttributeMethods + + attribute_method_suffix '' + + def attributes + { :'a?b' => 'value of a?b' } + end + +private + def attribute(name) + attributes[name.to_sym] + end +end + class AttributeMethodsTest < ActiveModel::TestCase test 'unrelated classes should not share attribute method matchers' do assert_not_equal ModelWithAttributes.send(:attribute_method_matchers), @@ -49,6 +64,14 @@ class AttributeMethodsTest < ActiveModel::TestCase assert_equal "value of foo", ModelWithAttributes.new.foo end + test '#define_attribute_method generates attribute method with invalid identifier characters' do + ModelWithWeirdNamesAttributes.define_attribute_method(:'a?b') + ModelWithWeirdNamesAttributes.define_attribute_method(:'a?b') + + assert_respond_to ModelWithWeirdNamesAttributes.new, :'a?b' + assert_equal "value of a?b", ModelWithWeirdNamesAttributes.new.send('a?b') + end + test '#define_attribute_methods generates attribute methods' do ModelWithAttributes.define_attribute_methods([:foo]) @@ -58,15 +81,15 @@ class AttributeMethodsTest < ActiveModel::TestCase test '#define_attribute_methods generates attribute methods with spaces in their names' do ModelWithAttributesWithSpaces.define_attribute_methods([:'foo bar']) - + assert_respond_to ModelWithAttributesWithSpaces.new, :'foo bar' assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') end - + test '#alias_attribute works with attributes with spaces in their names' do ModelWithAttributesWithSpaces.define_attribute_methods([:'foo bar']) ModelWithAttributesWithSpaces.alias_attribute(:'foo_bar', :'foo bar') - + assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.foo_bar end -- cgit v1.2.3 From 52320775175275379b1b215274dc26bed7f43634 Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Wed, 16 Mar 2011 19:31:57 -0300 Subject: Add test for define_attr_method using as name an invalid identifier --- activemodel/test/cases/attribute_methods_test.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index 4cfec66015..3df76295cf 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -41,6 +41,12 @@ class ModelWithWeirdNamesAttributes attribute_method_suffix '' + class << self + define_method(:'c?d') do + 'c?d' + end + end + def attributes { :'a?b' => 'value of a?b' } end @@ -86,6 +92,13 @@ class AttributeMethodsTest < ActiveModel::TestCase assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') end + test '#define_attr_method generates attribute method with invalid identifier characters' do + ModelWithWeirdNamesAttributes.define_attr_method(:'c?d', 'c?d') + + assert_respond_to ModelWithWeirdNamesAttributes, :'c?d' + assert_equal "c?d", ModelWithWeirdNamesAttributes.send('c?d') + end + test '#alias_attribute works with attributes with spaces in their names' do ModelWithAttributesWithSpaces.define_attribute_methods([:'foo bar']) ModelWithAttributesWithSpaces.alias_attribute(:'foo_bar', :'foo bar') -- cgit v1.2.3 From fda45f4fc493f5596375199fefe854df24b1ffbf Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Wed, 16 Mar 2011 21:08:02 -0300 Subject: Add a define_attr_method test --- activemodel/test/cases/attribute_methods_test.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index 3df76295cf..13f29a8fd9 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -5,6 +5,12 @@ class ModelWithAttributes attribute_method_suffix '' + class << self + define_method(:bar) do + 'bar' + end + end + def attributes { :foo => 'value of foo' } end @@ -92,6 +98,13 @@ class AttributeMethodsTest < ActiveModel::TestCase assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') end + test '#define_attr_method generates attribute method' do + ModelWithAttributes.define_attr_method(:bar, 'bar') + + assert_respond_to ModelWithAttributes, :bar + assert_equal "bar", ModelWithAttributes.bar + end + test '#define_attr_method generates attribute method with invalid identifier characters' do ModelWithWeirdNamesAttributes.define_attr_method(:'c?d', 'c?d') -- cgit v1.2.3 From c834a751d2acbd55b580cbba2e96dd29c5d9a452 Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Wed, 16 Mar 2011 21:19:35 -0300 Subject: define_attr_method correctly defines methods with invalid identifiers --- activemodel/lib/active_model/attribute_methods.rb | 5 ++--- activemodel/test/cases/attribute_methods_test.rb | 6 ++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index 2a99450a3d..21ddef6b75 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -108,9 +108,8 @@ module ActiveModel else # use eval instead of a block to work around a memory leak in dev # mode in fcgi - sing.class_eval <<-eorb, __FILE__, __LINE__ + 1 - def #{name}; #{value.nil? ? 'nil' : value.to_s.inspect}; end - eorb + value = value.nil? ? 'nil' : value.to_s + sing.send(:define_method, name) { value } end end diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index 13f29a8fd9..5cf905bc1f 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -7,7 +7,7 @@ class ModelWithAttributes class << self define_method(:bar) do - 'bar' + 'original bar' end end @@ -49,7 +49,7 @@ class ModelWithWeirdNamesAttributes class << self define_method(:'c?d') do - 'c?d' + 'original c?d' end end @@ -102,6 +102,7 @@ class AttributeMethodsTest < ActiveModel::TestCase ModelWithAttributes.define_attr_method(:bar, 'bar') assert_respond_to ModelWithAttributes, :bar + assert_equal "original bar", ModelWithAttributes.original_bar assert_equal "bar", ModelWithAttributes.bar end @@ -109,6 +110,7 @@ class AttributeMethodsTest < ActiveModel::TestCase ModelWithWeirdNamesAttributes.define_attr_method(:'c?d', 'c?d') assert_respond_to ModelWithWeirdNamesAttributes, :'c?d' + assert_equal "original c?d", ModelWithWeirdNamesAttributes.send('original_c?d') assert_equal "c?d", ModelWithWeirdNamesAttributes.send('c?d') end -- cgit v1.2.3 From 00f08793677a164bd728a6576e20241697e61c35 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Thu, 17 Mar 2011 10:26:11 -0700 Subject: dup strings on return so that in place modifications do not break anything. I am looking at you "compute_table_name" --- activemodel/lib/active_model/attribute_methods.rb | 2 +- activemodel/test/cases/attribute_methods_test.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index 21ddef6b75..c985eb293b 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -109,7 +109,7 @@ module ActiveModel # use eval instead of a block to work around a memory leak in dev # mode in fcgi value = value.nil? ? 'nil' : value.to_s - sing.send(:define_method, name) { value } + sing.send(:define_method, name) { value.dup } end end diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index 5cf905bc1f..7fa5a2a7d7 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -9,6 +9,10 @@ class ModelWithAttributes define_method(:bar) do 'original bar' end + + define_method(:zomg) do + 'original zomg' + end end def attributes @@ -98,6 +102,13 @@ class AttributeMethodsTest < ActiveModel::TestCase assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') end + def test_defined_methods_always_return_duped_string + ModelWithAttributes.define_attr_method(:zomg, 'lol') + assert_equal 'lol', ModelWithAttributes.zomg + ModelWithAttributes.zomg << 'bbq' + assert_equal 'lol', ModelWithAttributes.zomg + end + test '#define_attr_method generates attribute method' do ModelWithAttributes.define_attr_method(:bar, 'bar') -- cgit v1.2.3 From 4532b39f5faa15af957e3b41b671f07ed201488d Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Thu, 17 Mar 2011 10:31:12 -0700 Subject: remove misleading comment --- activemodel/lib/active_model/attribute_methods.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index c985eb293b..76bfd4b77d 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -106,8 +106,6 @@ module ActiveModel if block_given? sing.send :define_method, name, &block else - # use eval instead of a block to work around a memory leak in dev - # mode in fcgi value = value.nil? ? 'nil' : value.to_s sing.send(:define_method, name) { value.dup } end -- cgit v1.2.3 From 445241d713782262134ede64a967369f803076ff Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Thu, 17 Mar 2011 23:17:20 -0300 Subject: define_attr_method should be able to define methods that returns nil --- activemodel/lib/active_model/attribute_methods.rb | 4 ++-- activemodel/test/cases/attribute_methods_test.rb | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index 76bfd4b77d..73468afe55 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -106,8 +106,8 @@ module ActiveModel if block_given? sing.send :define_method, name, &block else - value = value.nil? ? 'nil' : value.to_s - sing.send(:define_method, name) { value.dup } + value = value.to_s if value + sing.send(:define_method, name) { value && value.dup } end end diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index 7fa5a2a7d7..e814666d99 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -115,6 +115,8 @@ class AttributeMethodsTest < ActiveModel::TestCase assert_respond_to ModelWithAttributes, :bar assert_equal "original bar", ModelWithAttributes.original_bar assert_equal "bar", ModelWithAttributes.bar + ModelWithAttributes.define_attr_method(:bar) + assert !ModelWithAttributes.bar end test '#define_attr_method generates attribute method with invalid identifier characters' do -- cgit v1.2.3 From f3666040a0a883ab75dd53012cc4ace66f90606b Mon Sep 17 00:00:00 2001 From: Paco Guzman Date: Fri, 18 Mar 2011 11:29:52 +0100 Subject: remove bank line --- .../active_record/connection_adapters/abstract/database_statements.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb index 5c1ce173c8..d54a643d7b 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -237,7 +237,6 @@ module ActiveRecord # add_limit_offset!('SELECT * FROM suppliers', {:limit => 10, :offset => 50}) # generates # SELECT * FROM suppliers LIMIT 10 OFFSET 50 - def add_limit_offset!(sql, options) if limit = options[:limit] sql << " LIMIT #{sanitize_limit(limit)}" -- cgit v1.2.3 From 5b84aebd14fdb1aa9746a8404589c9ada4bcbcbb Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Fri, 18 Mar 2011 23:14:45 +0000 Subject: Add order clauses to fix some tests which were failing under 1.8 on oracle and postgres --- activerecord/test/cases/identity_map_test.rb | 18 +++++++++--------- activerecord/test/cases/relations_test.rb | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/activerecord/test/cases/identity_map_test.rb b/activerecord/test/cases/identity_map_test.rb index 399d6c26df..89f7b92d09 100644 --- a/activerecord/test/cases/identity_map_test.rb +++ b/activerecord/test/cases/identity_map_test.rb @@ -207,31 +207,31 @@ class IdentityMapTest < ActiveRecord::TestCase def test_find_with_preloaded_associations assert_queries(2) do - posts = Post.preload(:comments) + posts = Post.preload(:comments).order('posts.id') assert posts.first.comments.first end # With IM we'll retrieve post object from previous query, it'll have comments # already preloaded from first call assert_queries(1) do - posts = Post.preload(:comments).to_a + posts = Post.preload(:comments).order('posts.id') assert posts.first.comments.first end assert_queries(2) do - posts = Post.preload(:author) + posts = Post.preload(:author).order('posts.id') assert posts.first.author end # With IM we'll retrieve post object from previous query, it'll have comments # already preloaded from first call assert_queries(1) do - posts = Post.preload(:author).to_a + posts = Post.preload(:author).order('posts.id') assert posts.first.author end assert_queries(1) do - posts = Post.preload(:author, :comments).to_a + posts = Post.preload(:author, :comments).order('posts.id') assert posts.first.author assert posts.first.comments.first end @@ -239,22 +239,22 @@ class IdentityMapTest < ActiveRecord::TestCase def test_find_with_included_associations assert_queries(2) do - posts = Post.includes(:comments) + posts = Post.includes(:comments).order('posts.id') assert posts.first.comments.first end assert_queries(1) do - posts = Post.scoped.includes(:comments) + posts = Post.scoped.includes(:comments).order('posts.id') assert posts.first.comments.first end assert_queries(2) do - posts = Post.includes(:author) + posts = Post.includes(:author).order('posts.id') assert posts.first.author end assert_queries(1) do - posts = Post.includes(:author, :comments).to_a + posts = Post.includes(:author, :comments).order('posts.id') assert posts.first.author assert posts.first.comments.first end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 680e1d50cf..94916f61c5 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -286,7 +286,7 @@ class RelationTest < ActiveRecord::TestCase end assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do - posts = Post.preload(:comments).to_a + posts = Post.preload(:comments).order('posts.id') assert posts.first.comments.first end @@ -296,12 +296,12 @@ class RelationTest < ActiveRecord::TestCase end assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do - posts = Post.preload(:author).to_a + posts = Post.preload(:author).order('posts.id') assert posts.first.author end assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 3) do - posts = Post.preload(:author, :comments).to_a + posts = Post.preload(:author, :comments).order('posts.id') assert posts.first.author assert posts.first.comments.first end @@ -314,7 +314,7 @@ class RelationTest < ActiveRecord::TestCase end assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 2) do - posts = Post.scoped.includes(:comments) + posts = Post.scoped.includes(:comments).order('posts.id') assert posts.first.comments.first end @@ -324,7 +324,7 @@ class RelationTest < ActiveRecord::TestCase end assert_queries(ActiveRecord::IdentityMap.enabled? ? 1 : 3) do - posts = Post.includes(:author, :comments).to_a + posts = Post.includes(:author, :comments).order('posts.id') assert posts.first.author assert posts.first.comments.first end -- cgit v1.2.3 From de1fe5e8a723baa095e83bb3ddca6af73b0aab5d Mon Sep 17 00:00:00 2001 From: Chris Kowalik Date: Sat, 19 Mar 2011 12:13:59 +0800 Subject: [action_view] added custom patterns to template resolver --- actionpack/lib/action_view/template/resolver.rb | 67 ++++++++++++++++------ actionpack/lib/action_view/testing/resolvers.rb | 12 ++-- .../test/fixtures/custom_pattern/another.html.erb | 1 + .../test/fixtures/custom_pattern/html/another.erb | 1 + .../test/fixtures/custom_pattern/html/path.erb | 1 + actionpack/test/template/resolver_patterns_test.rb | 31 ++++++++++ 6 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 actionpack/test/fixtures/custom_pattern/another.html.erb create mode 100644 actionpack/test/fixtures/custom_pattern/html/another.erb create mode 100644 actionpack/test/fixtures/custom_pattern/html/path.erb create mode 100644 actionpack/test/template/resolver_patterns_test.rb diff --git a/actionpack/lib/action_view/template/resolver.rb b/actionpack/lib/action_view/template/resolver.rb index 589b2a1a76..0966970a68 100644 --- a/actionpack/lib/action_view/template/resolver.rb +++ b/actionpack/lib/action_view/template/resolver.rb @@ -5,6 +5,25 @@ require "action_view/template" module ActionView # = Action View Resolver class Resolver + + class Path < String + attr_reader :name, :prefix, :partial, :virtual + alias_method :partial?, :partial + + def initialize(name, prefix, partial) + @name, @prefix, @partial = name, prefix, partial + rebuild(@name, @prefix, @partial) + end + + def rebuild(name, prefix, partial) + @virtual = "" + @virtual << "#{prefix}/" unless prefix.empty? + @virtual << (partial ? "_#{name}" : name) + + self.replace(@virtual) + end + end + cattr_accessor :caching self.caching = true @@ -41,10 +60,7 @@ module ActionView # Helpers that builds a path. Useful for building virtual paths. def build_path(name, prefix, partial) - path = "" - path << "#{prefix}/" unless prefix.empty? - path << (partial ? "_#{name}" : name) - path + Path.new(name, prefix, partial) end # Handles templates caching. If a key is given and caching is on @@ -97,25 +113,24 @@ module ActionView end class PathResolver < Resolver - EXTENSION_ORDER = [:locale, :formats, :handlers] + EXTENSIONS = [:locale, :formats, :handlers] + DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{.:handlers,}" + + def initialize(pattern=nil) + @pattern = pattern || DEFAULT_PATTERN + super() + end private def find_templates(name, prefix, partial, details) path = build_path(name, prefix, partial) - query(path, EXTENSION_ORDER.map { |ext| details[ext] }, details[:formats]) + extensions = Hash[EXTENSIONS.map { |ext| [ext, details[ext]] }.flatten(0)] + query(path, extensions, details[:formats]) end def query(path, exts, formats) - query = File.join(@path, path) - - query << exts.map { |ext| - "{#{ext.compact.map { |e| ".#{e}" }.join(',')},}" - }.join - - query.gsub!(/\{\.html,/, "{.html,.text.html,") - query.gsub!(/\{\.text,/, "{.text,.text.plain,") - + query = build_query(path, exts) templates = [] sanitizer = Hash.new { |h,k| h[k] = Dir["#{File.dirname(k)}/*"] } @@ -126,12 +141,28 @@ module ActionView contents = File.open(p, "rb") {|io| io.read } templates << Template.new(contents, File.expand_path(p), handler, - :virtual_path => path, :format => format, :updated_at => mtime(p)) + :virtual_path => path.virtual, :format => format, :updated_at => mtime(p)) end templates end + # Helper for building query glob string based on resolver's pattern. + def build_query(path, exts) + query = @pattern.dup + query.gsub!(/\:prefix(\/)?/, path.prefix.empty? ? "" : "#{path.prefix}\\1") # prefix can be empty... + query.gsub!(/\:action/, path.partial? ? "_#{path.name}" : path.name) + + exts.each { |ext, variants| + query.gsub!(/\:#{ext}/, "{#{variants.compact.uniq.join(',')}}") + } + + query.gsub!(/\.{html,/, ".{html,text.html,") + query.gsub!(/\.{text,/, ".{text,text.plain,") + + File.expand_path(query, @path) + end + # Returns the file mtime from the filesystem. def mtime(p) File.stat(p).mtime @@ -151,9 +182,9 @@ module ActionView # A resolver that loads files from the filesystem. class FileSystemResolver < PathResolver - def initialize(path) + def initialize(path, pattern=nil) raise ArgumentError, "path already is a Resolver class" if path.is_a?(Resolver) - super() + super(pattern) @path = File.expand_path(path) end diff --git a/actionpack/lib/action_view/testing/resolvers.rb b/actionpack/lib/action_view/testing/resolvers.rb index 5c5cab7c7d..773dfcbb1d 100644 --- a/actionpack/lib/action_view/testing/resolvers.rb +++ b/actionpack/lib/action_view/testing/resolvers.rb @@ -8,8 +8,8 @@ module ActionView #:nodoc: class FixtureResolver < PathResolver attr_reader :hash - def initialize(hash = {}) - super() + def initialize(hash = {}, pattern=nil) + super(pattern) @hash = hash end @@ -21,8 +21,8 @@ module ActionView #:nodoc: def query(path, exts, formats) query = "" - exts.each do |ext| - query << '(' << ext.map {|e| e && Regexp.escape(".#{e}") }.join('|') << '|)' + EXTENSIONS.each do |ext| + query << '(' << exts[ext].map {|e| e && Regexp.escape(".#{e}") }.join('|') << '|)' end query = /^(#{Regexp.escape(path)})#{query}$/ @@ -32,9 +32,9 @@ module ActionView #:nodoc: next unless _path =~ query handler, format = extract_handler_and_format(_path, formats) templates << Template.new(source, _path, handler, - :virtual_path => $1, :format => format, :updated_at => updated_at) + :virtual_path => path.virtual, :format => format, :updated_at => updated_at) end - + templates.sort_by {|t| -t.identifier.match(/^#{query}$/).captures.reject(&:blank?).size } end end diff --git a/actionpack/test/fixtures/custom_pattern/another.html.erb b/actionpack/test/fixtures/custom_pattern/another.html.erb new file mode 100644 index 0000000000..6d7f3bafbb --- /dev/null +++ b/actionpack/test/fixtures/custom_pattern/another.html.erb @@ -0,0 +1 @@ +Hello custom patterns! \ No newline at end of file diff --git a/actionpack/test/fixtures/custom_pattern/html/another.erb b/actionpack/test/fixtures/custom_pattern/html/another.erb new file mode 100644 index 0000000000..dbd7e96ab6 --- /dev/null +++ b/actionpack/test/fixtures/custom_pattern/html/another.erb @@ -0,0 +1 @@ +Another template! \ No newline at end of file diff --git a/actionpack/test/fixtures/custom_pattern/html/path.erb b/actionpack/test/fixtures/custom_pattern/html/path.erb new file mode 100644 index 0000000000..6d7f3bafbb --- /dev/null +++ b/actionpack/test/fixtures/custom_pattern/html/path.erb @@ -0,0 +1 @@ +Hello custom patterns! \ No newline at end of file diff --git a/actionpack/test/template/resolver_patterns_test.rb b/actionpack/test/template/resolver_patterns_test.rb new file mode 100644 index 0000000000..97b1bad055 --- /dev/null +++ b/actionpack/test/template/resolver_patterns_test.rb @@ -0,0 +1,31 @@ +require 'abstract_unit' + +class ResolverPatternsTest < ActiveSupport::TestCase + def setup + path = File.expand_path("../../fixtures/", __FILE__) + pattern = ":prefix/{:formats/,}:action{.:formats,}{.:handlers,}" + @resolver = ActionView::FileSystemResolver.new(path, pattern) + end + + def test_should_return_empty_list_for_unknown_path + templates = @resolver.find_all("unknown", "custom_pattern", false, {:locale => [], :formats => [:html], :handlers => [:erb]}) + assert_equal [], templates, "expected an empty list of templates" + end + + def test_should_return_template_for_declared_path + templates = @resolver.find_all("path", "custom_pattern", false, {:locale => [], :formats => [:html], :handlers => [:erb]}) + assert_equal 1, templates.size, "expected one template" + assert_equal "Hello custom patterns!", templates.first.source + assert_equal "custom_pattern/path", templates.first.virtual_path + assert_equal [:html], templates.first.formats + end + + def test_should_return_all_templates_when_ambigous_pattern + templates = @resolver.find_all("another", "custom_pattern", false, {:locale => [], :formats => [:html], :handlers => [:erb]}) + assert_equal 2, templates.size, "expected two templates" + assert_equal "Another template!", templates[0].source + assert_equal "custom_pattern/another", templates[0].virtual_path + assert_equal "Hello custom patterns!", templates[1].source + assert_equal "custom_pattern/another", templates[1].virtual_path + end +end -- cgit v1.2.3 From d76fadbd7d4f33b7fc208ce5242b37f0350ad493 Mon Sep 17 00:00:00 2001 From: Chris Kowalik Date: Sun, 20 Mar 2011 06:21:11 +0800 Subject: [action_view] docs for FileSystemResolver --- actionpack/lib/action_view/template/resolver.rb | 32 +++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_view/template/resolver.rb b/actionpack/lib/action_view/template/resolver.rb index 0966970a68..f9c52e3228 100644 --- a/actionpack/lib/action_view/template/resolver.rb +++ b/actionpack/lib/action_view/template/resolver.rb @@ -5,7 +5,7 @@ require "action_view/template" module ActionView # = Action View Resolver class Resolver - + # Keeps all information about view path and builds virtual path. class Path < String attr_reader :name, :prefix, :partial, :virtual alias_method :partial?, :partial @@ -180,7 +180,35 @@ module ActionView end end - # A resolver that loads files from the filesystem. + # A resolver that loads files from the filesystem. It allows to set your own + # resolving pattern. Such pattern can be a glob string supported by some variables. + # + # ==== Examples + # + # Default pattern, loads views the same way as previous versions of rails, eg. when you're + # looking for `users/new` it will produce query glob: `users/new{.{en},}{.{html,js},}{.{erb,haml,rjs},}` + # + # FileSystemResolver.new("/path/to/views", ":prefix/:action{.:locale,}{.:formats,}{.:handlers,}") + # + # This one allows you to keep files with different formats in seperated subdirectories, + # eg. `users/new.html` will be loaded from `users/html/new.erb` or `users/new.html.erb`, + # `users/new.js` from `users/js/new.erb` or `users/new.js.erb`, etc. + # + # FileSystemResolver.new("/path/to/views", ":prefix/{:formats/,}:action{.:locale,}{.:formats,}{.:handlers,}") + # + # If you don't specify pattern then the default will be used. + # + # ==== Pattern format and variables + # + # Pattern have to be a valid glob string, and it allows you to use the + # following variables: + # + # * :prefix - usualy the controller path + # * :action - name of the action + # * :locale - possible locale versions + # * :formats - possible file formats + # * :handlers - possible handlers + # class FileSystemResolver < PathResolver def initialize(path, pattern=nil) raise ArgumentError, "path already is a Resolver class" if path.is_a?(Resolver) -- cgit v1.2.3 From 1413c9b1d51414b72c4efb8583a3acae04a481dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 19 Mar 2011 16:06:50 -0700 Subject: Improved resolver docs a bit --- actionpack/lib/action_view/template/resolver.rb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_view/template/resolver.rb b/actionpack/lib/action_view/template/resolver.rb index f9c52e3228..6c1063592f 100644 --- a/actionpack/lib/action_view/template/resolver.rb +++ b/actionpack/lib/action_view/template/resolver.rb @@ -198,6 +198,14 @@ module ActionView # # If you don't specify pattern then the default will be used. # + # In order to use any of the customized resolvers above in a Rails application, you just need + # to configure ActionController::Base.view_paths in an initializer, for example: + # + # ActionController::Base.view_paths = FileSystemResolver.new( + # Rails.root.join("app/views"), + # ":prefix{/:locale}/:action{.:formats,}{.:handlers,}" + # ) + # # ==== Pattern format and variables # # Pattern have to be a valid glob string, and it allows you to use the @@ -206,8 +214,8 @@ module ActionView # * :prefix - usualy the controller path # * :action - name of the action # * :locale - possible locale versions - # * :formats - possible file formats - # * :handlers - possible handlers + # * :formats - possible request formats (for example html, json, xml...) + # * :handlers - possible handlers (for example erb, haml, builder...) # class FileSystemResolver < PathResolver def initialize(path, pattern=nil) -- cgit v1.2.3 From 6c309f04c9e2538cc61d6a1c753da3c6cc91b51f Mon Sep 17 00:00:00 2001 From: Gabriel Horner Date: Sat, 19 Mar 2011 19:53:36 -0400 Subject: Beef up Rails::Railtie::Configuration docs --- railties/lib/rails/railtie/configuration.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/railties/lib/rails/railtie/configuration.rb b/railties/lib/rails/railtie/configuration.rb index e4368866a1..2c7b5bc048 100644 --- a/railties/lib/rails/railtie/configuration.rb +++ b/railties/lib/rails/railtie/configuration.rb @@ -31,26 +31,34 @@ module Rails app_generators(&block) end + # First configurable block to run. Called before any initializers are run. def before_configuration(&block) ActiveSupport.on_load(:before_configuration, :yield => true, &block) end + # Third configurable block to run. Does not run if config.cache_classes + # set to false. def before_eager_load(&block) ActiveSupport.on_load(:before_eager_load, :yield => true, &block) end + # Second configurable block to run. Called before frameworks initialize. def before_initialize(&block) ActiveSupport.on_load(:before_initialize, :yield => true, &block) end + # Last configurable block to run. Called after frameworks initialize. def after_initialize(&block) ActiveSupport.on_load(:after_initialize, :yield => true, &block) end + # Array of callbacks defined by #to_prepare. def to_prepare_blocks @@to_prepare_blocks ||= [] end + # Defines generic callbacks to run before #after_initialize. Useful for + # Rails::Railtie subclasses. def to_prepare(&blk) to_prepare_blocks << blk if blk end -- cgit v1.2.3 From 55bf087da1d93eda93f9b9632fc363fd4788bed2 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Mon, 21 Mar 2011 10:00:30 -0700 Subject: SJIS is an alias to Windows-31J in ruby trunk. Use SHIFT_JIS for this test --- actionpack/test/template/render_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actionpack/test/template/render_test.rb b/actionpack/test/template/render_test.rb index 034fb6c210..dd86bfed04 100644 --- a/actionpack/test/template/render_test.rb +++ b/actionpack/test/template/render_test.rb @@ -381,7 +381,7 @@ class LazyViewRenderTest < ActiveSupport::TestCase end def test_render_utf8_template_with_incompatible_external_encoding - with_external_encoding Encoding::SJIS do + with_external_encoding Encoding::SHIFT_JIS do begin result = @view.render(:file => "test/utf8.html.erb", :layouts => "layouts/yield") flunk 'Should have raised incompatible encoding error' @@ -392,7 +392,7 @@ class LazyViewRenderTest < ActiveSupport::TestCase end def test_render_utf8_template_with_partial_with_incompatible_encoding - with_external_encoding Encoding::SJIS do + with_external_encoding Encoding::SHIFT_JIS do begin result = @view.render(:file => "test/utf8_magic_with_bare_partial.html.erb", :layouts => "layouts/yield") flunk 'Should have raised incompatible encoding error' -- cgit v1.2.3 From 96b9fc44000e5f40ba463ff0e893db8c4fd33b85 Mon Sep 17 00:00:00 2001 From: Iain Hecker Date: Sat, 19 Mar 2011 21:53:49 +0100 Subject: Reapply extensions when using except and only --- activerecord/lib/active_record/relation/spawn_methods.rb | 6 ++++++ activerecord/test/cases/relations_test.rb | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/activerecord/lib/active_record/relation/spawn_methods.rb b/activerecord/lib/active_record/relation/spawn_methods.rb index 4150e36a9a..128e0fbd86 100644 --- a/activerecord/lib/active_record/relation/spawn_methods.rb +++ b/activerecord/lib/active_record/relation/spawn_methods.rb @@ -79,6 +79,9 @@ module ActiveRecord result.send(:"#{method}_value=", send(:"#{method}_value")) end + # Apply scope extension modules + result.send(:apply_modules, extensions) + result end @@ -100,6 +103,9 @@ module ActiveRecord result.send(:"#{method}_value=", send(:"#{method}_value")) end + # Apply scope extension modules + result.send(:apply_modules, extensions) + result end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 00b7c26080..f0e483b6bc 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -797,6 +797,10 @@ class RelationTest < ActiveRecord::TestCase assert_equal Post.all, all_posts.all end + def test_extensions_with_except + assert_equal 2, Topic.named_extension.order(:author_name).except(:order).two + end + def test_only relation = Post.where(:author_id => 1).order('id ASC').limit(1) assert_equal [posts(:welcome)], relation.all @@ -808,6 +812,10 @@ class RelationTest < ActiveRecord::TestCase assert_equal Post.limit(1).all.first, all_posts.first end + def test_extensions_with_only + assert_equal 2, Topic.named_extension.order(:author_name).only(:order).two + end + def test_anonymous_extension relation = Post.where(:author_id => 1).order('id ASC').extending do def author -- cgit v1.2.3 From ea8fcfb729c0bca528d2e8522585cd6d0e98ebc7 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Mon, 21 Mar 2011 11:19:14 -0700 Subject: schemas set by set_table_name are respected by the mysql adapter. [#5322 state:resolved] --- .../connection_adapters/mysql_adapter.rb | 18 +++++++++-- .../test/cases/adapters/mysql/schema_test.rb | 36 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 activerecord/test/cases/adapters/mysql/schema_test.rb diff --git a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb index 368c5b2023..e1186209d3 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb @@ -504,14 +504,28 @@ module ActiveRecord show_variable 'collation_database' end - def tables(name = nil) #:nodoc: + def tables(name = nil, database = nil) #:nodoc: tables = [] - result = execute("SHOW TABLES", name) + result = execute(["SHOW TABLES", database].compact.join(' IN '), name) result.each { |field| tables << field[0] } result.free tables end + def table_exists?(name) + return true if super + + name = name.to_s + schema, table = name.split('.', 2) + + unless table # A table was provided without a schema + table = schema + schema = nil + end + + tables(nil, schema).include? table + end + def drop_table(table_name, options = {}) super(table_name, options) end diff --git a/activerecord/test/cases/adapters/mysql/schema_test.rb b/activerecord/test/cases/adapters/mysql/schema_test.rb new file mode 100644 index 0000000000..c6c1d1dad5 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/schema_test.rb @@ -0,0 +1,36 @@ +require "cases/helper" +require 'models/post' +require 'models/comment' + +module ActiveRecord + module ConnectionAdapters + class MysqlSchemaTest < ActiveRecord::TestCase + fixtures :posts + + def setup + @connection = ActiveRecord::Base.connection + db = Post.connection_pool.spec.config[:database] + table = Post.table_name + @db_name = db + + @omgpost = Class.new(Post) do + set_table_name "#{db}.#{table}" + def self.name; 'Post'; end + end + end + + def test_schema + assert @omgpost.find(:first) + end + + def test_table_exists? + name = @omgpost.table_name + assert @connection.table_exists?(name), "#{name} table should exist" + end + + def test_table_exists_wrong_schema + assert(!@connection.table_exists?("#{@db_name}.zomg"), "table should not exist") + end + end if current_adapter?(:MysqlAdapter) + end +end -- cgit v1.2.3 From 88636f7195fc936421392740f980d17ebe77d701 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Mon, 21 Mar 2011 14:48:43 -0700 Subject: escaping binary data encoding when inserting to sqlite3. Thanks Naruse! [#6559 state:resolved] --- .../connection_adapters/sqlite3_adapter.rb | 8 ++++++++ .../test/cases/adapters/sqlite/sqlite_adapter_test.rb | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index c2cd9e8d5e..c3a7b039ff 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -34,6 +34,14 @@ module ActiveRecord module ConnectionAdapters #:nodoc: class SQLite3Adapter < SQLiteAdapter # :nodoc: + def quote(value, column = nil) + if value.kind_of?(String) && column && column.type == :binary && column.class.respond_to?(:string_to_binary) + s = column.class.string_to_binary(value).unpack("H*")[0] + "x'#{s}'" + else + super + end + end # Returns the current database encoding format as a string, eg: 'UTF-8' def encoding diff --git a/activerecord/test/cases/adapters/sqlite/sqlite_adapter_test.rb b/activerecord/test/cases/adapters/sqlite/sqlite_adapter_test.rb index ce0b2f5f5b..d1fc470907 100644 --- a/activerecord/test/cases/adapters/sqlite/sqlite_adapter_test.rb +++ b/activerecord/test/cases/adapters/sqlite/sqlite_adapter_test.rb @@ -1,8 +1,13 @@ +# encoding: utf-8 require "cases/helper" +require 'models/binary' module ActiveRecord module ConnectionAdapters class SQLiteAdapterTest < ActiveRecord::TestCase + class DualEncoding < ActiveRecord::Base + end + def setup @ctx = Base.sqlite3_connection :database => ':memory:', :adapter => 'sqlite3', @@ -15,6 +20,20 @@ module ActiveRecord eosql end + def test_quote_binary_column_escapes_it + DualEncoding.connection.execute(<<-eosql) + CREATE TABLE dual_encodings ( + id integer PRIMARY KEY AUTOINCREMENT, + name string, + data binary + ) + eosql + str = "\x80".force_encoding("ASCII-8BIT") + binary = DualEncoding.new :name => 'いただきます!', :data => str + binary.save! + assert_equal str, binary.data + end + def test_execute @ctx.execute "INSERT INTO items (number) VALUES (10)" records = @ctx.execute "SELECT * FROM items" -- cgit v1.2.3 From 3378d77b044306d6e92f188932d435483af569e3 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Mon, 21 Mar 2011 16:32:13 -0700 Subject: use prepared statements to fetch the last insert id --- .../lib/active_record/connection_adapters/postgresql_adapter.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 576450bc3a..5a830a50fb 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -453,7 +453,7 @@ module ActiveRecord # If a pk is given, fallback to default sequence name. # Don't fetch last insert id for a table without a pk. if pk && sequence_name ||= default_sequence_name(table, pk) - last_insert_id(table, sequence_name) + last_insert_id(sequence_name) end end end @@ -1038,8 +1038,9 @@ module ActiveRecord end # Returns the current ID of a table's sequence. - def last_insert_id(table, sequence_name) #:nodoc: - Integer(select_value("SELECT currval('#{sequence_name}')")) + def last_insert_id(sequence_name) #:nodoc: + r = exec_query("SELECT currval($1)", 'SQL', [[nil, sequence_name]]) + Integer(r.rows.first.first) end # Executes a SELECT query and returns the results, performing any data type -- cgit v1.2.3 From c24e5548fd372e4d0058ab417230ebc2fa3bebde Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Tue, 22 Mar 2011 12:16:13 +1100 Subject: Querying guide: mention that performing a where on an relation that contains an includes statement will generate a LEFT OUTER JOIN rather than an INNER JOIN or another query --- railties/guides/source/active_record_querying.textile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/railties/guides/source/active_record_querying.textile b/railties/guides/source/active_record_querying.textile index ed3968e226..e0fab46db9 100644 --- a/railties/guides/source/active_record_querying.textile +++ b/railties/guides/source/active_record_querying.textile @@ -747,6 +747,20 @@ h4. Specifying Conditions on Eager Loaded Associations Even though Active Record lets you specify conditions on the eager loaded associations just like +joins+, the recommended way is to use "joins":#joining-tables instead. +However if you must do this, you may use +where+ as you would normally. + + +Post.includes(:comments).where("comments.visible", true) + + +This would generate a query which contains a +LEFT OUTER JOIN+ whereas the +joins+ method would generate one using the +INNER JOIN+ function instead. + + + SELECT "posts"."id" AS t0_r0, ... "comments"."updated_at" AS t1_r5 FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE (comments.visible) + + +If in this case there were no comments for any posts, all the posts would still be loaded. By using +joins+ (an INNER JOIN), the join conditions *must* match, otherwise no records will be returned. + h3. Scopes Scoping allows you to specify commonly-used ARel queries which can be referenced as method calls on the association objects or models. With these scopes, you can use every method previously covered such as +where+, +joins+ and +includes+. All scope methods will return an +ActiveRecord::Relation+ object which will allow for further methods (such as other scopes) to be called on it. -- cgit v1.2.3 From 5a44951186b410c3a79685f8aeaa926d93a6aab0 Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Tue, 22 Mar 2011 12:16:15 +1100 Subject: Querying guide: mention that performing a where on an relation that contains an includes statement will generate a LEFT OUTER JOIN rather than an INNER JOIN or another query --- railties/guides/source/active_record_querying.textile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/railties/guides/source/active_record_querying.textile b/railties/guides/source/active_record_querying.textile index e0fab46db9..009d541106 100644 --- a/railties/guides/source/active_record_querying.textile +++ b/railties/guides/source/active_record_querying.textile @@ -759,7 +759,9 @@ This would generate a query which contains a +LEFT OUTER JOIN+ whereas the +join SELECT "posts"."id" AS t0_r0, ... "comments"."updated_at" AS t1_r5 FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE (comments.visible) -If in this case there were no comments for any posts, all the posts would still be loaded. By using +joins+ (an INNER JOIN), the join conditions *must* match, otherwise no records will be returned. +If there was no +where+ condition, this would generate the normal set of two queries. + +If, in the case of this +includes+ query, there were no comments for any posts, all the posts would still be loaded. By using +joins+ (an INNER JOIN), the join conditions *must* match, otherwise no records will be returned. h3. Scopes -- cgit v1.2.3 From 15d3cc21f41af4b3caaae4f3586481effa77058f Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Tue, 22 Mar 2011 09:18:01 -0700 Subject: pushing id insertion and prefetch primary keys down to Relation#insert --- .../connection_adapters/abstract/database_statements.rb | 4 ++++ .../active_record/connection_adapters/sqlite_adapter.rb | 4 ++++ activerecord/lib/active_record/persistence.rb | 10 +--------- activerecord/lib/active_record/relation.rb | 17 ++++++++++++++--- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb index 5c1ce173c8..756c221fad 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -272,6 +272,10 @@ module ActiveRecord execute "INSERT INTO #{quote_table_name(table_name)} (#{key_list.join(', ')}) VALUES (#{value_list.join(', ')})", 'Fixture Insert' end + def null_insert_value + Arel.sql 'DEFAULT' + end + def empty_insert_statement_value "VALUES(DEFAULT)" end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb index 9ee6b88ab6..ae61d6ce94 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb @@ -336,6 +336,10 @@ module ActiveRecord alter_table(table_name, :rename => {column_name.to_s => new_column_name.to_s}) end + def null_insert_value + Arel.sql 'NULL' + end + def empty_insert_statement_value "VALUES(NULL)" end diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index df7b22080c..17a64b6e86 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -270,17 +270,9 @@ module ActiveRecord # Creates a record with values matching those of the instance attributes # and returns its id. def create - if id.nil? && connection.prefetch_primary_key?(self.class.table_name) - self.id = connection.next_sequence_value(self.class.sequence_name) - end - attributes_values = arel_attributes_values(!id.nil?) - new_id = if attributes_values.empty? - self.class.unscoped.insert connection.empty_insert_statement_value - else - self.class.unscoped.insert attributes_values - end + new_id = self.class.unscoped.insert attributes_values self.id ||= new_id diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 371403f510..8e545f9cad 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -30,15 +30,26 @@ module ActiveRecord end def insert(values) - im = arel.compile_insert values - im.into @table - primary_key_value = nil if primary_key && Hash === values primary_key_value = values[values.keys.find { |k| k.name == primary_key }] + + if !primary_key_value && connection.prefetch_primary_key?(klass.table_name) + primary_key_value = connection.next_sequence_value(klass.sequence_name) + values[klass.arel_table[klass.primary_key]] = primary_key_value + end + end + + im = arel.create_insert + im.into @table + + if values.empty? # empty insert + im.values = im.create_values [connection.null_insert_value], [] + else + im.insert values end @klass.connection.insert( -- cgit v1.2.3 From 2ddfdba9a0dab7d8499c3ad0d13583bddbac4f69 Mon Sep 17 00:00:00 2001 From: Prem Sichanugrist Date: Tue, 22 Mar 2011 22:19:31 +0700 Subject: Do not show optional (.:format) block for wildcard route [#6605 state:resolved] This will make the output of `rake routes` to be correctly match to the behavior of the application, as the regular expression used to match the path is greedy and won't capture the format part by default --- actionpack/lib/action_dispatch/routing/mapper.rb | 2 +- actionpack/test/action_dispatch/routing/mapper_test.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index cc6b8aa82d..1dba1d416c 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -107,7 +107,7 @@ module ActionDispatch if @options[:format] == false @options.delete(:format) path - elsif path.include?(":format") || path.end_with?('/') + elsif path.include?(":format") || path.end_with?('/') || path.match(/^\/?\*/) path else "#{path}(.:format)" diff --git a/actionpack/test/action_dispatch/routing/mapper_test.rb b/actionpack/test/action_dispatch/routing/mapper_test.rb index 9966234f1b..e21b271907 100644 --- a/actionpack/test/action_dispatch/routing/mapper_test.rb +++ b/actionpack/test/action_dispatch/routing/mapper_test.rb @@ -46,6 +46,13 @@ module ActionDispatch mapper.match '/one/two/', :to => 'posts#index', :as => :main assert_equal '/one/two(.:format)', fakeset.conditions.first[:path_info] end + + def test_map_wildcard + fakeset = FakeSet.new + mapper = Mapper.new fakeset + mapper.match '/*path', :to => 'pages#show', :as => :page + assert_equal '/*path', fakeset.conditions.first[:path_info] + end end end end -- cgit v1.2.3 From ed97c395178391c9083b20121cdd6606a9e92d14 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Tue, 22 Mar 2011 10:10:33 -0700 Subject: adding missing require --- activerecord/test/cases/associations/join_model_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index f54d69e7fa..1f95b31497 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require 'models/tag' require 'models/tagging' require 'models/post' +require 'models/rating' require 'models/item' require 'models/comment' require 'models/author' -- cgit v1.2.3 From e06c44800284696868a1e6a273abefec3047312f Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Tue, 22 Mar 2011 19:35:37 +0100 Subject: Install rb-fsevent gem if ENV[RB_FSEVENT] is set --- Gemfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Gemfile b/Gemfile index 7d8949409c..879598c6db 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,9 @@ platforms :mri_19 do end platforms :ruby do + if ENV["RB_FSEVENT"] + gem 'rb-fsevent' + end gem 'json' gem 'yajl-ruby' gem "nokogiri", ">= 1.4.4" -- cgit v1.2.3 From 137ff0402a05e39c85852b148443750d5d222b66 Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Wed, 23 Mar 2011 08:33:06 +1100 Subject: Caching guide: Capitalize 'Note' so that it's rendered as a proper note --- railties/guides/source/caching_with_rails.textile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/guides/source/caching_with_rails.textile b/railties/guides/source/caching_with_rails.textile index 1b5ec40d16..297ba2d661 100644 --- a/railties/guides/source/caching_with_rails.textile +++ b/railties/guides/source/caching_with_rails.textile @@ -65,7 +65,7 @@ end If you want a more complicated expiration scheme, you can use cache sweepers to expire cached objects when things change. This is covered in the section on Sweepers. -Note: Page caching ignores all parameters. For example +/products?page=1+ will be written out to the filesystem as +products.html+ with no reference to the +page+ parameter. Thus, if someone requests +/products?page=2+ later, they will get the cached first page. Be careful when page caching GET parameters in the URL! +NOTE: Page caching ignores all parameters. For example +/products?page=1+ will be written out to the filesystem as +products.html+ with no reference to the +page+ parameter. Thus, if someone requests +/products?page=2+ later, they will get the cached first page. Be careful when page caching GET parameters in the URL! INFO: Page caching runs in an after filter. Thus, invalid requests won't generate spurious cache entries as long as you halt them. Typically, a redirection in some before filter that checks request preconditions does the job. -- cgit v1.2.3 From ee8ca49414271026575dab70947b446ac6e4b51a Mon Sep 17 00:00:00 2001 From: Dr Nic Williams Date: Tue, 22 Mar 2011 15:11:12 -0700 Subject: Update 'Getting Started on Windows' tip to go to one-click Rails Installer --- railties/guides/source/getting_started.textile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/guides/source/getting_started.textile b/railties/guides/source/getting_started.textile index e94bdc97b0..0661549644 100644 --- a/railties/guides/source/getting_started.textile +++ b/railties/guides/source/getting_started.textile @@ -149,7 +149,7 @@ Usually run this as the root user: # gem install rails -TIP. If you're working on Windows, you should be aware that the vast majority of Rails development is done in Unix environments. While Ruby and Rails themselves install easily using for example "Ruby Installer":http://rubyinstaller.org/, the supporting ecosystem often assumes you are able to build C-based rubygems and work in a command window. If at all possible, we suggest that you install a Linux virtual machine and use that for Rails development, instead of using Windows. +TIP. If you're working on Windows, you can quickly install Ruby and Rails with "Rails Installer":http://railsinstaller.org. h4. Creating the Blog Application -- cgit v1.2.3 From da6c7bd4b411105e7556ff5015e3c9f6ab1d26fe Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Fri, 18 Mar 2011 13:35:57 -0300 Subject: Do not in place modify what table_name returns --- activemodel/lib/active_model/attribute_methods.rb | 2 +- activemodel/test/cases/attribute_methods_test.rb | 11 ----------- activerecord/lib/active_record/base.rb | 2 +- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index 73468afe55..f6648ea43e 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -107,7 +107,7 @@ module ActiveModel sing.send :define_method, name, &block else value = value.to_s if value - sing.send(:define_method, name) { value && value.dup } + sing.send(:define_method, name) { value } end end diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb index e814666d99..022c6716bd 100644 --- a/activemodel/test/cases/attribute_methods_test.rb +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -9,10 +9,6 @@ class ModelWithAttributes define_method(:bar) do 'original bar' end - - define_method(:zomg) do - 'original zomg' - end end def attributes @@ -102,13 +98,6 @@ class AttributeMethodsTest < ActiveModel::TestCase assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') end - def test_defined_methods_always_return_duped_string - ModelWithAttributes.define_attr_method(:zomg, 'lol') - assert_equal 'lol', ModelWithAttributes.zomg - ModelWithAttributes.zomg << 'bbq' - assert_equal 'lol', ModelWithAttributes.zomg - end - test '#define_attr_method generates attribute method' do ModelWithAttributes.define_attr_method(:bar, 'bar') diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 7f506faeee..b778b0c0f0 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -995,7 +995,7 @@ module ActiveRecord #:nodoc: if parent < ActiveRecord::Base && !parent.abstract_class? contained = parent.table_name contained = contained.singularize if parent.pluralize_table_names - contained << '_' + contained += '_' end "#{full_table_name_prefix}#{contained}#{undecorated_table_name(name)}#{table_name_suffix}" else -- cgit v1.2.3 From baa237c974fee8023dd704a4efb418ff0e963de0 Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Mon, 21 Mar 2011 21:36:05 -0300 Subject: Allow to read and write AR attributes with non valid identifiers --- activerecord/lib/active_record/attribute_methods/read.rb | 5 ++++- activerecord/lib/active_record/attribute_methods/write.rb | 4 +++- activerecord/test/cases/base_test.rb | 12 ++++++++++++ activerecord/test/schema/schema.rb | 3 +++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index ab86d8bad1..affe2d7f53 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -70,7 +70,10 @@ module ActiveRecord if cache_attribute?(attr_name) access_code = "@attributes_cache['#{attr_name}'] ||= (#{access_code})" end - generated_attribute_methods.module_eval("def _#{symbol}; #{access_code}; end; alias #{symbol} _#{symbol}", __FILE__, __LINE__) + generated_attribute_methods.module_eval do + define_method("_#{symbol}") { eval(access_code) } + alias_method(symbol, "_#{symbol}") + end end end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index 6a593a7e0e..832f2ed408 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -10,7 +10,9 @@ module ActiveRecord module ClassMethods protected def define_method_attribute=(attr_name) - generated_attribute_methods.module_eval("def #{attr_name}=(new_value); write_attribute('#{attr_name}', new_value); end", __FILE__, __LINE__) + generated_attribute_methods.send(:define_method, "#{attr_name}=") do |new_value| + write_attribute(attr_name, new_value) + end end end diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index d03fc68a11..fba7af741d 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -45,6 +45,8 @@ class ReadonlyTitlePost < Post attr_readonly :title end +class Weird < ActiveRecord::Base; end + class Boolean < ActiveRecord::Base; end class BasicsTest < ActiveRecord::TestCase @@ -477,6 +479,16 @@ class BasicsTest < ActiveRecord::TestCase assert_equal "changed", post.body end + def test_non_valid_identifier_column_name + weird = Weird.create('a$b' => 'value') + weird.reload + assert_equal 'value', weird.send('a$b') + + weird.update_attribute('a$b', 'value2') + weird.reload + assert_equal 'value2', weird.send('a$b') + end + def test_multiparameter_attributes_on_date attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "6", "last_read(3i)" => "24" } topic = Topic.find(1) diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index bdadd0698b..362475de36 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -690,6 +690,9 @@ ActiveRecord::Schema.define do t.integer :molecule_id t.string :name end + create_table :weirds, :force => true do |t| + t.string 'a$b' + end except 'SQLite' do # fk_test_has_fk should be before fk_test_has_pk -- cgit v1.2.3 From 450f7cf01b855b536416fc048a92c4309da2492e Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Tue, 22 Mar 2011 20:11:36 -0300 Subject: use class_eval with a string when it's possible --- activemodel/lib/active_model/attribute_methods.rb | 10 ++++++++-- activerecord/lib/active_record/attribute_methods/read.rb | 10 +++++++--- activerecord/lib/active_record/attribute_methods/write.rb | 8 ++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index f6648ea43e..be55581c66 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -106,8 +106,14 @@ module ActiveModel if block_given? sing.send :define_method, name, &block else - value = value.to_s if value - sing.send(:define_method, name) { value } + if name =~ /^[a-zA-Z_]\w*[!?=]?$/ + sing.class_eval <<-eorb, __FILE__, __LINE__ + 1 + def #{name}; #{value.nil? ? 'nil' : value.to_s.inspect}; end + eorb + else + value = value.to_s if value + sing.send(:define_method, name) { value } + end end end diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index affe2d7f53..69d5cd83f1 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -70,9 +70,13 @@ module ActiveRecord if cache_attribute?(attr_name) access_code = "@attributes_cache['#{attr_name}'] ||= (#{access_code})" end - generated_attribute_methods.module_eval do - define_method("_#{symbol}") { eval(access_code) } - alias_method(symbol, "_#{symbol}") + if symbol =~ /^[a-zA-Z_]\w*[!?=]?$/ + generated_attribute_methods.module_eval("def _#{symbol}; #{access_code}; end; alias #{symbol} _#{symbol}", __FILE__, __LINE__) + else + generated_attribute_methods.module_eval do + define_method("_#{symbol}") { eval(access_code) } + alias_method(symbol, "_#{symbol}") + end end end end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index 832f2ed408..3c4dab304e 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -10,8 +10,12 @@ module ActiveRecord module ClassMethods protected def define_method_attribute=(attr_name) - generated_attribute_methods.send(:define_method, "#{attr_name}=") do |new_value| - write_attribute(attr_name, new_value) + if attr_name =~ /^[a-zA-Z_]\w*[!?=]?$/ + generated_attribute_methods.module_eval("def #{attr_name}=(new_value); write_attribute('#{attr_name}', new_value); end", __FILE__, __LINE__) + else + generated_attribute_methods.send(:define_method, "#{attr_name}=") do |new_value| + write_attribute(attr_name, new_value) + end end end end -- cgit v1.2.3 From 7004434ad85901215e1cb4367b2b8b1e7f9063c7 Mon Sep 17 00:00:00 2001 From: Diego Carrion Date: Wed, 9 Mar 2011 20:27:29 -0300 Subject: test json decoding with time parsing disabled with all backends and respect ActiveSupport.parse_json_times when converting to yaml Signed-off-by: Santiago Pastorino --- activesupport/lib/active_support/json/backends/yaml.rb | 8 +++++--- activesupport/test/json/decoding_test.rb | 6 ++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/activesupport/lib/active_support/json/backends/yaml.rb b/activesupport/lib/active_support/json/backends/yaml.rb index 077eda548a..f9d8fd8e83 100644 --- a/activesupport/lib/active_support/json/backends/yaml.rb +++ b/activesupport/lib/active_support/json/backends/yaml.rb @@ -70,9 +70,11 @@ module ActiveSupport left_pos.each_with_index do |left, i| scanner.pos = left.succ chunk = scanner.peek(right_pos[i] - scanner.pos + 1) - # overwrite the quotes found around the dates with spaces - while times.size > 0 && times[0] <= right_pos[i] - chunk.insert(times.shift - scanner.pos - 1, '! ') + if ActiveSupport.parse_json_times + # overwrite the quotes found around the dates with spaces + while times.size > 0 && times[0] <= right_pos[i] + chunk.insert(times.shift - scanner.pos - 1, '! ') + end end chunk.gsub!(/\\([\\\/]|u[[:xdigit:]]{4})/) do ustr = $1 diff --git a/activesupport/test/json/decoding_test.rb b/activesupport/test/json/decoding_test.rb index 24d9f88c09..6b890c7d46 100644 --- a/activesupport/test/json/decoding_test.rb +++ b/activesupport/test/json/decoding_test.rb @@ -72,13 +72,11 @@ class TestJSONDecoding < ActiveSupport::TestCase end end end - end - if backends.include?("JSONGem") - test "json decodes time json with time parsing disabled" do + test "json decodes time json with time parsing disabled with the #{backend} backend" do ActiveSupport.parse_json_times = false expected = {"a" => "2007-01-01 01:12:34 Z"} - ActiveSupport::JSON.with_backend "JSONGem" do + ActiveSupport::JSON.with_backend backend do assert_equal expected, ActiveSupport::JSON.decode(%({"a": "2007-01-01 01:12:34 Z"})) end end -- cgit v1.2.3 From a1edbf720620c672566a1681572c3845452ca51a Mon Sep 17 00:00:00 2001 From: Diego Carrion Date: Wed, 9 Mar 2011 21:45:45 -0300 Subject: parse dates to yaml in json arrays Signed-off-by: Santiago Pastorino --- activesupport/lib/active_support/json/backends/yaml.rb | 4 ++-- activesupport/test/json/decoding_test.rb | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/activesupport/lib/active_support/json/backends/yaml.rb b/activesupport/lib/active_support/json/backends/yaml.rb index f9d8fd8e83..e25e29d36b 100644 --- a/activesupport/lib/active_support/json/backends/yaml.rb +++ b/activesupport/lib/active_support/json/backends/yaml.rb @@ -29,7 +29,7 @@ module ActiveSupport def convert_json_to_yaml(json) #:nodoc: require 'strscan' unless defined? ::StringScanner scanner, quoting, marks, pos, times = ::StringScanner.new(json), false, [], nil, [] - while scanner.scan_until(/(\\['"]|['":,\\]|\\.)/) + while scanner.scan_until(/(\\['"]|['":,\\]|\\.|[\]])/) case char = scanner[1] when '"', "'" if !quoting @@ -43,7 +43,7 @@ module ActiveSupport end quoting = false end - when ":","," + when ":",",", "]" marks << scanner.pos - 1 unless quoting when "\\" scanner.skip(/\\/) diff --git a/activesupport/test/json/decoding_test.rb b/activesupport/test/json/decoding_test.rb index 6b890c7d46..88cf97de7e 100644 --- a/activesupport/test/json/decoding_test.rb +++ b/activesupport/test/json/decoding_test.rb @@ -17,6 +17,8 @@ class TestJSONDecoding < ActiveSupport::TestCase %({"matzue": "松江", "asakusa": "浅草"}) => {"matzue" => "松江", "asakusa" => "浅草"}, %({"a": "2007-01-01"}) => {'a' => Date.new(2007, 1, 1)}, %({"a": "2007-01-01 01:12:34 Z"}) => {'a' => Time.utc(2007, 1, 1, 1, 12, 34)}, + %(["2007-01-01 01:12:34 Z"]) => [Time.utc(2007, 1, 1, 1, 12, 34)], + %(["2007-01-01 01:12:34 Z", "2007-01-01 01:12:35 Z"]) => [Time.utc(2007, 1, 1, 1, 12, 34), Time.utc(2007, 1, 1, 1, 12, 35)], # no time zone %({"a": "2007-01-01 01:12:34"}) => {'a' => "2007-01-01 01:12:34"}, # invalid date -- cgit v1.2.3 From 3a7c7dc73d6111e7c821a81d9c56024dd35769a5 Mon Sep 17 00:00:00 2001 From: Aditya Sanghi Date: Wed, 23 Mar 2011 18:06:22 +0100 Subject: Fix test for prepend giving a false positive. [#5716 state:resolved] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: José Valim --- railties/lib/rails/generators/rails/plugin_new/plugin_new_generator.rb | 2 +- railties/test/generators/shared_generator_tests.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/railties/lib/rails/generators/rails/plugin_new/plugin_new_generator.rb b/railties/lib/rails/generators/rails/plugin_new/plugin_new_generator.rb index c373ca5e67..3cf8410d1e 100644 --- a/railties/lib/rails/generators/rails/plugin_new/plugin_new_generator.rb +++ b/railties/lib/rails/generators/rails/plugin_new/plugin_new_generator.rb @@ -62,7 +62,7 @@ task :default => :test end def generate_test_dummy(force = false) - opts = (options || {}).slice(:skip_active_record, :skip_javascript, :database, :javascript) + opts = (options || {}).slice(:skip_active_record, :skip_javascript, :database, :javascript, :quiet, :pretend, :force, :skip) opts[:force] = force invoke Rails::Generators::AppGenerator, diff --git a/railties/test/generators/shared_generator_tests.rb b/railties/test/generators/shared_generator_tests.rb index 592da39ccd..c9c5d2fad2 100644 --- a/railties/test/generators/shared_generator_tests.rb +++ b/railties/test/generators/shared_generator_tests.rb @@ -26,8 +26,7 @@ module SharedGeneratorTests def test_plugin_new_generate_pretend run_generator ["testapp", "--pretend"] - - default_files.each{ |path| assert_no_file path } + default_files.each{ |path| assert_no_file File.join("testapp",path) } end def test_invalid_database_option_raises_an_error -- cgit v1.2.3 From 8ee81d21fb103be31adb8e0dcde8ed8f5e90a798 Mon Sep 17 00:00:00 2001 From: Murray Steele Date: Fri, 11 Mar 2011 11:41:30 +0000 Subject: Failing test case to show that habtm join table contents are removed when a model is destroyed but the destruction is blocked by a before_destroy. --- .../test/cases/habtm_destroy_order_test.rb | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/activerecord/test/cases/habtm_destroy_order_test.rb b/activerecord/test/cases/habtm_destroy_order_test.rb index 15598392e2..f2b91d977e 100644 --- a/activerecord/test/cases/habtm_destroy_order_test.rb +++ b/activerecord/test/cases/habtm_destroy_order_test.rb @@ -13,5 +13,39 @@ class HabtmDestroyOrderTest < ActiveRecord::TestCase sicp.destroy end end + assert !sicp.destroyed? + end + + test "not destroying a student with lessons leaves student<=>lesson association intact" do + # test a normal before_destroy doesn't destroy the habtm joins + begin + sicp = Lesson.new(:name => "SICP") + ben = Student.new(:name => "Ben Bitdiddle") + # add a before destroy to student + Student.class_eval do + before_destroy do + raise ActiveRecord::Rollback unless lessons.empty? + end + end + ben.lessons << sicp + ben.save! + ben.destroy + assert !ben.reload.lessons.empty? + ensure + # get rid of it so Student is still like it was + Student.reset_callbacks(:destroy) + end + end + + test "not destroying a lesson with students leaves student<=>lesson association intact" do + # test a more aggressive before_destroy doesn't destroy the habtm joins and still throws the exception + sicp = Lesson.new(:name => "SICP") + ben = Student.new(:name => "Ben Bitdiddle") + sicp.students << ben + sicp.save! + assert_raises LessonError do + sicp.destroy + end + assert !sicp.reload.students.empty? end end -- cgit v1.2.3 From 54c963c89b81cfc4fd7dcad6779e41c85d1180ce Mon Sep 17 00:00:00 2001 From: Murray Steele Date: Fri, 11 Mar 2011 12:02:49 +0000 Subject: Make clearing of HABTM join table contents happen in an after_destory callback. The old method of redefining destroy meant that clearing the HABTM join table would happen as long as the call to destroy succeeded. Which meant if there was a before_destroy that stopped the instance being destroyed using normal means (returning false, raising ActiveRecord::Rollback) rather than exceptional means the join table would be cleared even though the instance wasn't destroyed. Doing it in an after_destroy hook avoids this and has the advantage of happening inside the DB transaction too. --- .../builder/has_and_belongs_to_many.rb | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb index e40b32826a..4b48757da7 100644 --- a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb @@ -7,24 +7,24 @@ module ActiveRecord::Associations::Builder def build reflection = super check_validity(reflection) - redefine_destroy + define_after_destroy_method reflection end private - def redefine_destroy - # Don't use a before_destroy callback since users' before_destroy - # callbacks will be executed after the association is wiped out. + def define_after_destroy_method name = self.name - model.send(:include, Module.new { - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def destroy # def destroy - super # super - #{name}.clear # posts.clear - end # end - RUBY - }) + model.send(:class_eval, <<-eoruby, __FILE__, __LINE__ + 1) + def #{after_destroy_method_name} + association(#{name.to_sym.inspect}).delete_all + end + eoruby + model.after_destroy after_destroy_method_name + end + + def after_destroy_method_name + "has_and_belongs_to_many_after_destroy_for_#{name}" end # TODO: These checks should probably be moved into the Reflection, and we should not be -- cgit v1.2.3 From c5908a86492271ca55a6f54ccfd62b521cdc47c9 Mon Sep 17 00:00:00 2001 From: Adam Meehan Date: Tue, 1 Mar 2011 22:18:46 +1100 Subject: Fix before_type_cast for timezone aware attributes by caching converted value on write. Also remove read method reload arg on timezone attributes. --- .../attribute_methods/time_zone_conversion.rb | 9 ++++---- activerecord/test/cases/attribute_methods_test.rb | 26 +++++++++------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb index 76218d2a73..6aac96df6f 100644 --- a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb +++ b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb @@ -21,9 +21,9 @@ module ActiveRecord def define_method_attribute(attr_name) if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name]) method_body, line = <<-EOV, __LINE__ + 1 - def _#{attr_name}(reload = false) + def _#{attr_name} cached = @attributes_cache['#{attr_name}'] - return cached if cached && !reload + return cached if cached time = _read_attribute('#{attr_name}') @attributes_cache['#{attr_name}'] = time.acts_like?(:time) ? time.in_time_zone : time end @@ -41,12 +41,13 @@ module ActiveRecord if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name]) method_body, line = <<-EOV, __LINE__ + 1 def #{attr_name}=(original_time) - time = original_time.dup unless original_time.nil? + time = original_time unless time.acts_like?(:time) time = time.is_a?(String) ? Time.zone.parse(time) : time.to_time rescue time end time = time.in_time_zone rescue nil if time - write_attribute(:#{attr_name}, (time || original_time)) + write_attribute(:#{attr_name}, original_time) + @attributes_cache["#{attr_name}"] = time end EOV generated_attribute_methods.module_eval(method_body, __FILE__, line) diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index dfacf58da8..d8638ee776 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -118,22 +118,18 @@ class AttributeMethodsTest < ActiveRecord::TestCase end def test_read_attributes_before_type_cast_on_datetime - developer = Developer.find(:first) - if current_adapter?(:Mysql2Adapter, :OracleAdapter) - # Mysql2 and Oracle adapters keep the value in Time instance - assert_equal developer.created_at.to_s(:db), developer.attributes_before_type_cast["created_at"].to_s(:db) - else - assert_equal developer.created_at.to_s(:db), developer.attributes_before_type_cast["created_at"].to_s + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + + record.written_on = "345643456" + assert_equal "345643456", record.written_on_before_type_cast + assert_equal nil, record.written_on + + record.written_on = "2009-10-11 12:13:14" + assert_equal "2009-10-11 12:13:14", record.written_on_before_type_cast + assert_equal Time.zone.parse("2009-10-11 12:13:14"), record.written_on + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone end - - developer.created_at = "345643456" - - assert_equal developer.created_at_before_type_cast, "345643456" - assert_equal developer.created_at, nil - - developer.created_at = "2010-03-21 21:23:32" - assert_equal developer.created_at_before_type_cast, "2010-03-21 21:23:32" - assert_equal developer.created_at, Time.parse("2010-03-21 21:23:32") end def test_hash_content -- cgit v1.2.3 From 5da9a74bd35284cf5124793c1f7558e506b52592 Mon Sep 17 00:00:00 2001 From: Manfred Stienstra Date: Fri, 25 Feb 2011 12:34:46 +0100 Subject: Add a failing test case for an implicit action with a before filter. Signed-off-by: Andrew White --- actionpack/test/controller/filters_test.rb | 22 ++++++++++++++++++++++ .../filter_test/implicit_actions/edit.html.erb | 1 + .../filter_test/implicit_actions/show.html.erb | 1 + 3 files changed, 24 insertions(+) create mode 100644 actionpack/test/fixtures/filter_test/implicit_actions/edit.html.erb create mode 100644 actionpack/test/fixtures/filter_test/implicit_actions/show.html.erb diff --git a/actionpack/test/controller/filters_test.rb b/actionpack/test/controller/filters_test.rb index 330fa276d0..c95332220b 100644 --- a/actionpack/test/controller/filters_test.rb +++ b/actionpack/test/controller/filters_test.rb @@ -505,6 +505,16 @@ class FilterTest < ActionController::TestCase end end + class ImplicitActionsController < ActionController::Base + before_filter :find_user, :only => :edit + + private + + def find_user + @user = 'Jenny' + end + end + def test_sweeper_should_not_block_rendering response = test_process(SweeperTestController) assert_equal 'hello world', response.body @@ -783,6 +793,18 @@ class FilterTest < ActionController::TestCase assert_equal("I rescued this: #", response.body) end + def test_filter_runs_for_implicitly_defined_action_when_needed + test_process(ImplicitActionsController, 'edit') + assert_equal 'Jenny', assigns(:user) + assert_equal 'edit', response.body + end + + def test_filter_does_not_run_for_implicity_defined_action_when_not_needed + test_process(ImplicitActionsController, 'show') + assert_nil assigns(:user) + assert_equal 'show', response.body + end + private def test_process(controller, action = "show") @controller = controller.is_a?(Class) ? controller.new : controller diff --git a/actionpack/test/fixtures/filter_test/implicit_actions/edit.html.erb b/actionpack/test/fixtures/filter_test/implicit_actions/edit.html.erb new file mode 100644 index 0000000000..8491ab9f80 --- /dev/null +++ b/actionpack/test/fixtures/filter_test/implicit_actions/edit.html.erb @@ -0,0 +1 @@ +edit \ No newline at end of file diff --git a/actionpack/test/fixtures/filter_test/implicit_actions/show.html.erb b/actionpack/test/fixtures/filter_test/implicit_actions/show.html.erb new file mode 100644 index 0000000000..0a89cecf05 --- /dev/null +++ b/actionpack/test/fixtures/filter_test/implicit_actions/show.html.erb @@ -0,0 +1 @@ +show \ No newline at end of file -- cgit v1.2.3 From 9772de8d459960cc114c5b214343b7ce08fea21c Mon Sep 17 00:00:00 2001 From: Andrew White Date: Wed, 23 Mar 2011 23:09:00 +0000 Subject: Fix filter :only and :except with implicit actions The method_name argument is "default_render" for implicit actions so use the action_name attribute to determine which callbacks to run. [#5673 state:resolved] --- actionpack/lib/abstract_controller/callbacks.rb | 2 +- actionpack/test/controller/filters_test.rb | 27 +++++++++++++++---------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/actionpack/lib/abstract_controller/callbacks.rb b/actionpack/lib/abstract_controller/callbacks.rb index 95992c2698..1943ca4436 100644 --- a/actionpack/lib/abstract_controller/callbacks.rb +++ b/actionpack/lib/abstract_controller/callbacks.rb @@ -14,7 +14,7 @@ module AbstractController # Override AbstractController::Base's process_action to run the # process_action callbacks around the normal behavior. def process_action(method_name, *args) - run_callbacks(:process_action, method_name) do + run_callbacks(:process_action, action_name) do super end end diff --git a/actionpack/test/controller/filters_test.rb b/actionpack/test/controller/filters_test.rb index c95332220b..9e44e8e088 100644 --- a/actionpack/test/controller/filters_test.rb +++ b/actionpack/test/controller/filters_test.rb @@ -506,12 +506,17 @@ class FilterTest < ActionController::TestCase end class ImplicitActionsController < ActionController::Base - before_filter :find_user, :only => :edit + before_filter :find_only, :only => :edit + before_filter :find_except, :except => :edit private - def find_user - @user = 'Jenny' + def find_only + @only = 'Only' + end + + def find_except + @except = 'Except' end end @@ -793,16 +798,16 @@ class FilterTest < ActionController::TestCase assert_equal("I rescued this: #", response.body) end - def test_filter_runs_for_implicitly_defined_action_when_needed - test_process(ImplicitActionsController, 'edit') - assert_equal 'Jenny', assigns(:user) - assert_equal 'edit', response.body - end - - def test_filter_does_not_run_for_implicity_defined_action_when_not_needed + def test_filters_obey_only_and_except_for_implicit_actions test_process(ImplicitActionsController, 'show') - assert_nil assigns(:user) + assert_equal 'Except', assigns(:except) + assert_nil assigns(:only) assert_equal 'show', response.body + + test_process(ImplicitActionsController, 'edit') + assert_equal 'Only', assigns(:only) + assert_nil assigns(:except) + assert_equal 'edit', response.body end private -- cgit v1.2.3 From 5214e73850916de3c9127d35a4ecee0424d364a3 Mon Sep 17 00:00:00 2001 From: Josh Susser Date: Wed, 23 Mar 2011 21:52:53 -0700 Subject: add #first! and #last! to models & relations --- .../lib/active_record/relation/finder_methods.rb | 10 +++++++++ activerecord/test/cases/finder_test.rb | 24 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 426000fde1..25e23a9d55 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -123,6 +123,11 @@ module ActiveRecord end end + # Same as #first! but raises RecordNotFound if no record is returned + def first!(*args) + self.first(*args) or raise RecordNotFound + end + # A convenience wrapper for find(:last, *args). You can pass in all the # same arguments to this method as you can to find(:last). def last(*args) @@ -137,6 +142,11 @@ module ActiveRecord end end + # Same as #last! but raises RecordNotFound if no record is returned + def last!(*args) + self.last(*args) or raise RecordNotFound + end + # A convenience wrapper for find(:all, *args). You can pass in all the # same arguments to this method as you can to find(:all). def all(*args) diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 2e620d8b03..543981b4a0 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -191,6 +191,30 @@ class FinderTest < ActiveRecord::TestCase assert_nil Topic.where("title = 'The Second Topic of the day!'").first end + def test_first_bang_present + assert_nothing_raised do + assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").first! + end + end + + def test_first_bang_missing + assert_raises ActiveRecord::RecordNotFound do + Topic.where("title = 'This title does not exist'").first! + end + end + + def test_last_bang_present + assert_nothing_raised do + assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").last! + end + end + + def test_last_bang_missing + assert_raises ActiveRecord::RecordNotFound do + Topic.where("title = 'This title does not exist'").last! + end + end + def test_unexisting_record_exception_handling assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1).parent -- cgit v1.2.3 From 5170d210e84ab38fa93a1346ebe32b12e75d72bc Mon Sep 17 00:00:00 2001 From: Josh Kalderimis Date: Thu, 24 Mar 2011 23:24:47 +0100 Subject: correction to the outputted controller name in the diagnostics error template, test included Signed-off-by: Santiago Pastorino --- .../middleware/templates/rescues/diagnostics.erb | 2 +- actionpack/test/dispatch/show_exceptions_test.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb index 50d8ca9484..2099fd069a 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb @@ -1,7 +1,7 @@

<%=h @exception.class.to_s %> <% if @request.parameters['controller'] %> - in <%=h @request.parameters['controller'].humanize %>Controller<% if @request.parameters['action'] %>#<%=h @request.parameters['action'] %><% end %> + in <%=h @request.parameters['controller'].classify.pluralize %>Controller<% if @request.parameters['action'] %>#<%=h @request.parameters['action'] %><% end %> <% end %>

<%=h @exception.message %>
diff --git a/actionpack/test/dispatch/show_exceptions_test.rb b/actionpack/test/dispatch/show_exceptions_test.rb index 2a478c214f..e453dd11ce 100644 --- a/actionpack/test/dispatch/show_exceptions_test.rb +++ b/actionpack/test/dispatch/show_exceptions_test.rb @@ -7,6 +7,8 @@ class ShowExceptionsTest < ActionDispatch::IntegrationTest case req.path when "/not_found" raise ActionController::UnknownAction + when "/runtime_error" + raise RuntimeError when "/method_not_allowed" raise ActionController::MethodNotAllowed when "/not_implemented" @@ -121,4 +123,18 @@ class ShowExceptionsTest < ActionDispatch::IntegrationTest assert_response 404 assert_match(/AbstractController::ActionNotFound/, body) end + + test "show the controller name in the diagnostics template when controller name is present" do + @app = ProductionApp + get("/runtime_error", {}, { + 'action_dispatch.show_exceptions' => true, + 'action_dispatch.request.parameters' => { + 'action' => 'show', + 'id' => 'unknown', + 'controller' => 'featured_tiles' + } + }) + assert_response 500 + assert_match(/RuntimeError\n in FeaturedTilesController/, body) + end end -- cgit v1.2.3 From 7333f50abce3ad6c83d13c66b85e371e7951c84d Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Fri, 25 Mar 2011 12:10:11 -0700 Subject: fixing whitespace errors. :bomb: --- activerecord/test/cases/attribute_methods_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index d8638ee776..d03c3ee1a1 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -120,11 +120,11 @@ class AttributeMethodsTest < ActiveRecord::TestCase def test_read_attributes_before_type_cast_on_datetime in_time_zone "Pacific Time (US & Canada)" do record = @target.new - + record.written_on = "345643456" assert_equal "345643456", record.written_on_before_type_cast assert_equal nil, record.written_on - + record.written_on = "2009-10-11 12:13:14" assert_equal "2009-10-11 12:13:14", record.written_on_before_type_cast assert_equal Time.zone.parse("2009-10-11 12:13:14"), record.written_on -- cgit v1.2.3 From 65dce01091ec88f3485ccc26f8799c609b91e7cc Mon Sep 17 00:00:00 2001 From: Josh Susser Date: Fri, 25 Mar 2011 09:41:06 -0700 Subject: comment typo fix --- activerecord/lib/active_record/relation/finder_methods.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 25e23a9d55..9d4c6d60f5 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -123,7 +123,7 @@ module ActiveRecord end end - # Same as #first! but raises RecordNotFound if no record is returned + # Same as #first but raises RecordNotFound if no record is returned def first!(*args) self.first(*args) or raise RecordNotFound end @@ -142,7 +142,7 @@ module ActiveRecord end end - # Same as #last! but raises RecordNotFound if no record is returned + # Same as #last but raises RecordNotFound if no record is returned def last!(*args) self.last(*args) or raise RecordNotFound end -- cgit v1.2.3 From 25be204e3c2c46b4eb7738daa0ac622738aa211b Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Fri, 25 Mar 2011 22:30:36 +0000 Subject: No arguments for first! and last! --- activerecord/lib/active_record/relation/finder_methods.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 9d4c6d60f5..d41a360821 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -124,8 +124,8 @@ module ActiveRecord end # Same as #first but raises RecordNotFound if no record is returned - def first!(*args) - self.first(*args) or raise RecordNotFound + def first! + self.first or raise RecordNotFound end # A convenience wrapper for find(:last, *args). You can pass in all the @@ -143,8 +143,8 @@ module ActiveRecord end # Same as #last but raises RecordNotFound if no record is returned - def last!(*args) - self.last(*args) or raise RecordNotFound + def last! + self.last or raise RecordNotFound end # A convenience wrapper for find(:all, *args). You can pass in all the -- cgit v1.2.3 From a0946692c13b7d1724cbc86731f3611a7f8177d2 Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Fri, 25 Mar 2011 19:34:45 -0300 Subject: Update AR querying guide with #first! and #last! new methods --- .../guides/source/active_record_querying.textile | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/railties/guides/source/active_record_querying.textile b/railties/guides/source/active_record_querying.textile index 009d541106..484ba796bd 100644 --- a/railties/guides/source/active_record_querying.textile +++ b/railties/guides/source/active_record_querying.textile @@ -130,6 +130,40 @@ SELECT * FROM clients ORDER BY clients.id DESC LIMIT 1 Model.last returns +nil+ if no matching record is found. No exception will be raised. +h5. +first!+ + +Model.first! finds the first record matched by the supplied options. For example: + + +client = Client.first! +=> # + + +SQL equivalent of the above is: + + +SELECT * FROM clients LIMIT 1 + + +Model.first! raises +RecordNotFound+ if no matching record is found. + +h5. +last!+ + +Model.last! finds the last record matched by the supplied options. For example: + + +client = Client.last! +=> # + + +SQL equivalent of the above is: + + +SELECT * FROM clients ORDER BY clients.id DESC LIMIT 1 + + +Model.last! raises +RecordNotFound+ if no matching record is found. + h4. Retrieving Multiple Objects h5. Using Multiple Primary Keys -- cgit v1.2.3 From 9d9b873b9501f5fc90c0b6a4a550c5e29fc742a5 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 26 Mar 2011 00:02:40 +0100 Subject: removes unnecessary selfs, and mentions that first! and last! take no arguments in their API docs --- activerecord/lib/active_record/relation/finder_methods.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index d41a360821..563843f3cc 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -123,9 +123,10 @@ module ActiveRecord end end - # Same as #first but raises RecordNotFound if no record is returned + # Same as +first+ but raises ActiveRecord::RecordNotFound if no record + # is found. Note that first! accepts no arguments. def first! - self.first or raise RecordNotFound + first or raise RecordNotFound end # A convenience wrapper for find(:last, *args). You can pass in all the @@ -142,9 +143,10 @@ module ActiveRecord end end - # Same as #last but raises RecordNotFound if no record is returned + # Same as +last+ but raises ActiveRecord::RecordNotFound if no record + # is found. Note that last! accepts no arguments. def last! - self.last or raise RecordNotFound + last or raise RecordNotFound end # A convenience wrapper for find(:all, *args). You can pass in all the -- cgit v1.2.3 From 6ee5d9244c418ab51d48bcda444ce50b1ad2a4e7 Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Fri, 25 Mar 2011 20:07:13 -0300 Subject: impact recent updates to #first! and #last! methods --- railties/guides/source/active_record_querying.textile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/railties/guides/source/active_record_querying.textile b/railties/guides/source/active_record_querying.textile index 484ba796bd..2c5d9e67e3 100644 --- a/railties/guides/source/active_record_querying.textile +++ b/railties/guides/source/active_record_querying.textile @@ -76,7 +76,7 @@ Primary operation of Model.find(options) can be summarized as: h4. Retrieving a Single Object -Active Record lets you retrieve a single object using three different ways. +Active Record lets you retrieve a single object using five different ways. h5. Using a Primary Key @@ -132,7 +132,7 @@ SELECT * FROM clients ORDER BY clients.id DESC LIMIT 1 h5. +first!+ -Model.first! finds the first record matched by the supplied options. For example: +Model.first! finds the first record. For example: client = Client.first! @@ -149,7 +149,7 @@ SELECT * FROM clients LIMIT 1 h5. +last!+ -Model.last! finds the last record matched by the supplied options. For example: +Model.last! finds the last record. For example: client = Client.last! -- cgit v1.2.3 From 198f3883fab0a8b5ec96f5365286620344b0e6e7 Mon Sep 17 00:00:00 2001 From: Ryan Bigg Date: Sat, 26 Mar 2011 11:34:05 +1100 Subject: Proc objects for cache_path for caches_action no longer need controller object, nor to use send when calling routing helpers --- actionpack/lib/action_controller/caching/actions.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/actionpack/lib/action_controller/caching/actions.rb b/actionpack/lib/action_controller/caching/actions.rb index 2c8a6e4d4d..5fc6956266 100644 --- a/actionpack/lib/action_controller/caching/actions.rb +++ b/actionpack/lib/action_controller/caching/actions.rb @@ -56,19 +56,18 @@ module ActionController #:nodoc: # # caches_page :public # - # caches_action :index, :if => proc do |c| - # !c.request.format.json? # cache if is not a JSON request + # caches_action :index, :if => proc do + # !request.format.json? # cache if is not a JSON request # end # # caches_action :show, :cache_path => { :project => 1 }, # :expires_in => 1.hour # - # caches_action :feed, :cache_path => proc do |c| - # if c.params[:user_id] - # c.send(:user_list_url, - # c.params[:user_id], c.params[:id]) + # caches_action :feed, :cache_path => proc do + # if params[:user_id] + # user_list_url(params[:user_id, params[:id]) # else - # c.send(:list_url, c.params[:id]) + # list_url(params[:id]) # end # end # end -- cgit v1.2.3 From 7c6807296b114f0688e6e74494f1d43d3a0548ba Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Sat, 26 Mar 2011 12:13:39 -0300 Subject: Updated guides for new #update_column. --- railties/guides/source/active_record_validations_callbacks.textile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/railties/guides/source/active_record_validations_callbacks.textile b/railties/guides/source/active_record_validations_callbacks.textile index e5349d546c..6c80ec39f2 100644 --- a/railties/guides/source/active_record_validations_callbacks.textile +++ b/railties/guides/source/active_record_validations_callbacks.textile @@ -83,7 +83,7 @@ The following methods skip validations, and will save the object to the database * +increment_counter+ * +toggle!+ * +update_all+ -* +update_attribute+ +* +update_column+ * +update_counters+ Note that +save+ also has the ability to skip validations if passed +:validate => false+ as argument. This technique should be used with caution. @@ -964,7 +964,6 @@ The following methods trigger callbacks: * +save(false)+ * +toggle!+ * +update+ -* +update_attribute+ * +update_attributes+ * +update_attributes!+ * +valid?+ @@ -993,6 +992,7 @@ Just as with validations, it's also possible to skip callbacks. These methods sh * +increment+ * +increment_counter+ * +toggle+ +* +update_column+ * +update_all+ * +update_counters+ -- cgit v1.2.3