meilisearch-rails 0.1.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.
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ require 'rdoc/task'
5
+ Rake::RDocTask.new do |rdoc|
6
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
7
+
8
+ rdoc.rdoc_dir = 'rdoc'
9
+ rdoc.title = "MeiliSearch Rails #{version}"
10
+ rdoc.rdoc_files.include('README*')
11
+ rdoc.rdoc_files.include('lib/**/*.rb')
12
+ end
13
+
14
+ require "rspec/core/rake_task"
15
+ RSpec::Core::RakeTask.new(:spec)
16
+
17
+ task :default => :spec
@@ -0,0 +1,921 @@
1
+ require 'meilisearch'
2
+
3
+ require 'meilisearch/version'
4
+ require 'meilisearch/utilities'
5
+
6
+ if defined? Rails
7
+ begin
8
+ require 'meilisearch/railtie'
9
+ rescue LoadError
10
+ end
11
+ end
12
+
13
+ begin
14
+ require 'active_job'
15
+ rescue LoadError
16
+ # no queue support, fine
17
+ end
18
+
19
+ require 'logger'
20
+
21
+ module MeiliSearch
22
+
23
+ class NotConfigured < StandardError; end
24
+ class BadConfiguration < StandardError; end
25
+ class NoBlockGiven < StandardError; end
26
+
27
+ autoload :Configuration, 'meilisearch/configuration'
28
+ extend Configuration
29
+
30
+ autoload :Pagination, 'meilisearch/pagination'
31
+
32
+ class << self
33
+ attr_reader :included_in
34
+
35
+ def included(klass)
36
+ @included_in ||= []
37
+ @included_in << klass
38
+ @included_in.uniq!
39
+
40
+ klass.class_eval do
41
+ extend ClassMethods
42
+ include InstanceMethods
43
+ end
44
+ end
45
+
46
+ end
47
+
48
+ class IndexSettings
49
+ DEFAULT_BATCH_SIZE = 1000
50
+
51
+ DEFAULT_PRIMARY_KEY = 'id'
52
+
53
+ # MeiliSearch settings
54
+ OPTIONS = [
55
+ :searchableAttributes, :attributesForFaceting, :displayedAttributes, :distinctAttribute,
56
+ :synonyms, :stopWords, :rankingRules,
57
+ :attributesToHighlight,
58
+ :attributesToCrop, :cropLength
59
+ ]
60
+
61
+ OPTIONS.each do |k|
62
+ define_method k do |v|
63
+ instance_variable_set("@#{k}", v)
64
+ end
65
+ end
66
+
67
+ def initialize(options, &block)
68
+ @options = options
69
+ instance_exec(&block) if block_given?
70
+ end
71
+
72
+ def use_serializer(serializer)
73
+ @serializer = serializer
74
+ # instance_variable_set("@serializer", serializer)
75
+ end
76
+
77
+ def attribute(*names, &block)
78
+ raise ArgumentError.new('Cannot pass multiple attribute names if block given') if block_given? and names.length > 1
79
+ @attributes ||= {}
80
+ names.flatten.each do |name|
81
+ @attributes[name.to_s] = block_given? ? Proc.new { |d| d.instance_eval(&block) } : Proc.new { |d| d.send(name) }
82
+ end
83
+ end
84
+ alias :attributes :attribute
85
+
86
+ def add_attribute(*names, &block)
87
+ raise ArgumentError.new('Cannot pass multiple attribute names if block given') if block_given? and names.length > 1
88
+ @additional_attributes ||= {}
89
+ names.each do |name|
90
+ @additional_attributes[name.to_s] = block_given? ? Proc.new { |d| d.instance_eval(&block) } : Proc.new { |d| d.send(name) }
91
+ end
92
+ end
93
+ alias :add_attributes :add_attribute
94
+
95
+ def is_mongoid?(document)
96
+ defined?(::Mongoid::Document) && document.class.include?(::Mongoid::Document)
97
+ end
98
+
99
+ def is_sequel?(document)
100
+ defined?(::Sequel) && document.class < ::Sequel::Model
101
+ end
102
+
103
+ def is_active_record?(document)
104
+ !is_mongoid?(document) && !is_sequel?(document)
105
+ end
106
+
107
+ def get_default_attributes(document)
108
+ if is_mongoid?(document)
109
+ # work-around mongoid 2.4's unscoped method, not accepting a block
110
+ document.attributes
111
+ elsif is_sequel?(document)
112
+ document.to_hash
113
+ else
114
+ document.class.unscoped do
115
+ document.attributes
116
+ end
117
+ end
118
+ end
119
+
120
+ def get_attribute_names(document)
121
+ get_attributes(document).keys
122
+ end
123
+
124
+ def attributes_to_hash(attributes, document)
125
+ if attributes
126
+ Hash[attributes.map { |name, value| [name.to_s, value.call(document) ] }]
127
+ else
128
+ {}
129
+ end
130
+ end
131
+
132
+ def get_attributes(document)
133
+ # If a serializer is set, we ignore attributes
134
+ # everything should be done via the serializer
135
+ if not @serializer.nil?
136
+ attributes = @serializer.new(document).attributes
137
+ else
138
+ if @attributes.nil? || @attributes.length == 0
139
+ # no `attribute ...` have been configured, use the default attributes of the model
140
+ attributes = get_default_attributes(document)
141
+ else
142
+ # at least 1 `attribute ...` has been configured, therefore use ONLY the one configured
143
+ if is_active_record?(document)
144
+ document.class.unscoped do
145
+ attributes = attributes_to_hash(@attributes, document)
146
+ end
147
+ else
148
+ attributes = attributes_to_hash(@attributes, document)
149
+ end
150
+ end
151
+ end
152
+
153
+ attributes.merge!(attributes_to_hash(@additional_attributes, document)) if @additional_attributes
154
+
155
+ if @options[:sanitize]
156
+ sanitizer = begin
157
+ ::HTML::FullSanitizer.new
158
+ rescue NameError
159
+ # from rails 4.2
160
+ ::Rails::Html::FullSanitizer.new
161
+ end
162
+ attributes = sanitize_attributes(attributes, sanitizer)
163
+ end
164
+
165
+ if @options[:force_utf8_encoding]
166
+ attributes = encode_attributes(attributes)
167
+ end
168
+
169
+ attributes
170
+ end
171
+
172
+ def sanitize_attributes(v, sanitizer)
173
+ case v
174
+ when String
175
+ sanitizer.sanitize(v)
176
+ when Hash
177
+ v.each { |key, value| v[key] = sanitize_attributes(value, sanitizer) }
178
+ when Array
179
+ v.map { |x| sanitize_attributes(x, sanitizer) }
180
+ else
181
+ v
182
+ end
183
+ end
184
+
185
+ def encode_attributes(v)
186
+ case v
187
+ when String
188
+ v.force_encoding('utf-8')
189
+ when Hash
190
+ v.each { |key, value| v[key] = encode_attributes(value) }
191
+ when Array
192
+ v.map { |x| encode_attributes(x) }
193
+ else
194
+ v
195
+ end
196
+ end
197
+
198
+ def get_setting(name)
199
+ instance_variable_get("@#{name}")
200
+ end
201
+
202
+ def to_settings
203
+ settings = {}
204
+ OPTIONS.each do |k|
205
+ v = get_setting(k)
206
+ settings[k] = v if !v.nil?
207
+ end
208
+ settings
209
+ end
210
+
211
+ def add_index(index_uid, options = {}, &block)
212
+ raise ArgumentError.new('No block given') if !block_given?
213
+ raise ArgumentError.new('Options auto_index and auto_remove cannot be set on nested indexes') if options[:auto_index] || options[:auto_remove]
214
+ @additional_indexes ||= {}
215
+ options[:index_uid] = index_uid
216
+ @additional_indexes[options] = IndexSettings.new(options, &block)
217
+ end
218
+
219
+ def additional_indexes
220
+ @additional_indexes || {}
221
+ end
222
+ end
223
+
224
+ # Default queueing system
225
+ if defined?(::ActiveJob::Base)
226
+ # lazy load the ActiveJob class to ensure the
227
+ # queue is initialized before using it
228
+ autoload :MSJob, 'meilisearch/ms_job'
229
+ end
230
+
231
+ # this class wraps an MeiliSearch::Index document ensuring all raised exceptions
232
+ # are correctly logged or thrown depending on the `raise_on_failure` option
233
+ class SafeIndex
234
+ def initialize(index_uid, raise_on_failure, options)
235
+ client = MeiliSearch.client
236
+ primary_key = options[:primary_key] || MeiliSearch::IndexSettings::DEFAULT_PRIMARY_KEY
237
+ @index = client.get_or_create_index(index_uid, { primaryKey: primary_key })
238
+ @raise_on_failure = raise_on_failure.nil? || raise_on_failure
239
+ end
240
+
241
+ ::MeiliSearch::Index.instance_methods(false).each do |m|
242
+ define_method(m) do |*args, &block|
243
+ if (m == :update_settings)
244
+ args[0].delete(:attributesToHighlight) if args[0][:attributesToHighlight]
245
+ args[0].delete(:attributesToCrop) if args[0][:attributesToCrop]
246
+ args[0].delete(:cropLength) if args[0][:cropLength]
247
+ end
248
+ SafeIndex.log_or_throw(m, @raise_on_failure) do
249
+ @index.send(m, *args, &block)
250
+ end
251
+ end
252
+ end
253
+
254
+ # special handling of wait_for_pending_update to handle null task_id
255
+ def wait_for_pending_update(update_id)
256
+ return if update_id.nil? && !@raise_on_failure # ok
257
+ SafeIndex.log_or_throw(:wait_for_pending_update, @raise_on_failure) do
258
+ @index.wait_for_pending_update(update_id)
259
+ end
260
+ end
261
+
262
+ # special handling of settings to avoid raising errors on 404
263
+ def settings(*args)
264
+ SafeIndex.log_or_throw(:settings, @raise_on_failure) do
265
+ begin
266
+ @index.settings(*args)
267
+ rescue ::MeiliSearch::ApiError => e
268
+ return {} if e.code == 404 # not fatal
269
+ raise e
270
+ end
271
+ end
272
+ end
273
+
274
+ private
275
+ def self.log_or_throw(method, raise_on_failure, &block)
276
+ begin
277
+ yield
278
+ rescue ::MeiliSearch::ApiError => e
279
+ raise e if raise_on_failure
280
+ # log the error
281
+ (Rails.logger || Logger.new(STDOUT)).error("[meilisearch-rails] #{e.message}")
282
+ # return something
283
+ case method.to_s
284
+ when 'search'
285
+ # some attributes are required
286
+ { 'hits' => [], 'hitsPerPage' => 0, 'page' => 0, 'facetsDistribution' => {}, 'error' => e }
287
+ else
288
+ # empty answer
289
+ { 'error' => e }
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ # these are the class methods added when MeiliSearch is included
296
+ module ClassMethods
297
+
298
+ def self.extended(base)
299
+ class <<base
300
+ alias_method :without_auto_index, :ms_without_auto_index unless method_defined? :without_auto_index
301
+ alias_method :reindex!, :ms_reindex! unless method_defined? :reindex!
302
+ alias_method :index_documents, :ms_index_documents unless method_defined? :index_documents
303
+ alias_method :index!, :ms_index! unless method_defined? :index!
304
+ alias_method :remove_from_index!, :ms_remove_from_index! unless method_defined? :remove_from_index!
305
+ alias_method :clear_index!, :ms_clear_index! unless method_defined? :clear_index!
306
+ alias_method :search, :ms_search unless method_defined? :search
307
+ alias_method :raw_search, :ms_raw_search unless method_defined? :raw_search
308
+ alias_method :index, :ms_index unless method_defined? :index
309
+ alias_method :index_uid, :ms_index_uid unless method_defined? :index_uid
310
+ alias_method :must_reindex?, :ms_must_reindex? unless method_defined? :must_reindex?
311
+ end
312
+
313
+ base.cattr_accessor :meilisearch_options, :meilisearch_settings
314
+ end
315
+
316
+ def meilisearch(options = {}, &block)
317
+ self.meilisearch_settings = IndexSettings.new(options, &block)
318
+ self.meilisearch_options = { :type => ms_full_const_get(model_name.to_s), :per_page => meilisearch_settings.get_setting(:hitsPerPage) || 20, :page => 1 }.merge(options)
319
+
320
+ attr_accessor :formatted
321
+
322
+ if options[:synchronous] == true
323
+ if defined?(::Sequel) && self < Sequel::Model
324
+ class_eval do
325
+ copy_after_validation = instance_method(:after_validation)
326
+ define_method(:after_validation) do |*args|
327
+ super(*args)
328
+ copy_after_validation.bind(self).call
329
+ ms_mark_synchronous
330
+ end
331
+ end
332
+ else
333
+ after_validation :ms_mark_synchronous if respond_to?(:after_validation)
334
+ end
335
+ end
336
+ if options[:enqueue]
337
+ raise ArgumentError.new("Cannot use a enqueue if the `synchronous` option if set") if options[:synchronous]
338
+ proc = if options[:enqueue] == true
339
+ Proc.new do |record, remove|
340
+ MSJob.perform_later(record, remove ? 'ms_remove_from_index!' : 'ms_index!')
341
+ end
342
+ elsif options[:enqueue].respond_to?(:call)
343
+ options[:enqueue]
344
+ elsif options[:enqueue].is_a?(Symbol)
345
+ Proc.new { |record, remove| self.send(options[:enqueue], record, remove) }
346
+ else
347
+ raise ArgumentError.new("Invalid `enqueue` option: #{options[:enqueue]}")
348
+ end
349
+ meilisearch_options[:enqueue] = Proc.new do |record, remove|
350
+ proc.call(record, remove) unless ms_without_auto_index_scope
351
+ end
352
+ end
353
+ unless options[:auto_index] == false
354
+ if defined?(::Sequel) && self < Sequel::Model
355
+ class_eval do
356
+ copy_after_validation = instance_method(:after_validation)
357
+ copy_before_save = instance_method(:before_save)
358
+
359
+ define_method(:after_validation) do |*args|
360
+ super(*args)
361
+ copy_after_validation.bind(self).call
362
+ ms_mark_must_reindex
363
+ end
364
+
365
+ define_method(:before_save) do |*args|
366
+ copy_before_save.bind(self).call
367
+ ms_mark_for_auto_indexing
368
+ super(*args)
369
+ end
370
+
371
+ sequel_version = Gem::Version.new(Sequel.version)
372
+ if sequel_version >= Gem::Version.new('4.0.0') && sequel_version < Gem::Version.new('5.0.0')
373
+ copy_after_commit = instance_method(:after_commit)
374
+ define_method(:after_commit) do |*args|
375
+ super(*args)
376
+ copy_after_commit.bind(self).call
377
+ ms_perform_index_tasks
378
+ end
379
+ else
380
+ copy_after_save = instance_method(:after_save)
381
+ define_method(:after_save) do |*args|
382
+ super(*args)
383
+ copy_after_save.bind(self).call
384
+ self.db.after_commit do
385
+ ms_perform_index_tasks
386
+ end
387
+ end
388
+ end
389
+ end
390
+ else
391
+ after_validation :ms_mark_must_reindex if respond_to?(:after_validation)
392
+ before_save :ms_mark_for_auto_indexing if respond_to?(:before_save)
393
+ if respond_to?(:after_commit)
394
+ after_commit :ms_perform_index_tasks
395
+ elsif respond_to?(:after_save)
396
+ after_save :ms_perform_index_tasks
397
+ end
398
+ end
399
+ end
400
+ unless options[:auto_remove] == false
401
+ if defined?(::Sequel) && self < Sequel::Model
402
+ class_eval do
403
+ copy_after_destroy = instance_method(:after_destroy)
404
+
405
+ define_method(:after_destroy) do |*args|
406
+ copy_after_destroy.bind(self).call
407
+ ms_enqueue_remove_from_index!(ms_synchronous?)
408
+ super(*args)
409
+ end
410
+ end
411
+ else
412
+ after_destroy { |searchable| searchable.ms_enqueue_remove_from_index!(ms_synchronous?) } if respond_to?(:after_destroy)
413
+ end
414
+ end
415
+ end
416
+
417
+ def ms_without_auto_index(&block)
418
+ self.ms_without_auto_index_scope = true
419
+ begin
420
+ yield
421
+ ensure
422
+ self.ms_without_auto_index_scope = false
423
+ end
424
+ end
425
+
426
+ def ms_without_auto_index_scope=(value)
427
+ Thread.current["ms_without_auto_index_scope_for_#{self.model_name}"] = value
428
+ end
429
+
430
+ def ms_without_auto_index_scope
431
+ Thread.current["ms_without_auto_index_scope_for_#{self.model_name}"]
432
+ end
433
+
434
+ def ms_reindex!(batch_size = MeiliSearch::IndexSettings::DEFAULT_BATCH_SIZE, synchronous = false)
435
+ return if ms_without_auto_index_scope
436
+ ms_configurations.each do |options, settings|
437
+ next if ms_indexing_disabled?(options)
438
+ index = ms_ensure_init(options, settings)
439
+ last_update = nil
440
+
441
+ ms_find_in_batches(batch_size) do |group|
442
+ if ms_conditional_index?(options)
443
+ # delete non-indexable documents
444
+ ids = group.select { |d| !ms_indexable?(d, options) }.map { |d| ms_primary_key_of(d, options) }
445
+ index.delete_documents(ids.select { |id| !id.blank? })
446
+ # select only indexable documents
447
+ group = group.select { |d| ms_indexable?(d, options) }
448
+ end
449
+ documents = group.map do |d|
450
+ attributes = settings.get_attributes(d)
451
+ unless attributes.class == Hash
452
+ attributes = attributes.to_hash
453
+ end
454
+ attributes.merge ms_pk(options) => ms_primary_key_of(d, options)
455
+ end
456
+ last_update= index.add_documents(documents)
457
+ end
458
+ index.wait_for_pending_update(last_update["updateId"]) if last_update and (synchronous || options[:synchronous])
459
+ end
460
+ nil
461
+ end
462
+
463
+ def ms_set_settings(synchronous = false)
464
+ ms_configurations.each do |options, settings|
465
+ if options[:primary_settings] && options[:inherit]
466
+ primary = options[:primary_settings].to_settings
467
+ final_settings = primary.merge(settings.to_settings)
468
+ else
469
+ final_settings = settings.to_settings
470
+ end
471
+
472
+ index = SafeIndex.new(ms_index_uid(options), true, options)
473
+ update = index.update_settings(final_settings)
474
+ index.wait_for_pending_update(update["updateId"]) if synchronous
475
+ end
476
+ end
477
+
478
+ def ms_index_documents(documents, synchronous = false)
479
+ ms_configurations.each do |options, settings|
480
+ next if ms_indexing_disabled?(options)
481
+ index = ms_ensure_init(options, settings)
482
+ update = index.add_documents(documents.map { |d| settings.get_attributes(d).merge ms_pk(options) => ms_primary_key_of(d, options) })
483
+ index.wait_for_pending_update(update["updateId"]) if synchronous || options[:synchronous]
484
+ end
485
+ end
486
+
487
+ def ms_index!(document, synchronous = false)
488
+ return if ms_without_auto_index_scope
489
+ ms_configurations.each do |options, settings|
490
+ next if ms_indexing_disabled?(options)
491
+ primary_key = ms_primary_key_of(document, options)
492
+ index = ms_ensure_init(options, settings)
493
+ if ms_indexable?(document, options)
494
+ raise ArgumentError.new("Cannot index a record without a primary key") if primary_key.blank?
495
+ if synchronous || options[:synchronous]
496
+ doc = settings.get_attributes(document)
497
+ doc = doc.merge ms_pk(options) => primary_key
498
+ index.add_documents!(doc)
499
+ else
500
+ doc = settings.get_attributes(document)
501
+ doc = doc.merge ms_pk(options) => primary_key
502
+ index.add_documents(doc)
503
+ end
504
+ elsif ms_conditional_index?(options) && !primary_key.blank?
505
+ # remove non-indexable documents
506
+ if synchronous || options[:synchronous]
507
+ index.delete_document!(primary_key)
508
+ else
509
+ index.delete_document(primary_key)
510
+ end
511
+ end
512
+ end
513
+ nil
514
+ end
515
+
516
+ def ms_remove_from_index!(document, synchronous = false)
517
+ return if ms_without_auto_index_scope
518
+ primary_key = ms_primary_key_of(document)
519
+ raise ArgumentError.new("Cannot index a record without a primary key") if primary_key.blank?
520
+ ms_configurations.each do |options, settings|
521
+ next if ms_indexing_disabled?(options)
522
+ index = ms_ensure_init(options, settings)
523
+ if synchronous || options[:synchronous]
524
+ index.delete_document!(primary_key)
525
+ else
526
+ index.delete_document(primary_key)
527
+ end
528
+ end
529
+ nil
530
+ end
531
+
532
+ def ms_clear_index!(synchronous = false)
533
+ ms_configurations.each do |options, settings|
534
+ next if ms_indexing_disabled?(options)
535
+ index = ms_ensure_init(options, settings)
536
+ synchronous || options[:synchronous] ? index.delete_all_documents! : index.delete_all_documents
537
+ @ms_indexes[settings] = nil
538
+ end
539
+ nil
540
+ end
541
+
542
+ def ms_raw_search(q, params = {})
543
+ index_uid = params.delete(:index) ||
544
+ params.delete('index')
545
+
546
+ if !meilisearch_settings.get_setting(:attributesToHighlight).nil?
547
+ params[:attributesToHighlight] = meilisearch_settings.get_setting(:attributesToHighlight)
548
+ end
549
+
550
+ if !meilisearch_settings.get_setting(:attributesToCrop).nil?
551
+ params[:attributesToCrop] = meilisearch_settings.get_setting(:attributesToCrop)
552
+ params[:cropLength] = meilisearch_settings.get_setting(:cropLength) if !meilisearch_settings.get_setting(:cropLength).nil?
553
+ end
554
+ index = ms_index(index_uid)
555
+ # index = ms_index(ms_index_uid)
556
+ # index.search(q, Hash[params.map { |k,v| [k.to_s, v.to_s] }])
557
+ index.search(q, Hash[params.map { |k,v| [k, v] }])
558
+ end
559
+
560
+ module AdditionalMethods
561
+ def self.extended(base)
562
+ class <<base
563
+ alias_method :raw_answer, :ms_raw_answer unless method_defined? :raw_answer
564
+ alias_method :facets_distribution, :ms_facets_distribution unless method_defined? :facets_distribution
565
+ end
566
+ end
567
+
568
+ def ms_raw_answer
569
+ @ms_json
570
+ end
571
+
572
+ def ms_facets_distribution
573
+ @ms_json['facetsDistribution']
574
+ end
575
+
576
+ private
577
+ def ms_init_raw_answer(json)
578
+ @ms_json = json
579
+ end
580
+ end
581
+
582
+ def ms_search(q, params = {})
583
+ if MeiliSearch.configuration[:pagination_backend]
584
+
585
+ page = params[:page].nil? ? params[:page] : params[:page].to_i
586
+ hits_per_page = params[:hitsPerPage].nil? ? params[:hitsPerPage] : params[:hitsPerPage].to_i
587
+
588
+ params.delete(:page)
589
+ params.delete(:hitsPerPage)
590
+ params[:limit] = 200
591
+ end
592
+
593
+ if !meilisearch_settings.get_setting(:attributesToHighlight).nil?
594
+ params[:attributesToHighlight] = meilisearch_settings.get_setting(:attributesToHighlight)
595
+ end
596
+
597
+ if !meilisearch_settings.get_setting(:attributesToCrop).nil?
598
+ params[:attributesToCrop] = meilisearch_settings.get_setting(:attributesToCrop)
599
+ params[:cropLength] = meilisearch_settings.get_setting(:cropLength) if !meilisearch_settings.get_setting(:cropLength).nil?
600
+ end
601
+ # Returns raw json hits as follows:
602
+ # {"hits"=>[{"id"=>"13", "href"=>"apple", "name"=>"iphone"}], "offset"=>0, "limit"=>|| 20, "nbHits"=>1, "exhaustiveNbHits"=>false, "processingTimeMs"=>0, "query"=>"iphone"}
603
+ json = ms_raw_search(q, params)
604
+
605
+ # Returns the ids of the hits: 13
606
+ hit_ids = json['hits'].map { |hit| hit[ms_pk(meilisearch_options).to_s] }
607
+
608
+ # condition_key gets the primary key of the document; looks for "id" on the options
609
+ if defined?(::Mongoid::Document) && self.include?(::Mongoid::Document)
610
+ condition_key = ms_primary_key_method.in
611
+ else
612
+ condition_key = ms_primary_key_method
613
+ end
614
+
615
+ # meilisearch_options[:type] refers to the Model name (e.g. Product)
616
+ # results_by_id creates a hash with the primaryKey of the document (id) as the key and the document itself as the value
617
+ # {"13"=>#<Product id: 13, name: "iphone", href: "apple", tags: nil, type: nil, description: "Puts even more features at your fingertips", release_date: nil>}
618
+ results_by_id = meilisearch_options[:type].where(condition_key => hit_ids).index_by do |hit|
619
+ ms_primary_key_of(hit)
620
+ end
621
+
622
+ results = json['hits'].map do |hit|
623
+
624
+ o = results_by_id[hit[ms_pk(meilisearch_options).to_s].to_s]
625
+ if o
626
+ o.formatted = hit['_formatted']
627
+ o
628
+ end
629
+ end.compact
630
+
631
+ total_hits = json['hits'].length
632
+ hits_per_page ||= 20
633
+ page ||= 1
634
+
635
+ res = MeiliSearch::Pagination.create(results, total_hits, meilisearch_options.merge({ :page => page , :per_page => hits_per_page }))
636
+ res.extend(AdditionalMethods)
637
+ res.send(:ms_init_raw_answer, json)
638
+ res
639
+ end
640
+
641
+ def ms_index(name = nil)
642
+ if name
643
+ ms_configurations.each do |o, s|
644
+ return ms_ensure_init(o, s) if o[:index_uid].to_s == name.to_s
645
+ end
646
+ raise ArgumentError.new("Invalid index name: #{name}")
647
+ end
648
+ ms_ensure_init
649
+ end
650
+
651
+ def ms_index_uid(options = nil)
652
+ options ||= meilisearch_options
653
+ name = options[:index_uid] || model_name.to_s.gsub('::', '_')
654
+ name = "#{name}_#{Rails.env.to_s}" if options[:per_environment]
655
+ name
656
+ end
657
+
658
+ def ms_must_reindex?(document)
659
+ # use +ms_dirty?+ method if implemented
660
+ return document.send(:ms_dirty?) if (document.respond_to?(:ms_dirty?))
661
+ # Loop over each index to see if a attribute used in records has changed
662
+ ms_configurations.each do |options, settings|
663
+ next if ms_indexing_disabled?(options)
664
+ return true if ms_primary_key_changed?(document, options)
665
+ settings.get_attribute_names(document).each do |k|
666
+ return true if ms_attribute_changed?(document, k)
667
+ # return true if !document.respond_to?(changed_method) || document.send(changed_method)
668
+ end
669
+ [options[:if], options[:unless]].each do |condition|
670
+ case condition
671
+ when nil
672
+ when String, Symbol
673
+ return true if ms_attribute_changed?(document, condition)
674
+ else
675
+ # if the :if, :unless condition is a anything else,
676
+ # we have no idea whether we should reindex or not
677
+ # let's always reindex then
678
+ return true
679
+ end
680
+ end
681
+ end
682
+ # By default, we don't reindex
683
+ return false
684
+ end
685
+
686
+ protected
687
+
688
+ def ms_ensure_init(options = nil, settings = nil, index_settings = nil)
689
+ raise ArgumentError.new('No `meilisearch` block found in your model.') if meilisearch_settings.nil?
690
+
691
+ @ms_indexes ||= {}
692
+
693
+ options ||= meilisearch_options
694
+ settings ||= meilisearch_settings
695
+
696
+ return @ms_indexes[settings] if @ms_indexes[settings]
697
+
698
+ @ms_indexes[settings] = SafeIndex.new(ms_index_uid(options), meilisearch_options[:raise_on_failure], meilisearch_options)
699
+
700
+ current_settings = @ms_indexes[settings].settings(:getVersion => 1) rescue nil # if the index doesn't exist
701
+
702
+ index_settings ||= settings.to_settings
703
+ index_settings = options[:primary_settings].to_settings.merge(index_settings) if options[:inherit]
704
+
705
+ options[:check_settings] = true if options[:check_settings].nil?
706
+
707
+ if !ms_indexing_disabled?(options) && options[:check_settings] && meilisearch_settings_changed?(current_settings, index_settings)
708
+ @ms_indexes[settings].update_settings(index_settings)
709
+ end
710
+
711
+ @ms_indexes[settings]
712
+ end
713
+
714
+ private
715
+
716
+ def ms_configurations
717
+ raise ArgumentError.new('No `meilisearch` block found in your model.') if meilisearch_settings.nil?
718
+ if @configurations.nil?
719
+ @configurations = {}
720
+ @configurations[meilisearch_options] = meilisearch_settings
721
+ meilisearch_settings.additional_indexes.each do |k,v|
722
+ @configurations[k] = v
723
+
724
+ if v.additional_indexes.any?
725
+ v.additional_indexes.each do |options, index|
726
+ @configurations[options] = index
727
+ end
728
+ end
729
+ end
730
+ end
731
+ @configurations
732
+ end
733
+
734
+ def ms_primary_key_method(options = nil)
735
+ options ||= meilisearch_options
736
+ options[:primary_key] || options[:id] || :id
737
+ end
738
+
739
+ def ms_primary_key_of(doc, options = nil)
740
+ doc.send(ms_primary_key_method(options)).to_s
741
+ end
742
+
743
+ def ms_primary_key_changed?(doc, options = nil)
744
+ changed = ms_attribute_changed?(doc, ms_primary_key_method(options))
745
+ changed.nil? ? false : changed
746
+ end
747
+
748
+ def ms_pk(options = nil)
749
+ options[:primary_key] || MeiliSearch::IndexSettings::DEFAULT_PRIMARY_KEY
750
+ end
751
+
752
+ def meilisearch_settings_changed?(prev, current)
753
+ return true if prev.nil?
754
+ current.each do |k, v|
755
+ prev_v = prev[k.to_s]
756
+ if v.is_a?(Array) and prev_v.is_a?(Array)
757
+ # compare array of strings, avoiding symbols VS strings comparison
758
+ return true if v.map { |x| x.to_s } != prev_v.map { |x| x.to_s }
759
+ else
760
+ return true if prev_v != v
761
+ end
762
+ end
763
+ false
764
+ end
765
+
766
+ def ms_full_const_get(name)
767
+ list = name.split('::')
768
+ list.shift if list.first.blank?
769
+ obj = self
770
+ list.each do |x|
771
+ # This is required because const_get tries to look for constants in the
772
+ # ancestor chain, but we only want constants that are HERE
773
+ obj = obj.const_defined?(x) ? obj.const_get(x) : obj.const_missing(x)
774
+ end
775
+ obj
776
+ end
777
+
778
+ def ms_conditional_index?(options = nil)
779
+ options ||= meilisearch_options
780
+ options[:if].present? || options[:unless].present?
781
+ end
782
+
783
+ def ms_indexable?(document, options = nil)
784
+ options ||= meilisearch_options
785
+ if_passes = options[:if].blank? || ms_constraint_passes?(document, options[:if])
786
+ unless_passes = options[:unless].blank? || !ms_constraint_passes?(document, options[:unless])
787
+ if_passes && unless_passes
788
+ end
789
+
790
+ def ms_constraint_passes?(document, constraint)
791
+ case constraint
792
+ when Symbol
793
+ document.send(constraint)
794
+ when String
795
+ document.send(constraint.to_sym)
796
+ when Enumerable
797
+ # All constraints must pass
798
+ constraint.all? { |inner_constraint| ms_constraint_passes?(document, inner_constraint) }
799
+ else
800
+ if constraint.respond_to?(:call) # Proc
801
+ constraint.call(document)
802
+ else
803
+ raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})"
804
+ end
805
+ end
806
+ end
807
+
808
+ def ms_indexing_disabled?(options = nil)
809
+ options ||= meilisearch_options
810
+ constraint = options[:disable_indexing] || options['disable_indexing']
811
+ case constraint
812
+ when nil
813
+ return false
814
+ when true, false
815
+ return constraint
816
+ when String, Symbol
817
+ return send(constraint)
818
+ else
819
+ return constraint.call if constraint.respond_to?(:call) # Proc
820
+ end
821
+ raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})"
822
+ end
823
+
824
+ def ms_find_in_batches(batch_size, &block)
825
+ if (defined?(::ActiveRecord) && ancestors.include?(::ActiveRecord::Base)) || respond_to?(:find_in_batches)
826
+ find_in_batches(:batch_size => batch_size, &block)
827
+ elsif defined?(::Sequel) && self < Sequel::Model
828
+ dataset.extension(:pagination).each_page(batch_size, &block)
829
+ else
830
+ # don't worry, mongoid has its own underlying cursor/streaming mechanism
831
+ items = []
832
+ all.each do |item|
833
+ items << item
834
+ if items.length % batch_size == 0
835
+ yield items
836
+ items = []
837
+ end
838
+ end
839
+ yield items unless items.empty?
840
+ end
841
+ end
842
+
843
+ def ms_attribute_changed?(document, attr_name)
844
+ if document.respond_to?("will_save_change_to_#{attr_name}?")
845
+ return document.send("will_save_change_to_#{attr_name}?")
846
+ end
847
+
848
+ # We don't know if the attribute has changed, so conservatively assume it has
849
+ true
850
+ end
851
+ end
852
+
853
+ # these are the instance methods included
854
+ module InstanceMethods
855
+
856
+ def self.included(base)
857
+ base.instance_eval do
858
+ alias_method :index!, :ms_index! unless method_defined? :index!
859
+ alias_method :remove_from_index!, :ms_remove_from_index! unless method_defined? :remove_from_index!
860
+ end
861
+ end
862
+
863
+ def ms_index!(synchronous = false)
864
+ self.class.ms_index!(self, synchronous || ms_synchronous?)
865
+ end
866
+
867
+ def ms_remove_from_index!(synchronous = false)
868
+ self.class.ms_remove_from_index!(self, synchronous || ms_synchronous?)
869
+ end
870
+
871
+ def ms_enqueue_remove_from_index!(synchronous)
872
+ if meilisearch_options[:enqueue]
873
+ meilisearch_options[:enqueue].call(self, true) unless self.class.send(:ms_indexing_disabled?, meilisearch_options)
874
+ else
875
+ ms_remove_from_index!(synchronous || ms_synchronous?)
876
+ end
877
+ end
878
+
879
+ def ms_enqueue_index!(synchronous)
880
+ if meilisearch_options[:enqueue]
881
+ meilisearch_options[:enqueue].call(self, false) unless self.class.send(:ms_indexing_disabled?, meilisearch_options)
882
+ else
883
+ ms_index!(synchronous)
884
+ end
885
+ end
886
+
887
+ private
888
+
889
+ def ms_synchronous?
890
+ @ms_synchronous == true
891
+ end
892
+
893
+ def ms_mark_synchronous
894
+ @ms_synchronous = true
895
+ end
896
+
897
+ def ms_mark_for_auto_indexing
898
+ @ms_auto_indexing = true
899
+ end
900
+
901
+ def ms_mark_must_reindex
902
+ # ms_must_reindex flag is reset after every commit as part. If we must reindex at any point in
903
+ # a stransaction, keep flag set until it is explicitly unset
904
+ @ms_must_reindex ||=
905
+ if defined?(::Sequel) && is_a?(Sequel::Model)
906
+ new? || self.class.ms_must_reindex?(self)
907
+ else
908
+ new_record? || self.class.ms_must_reindex?(self)
909
+ end
910
+ true
911
+ end
912
+
913
+ def ms_perform_index_tasks
914
+ return if !@ms_auto_indexing || @ms_must_reindex == false
915
+ ms_enqueue_index!(ms_synchronous?)
916
+ remove_instance_variable(:@ms_auto_indexing) if instance_variable_defined?(:@ms_auto_indexing)
917
+ remove_instance_variable(:@ms_synchronous) if instance_variable_defined?(:@ms_synchronous)
918
+ remove_instance_variable(:@ms_must_reindex) if instance_variable_defined?(:@ms_must_reindex)
919
+ end
920
+ end
921
+ end