Skip to content

Commit b5983bd

Browse files
Merge #321
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]>
2 parents cda3f08 + 9c186c9 commit b5983bd

File tree

6 files changed

+386
-5
lines changed

6 files changed

+386
-5
lines changed

lib/meilisearch-rails.rb

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require 'meilisearch/rails/version'
44
require 'meilisearch/rails/utilities'
55
require 'meilisearch/rails/errors'
6+
require 'meilisearch/rails/multi_search'
67

78
if defined? Rails
89
begin
@@ -760,6 +761,11 @@ def ms_must_reindex?(document)
760761
false
761762
end
762763

764+
def ms_primary_key_method(options = nil)
765+
options ||= meilisearch_options
766+
options[:primary_key] || options[:id] || :id
767+
end
768+
763769
protected
764770

765771
def ms_ensure_init(options = meilisearch_options, settings = meilisearch_settings, user_configuration = settings.to_settings)
@@ -814,11 +820,6 @@ def ms_configurations
814820
@configurations
815821
end
816822

817-
def ms_primary_key_method(options = nil)
818-
options ||= meilisearch_options
819-
options[:primary_key] || options[:id] || :id
820-
end
821-
822823
def ms_primary_key_of(doc, options = nil)
823824
doc.send(ms_primary_key_method(options)).to_s
824825
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
require_relative 'multi_search/result'
2+
3+
module MeiliSearch
4+
module Rails
5+
class << self
6+
def multi_search(searches)
7+
search_parameters = searches.map do |(index_target, options)|
8+
paginate(options) if pagination_enabled?
9+
normalize(options, index_target)
10+
end
11+
12+
MultiSearchResult.new(searches, client.multi_search(search_parameters))
13+
end
14+
15+
private
16+
17+
def normalize(options, index_target)
18+
options
19+
.except(:class_name)
20+
.merge!(index_uid: index_uid_from_target(index_target))
21+
end
22+
23+
def index_uid_from_target(index_target)
24+
case index_target
25+
when String, Symbol
26+
index_target
27+
else
28+
index_target.index.uid
29+
end
30+
end
31+
32+
def paginate(options)
33+
%w[page hitsPerPage hits_per_page].each do |key|
34+
# Deletes hitsPerPage to avoid passing along a meilisearch-ruby warning/exception
35+
value = options.delete(key) || options.delete(key.to_sym)
36+
options[key.underscore.to_sym] = value.to_i if value
37+
end
38+
39+
# It is required to activate the finite pagination in Meilisearch v0.30 (or newer),
40+
# to have at least `hits_per_page` defined or `page` in the search request.
41+
options[:page] ||= 1
42+
end
43+
44+
def pagination_enabled?
45+
MeiliSearch::Rails.configuration[:pagination_backend]
46+
end
47+
end
48+
end
49+
end
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
module MeiliSearch
2+
module Rails
3+
class MultiSearchResult
4+
attr_reader :metadata
5+
6+
def initialize(searches, raw_results)
7+
@results = {}
8+
@metadata = {}
9+
10+
searches.zip(raw_results['results']).each do |(index_target, search_options), result|
11+
index_target = search_options[:class_name].constantize if search_options[:class_name]
12+
13+
@results[index_target] = case index_target
14+
when String, Symbol
15+
result['hits']
16+
else
17+
load_results(index_target, result)
18+
end
19+
20+
@metadata[index_target] = result.except('hits')
21+
end
22+
end
23+
24+
include Enumerable
25+
26+
def each_hit(&block)
27+
@results.each do |_index_target, results|
28+
results.each(&block)
29+
end
30+
end
31+
alias each each_hit
32+
33+
def each_result
34+
@results.each
35+
end
36+
37+
def to_a
38+
@results.values.flatten(1)
39+
end
40+
alias to_ary to_a
41+
42+
def to_h
43+
@results
44+
end
45+
alias to_hash to_h
46+
47+
private
48+
49+
def load_results(klass, result)
50+
pk_method = klass.ms_primary_key_method
51+
pk_method = pk_method.in if Utilities.mongo_model?(klass)
52+
53+
condition_key = pk_is_virtual?(klass, pk_method) ? klass.primary_key : pk_method
54+
55+
hits_by_id =
56+
result['hits'].index_by { |hit| hit[condition_key.to_s] }
57+
58+
records = klass.where(condition_key => hits_by_id.keys)
59+
60+
if records.respond_to? :in_order_of
61+
records.in_order_of(condition_key, hits_by_id.keys).each do |record|
62+
record.formatted = hits_by_id[record.send(condition_key).to_s]['_formatted']
63+
end
64+
else
65+
results_by_id = records.index_by do |hit|
66+
hit.send(condition_key).to_s
67+
end
68+
69+
result['hits'].filter_map do |hit|
70+
record = results_by_id[hit[condition_key.to_s].to_s]
71+
record&.formatted = hit['_formatted']
72+
record
73+
end
74+
end
75+
end
76+
77+
def pk_is_virtual?(model_class, pk_method)
78+
model_class.columns
79+
.map(&(Utilities.sequel_model?(model_class) ? :to_s : :name))
80+
.exclude?(pk_method.to_s)
81+
end
82+
end
83+
end
84+
end

lib/meilisearch/rails/utilities.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ def indexable?(record, options)
4848
true
4949
end
5050

51+
def mongo_model?(model_class)
52+
defined?(::Mongoid::Document) && model_class.include?(::Mongoid::Document)
53+
end
54+
55+
def sequel_model?(model_class)
56+
defined?(::Sequel::Model) && model_class < Sequel::Model
57+
end
58+
5159
private
5260

5361
def constraint_passes?(record, constraint)

spec/multi_search/result_spec.rb

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
require 'spec_helper'
2+
3+
describe MeiliSearch::Rails::MultiSearchResult do # rubocop:todo RSpec/FilePath
4+
let(:raw_results) do
5+
{
6+
'results' => [
7+
{ 'indexUid' => 'books_index',
8+
'hits' => [{ 'name' => 'Steve Jobs', 'id' => '3', 'author' => 'Walter Isaacson', 'premium' => nil, 'released' => nil, 'genre' => nil }],
9+
'query' => 'Steve', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 1 },
10+
{ 'indexUid' => 'products_index',
11+
'hits' => [{ 'id' => '4', 'href' => 'ebay', 'name' => 'palm pixi plus' }],
12+
'query' => 'palm', 'processingTimeMs' => 0, 'limit' => 1, 'offset' => 0, 'estimatedTotalHits' => 2 },
13+
{ 'indexUid' => 'color_index',
14+
'hits' => [
15+
{ 'name' => 'black', 'id' => '5', 'short_name' => 'bla', 'hex' => 0 },
16+
{ 'name' => 'blue', 'id' => '4', 'short_name' => 'blu', 'hex' => 255 }
17+
],
18+
'query' => 'bl', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 2 }
19+
]
20+
}
21+
end
22+
23+
it 'is enumerable' do
24+
expect(described_class).to include(Enumerable)
25+
end
26+
27+
context 'with index name keys' do
28+
subject(:result) { described_class.new(searches, raw_results) }
29+
30+
let(:searches) do
31+
{
32+
'books_index' => { q: 'Steve' },
33+
'products_index' => { q: 'palm', limit: 1 },
34+
'color_index' => { q: 'bl' }
35+
}
36+
end
37+
38+
it 'enumerates through the hits' do
39+
expect(result).to contain_exactly(
40+
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs'),
41+
a_hash_including('name' => 'palm pixi plus'),
42+
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
43+
a_hash_including('name' => 'black', 'short_name' => 'bla')
44+
)
45+
end
46+
47+
it 'enumerates through the hits of each result with #each_result' do
48+
expect(result.each_result).to be_an(Enumerator)
49+
expect(result.each_result).to contain_exactly(
50+
['books_index', contain_exactly(
51+
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs')
52+
)],
53+
['products_index', contain_exactly(
54+
a_hash_including('name' => 'palm pixi plus')
55+
)],
56+
['color_index', contain_exactly(
57+
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
58+
a_hash_including('name' => 'black', 'short_name' => 'bla')
59+
)]
60+
)
61+
end
62+
63+
describe '#to_a' do
64+
it 'returns the hits' do
65+
expect(result.to_a).to contain_exactly(
66+
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs'),
67+
a_hash_including('name' => 'palm pixi plus'),
68+
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
69+
a_hash_including('name' => 'black', 'short_name' => 'bla')
70+
)
71+
end
72+
73+
it 'aliases as #to_ary' do
74+
expect(result.method(:to_ary).original_name).to eq :to_a
75+
end
76+
end
77+
78+
describe '#to_h' do
79+
it 'returns a hash of indexes and hits' do
80+
expect(result.to_h).to match(
81+
'books_index' => contain_exactly(
82+
a_hash_including('author' => 'Walter Isaacson', 'name' => 'Steve Jobs')
83+
),
84+
'products_index' => contain_exactly(
85+
a_hash_including('name' => 'palm pixi plus')
86+
),
87+
'color_index' => contain_exactly(
88+
a_hash_including('name' => 'blue', 'short_name' => 'blu'),
89+
a_hash_including('name' => 'black', 'short_name' => 'bla')
90+
)
91+
)
92+
end
93+
94+
it 'is aliased as #to_hash' do
95+
expect(result.method(:to_hash).original_name).to eq :to_h
96+
end
97+
end
98+
99+
describe '#metadata' do
100+
it 'returns search metadata for each result' do
101+
expect(result.metadata).to match(
102+
'books_index' => {
103+
'indexUid' => 'books_index',
104+
'query' => 'Steve', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 1
105+
},
106+
'products_index' => {
107+
'indexUid' => 'products_index',
108+
'query' => 'palm', 'processingTimeMs' => 0, 'limit' => 1, 'offset' => 0, 'estimatedTotalHits' => 2
109+
},
110+
'color_index' => {
111+
'indexUid' => 'color_index',
112+
'query' => 'bl', 'processingTimeMs' => 0, 'limit' => 20, 'offset' => 0, 'estimatedTotalHits' => 2
113+
}
114+
)
115+
end
116+
end
117+
end
118+
end

0 commit comments

Comments
 (0)