ruby 搜索适用于Jsonapi-suite的适配器

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了ruby 搜索适用于Jsonapi-suite的适配器相关的知识,希望对你有一定的参考价值。

# Background : jsonapi-powered search controller
class MySearchController < JsonapiPoweredController
  jsonapi resource: ::Public::Search::ProfessionalResource

  def index
    render_jsonapi(search_scope)
  end
  def search_scope
    Searcher::SearchScopeProxy.new(
      collection: Professional,
      query: params[:q],
      scope: ::Professional.published.active
    )
  end
end

# Search-engine Resource 
class ProfessionalResource < ApplicationResource
  type :professional
  model Professional

  case Rails.configuration.search_engine
  when :algolia
    use_adapter Jsonapi::Adapters::AlgoliaAdapter
  else
    raise NotImplementedError, 'Need to implement some adapter'
  end

  # filters are [companies, experiences, etc.]
  Settings.search_filters.each do |filter_name|
    allow_filter filter_name
  end
end

# My scope proxy for search based operations to work with jsonapi-suite
module Searcher
  # Wraps a classic mongoid scope in a proxy
  # that allows to add filter metadata to help work with jsonapi
  # and other adapters that expect the scope to hold configuration
  #
  # @author [Cyril]
  #
  # note cannot extend BasicObject, Jsonapi seems to calls various methods on the scope 
  # eg. .is_a?() which are not defined on basic objects
  class SearchScopeProxy 
    # Expose the user search query
    attr_accessor :query
    attr_accessor :collection
    attr_reader :scope
    alias :criteria :scope

    # Search engines need to dynamically make a search configuration
    # based on query and filters.
    attr_accessor :search_configuration

    # Expose filters as a hash of arrays (meant to be joined in a disjunctive manner)
    # @example
    #
    #   { organization_name: ['axa', 'jcdecaux'] }
    #
    attr_accessor :filters

    # Some search engine apply pagination only after executing their main search() method
    # Remember pagination variables in the scope
    attr_accessor :results_per_page, :page_number

    # Initializes a search proxy
    #
    # @param collection [class includes Mongoid::Document]
    # @param scope [class includes Mongoid::Document or Mongoid::Criteria]
    # @param query [String or nil]
    #
    # @return [void]
    def initialize(collection:, scope:, query:)
      @collection = collection
      @scope = scope
      @query = query || ''
      @filters = {}
    end


    # Add a filter value to a filter category
    # @param category [String] Filter category (company_name, etc.)
    # @param value [String] [description]
    #
    # @return [type] [description]
    def add_filter(category, values)
      @filters[category.to_sym] = [*values]
    end

    # @param filter_name [String] Filter name
    #
    # @return [Boolean] True if filter enabled
    def filter_enabled?(filter_name)
      @filters[filter_name.to_sym].present?
    end

    # @param filter_names [Array<String>] filters
    #
    # @return [Boolean] true if all filters enabled
    def filters_enabled?(*filter_names)
      filter_names.each do |filter_name|
        return false unless filter_enabled?(filter_name)
      end
    end

    # @param category [String] Name of filter
    #
    # @return [Array<String>] an array of filters
    def filter(category)
      @filters[category.to_sym]
    end

    private

    def method_missing(method, *args, &block)
      if @scope.respond_to?(method)
        # ENsure chaining is done on proxy
        result = @scope.send(method, *args, &block)
        result == scope ? self : result
      else
        super
      end
    end

    def respond_to_missing?(method_name)
      PROXYABLE_METHODS.include?(method_name.to_s) || super
    end
  end
end

# And the adapter I started writing for Algolia
module Jsonapi
  module Adapters
    # Algolia adapter for Jsonapi-suite
    # Meant to be used on SearchScopeProxy
    #
    # @author [Cyril]
    #
    class AlgoliaAdapter < JsonapiCompliable::Adapters::Abstract
      # Filters eligible for Algolia string base filtering
      FORMATTABLE_FILTERS = Settings.search_filters & [
        :companies, :sectors, :sizes, :experiences
      ]

      # @Override
      #
      # @param scope [Searcher::SearchScopeProxy]
      #
      # @return [Paginated]
      def resolve(scope)
        configure_search(scope)
        records = scope.algolia_search(scope.query, scope.search_configuration)
        return records if records.empty?
        records.per(scope.results_per_page)

        # TODO log
      end

      # @Override
      # @param scope [Searcher::SearchScopeProxy]
      def filter(scope, attribute, values)
        scope.add_filter(attribute, values)
        scope
      end

      # @Override using Mongoid's #asc and #desc
      # TODO: Implement for Algolia
      def order(scope, attribute, direction)
        scope
        # raise NotImplementedError, 'Implement ordering for Algolia'
      end

      # @Override
      # @param scope [Searcher::SearchScopeProxy]
      def paginate(scope, current_page, per_page)
        scope.results_per_page = per_page
        scope.page_number = current_page
        scope
      end

      # @Override
      def count(scope, attr)
        scope.count
      end

      # @Override
      # Irrelevant for Algolia read-only search engine
      def transaction(_model_class)
        yield
      end

      private

      # Configure Algolia search options
      # @param scope [SearchScopeProxy]
      #
      # @return [void]
      def configure_search(scope)
        scope.search_configuration = {
          filters: assemble_filter_string(scope) || '',
          facets: '*',
          hitsPerPage: scope.results_per_page,
          page: scope.page_number
        }.tap do |config|
          if scope.filters_enabled?(:lat, :lng)
            config.merge(configure_geo_filters(
              lat: scope.filter(:lat),
              lng: scope.filter(:lng),
              radius: scope.filter(:radius)
            ))
          end
        end
      end

      # Assemble filter string using scope filters and CNF
      #
      # @return [String] filter string for algolia
      def assemble_filter_string(scope)
        active_filters = [] # list of (list of same filter types)

        # Run all apply_filters methods
        FORMATTABLE_FILTERS.each do |filter_name|
          next unless scope.filter_enabled?(filter_name)
          active_filters << send("format_#{filter_name}_filter", scope.filter(filter_name))
        end

        # Don't forget the document type filter !
        unless scope.collection == Professional
          active_filters << ["_type:\"#{scope.collection.name}\""]
        end

        conjunctive_normal_form_filters(active_filters)
      end

      # Filters for Algolia must be written
      # as a Conjunctive Normal Form (CNF)
      # [AND of [OR of (NOT(x) or x)]]
      #
      #   [[A, A'], [B, B']] -> (A OR A') AND (B OR B')
      #
      # @param filters [Array<Array<String>>] Filters
      #
      # @return [String] CNF filters
      def conjunctive_normal_form_filters(active_filters)
        disjunctive_filters(active_filters).join(' AND ')
      end

      # Assemble the filters of the same category into a disjunctive form
      # [[A, A'], [B, B']] -> ['(A OR A')', '(B OR B)'']
      #
      # @param active_filters [type] [description]
      #
      # @return [Array<String>] Disjunctive filter array
      def disjunctive_filters(active_filters)
        active_filters.map do |subfilter|
          "(#{subfilter.join(' OR ')})"
        end
      end

      def format_companies_filter(companies)
        companies.map do |name|
          "company_name:\"#{name}\" OR entity_name:\"#{name}\""
        end
      end

      def format_sectors_filter(sectors)
        # (sectors & Settings.company_sectors).map(&:to_s).each do |sector|
        #   @to_filter.send(sector.pluralize)
        # end
        sectors &= Settings.company_sectors.map(&:to_s)
        sectors.map do |sector|
          "company_sector:#{sector}"
        end
      end

      # Only non trivial case is x -> +infinity
      def format_experiences_filter(xp_pair)
        parsed_pair = xp_pair.map do |fi|
          fi.split('-')
        end
        parsed_pair.map do |pair|
          "years_of_experience: #{pair.join(' TO ')}"
        end
      end

      # @param sizes [Array<String>]
      #
      # @return [Array<String>] [description]
      def format_company_size_filter(sizes)
        sizes &= Settings.company_size.map(&:to_s)
        scope.collection.sizes_in(sizes)
      end

      # Geo filters are special on Algolia
      # @param lat: [Float]
      # @param lng: [Float]
      # @param radius: nil [Float or nil] [description]
      #
      # @return [Hash] [description]
      def configure_geo_filters(lat:, lng:, radius: nil)
        geopoint = ::Mongoid::Geospatial::Point.new(lng, lat)
        radius ||= Settings.search_radius
        {
          aroundRadius: radius,
          aroundLatLng: "#{geopoint.lat},#{geopoint.lng}"
        }
      end
    end
  end
end

以上是关于ruby 搜索适用于Jsonapi-suite的适配器的主要内容,如果未能解决你的问题,请参考以下文章

ruby 中是不是有适用于 ISO 8601 的综合库/模块?

ruby 适用于AWS的SD

ruby 适用于Comfy的CSS和Javascript助手

适用于 Python 或 Ruby 的 Amazon Book API? [复制]

使用适用于 Ruby 的 AWS 开发工具包发布到 SNS 主题时指定区域

适用于 Ruby 的 PayPal REST SDK - 非全局配置