meilisearch-rails 0.1.0

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