lgs/mongoid_taggable_with_context

View on GitHub
lib/mongoid/taggable_with_context.rb

Summary

Maintainability
A
0 mins
Test Coverage
module Mongoid::TaggableWithContext
  extend ActiveSupport::Concern

  class AggregationStrategyMissing < Exception; end
  class InvalidTagsFormat < Exception; end

  DEFAULT_FIELD = :tags
  DEFAULT_SEPARATOR = ' '

  included do
    class_attribute :taggable_with_context_options
    self.taggable_with_context_options = {}
  end

  def tag_string_for(context)
    self.read_attribute(context).join(self.class.get_tag_separator_for(context)) rescue ""
  end

  def set_tag_string_for(context, value)
    self.write_attribute(context, self.class.format_tags_for(context, value))
  end

  module ClassMethods
    # Macro to declare a document class as taggable, specify field name
    # for tags, and set options for tagging behavior.
    #
    # @example Define a taggable document.
    #
    #   class Article
    #     include Mongoid::Document
    #     include Mongoid::TaggableWithContext
    #     taggable :keywords, separator: ' ', default: ['foobar']
    #   end
    #
    # @param [ Symbol ] field The name of the field for tags. Defaults to :tags if not specified.
    # @param [ Hash ] options Options for taggable behavior.
    #
    # @option options [ String ] :separator
    #   The delimiter used when converting the tags to and from String format. Defaults to ' '
    # @option options [ :Symbol ] :group_by_field
    #   The Mongoid field to group by when RealTimeGroupBy aggregation is used.
    # @option options [ <various> ] :default, :as, :localize, etc.
    #   Options for Mongoid #field method will be automatically passed
    #   to the underlying Array field
    def taggable(*args)
      # init variables
      options = args.extract_options!
      field = args.present? ? args.shift.to_sym : DEFAULT_FIELD
      added = add_taggable(field, options)
      # TODO: test if this is needed
      # descendants.each do |subclass|
      #   subclass.add_taggable(field, options)
      # end
      added
    end

    def tag_contexts
      self.taggable_with_context_options.keys
    end

    def tag_database_fields
      self.taggable_with_context_options.keys.map do |context|
        tag_options_for(context)[:db_field]
      end
    end

    def tag_options_for(context)
      self.taggable_with_context_options[context]
    end

    def tags_for(context, group_by=nil, conditions={})
      raise AggregationStrategyMissing
    end

    def tags_with_weight_for(context, group_by=nil, conditions={})
      raise AggregationStrategyMissing
    end

    def get_tag_separator_for(context)
      self.taggable_with_context_options[context][:separator]
    end

    def get_tag_group_by_field_for(context)
      self.taggable_with_context_options[context][:group_by_field]
    end

    # Find documents tagged with all tags passed as a parameter, given
    # as an Array or a String using the configured separator.
    #
    # @example Find matching all tags in an Array.
    #   Article.tagged_with(['ruby', 'mongodb'])
    # @example Find matching all tags in a String.
    #   Article.tagged_with('ruby, mongodb')
    #
    # @param [ String ] :field The field name of the tag.
    # @param [ Array<String, Symbol>, String ] :tags Tags to match.
    # @return [ Criteria ] A new criteria.
    def tagged_with(context, tags)
      tags = format_tags_for(context, tags)
      field = tag_options_for(context)[:field]
      all_in(field => tags)
    end

    # Helper method to convert a a tag input value of unknown type
    # to a formatted array.
    def format_tags_for(context, value)
      return nil if value.nil?
      # 0) Tags must be an array or a string
      raise InvalidTagsFormat unless value.is_a?(Array) || value.is_a?(String)
      # 1) convert String to Array
      value = value.split(get_tag_separator_for(context)) if value.is_a? String
      # 2) remove all nil values
      # 3) strip all white spaces. Could leave blank strings (e.g. foo, , bar, baz)
      # 4) remove all blank strings
      # 5) remove duplicate
      value.compact.map(&:strip).reject(&:blank?).uniq
    end

    protected

    # Adds a taggable context to the list of contexts, and creates the underlying
    # Mongoid field and alias methods for the context.
    #
    # @param [ Symbol ] field The name of the Mongoid database field to store the taggable.
    # @param [ Hash ] options The taggable options.
    #
    # @since 1.1.1
    def add_taggable(field, options)
      validate_taggable_options(options)

      # db_field: the field name stored in the database
      options[:db_field] = field.to_sym
      # field: the field name used to identify the tags. :field will
      # be identical to :db_field unless the :as option is specified
      options[:field] = options[:as] || field
      context = options[:field]
      options.reverse_merge!(
          separator: DEFAULT_SEPARATOR
      )

      # register / update settings
      self.taggable_with_context_options[options[:field]] = options

      create_taggable_mongoid_field(field, options)
      create_taggable_mongoid_index(context)
      define_taggable_accessors(context)
    end

    # Validates the taggable options and raises errors if invalid options are detected.
    #
    # @param [ Hash ] options The taggable options.
    #
    # @since 1.1.1
    def validate_taggable_options(options)
      if options[:field]
        raise <<-ERR
          taggable :field option has been removed as of version 1.1.1. Please use the
          syntax "taggable <database_name>, as: <tag_name>"
        ERR
      end
      if options[:string_method]
        raise <<-ERR
          taggable :string_method option has been removed as of version 1.1.1. Please
          define an alias to "<tags>_string" in your Model
        ERR
      end
    end

    # Creates the underlying Mongoid field for the tag context.
    #
    # @param [ Symbol ] name The name of the Mongoid field.
    # @param [ Hash ] options Options for the Mongoid field.
    #
    # @since 1.1.1
    def create_taggable_mongoid_field(name, options)
      field name, mongoid_field_options(options)
    end

    # Prepares valid Mongoid option keys from the taggable options. Slices
    # the taggable options to include only valid options for the Mongoid #field
    # method, and coerces :type to Array.
    #
    # @param [ Hash ] :options The taggable options hash.
    # @return [ Hash ] A options hash for the Mongoid #field method.
    #
    # @since 1.1.1
    def mongoid_field_options(options = {})
      options.slice(*::Mongoid::Fields::Validators::Macro::OPTIONS).merge!(type: Array)
    end

    # Creates an index for the underlying Mongoid field.
    #
    # @param [ Symbol ] name The name or alias name of Mongoid field.
    #
    # @since 1.1.1
    def create_taggable_mongoid_index(name)
      index({ name => 1 }, { background: true })
    end

    # Defines all accessor methods for the taggable context at both
    # the instance and class level.
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_taggable_accessors(context)
      define_class_tags_getter(context)
      define_class_weighted_tags_getter(context)
      define_class_separator_getter(context)
      define_class_tagged_with_getter(context)
      define_class_group_by_getter(context)
      define_instance_tag_string_getter(context)
      define_instance_tag_setter(context)
    end

    # Create the singleton getter method to retrieve all tags
    # of a given context for all instances of the model.
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_class_tags_getter(context)
      # retrieve all tags ever created for the model
      self.class.class_eval do
        define_method context do |group_by = nil|
          tags_for(context, group_by)
        end
      end
    end

    # Create the singleton getter method to retrieve a weighted
    # array of tags of a given context for all instances of the model.
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_class_weighted_tags_getter(context)
      self.class.class_eval do
        define_method :"#{context}_with_weight" do |group_by = nil|
          tags_with_weight_for(context, group_by)
        end
      end
    end

    # Create the singleton getter method to retrieve the tag separator
    # for a given context for all instances of the model.
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_class_separator_getter(context)
      self.class.class_eval do
        define_method :"#{context}_separator" do
          get_tag_separator_for(context)
        end
      end
    end

    # Create the singleton getter method to retrieve the all
    # instances of the model which contain the tag/tags for a given context.
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_class_tagged_with_getter(context)
      self.class.class_eval do
        define_method :"#{context}_tagged_with" do |tags|
          tagged_with(context, tags)
        end
      end
    end

    # Create the singleton getter method to retrieve the
    # group_by field
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_class_group_by_getter(context)
      self.class.class_eval do
        define_method :"#{context}_group_by_field" do
          get_tag_group_by_field_for(context)
        end
      end
    end

    # Create the setter method for the provided taggable, using an
    # alias method chain to the underlying field method.
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_instance_tag_setter(context)
      generated_methods.module_eval do
        re_define_method("#{context}_with_taggable=") do |value|
          value = self.class.format_tags_for(context, value)
          self.send("#{context}_without_taggable=", value)
        end
        alias_method_chain "#{context}=", :taggable
      end
    end

    # Create the getter method for the joined tags string.
    #
    # @param [ Symbol ] context The name of the tag context.
    #
    # @since 1.1.1
    def define_instance_tag_string_getter(context)
      generated_methods.module_eval do
        re_define_method("#{context}_string") do
          tag_string_for(context)
        end

        re_define_method("#{context}_string=") do |value|
          set_tag_string_for(context, value)
        end
      end
    end
  end
end