class ActiveRecord::Associations::CollectionAssociation
:nodoc:load_target
and the loaded
flag are your friends.
If you need to work on all current children, new and existing records,
collection because new records may have been added to the target, etc.
If you look directly to the database you cannot assume that’s the entire
non-empty and still lack children waiting to be read from the database.
ones created with build
are added to the target. So, the target may be
does not fetch records from the database until it needs them, but new
You need to be careful with assumptions regarding the target: The proxy
the +:through association+ option.
defined by has_and_belongs_to_many
, has_many
or has_many
with
The CollectionAssociation class provides common methods to the collections
HasManyThroughAssociation + ThroughAssociation => has_many :through
HasManyAssociation => has_many
CollectionAssociation:
collections. See the class hierarchy in Association.
ease the implementation of association proxies that represent
CollectionAssociation is an abstract class that provides common stuff to
= Active Record Association Collection
def _create_record(attributes, raise = false, &block)
def _create_record(attributes, raise = false, &block) unless owner.persisted? raise ActiveRecord::RecordNotSaved.new("You cannot call create unless the parent is saved", owner) end if attributes.is_a?(Array) attributes.collect { |attr| _create_record(attr, raise, &block) } else record = build_record(attributes, &block) transaction do result = nil add_to_target(record) do result = insert_record(record, true, raise) { @_was_loaded = loaded? } end raise ActiveRecord::Rollback unless result end record end end
def add_to_target(record, skip_callbacks: false, replace: false, &block)
def add_to_target(record, skip_callbacks: false, replace: false, &block) replace_on_target(record, skip_callbacks, replace: replace || association_scope.distinct_value, &block) end
def build(attributes = nil, &block)
def build(attributes = nil, &block) if attributes.is_a?(Array) attributes.collect { |attr| build(attr, &block) } else add_to_target(build_record(attributes, &block), replace: true) end end
def callback(method, record)
def callback(method, record) callbacks_for(method).each do |callback| callback.call(method, owner, record) end end
def callbacks_for(callback_name)
def callbacks_for(callback_name) full_callback_name = "#{callback_name}_for_#{reflection.name}" if owner.class.respond_to?(full_callback_name) owner.class.send(full_callback_name) else [] end end
def concat(*records)
Add +records+ to this association. Since +<<+ flattens its argument list
def concat(*records) records = records.flatten if owner.new_record? load_target concat_records(records) else transaction { concat_records(records) } end end
def concat_records(records, raise = false)
def concat_records(records, raise = false) result = true records.each do |record| raise_on_type_mismatch!(record) add_to_target(record) do unless owner.new_record? result &&= insert_record(record, true, raise) { @_was_loaded = loaded? } end end end raise ActiveRecord::Rollback unless result records end
def delete(*records)
are actually removed from the database, that depends precisely on
provided by descendants. Note this method does not imply the records
This method is abstract in the sense that +delete_records+ has to be
+after_remove+ callbacks.
Removes +records+ from this association calling +before_remove+ and
def delete(*records) delete_or_destroy(records, options[:dependent]) end
def delete_all(dependent = nil)
@author.books.delete_all(:delete_all)
@author.books.delete_all(:nullify)
Example:
You can force a particular deletion strategy by passing a parameter.
deletion strategy for the association is applied.
if the +:dependent+ value is +:destroy+ then in that case the +:delete_all+
on the associated records. It honors the +:dependent+ option. However
Removes all records from the association without calling callbacks
def delete_all(dependent = nil) if dependent && ![:nullify, :delete_all].include?(dependent) raise ArgumentError, "Valid values are :nullify or :delete_all" end dependent = if dependent dependent elsif options[:dependent] == :destroy :delete_all else options[:dependent] end delete_or_nullify_all_records(dependent).tap do reset loaded! end end
def delete_or_destroy(records, method)
def delete_or_destroy(records, method) return if records.empty? records = find(records) if records.any? { |record| record.kind_of?(Integer) || record.kind_of?(String) } records = records.flatten records.each { |record| raise_on_type_mismatch!(record) } existing_records = records.reject(&:new_record?) if existing_records.empty? remove_records(existing_records, records, method) else transaction { remove_records(existing_records, records, method) } end end
def delete_records(records, method)
using one of the methods +:destroy+, +:delete_all+
Delete the given records from the association,
def delete_records(records, method) raise NotImplementedError end
def destroy(*records)
Note that this method removes records from the database ignoring the
+before_remove+, +after_remove+, +before_destroy+ and +after_destroy+ callbacks.
Deletes the +records+ and removes them from this association calling
def destroy(*records) delete_or_destroy(records, :destroy) end
def destroy_all
Destroy all the records from this association.
def destroy_all destroy(load_target).tap do reset loaded! end end
def empty?
loaded and you are going to fetch the records anyway it is better to
!collection.exists?. If the collection has not already been
collection has not been loaded, it is equivalent to
it is equivalent to collection.size.zero?. If the
If the collection has been loaded
Returns true if the collection is empty.
def empty? if loaded? || @association_ids || reflection.has_cached_counter? size.zero? else target.empty? && !scope.exists? end end
def find(*args)
def find(*args) if options[:inverse_of] && loaded? args_flatten = args.flatten model = scope.klass if args_flatten.blank? error_message = "Couldn't find #{model.name} without an ID" raise RecordNotFound.new(error_message, model.name, model.primary_key, args) end result = find_by_scan(*args) result_size = Array(result).size if !result || result_size != args_flatten.size scope.raise_record_not_found_exception!(args_flatten, result_size, args_flatten.size) else result end else scope.find(*args) end end
def find_by_scan(*args)
If the :inverse_of option has been
def find_by_scan(*args) expects_array = args.first.kind_of?(Array) ids = args.flatten.compact.map(&:to_s).uniq if ids.size == 1 id = ids.first record = load_target.detect { |r| id == r.id.to_s } expects_array ? [ record ] : record else load_target.select { |r| ids.include?(r.id.to_s) } end end
def find_from_target?
def find_from_target? loaded? || owner.strict_loading? || reflection.strict_loading? || owner.new_record? || target.any? { |record| record.new_record? || record.changed? } end
def ids_reader
def ids_reader if loaded? target.pluck(reflection.association_primary_key) elsif !target.empty? load_target.pluck(reflection.association_primary_key) else @association_ids ||= scope.pluck(reflection.association_primary_key) end end
def ids_writer(ids)
def ids_writer(ids) primary_key = reflection.association_primary_key pk_type = klass.type_for_attribute(primary_key) ids = Array(ids).compact_blank ids.map! { |i| pk_type.cast(i) } records = klass.where(primary_key => ids).index_by do |r| r.public_send(primary_key) end.values_at(*ids).compact if records.size != ids.size found_ids = records.map { |record| record.public_send(primary_key) } not_found_ids = ids - found_ids klass.all.raise_record_not_found_exception!(ids, records.size, ids.size, primary_key, not_found_ids) else replace(records) end end
def include?(record)
def include?(record) if record.is_a?(reflection.klass) if record.new_record? include_in_memory?(record) else loaded? ? target.include?(record) : scope.exists?(record.id) end else false end end
def include_in_memory?(record)
def include_in_memory?(record) if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection) assoc = owner.association(reflection.through_reflection.name) assoc.reader.any? { |source| target_reflection = source.send(reflection.source_reflection.name) target_reflection.respond_to?(:include?) ? target_reflection.include?(record) : target_reflection == record } || target.include?(record) else target.include?(record) end end
def insert_record(record, validate = true, raise = false, &block)
def insert_record(record, validate = true, raise = false, &block) if raise record.save!(validate: validate, &block) else record.save(validate: validate, &block) end end
def load_target
def load_target if find_target? @target = merge_target_lists(find_target, target) end loaded! target end
def merge_target_lists(persisted, memory)
* Any changes made to attributes on objects in the memory array are to be preserved
* The order of the persisted array is to be preserved
* The final array must not have duplicates
So the task of this method is to merge them according to the following rules:
and in the memory array.
in-memory (memory). The same record may be represented in the persisted array
We have some records loaded from the database (persisted) and some that are
def merge_target_lists(persisted, memory) return persisted if memory.empty? persisted.map! do |record| if mem_record = memory.delete(record) ((record.attribute_names & mem_record.attribute_names) - mem_record.changed_attribute_names_to_save).each do |name| mem_record[name] = record[name] end mem_record else record end end persisted + memory.reject(&:persisted?) end
def null_scope?
def null_scope? owner.new_record? && !foreign_key_present? end
def reader
:nodoc:
+load_target+ and the +loaded+ flag are your friends.
If you need to work on all current children, new and existing records,
collection because new records may have been added to the target, etc.
If you look directly to the database you cannot assume that's the entire
non-empty and still lack children waiting to be read from the database.
ones created with +build+ are added to the target. So, the target may be
does not fetch records from the database until it needs them, but new
You need to be careful with assumptions regarding the target: The proxy
the +:through association+ option.
defined by +has_and_belongs_to_many+, +has_many+ or +has_many+ with
The CollectionAssociation class provides common methods to the collections
HasManyThroughAssociation + ThroughAssociation => has_many :through
HasManyAssociation => has_many
CollectionAssociation:
collections. See the class hierarchy in Association.
ease the implementation of association proxies that represent
CollectionAssociation is an abstract class that provides common stuff to
= Active Record Association Collection
def reader ensure_klass_exists! if stale_target? reload end @proxy ||= CollectionProxy.create(klass, self) @proxy.reset_scope end
def remove_records(existing_records, records, method)
def remove_records(existing_records, records, method) catch(:abort) do records.each { |record| callback(:before_remove, record) } end || return delete_records(existing_records, method) if existing_records.any? @target -= records @association_ids = nil records.each { |record| callback(:after_remove, record) } end
def replace(other_array)
Replace this collection with +other_array+. This will perform a diff
def replace(other_array) other_array.each { |val| raise_on_type_mismatch!(val) } original_target = load_target.dup if owner.new_record? replace_records(other_array, original_target) else replace_common_records_in_memory(other_array, original_target) if other_array != original_target transaction { replace_records(other_array, original_target) } else other_array end end end
def replace_common_records_in_memory(new_target, original_target)
def replace_common_records_in_memory(new_target, original_target) common_records = intersection(new_target, original_target) common_records.each do |record| skip_callbacks = true replace_on_target(record, skip_callbacks, replace: true) end end
def replace_on_target(record, skip_callbacks, replace:, inversing: false)
def replace_on_target(record, skip_callbacks, replace:, inversing: false) if replace && (!record.new_record? || @replaced_or_added_targets.include?(record)) index = @target.index(record) end catch(:abort) do callback(:before_add, record) end || return unless skip_callbacks set_inverse_instance(record) @_was_loaded = true yield(record) if block_given? if !index && @replaced_or_added_targets.include?(record) index = @target.index(record) end @replaced_or_added_targets << record if inversing || index || record.new_record? if index target[index] = record elsif @_was_loaded || !loaded? @association_ids = nil target << record end callback(:after_add, record) unless skip_callbacks record ensure @_was_loaded = nil end
def replace_records(new_target, original_target)
def replace_records(new_target, original_target) delete(difference(target, new_target)) unless concat(difference(new_target, target)) @target = original_target raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \ "new records could not be saved." end target end
def reset
def reset super @target = [] @replaced_or_added_targets = Set.new @association_ids = nil end
def scope
def scope scope = super scope.none! if null_scope? scope end
def size
This method is abstract in the sense that it relies on
+length+ will take one less query. Otherwise +size+ is more efficient.
equivalent. If not and you are going to need the records anyway
If the collection has been already loaded +size+ and +length+ are
collection.size if it has.
query if the collection hasn't been loaded, and calling
Returns the size of the collection by executing a SELECT COUNT(*)
def size if !find_target? || loaded? target.size elsif @association_ids @association_ids.size elsif !association_scope.group_values.empty? load_target.size elsif !association_scope.distinct_value && !target.empty? unsaved_records = target.select(&:new_record?) unsaved_records.size + count_records else count_records end end
def target=(record)
def target=(record) return super unless reflection.klass.has_many_inversing case record when nil # It's not possible to remove the record from the inverse association. when Array super else replace_on_target(record, true, replace: true, inversing: true) end end
def transaction(&block)
def transaction(&block) reflection.klass.transaction(&block) end
def writer(records)
def writer(records) replace(records) end