diff options
18 files changed, 254 insertions, 102 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 309cd633c3..810a2c668d 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,6 +1,16 @@ +* Fixed `ActiveRecord::Associations::CollectionAssociation#find` + when using `has_many` association with `:inverse_of` and finding an array of one element, + it should return an array of one element too. + + *arthurnn* + +* Callbacks on has_many should access the in memory parent if a inverse_of is set. + + *arthurnn* + * `ActiveRecord::ConnectionAdapters.string_to_time` respects string with timezone (e.g. Wed, 04 Sep 2013 20:30:00 JST). - + Fixes: #12278 *kennyj* diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index 98d573a3a2..b9199f62b9 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -80,14 +80,13 @@ module ActiveRecord load_target.find(*args) { |*block_args| yield(*block_args) } else if options[:inverse_of] && loaded? - args = args.flatten - raise RecordNotFound, "Couldn't find #{scope.klass.name} without an ID" if args.blank? - + args_flatten = args.flatten + raise RecordNotFound, "Couldn't find #{scope.klass.name} without an ID" if args_flatten.blank? result = find_by_scan(*args) result_size = Array(result).size - if !result || result_size != args.size - scope.raise_record_not_found_exception!(args, result_size, args.size) + if !result || result_size != args_flatten.size + scope.raise_record_not_found_exception!(args_flatten, result_size, args_flatten.size) else result end diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index a3fcca8a27..0a23109b9b 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -32,6 +32,7 @@ module ActiveRecord def insert_record(record, validate = true, raise = false) set_owner_attributes(record) + set_inverse_instance(record) if raise record.save!(:validate => validate) diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index 6cc9d6c079..127d0e2642 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -46,8 +46,6 @@ module ActiveRecord autoload :BelongsTo, 'active_record/associations/preloader/belongs_to' end - attr_reader :records, :associations, :preload_scope, :model - # Eager loads the named associations for the given Active Record record(s). # # In this description, 'association name' shall refer to the name passed @@ -82,40 +80,47 @@ module ActiveRecord # [ :books, :author ] # { author: :avatar } # [ :books, { author: :avatar } ] - def initialize(records, associations, preload_scope = nil) - @records = Array.wrap(records).compact.uniq - @associations = Array.wrap(associations) - @preload_scope = preload_scope || NULL_RELATION - end NULL_RELATION = Struct.new(:values).new({}) - def run - unless records.empty? - associations.each { |association| preload(association) } + def preload(records, associations, preload_scope = nil) + records = Array.wrap(records).compact.uniq + associations = Array.wrap(associations) + preload_scope = preload_scope || NULL_RELATION + + if records.empty? + [] + else + associations.flat_map { |association| + preloaders_on association, records, preload_scope + } end end private - def preload(association) + def preloaders_on(association, records, scope) case association when Hash - preload_hash(association) + preloaders_for_hash(association, records, scope) when Symbol - preload_one(association) + preloaders_for_one(association, records, scope) when String - preload_one(association.to_sym) + preloaders_for_one(association.to_sym, records, scope) else raise ArgumentError, "#{association.inspect} was not recognised for preload" end end - def preload_hash(association) - association.each do |parent, child| - Preloader.new(records, parent, preload_scope).run - Preloader.new(records.map { |record| record.send(parent) }.flatten, child).run - end + def preloaders_for_hash(association, records, scope) + parent, child = association.to_a.first # hash should only be of length 1 + + loaders = preloaders_for_one parent, records, scope + + recs = loaders.flat_map(&:preloaded_records).uniq + loaders.concat Array.wrap(child).flat_map { |assoc| + preloaders_on assoc, recs, scope + } end # Not all records have the same class, so group then preload group on the reflection @@ -125,45 +130,76 @@ module ActiveRecord # Additionally, polymorphic belongs_to associations can have multiple associated # classes, depending on the polymorphic_type field. So we group by the classes as # well. - def preload_one(association) - grouped_records(association).each do |reflection, klasses| - klasses.each do |klass, records| - preloader_for(reflection).new(klass, records, reflection, preload_scope).run + def preloaders_for_one(association, records, scope) + grouped_records(association, records).flat_map do |reflection, klasses| + klasses.map do |rhs_klass, rs| + loader = preloader_for(reflection, rs, rhs_klass).new(rhs_klass, rs, reflection, scope) + loader.run self + loader end end end - def grouped_records(association) - Hash[ - records_by_reflection(association).map do |reflection, records| - [reflection, records.group_by { |record| association_klass(reflection, record) }] - end - ] + def grouped_records(association, records) + reflection_records = records_by_reflection(association, records) + + reflection_records.each_with_object({}) do |(reflection, r_records),h| + h[reflection] = r_records.group_by { |record| + association_klass(reflection, record) + } + end end - def records_by_reflection(association) + def records_by_reflection(association, records) records.group_by do |record| - reflection = record.class.reflections[association] + reflection = record.class.reflect_on_association(association) - unless reflection - raise ActiveRecord::ConfigurationError, "Association named '#{association}' was not found; " \ - "perhaps you misspelled it?" - end - - reflection + reflection || raise_config_error(association) end end + def raise_config_error(association) + raise ActiveRecord::ConfigurationError, + "Association named '#{association}' was not found; " \ + "perhaps you misspelled it?" + end + def association_klass(reflection, record) if reflection.macro == :belongs_to && reflection.options[:polymorphic] - klass = record.send(reflection.foreign_type) + klass = record.read_attribute(reflection.foreign_type.to_s) klass && klass.constantize else reflection.klass end end - def preloader_for(reflection) + class AlreadyLoaded + attr_reader :owners, :reflection + + def initialize(klass, owners, reflection, preload_scope) + @owners = owners + @reflection = reflection + end + + def run(preloader); end + + def preloaded_records + owners.flat_map { |owner| owner.read_attribute reflection.name } + end + end + + class NullPreloader + def self.new(klass, owners, reflection, preload_scope); self; end + def self.run(preloader); end + end + + def preloader_for(reflection, owners, rhs_klass) + return NullPreloader unless rhs_klass + + if owners.first.association(reflection.name).loaded? + return AlreadyLoaded + end + case reflection.macro when :has_many reflection.options[:through] ? HasManyThrough : HasMany diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index 928da71eed..69b65982b3 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -3,6 +3,7 @@ module ActiveRecord class Preloader class Association #:nodoc: attr_reader :owners, :reflection, :preload_scope, :model, :klass + attr_reader :preloaded_records def initialize(klass, owners, reflection, preload_scope) @klass = klass @@ -12,15 +13,14 @@ module ActiveRecord @model = owners.first && owners.first.class @scope = nil @owners_by_key = nil + @preloaded_records = [] end - def run - unless owners.first.association(reflection.name).loaded? - preload - end + def run(preloader) + preload(preloader) end - def preload + def preload(preloader) raise NotImplementedError end @@ -68,36 +68,39 @@ module ActiveRecord private - def associated_records_by_owner + def associated_records_by_owner(preloader) owners_map = owners_by_key owner_keys = owners_map.keys.compact # Each record may have multiple owners, and vice-versa - records_by_owner = Hash[owners.map { |owner| [owner, []] }] + records_by_owner = owners.each_with_object({}) do |owner,h| + h[owner] = [] + end - if klass && owner_keys.any? + if owner_keys.any? # Some databases impose a limit on the number of ids in a list (in Oracle it's 1000) # Make several smaller queries if necessary or make one query if the adapter supports it sliced = owner_keys.each_slice(klass.connection.in_clause_length || owner_keys.size) - sliced.each { |slice| - records = records_for(slice) - caster = type_caster(records, association_key_name) - records.each do |record| - owner_key = caster.call record[association_key_name] - - owners_map[owner_key].each do |owner| - records_by_owner[owner] << record - end + + records = load_slices sliced + records.each do |record, owner_key| + owners_map[owner_key].each do |owner| + records_by_owner[owner] << record end - } + end end records_by_owner end - IDENTITY = lambda { |value| value } - def type_caster(results, name) - IDENTITY + def load_slices(slices) + @preloaded_records = slices.flat_map { |slice| + records_for(slice) + } + + @preloaded_records.map { |record| + [record, record[association_key_name]] + } end def reflection_scope @@ -116,6 +119,14 @@ module ActiveRecord scope.select! preload_values[:select] || values[:select] || table[Arel.star] scope.includes! preload_values[:includes] || values[:includes] + if preload_values.key? :order + scope.order! preload_values[:order] + else + if values.key? :order + scope.order! values[:order] + end + end + if options[:as] scope.where!(klass.table_name => { reflection.type => model.base_class.sti_name }) end diff --git a/activerecord/lib/active_record/associations/preloader/collection_association.rb b/activerecord/lib/active_record/associations/preloader/collection_association.rb index e6cd35e7a1..5adffcd831 100644 --- a/activerecord/lib/active_record/associations/preloader/collection_association.rb +++ b/activerecord/lib/active_record/associations/preloader/collection_association.rb @@ -9,8 +9,8 @@ module ActiveRecord super.order(preload_scope.values[:order] || reflection_scope.values[:order]) end - def preload - associated_records_by_owner.each do |owner, records| + def preload(preloader) + associated_records_by_owner(preloader).each do |owner, records| association = owner.association(reflection.name) association.loaded! association.target.concat(records) diff --git a/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb b/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb index c042a44b21..b62ca6f681 100644 --- a/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb +++ b/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb @@ -33,16 +33,22 @@ module ActiveRecord # Once we have used the join table column (in super), we manually instantiate the # actual records, ensuring that we don't create more than one instances of the same # record - def associated_records_by_owner - records = {} - super.each_value do |rows| - rows.map! { |row| records[row[klass.primary_key]] ||= klass.instantiate(row) } - end - end + def load_slices(slices) + identity_map = {} + caster = nil + name = association_key_name + + records_to_keys = slices.flat_map { |slice| + records = records_for(slice) + caster ||= records.column_types.fetch(name, records.identity_type) + records.map! { |row| + record = identity_map[row[klass.primary_key]] ||= klass.instantiate(row) + [record, caster.type_cast(row[name])] + } + } + @preloaded_records = records_to_keys.map(&:first) - def type_caster(results, name) - caster = results.column_types.fetch(name, results.identity_type) - lambda { |value| caster.type_cast value } + records_to_keys end def build_scope diff --git a/activerecord/lib/active_record/associations/preloader/has_many_through.rb b/activerecord/lib/active_record/associations/preloader/has_many_through.rb index 157b627ad5..7b37b5942d 100644 --- a/activerecord/lib/active_record/associations/preloader/has_many_through.rb +++ b/activerecord/lib/active_record/associations/preloader/has_many_through.rb @@ -4,7 +4,7 @@ module ActiveRecord class HasManyThrough < CollectionAssociation #:nodoc: include ThroughAssociation - def associated_records_by_owner + def associated_records_by_owner(preloader) records_by_owner = super if reflection_scope.distinct_value diff --git a/activerecord/lib/active_record/associations/preloader/singular_association.rb b/activerecord/lib/active_record/associations/preloader/singular_association.rb index 44e804d785..2b5cfda8ce 100644 --- a/activerecord/lib/active_record/associations/preloader/singular_association.rb +++ b/activerecord/lib/active_record/associations/preloader/singular_association.rb @@ -5,8 +5,8 @@ module ActiveRecord private - def preload - associated_records_by_owner.each do |owner, associated_records| + def preload(preloader) + associated_records_by_owner(preloader).each do |owner, associated_records| record = associated_records.first association = owner.association(reflection.name) diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index 2c625cec04..ea21836c65 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -2,7 +2,6 @@ module ActiveRecord module Associations class Preloader module ThroughAssociation #:nodoc: - def through_reflection reflection.through_reflection end @@ -11,34 +10,67 @@ module ActiveRecord reflection.source_reflection end - def associated_records_by_owner - through_records = through_records_by_owner + def associated_records_by_owner(preloader) + preloader.preload(owners, + through_reflection.name, + through_scope) + + through_records = owners.map do |owner, h| + association = owner.association through_reflection.name + + [owner, Array(association.reader)] + end + + reset_association owners, through_reflection.name - Preloader.new(through_records.values.flatten, source_reflection.name, reflection_scope).run + middle_records = through_records.map { |(_,rec)| rec }.flatten - through_records.each do |owner, records| - records.map! { |r| r.send(source_reflection.name) }.flatten! - records.compact! + preloaders = preloader.preload(middle_records, + source_reflection.name, + reflection_scope) + + middle_to_pl = preloaders.each_with_object({}) do |pl,h| + pl.owners.each { |middle| + h[middle] = pl + } end + + through_records.each_with_object({}) { |(lhs,center),records_by_owner| + pl_to_middle = center.group_by { |record| middle_to_pl[record] } + + records_by_owner[lhs] = pl_to_middle.flat_map do |pl, middles| + rhs_records = middles.flat_map { |r| + r.send(source_reflection.name) + }.compact + + loaded_records = pl.preloaded_records + i = 0 + record_index = loaded_records.each_with_object({}) { |r,indexes| + indexes[r] = i + i += 1 + } + records = rhs_records.sort_by { |rhs| record_index[rhs] } + @preloaded_records.concat rhs_records + records + end + } end private - def through_records_by_owner - Preloader.new(owners, through_reflection.name, through_scope).run - + def reset_association(owners, association_name) should_reset = (through_scope != through_reflection.klass.unscoped) || (reflection.options[:source_type] && through_reflection.collection?) - owners.each_with_object({}) do |owner, h| - association = owner.association through_reflection.name - h[owner] = Array(association.reader) - - # Dont cache the association - we would only be caching a subset - association.reset if should_reset + # Dont cache the association - we would only be caching a subset + if should_reset + owners.each { |owner| + owner.association(association_name).reset + } end end + def through_scope scope = through_reflection.klass.unscoped diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 4e86e905ed..cfaf566ec4 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -599,8 +599,9 @@ module ActiveRecord preload = preload_values preload += includes_values unless eager_loading? + preloader = ActiveRecord::Associations::Preloader.new preload.each do |associations| - ActiveRecord::Associations::Preloader.new(@records, associations).run + preloader.preload @records, associations end @records.each { |record| record.readonly! } if readonly_value diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index ebeead0dc2..064e31f634 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -337,6 +337,18 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find(2, 99) } end + def test_find_ids_and_inverse_of + force_signal37_to_load_all_clients_of_firm + + firm = companies(:first_firm) + client = firm.clients_of_firm.find(3) + assert_kind_of Client, client + + client_ary = firm.clients_of_firm.find([3]) + assert_kind_of Array, client_ary + assert_equal client, client_ary.first + end + def test_find_all firm = Firm.all.merge!(:order => "id").first assert_equal 2, firm.clients.where("#{QUOTED_TYPE} = 'Client'").to_a.length @@ -518,6 +530,13 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end end + def test_inverse_on_before_validate + firm = companies(:first_firm) + assert_queries(1) do + firm.clients_of_firm << Client.new("name" => "Natural Company") + end + end + def test_new_aliased_to_build company = companies(:first_firm) new_client = assert_no_queries { company.clients_of_firm.new("name" => "Another Client") } 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 dd8b426b25..c0e80c5fe9 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -29,7 +29,7 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :posts, :readers, :people, :comments, :authors, :categories, :taggings, :tags, :owners, :pets, :toys, :jobs, :references, :companies, :members, :author_addresses, :subscribers, :books, :subscriptions, :developers, :categorizations, :essays, - :categories_posts + :categories_posts, :clubs, :memberships # Dummies to force column loads so query counts are clean. def setup @@ -37,10 +37,38 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase Reader.create :person_id => 0, :post_id => 0 end + def test_preload_sti_middle_relation + club = Club.create!(name: 'Aaron cool banana club') + member1 = Member.create!(name: 'Aaron') + member2 = Member.create!(name: 'Cat') + + SuperMembership.create! club: club, member: member1 + CurrentMembership.create! club: club, member: member2 + + club1 = Club.includes(:members).find_by_id club.id + assert_equal [member1, member2].sort_by(&:id), + club1.members.sort_by(&:id) + end + def make_model(name) Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } end + def test_ordered_habtm + person_prime = Class.new(ActiveRecord::Base) do + def self.name; 'Person'; end + + has_many :readers + has_many :posts, -> { order('posts.id DESC') }, :through => :readers + end + posts = person_prime.includes(:posts).first.posts + + assert_operator posts.length, :>, 1 + posts.each_cons(2) do |left,right| + assert_operator left.id, :>, right.id + end + end + def test_singleton_has_many_through book = make_model "Book" subscription = make_model "Subscription" diff --git a/activerecord/test/models/company.rb b/activerecord/test/models/company.rb index 8104c607b5..1aa77f95bf 100644 --- a/activerecord/test/models/company.rb +++ b/activerecord/test/models/company.rb @@ -39,7 +39,7 @@ class Firm < Company has_many :unsorted_clients, :class_name => "Client" has_many :unsorted_clients_with_symbol, :class_name => :Client has_many :clients_sorted_desc, -> { order "id DESC" }, :class_name => "Client" - has_many :clients_of_firm, -> { order "id" }, :foreign_key => "client_of", :class_name => "Client" + has_many :clients_of_firm, -> { order "id" }, :foreign_key => "client_of", :class_name => "Client", :inverse_of => :firm has_many :clients_ordered_by_name, -> { order "name" }, :class_name => "Client" has_many :unvalidated_clients_of_firm, :foreign_key => "client_of", :class_name => "Client", :validate => false has_many :dependent_clients_of_firm, -> { order "id" }, :foreign_key => "client_of", :class_name => "Client", :dependent => :destroy @@ -117,6 +117,10 @@ class Client < Company has_many :accounts, :through => :firm, :source => :accounts belongs_to :account + validate do + firm + end + class RaisedOnSave < RuntimeError; end attr_accessor :raise_on_save before_save do diff --git a/activerecord/test/models/membership.rb b/activerecord/test/models/membership.rb index bcbb7e42c5..df7167ee93 100644 --- a/activerecord/test/models/membership.rb +++ b/activerecord/test/models/membership.rb @@ -8,6 +8,11 @@ class CurrentMembership < Membership belongs_to :club end +class SuperMembership < Membership + belongs_to :member, -> { order('members.id DESC') } + belongs_to :club +end + class SelectedMembership < Membership def self.default_scope select("'1' as foo") diff --git a/activesupport/lib/active_support/cache.rb b/activesupport/lib/active_support/cache.rb index 308a1b2cd9..c5633d902f 100644 --- a/activesupport/lib/active_support/cache.rb +++ b/activesupport/lib/active_support/cache.rb @@ -234,7 +234,7 @@ module ActiveSupport # bump the cache expiration time by the value set in <tt>:race_condition_ttl</tt>. # Yes, this process is extending the time for a stale value by another few # seconds. Because of extended life of the previous cache, other processes - # will continue to use slightly stale data for a just a big longer. In the + # will continue to use slightly stale data for a just a bit longer. In the # meantime that first process will go ahead and will write into cache the # new value. After that all the processes will start getting new value. # The key is to keep <tt>:race_condition_ttl</tt> small. diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index 28013beeae..faa37efd37 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -1358,7 +1358,7 @@ COMMIT The new record might not be saved to the database; that depends on whether validations passed or not (just like `create`). -Suppose we want to set the 'locked' attribute to true if we're +Suppose we want to set the 'locked' attribute to `false` if we're creating a new record, but we don't want to include it in the query. So we want to find the client named "Andy", or if that client doesn't exist, create a client named "Andy" which is not locked. diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 5576b23d91..d3d3f4eea4 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -846,7 +846,7 @@ Open `app/views/welcome/index.html.erb` and modify it as follows: ```html+erb <h1>Hello, Rails!</h1> -<%= link_to "My Blog", controller: "posts" %> +<%= link_to 'My Blog', controller: 'posts' %> ``` The `link_to` method is one of Rails' built-in view helpers. It creates a |