diff options
Diffstat (limited to 'activerecord/lib/active_record/associations')
33 files changed, 1422 insertions, 1124 deletions
diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index 0c23029981..2b7e4f28c5 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -5,71 +5,86 @@ module ActiveRecord # Keeps track of table aliases for ActiveRecord::Associations::ClassMethods::JoinDependency and # ActiveRecord::Associations::ThroughAssociationScope class AliasTracker # :nodoc: - attr_reader :aliases, :table_joins, :connection + attr_reader :aliases - # table_joins is an array of arel joins which might conflict with the aliases we assign here - def initialize(connection = Base.connection, table_joins = []) - @aliases = Hash.new { |h,k| h[k] = initial_count_for(k) } - @table_joins = table_joins - @connection = connection + def self.create(connection, initial_table, type_caster) + aliases = Hash.new(0) + aliases[initial_table] = 1 + new connection, aliases, type_caster end - def aliased_table_for(table_name, aliased_name = nil) - table_alias = aliased_name_for(table_name, aliased_name) - - if table_alias == table_name - Arel::Table.new(table_name) + def self.create_with_joins(connection, initial_table, joins, type_caster) + if joins.empty? + create(connection, initial_table, type_caster) else - Arel::Table.new(table_name).alias(table_alias) + aliases = Hash.new { |h, k| + h[k] = initial_count_for(connection, k, joins) + } + aliases[initial_table] = 1 + new connection, aliases, type_caster end end - def aliased_name_for(table_name, aliased_name = nil) - aliased_name ||= table_name + def self.initial_count_for(connection, name, table_joins) + # quoted_name should be downcased as some database adapters (Oracle) return quoted name in uppercase + quoted_name = connection.quote_table_name(name).downcase + counts = table_joins.map do |join| + if join.is_a?(Arel::Nodes::StringJoin) + # Table names + table aliases + join.left.downcase.scan( + /join(?:\s+\w+)?\s+(\S+\s+)?#{quoted_name}\son/ + ).size + elsif join.respond_to? :left + join.left.table_name == name ? 1 : 0 + else + # this branch is reached by two tests: + # + # activerecord/test/cases/associations/cascaded_eager_loading_test.rb:37 + # with :posts + # + # activerecord/test/cases/associations/eager_test.rb:1133 + # with :comments + # + 0 + end + end + + counts.sum + end + + # table_joins is an array of arel joins which might conflict with the aliases we assign here + def initialize(connection, aliases, type_caster) + @aliases = aliases + @connection = connection + @type_caster = type_caster + end + + def aliased_table_for(table_name, aliased_name) if aliases[table_name].zero? # If it's zero, we can have our table_name aliases[table_name] = 1 - table_name + Arel::Table.new(table_name, type_caster: @type_caster) else # Otherwise, we need to use an alias - aliased_name = connection.table_alias_for(aliased_name) + aliased_name = @connection.table_alias_for(aliased_name) # Update the count aliases[aliased_name] += 1 - if aliases[aliased_name] > 1 + table_alias = if aliases[aliased_name] > 1 "#{truncate(aliased_name)}_#{aliases[aliased_name]}" else aliased_name end + Arel::Table.new(table_name, type_caster: @type_caster).alias(table_alias) end end private - def initial_count_for(name) - return 0 if Arel::Table === table_joins - - # quoted_name should be downcased as some database adapters (Oracle) return quoted name in uppercase - quoted_name = connection.quote_table_name(name).downcase - - counts = table_joins.map do |join| - if join.is_a?(Arel::Nodes::StringJoin) - # Table names + table aliases - join.left.downcase.scan( - /join(?:\s+\w+)?\s+(\S+\s+)?#{quoted_name}\son/ - ).size - else - join.left.table_name == name ? 1 : 0 - end - end - - counts.sum - end - def truncate(name) - name.slice(0, connection.table_alias_length - 2) + name.slice(0, @connection.table_alias_length - 2) end end end diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index 67d24b35d1..0d8e4ba870 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -13,11 +13,11 @@ module ActiveRecord # BelongsToAssociation # BelongsToPolymorphicAssociation # CollectionAssociation - # HasAndBelongsToManyAssociation # HasManyAssociation # HasManyThroughAssociation + ThroughAssociation class Association #:nodoc: attr_reader :owner, :target, :reflection + attr_accessor :inversed delegate :options, :to => :reflection @@ -43,6 +43,7 @@ module ActiveRecord @loaded = false @target = nil @stale_state = nil + @inversed = false end # Reloads the \target and returns +self+ on success. @@ -60,18 +61,19 @@ module ActiveRecord # Asserts the \target has been loaded setting the \loaded flag to +true+. def loaded! - @loaded = true + @loaded = true @stale_state = stale_state + @inversed = false end # The target is stale if the target no longer points to the record(s) that the # relevant foreign_key(s) refers to. If stale, the association accessor method # on the owner will reload the target. It's up to subclasses to implement the - # state_state method if relevant. + # stale_state method if relevant. # # Note that if the target has not been loaded, it is not considered stale. def stale_target? - loaded? && @stale_state != stale_state + !inversed && loaded? && @stale_state != stale_state end # Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+. @@ -92,7 +94,7 @@ module ActiveRecord # actually gets built. def association_scope if klass - @association_scope ||= AssociationScope.new(self).scope + @association_scope ||= AssociationScope.scope(self, klass.connection) end end @@ -102,10 +104,12 @@ module ActiveRecord # Set the inverse association, if possible def set_inverse_instance(record) - if record && invertible_for?(record) + if invertible_for?(record) inverse = record.association(inverse_reflection_for(record).name) inverse.target = owner + inverse.inversed = true end + record end # Returns the class of the target. belongs_to polymorphic overrides this to look at the @@ -117,7 +121,7 @@ module ActiveRecord # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the # through association's scope) def target_scope - AssociationRelation.create(klass, klass.arel_table, self).merge!(klass.all) + AssociationRelation.create(klass, klass.arel_table, klass.predicate_builder, self).merge!(klass.all) end # Loads the \target if needed and returns it. @@ -156,7 +160,7 @@ module ActiveRecord def marshal_load(data) reflection_name, ivars = data ivars.each { |name, val| instance_variable_set(name, val) } - @reflection = @owner.class.reflect_on_association(reflection_name) + @reflection = @owner.class._reflect_on_association(reflection_name) end def initialize_attributes(record) #:nodoc: @@ -175,7 +179,7 @@ module ActiveRecord def creation_attributes attributes = {} - if (reflection.macro == :has_one || reflection.macro == :has_many) && !options[:through] + if (reflection.has_one? || reflection.collection?) && !options[:through] attributes[reflection.foreign_key] = owner[reflection.active_record_primary_key] if reflection.options[:as] @@ -223,7 +227,12 @@ module ActiveRecord # Returns true if inverse association on the given record needs to be set. # This method is redefined by subclasses. def invertible_for?(record) - inverse_reflection_for(record) + foreign_key_for?(record) && inverse_reflection_for(record) + end + + # Returns true if record contains the foreign_key + def foreign_key_for?(record) + record.has_attribute?(reflection.foreign_key) end # This should be implemented to return the values of the relevant key(s) on the owner, diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index 8027acfb83..2416167834 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -1,136 +1,164 @@ module ActiveRecord module Associations class AssociationScope #:nodoc: - include JoinHelper - - attr_reader :association, :alias_tracker + def self.scope(association, connection) + INSTANCE.scope(association, connection) + end - delegate :klass, :owner, :reflection, :interpolate, :to => :association - delegate :chain, :scope_chain, :options, :source_options, :active_record, :to => :reflection + def self.create(&block) + block ||= lambda { |val| val } + new(block) + end - def initialize(association) - @association = association - @alias_tracker = AliasTracker.new klass.connection + def initialize(value_transformation) + @value_transformation = value_transformation end - def scope + INSTANCE = create + + def scope(association, connection) + klass = association.klass + reflection = association.reflection scope = klass.unscoped - scope.extending! Array(options[:extend]) - add_constraints(scope) + owner = association.owner + alias_tracker = AliasTracker.create connection, association.klass.table_name, klass.type_caster + chain_head, chain_tail = get_chain(reflection, association, alias_tracker) + + scope.extending! Array(reflection.options[:extend]) + add_constraints(scope, owner, klass, reflection, chain_head, chain_tail) end - private + def join_type + Arel::Nodes::InnerJoin + end + + def self.get_bind_values(owner, chain) + binds = [] + last_reflection = chain.last - def column_for(table_name, column_name) - columns = alias_tracker.connection.schema_cache.columns_hash(table_name) - columns[column_name] + binds << last_reflection.join_id_for(owner) + if last_reflection.type + binds << owner.class.base_class.name + end + + chain.each_cons(2).each do |reflection, next_reflection| + if reflection.type + binds << next_reflection.klass.base_class.name + end + end + binds end - def bind_value(scope, column, value) - substitute = alias_tracker.connection.substitute_at( - column, scope.bind_values.length) - scope.bind_values += [[column, value]] - substitute + protected + + attr_reader :value_transformation + + private + def join(table, constraint) + table.create_join(table, table.create_on(constraint), join_type) end - def bind(scope, table_name, column_name, value) - column = column_for table_name, column_name - bind_value scope, column, value + def last_chain_scope(scope, table, reflection, owner, association_klass) + join_keys = reflection.join_keys(association_klass) + key = join_keys.key + foreign_key = join_keys.foreign_key + + value = transform_value(owner[foreign_key]) + scope = scope.where(table.name => { key => value }) + + if reflection.type + polymorphic_type = transform_value(owner.class.base_class.name) + scope = scope.where(table.name => { reflection.type => polymorphic_type }) + end + + scope end - def add_constraints(scope) - tables = construct_tables + def transform_value(value) + value_transformation.call(value) + end - chain.each_with_index do |reflection, i| - table, foreign_table = tables.shift, tables.first + def next_chain_scope(scope, table, reflection, association_klass, foreign_table, next_reflection) + join_keys = reflection.join_keys(association_klass) + key = join_keys.key + foreign_key = join_keys.foreign_key - if reflection.source_macro == :has_and_belongs_to_many - join_table = tables.shift + constraint = table[key].eq(foreign_table[foreign_key]) - scope = scope.joins(join( - join_table, - table[reflection.association_primary_key]. - eq(join_table[reflection.association_foreign_key]) - )) + if reflection.type + value = transform_value(next_reflection.klass.base_class.name) + scope = scope.where(table.name => { reflection.type => value }) + end - table, foreign_table = join_table, tables.first - end + scope = scope.joins(join(foreign_table, constraint)) + end - if reflection.source_macro == :belongs_to - if reflection.options[:polymorphic] - key = reflection.association_primary_key(self.klass) - else - key = reflection.association_primary_key - end + class ReflectionProxy < SimpleDelegator # :nodoc: + attr_accessor :next + attr_reader :alias_name - foreign_key = reflection.foreign_key - else - key = reflection.foreign_key - foreign_key = reflection.active_record_primary_key - end + def initialize(reflection, alias_name) + super(reflection) + @alias_name = alias_name + end - if reflection == chain.last - bind_val = bind scope, table.table_name, key.to_s, owner[foreign_key] - scope = scope.where(table[key].eq(bind_val)) + def all_includes; nil; end + end - if reflection.type - value = owner.class.base_class.name - bind_val = bind scope, table.table_name, reflection.type.to_s, value - scope = scope.where(table[reflection.type].eq(bind_val)) - end - else - constraint = table[key].eq(foreign_table[foreign_key]) + def get_chain(reflection, association, tracker) + name = reflection.name + runtime_reflection = Reflection::RuntimeReflection.new(reflection, association) + previous_reflection = runtime_reflection + reflection.chain.drop(1).each do |refl| + alias_name = tracker.aliased_table_for(refl.table_name, refl.alias_candidate(name)) + proxy = ReflectionProxy.new(refl, alias_name) + previous_reflection.next = proxy + previous_reflection = proxy + end + [runtime_reflection, previous_reflection] + end - if reflection.type - value = chain[i + 1].klass.base_class.name - bind_val = bind scope, table.table_name, reflection.type.to_s, value - scope = scope.where(table[reflection.type].eq(bind_val)) - end + def add_constraints(scope, owner, association_klass, refl, chain_head, chain_tail) + owner_reflection = chain_tail + table = owner_reflection.alias_name + scope = last_chain_scope(scope, table, owner_reflection, owner, association_klass) - scope = scope.joins(join(foreign_table, constraint)) - end + reflection = chain_head + loop do + break unless reflection + table = reflection.alias_name - klass = i == 0 ? self.klass : reflection.klass + unless reflection == chain_tail + next_reflection = reflection.next + foreign_table = next_reflection.alias_name + scope = next_chain_scope(scope, table, reflection, association_klass, foreign_table, next_reflection) + end # Exclude the scope of the association itself, because that # was already merged in the #scope method. - scope_chain[i].each do |scope_chain_item| - item = eval_scope(klass, scope_chain_item) + reflection.constraints.each do |scope_chain_item| + item = eval_scope(reflection.klass, scope_chain_item, owner) + + if scope_chain_item == refl.scope + scope.merge! item.except(:where, :includes) + end - if scope_chain_item == self.reflection.scope - scope.merge! item.except(:where, :includes, :bind) + reflection.all_includes do + scope.includes! item.includes_values end - scope.includes! item.includes_values - scope.where_values += item.where_values + scope.where_clause += item.where_clause scope.order_values |= item.order_values end + + reflection = reflection.next end scope end - def alias_suffix - reflection.name - end - - def table_name_for(reflection) - if reflection == self.reflection - # If this is a polymorphic belongs_to, we want to get the klass from the - # association because it depends on the polymorphic_type attribute of - # the owner - klass.table_name - else - super - end - end - - def eval_scope(klass, scope) - if scope.is_a?(Relation) - scope - else - klass.unscoped.instance_exec(owner, &scope) - end + def eval_scope(klass, scope, owner) + klass.unscoped.instance_exec(owner, &scope) end end end diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 8eec4f56af..265a65c4c1 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -8,13 +8,16 @@ module ActiveRecord end def replace(record) - raise_on_type_mismatch!(record) if record - - update_counters(record) - replace_keys(record) - set_inverse_instance(record) - - @updated = true if record + if record + raise_on_type_mismatch!(record) + update_counters(record) + replace_keys(record) + set_inverse_instance(record) + @updated = true + else + decrement_counters + remove_keys + end self.target = record end @@ -28,64 +31,84 @@ module ActiveRecord @updated end + def decrement_counters # :nodoc: + with_cache_name { |name| decrement_counter name } + end + + def increment_counters # :nodoc: + with_cache_name { |name| increment_counter name } + end + private def find_target? !loaded? && foreign_key_present? && klass end - def update_counters(record) + def with_cache_name counter_cache_name = reflection.counter_cache_column + return unless counter_cache_name && owner.persisted? + yield counter_cache_name + end - if counter_cache_name && owner.persisted? && different_target?(record) - if record - record.class.increment_counter(counter_cache_name, record.id) - end + def update_counters(record) + with_cache_name do |name| + return unless different_target? record + record.class.increment_counter(name, record.id) + decrement_counter name + end + end + + def decrement_counter(counter_cache_name) + if foreign_key_present? + klass.decrement_counter(counter_cache_name, target_id) + end + end - if foreign_key_present? - klass.decrement_counter(counter_cache_name, target_id) + def increment_counter(counter_cache_name) + if foreign_key_present? + klass.increment_counter(counter_cache_name, target_id) + if target && !stale_target? + target.increment(counter_cache_name) end end end # Checks whether record is different to the current target, without loading it def different_target?(record) - if record.nil? - owner[reflection.foreign_key] - else - record.id != owner[reflection.foreign_key] - end + record.id != owner._read_attribute(reflection.foreign_key) end def replace_keys(record) - if record - owner[reflection.foreign_key] = record[reflection.association_primary_key(record.class)] - else - owner[reflection.foreign_key] = nil - end + owner[reflection.foreign_key] = record._read_attribute(reflection.association_primary_key(record.class)) + end + + def remove_keys + owner[reflection.foreign_key] = nil end def foreign_key_present? - owner[reflection.foreign_key] + owner._read_attribute(reflection.foreign_key) end # NOTE - for now, we're only supporting inverse setting from belongs_to back onto # has_one associations. def invertible_for?(record) inverse = inverse_reflection_for(record) - inverse && inverse.macro == :has_one + inverse && inverse.has_one? end def target_id if options[:primary_key] owner.send(reflection.name).try(:id) else - owner[reflection.foreign_key] + owner._read_attribute(reflection.foreign_key) end end def stale_state - owner[reflection.foreign_key] && owner[reflection.foreign_key].to_s + result = owner._read_attribute(reflection.foreign_key) + result && result.to_s end end end 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 eae5eed3a1..b710cf6bdb 100644 --- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb @@ -11,7 +11,12 @@ module ActiveRecord def replace_keys(record) super - owner[reflection.foreign_type] = record && record.class.base_class.name + owner[reflection.foreign_type] = record.class.base_class.name + end + + def remove_keys + super + owner[reflection.foreign_type] = nil end def different_target?(record) diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb index 34de1a1f32..88406740d8 100644 --- a/activerecord/lib/active_record/associations/builder/association.rb +++ b/activerecord/lib/active_record/associations/builder/association.rb @@ -8,7 +8,6 @@ # - HasOneAssociation # - CollectionAssociation # - HasManyAssociation -# - HasAndBelongsToManyAssociation module ActiveRecord::Associations::Builder class Association #:nodoc: @@ -17,11 +16,24 @@ module ActiveRecord::Associations::Builder end self.extensions = [] - VALID_OPTIONS = [:class_name, :foreign_key, :validate] - - attr_reader :name, :scope, :options + VALID_OPTIONS = [:class_name, :class, :foreign_key, :validate] # :nodoc: def self.build(model, name, scope, options, &block) + if model.dangerous_attribute_method?(name) + raise ArgumentError, "You tried to define an association named #{name} on the model #{model.name}, but " \ + "this will conflict with a method #{name} already defined by Active Record. " \ + "Please choose a different association name." + end + + extension = define_extensions model, name, &block + reflection = create_reflection model, name, scope, options, extension + define_accessors model, reflection + define_callbacks model, reflection + define_validations model, reflection + reflection + end + + def self.create_reflection(model, name, scope, options, extension = nil) raise ArgumentError, "association names must be a Symbol" unless name.kind_of?(Symbol) if scope.is_a?(Hash) @@ -29,47 +41,52 @@ module ActiveRecord::Associations::Builder scope = nil end - builder = new(name, scope, options, &block) - reflection = builder.build(model) - builder.define_accessors model - builder.define_callbacks model, reflection - builder.define_extensions model - reflection - end + validate_options(options) + + scope = build_scope(scope, extension) - def initialize(name, scope, options) - @name = name - @scope = scope - @options = options + ActiveRecord::Reflection.create(macro, name, scope, options, model) + end - validate_options + def self.build_scope(scope, extension) + new_scope = scope if scope && scope.arity == 0 - @scope = proc { instance_exec(&scope) } + new_scope = proc { instance_exec(&scope) } end + + if extension + new_scope = wrap_scope new_scope, extension + end + + new_scope end - def build(model) - ActiveRecord::Reflection.create(macro, name, scope, options, model) + def self.wrap_scope(scope, extension) + scope end - def macro + def self.macro raise NotImplementedError end - def valid_options + def self.valid_options(options) VALID_OPTIONS + Association.extensions.flat_map(&:valid_options) end - def validate_options - options.assert_valid_keys(valid_options) + def self.validate_options(options) + options.assert_valid_keys(valid_options(options)) end - def define_extensions(model) + def self.define_extensions(model, name) end - def define_callbacks(model, reflection) - add_before_destroy_callbacks(model, name) if options[:dependent] + def self.define_callbacks(model, reflection) + if dependent = reflection.options[:dependent] + check_dependent_options(dependent) + add_destroy_callbacks(model, reflection) + end + Association.extensions.each do |extension| extension.build model, reflection end @@ -81,14 +98,14 @@ module ActiveRecord::Associations::Builder # end # # Post.first.comments and Post.first.comments= methods are defined by this method... - - def define_accessors(model) - mixin = model.generated_feature_methods - define_readers(mixin) - define_writers(mixin) + def self.define_accessors(model, reflection) + mixin = model.generated_association_methods + name = reflection.name + define_readers(mixin, name) + define_writers(mixin, name) end - def define_readers(mixin) + def self.define_readers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name}(*args) association(:#{name}).reader(*args) @@ -96,7 +113,7 @@ module ActiveRecord::Associations::Builder CODE end - def define_writers(mixin) + def self.define_writers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name}=(value) association(:#{name}).writer(value) @@ -104,17 +121,22 @@ module ActiveRecord::Associations::Builder CODE end - def valid_dependent_options - raise NotImplementedError + def self.define_validations(model, reflection) + # noop end - private + def self.valid_dependent_options + raise NotImplementedError + end - def add_before_destroy_callbacks(model, name) - unless valid_dependent_options.include? options[:dependent] - raise ArgumentError, "The :dependent option must be one of #{valid_dependent_options}, but is :#{options[:dependent]}" + def self.check_dependent_options(dependent) + unless valid_dependent_options.include? dependent + raise ArgumentError, "The :dependent option must be one of #{valid_dependent_options}, but is :#{dependent}" end + end + def self.add_destroy_callbacks(model, reflection) + name = reflection.name model.before_destroy lambda { |o| o.association(name).handle_dependency } end end diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb index 4e88b50ec5..d0ad57f9c6 100644 --- a/activerecord/lib/active_record/associations/builder/belongs_to.rb +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -1,64 +1,39 @@ module ActiveRecord::Associations::Builder class BelongsTo < SingularAssociation #:nodoc: - def macro + def self.macro :belongs_to end - def valid_options - super + [:foreign_type, :polymorphic, :touch] + def self.valid_options(options) + super + [:foreign_type, :polymorphic, :touch, :counter_cache] end - def constructable? - !options[:polymorphic] - end - - def valid_dependent_options + def self.valid_dependent_options [:destroy, :delete] end - def define_callbacks(model, reflection) + def self.define_callbacks(model, reflection) super - add_counter_cache_callbacks(model, reflection) if options[:counter_cache] - add_touch_callbacks(model, reflection) if options[:touch] + add_counter_cache_callbacks(model, reflection) if reflection.options[:counter_cache] + add_touch_callbacks(model, reflection) if reflection.options[:touch] end - def define_accessors(mixin) + def self.define_accessors(mixin, reflection) super add_counter_cache_methods mixin end - private - - def add_counter_cache_methods(mixin) - return if mixin.method_defined? :belongs_to_counter_cache_after_create + def self.add_counter_cache_methods(mixin) + return if mixin.method_defined? :belongs_to_counter_cache_after_update mixin.class_eval do - def belongs_to_counter_cache_after_create(association, reflection) - if record = send(association.name) - cache_column = reflection.counter_cache_column - record.class.increment_counter(cache_column, record.id) - @_after_create_counter_called = true - end - end - - def belongs_to_counter_cache_before_destroy(association, reflection) - foreign_key = reflection.foreign_key.to_sym - unless destroyed_by_association && destroyed_by_association.foreign_key.to_sym == foreign_key - record = send association.name - if record && !self.destroyed? - cache_column = reflection.counter_cache_column - record.class.decrement_counter(cache_column, record.id) - end - end - end - - def belongs_to_counter_cache_after_update(association, reflection) + def belongs_to_counter_cache_after_update(reflection) foreign_key = reflection.foreign_key cache_column = reflection.counter_cache_column if (@_after_create_counter_called ||= false) @_after_create_counter_called = false - elsif attribute_changed?(foreign_key) && !new_record? && association.constructable? + elsif attribute_changed?(foreign_key) && !new_record? && reflection.constructable? model = reflection.klass foreign_key_was = attribute_was foreign_key foreign_key = attribute foreign_key @@ -74,20 +49,11 @@ module ActiveRecord::Associations::Builder end end - def add_counter_cache_callbacks(model, reflection) + def self.add_counter_cache_callbacks(model, reflection) cache_column = reflection.counter_cache_column - association = self - - model.after_create lambda { |record| - record.belongs_to_counter_cache_after_create(association, reflection) - } - - model.before_destroy lambda { |record| - record.belongs_to_counter_cache_before_destroy(association, reflection) - } model.after_update lambda { |record| - record.belongs_to_counter_cache_after_update(association, reflection) + record.belongs_to_counter_cache_after_update(reflection) } klass = reflection.class_name.safe_constantize @@ -98,7 +64,13 @@ module ActiveRecord::Associations::Builder old_foreign_id = o.changed_attributes[foreign_key] if old_foreign_id - klass = o.association(name).klass + association = o.association(name) + reflection = association.reflection + if reflection.polymorphic? + klass = o.public_send("#{reflection.foreign_type}_was").constantize + else + klass = association.klass + end old_record = klass.find_by(klass.primary_key => old_foreign_id) if old_record @@ -111,7 +83,7 @@ module ActiveRecord::Associations::Builder end record = o.send name - unless record.nil? || record.new_record? + if record && record.persisted? if touch != true record.touch touch else @@ -120,18 +92,23 @@ module ActiveRecord::Associations::Builder end end - def add_touch_callbacks(model, reflection) + def self.add_touch_callbacks(model, reflection) foreign_key = reflection.foreign_key - n = name - touch = options[:touch] + n = reflection.name + touch = reflection.options[:touch] callback = lambda { |record| BelongsTo.touch_record(record, foreign_key, n, touch) } - model.after_save callback + model.after_save callback, if: :changed? model.after_touch callback model.after_destroy callback end + + def self.add_destroy_callbacks(model, reflection) + name = reflection.name + model.after_destroy lambda { |o| o.association(name).handle_dependency } + end end end diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index 7bd0687c0b..2ff67f904d 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -1,4 +1,4 @@ -# This class is inherited by the has_many and has_many_and_belongs_to_many association classes +# This class is inherited by the has_many and has_many_and_belongs_to_many association classes require 'active_record/associations' @@ -7,35 +7,29 @@ module ActiveRecord::Associations::Builder CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove] - def valid_options + def self.valid_options(options) super + [:table_name, :before_add, :after_add, :before_remove, :after_remove, :extend] end - attr_reader :block_extension - - def initialize(name, scope, options) + def self.define_callbacks(model, reflection) super - @mod = nil - if block_given? - @mod = Module.new(&Proc.new) - @scope = wrap_scope @scope, @mod - end + name = reflection.name + options = reflection.options + CALLBACKS.each { |callback_name| + define_callback(model, callback_name, name, options) + } end - def define_callbacks(model, reflection) - super - CALLBACKS.each { |callback_name| define_callback(model, callback_name) } - end - - def define_extensions(model) - if @mod + def self.define_extensions(model, name) + if block_given? extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension" - model.parent.const_set(extension_module_name, @mod) + extension = Module.new(&Proc.new) + model.parent.const_set(extension_module_name, extension) end end - def define_callback(model, callback_name) + def self.define_callback(model, callback_name, name, options) full_callback_name = "#{callback_name}_for_#{name}" # TODO : why do i need method_defined? I think its because of the inheritance chain @@ -54,8 +48,7 @@ module ActiveRecord::Associations::Builder end # Defines the setter and getter methods for the collection_singular_ids. - - def define_readers(mixin) + def self.define_readers(mixin, name) super mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 @@ -65,7 +58,7 @@ module ActiveRecord::Associations::Builder CODE end - def define_writers(mixin) + def self.define_writers(mixin, name) super mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 @@ -75,9 +68,7 @@ module ActiveRecord::Associations::Builder CODE end - private - - def wrap_scope(scope, mod) + def self.wrap_scope(scope, mod) if scope proc { |owner| instance_exec(owner, &scope).extending(mod) } else diff --git a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb index 55ec3bf4f1..93dc4ae118 100644 --- a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb @@ -1,24 +1,124 @@ module ActiveRecord::Associations::Builder - class HasAndBelongsToMany < CollectionAssociation #:nodoc: - def macro - :has_and_belongs_to_many + class HasAndBelongsToMany # :nodoc: + class JoinTableResolver + KnownTable = Struct.new :join_table + + class KnownClass + def initialize(lhs_class, rhs_class_name) + @lhs_class = lhs_class + @rhs_class_name = rhs_class_name + @join_table = nil + end + + def join_table + @join_table ||= [@lhs_class.table_name, klass.table_name].sort.join("\0").gsub(/^(.*[._])(.+)\0\1(.+)/, '\1\2_\3').tr("\0", "_") + end + + private + + def klass + @lhs_class.send(:compute_type, @rhs_class_name) + end + end + + def self.build(lhs_class, name, options) + if options[:join_table] + KnownTable.new options[:join_table].to_s + else + class_name = options.fetch(:class_name) { + name.to_s.camelize.singularize + } + KnownClass.new lhs_class, class_name + end + end end - def valid_options - super + [:join_table, :association_foreign_key] + attr_reader :lhs_model, :association_name, :options + + def initialize(association_name, lhs_model, options) + @association_name = association_name + @lhs_model = lhs_model + @options = options end - def define_callbacks(model, reflection) - super - name = self.name - model.send(:include, Module.new { - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def destroy_associations - association(:#{name}).delete_all - super - end - RUBY - }) + def through_model + habtm = JoinTableResolver.build lhs_model, association_name, options + + join_model = Class.new(ActiveRecord::Base) { + class << self; + attr_accessor :class_resolver + attr_accessor :name + attr_accessor :table_name_resolver + attr_accessor :left_reflection + attr_accessor :right_reflection + end + + def self.table_name + table_name_resolver.join_table + end + + def self.compute_type(class_name) + class_resolver.compute_type class_name + end + + def self.add_left_association(name, options) + belongs_to name, options + self.left_reflection = _reflect_on_association(name) + end + + def self.add_right_association(name, options) + rhs_name = name.to_s.singularize.to_sym + belongs_to rhs_name, options + self.right_reflection = _reflect_on_association(rhs_name) + end + + } + + join_model.name = "HABTM_#{association_name.to_s.camelize}" + join_model.table_name_resolver = habtm + join_model.class_resolver = lhs_model + + join_model.add_left_association :left_side, class: lhs_model + join_model.add_right_association association_name, belongs_to_options(options) + join_model + end + + def middle_reflection(join_model) + middle_name = [lhs_model.name.downcase.pluralize, + association_name].join('_').gsub(/::/, '_').to_sym + middle_options = middle_options join_model + + HasMany.create_reflection(lhs_model, + middle_name, + nil, + middle_options) + end + + private + + def middle_options(join_model) + middle_options = {} + middle_options[:class_name] = "#{lhs_model.name}::#{join_model.name}" + middle_options[:source] = join_model.left_reflection.name + if options.key? :foreign_key + middle_options[:foreign_key] = options[:foreign_key] + end + middle_options + end + + def belongs_to_options(options) + rhs_options = {} + + if options.key? :class_name + rhs_options[:foreign_key] = options[:class_name].to_s.foreign_key + rhs_options[:class_name] = options[:class_name] + end + + if options.key? :association_foreign_key + rhs_options[:foreign_key] = options[:association_foreign_key] + end + + rhs_options end end end diff --git a/activerecord/lib/active_record/associations/builder/has_many.rb b/activerecord/lib/active_record/associations/builder/has_many.rb index a60cb4769a..1c1b47bd56 100644 --- a/activerecord/lib/active_record/associations/builder/has_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_many.rb @@ -1,14 +1,14 @@ module ActiveRecord::Associations::Builder class HasMany < CollectionAssociation #:nodoc: - def macro + def self.macro :has_many end - def valid_options - super + [:primary_key, :dependent, :as, :through, :source, :source_type, :inverse_of, :counter_cache] + def self.valid_options(options) + super + [:primary_key, :dependent, :as, :through, :source, :source_type, :inverse_of, :counter_cache, :join_table, :foreign_type] end - def valid_dependent_options + def self.valid_dependent_options [:destroy, :delete_all, :nullify, :restrict_with_error, :restrict_with_exception] end end diff --git a/activerecord/lib/active_record/associations/builder/has_one.rb b/activerecord/lib/active_record/associations/builder/has_one.rb index 62d454ce55..64e9e6b334 100644 --- a/activerecord/lib/active_record/associations/builder/has_one.rb +++ b/activerecord/lib/active_record/associations/builder/has_one.rb @@ -1,27 +1,21 @@ module ActiveRecord::Associations::Builder class HasOne < SingularAssociation #:nodoc: - def macro + def self.macro :has_one end - def valid_options - valid = super + [:order, :as] + def self.valid_options(options) + valid = super + [:as, :foreign_type] valid += [:through, :source, :source_type] if options[:through] valid end - def constructable? - !options[:through] - end - - def valid_dependent_options + def self.valid_dependent_options [:destroy, :delete, :nullify, :restrict_with_error, :restrict_with_exception] end - private - - def add_before_destroy_callbacks(model, name) - super unless options[:through] + def self.add_destroy_callbacks(model, reflection) + super unless reflection.options[:through] end end end diff --git a/activerecord/lib/active_record/associations/builder/singular_association.rb b/activerecord/lib/active_record/associations/builder/singular_association.rb index d97c0e9afd..f6274c027e 100644 --- a/activerecord/lib/active_record/associations/builder/singular_association.rb +++ b/activerecord/lib/active_record/associations/builder/singular_association.rb @@ -1,23 +1,18 @@ -# This class is inherited by the has_one and belongs_to association classes +# This class is inherited by the has_one and belongs_to association classes module ActiveRecord::Associations::Builder class SingularAssociation < Association #:nodoc: - def valid_options - super + [:remote, :dependent, :counter_cache, :primary_key, :inverse_of] + def self.valid_options(options) + super + [:dependent, :primary_key, :inverse_of, :required] end - def constructable? - true - end - - def define_accessors(model) + def self.define_accessors(model, reflection) super - define_constructors(model.generated_feature_methods) if constructable? + define_constructors(model.generated_association_methods, reflection.name) if reflection.constructable? end # Defines the (build|create)_association methods for belongs_to or has_one association - - def define_constructors(mixin) + def self.define_constructors(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def build_#{name}(*args, &block) association(:#{name}).build(*args, &block) @@ -32,5 +27,12 @@ module ActiveRecord::Associations::Builder end CODE end + + def self.define_validations(model, reflection) + super + if reflection.options[:required] + model.validates_presence_of reflection.name, message: :required + end + end end end diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index 228c500f0a..4b7591e15c 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -4,10 +4,9 @@ module ActiveRecord # # CollectionAssociation is an abstract class that provides common stuff to # ease the implementation of association proxies that represent - # collections. See the class hierarchy in AssociationProxy. + # collections. See the class hierarchy in Association. # # CollectionAssociation: - # HasAndBelongsToManyAssociation => has_and_belongs_to_many # HasManyAssociation => has_many # HasManyThroughAssociation + ThroughAssociation => has_many :through # @@ -34,7 +33,13 @@ module ActiveRecord reload end - @proxy ||= CollectionProxy.create(klass, self) + if owner.new_record? + # Cache the proxy separately before the owner has an id + # or else a post-save proxy will still lack the id + @new_record_proxy ||= CollectionProxy.create(klass, self) + else + @proxy ||= CollectionProxy.create(klass, self) + end end # Implements the writer method, e.g. foo.items= for Foo.has_many :items @@ -56,10 +61,10 @@ module ActiveRecord # Implements the ids writer method, e.g. foo.item_ids= for Foo.has_many :items def ids_writer(ids) - pk_column = reflection.primary_key_column - ids = Array(ids).reject { |id| id.blank? } - ids.map! { |i| pk_column.type_cast(i) } - replace(klass.find(ids).index_by { |r| r.id }.values_at(*ids)) + pk_type = reflection.primary_key_type + ids = Array(ids).reject(&:blank?) + ids.map! { |i| pk_type.type_cast_from_user(i) } + replace(klass.find(ids).index_by(&:id).values_at(*ids)) end def reset @@ -67,11 +72,11 @@ module ActiveRecord @target = [] end - def select(select = nil) + def select(*fields) if block_given? load_target.select.each { |e| yield e } else - scope.select(select) + scope.select(*fields) end end @@ -80,14 +85,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 @@ -98,11 +102,31 @@ module ActiveRecord end def first(*args) - first_or_last(:first, *args) + first_nth_or_last(:first, *args) + end + + def second(*args) + first_nth_or_last(:second, *args) + end + + def third(*args) + first_nth_or_last(:third, *args) + end + + def fourth(*args) + first_nth_or_last(:fourth, *args) + end + + def fifth(*args) + first_nth_or_last(:fifth, *args) + end + + def forty_two(*args) + first_nth_or_last(:forty_two, *args) end def last(*args) - first_or_last(:last, *args) + first_nth_or_last(:last, *args) end def build(attributes = {}, &block) @@ -116,20 +140,20 @@ module ActiveRecord end def create(attributes = {}, &block) - create_record(attributes, &block) + _create_record(attributes, &block) end def create!(attributes = {}, &block) - create_record(attributes, true, &block) + _create_record(attributes, true, &block) end # Add +records+ to this association. Returns +self+ so method calls may # be chained. Since << flattens its argument list and inserts each record, # +push+ and +concat+ behave identically. def concat(*records) - load_target if owner.new_record? - + records = records.flatten if owner.new_record? + load_target concat_records(records) else transaction { concat_records(records) } @@ -152,8 +176,8 @@ module ActiveRecord end # Removes all records from the association without calling callbacks - # on the associated records. It honors the `:dependent` option. However - # if the `:dependent` value is `:destroy` then in that case the default + # on the associated records. It honors the +:dependent+ option. However + # if the +:dependent+ value is +:destroy+ then in that case the +:delete_all+ # deletion strategy for the association is applied. # # You can force a particular deletion strategy by passing a parameter. @@ -165,21 +189,19 @@ module ActiveRecord # # See delete for more info. def delete_all(dependent = nil) - if dependent.present? && ![:nullify, :delete_all].include?(dependent) + if dependent && ![:nullify, :delete_all].include?(dependent) raise ArgumentError, "Valid values are :nullify or :delete_all" end - dependent = if dependent.present? + dependent = if dependent dependent elsif options[:dependent] == :destroy - # since delete_all should not invoke callbacks so use the default deletion strategy - # for :destroy - reflection.is_a?(ActiveRecord::Reflection::ThroughReflection) ? :delete_all : :nullify + :delete_all else options[:dependent] end - delete(:all, dependent: dependent).tap do + delete_or_nullify_all_records(dependent).tap do reset loaded! end @@ -197,9 +219,7 @@ module ActiveRecord # Count all records using SQL. Construct options and pass them with # scope to the target class's +count+. - def count(column_name = nil, count_options = {}) - column_name, count_options = nil, column_name if column_name.is_a?(Hash) - + def count(column_name = nil) relation = scope if association_scope.distinct_value # This is needed because 'SELECT count(DISTINCT *)..' is not valid SQL. @@ -227,19 +247,12 @@ module ActiveRecord # are actually removed from the database, that depends precisely on # +delete_records+. They are in any case removed from the collection. def delete(*records) + return if records.empty? _options = records.extract_options! dependent = _options[:dependent] || options[:dependent] - if records.first == :all - if loaded? || dependent == :destroy - delete_or_destroy(load_target, dependent) - else - delete_records(:all, dependent) - end - else - records = find(records) if records.any? { |record| record.kind_of?(Fixnum) || record.kind_of?(String) } - delete_or_destroy(records, dependent) - end + records = find(records) if records.any? { |record| record.kind_of?(Fixnum) || record.kind_of?(String) } + delete_or_destroy(records, dependent) end # Deletes the +records+ and removes them from this association calling @@ -248,6 +261,7 @@ module ActiveRecord # Note that this method removes records from the database ignoring the # +:dependent+ option. def destroy(*records) + return if records.empty? records = find(records) if records.any? { |record| record.kind_of?(Fixnum) || record.kind_of?(String) } delete_or_destroy(records, :destroy) end @@ -272,7 +286,7 @@ module ActiveRecord elsif !loaded? && !association_scope.group_values.empty? load_target.size elsif !loaded? && !association_scope.distinct_value && target.is_a?(Array) - unsaved_records = target.select { |r| r.new_record? } + unsaved_records = target.select(&:new_record?) unsaved_records.size + count_records else count_records @@ -341,7 +355,10 @@ module ActiveRecord if owner.new_record? replace_records(other_array, original_target) else - transaction { replace_records(other_array, original_target) } + replace_common_records_in_memory(other_array, original_target) + if other_array != original_target + transaction { replace_records(other_array, original_target) } + end end end @@ -350,7 +367,7 @@ module ActiveRecord if record.new_record? include_in_memory?(record) else - loaded? ? target.include?(record) : scope.exists?(record) + loaded? ? target.include?(record) : scope.exists?(record.id) end else false @@ -366,11 +383,18 @@ module ActiveRecord target end - def add_to_target(record, skip_callbacks = false) + def add_to_target(record, skip_callbacks = false, &block) + if association_scope.distinct_value + index = @target.index(record) + end + replace_on_target(record, index, skip_callbacks, &block) + end + + def replace_on_target(record, index, skip_callbacks) callback(:before_add, record) unless skip_callbacks yield(record) if block_given? - if association_scope.distinct_value && index = @target.index(record) + if index @target[index] = record else @target << record @@ -393,9 +417,29 @@ module ActiveRecord end private + def get_records + if reflection.scope_chain.any?(&:any?) || + scope.eager_loading? || + klass.current_scope || + klass.default_scopes.any? + + return scope.to_a + end + + conn = klass.connection + sc = reflection.association_scope_cache(conn, owner) do + StatementCache.create(conn) { |params| + as = AssociationScope.create { params.bind } + target_scope.merge as.scope(self, conn) + } + end + + binds = AssociationScope.get_bind_values(owner, reflection.chain) + sc.execute binds, klass, klass.connection + end def find_target - records = scope.to_a + records = get_records records.each { |record| set_inverse_instance(record) } records end @@ -430,13 +474,13 @@ module ActiveRecord persisted + memory end - def create_record(attributes, raise = false, &block) + def _create_record(attributes, raise = false, &block) unless owner.persisted? raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved" end if attributes.is_a?(Array) - attributes.collect { |attr| create_record(attr, raise, &block) } + attributes.collect { |attr| _create_record(attr, raise, &block) } else transaction do add_to_target(build_record(attributes)) do |record| @@ -459,7 +503,7 @@ module ActiveRecord def delete_or_destroy(records, method) records = records.flatten records.each { |record| raise_on_type_mismatch!(record) } - existing_records = records.reject { |r| r.new_record? } + existing_records = records.reject(&:new_record?) if existing_records.empty? remove_records(existing_records, records, method) @@ -495,13 +539,21 @@ module ActiveRecord target end - def concat_records(records) + def replace_common_records_in_memory(new_target, original_target) + common_records = new_target & original_target + common_records.each do |record| + skip_callbacks = true + replace_on_target(record, @target.index(record), skip_callbacks) + end + end + + def concat_records(records, should_raise = false) result = true - records.flatten.each do |record| + records.each do |record| raise_on_type_mismatch!(record) add_to_target(record) do |rec| - result &&= insert_record(rec) unless owner.new_record? + result &&= insert_record(rec, true, should_raise) unless owner.new_record? end end @@ -528,23 +580,22 @@ module ActiveRecord # * target already loaded # * owner is new record # * target contains new or changed record(s) - # * the first arg is an integer (which indicates the number of records to be returned) - def fetch_first_or_last_using_find?(args) + def fetch_first_nth_or_last_using_find?(args) if args.first.is_a?(Hash) true else !(loaded? || owner.new_record? || - target.any? { |record| record.new_record? || record.changed? } || - args.first.kind_of?(Integer)) + target.any? { |record| record.new_record? || record.changed? }) end end def include_in_memory?(record) if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection) - owner.send(reflection.through_reflection.name).any? { |source| - target = source.send(reflection.source_reflection.name) - target.respond_to?(:include?) ? target.include?(record) : target == record + assoc = owner.association(reflection.through_reflection.name) + assoc.reader.any? { |source| + target_reflection = source.send(reflection.source_reflection.name) + target_reflection.respond_to?(:include?) ? target_reflection.include?(record) : target_reflection == record } || target.include?(record) else target.include?(record) @@ -555,22 +606,22 @@ module ActiveRecord # specified, then #find scans the entire collection. def find_by_scan(*args) expects_array = args.first.kind_of?(Array) - ids = args.flatten.compact.map{ |arg| arg.to_i }.uniq + ids = args.flatten.compact.map(&:to_s).uniq if ids.size == 1 id = ids.first - record = load_target.detect { |r| id == r.id } + record = load_target.detect { |r| id == r.id.to_s } expects_array ? [ record ] : record else - load_target.select { |r| ids.include?(r.id) } + load_target.select { |r| ids.include?(r.id.to_s) } end end # Fetches the first/last using SQL if possible, otherwise from the target array. - def first_or_last(type, *args) + def first_nth_or_last(type, *args) args.shift if args.first.is_a?(Hash) && args.first.empty? - collection = fetch_first_or_last_using_find?(args) ? scope : load_target + collection = fetch_first_nth_or_last_using_find?(args) ? scope : load_target collection.send(type, *args).tap do |record| set_inverse_instance record if record.is_a? ActiveRecord::Base end diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index ea7f768a68..c22dc6e11e 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -29,10 +29,11 @@ module ActiveRecord # instantiation of the actual post records. class CollectionProxy < Relation delegate(*(ActiveRecord::Calculations.public_instance_methods - [:count]), to: :scope) + delegate :find_nth, to: :scope def initialize(klass, association) #:nodoc: @association = association - super klass, klass.arel_table + super klass, klass.arel_table, klass.predicate_builder merge! association.scope(nullify: false) end @@ -75,7 +76,7 @@ module ActiveRecord # # #<Pet id: nil, name: "Choo-Choo"> # # ] # - # person.pets.select([:id, :name]) + # person.pets.select(:id, :name ) # # => [ # # #<Pet id: 1, name: "Fancy-Fancy">, # # #<Pet id: 2, name: "Spook">, @@ -84,7 +85,7 @@ module ActiveRecord # # Be careful because this also means you're initializing a model # object with only the fields that you've selected. If you attempt - # to access a field that is not in the initialized record you'll + # to access a field except +id+ that is not in the initialized record you'll # receive: # # person.pets.select(:name).first.person_id @@ -106,13 +107,13 @@ module ActiveRecord # # #<Pet id: 2, name: "Spook">, # # #<Pet id: 3, name: "Choo-Choo"> # # ] - def select(select = nil, &block) - @association.select(select, &block) + def select(*fields, &block) + @association.select(*fields, &block) end # Finds an object in the collection responding to the +id+. Uses the same # rules as <tt>ActiveRecord::Base.find</tt>. Returns <tt>ActiveRecord::RecordNotFound</tt> - # error if the object can not be found. + # error if the object cannot be found. # # class Person < ActiveRecord::Base # has_many :pets @@ -170,6 +171,32 @@ module ActiveRecord @association.first(*args) end + # Same as +first+ except returns only the second record. + def second(*args) + @association.second(*args) + end + + # Same as +first+ except returns only the third record. + def third(*args) + @association.third(*args) + end + + # Same as +first+ except returns only the fourth record. + def fourth(*args) + @association.fourth(*args) + end + + # Same as +first+ except returns only the fifth record. + def fifth(*args) + @association.fifth(*args) + end + + # Same as +first+ except returns only the forty second record. + # Also known as accessing "the reddit". + def forty_two(*args) + @association.forty_two(*args) + end + # Returns the last record, or the last +n+ records, from the collection. # If the collection is empty, the first form returns +nil+, and the second # form returns an empty array. @@ -281,7 +308,7 @@ module ActiveRecord # so method calls may be chained. # # class Person < ActiveRecord::Base - # pets :has_many + # has_many :pets # end # # person.pets.size # => 0 @@ -329,14 +356,15 @@ module ActiveRecord @association.replace(other_array) end - # Deletes all the records from the collection. For +has_many+ associations, - # the deletion is done according to the strategy specified by the <tt>:dependent</tt> - # option. Returns an array with the deleted records. + # Deletes all the records from the collection according to the strategy + # specified by the +:dependent+ option. If no +:dependent+ option is given, + # then it will follow the default strategy. + # + # For +has_many :through+ associations, the default deletion strategy is + # +:delete_all+. # - # If no <tt>:dependent</tt> option is given, then it will follow the - # default strategy. The default strategy is <tt>:nullify</tt>. This - # sets the foreign keys to <tt>NULL</tt>. For, +has_many+ <tt>:through</tt>, - # the default strategy is +delete_all+. + # For +has_many+ associations, the default deletion strategy is +:nullify+. + # This sets the foreign keys to +NULL+. # # class Person < ActiveRecord::Base # has_many :pets # dependent: :nullify option by default @@ -367,9 +395,9 @@ module ActiveRecord # # #<Pet id: 3, name: "Choo-Choo", person_id: nil> # # ] # - # If it is set to <tt>:destroy</tt> all the objects from the collection - # are removed by calling their +destroy+ method. See +destroy+ for more - # information. + # Both +has_many+ and +has_many :through+ dependencies default to the + # +:delete_all+ strategy if the +:dependent+ option is set to +:destroy+. + # Records are not instantiated and callbacks will not be fired. # # class Person < ActiveRecord::Base # has_many :pets, dependent: :destroy @@ -384,11 +412,6 @@ module ActiveRecord # # ] # # person.pets.delete_all - # # => [ - # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>, - # # #<Pet id: 2, name: "Spook", person_id: 1>, - # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> - # # ] # # Pet.find(1, 2, 3) # # => ActiveRecord::RecordNotFound @@ -409,11 +432,6 @@ module ActiveRecord # # ] # # person.pets.delete_all - # # => [ - # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>, - # # #<Pet id: 2, name: "Spook", person_id: 1>, - # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> - # # ] # # Pet.find(1, 2, 3) # # => ActiveRecord::RecordNotFound @@ -422,8 +440,9 @@ module ActiveRecord end # Deletes the records of the collection directly from the database - # ignoring the +:dependent+ option. It invokes +before_remove+, - # +after_remove+ , +before_destroy+ and +after_destroy+ callbacks. + # ignoring the +:dependent+ option. Records are instantiated and it + # invokes +before_remove+, +after_remove+ , +before_destroy+ and + # +after_destroy+ callbacks. # # class Person < ActiveRecord::Base # has_many :pets @@ -669,8 +688,8 @@ module ActiveRecord # # #<Pet id: 2, name: "Spook", person_id: 1>, # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> # # ] - def count(column_name = nil, options = {}) - @association.count(column_name, options) + def count(column_name = nil) + @association.count(column_name) end # Returns the size of the collection. If the collection hasn't been loaded, @@ -760,7 +779,7 @@ module ActiveRecord # person.pets.count # => 0 # person.pets.any? # => true # - # You can also pass a block to define criteria. The behavior + # You can also pass a +block+ to define criteria. The behavior # is the same, it returns true if the collection based on the # criteria is not empty. # @@ -787,14 +806,14 @@ module ActiveRecord # has_many :pets # end # - # person.pets.count #=> 1 - # person.pets.many? #=> false + # person.pets.count # => 1 + # person.pets.many? # => false # # person.pets << Pet.new(name: 'Snoopy') - # person.pets.count #=> 2 - # person.pets.many? #=> true + # person.pets.count # => 2 + # person.pets.many? # => true # - # You can also pass a block to define criteria. The + # You can also pass a +block+ to define criteria. The # behavior is the same, it returns true if the collection # based on the criteria has more than one record. # @@ -818,7 +837,7 @@ module ActiveRecord @association.many?(&block) end - # Returns +true+ if the given object is present in the collection. + # Returns +true+ if the given +record+ is present in the collection. # # class Person < ActiveRecord::Base # has_many :pets @@ -832,6 +851,10 @@ module ActiveRecord !!@association.include?(record) end + def arel + scope.arel + end + def proxy_association @association end @@ -852,7 +875,7 @@ module ActiveRecord # Equivalent to <tt>Array#==</tt>. Returns +true+ if the two arrays # contain the same number of elements and if each element is equal - # to the corresponding element in the other array, otherwise returns + # to the corresponding element in the +other+ array, otherwise returns # +false+. # # class Person < ActiveRecord::Base @@ -976,6 +999,28 @@ module ActiveRecord proxy_association.reload self end + + # Unloads the association. Returns +self+. + # + # class Person < ActiveRecord::Base + # has_many :pets + # end + # + # person.pets # fetches pets from the database + # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] + # + # person.pets # uses the pets cache + # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] + # + # person.pets.reset # clears the pets cache + # + # person.pets # fetches pets from the database + # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] + def reset + proxy_association.reset + proxy_association.reset_scope + self + end end end end diff --git a/activerecord/lib/active_record/associations/foreign_association.rb b/activerecord/lib/active_record/associations/foreign_association.rb new file mode 100644 index 0000000000..fe48ecec29 --- /dev/null +++ b/activerecord/lib/active_record/associations/foreign_association.rb @@ -0,0 +1,11 @@ +module ActiveRecord::Associations + module ForeignAssociation + def foreign_key_present? + if reflection.klass.primary_key + owner.attribute_present?(reflection.active_record_primary_key) + else + false + end + end + end +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 deleted file mode 100644 index b2e6c708bf..0000000000 --- a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb +++ /dev/null @@ -1,56 +0,0 @@ -module ActiveRecord - # = Active Record Has And Belongs To Many Association - module Associations - class HasAndBelongsToManyAssociation < CollectionAssociation #:nodoc: - attr_reader :join_table - - def initialize(owner, reflection) - @join_table = Arel::Table.new(reflection.join_table) - super - end - - def insert_record(record, validate = true, raise = false) - if record.new_record? - if raise - record.save!(:validate => validate) - else - return unless record.save(:validate => validate) - end - end - - stmt = join_table.compile_insert( - join_table[reflection.foreign_key] => owner.id, - join_table[reflection.association_foreign_key] => record.id - ) - - owner.class.connection.insert stmt - - record - end - - private - - def count_records - load_target.size - end - - def delete_records(records, method) - relation = join_table - condition = relation[reflection.foreign_key].eq(owner.id) - - unless records == :all - condition = condition.and( - relation[reflection.association_foreign_key] - .in(records.map { |x| x.id }.compact) - ) - end - - owner.class.connection.delete(relation.where(condition).compile_delete) - end - - def invertible_for?(record) - false - end - end - end -end diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index 607ed0da46..ca27c9fdde 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -6,6 +6,7 @@ module ActiveRecord # If the association has a <tt>:through</tt> option further specialization # is provided by its child HasManyThroughAssociation. class HasManyAssociation < CollectionAssociation #:nodoc: + include ForeignAssociation def handle_dependency case options[:dependent] @@ -16,7 +17,7 @@ module ActiveRecord unless empty? record = klass.human_attribute_name(reflection.name).downcase owner.errors.add(:base, :"restrict_dependent_destroy.many", record: record) - false + throw(:abort) end else @@ -32,6 +33,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) @@ -40,6 +42,14 @@ module ActiveRecord end end + def empty? + if has_cached_counter? + size.zero? + else + super + end + end + private # Returns the number of records in this collection. @@ -57,7 +67,7 @@ module ActiveRecord # the loaded flag is set to true as well. def count_records count = if has_cached_counter? - owner.send(:read_attribute, cached_counter_attribute_name) + owner._read_attribute cached_counter_attribute_name else scope.count end @@ -70,20 +80,35 @@ module ActiveRecord [association_scope.limit_value, count].compact.min end - def has_cached_counter?(reflection = reflection) + def has_cached_counter?(reflection = reflection()) owner.attribute_present?(cached_counter_attribute_name(reflection)) end - def cached_counter_attribute_name(reflection = reflection) - options[:counter_cache] || "#{reflection.name}_count" + def cached_counter_attribute_name(reflection = reflection()) + if reflection.options[:counter_cache] + reflection.options[:counter_cache].to_s + else + "#{reflection.name}_count" + end + end + + def update_counter(difference, reflection = reflection()) + update_counter_in_database(difference, reflection) + update_counter_in_memory(difference, reflection) end - def update_counter(difference, reflection = reflection) + def update_counter_in_database(difference, reflection = reflection()) if has_cached_counter?(reflection) counter = cached_counter_attribute_name(reflection) owner.class.update_counters(owner.id, counter => difference) + end + end + + def update_counter_in_memory(difference, reflection = reflection()) + if counter_must_be_updated_by_has_many?(reflection) + counter = cached_counter_attribute_name(reflection) owner[counter] += difference - owner.changed_attributes.delete(counter) # eww + owner.send(:clear_attribute_changes, counter) # eww end end @@ -97,35 +122,69 @@ module ActiveRecord # it will be decremented twice. # # Hence this method. - def inverse_updates_counter_cache?(reflection = reflection) + def inverse_which_updates_counter_cache(reflection = reflection()) counter_name = cached_counter_attribute_name(reflection) - reflection.klass.reflect_on_all_associations(:belongs_to).any? { |inverse_reflection| + inverse_which_updates_counter_named(counter_name, reflection) + end + alias inverse_updates_counter_cache? inverse_which_updates_counter_cache + + def inverse_which_updates_counter_named(counter_name, reflection) + reflection.klass._reflections.values.find { |inverse_reflection| + inverse_reflection.belongs_to? && inverse_reflection.counter_cache_column == counter_name } end + def inverse_updates_counter_in_memory?(reflection) + inverse = inverse_which_updates_counter_cache(reflection) + inverse && inverse == reflection.inverse_of + end + + def counter_must_be_updated_by_has_many?(reflection) + !inverse_updates_counter_in_memory?(reflection) && has_cached_counter?(reflection) + end + + def delete_count(method, scope) + if method == :delete_all + scope.delete_all + else + scope.update_all(reflection.foreign_key => nil) + end + end + + def delete_or_nullify_all_records(method) + count = delete_count(method, self.scope) + update_counter(-count) + end + # Deletes the records according to the <tt>:dependent</tt> option. def delete_records(records, method) if method == :destroy - records.each { |r| r.destroy } + records.each(&:destroy!) update_counter(-records.length) unless inverse_updates_counter_cache? else - if records == :all - scope = self.scope - else - scope = self.scope.where(reflection.klass.primary_key => records) - end - - if method == :delete_all - update_counter(-scope.delete_all) - else - update_counter(-scope.update_all(reflection.foreign_key => nil)) - end + scope = self.scope.where(reflection.klass.primary_key => records) + update_counter(-delete_count(method, scope)) end end - def foreign_key_present? - owner.attribute_present?(reflection.association_primary_key) + def concat_records(records, *) + update_counter_if_success(super, records.length) + end + + def _create_record(attributes, *) + if attributes.is_a?(Array) + super + else + update_counter_if_success(super, 1) + end + end + + def update_counter_if_success(saved_successfully, difference) + if saved_successfully + update_counter_in_memory(difference) + end + saved_successfully end end end diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index a74dd1cdab..4897ec44e9 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,3 @@ - module ActiveRecord # = Active Record Has Many Through Association module Associations @@ -12,25 +11,10 @@ module ActiveRecord @through_association = nil end - # Returns the size of the collection by executing a SELECT COUNT(*) query if the collection hasn't been - # loaded and calling collection.size if it has. If it's more likely than not that the collection does - # have a size larger than zero, and you need to fetch that collection afterwards, it'll take one fewer - # SELECT query if you use #length. - def size - if has_cached_counter? - owner.send(:read_attribute, cached_counter_attribute_name) - elsif loaded? - target.size - else - count - end - end - def concat(*records) unless owner.new_record? records.flatten.each do |record| raise_on_type_mismatch!(record) - record.save! if record.new_record? end end @@ -40,7 +24,7 @@ module ActiveRecord def concat_records(records) ensure_not_nested - records = super + records = super(records, true) if owner.new_record? && records records.flatten.each do |record| @@ -63,7 +47,7 @@ module ActiveRecord end save_through_record(record) - update_counter(1) + record end @@ -73,23 +57,31 @@ module ActiveRecord @through_association ||= owner.association(through_reflection.name) end - # We temporarily cache through record that has been build, because if we build a - # through record in build_record and then subsequently call insert_record, then we - # want to use the exact same object. + # The through record (built with build_record) is temporarily cached + # so that it may be reused if insert_record is subsequently called. # - # However, after insert_record has been called, we clear the cache entry because - # we want it to be possible to have multiple instances of the same record in an - # association + # However, after insert_record has been called, the cache is cleared in + # order to allow multiple instances of the same record in an association. def build_through_record(record) @through_records[record.object_id] ||= begin ensure_mutable - through_record = through_association.build + through_record = through_association.build(*options_for_through_record) through_record.send("#{source_reflection.name}=", record) through_record end end + def options_for_through_record + [through_scope_attributes] + end + + def through_scope_attributes + scope.where_values_hash(through_association.reflection.name.to_s). + except!(through_association.reflection.foreign_key, + through_association.reflection.klass.inheritance_column) + end + def save_through_record(record) build_through_record(record).save! ensure @@ -103,9 +95,9 @@ module ActiveRecord inverse = source_reflection.inverse_of if inverse - if inverse.macro == :has_many + if inverse.collection? record.send(inverse.name) << build_through_record(record) - elsif inverse.macro == :has_one + elsif inverse.has_one? record.send("#{inverse.name}=", build_through_record(record)) end end @@ -114,7 +106,7 @@ module ActiveRecord end def target_reflection_has_associated_record? - !(through_reflection.macro == :belongs_to && owner[through_reflection.foreign_key].blank?) + !(through_reflection.belongs_to? && owner[through_reflection.foreign_key].blank?) end def update_through_counter?(method) @@ -128,19 +120,31 @@ module ActiveRecord end end + def delete_or_nullify_all_records(method) + delete_records(load_target, method) + end + def delete_records(records, method) ensure_not_nested - # This is unoptimised; it will load all the target records - # even when we just want to delete everything. - records = load_target if records == :all - scope = through_association.scope scope.where! construct_join_attributes(*records) case method when :destroy - count = scope.destroy_all.length + if scope.klass.primary_key + count = scope.destroy_all.length + else + scope.each(&:_run_destroy_callbacks) + + arel = scope.arel + + stmt = Arel::DeleteManager.new + stmt.from scope.klass.arel_table + stmt.wheres = arel.constraints + + count = scope.klass.connection.delete(stmt, 'SQL', scope.bound_attributes) + end when :nullify count = scope.update_all(source_reflection.foreign_key => nil) else @@ -149,29 +153,33 @@ module ActiveRecord delete_through_records(records) - if source_reflection.options[:counter_cache] + if source_reflection.options[:counter_cache] && method != :destroy counter = source_reflection.counter_cache_column klass.decrement_counter counter, records.map(&:id) end - if through_reflection.macro == :has_many && update_through_counter?(method) + if through_reflection.collection? && update_through_counter?(method) update_counter(-count, through_reflection) + else + update_counter(-count) end - - update_counter(-count) end def through_records_for(record) attributes = construct_join_attributes(record) candidates = Array.wrap(through_association.target) - candidates.find_all { |c| c.attributes.slice(*attributes.keys) == attributes } + candidates.find_all do |c| + attributes.all? do |key, value| + c.public_send(key) == value + end + end end def delete_through_records(records) records.each do |record| through_records = through_records_for(record) - if through_reflection.macro == :has_many + if through_reflection.collection? through_records.each { |r| through_association.target.delete(r) } else if through_records.include?(through_association.target) @@ -185,7 +193,7 @@ module ActiveRecord def find_target return [] unless target_reflection_has_associated_record? - scope.to_a + get_records end # NOTE - not sure that we can actually cope with inverses here diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index 3ab1ea1ff4..41a75b820e 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -1,8 +1,8 @@ - module ActiveRecord # = Active Record Belongs To Has One Association module Associations class HasOneAssociation < SingularAssociation #:nodoc: + include ForeignAssociation def handle_dependency case options[:dependent] @@ -13,7 +13,7 @@ module ActiveRecord if load_target record = klass.human_attribute_name(reflection.name).downcase owner.errors.add(:base, :"restrict_dependent_destroy.one", record: record) - false + throw(:abort) end else @@ -26,15 +26,19 @@ module ActiveRecord load_target return self.target if !(target || record) - if (target != record) || record.changed? + + assigning_another_record = target != record + if assigning_another_record || record.changed? + save &&= owner.persisted? + transaction_if(save) do - remove_target!(options[:dependent]) if target && !target.destroyed? + remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record if record set_owner_attributes(record) set_inverse_instance(record) - if owner.persisted? && save && !record.save + if save && !record.save nullify_owner_attributes(record) set_owner_attributes(target) if target raise RecordNotSaved, "Failed to save the new associated #{reflection.name}." diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb index 5aa17e5fbb..fcf06323e6 100644 --- a/activerecord/lib/active_record/associations/join_dependency.rb +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -1,15 +1,79 @@ module ActiveRecord module Associations class JoinDependency # :nodoc: - autoload :JoinPart, 'active_record/associations/join_dependency/join_part' autoload :JoinBase, 'active_record/associations/join_dependency/join_base' autoload :JoinAssociation, 'active_record/associations/join_dependency/join_association' - attr_reader :join_parts, :reflections, :alias_tracker, :base_klass + class Aliases # :nodoc: + def initialize(tables) + @tables = tables + @alias_cache = tables.each_with_object({}) { |table,h| + h[table.node] = table.columns.each_with_object({}) { |column,i| + i[column.name] = column.alias + } + } + @name_and_alias_cache = tables.each_with_object({}) { |table,h| + h[table.node] = table.columns.map { |column| + [column.name, column.alias] + } + } + end + + def columns + @tables.flat_map(&:column_aliases) + end + + # An array of [column_name, alias] pairs for the table + def column_aliases(node) + @name_and_alias_cache[node] + end + + def column_alias(node, column) + @alias_cache[node][column] + end + + class Table < Struct.new(:node, :columns) + def table + Arel::Nodes::TableAlias.new node.table, node.aliased_table_name + end + + def column_aliases + t = table + columns.map { |column| t[column.name].as Arel.sql column.alias } + end + end + Column = Struct.new(:name, :alias) + end + + attr_reader :alias_tracker, :base_klass, :join_root + + def self.make_tree(associations) + hash = {} + walk_tree associations, hash + hash + end + + def self.walk_tree(associations, hash) + case associations + when Symbol, String + hash[associations.to_sym] ||= {} + when Array + associations.each do |assoc| + walk_tree assoc, hash + end + when Hash + associations.each do |k,v| + cache = hash[k] ||= {} + walk_tree v, cache + end + else + raise ConfigurationError, associations.inspect + end + end # base is the base class on which operation is taking place. # associations is the list of associations which are joined using hash, symbol or array. - # joins is the list of all string join commnads and arel nodes. + # joins is the list of all string join commands and arel nodes. # # Example : # @@ -19,221 +83,204 @@ module ActiveRecord # end # # If I execute `@physician.patients.to_a` then - # base #=> Physician - # associations #=> [] - # joins #=> [#<Arel::Nodes::InnerJoin: ...] + # base # => Physician + # associations # => [] + # joins # => [#<Arel::Nodes::InnerJoin: ...] # # However if I execute `Physician.joins(:appointments).to_a` then - # base #=> Physician - # associations #=> [:appointments] - # joins #=> [] + # base # => Physician + # associations # => [:appointments] + # joins # => [] # def initialize(base, associations, joins) - @base_klass = base - @table_joins = joins - @join_parts = [JoinBase.new(base)] - @associations = {} - @reflections = [] - @alias_tracker = AliasTracker.new(base.connection, joins) - @alias_tracker.aliased_name_for(base.table_name) # Updates the count for base.table_name to 1 - build(associations) - end - - def graft(*associations) - associations.each do |association| - join_associations.detect {|a| association == a} || - build(association.reflection.name, association.find_parent_in(self) || join_base, association.join_type) - end - self + @alias_tracker = AliasTracker.create_with_joins(base.connection, base.table_name, joins, base.type_caster) + tree = self.class.make_tree associations + @join_root = JoinBase.new base, build(tree, base) + @join_root.children.each { |child| construct_tables! @join_root, child } end - def join_associations - join_parts.drop 1 + def reflections + join_root.drop(1).map!(&:reflection) end - def join_base - join_parts.first - end + def join_constraints(outer_joins) + joins = join_root.children.flat_map { |child| + make_inner_joins join_root, child + } - def join_relation(relation) - join_associations.inject(relation) do |rel,association| - association.join_relation(rel) - end + joins.concat outer_joins.flat_map { |oj| + if join_root.match? oj.join_root + walk join_root, oj.join_root + else + oj.join_root.children.flat_map { |child| + make_outer_joins oj.join_root, child + } + end + } end - def columns - join_parts.collect { |join_part| - table = join_part.aliased_table - join_part.column_names_with_alias.collect{ |column_name, aliased_name| - table[column_name].as Arel.sql(aliased_name) + def aliases + Aliases.new join_root.each_with_index.map { |join_part,i| + columns = join_part.column_names.each_with_index.map { |column_name,j| + Aliases::Column.new column_name, "t#{i}_r#{j}" } - }.flatten + Aliases::Table.new(join_part, columns) + } end - def instantiate(rows) - primary_key = join_base.aliased_primary_key - parents = {} + def instantiate(result_set, aliases) + primary_key = aliases.column_alias(join_root, join_root.primary_key) - records = rows.map { |model| - primary_id = model[primary_key] - parent = parents[primary_id] ||= join_base.instantiate(model) - construct(parent, @associations, join_associations, model) - parent - }.uniq + seen = Hash.new { |h,parent_klass| + h[parent_klass] = Hash.new { |i,parent_id| + i[parent_id] = Hash.new { |j,child_klass| j[child_klass] = {} } + } + } - remove_duplicate_results!(base_klass, records, @associations) - records - end + model_cache = Hash.new { |h,klass| h[klass] = {} } + parents = model_cache[join_root] + column_aliases = aliases.column_aliases join_root - protected + message_bus = ActiveSupport::Notifications.instrumenter - def remove_duplicate_results!(base, records, associations) - case associations - when Symbol, String - reflection = base.reflections[associations] - remove_uniq_by_reflection(reflection, records) - when Array - associations.each do |association| - remove_duplicate_results!(base, records, association) - end - when Hash - associations.each_key do |name| - reflection = base.reflections[name] - remove_uniq_by_reflection(reflection, records) - - parent_records = [] - records.each do |record| - if descendant = record.send(reflection.name) - if reflection.collection? - parent_records.concat descendant.target.uniq - else - parent_records << descendant - end - end - end + payload = { + record_count: result_set.length, + class_name: join_root.base_klass.name + } - remove_duplicate_results!(reflection.klass, parent_records, associations[name]) unless parent_records.empty? - end + message_bus.instrument('instantiation.active_record', payload) do + result_set.each { |row_hash| + parent = parents[row_hash[primary_key]] ||= join_root.instantiate(row_hash, column_aliases) + construct(parent, join_root, row_hash, result_set, seen, model_cache, aliases) + } end + + parents.values end - def cache_joined_association(association) - associations = [] - parent = association.parent - while parent != join_base - associations.unshift(parent.reflection.name) - parent = parent.parent - end - ref = @associations - associations.each do |key| - ref = ref[key] - end - ref[association.reflection.name] ||= {} + private + + def make_constraints(parent, child, tables, join_type) + chain = child.reflection.chain + foreign_table = parent.table + foreign_klass = parent.base_klass + child.join_constraints(foreign_table, foreign_klass, child, join_type, tables, child.reflection.scope_chain, chain) end - def build(associations, parent = join_parts.last, join_type = Arel::InnerJoin) - case associations - when Symbol, String - reflection = parent.reflections[associations.intern] or - raise ConfigurationError, "Association named '#{ associations }' was not found on #{ parent.base_klass.name }; perhaps you misspelled it?" - unless join_association = find_join_association(reflection, parent) - @reflections << reflection - join_association = build_join_association(reflection, parent) - join_association.join_type = join_type - @join_parts << join_association - cache_joined_association(join_association) - end - join_association - when Array - associations.each do |association| - build(association, parent, join_type) - end - when Hash - associations.keys.sort_by { |a| a.to_s }.each do |name| - join_association = build(name, parent, join_type) - build(associations[name], join_association, join_type) - end - else - raise ConfigurationError, associations.inspect - end + def make_outer_joins(parent, child) + tables = table_aliases_for(parent, child) + join_type = Arel::Nodes::OuterJoin + info = make_constraints parent, child, tables, join_type + + [info] + child.children.flat_map { |c| make_outer_joins(child, c) } end - def find_join_association(name_or_reflection, parent) - if String === name_or_reflection - name_or_reflection = name_or_reflection.to_sym - end + def make_inner_joins(parent, child) + tables = child.tables + join_type = Arel::Nodes::InnerJoin + info = make_constraints parent, child, tables, join_type + + [info] + child.children.flat_map { |c| make_inner_joins(child, c) } + end - join_associations.detect { |j| - j.reflection == name_or_reflection && j.parent == parent + def table_aliases_for(parent, node) + node.reflection.chain.map { |reflection| + alias_tracker.aliased_table_for( + reflection.table_name, + table_alias_for(reflection, parent, reflection != node.reflection) + ) } end - def remove_uniq_by_reflection(reflection, records) - if reflection && reflection.collection? - records.each { |record| record.send(reflection.name).target.uniq! } - end + def construct_tables!(parent, node) + node.tables = table_aliases_for(parent, node) + node.children.each { |child| construct_tables! node, child } end - def build_join_association(reflection, parent) - JoinAssociation.new(reflection, self, parent) + def table_alias_for(reflection, parent, join) + name = "#{reflection.plural_name}_#{parent.table_name}" + name << "_join" if join + name end - def construct(parent, associations, join_parts, row) - case associations - when Symbol, String - name = associations.to_s + def walk(left, right) + intersection, missing = right.children.map { |node1| + [left.children.find { |node2| node1.match? node2 }, node1] + }.partition(&:first) - join_part = join_parts.detect { |j| - j.reflection.name.to_s == name && - j.parent_table_name == parent.class.table_name } + ojs = missing.flat_map { |_,n| make_outer_joins left, n } + intersection.flat_map { |l,r| walk l, r }.concat ojs + end - raise(ConfigurationError, "No such association") unless join_part + def find_reflection(klass, name) + klass._reflect_on_association(name) or + raise ConfigurationError, "Association named '#{ name }' was not found on #{ klass.name }; perhaps you misspelled it?" + end - join_parts.delete(join_part) - construct_association(parent, join_part, row) - when Array - associations.each do |association| - construct(parent, association, join_parts, row) - end - when Hash - associations.sort_by { |k,_| k.to_s }.each do |association_name, assoc| - association = construct(parent, association_name, join_parts, row) - construct(association, assoc, join_parts, row) if association + def build(associations, base_klass) + associations.map do |name, right| + reflection = find_reflection base_klass, name + reflection.check_validity! + reflection.check_eager_loadable! + + if reflection.polymorphic? + raise EagerLoadPolymorphicError.new(reflection) end - else - raise ConfigurationError, associations.inspect + + JoinAssociation.new reflection, build(right, reflection.klass) end end - def construct_association(record, join_part, row) - return if record.id.to_s != join_part.parent.record_id(row).to_s + def construct(ar_parent, parent, row, rs, seen, model_cache, aliases) + return if ar_parent.nil? + primary_id = ar_parent.id - macro = join_part.reflection.macro - if macro == :has_one - return record.association(join_part.reflection.name).target if record.association_cache.key?(join_part.reflection.name) - association = join_part.instantiate(row) unless row[join_part.aliased_primary_key].nil? - set_target_and_inverse(join_part, association, record) - else - association = join_part.instantiate(row) unless row[join_part.aliased_primary_key].nil? - case macro - when :has_many, :has_and_belongs_to_many - other = record.association(join_part.reflection.name) + parent.children.each do |node| + if node.reflection.collection? + other = ar_parent.association(node.reflection.name) other.loaded! - other.target.push(association) if association - other.set_inverse_instance(association) - when :belongs_to - set_target_and_inverse(join_part, association, record) else - raise ConfigurationError, "unknown macro: #{join_part.reflection.macro}" + if ar_parent.association_cache.key?(node.reflection.name) + model = ar_parent.association(node.reflection.name).target + construct(model, node, row, rs, seen, model_cache, aliases) + next + end + end + + key = aliases.column_alias(node, node.primary_key) + id = row[key] + if id.nil? + nil_association = ar_parent.association(node.reflection.name) + nil_association.loaded! + next + end + + model = seen[parent.base_klass][primary_id][node.base_klass][id] + + if model + construct(model, node, row, rs, seen, model_cache, aliases) + else + model = construct_model(ar_parent, node, row, model_cache, id, aliases) + model.readonly! + seen[parent.base_klass][primary_id][node.base_klass][id] = model + construct(model, node, row, rs, seen, model_cache, aliases) end end - association end - def set_target_and_inverse(join_part, association, record) - other = record.association(join_part.reflection.name) - other.target = association - other.set_inverse_instance(association) + def construct_model(record, node, row, model_cache, id, aliases) + model = model_cache[node][id] ||= node.instantiate(row, + aliases.column_aliases(node)) + other = record.association(node.reflection.name) + + if node.reflection.collection? + other.target.push(model) + else + other.target = model + end + + other.set_inverse_instance(model) + model end end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index 58fc00d811..a6ad09a38a 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -1,77 +1,35 @@ +require 'active_record/associations/join_dependency/join_part' + module ActiveRecord module Associations class JoinDependency # :nodoc: class JoinAssociation < JoinPart # :nodoc: - include JoinHelper - # The reflection of the association represented attr_reader :reflection - # The JoinDependency object which this JoinAssociation exists within. This is mainly - # relevant for generating aliases which do not conflict with other joins which are - # part of the query. - attr_reader :join_dependency - - # A JoinBase instance representing the active record we are joining onto. - # (So in Author.has_many :posts, the Author would be that base record.) - attr_reader :parent - - # What type of join will be generated, either Arel::InnerJoin (default) or Arel::OuterJoin - attr_accessor :join_type - - # These implement abstract methods from the superclass - attr_reader :aliased_prefix - - attr_reader :tables - - delegate :options, :through_reflection, :source_reflection, :chain, :to => :reflection - delegate :alias_tracker, :to => :join_dependency + attr_accessor :tables - def initialize(reflection, join_dependency, parent = nil) - reflection.check_validity! - - if reflection.options[:polymorphic] - raise EagerLoadPolymorphicError.new(reflection) - end - - super(reflection.klass) + def initialize(reflection, children) + super(reflection.klass, children) @reflection = reflection - @join_dependency = join_dependency - @parent = parent - @join_type = Arel::InnerJoin - @aliased_prefix = "t#{ join_dependency.join_parts.size }" - @tables = construct_tables.reverse + @tables = nil end - def parent_table_name; parent.table_name; end - alias :alias_suffix :parent_table_name - - def ==(other) - other.class == self.class && - other.reflection == reflection && - other.parent == parent + def match?(other) + return true if self == other + super && reflection == other.reflection end - def find_parent_in(other_join_dependency) - other_join_dependency.join_parts.detect do |join_part| - case parent - when JoinBase - parent.base_klass == join_part.base_klass - else - parent == join_part - end - end - end + JoinInformation = Struct.new :joins, :binds - def join_constraints + def join_constraints(foreign_table, foreign_klass, node, join_type, tables, scope_chain, chain) joins = [] - tables = @tables.dup - - foreign_table = parent.table - foreign_klass = parent.base_klass + binds = [] + tables = tables.reverse - scope_chain_iter = reflection.scope_chain.reverse_each + scope_chain_index = 0 + scope_chain = scope_chain.reverse # The chain starts with the target table, but we want to end with it here (makes # more sense in this context), so we reverse @@ -79,59 +37,55 @@ module ActiveRecord table = tables.shift klass = reflection.klass - case reflection.source_macro - when :belongs_to - key = reflection.association_primary_key - foreign_key = reflection.foreign_key - when :has_and_belongs_to_many - # Join the join table first... - joins << join( - table, - table[reflection.foreign_key]. - eq(foreign_table[reflection.active_record_primary_key])) - - foreign_table, table = table, tables.shift - - key = reflection.association_primary_key - foreign_key = reflection.association_foreign_key - else - key = reflection.foreign_key - foreign_key = reflection.active_record_primary_key - end + join_keys = reflection.join_keys(klass) + key = join_keys.key + foreign_key = join_keys.foreign_key constraint = build_constraint(klass, table, key, foreign_table, foreign_key) - scope_chain_items = scope_chain_iter.next.map do |item| + predicate_builder = PredicateBuilder.new(TableMetadata.new(klass, table)) + scope_chain_items = scope_chain[scope_chain_index].map do |item| if item.is_a?(Relation) item else - ActiveRecord::Relation.create(klass, table).instance_exec(self, &item) + ActiveRecord::Relation.create(klass, table, predicate_builder) + .instance_exec(node, &item) end end + scope_chain_index += 1 - if reflection.type - scope_chain_items << - ActiveRecord::Relation.create(klass, table) - .where(reflection.type => foreign_klass.base_class.name) - end - - scope_chain_items.concat [klass.send(:build_default_scope)].compact + relation = ActiveRecord::Relation.create( + klass, + table, + predicate_builder, + ) + scope_chain_items.concat [klass.send(:build_default_scope, relation)].compact rel = scope_chain_items.inject(scope_chain_items.shift) do |left, right| left.merge right end if rel && !rel.arel.constraints.empty? + binds += rel.bound_attributes constraint = constraint.and rel.arel.constraints end - joins << join(table, constraint) + if reflection.type + value = foreign_klass.base_class.name + column = klass.columns_hash[reflection.type.to_s] + + substitute = klass.connection.substitute_at(column) + binds << Relation::QueryAttribute.new(column.name, value, klass.type_for_attribute(column.name)) + constraint = constraint.and table[reflection.type].eq substitute + end + + joins << table.create_join(table, table.create_on(constraint), join_type) # The current table in this iteration becomes the foreign table in the next foreign_table, foreign_klass = table, klass end - joins + JoinInformation.new joins, binds end # Builds equality condition. @@ -143,11 +97,11 @@ module ActiveRecord # end # # If I execute `Physician.joins(:appointments).to_a` then - # reflection #=> #<ActiveRecord::Reflection::AssociationReflection @macro=:has_many ...> - # table #=> #<Arel::Table @name="appointments" ...> - # key #=> physician_id - # foreign_table #=> #<Arel::Table @name="physicians" ...> - # foreign_key #=> id + # klass # => Physician + # table # => #<Arel::Table @name="appointments" ...> + # key # => physician_id + # foreign_table # => #<Arel::Table @name="physicians" ...> + # foreign_key # => id # def build_constraint(klass, table, key, foreign_table, foreign_key) constraint = table[key].eq(foreign_table[foreign_key]) @@ -162,13 +116,8 @@ module ActiveRecord constraint end - def join_relation(joining_relation) - self.join_type = Arel::OuterJoin - joining_relation.joins(self) - end - def table - tables.last + tables.first end def aliased_table_name diff --git a/activerecord/lib/active_record/associations/join_dependency/join_base.rb b/activerecord/lib/active_record/associations/join_dependency/join_base.rb index a7dacdbfd6..3a26c25737 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_base.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_base.rb @@ -1,18 +1,16 @@ +require 'active_record/associations/join_dependency/join_part' + module ActiveRecord module Associations class JoinDependency # :nodoc: class JoinBase < JoinPart # :nodoc: - def ==(other) - other.class == self.class && - other.base_klass == base_klass - end - - def aliased_prefix - "t0" + def match?(other) + return true if self == other + super && base_klass == other.base_klass end def table - Arel::Table.new(table_name, arel_engine) + base_klass.arel_table end def aliased_table_name diff --git a/activerecord/lib/active_record/associations/join_dependency/join_part.rb b/activerecord/lib/active_record/associations/join_dependency/join_part.rb index b534569063..9c6573f913 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_part.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_part.rb @@ -8,34 +8,35 @@ module ActiveRecord # operations (for example a has_and_belongs_to_many JoinAssociation would result in # two; one for the join table and one for the target table). class JoinPart # :nodoc: + include Enumerable + # The Active Record class which this join part is associated 'about'; for a JoinBase # this is the actual base model, for a JoinAssociation this is the target model of the # association. - attr_reader :base_klass + attr_reader :base_klass, :children - delegate :table_name, :column_names, :primary_key, :reflections, :arel_engine, :to => :base_klass + delegate :table_name, :column_names, :primary_key, :to => :base_klass - def initialize(base_klass) + def initialize(base_klass, children) @base_klass = base_klass - @cached_record = {} - @column_names_with_alias = nil + @children = children end - def aliased_table - Arel::Nodes::TableAlias.new table, aliased_table_name + def name + reflection.name end - def ==(other) - raise NotImplementedError + def match?(other) + self.class == other.class end - # An Arel::Table for the active_record - def table - raise NotImplementedError + def each(&block) + yield self + children.each { |child| child.each(&block) } end - # The prefix to be used when aliasing columns in the active_record's table - def aliased_prefix + # An Arel::Table for the active_record + def table raise NotImplementedError end @@ -44,33 +45,25 @@ module ActiveRecord raise NotImplementedError end - # The alias for the primary key of the active_record's table - def aliased_primary_key - "#{aliased_prefix}_r0" - end + def extract_record(row, column_names_with_alias) + # This code is performance critical as it is called per row. + # see: https://github.com/rails/rails/pull/12185 + hash = {} - # An array of [column_name, alias] pairs for the table - def column_names_with_alias - unless @column_names_with_alias - @column_names_with_alias = [] + index = 0 + length = column_names_with_alias.length - ([primary_key] + (column_names - [primary_key])).compact.each_with_index do |column_name, i| - @column_names_with_alias << [column_name, "#{aliased_prefix}_r#{i}"] - end + while index < length + column_name, alias_name = column_names_with_alias[index] + hash[column_name] = row[alias_name] + index += 1 end - @column_names_with_alias - end - - def extract_record(row) - Hash[column_names_with_alias.map{|cn, an| [cn, row[an]]}] - end - def record_id(row) - row[aliased_primary_key] + hash end - def instantiate(row) - @cached_record[record_id(row)] ||= base_klass.instantiate(extract_record(row)) + def instantiate(row, aliases) + base_klass.instantiate(extract_record(row, aliases)) end end end diff --git a/activerecord/lib/active_record/associations/join_helper.rb b/activerecord/lib/active_record/associations/join_helper.rb deleted file mode 100644 index 27b70edf1a..0000000000 --- a/activerecord/lib/active_record/associations/join_helper.rb +++ /dev/null @@ -1,45 +0,0 @@ -module ActiveRecord - module Associations - # Helper class module which gets mixed into JoinDependency::JoinAssociation and AssociationScope - module JoinHelper #:nodoc: - - def join_type - Arel::InnerJoin - end - - private - - def construct_tables - tables = [] - chain.each do |reflection| - tables << alias_tracker.aliased_table_for( - table_name_for(reflection), - table_alias_for(reflection, reflection != self.reflection) - ) - - if reflection.source_macro == :has_and_belongs_to_many - tables << alias_tracker.aliased_table_for( - reflection.source_reflection.join_table, - table_alias_for(reflection, true) - ) - end - end - tables - end - - def table_name_for(reflection) - reflection.table_name - end - - def table_alias_for(reflection, join = false) - name = "#{reflection.plural_name}_#{alias_suffix}" - name << "_join" if join - name - end - - def join(table, constraint) - table.create_join(table, table.create_on(constraint), join_type) - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index 6cc9d6c079..97f4bd3811 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -2,33 +2,42 @@ module ActiveRecord module Associations # Implements the details of eager loading of Active Record associations. # - # Note that 'eager loading' and 'preloading' are actually the same thing. - # However, there are two different eager loading strategies. + # Suppose that you have the following two Active Record models: # - # The first one is by using table joins. This was only strategy available - # prior to Rails 2.1. Suppose that you have an Author model with columns - # 'name' and 'age', and a Book model with columns 'name' and 'sales'. Using - # this strategy, Active Record would try to retrieve all data for an author - # and all of its books via a single query: + # class Author < ActiveRecord::Base + # # columns: name, age + # has_many :books + # end # - # SELECT * FROM authors - # LEFT OUTER JOIN books ON authors.id = books.author_id - # WHERE authors.name = 'Ken Akamatsu' + # class Book < ActiveRecord::Base + # # columns: title, sales + # end # - # However, this could result in many rows that contain redundant data. After - # having received the first row, we already have enough data to instantiate - # the Author object. In all subsequent rows, only the data for the joined - # 'books' table is useful; the joined 'authors' data is just redundant, and - # processing this redundant data takes memory and CPU time. The problem - # quickly becomes worse and worse as the level of eager loading increases - # (i.e. if Active Record is to eager load the associations' associations as - # well). + # When you load an author with all associated books Active Record will make + # multiple queries like this: + # + # Author.includes(:books).where(:name => ['bell hooks', 'Homer').to_a + # + # => SELECT `authors`.* FROM `authors` WHERE `name` IN ('bell hooks', 'Homer') + # => SELECT `books`.* FROM `books` WHERE `author_id` IN (2, 5) + # + # Active Record saves the ids of the records from the first query to use in + # the second. Depending on the number of associations involved there can be + # arbitrarily many SQL queries made. + # + # However, if there is a WHERE clause that spans across tables Active + # Record will fall back to a slightly more resource-intensive single query: + # + # Author.includes(:books).where(books: {title: 'Illiad'}).to_a + # => SELECT `authors`.`id` AS t0_r0, `authors`.`name` AS t0_r1, `authors`.`age` AS t0_r2, + # `books`.`id` AS t1_r0, `books`.`title` AS t1_r1, `books`.`sales` AS t1_r2 + # FROM `authors` + # LEFT OUTER JOIN `books` ON `authors`.`id` = `books`.`author_id` + # WHERE `books`.`title` = 'Illiad' + # + # This could result in many rows that contain redundant data and it performs poorly at scale + # and is therefore only used when necessary. # - # The second strategy is to use multiple database queries, one for each - # level of association. Since Rails 2.1, this is the default strategy. In - # situations where a table join is necessary (e.g. when the +:conditions+ - # option references an association's column), it will fallback to the table - # join strategy. class Preloader #:nodoc: extend ActiveSupport::Autoload @@ -42,12 +51,9 @@ module ActiveRecord autoload :HasManyThrough, 'active_record/associations/preloader/has_many_through' autoload :HasOne, 'active_record/associations/preloader/has_one' autoload :HasOneThrough, 'active_record/associations/preloader/has_one_through' - autoload :HasAndBelongsToMany, 'active_record/associations/preloader/has_and_belongs_to_many' 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 +88,48 @@ 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({}) + NULL_RELATION = Struct.new(:values, :where_clause, :joins_values).new({}, Relation::WhereClause.empty, []) + + def preload(records, associations, preload_scope = nil) + records = Array.wrap(records).compact.uniq + associations = Array.wrap(associations) + preload_scope = preload_scope || NULL_RELATION - def run - unless records.empty? - associations.each { |association| preload(association) } + 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) + association.flat_map { |parent, child| + 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 + } + loaders + } end # Not all records have the same class, so group then preload group on the reflection @@ -125,52 +139,61 @@ 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) + h = {} + records.each do |record| + next unless record + assoc = record.association(association) + klasses = h[assoc.reflection] ||= {} + (klasses[assoc.klass] ||= []) << record + end + h end - def records_by_reflection(association) - records.group_by do |record| - reflection = record.class.reflections[association] + class AlreadyLoaded + attr_reader :owners, :reflection - unless reflection - raise ActiveRecord::ConfigurationError, "Association named '#{association}' was not found; " \ - "perhaps you misspelled it?" - end + def initialize(klass, owners, reflection, preload_scope) + @owners = owners + @reflection = reflection + end + + def run(preloader); end - reflection + def preloaded_records + owners.flat_map { |owner| owner.association(reflection.name).target } end end - def association_klass(reflection, record) - if reflection.macro == :belongs_to && reflection.options[:polymorphic] - klass = record.send(reflection.foreign_type) - klass && klass.constantize - else - reflection.klass - end + class NullPreloader + def self.new(klass, owners, reflection, preload_scope); self; end + def self.run(preloader); end + def self.preloaded_records; []; end end - def preloader_for(reflection) + def preloader_for(reflection, owners, rhs_klass) + return NullPreloader unless rhs_klass + + if owners.first.association(reflection.name).loaded? + return AlreadyLoaded + end + reflection.check_preloadable! + case reflection.macro when :has_many reflection.options[:through] ? HasManyThrough : HasMany when :has_one reflection.options[:through] ? HasOneThrough : HasOne - when :has_and_belongs_to_many - HasAndBelongsToMany when :belongs_to BelongsTo end diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index 928da71eed..1dc8bff193 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 @@ -33,7 +33,7 @@ module ActiveRecord end def query_scope(ids) - scope.where(association_key.in(ids)) + scope.where(association_key_name => ids) end def table @@ -57,9 +57,15 @@ module ActiveRecord end def owners_by_key - @owners_by_key ||= owners.group_by do |owner| - owner[owner_key_name] - end + @owners_by_key ||= if key_conversion_required? + owners.group_by do |owner| + owner[owner_key_name].to_s + end + else + owners.group_by do |owner| + owner[owner_key_name] + end + end end def options @@ -68,36 +74,54 @@ 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 key_conversion_required? + association_key_type != owner_key_type + end + + def association_key_type + @klass.type_for_attribute(association_key_name.to_s).type + end + + def owner_key_type + @model.type_for_attribute(owner_key_name.to_s).type + end + + def load_slices(slices) + @preloaded_records = slices.flat_map { |slice| + records_for(slice) + } + + @preloaded_records.map { |record| + key = record[association_key_name] + key = key.to_s if key_conversion_required? + + [record, key] + } end def reflection_scope @@ -107,19 +131,30 @@ module ActiveRecord def build_scope scope = klass.unscoped - values = reflection_scope.values + values = reflection_scope.values preload_values = preload_scope.values - scope.where_values = Array(values[:where]) + Array(preload_values[:where]) + scope.where_clause = reflection_scope.where_clause + preload_scope.where_clause scope.references_values = Array(values[:references]) + Array(preload_values[:references]) - scope.select! preload_values[:select] || values[:select] || table[Arel.star] + scope._select! preload_values[:select] || values[:select] || table[Arel.star] scope.includes! preload_values[:includes] || values[:includes] + if preload_scope.joins_values.any? + scope.joins!(preload_scope.joins_values) + else + scope.joins!(reflection_scope.joins_values) + end + scope.order! preload_values[:order] || values[:order] + + if preload_values[:readonly] || values[:readonly] + scope.readonly! + end if options[:as] scope.where!(klass.table_name => { reflection.type => model.base_class.sti_name }) end + scope.unscope_values = Array(values[:unscope]) klass.default_scoped.merge(scope) end 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 deleted file mode 100644 index c042a44b21..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb +++ /dev/null @@ -1,65 +0,0 @@ -module ActiveRecord - module Associations - class Preloader - class HasAndBelongsToMany < CollectionAssociation #:nodoc: - attr_reader :join_table - - def initialize(klass, records, reflection, preload_options) - super - @join_table = Arel::Table.new(reflection.join_table).alias('t0') - end - - # Unlike the other associations, we want to get a raw array of rows so that we can - # access the aliased column on the join table - def records_for(ids) - scope = query_scope ids - klass.connection.select_all(scope.arel, 'SQL', scope.bind_values) - end - - def owner_key_name - reflection.active_record_primary_key - end - - def association_key_name - 'ar_association_key_name' - end - - def association_key - join_table[reflection.foreign_key] - end - - private - - # 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 type_caster(results, name) - caster = results.column_types.fetch(name, results.identity_type) - lambda { |value| caster.type_cast value } - end - - def build_scope - super.joins(join).select(join_select) - end - - def join_select - association_key.as(Arel.sql(association_key_name)) - end - - def join - condition = table[reflection.association_primary_key].eq( - join_table[reflection.association_foreign_key]) - - table.create_join(join_table, table.create_on(condition)) - end - end - end - end -end 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..2029871f39 100644 --- a/activerecord/lib/active_record/associations/preloader/has_many_through.rb +++ b/activerecord/lib/active_record/associations/preloader/has_many_through.rb @@ -4,11 +4,11 @@ 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 - records_by_owner.each_value { |records| records.uniq! } + records_by_owner.each_value(&:uniq!) end records_by_owner diff --git a/activerecord/lib/active_record/associations/preloader/singular_association.rb b/activerecord/lib/active_record/associations/preloader/singular_association.rb index 44e804d785..f60647a81e 100644 --- a/activerecord/lib/active_record/associations/preloader/singular_association.rb +++ b/activerecord/lib/active_record/associations/preloader/singular_association.rb @@ -5,13 +5,13 @@ 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) association.target = record - association.set_inverse_instance(record) + association.set_inverse_instance(record) if record end end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index 2c625cec04..56aa23b173 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,47 +10,81 @@ 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| + association = owner.association through_reflection.name + + [owner, Array(association.reader)] + end + + reset_association owners, through_reflection.name + + middle_records = through_records.flat_map { |(_,rec)| rec } + + preloaders = preloader.preload(middle_records, + source_reflection.name, + reflection_scope) - Preloader.new(through_records.values.flatten, source_reflection.name, reflection_scope).run + @preloaded_records = preloaders.flat_map(&:preloaded_records) + + middle_to_pl = preloaders.each_with_object({}) do |pl,h| + pl.owners.each { |middle| + h[middle] = pl + } + end - through_records.each do |owner, records| - records.map! { |r| r.send(source_reflection.name) }.flatten! - records.compact! + record_offset = {} + @preloaded_records.each_with_index do |record,i| + record_offset[record] = i 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| + association = r.association source_reflection.name + + association.reader + }.compact + + rhs_records.sort_by { |rhs| record_offset[rhs] } + 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 + # Don't 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 if options[:source_type] scope.where! reflection.foreign_type => options[:source_type] else - unless reflection_scope.where_values.empty? + unless reflection_scope.where_clause.empty? scope.includes_values = Array(reflection_scope.values[:includes] || options[:source]) - scope.where_values = reflection_scope.values[:where] + scope.where_clause = reflection_scope.where_clause end scope.references! reflection_scope.values[:references] - scope.order! reflection_scope.values[:order] if scope.eager_loading? + scope = scope.order reflection_scope.values[:order] if scope.eager_loading? end scope diff --git a/activerecord/lib/active_record/associations/singular_association.rb b/activerecord/lib/active_record/associations/singular_association.rb index 10238555f0..c44242a0f0 100644 --- a/activerecord/lib/active_record/associations/singular_association.rb +++ b/activerecord/lib/active_record/associations/singular_association.rb @@ -18,11 +18,11 @@ module ActiveRecord end def create(attributes = {}, &block) - create_record(attributes, &block) + _create_record(attributes, &block) end def create!(attributes = {}, &block) - create_record(attributes, true, &block) + _create_record(attributes, true, &block) end def build(attributes = {}) @@ -38,11 +38,33 @@ module ActiveRecord scope.scope_for_create.stringify_keys.except(klass.primary_key) end + def get_records + if reflection.scope_chain.any?(&:any?) || + scope.eager_loading? || + klass.current_scope || + klass.default_scopes.any? + + return scope.limit(1).to_a + end + + conn = klass.connection + sc = reflection.association_scope_cache(conn, owner) do + StatementCache.create(conn) { |params| + as = AssociationScope.create { params.bind } + target_scope.merge(as.scope(self, conn)).limit(1) + } + end + + binds = AssociationScope.get_bind_values(owner, reflection.chain) + sc.execute binds, klass, klass.connection + end + def find_target - scope.first.tap { |record| set_inverse_instance(record) } + if record = get_records.first + set_inverse_instance record + end end - # Implemented by subclasses def replace(record) raise NotImplementedError, "Subclasses must implement a replace(record) method" end @@ -51,7 +73,7 @@ module ActiveRecord replace(record) end - def create_record(attributes, raise_error = false) + def _create_record(attributes, raise_error = false) record = build_record(attributes) yield(record) if block_given? saved = record.save diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index ba7d2a3782..3ce9cffdbc 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -3,7 +3,7 @@ module ActiveRecord module Associations module ThroughAssociation #:nodoc: - delegate :source_reflection, :through_reflection, :chain, :to => :reflection + delegate :source_reflection, :through_reflection, :to => :reflection protected @@ -13,10 +13,16 @@ module ActiveRecord # 2. To get the type conditions for any STI models in the chain def target_scope scope = super - chain.drop(1).each do |reflection| + reflection.chain.drop(1).each do |reflection| + relation = reflection.klass.all + + reflection_scope = reflection.scope + if reflection_scope && reflection_scope.arity.zero? + relation = relation.merge(reflection_scope) + end + scope.merge!( - reflection.klass.all. - except(:select, :create_with, :includes, :preload, :joins, :eager_load) + relation.except(:select, :create_with, :includes, :preload, :joins, :eager_load) ) end scope @@ -39,12 +45,16 @@ module ActiveRecord def construct_join_attributes(*records) ensure_mutable - join_attributes = { - source_reflection.foreign_key => - records.map { |record| - record.send(source_reflection.association_primary_key(reflection.klass)) - } - } + if source_reflection.association_primary_key(reflection.klass) == reflection.klass.primary_key + join_attributes = { source_reflection.name => records } + else + join_attributes = { + source_reflection.foreign_key => + records.map { |record| + record.send(source_reflection.association_primary_key(reflection.klass)) + } + } + end if options[:source_type] join_attributes[source_reflection.foreign_type] = @@ -61,18 +71,17 @@ module ActiveRecord # Note: this does not capture all cases, for example it would be crazy to try to # properly support stale-checking for nested associations. def stale_state - if through_reflection.macro == :belongs_to + if through_reflection.belongs_to? owner[through_reflection.foreign_key] && owner[through_reflection.foreign_key].to_s end end def foreign_key_present? - through_reflection.macro == :belongs_to && - !owner[through_reflection.foreign_key].nil? + through_reflection.belongs_to? && !owner[through_reflection.foreign_key].nil? end def ensure_mutable - if source_reflection.macro != :belongs_to + unless source_reflection.belongs_to? raise HasManyThroughCantAssociateThroughHasOneOrManyReflection.new(owner, reflection) end end @@ -82,6 +91,17 @@ module ActiveRecord raise HasManyThroughNestedAssociationsAreReadonly.new(owner, reflection) end end + + def build_record(attributes) + inverse = source_reflection.inverse_of + target = through_association.target + + if inverse && target && !target.is_a?(Array) + attributes[inverse.foreign_key] = target.id + end + + super(attributes) + end end end end |