diff options
Diffstat (limited to 'activerecord/lib/active_record/associations')
42 files changed, 3173 insertions, 2259 deletions
diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index 6428fcec0a..6fc2bfdb31 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -5,32 +5,44 @@ module ActiveRecord # Keeps track of table aliases for ActiveRecord::Associations::ClassMethods::JoinDependency and # ActiveRecord::Associations::ThroughAssociationScope class AliasTracker # :nodoc: + attr_reader :aliases, :table_joins + # table_joins is an array of arel joins which might conflict with the aliases we assign here - def initialize(table_joins = nil) + def initialize(table_joins = []) @aliases = Hash.new @table_joins = table_joins end + def aliased_table_for(table_name, aliased_name = nil) + table_alias = aliased_name_for(table_name, aliased_name) + + if table_alias == table_name # TODO: Is this conditional necessary? + Arel::Table.new(table_name) + else + Arel::Table.new(table_name).alias(table_alias) + end + end + def aliased_name_for(table_name, aliased_name = nil) aliased_name ||= table_name - initialize_count_for(table_name) if @aliases[table_name].nil? + initialize_count_for(table_name) if aliases[table_name].nil? - if @aliases[table_name].zero? + if aliases[table_name].zero? # If it's zero, we can have our table_name - @aliases[table_name] = 1 + aliases[table_name] = 1 table_name else # Otherwise, we need to use an alias aliased_name = connection.table_alias_for(aliased_name) - initialize_count_for(aliased_name) if @aliases[aliased_name].nil? + initialize_count_for(aliased_name) if aliases[aliased_name].nil? # Update the count - @aliases[aliased_name] += 1 + aliases[aliased_name] += 1 - if @aliases[aliased_name] > 1 - "#{truncate(aliased_name)}_#{@aliases[aliased_name]}" + if aliases[aliased_name] > 1 + "#{truncate(aliased_name)}_#{aliases[aliased_name]}" else aliased_name end @@ -44,29 +56,21 @@ module ActiveRecord private def initialize_count_for(name) - @aliases[name] = 0 + aliases[name] = 0 - unless @table_joins.nil? || Arel::Table === @table_joins + unless 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 - @aliases[name] += @table_joins.grep(Arel::Nodes::Join).map { |join| - right = join.right - case right - when Arel::Table - right.name.downcase == name ? 1 : 0 - when String - # Table names + table aliases - right.downcase.scan( - /join(?:\s+\w+)?\s+(\S+\s+)?#{quoted_name}\son/ - ).size - else - 0 - end + aliases[name] += table_joins.map { |join| + # Table names + table aliases + join.left.downcase.scan( + /join(?:\s+\w+)?\s+(\S+\s+)?#{quoted_name}\son/ + ).size }.sum end - @aliases[name] + aliases[name] end def truncate(name) diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb new file mode 100644 index 0000000000..86904ea2bc --- /dev/null +++ b/activerecord/lib/active_record/associations/association.rb @@ -0,0 +1,262 @@ +require 'active_support/core_ext/array/wrap' + +module ActiveRecord + module Associations + # = Active Record Associations + # + # This is the root class of all associations ('+ Foo' signifies an included module Foo): + # + # Association + # SingularAssociaton + # HasOneAssociation + # HasOneThroughAssociation + ThroughAssociation + # BelongsToAssociation + # BelongsToPolymorphicAssociation + # CollectionAssociation + # HasAndBelongsToManyAssociation + # HasManyAssociation + # HasManyThroughAssociation + ThroughAssociation + class Association #:nodoc: + attr_reader :owner, :target, :reflection + + delegate :options, :to => :reflection + + def initialize(owner, reflection) + reflection.check_validity! + + @target = nil + @owner, @reflection = owner, reflection + @updated = false + + reset + construct_scope + end + + # Returns the name of the table of the related class: + # + # post.comments.aliased_table_name # => "comments" + # + def aliased_table_name + reflection.klass.table_name + end + + # Resets the \loaded flag to +false+ and sets the \target to +nil+. + def reset + @loaded = false + IdentityMap.remove(target) if IdentityMap.enabled? && target + @target = nil + end + + # Reloads the \target and returns +self+ on success. + def reload + reset + construct_scope + load_target + self unless target.nil? + end + + # Has the \target been already \loaded? + def loaded? + @loaded + end + + # Asserts the \target has been loaded setting the \loaded flag to +true+. + def loaded! + @loaded = true + @stale_state = stale_state + 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. + # + # Note that if the target has not been loaded, it is not considered stale. + def stale_target? + loaded? && @stale_state != stale_state + end + + # Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+. + def target=(target) + @target = target + loaded! + end + + def scoped + target_scope.merge(@association_scope) + end + + # Construct the scope for this association. + # + # Note that the association_scope is merged into the targed_scope only when the + # scoped method is called. This is because at that point the call may be surrounded + # by scope.scoping { ... } or with_scope { ... } etc, which affects the scope which + # actually gets built. + def construct_scope + @association_scope = association_scope if klass + end + + def association_scope + scope = klass.unscoped + scope = scope.create_with(creation_attributes) + scope = scope.apply_finder_options(options.slice(:readonly, :include)) + scope = scope.where(interpolate(options[:conditions])) + if select = select_value + scope = scope.select(select) + end + scope = scope.extending(*Array.wrap(options[:extend])) + scope.where(construct_owner_conditions) + end + + def aliased_table + klass.arel_table + end + + # Set the inverse association, if possible + def set_inverse_instance(record) + if record && invertible_for?(record) + inverse = record.association(inverse_reflection_for(record).name) + inverse.target = owner + end + end + + # This class of the target. belongs_to polymorphic overrides this to look at the + # polymorphic_type field on the owner. + def klass + reflection.klass + end + + # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the + # through association's scope) + def target_scope + klass.scoped + end + + # Loads the \target if needed and returns it. + # + # This method is abstract in the sense that it relies on +find_target+, + # which is expected to be provided by descendants. + # + # If the \target is already \loaded it is just returned. Thus, you can call + # +load_target+ unconditionally to get the \target. + # + # ActiveRecord::RecordNotFound is rescued within the method, and it is + # not reraised. The proxy is \reset and +nil+ is the return value. + def load_target + if find_target? + begin + if IdentityMap.enabled? && association_class && association_class.respond_to?(:base_class) + @target = IdentityMap.get(association_class, owner[reflection.foreign_key]) + end + rescue NameError + nil + ensure + @target ||= find_target + end + end + loaded! + target + rescue ActiveRecord::RecordNotFound + reset + end + + private + + def find_target? + !loaded? && (!owner.new_record? || foreign_key_present?) && klass + end + + def interpolate(sql, record = nil) + if sql.respond_to?(:to_proc) + owner.send(:instance_exec, record, &sql) + else + sql + end + end + + def select_value + options[:select] + end + + # Implemented by (some) subclasses + def creation_attributes + { } + end + + # Returns a hash linking the owner to the association represented by the reflection + def construct_owner_attributes(reflection = reflection) + attributes = {} + if reflection.macro == :belongs_to + attributes[reflection.association_primary_key] = owner[reflection.foreign_key] + else + attributes[reflection.foreign_key] = owner[reflection.active_record_primary_key] + + if options[:as] + attributes["#{options[:as]}_type"] = owner.class.base_class.name + end + end + attributes + end + + # Builds an array of arel nodes from the owner attributes hash + def construct_owner_conditions(table = aliased_table, reflection = reflection) + conditions = construct_owner_attributes(reflection).map do |attr, value| + table[attr].eq(value) + end + table.create_and(conditions) + end + + # Sets the owner attributes on the given record + def set_owner_attributes(record) + if owner.persisted? + construct_owner_attributes.each { |key, value| record[key] = value } + end + end + + # Should be true if there is a foreign key present on the owner which + # references the target. This is used to determine whether we can load + # the target if the owner is currently a new record (and therefore + # without a key). + # + # Currently implemented by belongs_to (vanilla and polymorphic) and + # has_one/has_many :through associations which go through a belongs_to + def foreign_key_present? + false + end + + # Raises ActiveRecord::AssociationTypeMismatch unless +record+ is of + # the kind of the class of the associated objects. Meant to be used as + # a sanity check when you are about to assign an associated record. + def raise_on_type_mismatch(record) + unless record.is_a?(reflection.klass) || record.is_a?(reflection.class_name.constantize) + message = "#{reflection.class_name}(##{reflection.klass.object_id}) expected, got #{record.class}(##{record.class.object_id})" + raise ActiveRecord::AssociationTypeMismatch, message + end + end + + # Can be redefined by subclasses, notably polymorphic belongs_to + # The record parameter is necessary to support polymorphic inverses as we must check for + # the association in the specific class of the record. + def inverse_reflection_for(record) + reflection.inverse_of + end + + # Is this association invertible? Can be redefined by subclasses. + def invertible_for?(record) + inverse_reflection_for(record) + end + + # This should be implemented to return the values of the relevant key(s) on the owner, + # so that when state_state is different from the value stored on the last find_target, + # the target is stale. + # + # This is only relevant to certain associations, which is why it returns nil by default. + def stale_state + end + + def association_class + @reflection.klass + end + end + end +end diff --git a/activerecord/lib/active_record/associations/association_collection.rb b/activerecord/lib/active_record/associations/association_collection.rb deleted file mode 100644 index abb17a11c6..0000000000 --- a/activerecord/lib/active_record/associations/association_collection.rb +++ /dev/null @@ -1,558 +0,0 @@ -require 'set' -require 'active_support/core_ext/array/wrap' - -module ActiveRecord - module Associations - # = Active Record Association Collection - # - # AssociationCollection is an abstract class that provides common stuff to - # ease the implementation of association proxies that represent - # collections. See the class hierarchy in AssociationProxy. - # - # You need to be careful with assumptions regarding the target: The proxy - # does not fetch records from the database until it needs them, but new - # ones created with +build+ are added to the target. So, the target may be - # non-empty and still lack children waiting to be read from the database. - # If you look directly to the database you cannot assume that's the entire - # collection because new records may have been added to the target, etc. - # - # If you need to work on all current children, new and existing records, - # +load_target+ and the +loaded+ flag are your friends. - class AssociationCollection < AssociationProxy #:nodoc: - delegate :group, :order, :limit, :joins, :where, :preload, :eager_load, :includes, :from, :lock, :readonly, :having, :to => :scoped - - def select(select = nil) - if block_given? - load_target - @target.select.each { |e| yield e } - else - scoped.select(select) - end - end - - def scoped - with_scope(@scope) { @reflection.klass.scoped } - end - - def find(*args) - options = args.extract_options! - - # If using a custom finder_sql, scan the entire collection. - if @reflection.options[:finder_sql] - expects_array = args.first.kind_of?(Array) - ids = args.flatten.compact.uniq.map { |arg| arg.to_i } - - if ids.size == 1 - id = ids.first - record = load_target.detect { |r| id == r.id } - expects_array ? [ record ] : record - else - load_target.select { |r| ids.include?(r.id) } - end - else - merge_options_from_reflection!(options) - construct_find_options!(options) - - with_scope(:find => @scope[:find].slice(:conditions, :order)) do - relation = @reflection.klass.send(:construct_finder_arel, options, @reflection.klass.send(:current_scoped_methods)) - - case args.first - when :first, :last - relation.send(args.first) - when :all - records = relation.all - @reflection.options[:uniq] ? uniq(records) : records - else - relation.find(*args) - end - end - end - end - - # Fetches the first one using SQL if possible. - def first(*args) - if fetch_first_or_last_using_find?(args) - find(:first, *args) - else - load_target unless loaded? - args = args[1..-1] if args.first.kind_of?(Hash) && args.first.empty? - @target.first(*args) - end - end - - # Fetches the last one using SQL if possible. - def last(*args) - if fetch_first_or_last_using_find?(args) - find(:last, *args) - else - load_target unless loaded? - @target.last(*args) - end - end - - def to_ary - load_target - if @target.is_a?(Array) - @target.to_ary - else - Array.wrap(@target) - end - end - alias_method :to_a, :to_ary - - def reset - reset_target! - reset_named_scopes_cache! - @loaded = false - end - - def build(attributes = {}, &block) - if attributes.is_a?(Array) - attributes.collect { |attr| build(attr, &block) } - else - build_record(attributes) do |record| - block.call(record) if block_given? - set_belongs_to_association_for(record) - end - end - 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 <<(*records) - result = true - load_target if @owner.new_record? - - transaction do - flatten_deeper(records).each do |record| - raise_on_type_mismatch(record) - add_record_to_target_with_callbacks(record) do |r| - result &&= insert_record(record) unless @owner.new_record? - end - end - end - - result && self - end - - alias_method :push, :<< - alias_method :concat, :<< - - # Starts a transaction in the association class's database connection. - # - # class Author < ActiveRecord::Base - # has_many :books - # end - # - # Author.first.books.transaction do - # # same effect as calling Book.transaction - # end - def transaction(*args) - @reflection.klass.transaction(*args) do - yield - end - end - - # Remove all records from this association - # - # See delete for more info. - def delete_all - load_target - delete(@target) - reset_target! - reset_named_scopes_cache! - end - - # Calculate sum using SQL, not Enumerable - def sum(*args) - if block_given? - calculate(:sum, *args) { |*block_args| yield(*block_args) } - else - calculate(:sum, *args) - end - end - - # Count all records using SQL. If the +:counter_sql+ or +:finder_sql+ option is set for the - # association, it will be used for the query. Otherwise, construct options and pass them with - # scope to the target class's +count+. - def count(column_name = nil, options = {}) - column_name, options = nil, column_name if column_name.is_a?(Hash) - - if @reflection.options[:counter_sql] || @reflection.options[:finder_sql] - unless options.blank? - raise ArgumentError, "If finder_sql/counter_sql is used then options cannot be passed" - end - - @reflection.klass.count_by_sql(custom_counter_sql) - else - - if @reflection.options[:uniq] - # This is needed because 'SELECT count(DISTINCT *)..' is not valid SQL. - column_name = "#{@reflection.quoted_table_name}.#{@reflection.klass.primary_key}" unless column_name - options.merge!(:distinct => true) - end - - value = @reflection.klass.send(:with_scope, @scope) { @reflection.klass.count(column_name, options) } - - limit = @reflection.options[:limit] - offset = @reflection.options[:offset] - - if limit || offset - [ [value - offset.to_i, 0].max, limit.to_i ].min - else - value - end - end - end - - # Removes +records+ from this association calling +before_remove+ and - # +after_remove+ callbacks. - # - # This method is abstract in the sense that +delete_records+ has to be - # provided by descendants. Note this method does not imply the records - # are actually removed from the database, that depends precisely on - # +delete_records+. They are in any case removed from the collection. - def delete(*records) - remove_records(records) do |_records, old_records| - delete_records(old_records) if old_records.any? - _records.each { |record| @target.delete(record) } - end - end - - # Destroy +records+ and remove them from this association calling - # +before_remove+ and +after_remove+ callbacks. - # - # Note that this method will _always_ remove records from the database - # ignoring the +:dependent+ option. - def destroy(*records) - records = find(records) if records.any? {|record| record.kind_of?(Fixnum) || record.kind_of?(String)} - remove_records(records) do |_records, old_records| - old_records.each { |record| record.destroy } - end - - load_target - end - - # Removes all records from this association. Returns +self+ so method calls may be chained. - def clear - unless length.zero? # forces load_target if it hasn't happened already - if @reflection.options[:dependent] == :destroy - destroy_all - else - delete_all - end - end - - self - end - - # Destroy all the records from this association. - # - # See destroy for more info. - def destroy_all - load_target - destroy(@target).tap do - reset_target! - reset_named_scopes_cache! - end - end - - def create(attrs = {}) - if attrs.is_a?(Array) - attrs.collect { |attr| create(attr) } - else - create_record(attrs) do |record| - yield(record) if block_given? - record.save - end - end - end - - def create!(attrs = {}) - create_record(attrs) do |record| - yield(record) if block_given? - record.save! - end - end - - # Returns the size of the collection by executing a SELECT COUNT(*) - # query if the collection hasn't been loaded, and calling - # <tt>collection.size</tt> if it has. - # - # If the collection has been already loaded +size+ and +length+ are - # equivalent. If not and you are going to need the records anyway - # +length+ will take one less query. Otherwise +size+ is more efficient. - # - # This method is abstract in the sense that it relies on - # +count_records+, which is a method descendants have to provide. - def size - if @owner.new_record? || (loaded? && !@reflection.options[:uniq]) - @target.size - elsif !loaded? && @reflection.options[:group] - load_target.size - elsif !loaded? && !@reflection.options[:uniq] && @target.is_a?(Array) - unsaved_records = @target.select { |r| r.new_record? } - unsaved_records.size + count_records - else - count_records - end - end - - # Returns the size of the collection calling +size+ on the target. - # - # If the collection has been already loaded +length+ and +size+ are - # equivalent. If not and you are going to need the records anyway this - # method will take one less query. Otherwise +size+ is more efficient. - def length - load_target.size - end - - # Equivalent to <tt>collection.size.zero?</tt>. If the collection has - # not been already loaded and you are going to fetch the records anyway - # it is better to check <tt>collection.length.zero?</tt>. - def empty? - size.zero? - end - - def any? - if block_given? - method_missing(:any?) { |*block_args| yield(*block_args) } - else - !empty? - end - end - - # Returns true if the collection has more than 1 record. Equivalent to collection.size > 1. - def many? - if block_given? - method_missing(:many?) { |*block_args| yield(*block_args) } - else - size > 1 - end - end - - def uniq(collection = self) - seen = {} - collection.find_all do |record| - seen[record.id] = true unless seen.key?(record.id) - end - end - - # Replace this collection with +other_array+ - # This will perform a diff and delete/add only records that have changed. - def replace(other_array) - other_array.each { |val| raise_on_type_mismatch(val) } - - load_target - other = other_array.size < 100 ? other_array : other_array.to_set - current = @target.size < 100 ? @target : @target.to_set - - transaction do - delete(@target.select { |v| !other.include?(v) }) - concat(other_array.select { |v| !current.include?(v) }) - end - end - - def include?(record) - return false unless record.is_a?(@reflection.klass) - return include_in_memory?(record) if record.new_record? - load_target if @reflection.options[:finder_sql] && !loaded? - loaded? ? @target.include?(record) : exists?(record) - end - - def proxy_respond_to?(method, include_private = false) - super || @reflection.klass.respond_to?(method, include_private) - end - - protected - def construct_find_options!(options) - end - - def load_target - if !@owner.new_record? || foreign_key_present - begin - unless loaded? - if @target.is_a?(Array) && @target.any? - @target = find_target.map do |f| - i = @target.index(f) - if i - @target.delete_at(i).tap do |t| - keys = ["id"] + t.changes.keys + (f.attribute_names - t.attribute_names) - f.attributes.except(*keys).each do |k,v| - t.send("#{k}=", v) - end - end - else - f - end - end + @target - else - @target = find_target - end - end - rescue ActiveRecord::RecordNotFound - reset - end - end - - loaded if target - target - end - - def method_missing(method, *args) - match = DynamicFinderMatch.match(method) - if match && match.creator? - attributes = match.attribute_names - return send(:"find_by_#{attributes.join('_and_')}", *args) || create(Hash[attributes.zip(args)]) - end - - if @target.respond_to?(method) || (!@reflection.klass.respond_to?(method) && Class.respond_to?(method)) - super - elsif @reflection.klass.scopes[method] - @_named_scopes_cache ||= {} - @_named_scopes_cache[method] ||= {} - @_named_scopes_cache[method][args] ||= with_scope(@scope) { @reflection.klass.send(method, *args) } - else - with_scope(@scope) do - if block_given? - @reflection.klass.send(method, *args) { |*block_args| yield(*block_args) } - else - @reflection.klass.send(method, *args) - end - end - end - end - - def custom_counter_sql - if @reflection.options[:counter_sql] - counter_sql = @reflection.options[:counter_sql] - else - # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ - counter_sql = @reflection.options[:finder_sql].sub(/SELECT\b(\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } - end - - interpolate_sql(counter_sql) - end - - def custom_finder_sql - interpolate_sql(@reflection.options[:finder_sql]) - end - - def reset_target! - @target = Array.new - end - - def reset_named_scopes_cache! - @_named_scopes_cache = {} - end - - def find_target - records = - if @reflection.options[:finder_sql] - @reflection.klass.find_by_sql(custom_finder_sql) - else - find(:all) - end - - records = @reflection.options[:uniq] ? uniq(records) : records - records.each do |record| - set_inverse_instance(record, @owner) - end - records - end - - def add_record_to_target_with_callbacks(record) - callback(:before_add, record) - yield(record) if block_given? - @target ||= [] unless loaded? - if index = @target.index(record) - @target[index] = record - else - @target << record - end - callback(:after_add, record) - set_inverse_instance(record, @owner) - record - end - - private - def create_record(attrs) - attrs.update(@reflection.options[:conditions]) if @reflection.options[:conditions].is_a?(Hash) - ensure_owner_is_persisted! - - scoped_where = scoped.where_values_hash - create_scope = scoped_where ? @scope[:create].merge(scoped_where) : @scope[:create] - record = @reflection.klass.send(:with_scope, :create => create_scope) do - @reflection.build_association(attrs) - end - if block_given? - add_record_to_target_with_callbacks(record) { |*block_args| yield(*block_args) } - else - add_record_to_target_with_callbacks(record) - end - end - - def build_record(attrs) - attrs.update(@reflection.options[:conditions]) if @reflection.options[:conditions].is_a?(Hash) - record = @reflection.build_association(attrs) - if block_given? - add_record_to_target_with_callbacks(record) { |*block_args| yield(*block_args) } - else - add_record_to_target_with_callbacks(record) - end - end - - def remove_records(*records) - records = flatten_deeper(records) - records.each { |record| raise_on_type_mismatch(record) } - - transaction do - records.each { |record| callback(:before_remove, record) } - old_records = records.reject { |r| r.new_record? } - yield(records, old_records) - records.each { |record| callback(:after_remove, record) } - end - end - - def callback(method, record) - callbacks_for(method).each do |callback| - case callback - when Symbol - @owner.send(callback, record) - when Proc - callback.call(@owner, record) - else - callback.send(method, @owner, record) - end - end - end - - def callbacks_for(callback_name) - full_callback_name = "#{callback_name}_for_#{@reflection.name}" - @owner.class.send(full_callback_name.to_sym) || [] - end - - def ensure_owner_is_persisted! - unless @owner.persisted? - raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved" - end - end - - def fetch_first_or_last_using_find?(args) - (args.first.kind_of?(Hash) && !args.first.empty?) || !(loaded? || @owner.new_record? || @reflection.options[:finder_sql] || - @target.any? { |record| record.new_record? } || args.first.kind_of?(Integer)) - end - - def include_in_memory?(record) - if @reflection.is_a?(ActiveRecord::Reflection::ThroughReflection) - @owner.send(proxy_reflection.through_reflection.name.to_sym).any? do |source| - target = source.send(proxy_reflection.source_reflection.name) - target.respond_to?(:include?) ? target.include?(record) : target == record - end - else - @target.include?(record) - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/association_proxy.rb b/activerecord/lib/active_record/associations/association_proxy.rb deleted file mode 100644 index 252ff7e7ea..0000000000 --- a/activerecord/lib/active_record/associations/association_proxy.rb +++ /dev/null @@ -1,314 +0,0 @@ -require 'active_support/core_ext/array/wrap' - -module ActiveRecord - module Associations - # = Active Record Associations - # - # This is the root class of all association proxies: - # - # AssociationProxy - # BelongsToAssociation - # HasOneAssociation - # BelongsToPolymorphicAssociation - # AssociationCollection - # HasAndBelongsToManyAssociation - # HasManyAssociation - # HasManyThroughAssociation - # HasOneThroughAssociation - # - # Association proxies in Active Record are middlemen between the object that - # holds the association, known as the <tt>@owner</tt>, and the actual associated - # object, known as the <tt>@target</tt>. The kind of association any proxy is - # about is available in <tt>@reflection</tt>. That's an instance of the class - # ActiveRecord::Reflection::AssociationReflection. - # - # For example, given - # - # class Blog < ActiveRecord::Base - # has_many :posts - # end - # - # blog = Blog.find(:first) - # - # the association proxy in <tt>blog.posts</tt> has the object in +blog+ as - # <tt>@owner</tt>, the collection of its posts as <tt>@target</tt>, and - # the <tt>@reflection</tt> object represents a <tt>:has_many</tt> macro. - # - # This class has most of the basic instance methods removed, and delegates - # unknown methods to <tt>@target</tt> via <tt>method_missing</tt>. As a - # corner case, it even removes the +class+ method and that's why you get - # - # blog.posts.class # => Array - # - # though the object behind <tt>blog.posts</tt> is not an Array, but an - # ActiveRecord::Associations::HasManyAssociation. - # - # The <tt>@target</tt> object is not \loaded until needed. For example, - # - # blog.posts.count - # - # is computed directly through SQL and does not trigger by itself the - # instantiation of the actual post records. - class AssociationProxy #:nodoc: - alias_method :proxy_respond_to?, :respond_to? - alias_method :proxy_extend, :extend - delegate :to_param, :to => :proxy_target - instance_methods.each { |m| undef_method m unless m.to_s =~ /^(?:nil\?|send|object_id|to_a)$|^__|^respond_to_missing|proxy_/ } - - def initialize(owner, reflection) - @owner, @reflection = owner, reflection - @updated = false - reflection.check_validity! - Array.wrap(reflection.options[:extend]).each { |ext| proxy_extend(ext) } - reset - construct_scope - end - - # Returns the owner of the proxy. - def proxy_owner - @owner - end - - # Returns the reflection object that represents the association handled - # by the proxy. - def proxy_reflection - @reflection - end - - # Returns the \target of the proxy, same as +target+. - def proxy_target - @target - end - - # Does the proxy or its \target respond to +symbol+? - def respond_to?(*args) - proxy_respond_to?(*args) || (load_target && @target.respond_to?(*args)) - end - - # Forwards <tt>===</tt> explicitly to the \target because the instance method - # removal above doesn't catch it. Loads the \target if needed. - def ===(other) - load_target - other === @target - end - - # Returns the name of the table of the related class: - # - # post.comments.aliased_table_name # => "comments" - # - def aliased_table_name - @reflection.klass.table_name - end - - # Returns the SQL string that corresponds to the <tt>:conditions</tt> - # option of the macro, if given, or +nil+ otherwise. - def conditions - @conditions ||= interpolate_sql(@reflection.sanitized_conditions) if @reflection.sanitized_conditions - end - alias :sql_conditions :conditions - - # Resets the \loaded flag to +false+ and sets the \target to +nil+. - def reset - @loaded = false - @target = nil - end - - # Reloads the \target and returns +self+ on success. - def reload - reset - load_target - self unless @target.nil? - end - - # Has the \target been already \loaded? - def loaded? - @loaded - end - - # Asserts the \target has been loaded setting the \loaded flag to +true+. - def loaded - @loaded = true - end - - # Returns the target of this proxy, same as +proxy_target+. - def target - @target - end - - # Sets the target of this proxy to <tt>\target</tt>, and the \loaded flag to +true+. - def target=(target) - @target = target - loaded - end - - # Forwards the call to the target. Loads the \target if needed. - def inspect - load_target - @target.inspect - end - - def send(method, *args) - if proxy_respond_to?(method) - super - else - load_target - @target.send(method, *args) - end - end - - protected - # Does the association have a <tt>:dependent</tt> option? - def dependent? - @reflection.options[:dependent] - end - - def interpolate_sql(sql, record = nil) - @owner.send(:interpolate_sql, sql, record) - end - - # Forwards the call to the reflection class. - def sanitize_sql(sql, table_name = @reflection.klass.table_name) - @reflection.klass.send(:sanitize_sql, sql, table_name) - end - - # Assigns the ID of the owner to the corresponding foreign key in +record+. - # If the association is polymorphic the type of the owner is also set. - def set_belongs_to_association_for(record) - if @reflection.options[:as] - record["#{@reflection.options[:as]}_id"] = @owner.id if @owner.persisted? - record["#{@reflection.options[:as]}_type"] = @owner.class.base_class.name.to_s - else - if @owner.persisted? - primary_key = @reflection.options[:primary_key] || :id - record[@reflection.primary_key_name] = @owner.send(primary_key) - end - end - end - - # Merges into +options+ the ones coming from the reflection. - def merge_options_from_reflection!(options) - options.reverse_merge!( - :group => @reflection.options[:group], - :having => @reflection.options[:having], - :limit => @reflection.options[:limit], - :offset => @reflection.options[:offset], - :joins => @reflection.options[:joins], - :include => @reflection.options[:include], - :select => @reflection.options[:select], - :readonly => @reflection.options[:readonly] - ) - end - - # Forwards +with_scope+ to the reflection. - def with_scope(*args, &block) - @reflection.klass.send :with_scope, *args, &block - end - - # Construct the scope used for find/create queries on the target - def construct_scope - @scope = { - :find => construct_find_scope, - :create => construct_create_scope - } - end - - # Implemented by subclasses - def construct_find_scope - raise NotImplementedError - end - - # Implemented by (some) subclasses - def construct_create_scope - {} - end - - private - # Forwards any missing method call to the \target. - def method_missing(method, *args) - if load_target - unless @target.respond_to?(method) - message = "undefined method `#{method.to_s}' for \"#{@target}\":#{@target.class.to_s}" - raise NoMethodError, message - end - - if block_given? - @target.send(method, *args) { |*block_args| yield(*block_args) } - else - @target.send(method, *args) - end - end - end - - # Loads the \target if needed and returns it. - # - # This method is abstract in the sense that it relies on +find_target+, - # which is expected to be provided by descendants. - # - # If the \target is already \loaded it is just returned. Thus, you can call - # +load_target+ unconditionally to get the \target. - # - # ActiveRecord::RecordNotFound is rescued within the method, and it is - # not reraised. The proxy is \reset and +nil+ is the return value. - def load_target - return nil unless defined?(@loaded) - - if !loaded? && (!@owner.new_record? || foreign_key_present) - @target = find_target - end - - @loaded = true - @target - rescue ActiveRecord::RecordNotFound - reset - end - - # Can be overwritten by associations that might have the foreign key - # available for an association without having the object itself (and - # still being a new record). Currently, only +belongs_to+ presents - # this scenario (both vanilla and polymorphic). - def foreign_key_present - false - end - - # Raises ActiveRecord::AssociationTypeMismatch unless +record+ is of - # the kind of the class of the associated objects. Meant to be used as - # a sanity check when you are about to assign an associated record. - def raise_on_type_mismatch(record) - unless record.is_a?(@reflection.klass) || record.is_a?(@reflection.class_name.constantize) - message = "#{@reflection.class_name}(##{@reflection.klass.object_id}) expected, got #{record.class}(##{record.class.object_id})" - raise ActiveRecord::AssociationTypeMismatch, message - end - end - - if RUBY_VERSION < '1.9.2' - # Array#flatten has problems with recursive arrays before Ruby 1.9.2. - # Going one level deeper solves the majority of the problems. - def flatten_deeper(array) - array.collect { |element| (element.respond_to?(:flatten) && !element.is_a?(Hash)) ? element.flatten : element }.flatten - end - else - def flatten_deeper(array) - array.flatten - end - end - - # Returns the ID of the owner, quoted if needed. - def owner_quoted_id - @owner.quoted_id - end - - def set_inverse_instance(record, instance) - return if record.nil? || !we_can_set_the_inverse_on_this?(record) - inverse_relationship = @reflection.inverse_of - unless inverse_relationship.nil? - record.send(:"set_#{inverse_relationship.name}_target", instance) - end - end - - # Override in subclasses - def we_can_set_the_inverse_on_this?(record) - false - end - 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 b438620c8f..c263edd2c6 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -1,41 +1,17 @@ module ActiveRecord # = Active Record Belongs To Associations module Associations - class BelongsToAssociation < AssociationProxy #:nodoc: - def create(attributes = {}) - replace(@reflection.create_association(attributes)) - end - - def build(attributes = {}) - replace(@reflection.build_association(attributes)) - end - + class BelongsToAssociation < SingularAssociation #:nodoc: def replace(record) - counter_cache_name = @reflection.counter_cache_column - - if record.nil? - if counter_cache_name && @owner.persisted? - @reflection.klass.decrement_counter(counter_cache_name, previous_record_id) if @owner[@reflection.primary_key_name] - end - - @target = @owner[@reflection.primary_key_name] = nil - else - raise_on_type_mismatch(record) + raise_on_type_mismatch(record) if record - if counter_cache_name && @owner.persisted? && record.id != @owner[@reflection.primary_key_name] - @reflection.klass.increment_counter(counter_cache_name, record.id) - @reflection.klass.decrement_counter(counter_cache_name, @owner[@reflection.primary_key_name]) if @owner[@reflection.primary_key_name] - end - - @target = (AssociationProxy === record ? record.target : record) - @owner[@reflection.primary_key_name] = record_id(record) if record.persisted? - @updated = true - end + update_counters(record) + replace_keys(record) + set_inverse_instance(record) - set_inverse_instance(record, @owner) + @updated = true if record - loaded - record + self.target = record end def updated? @@ -43,50 +19,52 @@ module ActiveRecord end private - def find_target - find_method = if @reflection.options[:primary_key] - "find_by_#{@reflection.options[:primary_key]}" - else - "find" - end - options = @reflection.options.dup.slice(:select, :include, :readonly) + def update_counters(record) + counter_cache_name = reflection.counter_cache_column + + if counter_cache_name && owner.persisted? && different_target?(record) + if record + record.class.increment_counter(counter_cache_name, record.id) + end - the_target = with_scope(:find => @scope[:find]) do - @reflection.klass.send(find_method, - @owner[@reflection.primary_key_name], - options - ) if @owner[@reflection.primary_key_name] + if foreign_key_present? + klass.decrement_counter(counter_cache_name, target_id) + end end - set_inverse_instance(the_target, @owner) - the_target end - def construct_find_scope - { :conditions => conditions } + # Checks whether record is different to the current target, without loading it + def different_target?(record) + record.nil? && owner[reflection.foreign_key] || + record.id != owner[reflection.foreign_key] end - def foreign_key_present - !@owner[@reflection.primary_key_name].nil? + def replace_keys(record) + owner[reflection.foreign_key] = record && record[reflection.association_primary_key] + end + + def foreign_key_present? + owner[reflection.foreign_key] end # NOTE - for now, we're only supporting inverse setting from belongs_to back onto # has_one associations. - def we_can_set_the_inverse_on_this?(record) - @reflection.has_inverse? && @reflection.inverse_of.macro == :has_one + def invertible_for?(record) + inverse = inverse_reflection_for(record) + inverse && inverse.macro == :has_one end - def record_id(record) - record.send(@reflection.options[:primary_key] || :id) + def target_id + if options[:primary_key] + owner.send(reflection.name).try(:id) + else + owner[reflection.foreign_key] + end end - def previous_record_id - @previous_record_id ||= if @reflection.options[:primary_key] - previous_record = @owner.send(@reflection.name) - previous_record.nil? ? nil : previous_record.id - else - @owner[@reflection.primary_key_name] - end + def stale_state + owner[reflection.foreign_key].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 a0df860623..1ca448236e 100644 --- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb @@ -1,77 +1,33 @@ module ActiveRecord # = Active Record Belongs To Polymorphic Association module Associations - class BelongsToPolymorphicAssociation < AssociationProxy #:nodoc: - def replace(record) - if record.nil? - @target = @owner[@reflection.primary_key_name] = @owner[@reflection.options[:foreign_type]] = nil - else - @target = (AssociationProxy === record ? record.target : record) - - @owner[@reflection.primary_key_name] = record_id(record) - @owner[@reflection.options[:foreign_type]] = record.class.base_class.name.to_s - - @updated = true - end - - set_inverse_instance(record, @owner) - loaded - record - end - - def updated? - @updated - end - + class BelongsToPolymorphicAssociation < BelongsToAssociation #:nodoc: private - # NOTE - for now, we're only supporting inverse setting from belongs_to back onto - # has_one associations. - def we_can_set_the_inverse_on_this?(record) - if @reflection.has_inverse? - inverse_association = @reflection.polymorphic_inverse_of(record.class) - inverse_association && inverse_association.macro == :has_one - else - false - end - end - - def set_inverse_instance(record, instance) - return if record.nil? || !we_can_set_the_inverse_on_this?(record) - inverse_relationship = @reflection.polymorphic_inverse_of(record.class) - if inverse_relationship - record.send(:"set_#{inverse_relationship.name}_target", instance) - end + def replace_keys(record) + super + owner[reflection.foreign_type] = record && record.class.base_class.name end - def construct_find_scope - { :conditions => conditions } + def different_target?(record) + super || record.class != klass end - def find_target - return nil if association_class.nil? - - target = association_class.send(:with_scope, :find => @scope[:find]) do - association_class.find( - @owner[@reflection.primary_key_name], - :select => @reflection.options[:select], - :include => @reflection.options[:include] - ) - end - set_inverse_instance(target, @owner) - target + def inverse_reflection_for(record) + reflection.polymorphic_inverse_of(record.class) end - def foreign_key_present - !@owner[@reflection.primary_key_name].nil? + def klass + type = owner[reflection.foreign_type] + type && type.constantize end - def record_id(record) - record.send(@reflection.options[:primary_key] || :id) + def raise_on_type_mismatch(record) + # A polymorphic association cannot have a type mismatch, by definition end - def association_class - @owner[@reflection.options[:foreign_type]] ? @owner[@reflection.options[:foreign_type]].constantize : nil + def stale_state + [super, owner[reflection.foreign_type].to_s] end end end diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb new file mode 100644 index 0000000000..96fca97440 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/association.rb @@ -0,0 +1,53 @@ +module ActiveRecord::Associations::Builder + class Association #:nodoc: + class_attribute :valid_options + self.valid_options = [:class_name, :foreign_key, :select, :conditions, :include, :extend, :readonly, :validate] + + # Set by subclasses + class_attribute :macro + + attr_reader :model, :name, :options, :reflection + + def self.build(model, name, options) + new(model, name, options).build + end + + def initialize(model, name, options) + @model, @name, @options = model, name, options + end + + def build + validate_options + reflection = model.create_reflection(self.class.macro, name, options, model) + define_accessors + reflection + end + + private + + def validate_options + options.assert_valid_keys(self.class.valid_options) + end + + def define_accessors + define_readers + define_writers + end + + def define_readers + name = self.name + + model.redefine_method(name) do |*params| + association(name).reader(*params) + end + end + + def define_writers + name = self.name + + model.redefine_method("#{name}=") do |value| + association(name).writer(value) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb new file mode 100644 index 0000000000..964e7fddc8 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -0,0 +1,83 @@ +module ActiveRecord::Associations::Builder + class BelongsTo < SingularAssociation #:nodoc: + self.macro = :belongs_to + + self.valid_options += [:foreign_type, :polymorphic, :touch] + + def constructable? + !options[:polymorphic] + end + + def build + reflection = super + add_counter_cache_callbacks(reflection) if options[:counter_cache] + add_touch_callbacks(reflection) if options[:touch] + configure_dependency + reflection + end + + private + + def add_counter_cache_callbacks(reflection) + cache_column = reflection.counter_cache_column + name = self.name + + method_name = "belongs_to_counter_cache_after_create_for_#{name}" + model.redefine_method(method_name) do + record = send(name) + record.class.increment_counter(cache_column, record.id) unless record.nil? + end + model.after_create(method_name) + + method_name = "belongs_to_counter_cache_before_destroy_for_#{name}" + model.redefine_method(method_name) do + record = send(name) + record.class.decrement_counter(cache_column, record.id) unless record.nil? + end + model.before_destroy(method_name) + + model.send(:module_eval, + "#{reflection.class_name}.send(:attr_readonly,\"#{cache_column}\".intern) if defined?(#{reflection.class_name}) && #{reflection.class_name}.respond_to?(:attr_readonly)", __FILE__, __LINE__ + ) + end + + def add_touch_callbacks(reflection) + name = self.name + method_name = "belongs_to_touch_after_save_or_destroy_for_#{name}" + touch = options[:touch] + + model.redefine_method(method_name) do + record = send(name) + + unless record.nil? + if touch == true + record.touch + else + record.touch(touch) + end + end + end + + model.after_save(method_name) + model.after_touch(method_name) + model.after_destroy(method_name) + end + + def configure_dependency + if options[:dependent] + unless [:destroy, :delete].include?(options[:dependent]) + raise ArgumentError, "The :dependent option expects either :destroy or :delete (#{options[:dependent].inspect})" + end + + method_name = "belongs_to_dependent_#{options[:dependent]}_for_#{name}" + model.send(:class_eval, <<-eoruby, __FILE__, __LINE__ + 1) + def #{method_name} + association = #{name} + association.#{options[:dependent]} if association + end + eoruby + model.after_destroy method_name + end + 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 new file mode 100644 index 0000000000..f62209a226 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -0,0 +1,75 @@ +module ActiveRecord::Associations::Builder + class CollectionAssociation < Association #:nodoc: + CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove] + + self.valid_options += [ + :table_name, :order, :group, :having, :limit, :offset, :uniq, :finder_sql, + :counter_sql, :before_add, :after_add, :before_remove, :after_remove + ] + + attr_reader :block_extension + + def self.build(model, name, options, &extension) + new(model, name, options, &extension).build + end + + def initialize(model, name, options, &extension) + super(model, name, options) + @block_extension = extension + end + + def build + wrap_block_extension + reflection = super + CALLBACKS.each { |callback_name| define_callback(callback_name) } + reflection + end + + def writable? + true + end + + private + + def wrap_block_extension + options[:extend] = Array.wrap(options[:extend]) + + if block_extension + silence_warnings do + model.parent.const_set(extension_module_name, Module.new(&block_extension)) + end + options[:extend].push("#{model.parent}::#{extension_module_name}".constantize) + end + end + + def extension_module_name + @extension_module_name ||= "#{model.to_s.demodulize}#{name.to_s.camelize}AssociationExtension" + end + + def define_callback(callback_name) + full_callback_name = "#{callback_name}_for_#{name}" + + # TODO : why do i need method_defined? I think its because of the inheritance chain + model.class_attribute full_callback_name.to_sym unless model.method_defined?(full_callback_name) + model.send("#{full_callback_name}=", Array.wrap(options[callback_name.to_sym])) + end + + def define_readers + super + + name = self.name + model.redefine_method("#{name.to_s.singularize}_ids") do + association(name).ids_reader + end + end + + def define_writers + super + + name = self.name + model.redefine_method("#{name.to_s.singularize}_ids=") do |ids| + association(name).ids_writer(ids) + end + end + end +end 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 new file mode 100644 index 0000000000..e40b32826a --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb @@ -0,0 +1,63 @@ +module ActiveRecord::Associations::Builder + class HasAndBelongsToMany < CollectionAssociation #:nodoc: + self.macro = :has_and_belongs_to_many + + self.valid_options += [:join_table, :association_foreign_key, :delete_sql, :insert_sql] + + def build + reflection = super + check_validity(reflection) + redefine_destroy + reflection + end + + private + + def redefine_destroy + # Don't use a before_destroy callback since users' before_destroy + # callbacks will be executed after the association is wiped out. + name = self.name + model.send(:include, Module.new { + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def destroy # def destroy + super # super + #{name}.clear # posts.clear + end # end + RUBY + }) + end + + # TODO: These checks should probably be moved into the Reflection, and we should not be + # redefining the options[:join_table] value - instead we should define a + # reflection.join_table method. + def check_validity(reflection) + if reflection.association_foreign_key == reflection.foreign_key + raise ActiveRecord::HasAndBelongsToManyAssociationForeignKeyNeeded.new(reflection) + end + + reflection.options[:join_table] ||= join_table_name( + model.send(:undecorated_table_name, model.to_s), + model.send(:undecorated_table_name, reflection.class_name) + ) + + if model.connection.supports_primary_key? && (model.connection.primary_key(reflection.options[:join_table]) rescue false) + raise ActiveRecord::HasAndBelongsToManyAssociationWithPrimaryKeyError.new(reflection) + end + end + + # Generates a join table name from two provided table names. + # The names in the join table names end up in lexicographic order. + # + # join_table_name("members", "clubs") # => "clubs_members" + # join_table_name("members", "special_clubs") # => "members_special_clubs" + def join_table_name(first_table_name, second_table_name) + if first_table_name < second_table_name + join_table = "#{first_table_name}_#{second_table_name}" + else + join_table = "#{second_table_name}_#{first_table_name}" + end + + model.table_name_prefix + join_table + model.table_name_suffix + 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 new file mode 100644 index 0000000000..77bb66228d --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/has_many.rb @@ -0,0 +1,63 @@ +module ActiveRecord::Associations::Builder + class HasMany < CollectionAssociation #:nodoc: + self.macro = :has_many + + self.valid_options += [:primary_key, :dependent, :as, :through, :source, :source_type, :inverse_of] + + def build + reflection = super + configure_dependency + reflection + end + + private + + def configure_dependency + if options[:dependent] + unless [:destroy, :delete_all, :nullify, :restrict].include?(options[:dependent]) + raise ArgumentError, "The :dependent option expects either :destroy, :delete_all, " \ + ":nullify or :restrict (#{options[:dependent].inspect})" + end + + send("define_#{options[:dependent]}_dependency_method") + model.before_destroy dependency_method_name + end + end + + def define_destroy_dependency_method + name = self.name + model.send(:define_method, dependency_method_name) do + send(name).each do |o| + # No point in executing the counter update since we're going to destroy the parent anyway + counter_method = ('belongs_to_counter_cache_before_destroy_for_' + self.class.name.downcase).to_sym + if o.respond_to?(counter_method) + class << o + self + end.send(:define_method, counter_method, Proc.new {}) + end + end + + send(name).delete_all + end + end + + def define_delete_all_dependency_method + name = self.name + model.send(:define_method, dependency_method_name) do + send(name).delete_all + end + end + alias :define_nullify_dependency_method :define_delete_all_dependency_method + + def define_restrict_dependency_method + name = self.name + model.send(:define_method, dependency_method_name) do + raise ActiveRecord::DeleteRestrictionError.new(name) unless send(name).empty? + end + end + + def dependency_method_name + "has_many_dependent_for_#{name}" + end + end +end diff --git a/activerecord/lib/active_record/associations/builder/has_one.rb b/activerecord/lib/active_record/associations/builder/has_one.rb new file mode 100644 index 0000000000..07ba5d088e --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/has_one.rb @@ -0,0 +1,61 @@ +module ActiveRecord::Associations::Builder + class HasOne < SingularAssociation #:nodoc: + self.macro = :has_one + + self.valid_options += [:order, :as] + + class_attribute :through_options + self.through_options = [:through, :source, :source_type] + + def constructable? + !options[:through] + end + + def build + reflection = super + configure_dependency unless options[:through] + reflection + end + + private + + def validate_options + valid_options = self.class.valid_options + valid_options += self.class.through_options if options[:through] + options.assert_valid_keys(valid_options) + end + + def configure_dependency + if options[:dependent] + unless [:destroy, :delete, :nullify, :restrict].include?(options[:dependent]) + raise ArgumentError, "The :dependent option expects either :destroy, :delete, " \ + ":nullify or :restrict (#{options[:dependent].inspect})" + end + + send("define_#{options[:dependent]}_dependency_method") + model.before_destroy dependency_method_name + end + end + + def dependency_method_name + "has_one_dependent_#{options[:dependent]}_for_#{name}" + end + + def define_destroy_dependency_method + model.send(:class_eval, <<-eoruby, __FILE__, __LINE__ + 1) + def #{dependency_method_name} + association(#{name.to_sym.inspect}).delete + end + eoruby + end + alias :define_delete_dependency_method :define_destroy_dependency_method + alias :define_nullify_dependency_method :define_destroy_dependency_method + + def define_restrict_dependency_method + name = self.name + model.redefine_method(dependency_method_name) do + raise ActiveRecord::DeleteRestrictionError.new(name) unless send(name).nil? + end + 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 new file mode 100644 index 0000000000..06a414b874 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/singular_association.rb @@ -0,0 +1,32 @@ +module ActiveRecord::Associations::Builder + class SingularAssociation < Association #:nodoc: + self.valid_options += [:remote, :dependent, :counter_cache, :primary_key, :inverse_of] + + def constructable? + true + end + + def define_accessors + super + define_constructors if constructable? + end + + private + + def define_constructors + name = self.name + + model.redefine_method("build_#{name}") do |*params| + association(name).build(*params) + end + + model.redefine_method("create_#{name}") do |*params| + association(name).create(*params) + end + + model.redefine_method("create_#{name}!") do |*params| + association(name).create!(*params) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/class_methods/join_dependency.rb b/activerecord/lib/active_record/associations/class_methods/join_dependency.rb deleted file mode 100644 index 78b634a26c..0000000000 --- a/activerecord/lib/active_record/associations/class_methods/join_dependency.rb +++ /dev/null @@ -1,216 +0,0 @@ -require 'active_record/associations/class_methods/join_dependency/join_part' -require 'active_record/associations/class_methods/join_dependency/join_base' -require 'active_record/associations/class_methods/join_dependency/join_association' - -module ActiveRecord - module Associations - module ClassMethods - class JoinDependency # :nodoc: - attr_reader :join_parts, :reflections, :alias_tracker, :active_record - - def initialize(base, associations, joins) - @active_record = base - @table_joins = joins - @join_parts = [JoinBase.new(base)] - @associations = {} - @reflections = [] - @alias_tracker = AliasTracker.new(joins) - @alias_tracker.aliased_name_for(base.table_name) # Updates the count for base.table_name to 1 - build(associations) - end - - 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 - end - - def join_associations - join_parts.last(join_parts.length - 1) - end - - def join_base - join_parts.first - 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) - } - }.flatten - end - - def instantiate(rows) - primary_key = join_base.aliased_primary_key - parents = {} - - 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 - - remove_duplicate_results!(active_record, records, @associations) - records - end - - 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.keys.each 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 - - remove_duplicate_results!(reflection.klass, parent_records, associations[name]) unless parent_records.empty? - end - end - end - - protected - - 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] ||= {} - end - - def build(associations, parent = nil, join_type = Arel::InnerJoin) - parent ||= join_parts.last - case associations - when Symbol, String - reflection = parent.reflections[associations.to_s.intern] or - raise ConfigurationError, "Association named '#{ associations }' was not found; perhaps you misspelled it?" - 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 - end - - def find_join_association(name_or_reflection, parent) - if String === name_or_reflection - name_or_reflection = name_or_reflection.to_sym - end - - join_associations.detect { |j| - j.reflection == name_or_reflection && j.parent == parent - } - end - - def remove_uniq_by_reflection(reflection, records) - if reflection && reflection.collection? - records.each { |record| record.send(reflection.name).target.uniq! } - end - end - - def build_join_association(reflection, parent) - JoinAssociation.new(reflection, self, parent) - end - - def construct(parent, associations, join_parts, row) - case associations - when Symbol, String - name = associations.to_s - - join_part = join_parts.detect { |j| - j.reflection.name.to_s == name && - j.parent_table_name == parent.class.table_name } - - raise(ConfigurationError, "No such association") unless join_part - - 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 |name, assoc| - association = construct(parent, name, join_parts, row) - construct(association, assoc, join_parts, row) if association - end - else - raise ConfigurationError, associations.inspect - end - end - - def construct_association(record, join_part, row) - return if record.id.to_s != join_part.parent.record_id(row).to_s - - macro = join_part.reflection.macro - if macro == :has_one - return if record.instance_variable_defined?("@#{join_part.reflection.name}") - association = join_part.instantiate(row) unless row[join_part.aliased_primary_key].nil? - set_target_and_inverse(join_part, association, record) - else - return if row[join_part.aliased_primary_key].nil? - association = join_part.instantiate(row) - case macro - when :has_many, :has_and_belongs_to_many - collection = record.send(join_part.reflection.name) - collection.loaded - collection.target.push(association) - collection.__send__(:set_inverse_instance, association, record) - when :belongs_to - set_target_and_inverse(join_part, association, record) - else - raise ConfigurationError, "unknown macro: #{join_part.reflection.macro}" - end - end - association - end - - def set_target_and_inverse(join_part, association, record) - association_proxy = record.send("set_#{join_part.reflection.name}_target", association) - association_proxy.__send__(:set_inverse_instance, association, record) - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/class_methods/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/class_methods/join_dependency/join_association.rb deleted file mode 100644 index 5cc96a7aef..0000000000 --- a/activerecord/lib/active_record/associations/class_methods/join_dependency/join_association.rb +++ /dev/null @@ -1,258 +0,0 @@ -module ActiveRecord - module Associations - module ClassMethods - class JoinDependency # :nodoc: - class JoinAssociation < JoinPart # :nodoc: - # The reflection of the association represented - attr_reader :reflection - - # The JoinDependency object which this JoinAssociation exists within. This is mainly - # relevant for generating aliases which do not conflict with other joins which are - # part of the query. - attr_reader :join_dependency - - # A JoinBase instance representing the active record we are joining onto. - # (So in Author.has_many :posts, the Author would be that base record.) - attr_reader :parent - - # What type of join will be generated, either Arel::InnerJoin (default) or Arel::OuterJoin - attr_accessor :join_type - - attr_reader :aliased_prefix - - delegate :options, :through_reflection, :source_reflection, :through_reflection_chain, :to => :reflection - delegate :table, :table_name, :to => :parent, :prefix => :parent - delegate :alias_tracker, :to => :join_dependency - - def initialize(reflection, join_dependency, parent = nil) - reflection.check_validity! - - if reflection.options[:polymorphic] - raise EagerLoadPolymorphicError.new(reflection) - end - - super(reflection.klass) - - @reflection = reflection - @join_dependency = join_dependency - @parent = parent - @join_type = Arel::InnerJoin - @aliased_prefix = "t#{ join_dependency.join_parts.size }" - - setup_tables - end - - def ==(other) - other.class == self.class && - other.reflection == reflection && - other.parent == parent - end - - def find_parent_in(other_join_dependency) - other_join_dependency.join_parts.detect do |join_part| - parent == join_part - end - end - - def join_to(relation) - # The chain starts with the target table, but we want to end with it here (makes - # more sense in this context) - chain = through_reflection_chain.reverse - - foreign_table = parent_table - index = 0 - - chain.each do |reflection| - table = @tables[index] - conditions = [] - - if reflection.source_reflection.nil? - case reflection.macro - when :belongs_to - key = reflection.association_primary_key - foreign_key = reflection.primary_key_name - when :has_many, :has_one - key = reflection.primary_key_name - foreign_key = reflection.active_record_primary_key - - conditions << polymorphic_conditions(reflection, table) - when :has_and_belongs_to_many - # For habtm, we need to deal with the join table at the same time as the - # target table (because unlike a :through association, there is no reflection - # to represent the join table) - table, join_table = table - - join_key = reflection.primary_key_name - join_foreign_key = reflection.active_record.primary_key - - relation = relation.join(join_table, join_type).on( - join_table[join_key]. - eq(foreign_table[join_foreign_key]) - ) - - # We've done the first join now, so update the foreign_table for the second - foreign_table = join_table - - key = reflection.klass.primary_key - foreign_key = reflection.association_foreign_key - end - else - case reflection.source_reflection.macro - when :belongs_to - key = reflection.association_primary_key - foreign_key = reflection.primary_key_name - - conditions << source_type_conditions(reflection, foreign_table) - when :has_many, :has_one - key = reflection.primary_key_name - foreign_key = reflection.source_reflection.active_record_primary_key - when :has_and_belongs_to_many - table, join_table = table - - join_key = reflection.primary_key_name - join_foreign_key = reflection.klass.primary_key - - relation = relation.join(join_table, join_type).on( - join_table[join_key]. - eq(foreign_table[join_foreign_key]) - ) - - foreign_table = join_table - - key = reflection.klass.primary_key - foreign_key = reflection.association_foreign_key - end - end - - conditions << table[key].eq(foreign_table[foreign_key]) - - conditions << reflection_conditions(index, table) - conditions << sti_conditions(reflection, table) - - ands = relation.create_and(conditions.flatten.compact) - - join = relation.create_join( - relation.froms.first, - table, - relation.create_on(ands), - join_type) - - relation = relation.from(join) - - # The current table in this iteration becomes the foreign table in the next - foreign_table = table - index += 1 - end - - relation - end - - def join_relation(joining_relation) - self.join_type = Arel::OuterJoin - joining_relation.joins(self) - end - - def table - if @tables.last.is_a?(Array) - @tables.last.first - else - @tables.last - end - end - - def aliased_table_name - table.table_alias || table.name - end - - protected - - def table_alias_for(reflection, join = false) - name = alias_tracker.pluralize(reflection.name) - name << "_#{parent_table_name}" - name << "_join" if join - name - end - - private - - # Generate aliases and Arel::Table instances for each of the tables which we will - # later generate joins for. We must do this in advance in order to correctly allocate - # the proper alias. - def setup_tables - @tables = through_reflection_chain.map do |reflection| - aliased_table_name = alias_tracker.aliased_name_for( - reflection.table_name, - table_alias_for(reflection, reflection != self.reflection) - ) - - table = Arel::Table.new(reflection.table_name, :as => aliased_table_name) - - # For habtm, we have two Arel::Table instances related to a single reflection, so - # we just store them as a pair in the array. - if reflection.macro == :has_and_belongs_to_many || - (reflection.source_reflection && - reflection.source_reflection.macro == :has_and_belongs_to_many) - - join_table_name = (reflection.source_reflection || reflection).options[:join_table] - - aliased_join_table_name = alias_tracker.aliased_name_for( - join_table_name, - table_alias_for(reflection, true) - ) - - join_table = Arel::Table.new(join_table_name, :as => aliased_join_table_name) - - [table, join_table] - else - table - end - end - - # The joins are generated from the through_reflection_chain in reverse order, so - # reverse the tables too (but it's important to generate the aliases in the 'forward' - # order, which is why we only do the reversal now. - @tables.reverse! - - @tables - end - - def reflection_conditions(index, table) - @reflection.through_conditions.reverse[index].map do |condition| - Arel.sql(sanitize_sql(condition, table.table_alias || table.name)) - end - end - - def sanitize_sql(condition, table_name) - active_record.send(:sanitize_sql, condition, table_name) - end - - def sti_conditions(reflection, table) - unless reflection.klass.descends_from_active_record? - sti_column = table[reflection.klass.inheritance_column] - sti_condition = sti_column.eq(reflection.klass.sti_name) - subclasses = reflection.klass.descendants - - subclasses.inject(sti_condition) { |attr,subclass| - attr.or(sti_column.eq(subclass.sti_name)) - } - end - end - - def source_type_conditions(reflection, foreign_table) - if reflection.options[:source_type] - foreign_table[reflection.source_reflection.options[:foreign_type]]. - eq(reflection.options[:source_type]) - end - end - - def polymorphic_conditions(reflection, table) - if reflection.options[:as] - table["#{reflection.options[:as]}_type"]. - eq(reflection.active_record.base_class.name) - end - end - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/class_methods/join_dependency/join_base.rb b/activerecord/lib/active_record/associations/class_methods/join_dependency/join_base.rb deleted file mode 100644 index 67567f06df..0000000000 --- a/activerecord/lib/active_record/associations/class_methods/join_dependency/join_base.rb +++ /dev/null @@ -1,26 +0,0 @@ -module ActiveRecord - module Associations - module ClassMethods - class JoinDependency # :nodoc: - class JoinBase < JoinPart # :nodoc: - def ==(other) - other.class == self.class && - other.active_record == active_record - end - - def aliased_prefix - "t0" - end - - def table - Arel::Table.new(table_name, arel_engine) - end - - def aliased_table_name - active_record.table_name - end - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/class_methods/join_dependency/join_part.rb b/activerecord/lib/active_record/associations/class_methods/join_dependency/join_part.rb deleted file mode 100644 index cd16ae5a8b..0000000000 --- a/activerecord/lib/active_record/associations/class_methods/join_dependency/join_part.rb +++ /dev/null @@ -1,80 +0,0 @@ -module ActiveRecord - module Associations - module ClassMethods - class JoinDependency # :nodoc: - # A JoinPart represents a part of a JoinDependency. It is an abstract class, inherited - # by JoinBase and JoinAssociation. A JoinBase represents the Active Record which - # everything else is being joined onto. A JoinAssociation represents an association which - # is joining to the base. A JoinAssociation may result in more than one actual join - # operations (for example a has_and_belongs_to_many JoinAssociation would result in - # two; one for the join table and one for the target table). - class JoinPart # :nodoc: - # The Active Record class which this join part is associated 'about'; for a JoinBase - # this is the actual base model, for a JoinAssociation this is the target model of the - # association. - attr_reader :active_record - - delegate :table_name, :column_names, :primary_key, :reflections, :arel_engine, :to => :active_record - - def initialize(active_record) - @active_record = active_record - @cached_record = {} - @column_names_with_alias = nil - end - - def aliased_table - Arel::Nodes::TableAlias.new aliased_table_name, table - end - - def ==(other) - raise NotImplementedError - end - - # An Arel::Table for the active_record - def table - raise NotImplementedError - end - - # The prefix to be used when aliasing columns in the active_record's table - def aliased_prefix - raise NotImplementedError - end - - # The alias for the active_record's table - def aliased_table_name - raise NotImplementedError - end - - # The alias for the primary key of the active_record's table - def aliased_primary_key - "#{aliased_prefix}_r0" - end - - # An array of [column_name, alias] pairs for the table - def column_names_with_alias - unless @column_names_with_alias - @column_names_with_alias = [] - - ([primary_key] + (column_names - [primary_key])).each_with_index do |column_name, i| - @column_names_with_alias << [column_name, "#{aliased_prefix}_r#{i}"] - end - 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] - end - - def instantiate(row) - @cached_record[record_id(row)] ||= active_record.send(:instantiate, extract_record(row)) - end - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb new file mode 100644 index 0000000000..f3761bd2c7 --- /dev/null +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -0,0 +1,549 @@ +require 'active_support/core_ext/array/wrap' + +module ActiveRecord + module Associations + # = Active Record Association Collection + # + # AssociationCollection is an abstract class that provides common stuff to + # ease the implementation of association proxies that represent + # collections. See the class hierarchy in AssociationProxy. + # + # You need to be careful with assumptions regarding the target: The proxy + # does not fetch records from the database until it needs them, but new + # ones created with +build+ are added to the target. So, the target may be + # non-empty and still lack children waiting to be read from the database. + # If you look directly to the database you cannot assume that's the entire + # collection because new records may have been added to the target, etc. + # + # If you need to work on all current children, new and existing records, + # +load_target+ and the +loaded+ flag are your friends. + class CollectionAssociation < Association #:nodoc: + attr_reader :proxy + + def initialize(owner, reflection) + # When scopes are created via method_missing on the proxy, they are stored so that + # any records fetched from the database are kept around for future use. + @scopes_cache = Hash.new do |hash, method| + hash[method] = { } + end + + super + + @proxy = CollectionProxy.new(self) + end + + # Implements the reader method, e.g. foo.items for Foo.has_many :items + def reader(force_reload = false) + if force_reload + klass.uncached { reload } + elsif stale_target? + reload + end + + proxy + end + + # Implements the writer method, e.g. foo.items= for Foo.has_many :items + def writer(records) + replace(records) + end + + # Implements the ids reader method, e.g. foo.item_ids for Foo.has_many :items + def ids_reader + if loaded? || options[:finder_sql] + load_target.map do |record| + record.send(reflection.association_primary_key) + end + else + column = "#{reflection.quoted_table_name}.#{reflection.association_primary_key}" + + scoped.select(column).except(:includes).map! do |record| + record.send(reflection.association_primary_key) + end + end + end + + # 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.wrap(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)) + end + + def reset + @loaded = false + @target = [] + @scopes_cache.clear + end + + def select(select = nil) + if block_given? + load_target.select.each { |e| yield e } + else + scoped.select(select) + end + end + + def find(*args) + if options[:finder_sql] + find_by_scan(*args) + else + scoped.find(*args) + end + end + + def first(*args) + first_or_last(:first, *args) + end + + def last(*args) + first_or_last(:last, *args) + end + + def build(attributes = {}, &block) + build_or_create(attributes, :build, &block) + end + + def create(attributes = {}, &block) + unless owner.persisted? + raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved" + end + + build_or_create(attributes, :create, &block) + end + + def create!(attrs = {}, &block) + record = create(attrs, &block) + Array.wrap(record).each(&:save!) + record + 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) + result = true + load_target if owner.new_record? + + transaction do + records.flatten.each do |record| + raise_on_type_mismatch(record) + add_to_target(record) do |r| + result &&= insert_record(record) unless owner.new_record? + end + end + end + + result && records + end + + # Starts a transaction in the association class's database connection. + # + # class Author < ActiveRecord::Base + # has_many :books + # end + # + # Author.first.books.transaction do + # # same effect as calling Book.transaction + # end + def transaction(*args) + reflection.klass.transaction(*args) do + yield + end + end + + # Remove all records from this association + # + # See delete for more info. + def delete_all + delete(load_target).tap do + reset + loaded! + end + end + + # Destroy all the records from this association. + # + # See destroy for more info. + def destroy_all + destroy(load_target).tap do + reset + loaded! + end + end + + # Calculate sum using SQL, not Enumerable + def sum(*args) + if block_given? + scoped.sum(*args) { |*block_args| yield(*block_args) } + else + scoped.sum(*args) + end + end + + # Count all records using SQL. If the +:counter_sql+ or +:finder_sql+ option is set for the + # association, it will be used for the query. Otherwise, construct options and pass them with + # scope to the target class's +count+. + def count(column_name = nil, count_options = {}) + column_name, count_options = nil, column_name if column_name.is_a?(Hash) + + if options[:counter_sql] || options[:finder_sql] + unless count_options.blank? + raise ArgumentError, "If finder_sql/counter_sql is used then options cannot be passed" + end + + reflection.klass.count_by_sql(custom_counter_sql) + else + if options[:uniq] + # This is needed because 'SELECT count(DISTINCT *)..' is not valid SQL. + column_name ||= reflection.klass.primary_key + count_options.merge!(:distinct => true) + end + + value = scoped.count(column_name, count_options) + + limit = options[:limit] + offset = options[:offset] + + if limit || offset + [ [value - offset.to_i, 0].max, limit.to_i ].min + else + value + end + end + end + + # Removes +records+ from this association calling +before_remove+ and + # +after_remove+ callbacks. + # + # This method is abstract in the sense that +delete_records+ has to be + # provided by descendants. Note this method does not imply the records + # are actually removed from the database, that depends precisely on + # +delete_records+. They are in any case removed from the collection. + def delete(*records) + delete_or_destroy(records, options[:dependent]) + end + + # Destroy +records+ and remove them from this association calling + # +before_remove+ and +after_remove+ callbacks. + # + # Note that this method will _always_ remove records from the database + # ignoring the +:dependent+ option. + def destroy(*records) + records = find(records) if records.any? { |record| record.kind_of?(Fixnum) || record.kind_of?(String) } + delete_or_destroy(records, :destroy) + end + + # Returns the size of the collection by executing a SELECT COUNT(*) + # query if the collection hasn't been loaded, and calling + # <tt>collection.size</tt> if it has. + # + # If the collection has been already loaded +size+ and +length+ are + # equivalent. If not and you are going to need the records anyway + # +length+ will take one less query. Otherwise +size+ is more efficient. + # + # This method is abstract in the sense that it relies on + # +count_records+, which is a method descendants have to provide. + def size + if owner.new_record? || (loaded? && !options[:uniq]) + target.size + elsif !loaded? && options[:group] + load_target.size + elsif !loaded? && !options[:uniq] && target.is_a?(Array) + unsaved_records = target.select { |r| r.new_record? } + unsaved_records.size + count_records + else + count_records + end + end + + # Returns the size of the collection calling +size+ on the target. + # + # If the collection has been already loaded +length+ and +size+ are + # equivalent. If not and you are going to need the records anyway this + # method will take one less query. Otherwise +size+ is more efficient. + def length + load_target.size + end + + # Equivalent to <tt>collection.size.zero?</tt>. If the collection has + # not been already loaded and you are going to fetch the records anyway + # it is better to check <tt>collection.length.zero?</tt>. + def empty? + size.zero? + end + + def any? + if block_given? + load_target.any? { |*block_args| yield(*block_args) } + else + !empty? + end + end + + # Returns true if the collection has more than 1 record. Equivalent to collection.size > 1. + def many? + if block_given? + load_target.many? { |*block_args| yield(*block_args) } + else + size > 1 + end + end + + def uniq(collection = load_target) + seen = {} + collection.find_all do |record| + seen[record.id] = true unless seen.key?(record.id) + end + end + + # Replace this collection with +other_array+ + # This will perform a diff and delete/add only records that have changed. + def replace(other_array) + other_array.each { |val| raise_on_type_mismatch(val) } + original_target = load_target.dup + + transaction do + delete(target - other_array) + + unless concat(other_array - target) + @target = original_target + raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \ + "new records could not be saved." + end + end + end + + def include?(record) + if record.is_a?(reflection.klass) + if record.new_record? + include_in_memory?(record) + else + load_target if options[:finder_sql] + loaded? ? target.include?(record) : scoped.exists?(record) + end + else + false + end + end + + def cached_scope(method, args) + @scopes_cache[method][args] ||= scoped.readonly(nil).send(method, *args) + end + + def association_scope + options = reflection.options.slice(:order, :limit, :joins, :group, :having, :offset) + super.apply_finder_options(options) + end + + def load_target + if find_target? + targets = [] + + begin + targets = find_target + rescue ActiveRecord::RecordNotFound + reset + end + + @target = merge_target_lists(targets, target) + end + + loaded! + target + end + + def add_to_target(record) + transaction do + callback(:before_add, record) + yield(record) if block_given? + + if options[:uniq] && index = @target.index(record) + @target[index] = record + else + @target << record + end + + callback(:after_add, record) + set_inverse_instance(record) + end + + record + end + + private + + def select_value + super || uniq_select_value + end + + def uniq_select_value + options[:uniq] && "DISTINCT #{reflection.quoted_table_name}.*" + end + + def custom_counter_sql + if options[:counter_sql] + interpolate(options[:counter_sql]) + else + # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ + interpolate(options[:finder_sql]).sub(/SELECT\b(\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } + end + end + + def custom_finder_sql + interpolate(options[:finder_sql]) + end + + def find_target + records = + if options[:finder_sql] + reflection.klass.find_by_sql(custom_finder_sql) + else + find(:all) + end + + records = options[:uniq] ? uniq(records) : records + records.each { |record| set_inverse_instance(record) } + records + end + + def merge_target_lists(loaded, existing) + return loaded if existing.empty? + return existing if loaded.empty? + + loaded.map do |f| + i = existing.index(f) + if i + existing.delete_at(i).tap do |t| + keys = ["id"] + t.changes.keys + (f.attribute_names - t.attribute_names) + # FIXME: this call to attributes causes many NoMethodErrors + attributes = f.attributes + (attributes.keys - keys).each do |k| + t.send("#{k}=", attributes[k]) + end + end + else + f + end + end + existing + end + + def build_or_create(attributes, method) + records = Array.wrap(attributes).map do |attrs| + record = build_record(attrs) + + add_to_target(record) do + yield(record) if block_given? + insert_record(record) if method == :create + end + end + + attributes.is_a?(Array) ? records : records.first + end + + # Do the relevant stuff to insert the given record into the association collection. + def insert_record(record, validate = true) + raise NotImplementedError + end + + def build_record(attributes) + reflection.build_association(scoped.scope_for_create.merge(attributes)) + end + + 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? } + + transaction do + records.each { |record| callback(:before_remove, record) } + + delete_records(existing_records, method) if existing_records.any? + records.each { |record| target.delete(record) } + + records.each { |record| callback(:after_remove, record) } + end + end + + # Delete the given records from the association, using one of the methods :destroy, + # :delete_all or :nullify (or nil, in which case a default is used). + def delete_records(records, method) + raise NotImplementedError + end + + def callback(method, record) + callbacks_for(method).each do |callback| + case callback + when Symbol + owner.send(callback, record) + when Proc + callback.call(owner, record) + else + callback.send(method, owner, record) + end + end + end + + def callbacks_for(callback_name) + full_callback_name = "#{callback_name}_for_#{reflection.name}" + owner.class.send(full_callback_name.to_sym) || [] + end + + # Should we deal with assoc.first or assoc.last by issuing an independent query to + # the database, or by getting the target, and then taking the first/last item from that? + # + # If the args is just a non-empty options hash, go to the database. + # + # Otherwise, go to the database only if none of the following are true: + # * target already loaded + # * owner is new record + # * custom :finder_sql exists + # * 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) + if args.first.is_a?(Hash) + true + else + !(loaded? || + owner.new_record? || + options[:finder_sql] || + target.any? { |record| record.new_record? || record.changed? } || + args.first.kind_of?(Integer)) + 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 + } || target.include?(record) + else + target.include?(record) + end + end + + # If using a custom finder_sql, #find scans the entire collection. + def find_by_scan(*args) + expects_array = args.first.kind_of?(Array) + ids = args.flatten.compact.uniq.map { |arg| arg.to_i } + + if ids.size == 1 + id = ids.first + record = load_target.detect { |r| id == r.id } + expects_array ? [ record ] : record + else + load_target.select { |r| ids.include?(r.id) } + end + end + + # Fetches the first/last using SQL if possible, otherwise from the target array. + def first_or_last(type, *args) + args.shift if args.first.is_a?(Hash) && args.first.empty? + + collection = fetch_first_or_last_using_find?(args) ? scoped : load_target + collection.send(type, *args) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb new file mode 100644 index 0000000000..cf77d770c9 --- /dev/null +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -0,0 +1,127 @@ +module ActiveRecord + module Associations + # Association proxies in Active Record are middlemen between the object that + # holds the association, known as the <tt>@owner</tt>, and the actual associated + # object, known as the <tt>@target</tt>. The kind of association any proxy is + # about is available in <tt>@reflection</tt>. That's an instance of the class + # ActiveRecord::Reflection::AssociationReflection. + # + # For example, given + # + # class Blog < ActiveRecord::Base + # has_many :posts + # end + # + # blog = Blog.find(:first) + # + # the association proxy in <tt>blog.posts</tt> has the object in +blog+ as + # <tt>@owner</tt>, the collection of its posts as <tt>@target</tt>, and + # the <tt>@reflection</tt> object represents a <tt>:has_many</tt> macro. + # + # This class has most of the basic instance methods removed, and delegates + # unknown methods to <tt>@target</tt> via <tt>method_missing</tt>. As a + # corner case, it even removes the +class+ method and that's why you get + # + # blog.posts.class # => Array + # + # though the object behind <tt>blog.posts</tt> is not an Array, but an + # ActiveRecord::Associations::HasManyAssociation. + # + # The <tt>@target</tt> object is not \loaded until needed. For example, + # + # blog.posts.count + # + # is computed directly through SQL and does not trigger by itself the + # instantiation of the actual post records. + class CollectionProxy # :nodoc: + alias :proxy_extend :extend + + instance_methods.each { |m| undef_method m unless m.to_s =~ /^(?:nil\?|send|object_id|to_a)$|^__|^respond_to|proxy_/ } + + delegate :group, :order, :limit, :joins, :where, :preload, :eager_load, :includes, :from, + :lock, :readonly, :having, :to => :scoped + + delegate :target, :load_target, :loaded?, :scoped, + :to => :@association + + delegate :select, :find, :first, :last, + :build, :create, :create!, + :concat, :delete_all, :destroy_all, :delete, :destroy, :uniq, + :sum, :count, :size, :length, :empty?, + :any?, :many?, :include?, + :to => :@association + + def initialize(association) + @association = association + Array.wrap(association.options[:extend]).each { |ext| proxy_extend(ext) } + end + + def respond_to?(*args) + super || + (load_target && target.respond_to?(*args)) || + @association.klass.respond_to?(*args) + end + + def method_missing(method, *args, &block) + match = DynamicFinderMatch.match(method) + if match && match.creator? + attributes = match.attribute_names + return send(:"find_by_#{attributes.join('_and_')}", *args) || create(Hash[attributes.zip(args)]) + end + + if target.respond_to?(method) || (!@association.klass.respond_to?(method) && Class.respond_to?(method)) + if load_target + if target.respond_to?(method) + target.send(method, *args, &block) + else + begin + super + rescue NoMethodError => e + raise e, e.message.sub(/ for #<.*$/, " via proxy for #{target}") + end + end + end + + elsif @association.klass.scopes[method] + @association.cached_scope(method, args) + else + scoped.readonly(nil).send(method, *args, &block) + end + end + + # Forwards <tt>===</tt> explicitly to the \target because the instance method + # removal above doesn't catch it. Loads the \target if needed. + def ===(other) + other === load_target + end + + def to_ary + load_target.dup + end + alias_method :to_a, :to_ary + + def <<(*records) + @association.concat(records) && self + end + alias_method :push, :<< + + def clear + delete_all + self + end + + def reload + @association.reload + self + end + + def new(*args, &block) + if @association.is_a?(HasManyThroughAssociation) + @association.build(*args, &block) + else + method_missing(:new, *args, &block) + end + 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 index e2ce9aefcf..028630977d 100644 --- a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb +++ b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb @@ -1,134 +1,73 @@ module ActiveRecord # = Active Record Has And Belongs To Many Association module Associations - class HasAndBelongsToManyAssociation < AssociationCollection #:nodoc: - def create(attributes = {}) - create_record(attributes) { |record| insert_record(record) } - end + class HasAndBelongsToManyAssociation < CollectionAssociation #:nodoc: + attr_reader :join_table - def create!(attributes = {}) - create_record(attributes) { |record| insert_record(record, true) } + def initialize(owner, reflection) + @join_table = Arel::Table.new(reflection.options[:join_table]) + super end - def columns - @reflection.columns(@reflection.options[:join_table], "#{@reflection.options[:join_table]} Columns") - end + def insert_record(record, validate = true) + return if record.new_record? && !record.save(:validate => validate) + + if options[:insert_sql] + owner.connection.insert(interpolate(options[:insert_sql], record)) + else + stmt = join_table.compile_insert( + join_table[reflection.foreign_key] => owner.id, + join_table[reflection.association_foreign_key] => record.id + ) - def reset_column_information - @reflection.reset_column_information + owner.connection.insert stmt.to_sql + end + + record end - def has_primary_key? - @has_primary_key ||= @owner.connection.supports_primary_key? && @owner.connection.primary_key(@reflection.options[:join_table]) + def association_scope + super.joins(construct_joins) end - protected - def construct_find_options!(options) - options[:joins] = Arel::SqlLiteral.new(@scope[:find][:joins]) - options[:readonly] = finding_with_ambiguous_select?(options[:select] || @reflection.options[:select]) - options[:select] ||= (@reflection.options[:select] || Arel::SqlLiteral.new('*')) - end + private def count_records load_target.size end - def insert_record(record, force = true, validate = true) - if record.new_record? - if force - record.save! - else - return false unless record.save(:validate => validate) - end - end - - if @reflection.options[:insert_sql] - @owner.connection.insert(interpolate_sql(@reflection.options[:insert_sql], record)) + def delete_records(records, method) + if sql = options[:delete_sql] + records.each { |record| owner.connection.delete(interpolate(sql, record)) } else - relation = Arel::Table.new(@reflection.options[:join_table]) - timestamps = record_timestamp_columns(record) - timezone = record.send(:current_time_from_proper_timezone) if timestamps.any? - - attributes = columns.map do |column| - name = column.name - value = case name.to_s - when @reflection.primary_key_name.to_s - @owner.id - when @reflection.association_foreign_key.to_s - record.id - when *timestamps - timezone - else - @owner.send(:quote_value, record[name], column) if record.has_attribute?(name) - end - [relation[name], value] unless value.nil? - end - - stmt = relation.compile_insert Hash[attributes] - @owner.connection.insert stmt.to_sql - end - - true - end - - def delete_records(records) - if sql = @reflection.options[:delete_sql] - records.each { |record| @owner.connection.delete(interpolate_sql(sql, record)) } - else - relation = Arel::Table.new(@reflection.options[:join_table]) - stmt = relation.where(relation[@reflection.primary_key_name].eq(@owner.id). - and(relation[@reflection.association_foreign_key].in(records.map { |x| x.id }.compact)) + relation = join_table + stmt = relation.where(relation[reflection.foreign_key].eq(owner.id). + and(relation[reflection.association_foreign_key].in(records.map { |x| x.id }.compact)) ).compile_delete - @owner.connection.delete stmt.to_sql + owner.connection.delete stmt.to_sql end end def construct_joins - "INNER JOIN #{@owner.connection.quote_table_name @reflection.options[:join_table]} ON #{@reflection.quoted_table_name}.#{@reflection.klass.primary_key} = #{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.association_foreign_key}" - end + right = join_table + left = reflection.klass.arel_table - def construct_conditions - sql = "#{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.primary_key_name} = #{owner_quoted_id} " - sql << " AND (#{conditions})" if conditions - sql - end + condition = left[reflection.klass.primary_key].eq( + right[reflection.association_foreign_key]) - def construct_find_scope - { - :conditions => construct_conditions, - :joins => construct_joins, - :readonly => false, - :order => @reflection.options[:order], - :include => @reflection.options[:include], - :limit => @reflection.options[:limit] - } + right.create_join(right, right.create_on(condition)) end - # Join tables with additional columns on top of the two foreign keys must be considered - # ambiguous unless a select clause has been explicitly defined. Otherwise you can get - # broken records back, if, for example, the join column also has an id column. This will - # then overwrite the id column of the records coming back. - def finding_with_ambiguous_select?(select_clause) - !select_clause && columns.size != 2 + def construct_owner_conditions + super(join_table) end - private - def create_record(attributes, &block) - # Can't use Base.create because the foreign key may be a protected attribute. - ensure_owner_is_persisted! - if attributes.is_a?(Array) - attributes.collect { |attr| create(attr) } - else - build_record(attributes, &block) - end + def select_value + super || reflection.klass.arel_table[Arel.star] end - def record_timestamp_columns(record) - if record.record_timestamps - record.send(:all_timestamp_attributes).map { |x| x.to_s } - else - [] - end + def invertible_for?(record) + false 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 851b10e300..cebf3e477a 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -5,15 +5,14 @@ module ActiveRecord # # If the association has a <tt>:through</tt> option further specialization # is provided by its child HasManyThroughAssociation. - class HasManyAssociation < AssociationCollection #:nodoc: - protected - def owner_quoted_id(reflection = @reflection) - if reflection.options[:primary_key] - @owner.class.quote_value(@owner.send(reflection.options[:primary_key])) - else - @owner.quoted_id - end - end + class HasManyAssociation < CollectionAssociation #:nodoc: + + def insert_record(record, validate = true) + set_owner_attributes(record) + record.save(:validate => validate) + end + + private # Returns the number of records in this collection. # @@ -30,87 +29,73 @@ 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) - elsif @reflection.options[:counter_sql] || @reflection.options[:finder_sql] - @reflection.klass.count_by_sql(custom_counter_sql) + owner.send(:read_attribute, cached_counter_attribute_name) + elsif options[:counter_sql] || options[:finder_sql] + reflection.klass.count_by_sql(custom_counter_sql) else - @reflection.klass.count(@scope[:find].slice(:conditions, :joins, :include)) + scoped.count end # If there's nothing in the database and @target has no new records # we are certain the current target is an empty array. This is a # documented side-effect of the method that may avoid an extra SELECT. - @target ||= [] and loaded if count == 0 + @target ||= [] and loaded! if count == 0 - [@reflection.options[:limit], count].compact.min + [options[:limit], count].compact.min end - def has_cached_counter? - @owner.attribute_present?(cached_counter_attribute_name) + def has_cached_counter?(reflection = reflection) + owner.attribute_present?(cached_counter_attribute_name(reflection)) end - def cached_counter_attribute_name - "#{@reflection.name}_count" + def cached_counter_attribute_name(reflection = reflection) + "#{reflection.name}_count" end - def insert_record(record, force = false, validate = true) - set_belongs_to_association_for(record) - force ? record.save! : record.save(:validate => validate) - end - - # Deletes the records according to the <tt>:dependent</tt> option. - def delete_records(records) - case @reflection.options[:dependent] - when :destroy - records.each { |r| r.destroy } - when :delete_all - @reflection.klass.delete(records.map { |record| record.id }) - else - relation = Arel::Table.new(@reflection.table_name) - stmt = relation.where(relation[@reflection.primary_key_name].eq(@owner.id). - and(relation[@reflection.klass.primary_key].in(records.map { |r| r.id })) - ).compile_update(relation[@reflection.primary_key_name] => nil) - @owner.connection.update stmt.to_sql - - @owner.class.update_counters(@owner.id, cached_counter_attribute_name => -records.size) if has_cached_counter? + def update_counter(difference, reflection = reflection) + if has_cached_counter?(reflection) + counter = cached_counter_attribute_name(reflection) + owner.class.update_counters(owner.id, counter => difference) + owner[counter] += difference + owner.changed_attributes.delete(counter) # eww end end - def target_obsolete? - false + # This shit is nasty. We need to avoid the following situation: + # + # * An associated record is deleted via record.destroy + # * Hence the callbacks run, and they find a belongs_to on the record with a + # :counter_cache options which points back at our owner. So they update the + # counter cache. + # * In which case, we must make sure to *not* update the counter cache, or else + # it will be decremented twice. + # + # Hence this method. + def inverse_updates_counter_cache?(reflection = reflection) + counter_name = cached_counter_attribute_name(reflection) + reflection.klass.reflect_on_all_associations(:belongs_to).any? { |inverse_reflection| + inverse_reflection.counter_cache_column == counter_name + } end - def construct_conditions - if @reflection.options[:as] - sql = - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " + - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" + # Deletes the records according to the <tt>:dependent</tt> option. + def delete_records(records, method) + if method == :destroy + records.each { |r| r.destroy } + update_counter(-records.length) unless inverse_updates_counter_cache? else - sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}" - end - sql << " AND (#{conditions})" if conditions - sql - end + keys = records.map { |r| r[reflection.association_primary_key] } + scope = scoped.where(reflection.association_primary_key => keys) - def construct_find_scope - { - :conditions => construct_conditions, - :readonly => false, - :order => @reflection.options[:order], - :limit => @reflection.options[:limit], - :include => @reflection.options[:include] - } - end - - def construct_create_scope - create_scoping = {} - set_belongs_to_association_for(create_scoping) - create_scoping + if method == :delete_all + update_counter(-scope.delete_all) + else + update_counter(-scope.update_all(reflection.foreign_key => nil)) + end + end end - def we_can_set_the_inverse_on_this?(record) - @reflection.inverse_of - end + alias creation_attributes construct_owner_attributes 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 0b00132ad9..9d2b29685b 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -1,109 +1,144 @@ -require "active_record/associations/through_association_scope" require 'active_support/core_ext/object/blank' module ActiveRecord # = Active Record Has Many Through Association module Associations class HasManyThroughAssociation < HasManyAssociation #:nodoc: - include ThroughAssociationScope - - def build(attributes = {}, &block) - ensure_not_nested - super - end + include ThroughAssociation alias_method :new, :build - def create!(attrs = nil) - create_record(attrs, true) - end - - def create(attrs = nil) - create_record(attrs, false) - end - - def destroy(*records) - transaction do - delete_records(flatten_deeper(records)) - super - end - 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 - return @owner.send(:read_attribute, cached_counter_attribute_name) if has_cached_counter? - return @target.size if loaded? - return count + 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 + + super end - protected - def create_record(attrs, force = true) + def insert_record(record, validate = true) + ensure_not_nested + return if record.new_record? && !record.save(:validate => validate) + + through_record(record).save! + update_counter(1) + record + end + + private + + def through_record(record) + through_association = owner.association(through_reflection.name) + attributes = construct_join_attributes(record) + + through_record = Array.wrap(through_association.target).find { |candidate| + candidate.attributes.slice(*attributes.keys) == attributes + } + + unless through_record + through_record = through_association.build(attributes) + through_record.send("#{source_reflection.name}=", record) + end + + through_record + end + + def build_record(attributes) ensure_not_nested - ensure_owner_is_persisted! - transaction do - object = @reflection.klass.new(attrs) - add_record_to_target_with_callbacks(object) {|r| insert_record(object, force) } - object + record = super(attributes) + + inverse = source_reflection.inverse_of + if inverse + if inverse.macro == :has_many + record.send(inverse.name) << through_record(record) + elsif inverse.macro == :has_one + record.send("#{inverse.name}=", through_record(record)) + end end + + record end def target_reflection_has_associated_record? - if @reflection.through_reflection.macro == :belongs_to && @owner[@reflection.through_reflection.primary_key_name].blank? + if through_reflection.macro == :belongs_to && owner[through_reflection.foreign_key].blank? false else true end end - def construct_find_options!(options) - options[:joins] = construct_joins(options[:joins]) - options[:include] = @reflection.source_reflection.options[:include] if options[:include].nil? && @reflection.source_reflection.options[:include] + def update_through_counter?(method) + case method + when :destroy + !inverse_updates_counter_cache?(through_reflection) + when :nullify + false + else + true + end end - def insert_record(record, force = true, validate = true) + def delete_records(records, method) ensure_not_nested - if record.new_record? - if force - record.save! - else - return false unless record.save(:validate => validate) - end - end + through = owner.association(through_reflection.name) + scope = through.scoped.where(construct_join_attributes(*records)) - through_association = @owner.send(@reflection.through_reflection.name) - through_association.create!(construct_join_attributes(record)) - end + case method + when :destroy + count = scope.destroy_all.length + when :nullify + count = scope.update_all(source_reflection.foreign_key => nil) + else + count = scope.delete_all + end - # TODO - add dependent option support - def delete_records(records) - ensure_not_nested + delete_through_records(through, records) - klass = @reflection.through_reflection.klass - records.each do |associate| - klass.delete_all(construct_join_attributes(associate)) + if through_reflection.macro == :has_many && update_through_counter?(method) + update_counter(-count, through_reflection) end - end - def find_target - return [] unless target_reflection_has_associated_record? - with_scope(@scope) { @reflection.klass.find(:all) } + update_counter(-count) end - def has_cached_counter? - @owner.attribute_present?(cached_counter_attribute_name) + def delete_through_records(through, records) + if through_reflection.macro == :has_many + records.each do |record| + through.target.delete(through_record(record)) + end + else + records.each do |record| + through.target = nil if through.target == through_record(record) + end + end end - def cached_counter_attribute_name - "#{@reflection.name}_count" + def find_target + return [] unless target_reflection_has_associated_record? + scoped.all end # NOTE - not sure that we can actually cope with inverses here - def we_can_set_the_inverse_on_this?(record) + def invertible_for?(record) false end end diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index d581939f04..e13f97125f 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -1,135 +1,76 @@ module ActiveRecord # = Active Record Belongs To Has One Association module Associations - class HasOneAssociation < AssociationProxy #:nodoc: - def create(attrs = {}, replace_existing = true) - new_record(replace_existing) do |reflection| - attrs = merge_with_conditions(attrs) - reflection.create_association(attrs) - end - end + class HasOneAssociation < SingularAssociation #:nodoc: + def replace(record, save = true) + raise_on_type_mismatch(record) if record + load_target - def create!(attrs = {}, replace_existing = true) - new_record(replace_existing) do |reflection| - attrs = merge_with_conditions(attrs) - reflection.create_association!(attrs) - end - end + reflection.klass.transaction do + if target && target != record + remove_target!(options[:dependent]) + end + + if record + set_inverse_instance(record) + set_owner_attributes(record) - def build(attrs = {}, replace_existing = true) - new_record(replace_existing) do |reflection| - attrs = merge_with_conditions(attrs) - reflection.build_association(attrs) + if owner.persisted? && save && !record.save + nullify_owner_attributes(record) + set_owner_attributes(target) + raise RecordNotSaved, "Failed to save the new associated #{reflection.name}." + end + end end - end - def replace(obj, dont_save = false) - load_target + self.target = record + end - unless @target.nil? || @target == obj - if dependent? && !dont_save - case @reflection.options[:dependent] + def delete(method = options[:dependent]) + if load_target + case method when :delete - @target.delete if @target.persisted? - @owner.clear_association_cache + target.delete when :destroy - @target.destroy if @target.persisted? - @owner.clear_association_cache + target.destroy when :nullify - @target[@reflection.primary_key_name] = nil - @target.save if @owner.persisted? && @target.persisted? - end - else - @target[@reflection.primary_key_name] = nil - @target.save if @owner.persisted? && @target.persisted? + target.update_attribute(reflection.foreign_key, nil) end end - - if obj.nil? - @target = nil - else - raise_on_type_mismatch(obj) - set_belongs_to_association_for(obj) - @target = (AssociationProxy === obj ? obj.target : obj) - end - - set_inverse_instance(obj, @owner) - @loaded = true - - unless !@owner.persisted? || obj.nil? || dont_save - return (obj.save ? self : false) - else - return (obj.nil? ? nil : self) - end end - protected - def owner_quoted_id(reflection = @reflection) - if reflection.options[:primary_key] - @owner.class.quote_value(@owner.send(reflection.options[:primary_key])) - else - @owner.quoted_id - end - end + def association_scope + super.order(options[:order]) + end private - def find_target - options = @reflection.options.dup.slice(:select, :order, :include, :readonly) - the_target = with_scope(:find => @scope[:find]) do - @reflection.klass.find(:first, options) - end - set_inverse_instance(the_target, @owner) - the_target - end - - def construct_find_scope - if @reflection.options[:as] - sql = - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " + - "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" - else - sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}" - end - sql << " AND (#{conditions})" if conditions - { :conditions => sql } - end + alias creation_attributes construct_owner_attributes - def construct_create_scope - create_scoping = {} - set_belongs_to_association_for(create_scoping) - create_scoping + # The reason that the save param for replace is false, if for create (not just build), + # is because the setting of the foreign keys is actually handled by the scoping when + # the record is instantiated, and so they are set straight away and do not need to be + # updated within replace. + def set_new_record(record) + replace(record, false) end - def new_record(replace_existing) - # Make sure we load the target first, if we plan on replacing the existing - # instance. Otherwise, if the target has not previously been loaded - # elsewhere, the instance we create will get orphaned. - load_target if replace_existing - record = @reflection.klass.send(:with_scope, :create => @scope[:create]) do - yield @reflection - end - - if replace_existing - replace(record, true) + def remove_target!(method) + if [:delete, :destroy].include?(method) + target.send(method) else - record[@reflection.primary_key_name] = @owner.id if @owner.persisted? - self.target = record - set_inverse_instance(record, @owner) - end + nullify_owner_attributes(target) - record - end - - def we_can_set_the_inverse_on_this?(record) - inverse = @reflection.inverse_of - return !inverse.nil? + if target.persisted? && owner.persisted? && !target.save + set_owner_attributes(target) + raise RecordNotSaved, "Failed to remove the existing associated #{reflection.name}. " + + "The record failed to save when after its foreign key was set to nil." + end + end end - def merge_with_conditions(attrs={}) - attrs ||= {} - attrs.update(@reflection.options[:conditions]) if @reflection.options[:conditions].is_a?(Hash) - attrs + def nullify_owner_attributes(record) + record[reflection.foreign_key] = nil end end end diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb index e9dc32efd3..fdf8ae1453 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -1,42 +1,36 @@ -require "active_record/associations/through_association_scope" - module ActiveRecord # = Active Record Has One Through Association module Associations - class HasOneThroughAssociation < HasOneAssociation - include ThroughAssociationScope + class HasOneThroughAssociation < HasOneAssociation #:nodoc: + include ThroughAssociation - def replace(new_value) - create_through_record(new_value) - @target = new_value + def replace(record) + create_through_record(record) + self.target = record end private - def create_through_record(new_value) #nodoc: - ensure_not_nested + def create_through_record(record) + ensure_not_nested - klass = @reflection.through_reflection.klass + through_proxy = owner.association(through_reflection.name) + through_record = through_proxy.send(:load_target) - current_object = @owner.send(@reflection.through_reflection.name) + if through_record && !record + through_record.destroy + elsif record + attributes = construct_join_attributes(record) - if current_object - new_value ? current_object.update_attributes(construct_join_attributes(new_value)) : current_object.destroy - elsif new_value - if @owner.new_record? - self.target = new_value - through_association = @owner.send(:association_instance_get, @reflection.through_reflection.name) - through_association.build(construct_join_attributes(new_value)) - else - @owner.send(@reflection.through_reflection.name, klass.create(construct_join_attributes(new_value))) + if through_record + through_record.update_attributes(attributes) + elsif owner.new_record? + through_proxy.build(attributes) + else + through_proxy.create(attributes) + end end end - end - - private - def find_target - with_scope(@scope) { @reflection.klass.find(:first) } - end end end end diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb new file mode 100644 index 0000000000..504f25271c --- /dev/null +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -0,0 +1,215 @@ +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, :active_record + + def initialize(base, associations, joins) + @active_record = base + @table_joins = joins + @join_parts = [JoinBase.new(base)] + @associations = {} + @reflections = [] + @alias_tracker = AliasTracker.new(joins) + @alias_tracker.aliased_name_for(base.table_name) # Updates the count for base.table_name to 1 + build(associations) + end + + 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 + end + + def join_associations + join_parts.last(join_parts.length - 1) + end + + def join_base + join_parts.first + 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) + } + }.flatten + end + + def instantiate(rows) + primary_key = join_base.aliased_primary_key + parents = {} + + 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 + + remove_duplicate_results!(active_record, records, @associations) + records + end + + 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.keys.each 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 + + remove_duplicate_results!(reflection.klass, parent_records, associations[name]) unless parent_records.empty? + end + end + end + + protected + + 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] ||= {} + end + + def build(associations, parent = nil, join_type = Arel::InnerJoin) + parent ||= join_parts.last + case associations + when Symbol, String + reflection = parent.reflections[associations.to_s.intern] or + raise ConfigurationError, "Association named '#{ associations }' was not found; perhaps you misspelled it?" + 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 + end + + def find_join_association(name_or_reflection, parent) + if String === name_or_reflection + name_or_reflection = name_or_reflection.to_sym + end + + join_associations.detect { |j| + j.reflection == name_or_reflection && j.parent == parent + } + end + + def remove_uniq_by_reflection(reflection, records) + if reflection && reflection.collection? + records.each { |record| record.send(reflection.name).target.uniq! } + end + end + + def build_join_association(reflection, parent) + JoinAssociation.new(reflection, self, parent) + end + + def construct(parent, associations, join_parts, row) + case associations + when Symbol, String + name = associations.to_s + + join_part = join_parts.detect { |j| + j.reflection.name.to_s == name && + j.parent_table_name == parent.class.table_name } + + raise(ConfigurationError, "No such association") unless join_part + + 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 + end + else + raise ConfigurationError, associations.inspect + end + end + + def construct_association(record, join_part, row) + return if record.id.to_s != join_part.parent.record_id(row).to_s + + macro = join_part.reflection.macro + if macro == :has_one + return 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 + return if row[join_part.aliased_primary_key].nil? + association = join_part.instantiate(row) + case macro + when :has_many, :has_and_belongs_to_many + other = record.association(join_part.reflection.name) + other.loaded! + other.target.push(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}" + 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) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb new file mode 100644 index 0000000000..890e77fca9 --- /dev/null +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -0,0 +1,259 @@ +module ActiveRecord + module Associations + class JoinDependency # :nodoc: + class JoinAssociation < JoinPart # :nodoc: + # The reflection of the association represented + attr_reader :reflection + + # The JoinDependency object which this JoinAssociation exists within. This is mainly + # relevant for generating aliases which do not conflict with other joins which are + # part of the query. + attr_reader :join_dependency + + # A JoinBase instance representing the active record we are joining onto. + # (So in Author.has_many :posts, the Author would be that base record.) + attr_reader :parent + + # What type of join will be generated, either Arel::InnerJoin (default) or Arel::OuterJoin + attr_accessor :join_type + + # These implement abstract methods from the superclass + attr_reader :aliased_prefix + + attr_reader :tables + + delegate :options, :through_reflection, :source_reflection, :through_reflection_chain, :to => :reflection + delegate :table, :table_name, :to => :parent, :prefix => :parent + delegate :alias_tracker, :to => :join_dependency + + def initialize(reflection, join_dependency, parent = nil) + reflection.check_validity! + + if reflection.options[:polymorphic] + raise EagerLoadPolymorphicError.new(reflection) + end + + super(reflection.klass) + + @reflection = reflection + @join_dependency = join_dependency + @parent = parent + @join_type = Arel::InnerJoin + @aliased_prefix = "t#{ join_dependency.join_parts.size }" + + setup_tables + end + + def ==(other) + other.class == self.class && + other.reflection == reflection && + other.parent == parent + end + + def find_parent_in(other_join_dependency) + other_join_dependency.join_parts.detect do |join_part| + parent == join_part + end + end + + def join_to(relation) + # The chain starts with the target table, but we want to end with it here (makes + # more sense in this context) + chain = through_reflection_chain.reverse + + foreign_table = parent_table + index = 0 + + chain.each do |reflection| + table = tables[index] + conditions = [] + + if reflection.source_reflection.nil? + case reflection.macro + when :belongs_to + key = reflection.association_primary_key + foreign_key = reflection.foreign_key + when :has_many, :has_one + key = reflection.foreign_key + foreign_key = reflection.active_record_primary_key + + conditions << polymorphic_conditions(reflection, table) + when :has_and_belongs_to_many + # For habtm, we need to deal with the join table at the same time as the + # target table (because unlike a :through association, there is no reflection + # to represent the join table) + table, join_table = table + + join_key = reflection.foreign_key + join_foreign_key = reflection.active_record.primary_key + + relation = relation.join(join_table, join_type).on( + join_table[join_key]. + eq(foreign_table[join_foreign_key]) + ) + + # We've done the first join now, so update the foreign_table for the second + foreign_table = join_table + + key = reflection.klass.primary_key + foreign_key = reflection.association_foreign_key + end + else + case reflection.source_reflection.macro + when :belongs_to + key = reflection.association_primary_key + foreign_key = reflection.foreign_key + + conditions << source_type_conditions(reflection, foreign_table) + when :has_many, :has_one + key = reflection.foreign_key + foreign_key = reflection.source_reflection.active_record_primary_key + when :has_and_belongs_to_many + table, join_table = table + + join_key = reflection.foreign_key + join_foreign_key = reflection.klass.primary_key + + relation = relation.join(join_table, join_type).on( + join_table[join_key]. + eq(foreign_table[join_foreign_key]) + ) + + foreign_table = join_table + + key = reflection.klass.primary_key + foreign_key = reflection.association_foreign_key + end + end + + conditions << table[key].eq(foreign_table[foreign_key]) + + conditions << reflection_conditions(index, table) + conditions << sti_conditions(reflection, table) + + ands = relation.create_and(conditions.flatten.compact) + + join = relation.create_join( + table, + relation.create_on(ands), + join_type) + + relation = relation.from(join) + + # The current table in this iteration becomes the foreign table in the next + foreign_table = table + index += 1 + end + + relation + end + + def join_relation(joining_relation) + self.join_type = Arel::OuterJoin + joining_relation.joins(self) + end + + def table + if tables.last.is_a?(Array) + tables.last.first + else + tables.last + end + end + + def aliased_table_name + table.table_alias || table.name + end + + protected + + def table_alias_for(reflection, join = false) + name = alias_tracker.pluralize(reflection.name) + name << "_#{parent_table_name}" + name << "_join" if join + name + end + + private + + # Generate aliases and Arel::Table instances for each of the tables which we will + # later generate joins for. We must do this in advance in order to correctly allocate + # the proper alias. + def setup_tables + @tables = through_reflection_chain.map do |reflection| + table = alias_tracker.aliased_table_for( + reflection.table_name, + table_alias_for(reflection, reflection != self.reflection) + ) + + # For habtm, we have two Arel::Table instances related to a single reflection, so + # we just store them as a pair in the array. + if reflection.macro == :has_and_belongs_to_many || + (reflection.source_reflection && reflection.source_reflection.macro == :has_and_belongs_to_many) + + join_table = alias_tracker.aliased_table_for( + (reflection.source_reflection || reflection).options[:join_table], + table_alias_for(reflection, true) + ) + + [table, join_table] + else + table + end + end + + # The joins are generated from the through_reflection_chain in reverse order, so + # reverse the tables too (but it's important to generate the aliases in the 'forward' + # order, which is why we only do the reversal now. + @tables.reverse! + end + + def process_conditions(conditions, table_name) + if conditions.respond_to?(:to_proc) + conditions = instance_eval(&conditions) + end + + Arel.sql(sanitize_sql(conditions, table_name)) + end + + def sanitize_sql(condition, table_name) + active_record.send(:sanitize_sql, condition, table_name) + end + + def reflection_conditions(index, table) + reflection.through_conditions.reverse[index].map do |condition| + process_conditions(condition, table.table_alias || table.name) + end + end + + def sti_conditions(reflection, table) + unless reflection.klass.descends_from_active_record? + sti_column = table[reflection.klass.inheritance_column] + sti_condition = sti_column.eq(reflection.klass.sti_name) + subclasses = reflection.klass.descendants + + # TODO: use IN (...), or possibly AR::Base#type_condition + subclasses.inject(sti_condition) { |attr,subclass| + attr.or(sti_column.eq(subclass.sti_name)) + } + end + end + + def source_type_conditions(reflection, foreign_table) + if reflection.options[:source_type] + foreign_table[reflection.source_reflection.foreign_type]. + eq(reflection.options[:source_type]) + end + end + + def polymorphic_conditions(reflection, table) + if reflection.options[:as] + table[reflection.type]. + eq(reflection.active_record.base_class.name) + end + end + + end + end + end +end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_base.rb b/activerecord/lib/active_record/associations/join_dependency/join_base.rb new file mode 100644 index 0000000000..3920e84976 --- /dev/null +++ b/activerecord/lib/active_record/associations/join_dependency/join_base.rb @@ -0,0 +1,24 @@ +module ActiveRecord + module Associations + class JoinDependency # :nodoc: + class JoinBase < JoinPart # :nodoc: + def ==(other) + other.class == self.class && + other.active_record == active_record + end + + def aliased_prefix + "t0" + end + + def table + Arel::Table.new(table_name, arel_engine) + end + + def aliased_table_name + active_record.table_name + end + end + end + end +end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_part.rb b/activerecord/lib/active_record/associations/join_dependency/join_part.rb new file mode 100644 index 0000000000..3279e56e7d --- /dev/null +++ b/activerecord/lib/active_record/associations/join_dependency/join_part.rb @@ -0,0 +1,78 @@ +module ActiveRecord + module Associations + class JoinDependency # :nodoc: + # A JoinPart represents a part of a JoinDependency. It is an abstract class, inherited + # by JoinBase and JoinAssociation. A JoinBase represents the Active Record which + # everything else is being joined onto. A JoinAssociation represents an association which + # is joining to the base. A JoinAssociation may result in more than one actual join + # operations (for example a has_and_belongs_to_many JoinAssociation would result in + # two; one for the join table and one for the target table). + class JoinPart # :nodoc: + # The Active Record class which this join part is associated 'about'; for a JoinBase + # this is the actual base model, for a JoinAssociation this is the target model of the + # association. + attr_reader :active_record + + delegate :table_name, :column_names, :primary_key, :reflections, :arel_engine, :to => :active_record + + def initialize(active_record) + @active_record = active_record + @cached_record = {} + @column_names_with_alias = nil + end + + def aliased_table + Arel::Nodes::TableAlias.new aliased_table_name, table + end + + def ==(other) + raise NotImplementedError + end + + # An Arel::Table for the active_record + def table + raise NotImplementedError + end + + # The prefix to be used when aliasing columns in the active_record's table + def aliased_prefix + raise NotImplementedError + end + + # The alias for the active_record's table + def aliased_table_name + raise NotImplementedError + end + + # The alias for the primary key of the active_record's table + def aliased_primary_key + "#{aliased_prefix}_r0" + end + + # An array of [column_name, alias] pairs for the table + def column_names_with_alias + unless @column_names_with_alias + @column_names_with_alias = [] + + ([primary_key] + (column_names - [primary_key])).each_with_index do |column_name, i| + @column_names_with_alias << [column_name, "#{aliased_prefix}_r#{i}"] + end + 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] + end + + def instantiate(row) + @cached_record[record_id(row)] ||= active_record.send(:instantiate, extract_record(row)) + end + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb new file mode 100644 index 0000000000..fafed94ff2 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -0,0 +1,177 @@ +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. + # + # 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: + # + # SELECT * FROM authors + # LEFT OUTER JOIN books ON authors.id = books.id + # WHERE authors.name = 'Ken Akamatsu' + # + # 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). + # + # 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: + autoload :Association, 'active_record/associations/preloader/association' + autoload :SingularAssociation, 'active_record/associations/preloader/singular_association' + autoload :CollectionAssociation, 'active_record/associations/preloader/collection_association' + autoload :ThroughAssociation, 'active_record/associations/preloader/through_association' + + autoload :HasMany, 'active_record/associations/preloader/has_many' + 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' + + attr_reader :records, :associations, :options, :model + + # Eager loads the named associations for the given Active Record record(s). + # + # In this description, 'association name' shall refer to the name passed + # to an association creation method. For example, a model that specifies + # <tt>belongs_to :author</tt>, <tt>has_many :buyers</tt> has association + # names +:author+ and +:buyers+. + # + # == Parameters + # +records+ is an array of ActiveRecord::Base. This array needs not be flat, + # i.e. +records+ itself may also contain arrays of records. In any case, + # +preload_associations+ will preload the all associations records by + # flattening +records+. + # + # +associations+ specifies one or more associations that you want to + # preload. It may be: + # - a Symbol or a String which specifies a single association name. For + # example, specifying +:books+ allows this method to preload all books + # for an Author. + # - an Array which specifies multiple association names. This array + # is processed recursively. For example, specifying <tt>[:avatar, :books]</tt> + # allows this method to preload an author's avatar as well as all of his + # books. + # - a Hash which specifies multiple association names, as well as + # association names for the to-be-preloaded association objects. For + # example, specifying <tt>{ :author => :avatar }</tt> will preload a + # book's author, as well as that author's avatar. + # + # +:associations+ has the same format as the +:include+ option for + # <tt>ActiveRecord::Base.find</tt>. So +associations+ could look like this: + # + # :books + # [ :books, :author ] + # { :author => :avatar } + # [ :books, { :author => :avatar } ] + # + # +options+ contains options that will be passed to ActiveRecord::Base#find + # (which is called under the hood for preloading records). But it is passed + # only one level deep in the +associations+ argument, i.e. it's not passed + # to the child associations when +associations+ is a Hash. + def initialize(records, associations, options = {}) + @records = Array.wrap(records).compact.uniq + @associations = Array.wrap(associations) + @options = options + end + + def run + unless records.empty? + associations.each { |association| preload(association) } + end + end + + private + + def preload(association) + case association + when Hash + preload_hash(association) + when String, Symbol + preload_one(association.to_sym) + 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, options).run + Preloader.new(records.map { |record| record.send(parent) }.flatten, child).run + end + end + + # Not all records have the same class, so group then preload group on the reflection + # itself so that if various subclass share the same association then we do not split + # them unnecessarily + # + # 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, options).run + 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 + ] + end + + def records_by_reflection(association) + records.group_by do |record| + reflection = record.class.reflections[association] + + unless reflection + raise ActiveRecord::ConfigurationError, "Association named '#{association}' was not found; " \ + "perhaps you misspelled it?" + end + + reflection + 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 + end + + def preloader_for(reflection) + 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 + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb new file mode 100644 index 0000000000..7256dd5288 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -0,0 +1,126 @@ +module ActiveRecord + module Associations + class Preloader + class Association #:nodoc: + attr_reader :owners, :reflection, :preload_options, :model, :klass + + def initialize(klass, owners, reflection, preload_options) + @klass = klass + @owners = owners + @reflection = reflection + @preload_options = preload_options || {} + @model = owners.first && owners.first.class + @scoped = nil + @owners_by_key = nil + end + + def run + unless owners.first.association(reflection.name).loaded? + preload + end + end + + def preload + raise NotImplementedError + end + + def scoped + @scoped ||= build_scope + end + + def records_for(ids) + scoped.where(association_key.in(ids)) + end + + def table + klass.arel_table + end + + # The name of the key on the associated records + def association_key_name + raise NotImplementedError + end + + # This is overridden by HABTM as the condition should be on the foreign_key column in + # the join table + def association_key + table[association_key_name] + end + + # The name of the key on the model which declares the association + def owner_key_name + raise NotImplementedError + end + + # We're converting to a string here because postgres will return the aliased association + # key in a habtm as a string (for whatever reason) + def owners_by_key + @owners_by_key ||= owners.group_by do |owner| + key = owner[owner_key_name] + key && key.to_s + end + end + + def options + reflection.options + end + + private + + def associated_records_by_owner + owner_keys = owners.map { |owner| owner[owner_key_name] }.compact.uniq + + if klass.nil? || owner_keys.empty? + records = [] + else + # 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(model.connection.in_clause_length || owner_keys.size) + records = sliced.map { |slice| records_for(slice) }.flatten + end + + # Each record may have multiple owners, and vice-versa + records_by_owner = Hash[owners.map { |owner| [owner, []] }] + records.each do |record| + owner_key = record[association_key_name].to_s + + owners_by_key[owner_key].each do |owner| + records_by_owner[owner] << record + end + end + records_by_owner + end + + def build_scope + scope = klass.scoped + + scope = scope.where(process_conditions(options[:conditions])) + scope = scope.where(process_conditions(preload_options[:conditions])) + + scope = scope.select(preload_options[:select] || options[:select] || table[Arel.star]) + scope = scope.includes(preload_options[:include] || options[:include]) + + if options[:as] + scope = scope.where( + klass.table_name => { + reflection.type => model.base_class.sti_name + } + ) + end + + scope + end + + def process_conditions(conditions) + if conditions.respond_to?(:to_proc) + conditions = klass.send(:instance_eval, &conditions) + end + + if conditions + klass.send(:sanitize_sql, conditions) + end + end + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader/belongs_to.rb b/activerecord/lib/active_record/associations/preloader/belongs_to.rb new file mode 100644 index 0000000000..5091d4717a --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/belongs_to.rb @@ -0,0 +1,17 @@ +module ActiveRecord + module Associations + class Preloader + class BelongsTo < SingularAssociation #:nodoc: + + def association_key_name + reflection.options[:primary_key] || klass && klass.primary_key + end + + def owner_key_name + reflection.foreign_key + end + + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader/collection_association.rb b/activerecord/lib/active_record/associations/preloader/collection_association.rb new file mode 100644 index 0000000000..c248aeaaf6 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/collection_association.rb @@ -0,0 +1,24 @@ +module ActiveRecord + module Associations + class Preloader + class CollectionAssociation < Association #:nodoc: + + private + + def build_scope + super.order(preload_options[:order] || options[:order]) + end + + def preload + associated_records_by_owner.each do |owner, records| + association = owner.association(reflection.name) + association.loaded! + association.target.concat(records) + records.each { |record| association.set_inverse_instance(record) } + end + end + + end + end + end +end 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 new file mode 100644 index 0000000000..e794f05340 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/has_and_belongs_to_many.rb @@ -0,0 +1,58 @@ +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(options[: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 = super + klass.connection.select_all(scope.arel.to_sql, '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 + def associated_records_by_owner + super.each do |owner_key, rows| + rows.map! { |row| klass.instantiate(row) } + end + 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.rb b/activerecord/lib/active_record/associations/preloader/has_many.rb new file mode 100644 index 0000000000..3ea91a8c11 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/has_many.rb @@ -0,0 +1,17 @@ +module ActiveRecord + module Associations + class Preloader + class HasMany < CollectionAssociation #:nodoc: + + def association_key_name + reflection.foreign_key + end + + def owner_key_name + reflection.active_record_primary_key + 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 new file mode 100644 index 0000000000..c6e9ede356 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/has_many_through.rb @@ -0,0 +1,15 @@ +module ActiveRecord + module Associations + class Preloader + class HasManyThrough < CollectionAssociation #:nodoc: + include ThroughAssociation + + def associated_records_by_owner + super.each do |owner, records| + records.uniq! if options[:uniq] + end + end + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader/has_one.rb b/activerecord/lib/active_record/associations/preloader/has_one.rb new file mode 100644 index 0000000000..848448bb48 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/has_one.rb @@ -0,0 +1,23 @@ +module ActiveRecord + module Associations + class Preloader + class HasOne < SingularAssociation #:nodoc: + + def association_key_name + reflection.foreign_key + end + + def owner_key_name + reflection.active_record_primary_key + end + + private + + def build_scope + super.order(preload_options[:order] || options[:order]) + end + + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader/has_one_through.rb b/activerecord/lib/active_record/associations/preloader/has_one_through.rb new file mode 100644 index 0000000000..f063f85574 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/has_one_through.rb @@ -0,0 +1,9 @@ +module ActiveRecord + module Associations + class Preloader + class HasOneThrough < SingularAssociation #:nodoc: + include ThroughAssociation + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader/singular_association.rb b/activerecord/lib/active_record/associations/preloader/singular_association.rb new file mode 100644 index 0000000000..44e804d785 --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/singular_association.rb @@ -0,0 +1,21 @@ +module ActiveRecord + module Associations + class Preloader + class SingularAssociation < Association #:nodoc: + + private + + def preload + associated_records_by_owner.each do |owner, associated_records| + record = associated_records.first + + association = owner.association(reflection.name) + association.target = record + association.set_inverse_instance(record) + end + end + + end + end + end +end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb new file mode 100644 index 0000000000..ad6374d09a --- /dev/null +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -0,0 +1,67 @@ +module ActiveRecord + module Associations + class Preloader + module ThroughAssociation #:nodoc: + + def through_reflection + reflection.through_reflection + end + + def source_reflection + reflection.source_reflection + end + + def associated_records_by_owner + through_records = through_records_by_owner + + ActiveRecord::Associations::Preloader.new( + through_records.values.flatten, + source_reflection.name, options + ).run + + through_records.each do |owner, records| + records.map! { |r| r.send(source_reflection.name) }.flatten! + records.compact! + end + end + + private + + def through_records_by_owner + ActiveRecord::Associations::Preloader.new( + owners, through_reflection.name, + through_options + ).run + + Hash[owners.map do |owner| + through_records = Array.wrap(owner.send(through_reflection.name)) + + # Dont cache the association - we would only be caching a subset + if reflection.options[:source_type] && through_reflection.collection? + owner.association(through_reflection.name).reset + end + + [owner, through_records] + end] + end + + def through_options + through_options = {} + + if options[:source_type] + through_options[:conditions] = { reflection.foreign_type => options[:source_type] } + else + if options[:conditions] + through_options[:include] = options[:include] || options[:source] + through_options[:conditions] = options[:conditions] + end + + through_options[:order] = options[:order] + end + + through_options + end + end + end + end +end diff --git a/activerecord/lib/active_record/associations/singular_association.rb b/activerecord/lib/active_record/associations/singular_association.rb new file mode 100644 index 0000000000..0d8e45adb5 --- /dev/null +++ b/activerecord/lib/active_record/associations/singular_association.rb @@ -0,0 +1,55 @@ +module ActiveRecord + module Associations + class SingularAssociation < Association #:nodoc: + # Implements the reader method, e.g. foo.bar for Foo.has_one :bar + def reader(force_reload = false) + if force_reload + klass.uncached { reload } + elsif !loaded? || stale_target? + reload + end + + target + end + + # Implements the writer method, e.g. foo.items= for Foo.has_many :items + def writer(record) + replace(record) + end + + def create(attributes = {}) + new_record(:create, attributes) + end + + def create!(attributes = {}) + build(attributes).tap { |record| record.save! } + end + + def build(attributes = {}) + new_record(:build, attributes) + end + + private + + def find_target + scoped.first.tap { |record| set_inverse_instance(record) } + end + + # Implemented by subclasses + def replace(record) + raise NotImplementedError + end + + def set_new_record(record) + replace(record) + end + + def new_record(method, attributes) + attributes = scoped.scope_for_create.merge(attributes || {}) + record = reflection.send("#{method}_association", attributes) + set_new_record(record) + record + end + end + end +end diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb new file mode 100644 index 0000000000..ed24373cba --- /dev/null +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -0,0 +1,281 @@ +require 'enumerator' + +module ActiveRecord + # = Active Record Through Association + module Associations + module ThroughAssociation #:nodoc: + + delegate :source_options, :through_options, :source_reflection, :through_reflection, + :through_reflection_chain, :through_conditions, :to => :reflection + + protected + + def target_scope + super.merge(through_reflection.klass.scoped) + end + + def association_scope + scope = super.joins(construct_joins) + scope = scope.where(reflection_conditions(0)) + + unless options[:include] + scope = scope.includes(source_options[:include]) + end + + scope + end + + private + + # This scope affects the creation of the associated records (not the join records). At the + # moment we only support creating on a :through association when the source reflection is a + # belongs_to. Thus it's not necessary to set a foreign key on the associated record(s), so + # this scope has can legitimately be empty. + def creation_attributes + { } + end + + # TODO: Needed? + def aliased_through_table + name = through_reflection.table_name + + reflection.table_name == name ? + through_reflection.klass.arel_table.alias(name + "_join") : + through_reflection.klass.arel_table + end + + def construct_owner_conditions + reflection = through_reflection_chain.last + + if reflection.macro == :has_and_belongs_to_many + table = tables[reflection].first + else + table = Array.wrap(tables[reflection]).first + end + + super(table, reflection) + end + + def construct_joins + joins, right_index = [], 1 + + # Iterate over each pair in the through reflection chain, joining them together + through_reflection_chain.each_cons(2) do |left, right| + left_table, right_table = tables[left], tables[right] + + if left.source_reflection.nil? + case left.macro + when :belongs_to + joins << inner_join( + right_table, + left_table[left.association_primary_key], + right_table[left.foreign_key], + reflection_conditions(right_index) + ) + when :has_many, :has_one + joins << inner_join( + right_table, + left_table[left.foreign_key], + right_table[right.association_primary_key], + polymorphic_conditions(left, left), + reflection_conditions(right_index) + ) + when :has_and_belongs_to_many + joins << inner_join( + right_table, + left_table.first[left.foreign_key], + right_table[right.klass.primary_key], + reflection_conditions(right_index) + ) + end + else + case left.source_reflection.macro + when :belongs_to + joins << inner_join( + right_table, + left_table[left.association_primary_key], + right_table[left.foreign_key], + source_type_conditions(left), + reflection_conditions(right_index) + ) + when :has_many, :has_one + if right.macro == :has_and_belongs_to_many + join_table, right_table = tables[right] + end + + joins << inner_join( + right_table, + left_table[left.foreign_key], + right_table[left.source_reflection.active_record_primary_key], + polymorphic_conditions(left, left.source_reflection), + reflection_conditions(right_index) + ) + + if right.macro == :has_and_belongs_to_many + joins << inner_join( + join_table, + right_table[right.klass.primary_key], + join_table[right.association_foreign_key] + ) + end + when :has_and_belongs_to_many + join_table, left_table = tables[left] + + joins << inner_join( + join_table, + left_table[left.klass.primary_key], + join_table[left.association_foreign_key] + ) + + joins << inner_join( + right_table, + join_table[left.foreign_key], + right_table[right.klass.primary_key], + reflection_conditions(right_index) + ) + end + end + + right_index += 1 + end + + joins + end + + # Construct attributes for :through pointing to owner and associate. This is used by the + # methods which create and delete records on the association. + # + # We only support indirectly modifying through associations which has a belongs_to source. + # This is the "has_many :tags, :through => :taggings" situation, where the join model + # typically has a belongs_to on both side. In other words, associations which could also + # be represented as has_and_belongs_to_many associations. + # + # We do not support creating/deleting records on the association where the source has + # some other type, because this opens up a whole can of worms, and in basically any + # situation it is more natural for the user to just create or modify their join records + # directly as required. + def construct_join_attributes(*records) + if source_reflection.macro != :belongs_to + raise HasManyThroughCantAssociateThroughHasOneOrManyReflection.new(owner, reflection) + end + + join_attributes = { + source_reflection.foreign_key => + records.map { |record| + record.send(source_reflection.association_primary_key) + } + } + + if options[:source_type] + join_attributes[source_reflection.foreign_type] = + records.map { |record| record.class.base_class.name } + end + + if records.count == 1 + Hash[join_attributes.map { |k, v| [k, v.first] }] + else + join_attributes + end + end + + def alias_tracker + @alias_tracker ||= AliasTracker.new + end + + # TODO: It is decidedly icky to have an array for habtm entries, and no array for others + def tables + @tables ||= begin + Hash[ + through_reflection_chain.map do |reflection| + table = alias_tracker.aliased_table_for( + reflection.table_name, + table_alias_for(reflection, reflection != self.reflection) + ) + + if reflection.macro == :has_and_belongs_to_many || + (reflection.source_reflection && + reflection.source_reflection.macro == :has_and_belongs_to_many) + + join_table = alias_tracker.aliased_table_for( + (reflection.source_reflection || reflection).options[:join_table], + table_alias_for(reflection, true) + ) + + [reflection, [join_table, table]] + else + [reflection, table] + end + end + ] + end + end + + def table_alias_for(reflection, join = false) + name = alias_tracker.pluralize(reflection.name) + name << "_#{self.reflection.name}" + name << "_join" if join + name + end + + def inner_join(table, left_column, right_column, *conditions) + conditions << left_column.eq(right_column) + + table.create_join( + table, + table.create_on(table.create_and(conditions.flatten.compact))) + end + + def reflection_conditions(index) + reflection = through_reflection_chain[index] + conditions = through_conditions[index].dup + + # TODO: maybe this should go in Reflection#through_conditions directly? + unless reflection.klass.descends_from_active_record? + conditions << reflection.klass.send(:type_condition) + end + + unless conditions.empty? + conditions.map! do |condition| + condition = reflection.klass.send(:sanitize_sql, interpolate(condition), reflection.table_name) + condition = Arel.sql(condition) unless condition.is_a?(Arel::Node) + condition + end + + Arel::Nodes::And.new(conditions) + end + end + + def polymorphic_conditions(reflection, polymorphic_reflection) + if polymorphic_reflection.options[:as] + tables[reflection][polymorphic_reflection.type]. + eq(polymorphic_reflection.active_record.base_class.name) + end + end + + def source_type_conditions(reflection) + if reflection.options[:source_type] + tables[reflection.through_reflection][reflection.foreign_type]. + eq(reflection.options[:source_type]) + end + end + + # TODO: Think about this in the context of nested associations + def stale_state + if through_reflection.macro == :belongs_to + owner[through_reflection.foreign_key].to_s + end + end + + def foreign_key_present? + through_reflection.macro == :belongs_to && + !owner[through_reflection.foreign_key].nil? + end + + def ensure_not_nested + if reflection.nested? + raise HasManyThroughNestedAssociationsAreReadonly.new(owner, reflection) + end + end + end + end +end diff --git a/activerecord/lib/active_record/associations/through_association_scope.rb b/activerecord/lib/active_record/associations/through_association_scope.rb deleted file mode 100644 index f6d02a215f..0000000000 --- a/activerecord/lib/active_record/associations/through_association_scope.rb +++ /dev/null @@ -1,300 +0,0 @@ -require 'enumerator' - -module ActiveRecord - # = Active Record Through Association Scope - module Associations - module ThroughAssociationScope - - protected - - def construct_find_scope - { - :conditions => construct_conditions, - :joins => construct_joins, - :include => @reflection.options[:include] || @reflection.source_reflection.options[:include], - :select => construct_select, - :order => @reflection.options[:order], - :limit => @reflection.options[:limit], - :readonly => @reflection.options[:readonly] - } - end - - def construct_create_scope - @reflection.nested? ? {} : construct_owner_attributes(@reflection) - end - - # Build SQL conditions from attributes, qualified by table name. - def construct_conditions - reflection = @reflection.through_reflection_chain.last - - if reflection.macro == :has_and_belongs_to_many - table_alias = table_aliases[reflection].first - else - table_alias = table_aliases[reflection] - end - - parts = construct_quoted_owner_attributes(reflection).map do |attr, value| - "#{table_alias}.#{attr} = #{value}" - end - parts += reflection_conditions(0) - - "(" + parts.join(') AND (') + ")" - end - - # Associate attributes pointing to owner, quoted. - def construct_quoted_owner_attributes(reflection) - if as = reflection.options[:as] - { "#{as}_id" => owner_quoted_id(reflection), - "#{as}_type" => reflection.klass.quote_value( - @owner.class.base_class.name.to_s, - reflection.klass.columns_hash["#{as}_type"]) } - elsif reflection.macro == :belongs_to - { reflection.klass.primary_key => @owner.class.quote_value(@owner[reflection.primary_key_name]) } - else - { reflection.primary_key_name => owner_quoted_id(reflection) } - end - end - - def construct_select(custom_select = nil) - distinct = "DISTINCT " if @reflection.options[:uniq] - custom_select || @reflection.options[:select] || "#{distinct}#{@reflection.quoted_table_name}.*" - end - - def construct_joins(custom_joins = nil) - "#{construct_through_joins} #{@reflection.options[:joins]} #{custom_joins}" - end - - def construct_through_joins - joins, right_index = [], 1 - - # Iterate over each pair in the through reflection chain, joining them together - @reflection.through_reflection_chain.each_cons(2) do |left, right| - right_table_and_alias = table_name_and_alias(right.quoted_table_name, table_aliases[right]) - - if left.source_reflection.nil? - case left.macro - when :belongs_to - joins << inner_join_sql( - right_table_and_alias, - table_aliases[left], left.association_primary_key, - table_aliases[right], left.primary_key_name, - reflection_conditions(right_index) - ) - when :has_many, :has_one - joins << inner_join_sql( - right_table_and_alias, - table_aliases[left], left.primary_key_name, - table_aliases[right], right.association_primary_key, - polymorphic_conditions(left, left), - reflection_conditions(right_index) - ) - when :has_and_belongs_to_many - joins << inner_join_sql( - right_table_and_alias, - table_aliases[left].first, left.primary_key_name, - table_aliases[right], right.klass.primary_key, - reflection_conditions(right_index) - ) - end - else - case left.source_reflection.macro - when :belongs_to - joins << inner_join_sql( - right_table_and_alias, - table_aliases[left], left.association_primary_key, - table_aliases[right], left.primary_key_name, - source_type_conditions(left), - reflection_conditions(right_index) - ) - when :has_many, :has_one - if right.macro == :has_and_belongs_to_many - join_table, right_table = table_aliases[right] - right_table_and_alias = table_name_and_alias(right.quoted_table_name, right_table) - else - right_table = table_aliases[right] - end - - joins << inner_join_sql( - right_table_and_alias, - table_aliases[left], left.primary_key_name, - right_table, left.source_reflection.active_record_primary_key, - polymorphic_conditions(left, left.source_reflection), - reflection_conditions(right_index) - ) - - if right.macro == :has_and_belongs_to_many - joins << inner_join_sql( - table_name_and_alias( - quote_table_name(right.options[:join_table]), - join_table - ), - right_table, right.klass.primary_key, - join_table, right.association_foreign_key - ) - end - when :has_and_belongs_to_many - join_table, left_table = table_aliases[left] - - joins << inner_join_sql( - table_name_and_alias( - quote_table_name(left.source_reflection.options[:join_table]), - join_table - ), - left_table, left.klass.primary_key, - join_table, left.association_foreign_key - ) - - joins << inner_join_sql( - right_table_and_alias, - join_table, left.primary_key_name, - table_aliases[right], right.klass.primary_key, - reflection_conditions(right_index) - ) - end - end - - right_index += 1 - end - - joins.join(" ") - end - - def alias_tracker - @alias_tracker ||= AliasTracker.new - end - - def table_aliases - @table_aliases ||= begin - @reflection.through_reflection_chain.inject({}) do |aliases, reflection| - table_alias = quote_table_name(alias_tracker.aliased_name_for( - reflection.table_name, - table_alias_for(reflection, reflection != @reflection) - )) - - if reflection.macro == :has_and_belongs_to_many || - (reflection.source_reflection && - reflection.source_reflection.macro == :has_and_belongs_to_many) - - join_table_alias = quote_table_name(alias_tracker.aliased_name_for( - (reflection.source_reflection || reflection).options[:join_table], - table_alias_for(reflection, true) - )) - - aliases[reflection] = [join_table_alias, table_alias] - else - aliases[reflection] = table_alias - end - - aliases - end - end - end - - def table_alias_for(reflection, join = false) - name = alias_tracker.pluralize(reflection.name) - name << "_#{@reflection.name}" - name << "_join" if join - name - end - - def quote_table_name(table_name) - @reflection.klass.connection.quote_table_name(table_name) - end - - def table_name_and_alias(table_name, table_alias) - "#{table_name} #{table_alias if table_alias != table_name}".strip - end - - def inner_join_sql(table, on_left_table, on_left_key, on_right_table, on_right_key, *conditions) - conditions << "#{on_left_table}.#{on_left_key} = #{on_right_table}.#{on_right_key}" - conditions = conditions.flatten.compact - conditions = conditions.map { |sql| "(#{sql})" } * ' AND ' - - "INNER JOIN #{table} ON #{conditions}" - end - - def reflection_conditions(index) - reflection = @reflection.through_reflection_chain[index] - reflection_conditions = @reflection.through_conditions[index] - - conditions = [] - - if reflection.options[:as].nil? && # reflection.klass is a Module if :as is used - reflection.klass.finder_needs_type_condition? - conditions << reflection.klass.send(:type_condition).to_sql - end - - reflection_conditions.each do |condition| - sanitized_condition = reflection.klass.send(:sanitize_sql, condition) - interpolated_condition = interpolate_sql(sanitized_condition) - - if condition.is_a?(Hash) - interpolated_condition.gsub!( - @reflection.quoted_table_name, - reflection.quoted_table_name - ) - end - - conditions << interpolated_condition - end - - conditions - end - - def polymorphic_conditions(reflection, polymorphic_reflection) - if polymorphic_reflection.options[:as] - "%s.%s = %s" % [ - table_aliases[reflection], "#{polymorphic_reflection.options[:as]}_type", - @owner.class.quote_value(polymorphic_reflection.active_record.base_class.name) - ] - end - end - - def source_type_conditions(reflection) - if reflection.options[:source_type] - "%s.%s = %s" % [ - table_aliases[reflection.through_reflection], - reflection.source_reflection.options[:foreign_type].to_s, - @owner.class.quote_value(reflection.options[:source_type]) - ] - end - end - - # Construct attributes for associate pointing to owner. - def construct_owner_attributes(reflection) - if as = reflection.options[:as] - { "#{as}_id" => @owner.id, - "#{as}_type" => @owner.class.base_class.name.to_s } - else - { reflection.primary_key_name => @owner.id } - end - end - - # Construct attributes for :through pointing to owner and associate. - # This method is used when adding records to the association. Since this only makes sense for - # non-nested through associations, that's the only case we have to worry about here. - def construct_join_attributes(associate) - # TODO: revisit this to allow it for deletion, supposing dependent option is supported - raise ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection.new(@owner, @reflection) if [:has_one, :has_many].include?(@reflection.source_reflection.macro) - - join_attributes = construct_owner_attributes(@reflection.through_reflection).merge(@reflection.source_reflection.primary_key_name => associate.id) - - if @reflection.options[:source_type] - join_attributes.merge!(@reflection.source_reflection.options[:foreign_type] => associate.class.base_class.name.to_s) - end - - if @reflection.through_reflection.options[:conditions].is_a?(Hash) - join_attributes.merge!(@reflection.through_reflection.options[:conditions]) - end - - join_attributes - end - - def ensure_not_nested - if @reflection.nested? - raise HasManyThroughNestedAssociationsAreReadonly.new(@owner, @reflection) - end - end - end - end -end |