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.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/Gemfile +30 -0
- data/LICENSE +21 -0
- data/README.md +668 -0
- data/Rakefile +17 -0
- data/lib/meilisearch-rails.rb +921 -0
- data/lib/meilisearch/configuration.rb +15 -0
- data/lib/meilisearch/ms_job.rb +9 -0
- data/lib/meilisearch/pagination.rb +19 -0
- data/lib/meilisearch/pagination/kaminari.rb +40 -0
- data/lib/meilisearch/pagination/will_paginate.rb +19 -0
- data/lib/meilisearch/railtie.rb +11 -0
- data/lib/meilisearch/tasks/meilisearch.rake +19 -0
- data/lib/meilisearch/utilities.rb +48 -0
- data/lib/meilisearch/version.rb +3 -0
- data/meilisearch-rails.gemspec +46 -0
- data/spec/spec_helper.rb +52 -0
- data/spec/utilities_spec.rb +30 -0
- metadata +90 -0
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
|