Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Startouf/ed941dcbcb76c01995d7383039a6245d to your computer and use it in GitHub Desktop.
Save Startouf/ed941dcbcb76c01995d7383039a6245d to your computer and use it in GitHub Desktop.
Search adapters for 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment