mongodb_meilisearch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.env +13 -0
- data/.idea/.gitignore +8 -0
- data/.idea/modules.xml +8 -0
- data/.rspec +3 -0
- data/.rubocop.yml +1859 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/CONTRIBUTORS.md +5 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +234 -0
- data/LICENSE.txt +557 -0
- data/README.md +435 -0
- data/Rakefile +12 -0
- data/lefthook.yml +18 -0
- data/lib/mongodb_meilisearch/version.rb +9 -0
- data/lib/mongodb_meilisearch.rb +3 -0
- data/lib/search/class_methods.rb +529 -0
- data/lib/search/client.rb +41 -0
- data/lib/search/instance_methods.rb +94 -0
- data/sig/mongodb_meilisearch.rbs +4 -0
- metadata +192 -0
@@ -0,0 +1,529 @@
|
|
1
|
+
module Search
|
2
|
+
module ClassMethods
|
3
|
+
# This module strives for sensible defaults, but you can override them with
|
4
|
+
# the following optional constants:
|
5
|
+
#
|
6
|
+
# * PRIMARY_SEARCH_KEY - a Symbol matching one of your attributes that is guaranteed unique
|
7
|
+
# * SEARCH_INDEX_NAME - a String - useful if you want to have records from
|
8
|
+
# multiple classes come back in the same search results.
|
9
|
+
# * CLASS_PREFIXED_SEARCH_IDS - boolean defaults to false. Set this to true if you've got
|
10
|
+
# multiple models in the same search index. This causes the `id` field to be `<ClassName>_<_id>` so a `Note` record might have an index of `"Note_64274543906b1d7d02c1fcc6"`
|
11
|
+
# * SEARCH_OPTIONS - a hash of key value pairs in js style
|
12
|
+
# - docs see https://www.meilisearch.com/docs/reference/api/search#search-parameters
|
13
|
+
# - example from https://github.com/meilisearch/meilisearch-ruby/blob/main/spec/meilisearch/index/search/multi_params_spec.rb
|
14
|
+
# {
|
15
|
+
# attributesToCrop: ['title'],
|
16
|
+
# cropLength: 2,
|
17
|
+
# filter: 'genre = adventure',
|
18
|
+
# attributesToHighlight: ['title'],
|
19
|
+
# limit: 2
|
20
|
+
# }
|
21
|
+
# * SEARCH_RANKING_RULES - an array of strings that correspond to meilisearch rules
|
22
|
+
# see https://www.meilisearch.com/docs/learn/core_concepts/relevancy#ranking-rules
|
23
|
+
# - you probably don't want to muck with this
|
24
|
+
#
|
25
|
+
# NOTE: It should be self-evident from the method names which ones modify state. As such
|
26
|
+
# this class uses ! to indicate synchronous methods and no ! to indicate async methods.
|
27
|
+
# You'll probably want to restrict your use of ! methods to the console or rake tasks.
|
28
|
+
|
29
|
+
# the default search ranking rules from https://www.meilisearch.com/docs/learn/core_concepts/relevancy#ranking-rules
|
30
|
+
MEILISEARCH_DEFAULT_SEARCH_RANKING_RULES = %w[
|
31
|
+
words
|
32
|
+
typo
|
33
|
+
proximity
|
34
|
+
attribute
|
35
|
+
sort
|
36
|
+
exactness
|
37
|
+
]
|
38
|
+
|
39
|
+
# Every MeiliSearch response has these keys
|
40
|
+
# we group them in a hash named search_result_metadata
|
41
|
+
MEILISEARCH_RESPONSE_METADATA_KEYS = %w[
|
42
|
+
query
|
43
|
+
processingTimeMs
|
44
|
+
limit
|
45
|
+
offset
|
46
|
+
estimatedTotalHits
|
47
|
+
nbHits
|
48
|
+
]
|
49
|
+
|
50
|
+
# The unique identifier for your document. This is almost
|
51
|
+
# always going to be :id but you can override it by setting
|
52
|
+
# the PRIMARY_SEARCH_KEY constant in your model.
|
53
|
+
def primary_search_key
|
54
|
+
# this is almost _never_ going to be defined
|
55
|
+
return :id unless constants.include?(:PRIMARY_SEARCH_KEY)
|
56
|
+
const_get(:PRIMARY_SEARCH_KEY)
|
57
|
+
end
|
58
|
+
|
59
|
+
# By default each class has its own search index which is the class name run through `.underscore`
|
60
|
+
# For example MyClass would become my_class.
|
61
|
+
# If you'd like to search across multiple classes define a custom SEARCH_INDEX_NAME constant
|
62
|
+
# and have all the classes you'd like to search across use the same value.
|
63
|
+
# @return [String] the name of the search index for this class
|
64
|
+
def search_index_name
|
65
|
+
class_index_name = constants.include?(:SEARCH_INDEX_NAME) \
|
66
|
+
? const_get(:SEARCH_INDEX_NAME) \
|
67
|
+
: name.to_s.underscore
|
68
|
+
raise "Invalid search index name for #{self.class.name}: \"#{custom_name}\"" if class_index_name.blank?
|
69
|
+
class_index_name
|
70
|
+
end
|
71
|
+
|
72
|
+
# @return [MeiliSearch::Index] the search index for this class
|
73
|
+
def search_index
|
74
|
+
Search::Client.instance.index(search_index_name)
|
75
|
+
end
|
76
|
+
|
77
|
+
# MeiliSearch allows you to define the ranking of search results. Alas, this is not based
|
78
|
+
# on which attribute, but criteria about match quality. See this page
|
79
|
+
# for more details
|
80
|
+
# https://www.meilisearch.com/docs/learn/core_concepts/relevancy#ranking-rules
|
81
|
+
#
|
82
|
+
# You can override this by defining a SEARCH_RANKING_RULES constant on your class
|
83
|
+
# that returns an array of strings that correspond to meilisearch rules.
|
84
|
+
#
|
85
|
+
# @return [Array[String]] an array of strings that correspond to meilisearch rules
|
86
|
+
def search_ranking_rules
|
87
|
+
# this is rarely going to be defined
|
88
|
+
return const_get(:SEARCH_RANKING_RULES) if constants.include?(:SEARCH_RANKING_RULES)
|
89
|
+
MEILISEARCH_DEFAULT_SEARCH_RANKING_RULES
|
90
|
+
end
|
91
|
+
|
92
|
+
# @param [String] search_string - what you're searching for
|
93
|
+
# @param [options] options - configuration options for meilisearch
|
94
|
+
# See https://www.meilisearch.com/docs/reference/api/search#search-parameters
|
95
|
+
# Note that without specifying any options you'll get ALL the matching objects
|
96
|
+
# @return [Hash] raw results directly from meilisearch-ruby gem
|
97
|
+
# This is a hash with paging information and more.
|
98
|
+
def raw_search(search_string, options = search_options)
|
99
|
+
index = search_index
|
100
|
+
index.search(search_string, options)
|
101
|
+
end
|
102
|
+
|
103
|
+
# @param search_string [String] - what you're searching for
|
104
|
+
# @param options [Hash] - configuration options for meilisearch
|
105
|
+
# See https://www.meilisearch.com/docs/reference/api/search#search-parameters
|
106
|
+
# Note that without specifying any options you'll get ALL the matching objects
|
107
|
+
# @param options [Hash] - see .search_options
|
108
|
+
# @param ids_only [Boolean] - if true returns only the IDs of matching records
|
109
|
+
# @param filtered_by_class [Boolean] - defaults to filtering results by the class
|
110
|
+
# your searching from. Ex. Foo.search("something") it will
|
111
|
+
# have Meilisearch filter on records where `object_class` == `"Foo"`
|
112
|
+
# @return [Hash[String, [Array[String | Document]] a hash with keys corresponding
|
113
|
+
# to the classes of objects returned, and a value of an array of ids or
|
114
|
+
# Mongoid::Document objects sorted by match strength. It will ALSO have a key named
|
115
|
+
# search_result_metadata which contains a hash with the following information
|
116
|
+
# from Meilisearch:
|
117
|
+
# - query [String]
|
118
|
+
# - processingTimeMs [Integer]
|
119
|
+
# - limit [Integer]
|
120
|
+
# - offset [Integer]
|
121
|
+
# - estimatedTotalHits [Integer]
|
122
|
+
# - nbHits [Integer]
|
123
|
+
#
|
124
|
+
# @example
|
125
|
+
# Note.search("foo bar") # returns Notes
|
126
|
+
# Note.search("foo bar", filtered_by_class: false) # returns Notes & Tasks
|
127
|
+
#
|
128
|
+
#
|
129
|
+
def search(search_string,
|
130
|
+
options: search_options,
|
131
|
+
ids_only: false,
|
132
|
+
filtered_by_class: true,
|
133
|
+
include_metadata: true)
|
134
|
+
pk = primary_search_key
|
135
|
+
|
136
|
+
# TODO: break this if/else out into a separate function
|
137
|
+
if ids_only
|
138
|
+
options.merge!({attributesToRetrieve: [pk.to_s]})
|
139
|
+
else
|
140
|
+
# Don't care what you add, but we need the primary key and object_class
|
141
|
+
options[:attributesToRetrieve] = [] unless options.has_key?(:attributesToRetrieve)
|
142
|
+
[pk.to_s, "object_class"].each do |attr|
|
143
|
+
unless options[:attributesToRetrieve].include?(attr)
|
144
|
+
options[:attributesToRetrieve] << attr
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
filter_search_options_by_class!(options) if filtered_by_class
|
150
|
+
|
151
|
+
# response looks like this
|
152
|
+
# {
|
153
|
+
# "hits" => [{
|
154
|
+
# "id" => 1, # or your primary key if something custom
|
155
|
+
# "object_class" => "MyClass",
|
156
|
+
# "original_document_id" => "6436ac83906b1d2f6b3b1383"
|
157
|
+
# }],
|
158
|
+
# "offset" => 0,
|
159
|
+
# "limit" => 20,
|
160
|
+
# "processingTimeMs" => 1,
|
161
|
+
# "query" => "carlo"
|
162
|
+
# }
|
163
|
+
response = raw_search(search_string, options)
|
164
|
+
|
165
|
+
# matching_ids is coming from the id field by default,
|
166
|
+
# but would be from your custom primary key if you defined that.
|
167
|
+
#
|
168
|
+
# IF you're not sharing an index with multiple models,
|
169
|
+
# and thus has_prefixed_class_ids? is false
|
170
|
+
# these would NOT be prefixed with the class name
|
171
|
+
#
|
172
|
+
# results =
|
173
|
+
# {
|
174
|
+
# "matching_ids" => ["FooModel_1234",
|
175
|
+
# "BarModel_1234",
|
176
|
+
# "FooModel_2345",
|
177
|
+
# "FooModel_3456"],
|
178
|
+
# "object_classes" => ["FooModel",
|
179
|
+
# "BarModel",
|
180
|
+
# "FooModel",
|
181
|
+
# "FooModel"]
|
182
|
+
#
|
183
|
+
# # want ids grouped by class so that we can
|
184
|
+
# # limit MongoDB interactions to 1 query per class
|
185
|
+
# # instead of one per id returned.
|
186
|
+
# "matches_by_class" => {
|
187
|
+
# "FooModel" => [<array of ids not class prefixed>],
|
188
|
+
# "BarModel" => [<array of ids not class prefixed>]
|
189
|
+
# }
|
190
|
+
# }
|
191
|
+
results = extract_ordered_ids_from_hits(response["hits"], pk, ids_only)
|
192
|
+
|
193
|
+
if response["hits"].size == 0 || ids_only
|
194
|
+
# TODO change it to `matches` from `matching_ids`
|
195
|
+
matches = {"matches" => results["matching_ids"]}
|
196
|
+
if include_metadata
|
197
|
+
return merge_results_with_metadata(matches, response)
|
198
|
+
else
|
199
|
+
return matches
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# I see you'd like some objects. Okeydokey.
|
204
|
+
|
205
|
+
populate_results_from_ids!(
|
206
|
+
results,
|
207
|
+
# Meilisearch doesn't like a primary key
|
208
|
+
# of _id but Mongoid wants _id
|
209
|
+
((pk == :id) ? :_id : pk)
|
210
|
+
)
|
211
|
+
|
212
|
+
only_matches = results.slice("matches")
|
213
|
+
|
214
|
+
if include_metadata
|
215
|
+
merge_results_with_metadata(only_matches, response)
|
216
|
+
else
|
217
|
+
only_matches
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# adds a filter to the options to restrict results to those with
|
222
|
+
# a matching object_class field. This is useful when you have multiple
|
223
|
+
# model's records in the same index and want to restrict results to just one class.
|
224
|
+
def filter_search_options_by_class!(options)
|
225
|
+
class_filter_string = "object_class = #{name}"
|
226
|
+
if options.has_key?(:filter) && options[:filter].is_a?(Array)
|
227
|
+
options[:filter] << class_filter_string
|
228
|
+
elsif options.has_key?(:filter) && options[:filter].is_a?(String)
|
229
|
+
options[:filter] += " AND #{class_filter_string}"
|
230
|
+
else
|
231
|
+
options.merge!({filter: [class_filter_string]})
|
232
|
+
end
|
233
|
+
|
234
|
+
options
|
235
|
+
end
|
236
|
+
|
237
|
+
# Primarily used internally, this alamost always returns an empty hash.
|
238
|
+
# @return [Hash] - a hash of options to pass to meilisearch-ruby gem
|
239
|
+
# see the documentation of SEARH_OPTIONS at the top of this file.
|
240
|
+
def search_options
|
241
|
+
# this is rarely going to be defined
|
242
|
+
return {} unless constants.include?(:SEARCH_OPTIONS)
|
243
|
+
options = const_get(:SEARCH_OPTIONS)
|
244
|
+
raise "SEARCH_OPTIONS must be a hash" unless options.is_a? Hash
|
245
|
+
options
|
246
|
+
end
|
247
|
+
|
248
|
+
# @param [Array[Hash]] updated_documents - array of document hashes
|
249
|
+
# - each document hash is presumed to have been created by way of
|
250
|
+
# search_indexable_hash
|
251
|
+
def update_documents(updated_documents, async: true)
|
252
|
+
if async
|
253
|
+
search_index.update_documents(updated_documents, primary_search_key)
|
254
|
+
else
|
255
|
+
search_index.update_documents!(updated_documents, primary_search_key)
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
# @param [Array[Hash]] updated_documents - array of document hashes
|
260
|
+
# - each document hash is presumed to have been created by way of
|
261
|
+
# search_indexable_hash
|
262
|
+
def add_documents(new_documents, async: true)
|
263
|
+
if async
|
264
|
+
search_index.add_documents(new_documents, primary_search_key)
|
265
|
+
else
|
266
|
+
search_index.add_documents!(new_documents, primary_search_key)
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
# Adds all documents in the collection to the search index
|
271
|
+
def add_all_to_search(async: true)
|
272
|
+
add_documents(
|
273
|
+
all.map { |x| x.search_indexable_hash },
|
274
|
+
async: async
|
275
|
+
)
|
276
|
+
end
|
277
|
+
|
278
|
+
# A convenience method to add all documents in the collection to the search index
|
279
|
+
# synchronously
|
280
|
+
def add_all_to_search!
|
281
|
+
add_all_to_search(async: false)
|
282
|
+
end
|
283
|
+
|
284
|
+
# A convenience method that wraps MeiliSearch::Index#stats
|
285
|
+
# See https://www.meilisearch.com/docs/reference/api/stats for more info
|
286
|
+
def search_stats
|
287
|
+
search_index.stats
|
288
|
+
end
|
289
|
+
|
290
|
+
# @return [Integer] the number of documents in the search index
|
291
|
+
# as reported via stats.
|
292
|
+
# See https://www.meilisearch.com/docs/reference/api/stats for more info
|
293
|
+
def searchable_documents
|
294
|
+
search_index.number_of_documents
|
295
|
+
end
|
296
|
+
|
297
|
+
# @return [Boolean] indicating if search ids should be prefixed with the class name
|
298
|
+
# This is controlled by the CLASS_PREFIXED_SEARCH_IDS constant
|
299
|
+
# and defaults to false.
|
300
|
+
def has_class_prefixed_search_ids?
|
301
|
+
return false unless constants.include?(:CLASS_PREFIXED_SEARCH_IDS)
|
302
|
+
!!const_get(:CLASS_PREFIXED_SEARCH_IDS)
|
303
|
+
end
|
304
|
+
|
305
|
+
# DANGER!!!!
|
306
|
+
# Deletes the entire index from Meilisearch. If you think
|
307
|
+
# you should use this, you're probably mistaken.
|
308
|
+
# @warning this will delete the index and all documents in it
|
309
|
+
def delete_index!
|
310
|
+
search_index.delete_index
|
311
|
+
end
|
312
|
+
|
313
|
+
# Asynchronously deletes all documents from the search index
|
314
|
+
# regardless of what model they're associated with.
|
315
|
+
def delete_all_documents
|
316
|
+
search_index.delete_all_documents
|
317
|
+
end
|
318
|
+
|
319
|
+
# Synchronously deletes all documents from the search index
|
320
|
+
# regardless of what model they're associated with.
|
321
|
+
def delete_all_documents!
|
322
|
+
search_index.delete_all_documents!
|
323
|
+
end
|
324
|
+
|
325
|
+
# Asynchronously delete & reindex all instances of this class.
|
326
|
+
# Note that this will first attempt to _synchronously_
|
327
|
+
# delete all records from the search index and raise an exception
|
328
|
+
# if that failse.
|
329
|
+
def reindex
|
330
|
+
reindex_core(async: true)
|
331
|
+
end
|
332
|
+
|
333
|
+
# Synchronously delete & reindex all instances of this class
|
334
|
+
def reindex!
|
335
|
+
reindex_core(async: false)
|
336
|
+
end
|
337
|
+
|
338
|
+
# The default list of searchable attributes.
|
339
|
+
# Please don't override this. Define SEARCHABLE_ATTRIBUTES instead.
|
340
|
+
# @return [Array[Symbol]] an array of attribute names as symbols
|
341
|
+
def default_searchable_attributes
|
342
|
+
attribute_names.map { |n| n.to_sym }
|
343
|
+
end
|
344
|
+
|
345
|
+
# This returns the list from SEARCHABLE_ATTRIBUTES if defined,
|
346
|
+
# or the list from default_searchable_attributes
|
347
|
+
# @return [Array[Symbol]] an array symbols corresponding to
|
348
|
+
# attribute or method names.
|
349
|
+
def searchable_attributes
|
350
|
+
if constants.include?(:SEARCHABLE_ATTRIBUTES)
|
351
|
+
return const_get(:SEARCHABLE_ATTRIBUTES).map(&:to_sym)
|
352
|
+
end
|
353
|
+
default_searchable_attributes
|
354
|
+
end
|
355
|
+
|
356
|
+
# indicates if this class is unfilterable.
|
357
|
+
# Defaults to false, but can be overridden by defining
|
358
|
+
# UNFILTERABLE_IN_SEARCH to be true.
|
359
|
+
# @return [Boolean] true if this class is unfilterable
|
360
|
+
def unfilterable?
|
361
|
+
# this is almost _never_ going to be true
|
362
|
+
return false unless constants.include?(:UNFILTERABLE_IN_SEARCH)
|
363
|
+
!!const_get(:UNFILTERABLE_IN_SEARCH)
|
364
|
+
end
|
365
|
+
|
366
|
+
# The list of filterable attributes. By default this
|
367
|
+
# is the same as searchable_attributes. If you want to
|
368
|
+
# restrict filtering to a subset of searchable attributes
|
369
|
+
# you can define FILTERABLE_ATTRIBUTE_NAMES as an array
|
370
|
+
# of symbols corresponding to attribute names.
|
371
|
+
# `object_class` will _always_ be filterable.
|
372
|
+
#
|
373
|
+
# @return [Array[Symbol]] an array of symbols corresponding to
|
374
|
+
# filterable attribute names.
|
375
|
+
def filterable_attributes
|
376
|
+
attributes = []
|
377
|
+
if constants.include?(:FILTERABLE_ATTRIBUTE_NAMES)
|
378
|
+
# the union operator is to guarantee no-one tries to create
|
379
|
+
# invalid filterable attributes
|
380
|
+
attributes = const_get(:FILTERABLE_ATTRIBUTE_NAMES).map(&:to_sym) & searchable_attributes
|
381
|
+
elsif !unfilterable?
|
382
|
+
attributes = searchable_attributes
|
383
|
+
end
|
384
|
+
attributes << "object_class" unless attributes.include? "object_class"
|
385
|
+
attributes
|
386
|
+
end
|
387
|
+
|
388
|
+
# Updates the filterable attributes in the search index.
|
389
|
+
# Note that this forces Meilisearch to rebuild your index,
|
390
|
+
# which may take time. Best to run this in a background job
|
391
|
+
# for large datasets.
|
392
|
+
def set_filterable_attributes!(new_attributes = filterable_attributes)
|
393
|
+
search_index.update_filterable_attributes(new_attributes)
|
394
|
+
end
|
395
|
+
|
396
|
+
private
|
397
|
+
|
398
|
+
def lookup_matches_by_class!(results, primary_key)
|
399
|
+
# matches_by_class contains a hash of
|
400
|
+
# "FooModel" => [<array of ids>]
|
401
|
+
#
|
402
|
+
# when we exit it will be
|
403
|
+
# "FooModel" => { "id123" => #FooModel<_id: 123> }
|
404
|
+
results["matches_by_class"].each do |klass, ids|
|
405
|
+
results["matches_by_class"][klass] = Hash[* klass
|
406
|
+
.constantize
|
407
|
+
.in(primary_key => ids)
|
408
|
+
.to_a
|
409
|
+
.map { |obj| [obj.send(primary_key).to_s, obj] }
|
410
|
+
.flatten ]
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def populate_results_from_ids!(results, primary_key)
|
415
|
+
lookup_matches_by_class!(results, primary_key)
|
416
|
+
# ^^^
|
417
|
+
# results["matches_by_class"] is now a hash with
|
418
|
+
# class names as keys, and values of hashes with
|
419
|
+
# id => instance object
|
420
|
+
# "FooModel" => { "id123" => #FooModel<_id: 123> }
|
421
|
+
|
422
|
+
# this will be an array of Mongoid::Document
|
423
|
+
# objects
|
424
|
+
results["matches"] = []
|
425
|
+
|
426
|
+
# ok we now have
|
427
|
+
# "matches_by_class" = { "Note" => {id => obj}}
|
428
|
+
# but possibly with many classes.
|
429
|
+
# we NEED matches in the same order as "matching_ids"
|
430
|
+
# because that's the order Meilisearch felt best matched your query
|
431
|
+
#
|
432
|
+
results["matching_ids"].each do |data|
|
433
|
+
klass = data[:klass]
|
434
|
+
original_id = data[:original_id]
|
435
|
+
|
436
|
+
maybe_obj = results["matches_by_class"][klass][original_id]
|
437
|
+
|
438
|
+
results["matches"] << maybe_obj if maybe_obj
|
439
|
+
end
|
440
|
+
|
441
|
+
results
|
442
|
+
end
|
443
|
+
|
444
|
+
def merge_results_with_metadata(results, response)
|
445
|
+
results.merge(
|
446
|
+
{
|
447
|
+
"search_result_metadata" => extract_metadata_from_search_results(response)
|
448
|
+
}
|
449
|
+
)
|
450
|
+
end
|
451
|
+
|
452
|
+
def extract_metadata_from_search_results(result)
|
453
|
+
result.slice(* MEILISEARCH_RESPONSE_METADATA_KEYS)
|
454
|
+
end
|
455
|
+
|
456
|
+
# @returns [Hash[String, Array[String]]] - a hash with class name as the key
|
457
|
+
# and an array of ids as the value -> ClassName -> [id, id, id]
|
458
|
+
def extract_ordered_ids_from_hits(hits, primary_key, ids_only)
|
459
|
+
# TODO better name than matching_ids
|
460
|
+
# also change in other places in this file
|
461
|
+
response = {"matching_ids" => []}
|
462
|
+
unless ids_only
|
463
|
+
response["matches_by_class"] = {}
|
464
|
+
end
|
465
|
+
string_primary_key = primary_key.to_s
|
466
|
+
return response if hits.empty?
|
467
|
+
|
468
|
+
hits.each do |hit|
|
469
|
+
hit_id = hit[string_primary_key]
|
470
|
+
if ids_only
|
471
|
+
response["matching_ids"] << hit_id
|
472
|
+
else
|
473
|
+
object_class = hit["object_class"]
|
474
|
+
match_info = {
|
475
|
+
id: hit_id,
|
476
|
+
klass: object_class
|
477
|
+
}
|
478
|
+
|
479
|
+
original_id = if !has_class_prefixed_search_ids?
|
480
|
+
hit_id
|
481
|
+
elsif hit.has_key?("original_document_id")
|
482
|
+
# DOES have class prefixed search ids
|
483
|
+
hit["original_document_id"]
|
484
|
+
else
|
485
|
+
# DOES have class prefixed search ids but
|
486
|
+
# DOES NOT have original_document_id
|
487
|
+
# This is only possible if they've overridden #search_indexable_hash
|
488
|
+
hit_id.sub(/^\w+_/, "")
|
489
|
+
end
|
490
|
+
|
491
|
+
match_info[:original_id] = original_id
|
492
|
+
|
493
|
+
# set up a default array to add to
|
494
|
+
unless response["matches_by_class"].has_key?(object_class)
|
495
|
+
response["matches_by_class"][object_class] = []
|
496
|
+
end
|
497
|
+
response["matches_by_class"][object_class] << original_id
|
498
|
+
response["matching_ids"] << match_info
|
499
|
+
end
|
500
|
+
end
|
501
|
+
|
502
|
+
response
|
503
|
+
end
|
504
|
+
|
505
|
+
def validate_documents(documents)
|
506
|
+
return true if documents.all? { |x| x.has_key?("object_class") }
|
507
|
+
raise "All searchable documents must define object_class"
|
508
|
+
end
|
509
|
+
|
510
|
+
def reindex_core(async: true)
|
511
|
+
# no point in continuing if this fails...
|
512
|
+
delete_all_documents!
|
513
|
+
|
514
|
+
# this conveniently lines up with the batch size of 100
|
515
|
+
# that Mongoid gives us
|
516
|
+
documents = []
|
517
|
+
all.each do |r|
|
518
|
+
documents << r.search_indexable_hash
|
519
|
+
if documents.size == 100
|
520
|
+
add_documents(documents, async: async)
|
521
|
+
documents = []
|
522
|
+
end
|
523
|
+
end
|
524
|
+
add_documents(documents, async: async) if documents.size != 0
|
525
|
+
|
526
|
+
set_filterable_attributes!
|
527
|
+
end
|
528
|
+
end
|
529
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "singleton"
|
2
|
+
|
3
|
+
module Search
|
4
|
+
class Client
|
5
|
+
include Singleton
|
6
|
+
attr_reader :client
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
if ENV.fetch("SEARCH_ENABLED", "true") == "true"
|
10
|
+
url = ENV.fetch("MEILISEARCH_URL")
|
11
|
+
api_key = ENV.fetch("MEILISEARCH_API_KEY")
|
12
|
+
timeout = ENV.fetch("MEILISEARCH_TIMEOUT", 10).to_i
|
13
|
+
max_retries = ENV.fetch("MEILISEARCH_MAX_RETRIES", 2).to_i
|
14
|
+
if url.present? && api_key.present?
|
15
|
+
@client = MeiliSearch::Client.new(url, api_key,
|
16
|
+
timeout: timeout,
|
17
|
+
max_retries: max_retries)
|
18
|
+
else
|
19
|
+
Rails.logger.warn("UNABLE TO CONFIGURE SEARCH. Check env vars.")
|
20
|
+
@client = nil
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def enabled?
|
26
|
+
!@client.nil?
|
27
|
+
end
|
28
|
+
|
29
|
+
def method_missing(m, *args, &block)
|
30
|
+
if @client.respond_to? m.to_sym
|
31
|
+
@client.send(m, *args, &block)
|
32
|
+
else
|
33
|
+
raise ArgumentError.new("Method `#{m}` doesn't exist.")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def respond_to_missing?(method_name, include_private = false)
|
38
|
+
@client.respond_to?(method_name.to_sym) || super
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module Search
|
2
|
+
module InstanceMethods
|
3
|
+
# Adds this record to the search index asynchronously
|
4
|
+
def add_to_search
|
5
|
+
search_index.add_documents(
|
6
|
+
[search_indexable_hash],
|
7
|
+
primary_search_key.to_s
|
8
|
+
)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Adds this record to the search index synchronously
|
12
|
+
def add_to_search!
|
13
|
+
index = search_index
|
14
|
+
documents = [search_indexable_hash]
|
15
|
+
pk = primary_search_key.to_s
|
16
|
+
index.add_documents!(documents, pk)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Updates this record in the search index asynchronously
|
20
|
+
def update_in_search
|
21
|
+
search_index.update_documents(
|
22
|
+
[search_indexable_hash],
|
23
|
+
primary_search_key
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Updates this record in the search index synchronously
|
28
|
+
def update_in_search!
|
29
|
+
search_index.update_documents!(
|
30
|
+
[search_indexable_hash],
|
31
|
+
primary_search_key
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Removes this record from the search asynchronously
|
36
|
+
def remove_from_search
|
37
|
+
search_index.delete_document(send(primary_search_key).to_s)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Removes this record from the search synchronously
|
41
|
+
def remove_from_search!
|
42
|
+
search_index.delete_document!(send(primary_search_key).to_s)
|
43
|
+
end
|
44
|
+
|
45
|
+
# returns a hash of all the attributes
|
46
|
+
# if searchable_attributes method is defined
|
47
|
+
# it is assumed to return a list of symbols
|
48
|
+
# indicating the things that should be searched
|
49
|
+
# the symbol will be used as the name of the key in the search index
|
50
|
+
# If CLASS_PREFIXED_SEARCH_IDS == true then an `"original_document_id"`
|
51
|
+
# key will be added with the value of `_id.to_s`
|
52
|
+
#
|
53
|
+
# An `"object_class"` key will _also_ be added with the value of `class.name`
|
54
|
+
# _unless_ one is already defined. This gem relies on "object_class" being present
|
55
|
+
# in returned results
|
56
|
+
def search_indexable_hash
|
57
|
+
klass = self.class
|
58
|
+
hash = attributes
|
59
|
+
.to_h
|
60
|
+
.slice(* klass.searchable_attributes.map { |a| a.to_s })
|
61
|
+
|
62
|
+
# Meilisearch doesn't like a primary key of _id
|
63
|
+
# but Mongoid ids are _id
|
64
|
+
# BUT you might ALSO have an id attribute because you're
|
65
|
+
# massochistic. Sheesh. Don't make your own life so hard.
|
66
|
+
if hash.has_key?("_id") && !hash.has_key?("id")
|
67
|
+
id = hash.delete("_id").to_s
|
68
|
+
new_id = (!klass.has_class_prefixed_search_ids?) ? id : "#{self.class.name}_#{id}"
|
69
|
+
hash["id"] = new_id
|
70
|
+
elsif hash.has_key?("id") && !hash[id].is_a?(String)
|
71
|
+
# this is mostly in case it's a BSON::ObjectId
|
72
|
+
hash["id"] = hash["id"].to_s
|
73
|
+
elsif !hash.has_key?("id")
|
74
|
+
hash["id"] = self.id.to_s
|
75
|
+
end
|
76
|
+
|
77
|
+
hash["object_class"] = klass.name unless hash.has_key?("object_class")
|
78
|
+
hash["original_document_id"] = _id.to_s if klass.has_class_prefixed_search_ids?
|
79
|
+
hash
|
80
|
+
end
|
81
|
+
|
82
|
+
# A convenience method to ease accessing the search index
|
83
|
+
# from the ClassMethods
|
84
|
+
def search_index
|
85
|
+
self.class.search_index
|
86
|
+
end
|
87
|
+
|
88
|
+
# A convenience method to ease accessing the primary search key
|
89
|
+
# from the ClassMethods
|
90
|
+
def primary_search_key
|
91
|
+
self.class.primary_search_key
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|