diff options
Diffstat (limited to 'activerecord')
60 files changed, 1747 insertions, 593 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/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index affa2fbcaf..379a4eb1ef 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}.") @@ -64,6 +56,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) @@ -111,8 +109,10 @@ 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' # Clears out the association cache. def clear_association_cache #:nodoc: @@ -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 <tt>:through</tt> option, including an + # association which has a <tt>:through</tt> 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 <tt>Commenter</tt> in the example above, there would be no way to tell how to set up the + # intermediate <tt>Post</tt> and <tt>Comment</tt> 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 <tt>belongs_to</tt>). # [:through] - # Specifies a join model through which to perform the query. Options for <tt>:class_name</tt> - # and <tt>:foreign_key</tt> are ignored, as the association uses the source reflection. You - # can only use a <tt>:through</tt> query through a <tt>belongs_to</tt>, <tt>has_one</tt> - # or <tt>has_many</tt> association on the join model. The collection of join models + # Specifies a join model through which to perform the query. Options for <tt>:class_name</tt>, + # <tt>:primary_key</tt> and <tt>:foreign_key</tt> are ignored, as the association uses the + # source reflection. You can use a <tt>:through</tt> association through any other, + # association, but if other <tt>:through</tt> 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 <tt>:class_name</tt> - # and <tt>:foreign_key</tt> are ignored, as the association uses the source reflection. You - # can only use a <tt>:through</tt> query through a <tt>has_one</tt> or <tt>belongs_to</tt> - # association on the join model. + # Specifies a Join Model through which to perform the query. Options for <tt>:class_name</tt>, + # <tt>:primary_key</tt>, and <tt>:foreign_key</tt> are ignored, as the association uses the + # source reflection. You can only use a <tt>:through</tt> query through a <tt>has_one</tt> + # or <tt>belongs_to</tt> association on the join model. # [:source] # Specifies the source association name used by <tt>has_one :through</tt> queries. # Only use it if the name cannot be inferred from the association. @@ -1833,7 +1877,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)] @@ -1841,8 +1885,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 @@ -1862,17 +1906,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) @@ -2125,11 +2158,11 @@ 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, :to => :reflection + 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! @@ -2140,15 +2173,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) @@ -2164,7 +2195,88 @@ 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 + index = 0 + + chain.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.primary_key_name + 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 + else + case reflection.source_reflection.macro + 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 def join_relation(joining_relation) @@ -2173,213 +2285,117 @@ module ActiveRecord end def table - @table ||= Arel::Table.new( - table_name, :as => aliased_table_name, - :engine => arel_engine, :columns => active_record.columns - ) + if @tables.last.is_a?(Array) + @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, 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}" - @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, join = false) + name = alias_tracker.pluralize(reflection.name) + name << "_#{parent_table_name}" + name << "_join" if join name end - def pluralize(table_name) - ActiveRecord::Base.pluralize_table_names ? table_name.to_s.pluralize : table_name - end - def interpolate_sql(sql) instance_eval("%@#{sql.gsub('@', '\@')}@", __FILE__, __LINE__) 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] + # 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| + aliased_table_name = alias_tracker.aliased_name_for( + reflection.table_name, + table_alias_for(reflection, reflection != self.reflection) + ) - 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 + table = Arel::Table.new( + reflection.table_name, :engine => arel_engine, + :as => aliased_table_name, :columns => reflection.klass.columns + ) - conditions << sti_condition - end - - # If the reflection has conditions, add them - if options[:conditions] - conditions << process_conditions(options[:conditions], aliased_table_name) + # 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 - relation = relation.on(*conditions) - 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 - ) + # 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! - 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]) - ) + @tables 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]) - ) + + def reflection_conditions(index, table) + @reflection.through_conditions.reverse[index].map do |condition| + Arel.sql(interpolate_sql(sanitize_sql( + condition, + table.table_alias || table.name + ))) 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' + def sti_conditions(reflection, table) + unless reflection.klass.descends_from_active_record? + sti_column = table[reflection.klass.inheritance_column] - 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" - else - first_key = through_reflection.klass.base_class.to_s.foreign_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) - end - when :belongs_to - first_key = primary_key + condition = sti_column.eq(reflection.klass.sti_name) - 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 + reflection.klass.descendants.each do |subclass| + condition = condition.or(sti_column.eq(subclass.sti_name)) end + + condition 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 - - 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 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..10e90ec117 --- /dev/null +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -0,0 +1,73 @@ +require 'active_support/core_ext/string/conversions' + +module ActiveRecord + module Associations + # 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 = nil) + @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 + + 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 + end + end +end 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..7eaa05ee36 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 <tt>:through</tt> 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])) + 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 @@ -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 97883d8393..419a3d385e 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 @@ -7,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) @@ -36,6 +42,7 @@ module ActiveRecord protected def create_record(attrs, force = true) + ensure_not_nested ensure_owner_is_not_new transaction do @@ -59,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! @@ -74,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)) @@ -82,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..c6bcfec275 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) @@ -69,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 @@ -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 fba0a2bfcc..de962e01b6 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) @@ -33,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 cabb33c4a8..abe7af418d 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 @@ -5,79 +7,257 @@ 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], - } } + def construct_find_scope + { + :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] + } + end + + def construct_create_scope + @reflection.nested? ? {} : construct_owner_attributes(@reflection) end # 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}.#{attr} = #{value}" + 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 << sql_conditions if sql_conditions - "(" + conditions.join(') AND (') + ")" + + parts = construct_quoted_owner_attributes(reflection).map do |attr, value| + "#{table_alias}.#{attr} = #{value}" + end + parts += reflection_conditions(0) + + "(" + parts.join(') AND (') + ")" end # 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 - 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) - 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) - ] + "#{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 + joins << inner_join_sql( + right_table_and_alias, + table_aliases[left], left.association_primary_key, + 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.association_primary_key, + 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, + reflection_conditions(right_index) + ) + end + else + case left.source_reflection.macro + when :belongs_to + joins << inner_join_sql( + right_table_and_alias, + table_aliases[left], left.association_primary_key, + table_aliases[right], left.primary_key_name, + source_type_conditions(left), + reflection_conditions(right_index) + ) + 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.primary_key_name, + right_table, left.source_reflection.active_record_primary_key, + 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( + 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] + + joins << inner_join_sql( + table_name_and_alias( + quote_table_name(left.source_reflection.options[:join_table]), + join_table + ), + 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, + table_aliases[right], right.klass.primary_key, + reflection_conditions(right_index) + ) + end end + + right_index += 1 end + + joins.join(" ") + end + + def alias_tracker + @alias_tracker ||= AliasTracker.new + 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, - polymorphic_join - ] + def table_aliases + @table_aliases ||= begin + @reflection.through_reflection_chain.inject({}) do |aliases, reflection| + 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 || + (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" % [ + table_aliases[reflection], "#{polymorphic_reflection.options[:as]}_type", + @owner.class.quote_value(polymorphic_reflection.active_record.base_class.name) + ] + end + end + + def source_type_conditions(reflection) + if reflection.options[:source_type] + "%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. @@ -91,6 +271,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) @@ -107,48 +289,12 @@ 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)) + + def ensure_not_nested + if @reflection.nested? + raise HasManyThroughNestedAssociationsAreReadonly.new(@owner, @reflection) end end - - def build_sti_condition - @reflection.through_reflection.klass.send(:type_condition).to_sql - end - - alias_method :sql_conditions, :conditions end end end 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 diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index db18fb7c0f..824674ee1d 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -209,6 +209,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 @@ -241,8 +249,13 @@ module ActiveRecord def through_reflection false end - - def through_reflection_primary_key_name + + def through_reflection_chain + [self] + end + + def through_conditions + [Array.wrap(options[:conditions])] end def source_reflection @@ -326,6 +339,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 <tt>:belongs_to</tt> or <tt>:has_many</tt>. # @@ -352,6 +367,101 @@ 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. + def through_reflection_chain + @through_reflection_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 + 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 + + # 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 + # 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 + + # 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. + def association_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 <tt>:through</tt> source reflection names: # @@ -378,21 +488,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 - 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 diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index c7c32da9f3..5b24d49a7d 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 3, authors[1].posts.size 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 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 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 @@ -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 3, authors[1].posts.size 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 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 2d8e02e398..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") - [: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/has_and_belongs_to_many_associations_test.rb b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb index 7e070e1746..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 @@ -713,12 +713,12 @@ 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 5, categories(:general).posts_grouped_by_title.size assert_equal 1, categories(:technology).posts_grouped_by_title.size 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..713c492f5e 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,15 @@ require 'models/developer' require 'models/subscriber' 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 + :subscribers, :books, :subscriptions, :developers, + :essays, :categories, :owners # Dummies to force column loads so query counts are clean. def setup @@ -449,4 +453,23 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase comment = post.comments.build assert author.comments.include?(comment) end + + 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 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..39e14b4bfd 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,14 @@ require 'models/member_detail' require 'models/minivan' require 'models/dashboard' 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 + fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, + :dashboards, :speedometers, :categories, :authors, :essays, :owners def setup @member = members(:groucho) @@ -212,4 +217,23 @@ 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 end diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index 7a22ce4dad..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 @@ -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..23fa1709ce --- /dev/null +++ b/activerecord/test/cases/associations/nested_has_many_through_associations_test.rb @@ -0,0 +1,462 @@ +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 + + # 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 + + 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? + + 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 + 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 + + 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? + + 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 + 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 + + 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 + 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 + + 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 + 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 + + 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 + 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 + + 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 + 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 } + 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 } + 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 + 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 + 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 + + 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 } + 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 + + 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/batches_test.rb b/activerecord/test/cases/batches_test.rb index dcc49e12ca..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(4) 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 c058196078..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]], 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/json_serialization_test.rb b/activerecord/test/cases/json_serialization_test.rb index 5da7f9e1b9..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 @@ -196,7 +200,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/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/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 222819cbe5..0d88c8eded 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -428,7 +428,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 @@ -501,22 +501,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 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 5, 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 7, 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 7, 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 7, 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 7, 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 5, 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/authors.yml b/activerecord/test/fixtures/authors.yml index de2ec7d38b..832236a486 100644 --- a/activerecord/test/fixtures/authors.yml +++ b/activerecord/test/fixtures/authors.yml @@ -3,7 +3,13 @@ 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 name: Mary + +bob: + id: 3 + name: Bob 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/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 9b67ab4fa4..c6f0d885f5 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 + +cooking_misc_by_bob: + category_id: 4 + post_id: 8 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/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/essays.yml b/activerecord/test/fixtures/essays.yml new file mode 100644 index 0000000000..9d15d82359 --- /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: General + author_id: David diff --git a/activerecord/test/fixtures/member_details.yml b/activerecord/test/fixtures/member_details.yml new file mode 100644 index 0000000000..e1fe695a9b --- /dev/null +++ b/activerecord/test/fixtures/member_details.yml @@ -0,0 +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/fixtures/members.yml b/activerecord/test/fixtures/members.yml index 6db945e61d..f3bbf0dac6 100644 --- a/activerecord/test/fixtures/members.yml +++ b/activerecord/test/fixtures/members.yml @@ -1,6 +1,11 @@ groucho: + id: 1 name: Groucho Marx member_type_id: 1 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 b9722dbc8a..60eb641054 100644 --- a/activerecord/test/fixtures/memberships.yml +++ b/activerecord/test/fixtures/memberships.yml @@ -1,20 +1,27 @@ 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 + +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/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/fixtures/posts.yml b/activerecord/test/fixtures/posts.yml index f817493190..264ca164f0 100644 --- a/activerecord/test/fixtures/posts.yml +++ b/activerecord/test/fixtures/posts.yml @@ -50,3 +50,31 @@ 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 + +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/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/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/fixtures/taggings.yml b/activerecord/test/fixtures/taggings.yml index 3db6a4c079..a337cce019 100644 --- a/activerecord/test/fixtures/taggings.yml +++ b/activerecord/test/fixtures/taggings.yml @@ -26,3 +26,43 @@ 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 + +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 7610fd38b9..d4b7c9a4d5 100644 --- a/activerecord/test/fixtures/tags.yml +++ b/activerecord/test/fixtures/tags.yml @@ -4,4 +4,8 @@ general: misc: id: 2 - name: Misc
\ No newline at end of file + name: Misc + +blue: + id: 3 + name: Blue diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 34bfd2d881..7dcfbd268b 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -83,16 +83,48 @@ 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 + has_many :taggings, :through => :posts + has_many :tags, :through => :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 + has_many :tags_with_primary_key, :through => :posts + + has_many :books + 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 + + 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 + + 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, :dependent => :destroy 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 + + 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/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/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 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 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/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/essay.rb b/activerecord/test/models/essay.rb index 6c28f5e49b..ec4b982b5b 100644 --- a/activerecord/test/models/essay.rb +++ b/activerecord/test/models/essay.rb @@ -1,3 +1,5 @@ class Essay < ActiveRecord::Base belongs_to :writer, :primary_key => :name, :polymorphic => true + belongs_to :category, :primary_key => :name + has_one :owner, :primary_key => :name end 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/member.rb b/activerecord/test/models/member.rb index 255fb569d7..bed62f8b7f 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -9,4 +9,15 @@ 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 + + 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 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 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/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..7c919a55eb 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' @@ -61,7 +63,13 @@ 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'" + 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/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/models/tagging.rb b/activerecord/test/models/tagging.rb index a1fa1a9750..c6ff8d390b 100644 --- a/activerecord/test/models/tagging.rb +++ b/activerecord/test/models/tagging.rb @@ -6,5 +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 :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
\ No newline at end of file +end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index ea62833d81..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| @@ -71,6 +73,7 @@ ActiveRecord::Schema.define do end create_table :books, :force => true do |t| + t.integer :author_id t.column :name, :string end @@ -112,6 +115,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| @@ -212,6 +216,8 @@ ActiveRecord::Schema.define do t.string :name t.string :writer_id t.string :writer_type + t.string :category_id + t.string :author_id end create_table :events, :force => true do |t| @@ -365,6 +371,7 @@ ActiveRecord::Schema.define do t.string :name t.column :updated_at, :datetime t.column :happy_at, :datetime + t.string :essay_id end @@ -448,6 +455,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 @@ -530,6 +542,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| |