3scale/porta

View on GitHub
app/lib/three_scale/search.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

class ThreeScale::Search < ActiveSupport::HashWithIndifferentAccess

  class FormBuilder < ActionView::Helpers::FormBuilder
    def fields_for(record_name, record_object = nil, fields_options = {}, &block)
      fields_options[:builder] ||= options[:builder]
      fields_options[:namespace] = options[:namespace]
      fields_options[:parent_builder] = self

      record_object ||= @object.send(record_name)
      record_name = "#{object_name}[#{record_name}]"

      @template.fields_for(record_name, record_object, fields_options, &block)
    end
  end

  def initialize(params = nil)
    hash_methods = %I[to_unsafe_h to_hash]
    to_hash_method = hash_methods.find { |method| params.respond_to?(method) }

    if to_hash_method
      params.public_send(to_hash_method).each_pair do |key, val|
        self[key] = val
      end
    end
    self.symbolize_keys!
    self.reject! { |key, val| val.blank? }
    self
  end

  # Assigns variables to a search object.
  #
  def method_missing(name, value=nil)
    key = name.to_s.sub(/[=?!]$/,'')

    if name.to_s.ends_with?("=")
      self[key] = value
    end

    value = self[key]

    # convert value to integer if key ends with _id
    if key.ends_with?("_id") && value.present?
      value.respond_to?(:map) ? value.map(&:to_i) : value.to_i
    else
      # convert hash to ThreeScale::Search so we can use it in nested forms
      value.respond_to?(:to_hash) ? self.class.new(value) : value
    end
  end


  # Scopes for models
  module Scopes
    def self.included(base)
      base.class_eval do
        with_options :instance_writer => false, :instance_reader => false do |config|
          config.class_attribute :allowed_sort_directions
          config.class_attribute :allowed_sort_columns
          config.class_attribute :default_sort_column, :default_sort_direction
          config.class_attribute :allowed_search_scopes
          config.class_attribute :default_search_scopes
          config.class_attribute :sort_columns_joins
        end

        self.allowed_sort_directions = [:ASC, :DESC]

        self.allowed_sort_columns = []
        self.allowed_search_scopes = []
        self.default_search_scopes = []

        self.sort_columns_joins = {}

        extend ClassMethods
      end
    end

    module ClassMethods

      def order_by(column = nil, direction = nil)
        column ||= default_sort_column
        direction ||= default_sort_direction

        return default_search_scope unless allowed_sort_columns
        return default_search_scope unless column.present? && allowed_sort_column?(column)

        order = table_and_column(column).join(".")

        join_columns = sort_column_joins(order)

        if direction.present? && allowed_sort_direction?(direction)
          order << " " << direction.to_s.upcase
        end

        # with scope overrides default scope
        reorder(order).scoping do
          join_columns.reduce(default_search_scope) do |scope, join|
            scope.joins{ |dsl| dsl.__send__(join).outer }
          end
        end
      end

      def scope_search(params)
        return default_search_scope unless allowed_scopes || default_search_scopes.present? || params

        params = params.dup
        params[:query] = escape_query(params)

        selected_scopes = params.stringify_keys.slice(*allowed_scopes)

        Rails.logger.debug { "[search] Allowed scopes: #{allowed_scopes}" }
        Rails.logger.debug { "[search] Selected #{selected_scopes} from #{params}" }

        join_scopes(selected_scopes)
      end

      private

      def escape_query(params)
        return unless (query = params[:query])

        ThinkingSphinx::Query.escape(query)
      end

      def default_search_scope
        all
      end

      def allowed_scopes
        allowed_search_scopes.map(&:to_s).presence
      end

      def join_scopes(selected_scopes)
        # process default scopes - reduce all default scopes to one
        scope = default_search_scopes.inject(default_search_scope) do |scope, (method, args)|
          # skip default scope if it is used explicitly in params
          next scope if selected_scopes.include?(method.to_s)

          scope.send "by_#{method}", *args
        end

        # process selected scopes - reduce all scopes to one
        selected_scopes.inject(scope) do |scope, (method, args)|
          # skip if no params are supplied (nothing was selected or blank string given)
          next scope if args.blank?

          scope.send "by_#{method}", *args
        end
      end

      def allowed_sort_column?(column)
        table, column = table_and_column(column)

        allowed_sort_columns.any? do |col|
          tbl, col = table_and_column(col)
          tbl == table and col == column
        end
      end

      def allowed_sort_direction?(direction)
        allowed_sort_directions.include?(direction.to_s.upcase.to_sym)
      end

      def sort_column_joins(column)
        [ sort_columns_joins.symbolize_keys[column.to_sym] ].flatten.compact
      end

      def table_and_column(column)
        table, column = column.to_s.split(".")

        if table.present? && column.nil?
          column = table
          table = table_name
        end

        [ table, column ]
      end

    end # ClassMethods

  end # Scopes

  module Helpers

    # Default maximum page size for search results
    MAX_PER_PAGE = 20

    # By default sphinx paginates search results, and the default page size is 20.
    # There’s no way to turn it off, but we can request really big pages as a workaround.
    # e.g Searchable::by_query implements this workaround, otherwise it wouldn't work properly.
    # See https://freelancing-gods.com/thinking-sphinx/v5/searching.html#pagination
    SPHINX_PAGE_SIZE_INFINITE = 1_000_000

    def self.included(controller)
      controller.class_eval do
        helper_method :sort_column, :sort_direction
      end
    end

    def sort_column
      params[:sort]
    end

    def sort_direction
      params[:direction]
    end

    private

    def pagination_params
      { :page => params[:page] || 1, :per_page => per_page }
    end

    def per_page
      if params[:per_page].present? && params[:per_page].to_i <= MAX_PER_PAGE
        params[:per_page]
      else
        MAX_PER_PAGE
      end
    end

  end # Helpers
end