Skip to content

Commit

Permalink
Merge #321
Browse files Browse the repository at this point in the history
321: Support Multi-Search r=ellnix a=ellnix

# Pull Request

## Related issue
Fixes #254

I thought I'd make a draft PR to discuss and review API decisions. I discussed the method signature of a theoretical `multi_search` in #254 (comment), if there is no problem I will proceed with this one:
```ruby
MeiliSearch::Rails.multi_search(
  'book_production' => {q: 'paper', class_name: 'Book', **book_options}, # Index with a model
  Product => {q: 'thing', **product_options}, # Model with implied index
  'blurbs' => { q: 'happy' }, # Index not backed by a model, results will be simple hashes
  **other_searches
)
```

Initially I expected that the return type would be a simple array, however this might not be ideal since it
- does not provide a performant way to use only the results of a single search
- does not provide a way to access search metadata [provided by meilisearch](https://www.meilisearch.com/docs/reference/api/multi_search#response)

I am thinking of either a simple hash or a hash-like class with convenience methods.


Co-authored-by: ellnix <[email protected]>
  • Loading branch information
meili-bors[bot] and ellnix committed Feb 27, 2024
2 parents cda3f08 + 9c186c9 commit b5983bd
Show file tree
Hide file tree
Showing 6 changed files with 386 additions and 5 deletions.
11 changes: 6 additions & 5 deletions lib/meilisearch-rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'meilisearch/rails/version'
require 'meilisearch/rails/utilities'
require 'meilisearch/rails/errors'
require 'meilisearch/rails/multi_search'

if defined? Rails
begin
Expand Down Expand Up @@ -760,6 +761,11 @@ def ms_must_reindex?(document)
false
end

def ms_primary_key_method(options = nil)
options ||= meilisearch_options
options[:primary_key] || options[:id] || :id
end

protected

def ms_ensure_init(options = meilisearch_options, settings = meilisearch_settings, user_configuration = settings.to_settings)
Expand Down Expand Up @@ -814,11 +820,6 @@ def ms_configurations
@configurations
end

def ms_primary_key_method(options = nil)
options ||= meilisearch_options
options[:primary_key] || options[:id] || :id
end

def ms_primary_key_of(doc, options = nil)
doc.send(ms_primary_key_method(options)).to_s
end
Expand Down
49 changes: 49 additions & 0 deletions lib/meilisearch/rails/multi_search.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
require_relative 'multi_search/result'

module MeiliSearch
module Rails
class << self
def multi_search(searches)
search_parameters = searches.map do |(index_target, options)|
paginate(options) if pagination_enabled?
normalize(options, index_target)
end

MultiSearchResult.new(searches, client.multi_search(search_parameters))
end

private

def normalize(options, index_target)
options
.except(:class_name)
.merge!(index_uid: index_uid_from_target(index_target))
end

def index_uid_from_target(index_target)
case index_target
when String, Symbol
index_target
else
index_target.index.uid
end
end

def paginate(options)
%w[page hitsPerPage hits_per_page].each do |key|
# Deletes hitsPerPage to avoid passing along a meilisearch-ruby warning/exception
value = options.delete(key) || options.delete(key.to_sym)
options[key.underscore.to_sym] = value.to_i if value
end

# It is required to activate the finite pagination in Meilisearch v0.30 (or newer),
# to have at least `hits_per_page` defined or `page` in the search request.
options[:page] ||= 1
end

def pagination_enabled?
MeiliSearch::Rails.configuration[:pagination_backend]
end
end
end
end
84 changes: 84 additions & 0 deletions lib/meilisearch/rails/multi_search/result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
module MeiliSearch
module Rails
class MultiSearchResult
attr_reader :metadata

def initialize(searches, raw_results)
@results = {}
@metadata = {}

searches.zip(raw_results['results']).each do |(index_target, search_options), result|
index_target = search_options[:class_name].constantize if search_options[:class_name]

@results[index_target] = case index_target
when String, Symbol
result['hits']
else
load_results(index_target, result)
end

@metadata[index_target] = result.except('hits')
end
end

include Enumerable

def each_hit(&block)
@results.each do |_index_target, results|
results.each(&block)
end
end
alias each each_hit

def each_result
@results.each
end

def to_a
@results.values.flatten(1)
end
alias to_ary to_a

def to_h
@results
end
alias to_hash to_h

private

def load_results(klass, result)
pk_method = klass.ms_primary_key_method
pk_method = pk_method.in if Utilities.mongo_model?(klass)

condition_key = pk_is_virtual?(klass, pk_method) ? klass.primary_key : pk_method

hits_by_id =
result['hits'].index_by { |hit| hit[condition_key.to_s] }

records = klass.where(condition_key => hits_by_id.keys)

if records.respond_to? :in_order_of
records.in_order_of(condition_key, hits_by_id.keys).each do |record|
record.formatted = hits_by_id[record.send(condition_key).to_s]['_formatted']
end
else
results_by_id = records.index_by do |hit|
hit.send(condition_key).to_s
end

result['hits'].filter_map do |hit|
record = results_by_id[hit[condition_key.to_s].to_s]
record&.formatted = hit['_formatted']
record
end
end
end

def pk_is_virtual?(model_class, pk_method)
model_class.columns
.map(&(Utilities.sequel_model?(model_class) ? :to_s : :name))
.exclude?(pk_method.to_s)
end
end
end
end
8 changes: 8 additions & 0 deletions lib/meilisearch/rails/utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ def indexable?(record, options)
true
end

def mongo_model?(model_class)
defined?(::Mongoid::Document) && model_class.include?(::Mongoid::Document)
end

def sequel_model?(model_class)
defined?(::Sequel::Model) && model_class < Sequel::Model
end

private

def constraint_passes?(record, constraint)
Expand Down
118 changes: 118 additions & 0 deletions spec/multi_search/result_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
require 'spec_helper'

describe MeiliSearch::Rails::MultiSearchResult do # rubocop:todo RSpec/FilePath
let(:raw_results) do
{
'results' => [
{ 'indexUid' => 'books_index',
'hits' => [{ 'name' => 'Steve Jobs', 'id' => '3', 'author' => 'Walter Isaacson', 'premium' => nil, 'released' => nil, 'genre' => nil }],
'query' => 'Steve', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 1 },
{ 'indexUid' => 'products_index',
'hits' => [{ 'id' => '4', 'href' => 'ebay', 'name' => 'palm pixi plus' }],
'query' => 'palm', 'processingTimeMs' => 0, 'limit' => 1, 'offset' => 0, 'estimatedTotalHits' => 2 },
{ 'indexUid' => 'color_index',
'hits' => [
{ 'name' => 'black', 'id' => '5', 'short_name' => 'bla', 'hex' => 0 },
{ 'name' => 'blue', 'id' => '4', 'short_name' => 'blu', 'hex' => 255 }
],
'query' => 'bl', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 2 }
]
}
end

it 'is enumerable' do
expect(described_class).to include(Enumerable)
end

context 'with index name keys' do
subject(:result) { described_class.new(searches, raw_results) }

let(:searches) do
{
'books_index' => { q: 'Steve' },
'products_index' => { q: 'palm', limit: 1 },
'color_index' => { q: 'bl' }
}
end

it 'enumerates through the hits' do
expect(result).to contain_exactly(
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs'),
a_hash_including('name' => 'palm pixi plus'),
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
a_hash_including('name' => 'black', 'short_name' => 'bla')
)
end

it 'enumerates through the hits of each result with #each_result' do
expect(result.each_result).to be_an(Enumerator)
expect(result.each_result).to contain_exactly(
['books_index', contain_exactly(
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs')
)],
['products_index', contain_exactly(
a_hash_including('name' => 'palm pixi plus')
)],
['color_index', contain_exactly(
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
a_hash_including('name' => 'black', 'short_name' => 'bla')
)]
)
end

describe '#to_a' do
it 'returns the hits' do
expect(result.to_a).to contain_exactly(
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs'),
a_hash_including('name' => 'palm pixi plus'),
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
a_hash_including('name' => 'black', 'short_name' => 'bla')
)
end

it 'aliases as #to_ary' do
expect(result.method(:to_ary).original_name).to eq :to_a
end
end

describe '#to_h' do
it 'returns a hash of indexes and hits' do
expect(result.to_h).to match(
'books_index' => contain_exactly(
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs')
),
'products_index' => contain_exactly(
a_hash_including('name' => 'palm pixi plus')
),
'color_index' => contain_exactly(
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
a_hash_including('name' => 'black', 'short_name' => 'bla')
)
)
end

it 'is aliased as #to_hash' do
expect(result.method(:to_hash).original_name).to eq :to_h
end
end

describe '#metadata' do
it 'returns search metadata for each result' do
expect(result.metadata).to match(
'books_index' => {
'indexUid' => 'books_index',
'query' => 'Steve', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 1
},
'products_index' => {
'indexUid' => 'products_index',
'query' => 'palm', 'processingTimeMs' => 0, 'limit' => 1, 'offset' => 0, 'estimatedTotalHits' => 2
},
'color_index' => {
'indexUid' => 'color_index',
'query' => 'bl', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 2
}
)
end
end
end
end
Loading

0 comments on commit b5983bd

Please sign in to comment.