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.
@@ -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
@@ -0,0 +1,4 @@
1
+ module MongodbMeilisearch
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end