Created
June 22, 2017 15:04
-
-
Save Startouf/ed941dcbcb76c01995d7383039a6245d to your computer and use it in GitHub Desktop.
Search adapters for Jsonapi-suite
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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