mongodb_meilisearch 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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