mongoid/mongoid-slug

View on GitHub
lib/mongoid/slug.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

require 'mongoid'
require 'stringex'
require 'mongoid/slug/criteria'
require 'mongoid/slug/index_builder'
require 'mongoid/slug/unique_slug'
require 'mongoid/slug/slug_id_strategy'
require 'mongoid/slug/railtie' if defined?(Rails)

module Mongoid
  # Slugs your Mongoid model.
  module Slug
    extend ActiveSupport::Concern

    MONGO_INDEX_KEY_LIMIT_BYTES = 1024

    included do
      cattr_accessor :slug_reserved_words,
                     :slug_scope,
                     :slug_index,
                     :slugged_attributes,
                     :slug_url_builder,
                     :slug_history,
                     :slug_by_model_type,
                     :slug_max_length

      # field :_slugs, type: Array, default: [], localize: false
      # alias_attribute :slugs, :_slugs
    end

    class << self
      attr_accessor :default_slug

      def configure(&block)
        instance_eval(&block)
      end

      def slug(&block)
        @default_slug = block if block_given?
      end
    end

    module ClassMethods
      # @overload slug(*fields)
      #   Sets one ore more fields as source of slug.
      #   @param [Array] fields One or more fields the slug should be based on.
      #   @yield If given, the block is used to build a custom slug.
      #
      # @overload slug(*fields, options)
      #   Sets one ore more fields as source of slug.
      #   @param [Array] fields One or more fields the slug should be based on.
      #   @param [Hash] options
      #   @param options [Boolean] :history Whether a history of changes to
      #   the slug should be retained. When searched by slug, the document now
      #   matches both past and present slugs.
      #   @param options [Boolean] :permanent Whether the slug should be
      #   immutable. Defaults to `false`.
      #   @param options [Array] :reserve` A list of reserved slugs
      #   @param options :scope [Symbol, Array<Symbol>] a reference association, field,
      #   or array of fields to scope the slug by.
      #   Embedded documents are, by default, scoped by their parent. Now it supports not only
      #   a single association or field but also an array of them.
      #   @param options :max_length [Integer] the maximum length of the text portion of the slug
      #   @yield If given, a block is used to build a slug.
      #
      # @example A custom builder
      #   class Person
      #     include Mongoid::Document
      #     include Mongoid::Slug
      #
      #     field :names, :type => Array
      #     slug :names do |doc|
      #       doc.names.join(' ')
      #     end
      #   end
      #
      def slug(*fields, &block)
        options = fields.extract_options!

        self.slug_scope            = options[:scope]
        self.slug_index            = options[:index].nil? ? true : options[:index]
        self.slug_reserved_words   = options[:reserve] || Set.new(%w[new edit])
        self.slugged_attributes    = fields.map(&:to_s)
        self.slug_history          = options[:history]
        self.slug_by_model_type    = options[:by_model_type]
        self.slug_max_length       = options.key?(:max_length) ? options[:max_length] : MONGO_INDEX_KEY_LIMIT_BYTES - 32

        field :_slugs, type: Array, localize: options[:localize]
        alias_attribute :slugs, :_slugs

        # Set indexes
        if slug_index && !embedded?
          Mongoid::Slug::IndexBuilder.build_indexes(self, slug_scope_keys, slug_by_model_type, options[:localize])
        end

        self.slug_url_builder = block_given? ? block : default_slug_url_builder

        #-- always create slug on create
        #-- do not create new slug on update if the slug is permanent
        if options[:permanent]
          set_callback :create, :before, :build_slug
        else
          set_callback :save, :before, :build_slug, if: :slug_should_be_rebuilt?
        end
      end

      def default_slug_url_builder
        Mongoid::Slug.default_slug || ->(cur_object) { cur_object.slug_builder.to_url }
      end

      def look_like_slugs?(*args)
        with_default_scope.look_like_slugs?(*args)
      end

      def slug_scopes
        # If slug_scope is set (i.e., not nil), we convert it to an array to ensure we can handle it consistently.
        # If it's not set, we use an array with a single nil element, signifying no specific scope.
        slug_scope ? Array(slug_scope) : [nil]
      end

      # Returns the scope keys for indexing, considering associations
      #
      # @return [ Array<Document>, Document ]
      def slug_scope_keys
        return nil unless slug_scope

        # If slug_scope is an array, we map over its elements to get each individual scope's key.
        slug_scopes.map do |individual_scope|
          # Attempt to find the association and get its key. If no association is found, use the scope as-is.
          reflect_on_association(individual_scope).try(:key) || individual_scope
        end
      end

      # Find documents by slugs.
      #
      # A document matches if any of its slugs match one of the supplied params.
      #
      # A document matching multiple supplied params will be returned only once.
      #
      # If any supplied param does not match a document a Mongoid::Errors::DocumentNotFound will be raised.
      #
      # @example Find by a slug.
      #   Model.find_by_slug!('some-slug')
      #
      # @example Find by multiple slugs.
      #   Model.find_by_slug!('some-slug', 'some-other-slug')
      #
      # @param [ Array<Object> ] args The slugs to search for.
      #
      # @return [ Array<Document>, Document ] The matching document(s).
      def find_by_slug!(*args)
        with_default_scope.find_by_slug!(*args)
      end

      def queryable
        current_scope || Criteria.new(self) # Use Mongoid::Slug::Criteria for slugged documents.
      end

      private

      if Threaded.method(:current_scope).arity == -1
        def current_scope
          Threaded.current_scope(self)
        end
      else
        def current_scope
          Threaded.current_scope
        end
      end
    end

    # Builds a new slug.
    #
    # @return [true]
    def build_slug
      if localized?
        begin
          orig_locale = I18n.locale
          all_locales.each do |target_locale|
            I18n.locale = target_locale
            apply_slug
          end
        ensure
          I18n.locale = orig_locale
        end
      else
        apply_slug
      end
      true
    end

    def apply_slug
      new_slug = find_unique_slug

      # skip slug generation and use Mongoid id
      # to find document instead
      return true if new_slug.empty?

      # avoid duplicate slugs
      _slugs&.delete(new_slug)

      if !!slug_history && _slugs.is_a?(Array)
        append_slug(new_slug)
      else
        self._slugs = [new_slug]
      end
    end

    # Builds slug then atomically sets it in the database.
    #
    # This method is adapted to use the :set method variants from both
    # Mongoid 3 (two args) and Mongoid 4 (hash arg)
    def set_slug!
      build_slug
      method(:set).arity == 1 ? set(_slugs: _slugs) : set(:_slugs, _slugs)
    end

    # Atomically unsets the slug field in the database. It is important to unset
    # the field for the sparse index on slugs.
    #
    # This also resets the in-memory value of the slug field to its default (empty array)
    def unset_slug!
      unset(:_slugs)
      clear_slug!
    end

    # Rolls back the slug value from the Mongoid changeset.
    def reset_slug!
      reset__slugs!
    end

    # Sets the slug to its default value.
    def clear_slug!
      self._slugs = []
    end

    # Finds a unique slug, were specified string used to generate a slug.
    #
    # Returned slug will the same as the specified string when there are no
    # duplicates.
    #
    # @return [String] A unique slug
    def find_unique_slug
      UniqueSlug.new(self).find_unique
    end

    # @return [Boolean] Whether the slug requires to be rebuilt
    def slug_should_be_rebuilt?
      new_record? || _slugs_changed? || slugged_attributes_changed?
    end

    def slugged_attributes_changed?
      slugged_attributes.any? { |f| attribute_changed? f.to_s }
    end

    # @return [String] A string which Action Pack uses for constructing an URL
    # to this record.
    def to_param
      slug || super
    end

    # @return [String] the slug, or nil if the document does not have a slug.
    def slug
      return _slugs.last if _slugs

      _id.to_s
    end

    def slug_builder
      cur_slug = nil
      if new_with_slugs? || persisted_with_slug_changes?
        # user defined slug
        cur_slug = _slugs.last
      end
      # generate slug if the slug is not user defined or does not exist
      cur_slug || pre_slug_string
    end

    private

    def append_slug(value)
      if localized?
        # This is necessary for the scenario in which the slugged locale is not yet present
        # but the default locale is. In this situation, self._slugs falls back to the default
        # which is undesired
        current_slugs = _slugs_translations.fetch(I18n.locale.to_s, [])
        current_slugs << value
        self._slugs_translations = _slugs_translations.merge(I18n.locale.to_s => current_slugs)
      else
        _slugs << value
      end
    end

    # Returns true if object is a new record and slugs are present
    def new_with_slugs?
      if localized?
        # We need to check if slugs are present for the locale without falling back
        # to a default
        new_record? && _slugs_translations.fetch(I18n.locale.to_s, []).any?
      else
        new_record? && _slugs.present?
      end
    end

    # Returns true if object has been persisted and has changes in the slug
    def persisted_with_slug_changes?
      if localized?
        changes = _slugs_change
        return false if changes.nil?

        # ensure we check for changes only between the same locale
        original = changes.first.try(:fetch, I18n.locale.to_s, nil)
        compare = changes.last.try(:fetch, I18n.locale.to_s, nil)
        persisted? && original != compare
      else
        persisted? && _slugs_changed?
      end
    end

    def localized?
      fields['_slugs'].options[:localize]
    rescue StandardError
      false
    end

    # Return all possible locales for model
    # Avoiding usage of I18n.available_locales in case the user hasn't set it properly, or is
    # doing something crazy, but at the same time we need a fallback in case the model doesn't
    # have any localized attributes at all (extreme edge case).
    def all_locales
      locales = slugged_attributes
                .map { |attr| send("#{attr}_translations").keys if respond_to?("#{attr}_translations") }
                .flatten.compact.uniq
      locales = I18n.available_locales if locales.empty?
      locales
    end

    def pre_slug_string
      slugged_attributes.map { |f| send f }.join ' '
    end
  end
end