Skip to content

Commit a35652c

Browse files
Merge #393
393: Add federated search r=ellnix a=ellnix # Pull Request ## Related issue Continues on top of #384 Fixes #389 I've got the general idea of the implementation, what's left is: - [x] Finish writing tests - [x] Add to README - [ ] Refactor (especially warnings and error handling) - [ ] Investigate pagination Ready for review Co-authored-by: ellnix <[email protected]>
2 parents 31b9eaf + 8cb1a8d commit a35652c

File tree

9 files changed

+604
-14
lines changed

9 files changed

+604
-14
lines changed

.rubocop_todo.yml

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This configuration was generated by
22
# `rubocop --auto-gen-config`
3-
# on 2025-03-14 19:22:00 UTC using RuboCop version 1.27.0.
3+
# on 2025-03-24 19:18:19 UTC using RuboCop version 1.27.0.
44
# The point is for the user to remove these configuration records
55
# one by one as the offenses are removed from the code base.
66
# Note that changes in the inspected code, or installation of new
@@ -66,7 +66,7 @@ Lint/UnusedMethodArgument:
6666
Exclude:
6767
- 'lib/meilisearch-rails.rb'
6868

69-
# Offense count: 13
69+
# Offense count: 15
7070
# Configuration parameters: IgnoredMethods, CountRepeatedAttributes.
7171
Metrics/AbcSize:
7272
Max: 105
@@ -82,12 +82,12 @@ Metrics/BlockLength:
8282
Metrics/ClassLength:
8383
Max: 171
8484

85-
# Offense count: 9
85+
# Offense count: 11
8686
# Configuration parameters: IgnoredMethods.
8787
Metrics/CyclomaticComplexity:
8888
Max: 28
8989

90-
# Offense count: 20
90+
# Offense count: 23
9191
# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
9292
Metrics/MethodLength:
9393
Max: 102
@@ -97,7 +97,7 @@ Metrics/MethodLength:
9797
Metrics/ModuleLength:
9898
Max: 437
9999

100-
# Offense count: 8
100+
# Offense count: 9
101101
# Configuration parameters: IgnoredMethods.
102102
Metrics/PerceivedComplexity:
103103
Max: 35
@@ -139,7 +139,7 @@ RSpec/ContextWording:
139139
- 'spec/options_spec.rb'
140140
- 'spec/system/tech_shop_spec.rb'
141141

142-
# Offense count: 61
142+
# Offense count: 72
143143
# Configuration parameters: CountAsOne.
144144
RSpec/ExampleLength:
145145
Max: 16
@@ -177,11 +177,12 @@ RSpec/VerifiedDoubles:
177177
Exclude:
178178
- 'spec/configuration_spec.rb'
179179

180-
# Offense count: 3
180+
# Offense count: 6
181181
# Configuration parameters: ForbiddenMethods, AllowedMethods.
182182
# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
183183
Rails/SkipsModelValidations:
184184
Exclude:
185+
- 'spec/federated_search_spec.rb'
185186
- 'spec/multi_search_spec.rb'
186187

187188
# Offense count: 2
@@ -240,7 +241,7 @@ Style/StringLiterals:
240241
Exclude:
241242
- 'spec/ms_clean_up_job_spec.rb'
242243

243-
# Offense count: 16
244+
# Offense count: 20
244245
# This cop supports safe auto-correction (--auto-correct).
245246
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
246247
# URISchemes: http, https

README.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
- [⚙️ Settings](#️-settings)
3939
- [🔍 Custom search](#-custom-search)
4040
- [🔍🔍 Multi search](#-multi-search)
41+
- [🔍🔍 Federated search](#-federated-search)
4142
- [🪛 Options](#-options)
4243
- [Meilisearch configuration & environment](#meilisearch-configuration--environment)
4344
- [Pagination with `kaminari` or `will_paginate`](#backend-pagination-with-kaminari-or-will_paginate-)
@@ -323,6 +324,172 @@ But this has been deprecated in favor of **federated search**.
323324

324325
See the [official multi search documentation](https://www.meilisearch.com/docs/reference/api/multi_search).
325326

327+
## 🔍🔍 Federated search
328+
329+
Federated search is similar to multi search, except that results are not grouped but sorted by ranking rules.
330+
331+
```ruby
332+
results = Meilisearch::Rails.federated_search(
333+
queries: [
334+
{ q: 'Harry', scope: Book.all },
335+
{ q: 'Attack on Titan', scope: Manga.all }
336+
]
337+
)
338+
```
339+
340+
An enumerable `FederatedSearchResult` is returned, which can be iterated through with `#each`:
341+
342+
```erb
343+
<ul>
344+
<% results.each do |record| %>
345+
<li><%= record.title %></li>
346+
<% end %>
347+
</ul>
348+
349+
350+
<ul>
351+
<!-- Attack on Titan appears first even though it was specified second,
352+
it's ranked higher because it's a closer match -->
353+
<li>Attack on Titan</li>
354+
<li>Harry Potter and the Philosopher's Stone</li>
355+
<li>Harry Potter and the Chamber of Secrets</li>
356+
</ul>
357+
```
358+
359+
The `queries` parameter may be a multi-search style hash with keys that are either classes, index names, or neither:
360+
361+
```ruby
362+
results = Meilisearch::Rails.federated_search(
363+
queries: {
364+
Book => { q: 'Harry' },
365+
Manga => { q: 'Attack on Titan' }
366+
}
367+
)
368+
```
369+
370+
```ruby
371+
results = Meilisearch::Rails.federated_search(
372+
queries: {
373+
'books_production' => { q: 'Harry', scope: Book.all },
374+
'mangas_production' => { q: 'Attack on Titan', scope: Manga.all }
375+
}
376+
)
377+
```
378+
379+
```ruby
380+
results = Meilisearch::Rails.federated_search(
381+
queries: {
382+
'potter' => { q: 'Harry', scope: Book.all, index_uid: 'books_production' },
383+
'titan' => { q: 'Attack on Titan', scope: Manga.all, index_uid: 'mangas_production' }
384+
}
385+
)
386+
```
387+
388+
### Loading records <!-- omit in toc -->
389+
390+
Records are loaded when the `:scope` option is passed (may be a model or a relation),
391+
or when a hash query is used with models as keys:
392+
393+
```ruby
394+
results = Meilisearch::Rails.federated_search(
395+
queries: [
396+
{ q: 'Harry', scope: Book },
397+
{ q: 'Attack on Titan', scope: Manga },
398+
]
399+
)
400+
```
401+
402+
```ruby
403+
results = Meilisearch::Rails.federated_search(
404+
queries: {
405+
Book => { q: 'Harry' },
406+
Manga => { q: 'Attack on Titan' }
407+
}
408+
)
409+
```
410+
411+
If the model is not provided, hashes are returned!
412+
413+
### Scoping records <!-- omit in toc -->
414+
415+
Any relation passed as `:scope` is used as the starting point when loading records:
416+
417+
```ruby
418+
results = Meilisearch::Rails.federated_search(
419+
queries: [
420+
{ q: 'Harry', scope: Book.where('year <= 2006') },
421+
{ q: 'Attack on Titan', scope: Manga.where(author: Author.find_by(name: 'Iseyama')) },
422+
]
423+
)
424+
```
425+
426+
### Specifying the search index <!-- omit in toc -->
427+
428+
In order of precedence, to figure out which index to search, Meilisearch Rails will check:
429+
430+
1. `index_uid` options
431+
```ruby
432+
results = Meilisearch::Rails.federated_search(
433+
queries: [
434+
# Searching the 'fantasy_books' index
435+
{ q: 'Harry', scope: Book, index_uid: 'fantasy_books' },
436+
]
437+
)
438+
```
439+
2. The index associated with the model
440+
```ruby
441+
results = Meilisearch::Rails.federated_search(
442+
queries: [
443+
# Searching the index associated with the Book model
444+
# i. e. Book.index.uid
445+
{ q: 'Harry', scope: Book },
446+
]
447+
)
448+
```
449+
3. The key when using hash queries
450+
```ruby
451+
results = Meilisearch::Rails.federated_search(
452+
queries: {
453+
# Searching index 'books_production'
454+
books_production: { q: 'Harry', scope: Book },
455+
}
456+
)
457+
```
458+
459+
### Pagination and other options <!-- omit in toc -->
460+
461+
In addition to queries, federated search also accepts `:federation` parameters which allow for finer control of the search:
462+
463+
```ruby
464+
results = Meilisearch::Rails.federated_search(
465+
queries: [
466+
{ q: 'Harry', scope: Book },
467+
{ q: 'Attack on Titan', scope: Manga },
468+
],
469+
federation: { offset: 10, limit: 5 }
470+
)
471+
```
472+
See a full list of accepted options in [the meilisearch documentation](https://www.meilisearch.com/docs/reference/api/multi_search#federation).
473+
474+
#### Metadata <!-- omit in toc -->
475+
476+
The returned result from a federated search includes a `.metadata` attribute you can use to access everything other than the search hits:
477+
478+
```ruby
479+
result.metadata
480+
# {
481+
# "processingTimeMs" => 0,
482+
# "limit" => 20,
483+
# "offset" => 0,
484+
# "estimatedTotalHits" => 2,
485+
# "semanticHitCount": 0
486+
# }
487+
```
488+
489+
The metadata contains facet stats and pagination stats, among others. See the full response in [the documentation](https://www.meilisearch.com/docs/reference/api/multi_search#federated-multi-search-requests).
490+
491+
More details on federated search (such as available `federation:` options) can be found on [the official multi search documentation](https://www.meilisearch.com/docs/reference/api/multi_search).
492+
326493
## 🪛 Options
327494

328495
### Meilisearch configuration & environment

lib/meilisearch/rails/multi_search.rb

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
require_relative 'multi_search/result'
1+
require_relative 'multi_search/multi_search_result'
2+
require_relative 'multi_search/federated_search_result'
23

34
module Meilisearch
45
module Rails
@@ -12,23 +13,60 @@ def multi_search(searches)
1213
normalize(options, index_target)
1314
end
1415

15-
MultiSearchResult.new(searches, client.multi_search(search_parameters))
16+
MultiSearchResult.new(searches, client.multi_search(queries: search_parameters))
17+
end
18+
19+
def federated_search(queries:, federation: {})
20+
if federation.nil?
21+
Meilisearch::Rails.logger.warn(
22+
'[meilisearch-rails] In federated_search, `nil` is an invalid `:federation` option. To explicitly use defaults, pass `{}`.'
23+
)
24+
25+
federation = {}
26+
end
27+
28+
queries.map! { |item| [nil, item] } if queries.is_a?(Array)
29+
30+
cleaned_queries = queries.filter_map do |(index_target, options)|
31+
model_class = options[:scope].respond_to?(:model) ? options[:scope].model : options[:scope]
32+
index_target = options.delete(:index_uid) || index_target || model_class
33+
34+
strip_pagination_options(options)
35+
normalize(options, index_target)
36+
end
37+
38+
raw_results = client.multi_search(queries: cleaned_queries, federation: federation)
39+
40+
FederatedSearchResult.new(queries, raw_results)
1641
end
1742

1843
private
1944

2045
def normalize(options, index_target)
46+
index_target = index_uid_from_target(index_target)
47+
48+
return nil if index_target.nil?
49+
2150
options
2251
.except(:class_name, :scope)
23-
.merge!(index_uid: index_uid_from_target(index_target))
52+
.merge!(index_uid: index_target)
2453
end
2554

2655
def index_uid_from_target(index_target)
2756
case index_target
2857
when String, Symbol
2958
index_target
30-
else
31-
index_target.index.uid
59+
when Class
60+
if index_target.respond_to?(:index)
61+
index_target.index.uid
62+
else
63+
Meilisearch::Rails.logger.warn <<~MODEL_NOT_INDEXED
64+
[meilisearch-rails] This class was passed to a multi/federated search but it does not have an #index: #{index_target}
65+
[meilisearch-rails] Are you sure it has a `meilisearch` block?
66+
MODEL_NOT_INDEXED
67+
68+
nil
69+
end
3270
end
3371
end
3472

@@ -44,6 +82,20 @@ def paginate(options)
4482
options[:page] ||= 1
4583
end
4684

85+
def strip_pagination_options(options)
86+
pagination_options = %w[page hitsPerPage hits_per_page limit offset].select do |key|
87+
options.delete(key) || options.delete(key.to_sym)
88+
end
89+
90+
return if pagination_options.empty?
91+
92+
Meilisearch::Rails.logger.warn <<~WRONG_PAGINATION
93+
[meilisearch-rails] Pagination options in federated search must apply to whole federation.
94+
[meilisearch-rails] These options have been removed: #{pagination_options.join(', ')}.
95+
[meilisearch-rails] Please pass them after queries, in the `federation:` option.
96+
WRONG_PAGINATION
97+
end
98+
4799
def pagination_enabled?
48100
Meilisearch::Rails.configuration[:pagination_backend]
49101
end

0 commit comments

Comments
 (0)