mongodb/mongoid

View on GitHub
lib/mongoid/association/accessors.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true
# rubocop:todo all

module Mongoid
  module Association

    # This module contains all the behavior related to accessing associations
    # through the getters and setters, and how to delegate to builders to
    # create new ones.
    module Accessors
      extend ActiveSupport::Concern

      # Builds the related document and creates the association unless the
      # document is nil, then sets the association on this document.
      #
      # @example Build the association.
      #   person.__build__(:addresses, { :_id => 1 }, association)
      #
      # @param [ String | Symbol ] name The name of the association.
      # @param [ Hash | BSON::ObjectId ] object The id or attributes to use.
      # @param [ Mongoid::Association::Relatable ] association The association metadata.
      # @param [ Hash ] selected_fields Fields which were retrieved via #only.
      #   If selected_fields is specified, fields not listed in it will not be
      #   accessible in the built document.
      #
      # @return [ Proxy ] The association.
      def __build__(name, object, association, selected_fields = nil)
        relation = create_relation(object, association, selected_fields)
        set_relation(name, relation)
      end

      # Create an association from an object and association metadata.
      #
      # @example Create the association.
      #   person.create_relation(document, association)
      #
      # @param [ Document | Array<Document> ] object The association target.
      # @param [ Mongoid::Association::Relatable ] association The association metadata.
      # @param [ Hash ] selected_fields Fields which were retrieved via #only.
      #   If selected_fields is specified, fields not listed in it will not be
      #   accessible in the created association document.
      #
      # @return [ Proxy ] The association.
      def create_relation(object, association, selected_fields = nil)
        type = @attributes[association.inverse_type]
        target = if t = association.build(self, object, type, selected_fields)
          association.create_relation(self, t)
        else
          nil
        end

        # Only need to do this on embedded associations. The pending callbacks
        # are only added when materializing the documents, which only happens
        # on embedded associations. There is no call to the database in the
        # construction of a referenced association.
        if association.embedded?
          Array(target).each do |doc|
            doc.try(:run_pending_callbacks)
          end
        end

        target
      end

      # Resets the criteria inside the association proxy. Used by many-to-many
      # associations to keep the underlying ids array in sync.
      #
      # @example Reset the association criteria.
      #   person.reset_relation_criteria(:preferences)
      #
      # @param [ Symbol ] name The name of the association.
      def reset_relation_criteria(name)
        if instance_variable_defined?("@_#{name}")
          send(name).reset_unloaded
        end
      end

      # Set the supplied association to an instance variable on the class with the
      # provided name. Used as a helper just for code cleanliness.
      #
      # @example Set the proxy on the document.
      #   person.set(:addresses, addresses)
      #
      # @param [ String | Symbol ] name The name of the association.
      # @param [ Proxy ] relation The association to set.
      #
      # @return [ Proxy ] The association.
      def set_relation(name, relation)
        instance_variable_set("@_#{name}", relation)
      end

      private

      # Get the association. Extracted out from the getter method to avoid
      # infinite recursion when overriding the getter.
      #
      # @api private
      #
      # @example Get the association.
      #   document.get_relation(:name, association)
      #
      # @param [ Symbol ] name The name of the association.
      # @param [ Mongoid::Association::Relatable ] association The association metadata.
      # @param [ Object ] object The object used to build the association.
      # @param [ true | false ] reload If the association is to be reloaded.
      #
      # @return [ Proxy ] The association.
      def get_relation(name, association, object, reload = false)
        field_name = database_field_name(name)

        # As per the comments under MONGOID-5034, I've decided to only raise on
        # embedded associations for a missing attribute. Rails does not raise
        # for a missing attribute on referenced associations.
        # We also don't want to raise if we're retrieving an association within
        # the codebase. This is often done when retrieving the inverse association
        # during binding or when cascading callbacks. Whenever we retrieve
        # associations within the codebase, we use without_autobuild.
        if !without_autobuild? && association.embedded? && attribute_missing?(field_name)
          raise Mongoid::Errors::AttributeNotLoaded.new(self.class, field_name)
        end

        if !reload && (value = ivar(name)) != false
          value
        else
          _building do
            _loading do
              if object && needs_no_database_query?(object, association)
                __build__(name, object, association)
              else
                selected_fields = _mongoid_filter_selected_fields(association.key)
                __build__(name, attributes[association.key], association, selected_fields)
              end
            end
          end
        end
      end

      # Returns a subset of __selected_fields attribute applicable to the
      # (embedded) association with the given key, or nil if no projection
      # is to be performed.
      #
      # Also returns nil if exclusionary projection was requested but it does
      # not exclude the field of the association.
      #
      # For example, if __selected_fields is {'a' => 1, 'b.c' => 2, 'b.c.f' => 3},
      # and assoc_key is 'b', return value would be {'c' => 2, 'c.f' => 3}.
      #
      # @param [ String ] assoc_key
      #
      # @return [ Hash | nil ]
      #
      # @api private
      def _mongoid_filter_selected_fields(assoc_key)
        return nil unless __selected_fields

        # If the list of fields was specified using #without instead of #only
        # and the provided list does not include the association, any of its
        # fields should be allowed.
        if __selected_fields.values.all? { |v| v == 0 } &&
          __selected_fields.keys.none? { |k| k.split('.', 2).first == assoc_key }
        then
          return nil
        end

        projecting_assoc = false

        filtered = {}
        __selected_fields.each do |k, v|
          bits = k.split('.')

          # If we are asked to project an association, we need all of that
          # association's fields. However, we may be asked to project
          # an association *and* its fields in the same query. In this case
          # behavior differs according to server version:
          #
          # 4.2 and lower take the most recent projection specification, meaning
          # projecting foo followed by foo.bar effectively projects foo.bar and
          # projecting foo.bar followed by foo effectively projects foo.
          # To match this behavior we need to track when we are being asked
          # to project the association and when we are asked to project a field,
          # and if we are asked to project the association last we need to
          # remove any field projections.
          #
          # 4.4 (and presumably higher) do not allow projection to be on an
          # association and its field, so it doesn't matter what we do. Hence
          # we just need to handle the 4.2 and lower case correctly.
          if bits.first == assoc_key
            # Projecting the entire association OR some of its fields
            if bits.length > 1
              # Projecting a field
              bits.shift
              filtered[bits.join('.')] = v
              projecting_assoc = false
            else
              # Projecting the entire association
              projecting_assoc = true
            end
          end
        end

        if projecting_assoc
          # The last projection was of the entire association; we may have
          # also been projecting fields, but discard the field projections
          # and return nil indicating we want the entire association.
          return nil
        end

        # Positional projection is specified as "foo.$". In this case the
        # document that the $ is referring to should be retrieved with all
        # fields. See https://www.mongodb.com/docs/manual/reference/operator/projection/positional/
        # and https://jira.mongodb.org/browse/MONGOID-4769.
        if filtered.keys == %w($)
          filtered = nil
        end

        filtered
      end

      def needs_no_database_query?(object, association)
        object.is_a?(Document) && !object.embedded? &&
            object._id == attributes[association.key]
      end

      # Is the current code executing without autobuild functionality?
      #
      # @example Is autobuild disabled?
      #   document.without_autobuild?
      #
      # @return [ true | false ] If autobuild is disabled.
      def without_autobuild?
        Threaded.executing?(:without_autobuild)
      end

      # Yield to the block with autobuild functionality turned off.
      #
      # @example Execute without autobuild.
      #   document.without_autobuild do
      #     document.name
      #   end
      #
      # @return [ Object ] The result of the yield.
      def without_autobuild
        Threaded.begin_execution("without_autobuild")
        yield
      ensure
        Threaded.exit_execution("without_autobuild")
      end

      # Parse out the attributes and the options from the args passed to a
      # build_ or create_ methods.
      #
      # @example Parse the args.
      #   doc.parse_args(:name => "Joe")
      #
      # @param [ Hash... ] *args The arguments.
      #
      # @return [ Array<Hash> ] The attributes and options.
      def parse_args(*args)
        [args.first || {}, args.size > 1 ? args[1] : {}]
      end

      # Adds the existence check for associations.
      #
      # @example Add the existence check.
      #   Person.define_existence_check!(association)
      #
      # @example Check if an association exists.
      #   person = Person.new
      #   person.has_game?
      #   person.game?
      #
      # @param [ Mongoid::Association::Relatable ] association The association.
      #
      # @return [ Class ] The model being set up.
      def self.define_existence_check!(association)
        name = association.name
        association.inverse_class.tap do |klass|
          klass.module_eval <<-END, __FILE__, __LINE__ + 1
              def #{name}?
                without_autobuild { !__send__(:#{name}).blank? }
              end
              alias :has_#{name}? :#{name}?
          END
        end
      end

      # Defines the getter for the association. Nothing too special here: just
      # return the instance variable for the association if it exists or build
      # the thing.
      #
      # @example Set up the getter for the association.
      #   Person.define_getter!(association)
      #
      # @param [ Mongoid::Association::Relatable ] association The association metadata for the association.
      #
      # @return [ Class ] The class being set up.
      def self.define_getter!(association)
        name = association.name
        association.inverse_class.tap do |klass|
          klass.re_define_method(name) do |reload = false|
            value = get_relation(name, association, nil, reload)
            if value.nil? && association.autobuilding? && !without_autobuild?
              value = send("build_#{name}")
            end
            value
          end
        end
      end

      # Defines the getter for the ids of documents in the association. Should
      # be specify only for referenced many associations.
      #
      # @example Set up the ids getter for the association.
      #   Person.define_ids_getter!(association)
      #
      # @param [ Mongoid::Association::Relatable ] association The association metadata for the association.
      #
      # @return [ Class ] The class being set up.
      def self.define_ids_getter!(association)
        ids_method = "#{association.name.to_s.singularize}_ids"
        association.inverse_class.tap do |klass|
          klass.re_define_method(ids_method) do
            send(association.name).pluck(:_id)
          end
        end
      end

      # Defines the setter for the association. This does a few things based on
      # some conditions. If there is an existing association, a target
      # substitution will take place, otherwise a new association will be
      # created with the supplied target.
      #
      # @example Set up the setter for the association.
      #   Person.define_setter!(association)
      #
      # @param [ Mongoid::Association::Relatable ] association The association metadata for the association.
      #
      # @return [ Class ] The class being set up.
      def self.define_setter!(association)
        name = association.name
        association.inverse_class.tap do |klass|
          klass.re_define_method("#{name}=") do |object|
            without_autobuild do
              if value = get_relation(name, association, object)
                if !value.respond_to?(:substitute)
                  value = __build__(name, value, association) 
                end

                set_relation(name, value.substitute(object.substitutable))
              else
                __build__(name, object.substitutable, association)
              end
            end
          end
        end
      end

      # Defines the setter method that allows you to set documents
      # in this association by their ids. The defined setter, finds
      # documents with given ids and invokes regular association setter
      # with found documents. Ids setters should be defined only for
      # referenced many associations.
      #
      # @example Set up the id_setter for the association.
      #   Person.define_ids_setter!(association)
      #
      #  @param [ Mongoid::Association::Relatable ] association The association for the association.
      #
      #  @return [ Class ] The class being set up.
      def self.define_ids_setter!(association)
        ids_method = "#{association.name.to_s.singularize}_ids="
        association.inverse_class.aliased_associations[ids_method.chop] = association.name.to_s
        association.inverse_class.tap do |klass|
          klass.re_define_method(ids_method) do |ids|
            send(association.setter, association.relation_class.find(ids.reject(&:blank?)))
          end
        end
      end

      # Defines a builder method for an embeds_one association. This is
      # defined as #build_name.
      #
      # @example
      #   Person.define_builder!(association)
      #
      # @param [ Mongoid::Association::Relatable ] association The association for the association.
      #
      # @return [ Class ] The class being set up.
      def self.define_builder!(association)
        name = association.name
        association.inverse_class.tap do |klass|
          klass.re_define_method("build_#{name}") do |*args|
            attributes, _options = parse_args(*args)
            document = Factory.build(association.relation_class, attributes)
            _building do
              child = send("#{name}=", document)
              child.run_callbacks(:build)
              child
            end
          end
        end
      end

      # Defines a creator method for an embeds_one association. This is
      # defined as #create_name. After the object is built it will
      # immediately save.
      #
      # @example
      #   Person.define_creator!(association)
      #
      # @param [ Mongoid::Association::Relatable ] association The association for the association.
      #
      # @return [ Class ] The class being set up.
      def self.define_creator!(association)
        name = association.name
        association.inverse_class.tap do |klass|
          klass.re_define_method("create_#{name}") do |*args|
            attributes, _options = parse_args(*args)
            document = Factory.build(association.klass, attributes)
            doc = _assigning do
              send("#{name}=", document)
            end
            doc.save
            save if new_record? && association.stores_foreign_key?
            doc
          end
        end
      end
    end
  end
end