mongodb/mongoid

View on GitHub
lib/mongoid/fields.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true
# rubocop:todo all

require "mongoid/fields/standard"
require "mongoid/fields/encrypted"
require "mongoid/fields/foreign_key"
require "mongoid/fields/localized"
require "mongoid/fields/validators"

module Mongoid

  # This module defines behavior for fields.
  module Fields
    extend ActiveSupport::Concern

    StringifiedSymbol = Mongoid::StringifiedSymbol
    Boolean = Mongoid::Boolean

    # For fields defined with symbols use the correct class.
    TYPE_MAPPINGS = {
      array: Array,
      big_decimal: BigDecimal,
      binary: BSON::Binary,
      boolean: Mongoid::Boolean,
      date: Date,
      date_time: DateTime,
      float: Float,
      hash: Hash,
      integer: Integer,
      object_id: BSON::ObjectId,
      range: Range,
      regexp: Regexp,
      set: Set,
      string: String,
      stringified_symbol: StringifiedSymbol,
      symbol: Symbol,
      time: Time
    }.with_indifferent_access

    # Constant for all names of the _id field in a document.
    #
    # This does not include aliases of _id field.
    #
    # @api private
    IDS = [ :_id, '_id', ].freeze

    # BSON classes that are not supported as field types
    #
    # @api private
    INVALID_BSON_CLASSES = [ BSON::Decimal128, BSON::Int32, BSON::Int64 ].freeze

    module ClassMethods
      # Returns the list of id fields for this model class, as both strings
      # and symbols.
      #
      # @return [ Array<Symbol | String> ] List of id fields.
      #
      # @api private
      def id_fields
        IDS.dup.tap do |id_fields|
          aliased_fields.each do |k, v|
            if v == '_id'
              id_fields << k.to_sym
              id_fields << k
            end
          end
        end
      end

      # Extracts the id field from the specified attributes hash based on
      # aliases defined in this class.
      #
      # @param [ Hash ] attributes The attributes to inspect.
      #
      # @return [ Object ] The id value.
      #
      # @api private
      def extract_id_field(attributes)
        id_fields.each do |k|
          if v = attributes[k]
            return v
          end
        end
        nil
      end

      # Removes the _translations from the given field name. This is done only
      # when there doesn't already exist a field name or relation with the
      # same name (i.e. with the _translations suffix). This check for an
      # existing field is done recursively
      #
      # @param [ String | Symbol ] name The name of the field to cleanse.
      #
      # @return [ Field ] The field name without _translations
      def cleanse_localized_field_names(name)
        name = database_field_name(name.to_s)

        klass = self
        [].tap do |res|
          ar = name.split('.')
          ar.each_with_index do |fn, i|
            key = fn
            unless klass.fields.key?(fn) || klass.relations.key?(fn)
              if tr = fn.match(/(.*)_translations\z/)&.captures&.first
                key = tr
              else
                key = fn
              end

            end
            res.push(key)

            if klass.fields.key?(fn)
              res.push(ar.drop(i+1).join('.')) unless i == ar.length - 1
              break
            elsif klass.relations.key?(fn)
              klass = klass.relations[key].klass
            end
          end
        end.join('.')
      end
    end

    included do
      class_attribute :aliased_fields
      class_attribute :localized_fields
      class_attribute :fields
      class_attribute :pre_processed_defaults
      class_attribute :post_processed_defaults

      self.aliased_fields = { "id" => "_id" }
      self.fields = {}
      self.localized_fields = {}
      self.pre_processed_defaults = []
      self.post_processed_defaults = []

      field(
        :_id,
        default: ->{ BSON::ObjectId.new },
        pre_processed: true,
        type: BSON::ObjectId
      )

      alias_attribute(:id, :_id)
    end

    # Apply all default values to the document which are not procs.
    #
    # @example Apply all the non-proc defaults.
    #   model.apply_pre_processed_defaults
    #
    # @return [ Array<String> ] The names of the non-proc defaults.
    def apply_pre_processed_defaults
      pre_processed_defaults.each do |name|
        apply_default(name)
      end
    end

    # Apply all default values to the document which are procs.
    #
    # @example Apply all the proc defaults.
    #   model.apply_post_processed_defaults
    #
    # @return [ Array<String> ] The names of the proc defaults.
    def apply_post_processed_defaults
      pending_callbacks.delete(:apply_post_processed_defaults)
      post_processed_defaults.each do |name|
        apply_default(name)
      end
    end

    # Applies a single default value for the given name.
    #
    # @example Apply a single default.
    #   model.apply_default("name")
    #
    # @param [ String ] name The name of the field.
    def apply_default(name)
      unless attributes.key?(name)
        if field = fields[name]
          default = field.eval_default(self)
          unless default.nil? || field.lazy?
            attribute_will_change!(name)
            attributes[name] = default
          end
        end
      end
    end

    # Apply all the defaults at once.
    #
    # @example Apply all the defaults.
    #   model.apply_defaults
    def apply_defaults
      pending_callbacks.delete(:apply_defaults)
      apply_pre_processed_defaults
      apply_post_processed_defaults
    end

    # Returns an array of names for the attributes available on this object.
    #
    # Provides the field names in an ORM-agnostic way. Rails v3.1+ uses this
    # method to automatically wrap params in JSON requests.
    #
    # @example Get the field names
    #   document.attribute_names
    #
    # @return [ Array<String> ] The field names
    def attribute_names
      self.class.attribute_names
    end

    # Get the name of the provided field as it is stored in the database.
    # Used in determining if the field is aliased or not.
    #
    # @example Get the database field name.
    #   model.database_field_name(:authorization)
    #
    # @param [ String | Symbol ] name The name to get.
    #
    # @return [ String ] The name of the field as it's stored in the db.
    def database_field_name(name)
      self.class.database_field_name(name)
    end

    # Is the provided field a lazy evaluation?
    #
    # @example If the field is lazy settable.
    #   doc.lazy_settable?(field, nil)
    #
    # @param [ Field ] field The field.
    # @param [ Object ] value The current value.
    #
    # @return [ true | false ] If we set the field lazily.
    def lazy_settable?(field, value)
      !frozen? && value.nil? && field.lazy?
    end

    # Is the document using object ids?
    #
    # @note Refactored from using delegate for class load performance.
    #
    # @example Is the document using object ids?
    #   model.using_object_ids?
    #
    # @return [ true | false ] Using object ids.
    def using_object_ids?
      self.class.using_object_ids?
    end

    # Does this field start with a dollar sign ($) or contain a dot/period (.)?
    #
    # @api private
    #
    # @param [ String ] name The field name.
    #
    # @return [ true | false ] If this field is dotted or dollared.
    def dot_dollar_field?(name)
      n = aliased_fields[name] || name
      fields.key?(n) && (n.include?('.') || n.start_with?('$'))
    end

    # Validate whether or not the field starts with a dollar sign ($) or
    # contains a dot/period (.).
    #
    # @api private
    #
    # @raise [ InvalidDotDollarAssignment ] If contains dots or starts with a dollar.
    #
    # @param [ String ] name The field name.
    def validate_writable_field_name!(name)
      if dot_dollar_field?(name)
        raise Errors::InvalidDotDollarAssignment.new(self.class, name)
      end
    end

    class << self

      # Stores the provided block to be run when the option name specified is
      # defined on a field.
      #
      # No assumptions are made about what functionality the handler might
      # perform, so it will always be called if the `option_name` key is
      # provided in the field definition -- even if it is false or nil.
      #
      # @example
      #   Mongoid::Fields.option :required do |model, field, value|
      #     model.validates_presence_of field if value
      #   end
      #
      # @param [ Symbol ] option_name the option name to match against
      # @param &block the handler to execute when the option is provided.
      def option(option_name, &block)
        options[option_name] = block
      end

      # Return a map of custom option names to their handlers.
      #
      # @example
      #   Mongoid::Fields.options
      #   # => { :required => #<Proc:0x00000100976b38> }
      #
      # @return [ Hash ] the option map
      def options
        @options ||= {}
      end

      # Traverse down the association tree and search for the field for the
      # given key. To do this, split the key by '.' and for each part (meth) of
      # the key:
      #
      # - If the meth is a field, yield the meth, field, and is_field as true.
      # - If the meth is an association, update the klass to the association's
      #   klass, and yield the meth, klass, and is_field as false.
      #
      # The next iteration will use klass's fields and associations to continue
      # traversing the tree.
      #
      # @param [ String ] key The key used to search the association tree.
      # @param [ Hash ] fields The fields to begin the search with.
      # @param [ Hash ] associations The associations to begin the search with.
      # @param [ Hash ] aliased_associations The alaised associations to begin
      #   the search with.
      # @param &block The block.
      # @yieldparam [ Symbol ] The current method.
      # @yieldparam [ Symbol | String ] The field or the relation.
      # @yieldparam [ true | false ] Whether the second yield parameter is a
      #   field or not.
      #
      # @return [ Field ] The field found for the given key at the end of the
      #   search. This will return nil if the last thing found is an association
      #   or no field was found for the given key.
      #
      # @api private
      def traverse_association_tree(key, fields, associations, aliased_associations)
        klass = nil
        field = nil
        key.split('.').each_with_index do |meth, i|
          fs = i == 0 ? fields : klass&.fields
          rs = i == 0 ? associations : klass&.relations
          as = i == 0 ? aliased_associations : klass&.aliased_associations

          # Associations can possibly have two "keys", their name and their alias.
          # The fields name is what is used to store it in the klass's relations
          # and field hashes, and the alias is what's used to store that field
          # in the database. The key inputted to this function is the aliased
          # key. We can convert them back to their names by looking in the
          # aliased_associations hash.
          aliased = meth
          if as && a = as.fetch(meth, nil)
            aliased = a.to_s
          end

          field = nil
          klass = nil
          if fs && f = fs[aliased]
            field = f
            yield(meth, f, true) if block_given?
          elsif rs && rel = rs[aliased]
            klass = rel.klass
            yield(meth, rel, false) if block_given?
          else
            yield(meth, nil, false) if block_given?
          end
        end
        field
      end

      # Get the name of the provided field as it is stored in the database.
      # Used in determining if the field is aliased or not. Recursively
      # finds aliases for embedded documents and fields, delimited with
      # period "." character.
      #
      # Note that this method returns the name of associations as they're
      # stored in the database, whereas the `relations` hash uses their in-code
      # aliases. In order to check for membership in the relations hash, you
      # would first have to look up the string returned from this method in
      # the aliased_associations hash.
      #
      # This method will not expand the alias of a belongs_to association that
      # is not the last item. For example, if we had a School that has_many
      # Students, and the field name passed was (from the Student's perspective):
      #
      #   school._id
      #
      # The alias for a belongs_to association is that association's _id field.
      # Therefore, expanding out this association would yield:
      #
      #   school_id._id
      #
      # This is not the correct field name, because the intention here was not
      # to get a property of the _id field. The intention was to get a property
      # of the referenced document. Therefore, if a part of the name passed is
      # a belongs_to association that is not the last part of the name, we
      # won't expand its alias, and return:
      #
      #   school._id
      #
      # If the belongs_to association is the last part of the name, we will
      # pass back the _id field.
      #
      # @param [ String | Symbol ] name The name to get.
      # @param [ Hash ] relations The associations.
      # @param [ Hash ] aliased_fields The aliased fields.
      # @param [ Hash ] aliased_associations The aliased associations.
      #
      # @return [ String ] The name of the field as stored in the database.
      #
      # @api private
      def database_field_name(name, relations, aliased_fields, aliased_associations)
        return nil unless name.present?
        key = name.to_s
        segment, remaining = key.split('.', 2)

        # Don't get the alias for the field when a belongs_to association
        # is not the last item. Therefore, get the alias when one of the
        # following is true:
        # 1. This is the last item, i.e. there is no remaining.
        # 2. It is not an association.
        # 3. It is not a belongs association
        if !remaining || !relations.key?(segment) || !relations[segment].is_a?(Association::Referenced::BelongsTo)
          segment = aliased_fields[segment]&.dup || segment
        end

        return segment unless remaining

        relation = relations[aliased_associations[segment] || segment]
        if relation
          k = relation.klass
          "#{segment}.#{database_field_name(remaining, k.relations, k.aliased_fields, k.aliased_associations)}"
        else
          "#{segment}.#{remaining}"
        end
      end
    end

    module ClassMethods

      # Returns an array of names for the attributes available on this object.
      #
      # Provides the field names in an ORM-agnostic way. Rails v3.1+ uses this
      # method to automatically wrap params in JSON requests.
      #
      # @example Get the field names
      #   Model.attribute_names
      #
      # @return [ Array<String> ] The field names
      def attribute_names
        fields.keys
      end

      # Get the name of the provided field as it is stored in the database.
      # Used in determining if the field is aliased or not.
      #
      # @param [ String | Symbol ] name The name to get.
      #
      # @return [ String ] The name of the field as it's stored in the db.
      def database_field_name(name)
        Fields.database_field_name(name, relations, aliased_fields, aliased_associations)
      end

      # Defines all the fields that are accessible on the Document
      # For each field that is defined, a getter and setter will be
      # added as an instance method to the Document.
      #
      # @example Define a field.
      #   field :score, type: Integer, default: 0
      #
      # @param [ Symbol ] name The name of the field.
      # @param [ Hash ] options The options to pass to the field.
      #
      # @option options [ Class | Symbol | String ] :type The type of the field.
      # @option options [ String ] :label The label for the field.
      # @option options [ Object | Proc ] :default The field's default.
      #
      # @return [ Field ] The generated field
      def field(name, options = {})
        named = name.to_s
        Validators::Macro.validate(self, name, options)
        added = add_field(named, options)
        descendants.each do |subclass|
          subclass.add_field(named, options)
        end
        added
      end

      # Replace a field with a new type.
      #
      # @example Replace the field.
      #   Model.replace_field("_id", String)
      #
      # @param [ String ] name The name of the field.
      # @param [ Class ] type The new type of field.
      #
      # @return [ Serializable ] The new field.
      def replace_field(name, type)
        remove_defaults(name)
        add_field(name, fields[name].options.merge(type: type))
      end

      # Convenience method for determining if we are using +BSON::ObjectIds+ as
      # our id.
      #
      # @example Does this class use object ids?
      #   person.using_object_ids?
      #
      # @return [ true | false ] If the class uses BSON::ObjectIds for the id.
      def using_object_ids?
        fields["_id"].object_id_field?
      end

      # Traverse down the association tree and search for the field for the
      # given key.
      #
      # @param [ String ] key The key used to search the association tree.
      # @param &block The block.
      # @yieldparam [ Symbol ] The current method.
      # @yieldparam [ Symbol | String ] The field or the relation.
      # @yieldparam [ true | false ] Whether the second yield parameter is a
      #   field or not.
      #
      # @return [ Field ] The field found for the given key at the end of the
      #   search. This will return nil if the last thing found is an association
      #   or no field was found for the given key.
      #
      # @api private
      def traverse_association_tree(key, &block)
        Fields.traverse_association_tree(key, fields, relations, aliased_associations, &block)
      end

      protected

      # Add the defaults to the model. This breaks them up between ones that
      # are procs and ones that are not.
      #
      # @example Add to the defaults.
      #   Model.add_defaults(field)
      #
      # @param [ Field ] field The field to add for.
      #
      # @api private
      def add_defaults(field)
        default, name = field.default_val, field.name.to_s
        remove_defaults(name)
        unless default.nil?
          if field.pre_processed?
            pre_processed_defaults.push(name)
          else
            post_processed_defaults.push(name)
          end
        end
      end

      # Define a field attribute for the +Document+.
      #
      # @example Set the field.
      #   Person.add_field(:name, :default => "Test")
      #
      # @param [ Symbol ] name The name of the field.
      # @param [ Hash ] options The hash of options.
      #
      # @api private
      def add_field(name, options = {})
        aliased = options[:as]
        aliased_fields[aliased.to_s] = name if aliased
        field = field_for(name, options)
        fields[name] = field
        add_defaults(field)
        create_accessors(name, name, options)
        create_accessors(name, aliased, options) if aliased
        process_options(field)
        create_dirty_methods(name, name)
        create_dirty_methods(name, aliased) if aliased
        field
      end

      # Run through all custom options stored in Mongoid::Fields.options and
      # execute the handler if the option is provided.
      #
      # @example
      #   Mongoid::Fields.option :custom do
      #     puts "called"
      #   end
      #
      #   field = Mongoid::Fields.new(:test, :custom => true)
      #   Person.process_options(field)
      #   # => "called"
      #
      # @param [ Field ] field the field to process
      #
      # @api private
      def process_options(field)
        field_options = field.options

        Fields.options.each_pair do |option_name, handler|
          if field_options.key?(option_name)
            handler.call(self, field, field_options[option_name])
          end
        end
      end

      # Create the field accessors.
      #
      # @example Generate the accessors.
      #   Person.create_accessors(:name, "name")
      #   person.name #=> returns the field
      #   person.name = "" #=> sets the field
      #   person.name? #=> Is the field present?
      #   person.name_before_type_cast #=> returns the field before type cast
      #
      # @param [ Symbol ] name The name of the field.
      # @param [ Symbol ] meth The name of the accessor.
      # @param [ Hash ] options The options.
      #
      # @api private
      def create_accessors(name, meth, options = {})
        field = fields[name]

        create_field_getter(name, meth, field)
        create_field_getter_before_type_cast(name, meth)
        create_field_setter(name, meth, field)
        create_field_check(name, meth)

        if options[:localize]
          create_translations_getter(name, meth)
          create_translations_setter(name, meth, field)
          localized_fields[name] = field
        end
      end

      # Create the getter method for the provided field.
      #
      # @example Create the getter.
      #   Model.create_field_getter("name", "name", field)
      #
      # @param [ String ] name The name of the attribute.
      # @param [ String ] meth The name of the method.
      # @param [ Field ] field The field.
      #
      # @api private
      def create_field_getter(name, meth, field)
        generated_methods.module_eval do
          re_define_method(meth) do
            raw = read_raw_attribute(name)
            if lazy_settable?(field, raw)
              write_attribute(name, field.eval_default(self))
            else
              process_raw_attribute(name.to_s, raw, field)
            end
          end
        end
      end

      # Create the getter_before_type_cast method for the provided field. If
      # the attribute has been assigned, return the attribute before it was
      # type cast. Otherwise, delegate to the getter.
      #
      # @example Create the getter_before_type_cast.
      #   Model.create_field_getter_before_type_cast("name", "name")
      #
      # @param [ String ] name The name of the attribute.
      # @param [ String ] meth The name of the method.
      #
      # @api private
      def create_field_getter_before_type_cast(name, meth)
        generated_methods.module_eval do
          re_define_method("#{meth}_before_type_cast") do
            if has_attribute_before_type_cast?(name)
              read_attribute_before_type_cast(name)
            else
              send meth
            end
          end
        end
      end

      # Create the setter method for the provided field.
      #
      # @example Create the setter.
      #   Model.create_field_setter("name", "name")
      #
      # @param [ String ] name The name of the attribute.
      # @param [ String ] meth The name of the method.
      # @param [ Field ] field The field.
      #
      # @api private
      def create_field_setter(name, meth, field)
        generated_methods.module_eval do
          re_define_method("#{meth}=") do |value|
            val = write_attribute(name, value)
            if field.foreign_key?
              remove_ivar(field.association.name)
            end
            val
          end
        end
      end

      # Create the check method for the provided field.
      #
      # @example Create the check.
      #   Model.create_field_check("name", "name")
      #
      # @param [ String ] name The name of the attribute.
      # @param [ String ] meth The name of the method.
      #
      # @api private
      def create_field_check(name, meth)
        generated_methods.module_eval do
          re_define_method("#{meth}?") do
            value = read_raw_attribute(name)
            lookup_attribute_presence(name, value)
          end
        end
      end

      # Create the translation getter method for the provided field.
      #
      # @example Create the translation getter.
      #   Model.create_translations_getter("name", "name")
      #
      # @param [ String ] name The name of the attribute.
      # @param [ String ] meth The name of the method.
      #
      # @api private
      def create_translations_getter(name, meth)
        generated_methods.module_eval do
          re_define_method("#{meth}_translations") do
            attributes[name] ||= {}
            attributes[name].with_indifferent_access
          end
          alias_method :"#{meth}_t", :"#{meth}_translations"
        end
      end

      # Create the translation setter method for the provided field.
      #
      # @example Create the translation setter.
      #   Model.create_translations_setter("name", "name")
      #
      # @param [ String ] name The name of the attribute.
      # @param [ String ] meth The name of the method.
      # @param [ Field ] field The field.
      #
      # @api private
      def create_translations_setter(name, meth, field)
        generated_methods.module_eval do
          re_define_method("#{meth}_translations=") do |value|
            attribute_will_change!(name)
            value&.transform_values! do |_value|
              field.type.mongoize(_value)
            end
            attributes[name] = value
          end
          alias_method :"#{meth}_t=", :"#{meth}_translations="
        end
      end

      # Include the field methods as a module, so they can be overridden.
      #
      # @example Include the fields.
      #   Person.generated_methods
      #
      # @return [ Module ] The module of generated methods.
      #
      # @api private
      def generated_methods
        @generated_methods ||= begin
          mod = Module.new
          include(mod)
          mod
        end
      end

      # Remove the default keys for the provided name.
      #
      # @example Remove the default keys.
      #   Model.remove_defaults(name)
      #
      # @param [ String ] name The field name.
      #
      # @api private
      def remove_defaults(name)
        pre_processed_defaults.delete_one(name)
        post_processed_defaults.delete_one(name)
      end

      # Create a field for the given name and options.
      #
      # @param [ Symbol ] name The name of the field.
      # @param [ Hash ] options The hash of options.
      #
      # @return [ Field ] The created field.
      #
      # @api private
      def field_for(name, options)
        opts = options.merge(klass: self)
        opts[:type] = retrieve_and_validate_type(name, options[:type])
        return Fields::Localized.new(name, opts) if options[:localize]
        return Fields::ForeignKey.new(name, opts) if options[:identity]
        return Fields::Encrypted.new(name, opts) if options[:encrypt]
        Fields::Standard.new(name, opts)
      end

      # Get the class for the given type.
      #
      # @param [ Symbol ] name The name of the field.
      # @param [ Symbol | Class ] type The type of the field.
      #
      # @return [ Class ] The type of the field.
      #
      # @raise [ Mongoid::Errors::InvalidFieldType ] if given an invalid field
      #   type.
      #
      # @api private
      def retrieve_and_validate_type(name, type)
        result = TYPE_MAPPINGS[type] || unmapped_type(type)
        raise Errors::InvalidFieldType.new(self, name, type) if !result.is_a?(Class)

        if unsupported_type?(result)
          warn_message = "Using #{result} as the field type is not supported. "
          if result == BSON::Decimal128
            warn_message += 'In BSON <= 4, the BSON::Decimal128 type will work as expected for both storing and querying, but will return a BigDecimal on query in BSON 5+. To use literal BSON::Decimal128 fields with BSON 5, set Mongoid.allow_bson5_decimal128 to true.'
          else
            warn_message += 'Saving values of this type to the database will work as expected, however, querying them will return a value of the native Ruby Integer type.'
          end
          Mongoid.logger.warn(warn_message)
        end

        result
      end

      # Returns the type of the field if the type was not in the TYPE_MAPPINGS
      # hash.
      #
      # @param [ Symbol | Class ] type The type of the field.
      #
      # @return [ Class ] The type of the field.
      #
      # @api private
      def unmapped_type(type)
        if "Boolean" == type.to_s
          Mongoid::Boolean
        else
          type || Object
        end
      end

      # Queries whether or not the given type is permitted as a declared field
      # type.
      #
      # @param [ Class ] type The type to query
      #
      # @return [ true | false ] whether or not the type is supported
      #
      # @api private
      def unsupported_type?(type)
        return !Mongoid::Config.allow_bson5_decimal128? if type == BSON::Decimal128
        INVALID_BSON_CLASSES.include?(type)
      end
    end
  end
end