# frozen_string_literal: true module ActiveRecord module Associations # = Active Record Collection Proxy # # Collection proxies in Active Record are middlemen between an # association, and its target result set. # # For example, given # # class Blog < ActiveRecord::Base # has_many :posts # end # # blog = Blog.first # # The collection proxy returned by blog.posts is built from a # :has_many association, and delegates to a collection # of posts as the target. # # This class delegates unknown methods to the association's # relation class via a delegate cache. # # The target result set 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 < Relation def initialize(klass, association, **) # :nodoc: @association = association super klass extensions = association.extensions extend(*extensions) if extensions.any? end def target @association.target end def load_target @association.load_target end # Returns +true+ if the association has been loaded, otherwise +false+. # # person.pets.loaded? # => false # person.pets.records # person.pets.loaded? # => true def loaded? @association.loaded? end alias :loaded :loaded? ## # :method: select # # :call-seq: # select(*fields, &block) # # Works in two ways. # # *First:* Specify a subset of fields to be selected from the result set. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.select(:name) # # => [ # # #, # # #, # # # # # ] # # person.pets.select(:id, :name) # # => [ # # #, # # #, # # # # # ] # # Be careful because this also means you're initializing a model # object with only the fields that you've selected. If you attempt # to access a field except +id+ that is not in the initialized record you'll # receive: # # person.pets.select(:name).first.person_id # # => ActiveModel::MissingAttributeError: missing attribute 'person_id' for Pet # # *Second:* You can pass a block so it can be used just like Array#select. # This builds an array of objects from the database for the scope, # converting them into an array and iterating through them using # Array#select. # # person.pets.select { |pet| /oo/.match?(pet.name) } # # => [ # # #, # # # # # ] # Finds an object in the collection responding to the +id+. Uses the same # rules as ActiveRecord::FinderMethods.find. Returns ActiveRecord::RecordNotFound # error if the object cannot be found. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.find(1) # => # # person.pets.find(4) # => ActiveRecord::RecordNotFound: Couldn't find Pet with 'id'=4 # # person.pets.find(2) { |pet| pet.name.downcase! } # # => # # # person.pets.find(2, 3) # # => [ # # #, # # # # # ] def find(*args) return super if block_given? @association.find(*args) end ## # :method: first # # :call-seq: # first(limit = nil) # # Returns the first record, or the first +n+ records, from the collection. # If the collection is empty, the first form returns +nil+, and the second # form returns an empty array. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.first # => # # # person.pets.first(2) # # => [ # # #, # # # # # ] # # another_person_without.pets # => [] # another_person_without.pets.first # => nil # another_person_without.pets.first(3) # => [] ## # :method: second # # :call-seq: # second() # # Same as #first except returns only the second record. ## # :method: third # # :call-seq: # third() # # Same as #first except returns only the third record. ## # :method: fourth # # :call-seq: # fourth() # # Same as #first except returns only the fourth record. ## # :method: fifth # # :call-seq: # fifth() # # Same as #first except returns only the fifth record. ## # :method: forty_two # # :call-seq: # forty_two() # # Same as #first except returns only the forty second record. # Also known as accessing "the reddit". ## # :method: third_to_last # # :call-seq: # third_to_last() # # Same as #last except returns only the third-to-last record. ## # :method: second_to_last # # :call-seq: # second_to_last() # # Same as #last except returns only the second-to-last record. # Returns the last record, or the last +n+ records, from the collection. # If the collection is empty, the first form returns +nil+, and the second # form returns an empty array. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.last # => # # # person.pets.last(2) # # => [ # # #, # # # # # ] # # another_person_without.pets # => [] # another_person_without.pets.last # => nil # another_person_without.pets.last(3) # => [] def last(limit = nil) load_target if find_from_target? super end # Gives a record (or N records if a parameter is supplied) from the collection # using the same rules as ActiveRecord::FinderMethods.take. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.take # => # # # person.pets.take(2) # # => [ # # #, # # # # # ] # # another_person_without.pets # => [] # another_person_without.pets.take # => nil # another_person_without.pets.take(2) # => [] def take(limit = nil) load_target if find_from_target? super end # Returns a new object of the collection type that has been instantiated # with +attributes+ and linked to this object, but have not yet been saved. # You can pass an array of attributes hashes, this will return an array # with the new objects. # # class Person # has_many :pets # end # # person.pets.build # # => # # # person.pets.build(name: 'Fancy-Fancy') # # => # # # person.pets.build([{name: 'Spook'}, {name: 'Choo-Choo'}, {name: 'Brain'}]) # # => [ # # #, # # #, # # # # # ] # # person.pets.size # => 5 # size of the collection # person.pets.count # => 0 # count from database def build(attributes = {}, &block) @association.build(attributes, &block) end alias_method :new, :build # Returns a new object of the collection type that has been instantiated with # attributes, linked to this object and that has already been saved (if it # passes the validations). # # class Person # has_many :pets # end # # person.pets.create(name: 'Fancy-Fancy') # # => # # # person.pets.create([{name: 'Spook'}, {name: 'Choo-Choo'}]) # # => [ # # #, # # # # # ] # # person.pets.size # => 3 # person.pets.count # => 3 # # person.pets.find(1, 2, 3) # # => [ # # #, # # #, # # # # # ] def create(attributes = {}, &block) @association.create(attributes, &block) end # Like #create, except that if the record is invalid, raises an exception. # # class Person # has_many :pets # end # # class Pet # validates :name, presence: true # end # # person.pets.create!(name: nil) # # => ActiveRecord::RecordInvalid: Validation failed: Name can't be blank def create!(attributes = {}, &block) @association.create!(attributes, &block) end # Replaces this collection with +other_array+. This will perform a diff # and delete/add only records that have changed. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [#] # # other_pets = [Pet.new(name: 'Puff', group: 'celebrities')] # # person.pets.replace(other_pets) # # person.pets # # => [#] # # If the supplied array has an incorrect association type, it raises # an ActiveRecord::AssociationTypeMismatch error: # # person.pets.replace(["doo", "ggie", "gaga"]) # # => ActiveRecord::AssociationTypeMismatch: Pet expected, got String def replace(other_array) @association.replace(other_array) end # Deletes all the records from the collection according to the strategy # specified by the +:dependent+ option. If no +:dependent+ option is given, # then it will follow the default strategy. # # For has_many :through associations, the default deletion strategy is # +:delete_all+. # # For +has_many+ associations, the default deletion strategy is +:nullify+. # This sets the foreign keys to +NULL+. # # class Person < ActiveRecord::Base # has_many :pets # dependent: :nullify option by default # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.delete_all # # => [ # # #, # # #, # # # # # ] # # person.pets.size # => 0 # person.pets # => [] # # Pet.find(1, 2, 3) # # => [ # # #, # # #, # # # # # ] # # Both +has_many+ and has_many :through dependencies default to the # +:delete_all+ strategy if the +:dependent+ option is set to +:destroy+. # Records are not instantiated and callbacks will not be fired. # # class Person < ActiveRecord::Base # has_many :pets, dependent: :destroy # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.delete_all # # Pet.find(1, 2, 3) # # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 2, 3) # # If it is set to :delete_all, all the objects are deleted # *without* calling their +destroy+ method. # # class Person < ActiveRecord::Base # has_many :pets, dependent: :delete_all # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.delete_all # # Pet.find(1, 2, 3) # # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 2, 3) def delete_all(dependent = nil) @association.delete_all(dependent).tap { reset_scope } end # Deletes the records of the collection directly from the database # ignoring the +:dependent+ option. Records are instantiated and it # invokes +before_remove+, +after_remove+, +before_destroy+, and # +after_destroy+ callbacks. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.destroy_all # # person.pets.size # => 0 # person.pets # => [] # # Pet.find(1) # => Couldn't find Pet with id=1 def destroy_all @association.destroy_all.tap { reset_scope } end # Deletes the +records+ supplied from the collection according to the strategy # specified by the +:dependent+ option. If no +:dependent+ option is given, # then it will follow the default strategy. Returns an array with the # deleted records. # # For has_many :through associations, the default deletion strategy is # +:delete_all+. # # For +has_many+ associations, the default deletion strategy is +:nullify+. # This sets the foreign keys to +NULL+. # # class Person < ActiveRecord::Base # has_many :pets # dependent: :nullify option by default # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.delete(Pet.find(1)) # # => [#] # # person.pets.size # => 2 # person.pets # # => [ # # #, # # # # # ] # # Pet.find(1) # # => # # # If it is set to :destroy all the +records+ are removed by calling # their +destroy+ method. See +destroy+ for more information. # # class Person < ActiveRecord::Base # has_many :pets, dependent: :destroy # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.delete(Pet.find(1), Pet.find(3)) # # => [ # # #, # # # # # ] # # person.pets.size # => 1 # person.pets # # => [#] # # Pet.find(1, 3) # # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 3) # # If it is set to :delete_all, all the +records+ are deleted # *without* calling their +destroy+ method. # # class Person < ActiveRecord::Base # has_many :pets, dependent: :delete_all # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.delete(Pet.find(1)) # # => [#] # # person.pets.size # => 2 # person.pets # # => [ # # #, # # # # # ] # # Pet.find(1) # # => ActiveRecord::RecordNotFound: Couldn't find Pet with 'id'=1 # # You can pass +Integer+ or +String+ values, it finds the records # responding to the +id+ and executes delete on them. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.delete("1") # # => [#] # # person.pets.delete(2, 3) # # => [ # # #, # # # # # ] def delete(*records) @association.delete(*records).tap { reset_scope } end # Destroys the +records+ supplied and removes them from the collection. # This method will _always_ remove record from the database ignoring # the +:dependent+ option. Returns an array with the removed records. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.destroy(Pet.find(1)) # # => [#] # # person.pets.size # => 2 # person.pets # # => [ # # #, # # # # # ] # # person.pets.destroy(Pet.find(2), Pet.find(3)) # # => [ # # #, # # # # # ] # # person.pets.size # => 0 # person.pets # => [] # # Pet.find(1, 2, 3) # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 2, 3) # # You can pass +Integer+ or +String+ values, it finds the records # responding to the +id+ and then deletes them from the database. # # person.pets.size # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.destroy("4") # # => # # # person.pets.size # => 2 # person.pets # # => [ # # #, # # # # # ] # # person.pets.destroy(5, 6) # # => [ # # #, # # # # # ] # # person.pets.size # => 0 # person.pets # => [] # # Pet.find(4, 5, 6) # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (4, 5, 6) def destroy(*records) @association.destroy(*records).tap { reset_scope } end ## # :method: distinct # # :call-seq: # distinct(value = true) # # Specifies whether the records should be unique or not. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.select(:name) # # => [ # # #, # # # # # ] # # person.pets.select(:name).distinct # # => [#] # # person.pets.select(:name).distinct.distinct(false) # # => [ # # #, # # # # # ] #-- def calculate(operation, column_name) null_scope? ? scope.calculate(operation, column_name) : super end def pluck(*column_names) null_scope? ? scope.pluck(*column_names) : super end ## # :method: count # # :call-seq: # count(column_name = nil, &block) # # Count all records. # # class Person < ActiveRecord::Base # has_many :pets # end # # # This will perform the count using SQL. # person.pets.count # => 3 # person.pets # # => [ # # #, # # #, # # # # # ] # # Passing a block will select all of a person's pets in SQL and then # perform the count using Ruby. # # person.pets.count { |pet| pet.name.include?('-') } # => 2 # Returns the size of the collection. If the collection hasn't been loaded, # it executes a SELECT COUNT(*) query. Else it calls collection.size. # # 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. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.size # => 3 # # executes something like SELECT COUNT(*) FROM "pets" WHERE "pets"."person_id" = 1 # # person.pets # This will execute a SELECT * FROM query # # => [ # # #, # # #, # # # # # ] # # person.pets.size # => 3 # # Because the collection is already loaded, this will behave like # # collection.size and no SQL count query is executed. def size @association.size end ## # :method: length # # :call-seq: # length() # # 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. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.length # => 3 # # executes something like SELECT "pets".* FROM "pets" WHERE "pets"."person_id" = 1 # # # Because the collection is loaded, you can # # call the collection with no additional queries: # person.pets # # => [ # # #, # # #, # # # # # ] # Returns +true+ if the collection is empty. If the collection has been # loaded it is equivalent # to collection.size.zero?. If the collection has not been loaded, # it is equivalent to !collection.exists?. If the collection has # not already been loaded and you are going to fetch the records anyway it # is better to check collection.load.empty?. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.count # => 1 # person.pets.empty? # => false # # person.pets.delete_all # # person.pets.count # => 0 # person.pets.empty? # => true def empty? @association.empty? end ## # :method: any? # # :call-seq: # any?() # # Returns +true+ if the collection is not empty. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.count # => 0 # person.pets.any? # => false # # person.pets << Pet.new(name: 'Snoop') # person.pets.count # => 1 # person.pets.any? # => true # # Calling it without a block when the collection is not yet # loaded is equivalent to collection.exists?. # If you're going to load the collection anyway, it is better # to call collection.load.any? to avoid an extra query. # # You can also pass a +block+ to define criteria. The behavior # is the same, it returns true if the collection based on the # criteria is not empty. # # person.pets # # => [#] # # person.pets.any? do |pet| # pet.group == 'cats' # end # # => false # # person.pets.any? do |pet| # pet.group == 'dogs' # end # # => true ## # :method: many? # # :call-seq: # many?() # # Returns true if the collection has more than one record. # Equivalent to collection.size > 1. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.count # => 1 # person.pets.many? # => false # # person.pets << Pet.new(name: 'Snoopy') # person.pets.count # => 2 # person.pets.many? # => true # # You can also pass a +block+ to define criteria. The # behavior is the same, it returns true if the collection # based on the criteria has more than one record. # # person.pets # # => [ # # #, # # #, # # # # # ] # # person.pets.many? do |pet| # pet.group == 'dogs' # end # # => false # # person.pets.many? do |pet| # pet.group == 'cats' # end # # => true # Returns +true+ if the given +record+ is present in the collection. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # => [#] # # person.pets.include?(Pet.find(20)) # => true # person.pets.include?(Pet.find(21)) # => false def include?(record) !!@association.include?(record) end # Returns the association object for the collection. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.proxy_association # # => # # # Returns the same object as person.association(:pets), # allowing you to make calls like person.pets.proxy_association.owner. # # See Associations::ClassMethods@Association+extensions for more. def proxy_association @association end # Returns a Relation object for the records in this association def scope @scope ||= @association.scope end # Equivalent to Array#==. Returns +true+ if the two arrays # contain the same number of elements and if each element is equal # to the corresponding element in the +other+ array, otherwise returns # +false+. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [ # # #, # # # # # ] # # other = person.pets.to_ary # # person.pets == other # # => true # # # Note that unpersisted records can still be seen as equal: # # other = [Pet.new(id: 1), Pet.new(id: 2)] # # person.pets == other # # => true def ==(other) load_target == other end ## # :method: to_ary # # :call-seq: # to_ary() # # Returns a new array of objects from the collection. If the collection # hasn't been loaded, it fetches the records from the database. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # # => [ # # #, # # #, # # # # # ] # # other_pets = person.pets.to_ary # # => [ # # #, # # #, # # # # # ] # # other_pets.replace([Pet.new(name: 'BooGoo')]) # # other_pets # # => [#] # # person.pets # # This is not affected by replace # # => [ # # #, # # #, # # # # # ] def records # :nodoc: load_target end # Adds one or more +records+ to the collection by setting their foreign keys # to the association's primary key. Since << flattens its argument list and # inserts each record, +push+ and +concat+ behave identically. Returns +self+ # so several appends may be chained together. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets.size # => 0 # person.pets << Pet.new(name: 'Fancy-Fancy') # person.pets << [Pet.new(name: 'Spook'), Pet.new(name: 'Choo-Choo')] # person.pets.size # => 3 # # person.id # => 1 # person.pets # # => [ # # #, # # #, # # # # # ] def <<(*records) proxy_association.concat(records) && self end alias_method :push, :<< alias_method :append, :<< alias_method :concat, :<< def prepend(*args) # :nodoc: raise NoMethodError, "prepend on association is not defined. Please use <<, push or append" end # Equivalent to +delete_all+. The difference is that returns +self+, instead # of an array with the deleted objects, so methods can be chained. See # +delete_all+ for more information. # Note that because +delete_all+ removes records by directly # running an SQL query into the database, the +updated_at+ column of # the object is not changed. def clear delete_all self end # Reloads the collection from the database. Returns +self+. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # fetches pets from the database # # => [#] # # person.pets # uses the pets cache # # => [#] # # person.pets.reload # fetches pets from the database # # => [#] def reload proxy_association.reload(true) reset_scope end # Unloads the association. Returns +self+. # # class Person < ActiveRecord::Base # has_many :pets # end # # person.pets # fetches pets from the database # # => [#] # # person.pets # uses the pets cache # # => [#] # # person.pets.reset # clears the pets cache # # person.pets # fetches pets from the database # # => [#] def reset proxy_association.reset proxy_association.reset_scope reset_scope end def reset_scope # :nodoc: @offsets = @take = nil @scope = nil self end def inspect # :nodoc: load_target if find_from_target? super end def pretty_print(pp) # :nodoc: load_target if find_from_target? super end delegate_methods = [ QueryMethods, SpawnMethods, ].flat_map { |klass| klass.public_instance_methods(false) } - self.public_instance_methods(false) - [:select] + [ :scoping, :values, :insert, :insert_all, :insert!, :insert_all!, :upsert, :upsert_all, :load_async ] delegate(*delegate_methods, to: :scope) private def find_nth_with_limit(index, limit) load_target if find_from_target? super end def find_nth_from_last(index) load_target if find_from_target? super end def null_scope? @association.null_scope? end def find_from_target? @association.find_from_target? end def exec_queries load_target end end end end