yc-algoliasearch-rails 2.1.4

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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.document +5 -0
  3. data/.rspec +1 -0
  4. data/CHANGELOG.MD +566 -0
  5. data/Gemfile +38 -0
  6. data/Gemfile.lock +213 -0
  7. data/LICENSE +21 -0
  8. data/README.md +1171 -0
  9. data/Rakefile +17 -0
  10. data/algoliasearch-rails.gemspec +95 -0
  11. data/lib/algoliasearch/algolia_job.rb +9 -0
  12. data/lib/algoliasearch/configuration.rb +30 -0
  13. data/lib/algoliasearch/pagination/kaminari.rb +40 -0
  14. data/lib/algoliasearch/pagination/will_paginate.rb +15 -0
  15. data/lib/algoliasearch/pagination.rb +19 -0
  16. data/lib/algoliasearch/railtie.rb +11 -0
  17. data/lib/algoliasearch/tasks/algoliasearch.rake +19 -0
  18. data/lib/algoliasearch/utilities.rb +48 -0
  19. data/lib/algoliasearch/version.rb +3 -0
  20. data/lib/algoliasearch-rails.rb +1083 -0
  21. data/spec/spec_helper.rb +52 -0
  22. data/spec/utilities_spec.rb +30 -0
  23. data/vendor/assets/javascripts/algolia/algoliasearch.angular.js +2678 -0
  24. data/vendor/assets/javascripts/algolia/algoliasearch.angular.min.js +7 -0
  25. data/vendor/assets/javascripts/algolia/algoliasearch.jquery.js +2678 -0
  26. data/vendor/assets/javascripts/algolia/algoliasearch.jquery.min.js +7 -0
  27. data/vendor/assets/javascripts/algolia/algoliasearch.js +2663 -0
  28. data/vendor/assets/javascripts/algolia/algoliasearch.min.js +7 -0
  29. data/vendor/assets/javascripts/algolia/bloodhound.js +727 -0
  30. data/vendor/assets/javascripts/algolia/bloodhound.min.js +7 -0
  31. data/vendor/assets/javascripts/algolia/typeahead.bundle.js +1782 -0
  32. data/vendor/assets/javascripts/algolia/typeahead.bundle.min.js +7 -0
  33. data/vendor/assets/javascripts/algolia/typeahead.jquery.js +1184 -0
  34. data/vendor/assets/javascripts/algolia/typeahead.jquery.min.js +7 -0
  35. data/vendor/assets/javascripts/algolia/v2/algoliasearch.angular.js +2678 -0
  36. data/vendor/assets/javascripts/algolia/v2/algoliasearch.angular.min.js +7 -0
  37. data/vendor/assets/javascripts/algolia/v2/algoliasearch.jquery.js +2678 -0
  38. data/vendor/assets/javascripts/algolia/v2/algoliasearch.jquery.min.js +7 -0
  39. data/vendor/assets/javascripts/algolia/v2/algoliasearch.js +2663 -0
  40. data/vendor/assets/javascripts/algolia/v2/algoliasearch.min.js +7 -0
  41. data/vendor/assets/javascripts/algolia/v3/algoliasearch.angular.js +6277 -0
  42. data/vendor/assets/javascripts/algolia/v3/algoliasearch.angular.min.js +3 -0
  43. data/vendor/assets/javascripts/algolia/v3/algoliasearch.jquery.js +6223 -0
  44. data/vendor/assets/javascripts/algolia/v3/algoliasearch.jquery.min.js +3 -0
  45. data/vendor/assets/javascripts/algolia/v3/algoliasearch.js +6070 -0
  46. data/vendor/assets/javascripts/algolia/v3/algoliasearch.min.js +3 -0
  47. metadata +174 -0
@@ -0,0 +1,1083 @@
1
+ require 'algolia'
2
+
3
+ require 'algoliasearch/version'
4
+ require 'algoliasearch/utilities'
5
+
6
+ if defined? Rails
7
+ begin
8
+ require 'algoliasearch/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 AlgoliaSearch
22
+
23
+ class NotConfigured < StandardError; end
24
+ class BadConfiguration < StandardError; end
25
+ class NoBlockGiven < StandardError; end
26
+ class MixedSlavesAndReplicas < StandardError; end
27
+
28
+ autoload :Configuration, 'algoliasearch/configuration'
29
+ extend Configuration
30
+
31
+ autoload :Pagination, 'algoliasearch/pagination'
32
+
33
+ class << self
34
+ attr_reader :included_in
35
+
36
+ def included(klass)
37
+ @included_in ||= []
38
+ @included_in << klass
39
+ @included_in.uniq!
40
+
41
+ klass.class_eval do
42
+ extend ClassMethods
43
+ include InstanceMethods
44
+ end
45
+ end
46
+
47
+ end
48
+
49
+ class IndexSettings
50
+ DEFAULT_BATCH_SIZE = 1000
51
+
52
+ # AlgoliaSearch settings
53
+ OPTIONS = [
54
+ # Attributes
55
+ :searchableAttributes, :attributesForFaceting, :unretrievableAttributes, :attributesToRetrieve,
56
+ # Ranking
57
+ :ranking, :customRanking, # Replicas are handled via `add_replica`
58
+ # Faceting
59
+ :maxValuesPerFacet, :sortFacetValuesBy,
60
+ # Highlighting / Snippeting
61
+ :attributesToHighlight, :attributesToSnippet, :highlightPreTag, :highlightPostTag,
62
+ :snippetEllipsisText, :restrictHighlightAndSnippetArrays,
63
+ # Pagination
64
+ :hitsPerPage, :paginationLimitedTo,
65
+ # Typo
66
+ :minWordSizefor1Typo, :minWordSizefor2Typos, :typoTolerance, :allowTyposOnNumericTokens,
67
+ :disableTypoToleranceOnAttributes, :disableTypoToleranceOnWords, :separatorsToIndex,
68
+ # Language
69
+ :ignorePlurals, :removeStopWords, :camelCaseAttributes, :decompoundedAttributes,
70
+ :keepDiacriticsOnCharacters, :queryLanguages, :indexLanguages,
71
+ # Query Rules
72
+ :enableRules,
73
+ # Query Strategy
74
+ :queryType, :removeWordsIfNoResults, :advancedSyntax, :optionalWords,
75
+ :disablePrefixOnAttributes, :disableExactOnAttributes, :exactOnSingleWordQuery, :alternativesAsExact,
76
+ # Performance
77
+ :numericAttributesForFiltering, :allowCompressionOfIntegerArray,
78
+ # Advanced
79
+ :attributeForDistinct, :distinct, :replaceSynonymsInHighlight, :minProximity, :responseFields,
80
+ :maxFacetHits,
81
+
82
+ # Rails-specific
83
+ :synonyms, :placeholders, :altCorrections,
84
+ ]
85
+ OPTIONS.each do |k|
86
+ define_method k do |v|
87
+ instance_variable_set("@#{k}", v)
88
+ end
89
+ end
90
+
91
+ def initialize(options, &block)
92
+ @options = options
93
+ instance_exec(&block) if block_given?
94
+ end
95
+
96
+ def use_serializer(serializer)
97
+ @serializer = serializer
98
+ # instance_variable_set("@serializer", serializer)
99
+ end
100
+
101
+ def get_attribute_list
102
+ @attributes
103
+ end
104
+
105
+ def get_additional_attribute_list
106
+ @additional_attributes
107
+ end
108
+
109
+ def attribute(*names, &block)
110
+ raise ArgumentError.new('Cannot pass multiple attribute names if block given') if block_given? and names.length > 1
111
+ raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica]
112
+ @attributes ||= {}
113
+ names.flatten.each do |name|
114
+ @attributes[name.to_s] = block_given? ? Proc.new { |o| o.instance_eval(&block) } : Proc.new { |o| o.send(name) }
115
+ end
116
+ end
117
+ alias :attributes :attribute
118
+
119
+ def add_attribute(*names, &block)
120
+ raise ArgumentError.new('Cannot pass multiple attribute names if block given') if block_given? and names.length > 1
121
+ raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica]
122
+ @additional_attributes ||= {}
123
+ names.each do |name|
124
+ @additional_attributes[name.to_s] = block_given? ? Proc.new { |o| o.instance_eval(&block) } : Proc.new { |o| o.send(name) }
125
+ end
126
+ end
127
+ alias :add_attributes :add_attribute
128
+
129
+ def is_mongoid?(object)
130
+ defined?(::Mongoid::Document) && object.class.include?(::Mongoid::Document)
131
+ end
132
+
133
+ def is_sequel?(object)
134
+ defined?(::Sequel) && object.class < ::Sequel::Model
135
+ end
136
+
137
+ def is_active_record?(object)
138
+ !is_mongoid?(object) && !is_sequel?(object)
139
+ end
140
+
141
+ def get_default_attributes(object)
142
+ if is_mongoid?(object)
143
+ # work-around mongoid 2.4's unscoped method, not accepting a block
144
+ object.attributes
145
+ elsif is_sequel?(object)
146
+ object.to_hash
147
+ else
148
+ object.class.unscoped do
149
+ object.attributes
150
+ end
151
+ end
152
+ end
153
+
154
+ def get_attribute_names(object)
155
+ get_attributes(object).keys
156
+ end
157
+
158
+ def attributes_to_hash(attributes, object)
159
+ if attributes
160
+ Hash[attributes.map { |name, value| [name.to_s, value.call(object) ] }]
161
+ else
162
+ {}
163
+ end
164
+ end
165
+
166
+ def get_attributes(object)
167
+ # If a serializer is set, we ignore attributes
168
+ # everything should be done via the serializer
169
+ if not @serializer.nil?
170
+ attributes = @serializer.new(object).attributes
171
+ else
172
+ if @options[:primary_settings] && @options[:inherit_attributes]
173
+ attr = @options[:primary_settings].get_attribute_list
174
+ if attr.nil?
175
+ attr = @attributes
176
+ elsif not @attributes.nil?
177
+ attr.merge!(@attributes)
178
+ end
179
+ else
180
+ attr = @attributes
181
+ end
182
+
183
+ if attr.nil? || attr.length == 0
184
+ # no `attribute ...` have been configured, use the default attributes of the model
185
+ attributes = get_default_attributes(object)
186
+ else
187
+ # at least 1 `attribute ...` has been configured, therefore use ONLY the one configured
188
+ if is_active_record?(object)
189
+ object.class.unscoped do
190
+ attributes = attributes_to_hash(attr, object)
191
+ end
192
+ else
193
+ attributes = attributes_to_hash(attr, object)
194
+ end
195
+ end
196
+ end
197
+
198
+ additional_attr = {}
199
+ if @options[:primary_settings] && @options[:inherit_attributes]
200
+ additional_attr.merge!(attributes_to_hash(@options[:primary_settings].get_additional_attribute_list, object))
201
+ end
202
+ additional_attr.merge!(attributes_to_hash(@additional_attributes, object)) if @additional_attributes
203
+ attributes.merge!(additional_attr)
204
+
205
+ if @options[:sanitize]
206
+ sanitizer = begin
207
+ ::HTML::FullSanitizer.new
208
+ rescue NameError
209
+ # from rails 4.2
210
+ ::Rails::Html::FullSanitizer.new
211
+ end
212
+ attributes = sanitize_attributes(attributes, sanitizer)
213
+ end
214
+
215
+ if @options[:force_utf8_encoding] && Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f > 1.8
216
+ attributes = encode_attributes(attributes)
217
+ end
218
+
219
+ attributes
220
+ end
221
+
222
+ def sanitize_attributes(v, sanitizer)
223
+ case v
224
+ when String
225
+ sanitizer.sanitize(v)
226
+ when Hash
227
+ v.each { |key, value| v[key] = sanitize_attributes(value, sanitizer) }
228
+ when Array
229
+ v.map { |x| sanitize_attributes(x, sanitizer) }
230
+ else
231
+ v
232
+ end
233
+ end
234
+
235
+ def encode_attributes(v)
236
+ case v
237
+ when String
238
+ v.force_encoding('utf-8')
239
+ when Hash
240
+ v.each { |key, value| v[key] = encode_attributes(value) }
241
+ when Array
242
+ v.map { |x| encode_attributes(x) }
243
+ else
244
+ v
245
+ end
246
+ end
247
+
248
+ def geoloc(lat_attr = nil, lng_attr = nil, &block)
249
+ raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica]
250
+ add_attribute :_geoloc do |o|
251
+ block_given? ? o.instance_eval(&block) : { :lat => o.send(lat_attr).to_f, :lng => o.send(lng_attr).to_f }
252
+ end
253
+ end
254
+
255
+ def tags(*args, &block)
256
+ raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica]
257
+ add_attribute :_tags do |o|
258
+ v = block_given? ? o.instance_eval(&block) : args
259
+ v.is_a?(Array) ? v : [v]
260
+ end
261
+ end
262
+
263
+ def get_setting(name)
264
+ instance_variable_get("@#{name}")
265
+ end
266
+
267
+ def to_settings
268
+ settings = {}
269
+ OPTIONS.each do |k|
270
+ v = get_setting(k)
271
+ settings[k] = v if !v.nil?
272
+ end
273
+
274
+ if !@options[:replica]
275
+ settings[:replicas] = additional_indexes.select { |opts, s| opts[:replica] }.map do |opts, s|
276
+ name = opts[:index_name]
277
+ name = "#{name}_#{Rails.env.to_s}" if opts[:per_environment]
278
+ name = "virtual(#{name})" if opts[:virtual]
279
+ name
280
+ end
281
+ settings.delete(:replicas) if settings[:replicas].empty?
282
+ end
283
+ settings
284
+ end
285
+
286
+ def add_index(index_name, options = {}, &block)
287
+ raise ArgumentError.new('Cannot specify additional index on a replica index') if @options[:replica]
288
+ raise ArgumentError.new('No block given') if !block_given?
289
+ raise ArgumentError.new('Options auto_index and auto_remove cannot be set on nested indexes') if options[:auto_index] || options[:auto_remove]
290
+ @additional_indexes ||= {}
291
+ options[:index_name] = index_name
292
+ options.merge!({ :primary_settings => self }) if options[:inherit]
293
+ @additional_indexes[options] = IndexSettings.new(options, &block)
294
+ end
295
+
296
+ def add_replica(index_name, options = {}, &block)
297
+ raise ArgumentError.new('Cannot specify additional replicas on a replica index') if @options[:replica]
298
+ raise ArgumentError.new('No block given') if !block_given?
299
+ add_index(index_name, options.merge({ :replica => true }), &block)
300
+ end
301
+
302
+ def add_slave(index_name, options = {}, &block)
303
+ raise ArgumentError.new('Cannot specify additional slaves on a slave index') if @options[:slave] || @options[:replica]
304
+ raise ArgumentError.new('No block given') if !block_given?
305
+ add_index(index_name, options.merge({ :slave => true }), &block)
306
+ end
307
+
308
+ def additional_indexes
309
+ @additional_indexes || {}
310
+ end
311
+ end
312
+
313
+ # Default queueing system
314
+ if defined?(::ActiveJob::Base)
315
+ # lazy load the ActiveJob class to ensure the
316
+ # queue is initialized before using it
317
+ # see https://github.com/algolia/algoliasearch-rails/issues/69
318
+ autoload :AlgoliaJob, 'algoliasearch/algolia_job'
319
+ end
320
+
321
+ # this class wraps an Algolia::Index object ensuring all raised exceptions
322
+ # are correctly logged or thrown depending on the `raise_on_failure` option
323
+ class SafeIndex
324
+ def initialize(name, raise_on_failure)
325
+ @index = AlgoliaSearch.client.init_index(name)
326
+ @raise_on_failure = raise_on_failure.nil? || raise_on_failure
327
+ end
328
+
329
+ ::Algolia::Search::Index.instance_methods(false).each do |m|
330
+ define_method(m) do |*args, &block|
331
+ SafeIndex.log_or_throw(m, @raise_on_failure) do
332
+ @index.send(m, *args, &block)
333
+ end
334
+ end
335
+ end
336
+
337
+ # special handling of wait_task to handle null task_id
338
+ def wait_task(task_id)
339
+ return if task_id.nil? && !@raise_on_failure # ok
340
+ SafeIndex.log_or_throw(:wait_task, @raise_on_failure) do
341
+ @index.wait_task(task_id)
342
+ end
343
+ end
344
+
345
+ # special handling of get_settings to avoid raising errors on 404
346
+ def get_settings(*args)
347
+ SafeIndex.log_or_throw(:get_settings, @raise_on_failure) do
348
+ begin
349
+ @index.get_settings(*args)
350
+ rescue Algolia::AlgoliaHttpError => e
351
+ return {} if e.code == 404 # not fatal
352
+ raise e
353
+ end
354
+ end
355
+ end
356
+
357
+ # expose move as well
358
+ def self.move_index(old_name, new_name)
359
+ SafeIndex.log_or_throw(:move_index, true) do
360
+ AlgoliaSearch.client.move_index(old_name, new_name)
361
+ end
362
+ end
363
+
364
+ private
365
+ def self.log_or_throw(method, raise_on_failure, &block)
366
+ begin
367
+ yield
368
+ rescue Algolia::AlgoliaError => e
369
+ raise e if raise_on_failure
370
+ # log the error
371
+ (Rails.logger || Logger.new(STDOUT)).error("[algoliasearch-rails] #{e.message}")
372
+ # return something
373
+ case method.to_s
374
+ when 'search'
375
+ # some attributes are required
376
+ { 'hits' => [], 'hitsPerPage' => 0, 'page' => 0, 'facets' => {}, 'error' => e }
377
+ else
378
+ # empty answer
379
+ { 'error' => e }
380
+ end
381
+ end
382
+ end
383
+ end
384
+
385
+ # these are the class methods added when AlgoliaSearch is included
386
+ module ClassMethods
387
+
388
+ def self.extended(base)
389
+ class <<base
390
+ alias_method :without_auto_index, :algolia_without_auto_index unless method_defined? :without_auto_index
391
+ alias_method :reindex!, :algolia_reindex! unless method_defined? :reindex!
392
+ alias_method :reindex, :algolia_reindex unless method_defined? :reindex
393
+ alias_method :index_objects, :algolia_index_objects unless method_defined? :index_objects
394
+ alias_method :index!, :algolia_index! unless method_defined? :index!
395
+ alias_method :remove_from_index!, :algolia_remove_from_index! unless method_defined? :remove_from_index!
396
+ alias_method :clear_index!, :algolia_clear_index! unless method_defined? :clear_index!
397
+ alias_method :search, :algolia_search unless method_defined? :search
398
+ alias_method :raw_search, :algolia_raw_search unless method_defined? :raw_search
399
+ alias_method :search_facet, :algolia_search_facet unless method_defined? :search_facet
400
+ alias_method :search_for_facet_values, :algolia_search_for_facet_values unless method_defined? :search_for_facet_values
401
+ alias_method :index, :algolia_index unless method_defined? :index
402
+ alias_method :index_name, :algolia_index_name unless method_defined? :index_name
403
+ alias_method :must_reindex?, :algolia_must_reindex? unless method_defined? :must_reindex?
404
+ end
405
+
406
+ base.cattr_accessor :algoliasearch_options, :algoliasearch_settings
407
+ end
408
+
409
+ def algoliasearch(options = {}, &block)
410
+ self.algoliasearch_settings = IndexSettings.new(options, &block)
411
+ self.algoliasearch_options = { :type => algolia_full_const_get(model_name.to_s), :per_page => algoliasearch_settings.get_setting(:hitsPerPage) || 10, :page => 1 }.merge(options)
412
+
413
+ attr_accessor :highlight_result, :snippet_result
414
+
415
+ if options[:synchronous] == true
416
+ if defined?(::Sequel) && self < Sequel::Model
417
+ class_eval do
418
+ copy_after_validation = instance_method(:after_validation)
419
+ define_method(:after_validation) do |*args|
420
+ super(*args)
421
+ copy_after_validation.bind(self).call
422
+ algolia_mark_synchronous
423
+ end
424
+ end
425
+ else
426
+ after_validation :algolia_mark_synchronous if respond_to?(:after_validation)
427
+ end
428
+ end
429
+ if options[:enqueue]
430
+ raise ArgumentError.new("Cannot use a enqueue if the `synchronous` option if set") if options[:synchronous]
431
+ proc = if options[:enqueue] == true
432
+ Proc.new do |record, remove|
433
+ AlgoliaJob.perform_later(record, remove ? 'algolia_remove_from_index!' : 'algolia_index!')
434
+ end
435
+ elsif options[:enqueue].respond_to?(:call)
436
+ options[:enqueue]
437
+ elsif options[:enqueue].is_a?(Symbol)
438
+ Proc.new { |record, remove| self.send(options[:enqueue], record, remove) }
439
+ else
440
+ raise ArgumentError.new("Invalid `enqueue` option: #{options[:enqueue]}")
441
+ end
442
+ algoliasearch_options[:enqueue] = Proc.new do |record, remove|
443
+ proc.call(record, remove) unless algolia_without_auto_index_scope
444
+ end
445
+ end
446
+ unless options[:auto_index] == false
447
+ if defined?(::Sequel) && self < Sequel::Model
448
+ class_eval do
449
+ copy_after_validation = instance_method(:after_validation)
450
+ copy_before_save = instance_method(:before_save)
451
+
452
+ define_method(:after_validation) do |*args|
453
+ super(*args)
454
+ copy_after_validation.bind(self).call
455
+ algolia_mark_must_reindex
456
+ end
457
+
458
+ define_method(:before_save) do |*args|
459
+ copy_before_save.bind(self).call
460
+ algolia_mark_for_auto_indexing
461
+ super(*args)
462
+ end
463
+
464
+ sequel_version = Gem::Version.new(Sequel.version)
465
+ if sequel_version >= Gem::Version.new('4.0.0') && sequel_version < Gem::Version.new('5.0.0')
466
+ copy_after_commit = instance_method(:after_commit)
467
+ define_method(:after_commit) do |*args|
468
+ super(*args)
469
+ copy_after_commit.bind(self).call
470
+ algolia_perform_index_tasks
471
+ end
472
+ else
473
+ copy_after_save = instance_method(:after_save)
474
+ define_method(:after_save) do |*args|
475
+ super(*args)
476
+ copy_after_save.bind(self).call
477
+ self.db.after_commit do
478
+ algolia_perform_index_tasks
479
+ end
480
+ end
481
+ end
482
+ end
483
+ else
484
+ after_validation :algolia_mark_must_reindex if respond_to?(:after_validation)
485
+ before_save :algolia_mark_for_auto_indexing if respond_to?(:before_save)
486
+ if respond_to?(:after_commit)
487
+ after_commit :algolia_perform_index_tasks
488
+ elsif respond_to?(:after_save)
489
+ after_save :algolia_perform_index_tasks
490
+ end
491
+ end
492
+ end
493
+ unless options[:auto_remove] == false
494
+ if defined?(::Sequel) && self < Sequel::Model
495
+ class_eval do
496
+ copy_after_destroy = instance_method(:after_destroy)
497
+
498
+ define_method(:after_destroy) do |*args|
499
+ copy_after_destroy.bind(self).call
500
+ algolia_enqueue_remove_from_index!(algolia_synchronous?)
501
+ super(*args)
502
+ end
503
+ end
504
+ else
505
+ after_destroy { |searchable| searchable.algolia_enqueue_remove_from_index!(algolia_synchronous?) } if respond_to?(:after_destroy)
506
+ end
507
+ end
508
+ end
509
+
510
+ def algolia_without_auto_index(&block)
511
+ self.algolia_without_auto_index_scope = true
512
+ begin
513
+ yield
514
+ ensure
515
+ self.algolia_without_auto_index_scope = false
516
+ end
517
+ end
518
+
519
+ def algolia_without_auto_index_scope=(value)
520
+ Thread.current["algolia_without_auto_index_scope_for_#{self.model_name}"] = value
521
+ end
522
+
523
+ def algolia_without_auto_index_scope
524
+ Thread.current["algolia_without_auto_index_scope_for_#{self.model_name}"]
525
+ end
526
+
527
+ def algolia_reindex!(batch_size = AlgoliaSearch::IndexSettings::DEFAULT_BATCH_SIZE, synchronous = false)
528
+ return if algolia_without_auto_index_scope
529
+ algolia_configurations.each do |options, settings|
530
+ next if algolia_indexing_disabled?(options)
531
+ index = algolia_ensure_init(options, settings)
532
+ next if options[:replica]
533
+ last_task = nil
534
+
535
+ algolia_find_in_batches(batch_size) do |group|
536
+ if algolia_conditional_index?(options)
537
+ # delete non-indexable objects
538
+ ids = group.select { |o| !algolia_indexable?(o, options) }.map { |o| algolia_object_id_of(o, options) }
539
+ index.delete_objects(ids.select { |id| !id.blank? })
540
+ # select only indexable objects
541
+ group = group.select { |o| algolia_indexable?(o, options) }
542
+ end
543
+ objects = group.map do |o|
544
+ attributes = settings.get_attributes(o)
545
+ unless attributes.class == Hash
546
+ attributes = attributes.to_hash
547
+ end
548
+ attributes.merge 'objectID' => algolia_object_id_of(o, options)
549
+ end
550
+ last_task = index.save_objects(objects)
551
+ end
552
+ index.wait_task(last_task.raw_response["taskID"]) if last_task and (synchronous || options[:synchronous])
553
+ end
554
+ nil
555
+ end
556
+
557
+ # reindex whole database using a temporary index + move operation
558
+ def algolia_reindex(batch_size = AlgoliaSearch::IndexSettings::DEFAULT_BATCH_SIZE, synchronous = false)
559
+ return if algolia_without_auto_index_scope
560
+
561
+ algolia_configurations.each do |options, settings|
562
+
563
+ next if algolia_indexing_disabled?(options)
564
+
565
+ if options[:primary_settings] && options[:inherit_settings]
566
+ final_settings_map = options[:primary_settings].to_settings
567
+ final_settings_map.merge!(settings.to_settings)
568
+ else
569
+ final_settings_map = settings.to_settings
570
+ end
571
+
572
+ # Always remove the replicas because the index is either:
573
+ # - Temporary index (never set replicas to tmp index you're going to move)
574
+ # - A replica itself so it cannot have replicas
575
+ final_settings_map.delete :slaves
576
+ final_settings_map.delete :replicas
577
+
578
+ # For replica, we set_settings in case they are different or if they have changed
579
+ if options[:slave] || options[:replica]
580
+ replicaIndex = SafeIndex.new(algolia_index_name(options), algoliasearch_options[:raise_on_failure])
581
+ replicaIndex.set_settings final_settings_map
582
+ next
583
+ end
584
+
585
+ src_index_name = algolia_index_name(options)
586
+
587
+ # init temporary index
588
+ ::Algolia::copy_index!(src_index_name, "#{src_index_name}.tmp", %w(settings synonyms rules))
589
+ tmp_index = SafeIndex.new("#{src_index_name}.tmp", !!options[:raise_on_failure])
590
+ tmp_index.set_settings final_settings_map
591
+
592
+ algolia_find_in_batches(batch_size) do |group|
593
+ if algolia_conditional_index?(options)
594
+ # select only indexable objects
595
+ group = group.select { |o| algolia_indexable?(o, options) }
596
+ end
597
+ objects = group.map { |o| settings.get_attributes(o).merge 'objectID' => algolia_object_id_of(o, options) }
598
+ tmp_index.save_objects(objects)
599
+ end
600
+
601
+ move_task = SafeIndex.move_index(tmp_index.name, src_index_name)
602
+ # wait if synchronous
603
+ SafeIndex.new(src_index_name, !!options[:raise_on_failure]).wait_task(move_task["taskID"]) if synchronous || options[:synchronous]
604
+ end
605
+ nil
606
+ end
607
+
608
+ def algolia_set_settings(synchronous = false)
609
+ algolia_configurations.each do |options, settings|
610
+ if options[:primary_settings] && (options[:inherit] || options[:inherit_settings])
611
+ primary = options[:primary_settings].to_settings
612
+ primary.delete :replicas
613
+ primary.delete 'replicas'
614
+ final_settings = primary.merge(settings.to_settings)
615
+ else
616
+ final_settings = settings.to_settings
617
+ end
618
+
619
+ index = SafeIndex.new(algolia_index_name(options), true)
620
+ task = index.set_settings(final_settings)
621
+ index.wait_task(task.raw_response["taskID"]) if synchronous
622
+ end
623
+ end
624
+
625
+ def algolia_index_objects(objects, synchronous = false)
626
+ algolia_configurations.each do |options, settings|
627
+ next if algolia_indexing_disabled?(options)
628
+ index = algolia_ensure_init(options, settings)
629
+ next if options[:replica]
630
+ task = index.save_objects(objects.map { |o| settings.get_attributes(o).merge 'objectID' => algolia_object_id_of(o, options) })
631
+ index.wait_task(task.raw_response["taskID"]) if synchronous || options[:synchronous]
632
+ end
633
+ end
634
+
635
+ def algolia_index!(object, synchronous = false)
636
+ return if algolia_without_auto_index_scope
637
+ algolia_configurations.each do |options, settings|
638
+ next if algolia_indexing_disabled?(options)
639
+ object_id = algolia_object_id_of(object, options)
640
+ index = algolia_ensure_init(options, settings)
641
+ next if options[:replica]
642
+ if algolia_indexable?(object, options)
643
+ raise ArgumentError.new("Cannot index a record with a blank objectID") if object_id.blank?
644
+ if synchronous || options[:synchronous]
645
+ index.save_object!(settings.get_attributes(object).merge 'objectID' => algolia_object_id_of(object, options))
646
+ else
647
+ index.save_object(settings.get_attributes(object).merge 'objectID' => algolia_object_id_of(object, options))
648
+ end
649
+ elsif algolia_conditional_index?(options) && !object_id.blank?
650
+ # remove non-indexable objects
651
+ if synchronous || options[:synchronous]
652
+ index.delete_object!(object_id)
653
+ else
654
+ index.delete_object(object_id)
655
+ end
656
+ end
657
+ end
658
+ nil
659
+ end
660
+
661
+ def algolia_remove_from_index!(object, synchronous = false)
662
+ return if algolia_without_auto_index_scope
663
+ object_id = algolia_object_id_of(object)
664
+ raise ArgumentError.new("Cannot index a record with a blank objectID") if object_id.blank?
665
+ algolia_configurations.each do |options, settings|
666
+ next if algolia_indexing_disabled?(options)
667
+ index = algolia_ensure_init(options, settings)
668
+ next if options[:replica]
669
+ if synchronous || options[:synchronous]
670
+ index.delete_object!(object_id)
671
+ else
672
+ index.delete_object(object_id)
673
+ end
674
+ end
675
+ nil
676
+ end
677
+
678
+ def algolia_clear_index!(synchronous = false)
679
+ algolia_configurations.each do |options, settings|
680
+ next if algolia_indexing_disabled?(options)
681
+ index = algolia_ensure_init(options, settings)
682
+ next if options[:replica]
683
+ synchronous || options[:synchronous] ? index.clear_objects! : index.clear_objects
684
+ @algolia_indexes[settings] = nil
685
+ end
686
+ nil
687
+ end
688
+
689
+ def algolia_raw_search(q, params = {})
690
+ index_name = params.delete(:index) ||
691
+ params.delete('index') ||
692
+ params.delete(:replica) ||
693
+ params.delete('replica')
694
+ index = algolia_index(index_name)
695
+ index.search(q, Hash[params.map { |k,v| [k.to_s, v.to_s] }])
696
+ end
697
+
698
+ module AdditionalMethods
699
+ def self.extended(base)
700
+ class <<base
701
+ alias_method :raw_answer, :algolia_raw_answer unless method_defined? :raw_answer
702
+ alias_method :facets, :algolia_facets unless method_defined? :facets
703
+ end
704
+ end
705
+
706
+ def algolia_raw_answer
707
+ @algolia_json
708
+ end
709
+
710
+ def algolia_facets
711
+ @algolia_json['facets']
712
+ end
713
+
714
+ private
715
+ def algolia_init_raw_answer(json)
716
+ @algolia_json = json
717
+ end
718
+ end
719
+
720
+ def algolia_search(q, params = {})
721
+ if AlgoliaSearch.configuration[:pagination_backend]
722
+ # kaminari and will_paginate start pagination at 1, Algolia starts at 0
723
+ params[:page] = (params.delete('page') || params.delete(:page)).to_i
724
+ params[:page] -= 1 if params[:page].to_i > 0
725
+ end
726
+ json = algolia_raw_search(q, params)
727
+ hit_ids = json['hits'].map { |hit| hit['objectID'] }
728
+ if defined?(::Mongoid::Document) && self.include?(::Mongoid::Document)
729
+ condition_key = algolia_object_id_method.in
730
+ else
731
+ condition_key = algolia_object_id_method
732
+ end
733
+ results_by_id = algoliasearch_options[:type].where(condition_key => hit_ids).index_by do |hit|
734
+ algolia_object_id_of(hit)
735
+ end
736
+ results = json['hits'].map do |hit|
737
+ o = results_by_id[hit['objectID'].to_s]
738
+ if o
739
+ o.highlight_result = hit['_highlightResult']
740
+ o.snippet_result = hit['_snippetResult']
741
+ o
742
+ end
743
+ end.compact
744
+ # Algolia has a default limit of 1000 retrievable hits
745
+ total_hits = json['nbHits'].to_i < json['nbPages'].to_i * json['hitsPerPage'].to_i ?
746
+ json['nbHits'].to_i: json['nbPages'].to_i * json['hitsPerPage'].to_i
747
+ res = AlgoliaSearch::Pagination.create(results, total_hits, algoliasearch_options.merge({ :page => json['page'].to_i + 1, :per_page => json['hitsPerPage'] }))
748
+ res.extend(AdditionalMethods)
749
+ res.send(:algolia_init_raw_answer, json)
750
+ res
751
+ end
752
+
753
+ def algolia_search_for_facet_values(facet, text, params = {})
754
+ index_name = params.delete(:index) ||
755
+ params.delete('index') ||
756
+ params.delete(:replica) ||
757
+ params.delete('replicas')
758
+ index = algolia_index(index_name)
759
+ query = Hash[params.map { |k, v| [k.to_s, v.to_s] }]
760
+ index.search_for_facet_values(facet, text, query)['facetHits']
761
+ end
762
+
763
+ # deprecated (renaming)
764
+ alias :algolia_search_facet :algolia_search_for_facet_values
765
+
766
+ def algolia_index(name = nil)
767
+ if name
768
+ algolia_configurations.each do |o, s|
769
+ return algolia_ensure_init(o, s) if o[:index_name].to_s == name.to_s
770
+ end
771
+ raise ArgumentError.new("Invalid index/replica name: #{name}")
772
+ end
773
+ algolia_ensure_init
774
+ end
775
+
776
+ def algolia_index_name(options = nil)
777
+ options ||= algoliasearch_options
778
+ name = options[:index_name] || model_name.to_s.gsub('::', '_')
779
+ name = "#{name}_#{Rails.env.to_s}" if options[:per_environment]
780
+ name
781
+ end
782
+
783
+ def algolia_must_reindex?(object)
784
+ # use +algolia_dirty?+ method if implemented
785
+ return object.send(:algolia_dirty?) if (object.respond_to?(:algolia_dirty?))
786
+ # Loop over each index to see if a attribute used in records has changed
787
+ algolia_configurations.each do |options, settings|
788
+ next if algolia_indexing_disabled?(options)
789
+ next if options[:replica]
790
+ return true if algolia_object_id_changed?(object, options)
791
+ settings.get_attribute_names(object).each do |k|
792
+ return true if algolia_attribute_changed?(object, k)
793
+ # return true if !object.respond_to?(changed_method) || object.send(changed_method)
794
+ end
795
+ [options[:if], options[:unless]].each do |condition|
796
+ case condition
797
+ when nil
798
+ when String, Symbol
799
+ return true if algolia_attribute_changed?(object, condition)
800
+ else
801
+ # if the :if, :unless condition is a anything else,
802
+ # we have no idea whether we should reindex or not
803
+ # let's always reindex then
804
+ return true
805
+ end
806
+ end
807
+ end
808
+ # By default, we don't reindex
809
+ return false
810
+ end
811
+
812
+ protected
813
+
814
+ def algolia_ensure_init(options = nil, settings = nil, index_settings = nil)
815
+ raise ArgumentError.new('No `algoliasearch` block found in your model.') if algoliasearch_settings.nil?
816
+
817
+ @algolia_indexes ||= {}
818
+
819
+ options ||= algoliasearch_options
820
+ settings ||= algoliasearch_settings
821
+
822
+ return @algolia_indexes[settings] if @algolia_indexes[settings]
823
+
824
+ @algolia_indexes[settings] = SafeIndex.new(algolia_index_name(options), algoliasearch_options[:raise_on_failure])
825
+
826
+ index_settings ||= settings.to_settings
827
+ index_settings = options[:primary_settings].to_settings.merge(index_settings) if options[:inherit]
828
+
829
+ options[:check_settings] = true if options[:check_settings].nil?
830
+
831
+ current_settings = if options[:check_settings]
832
+ @algolia_indexes[settings].get_settings(:getVersion => 1) rescue nil # if the index doesn't exist
833
+ end
834
+
835
+ if !algolia_indexing_disabled?(options) && options[:check_settings] && algoliasearch_settings_changed?(current_settings, index_settings)
836
+ replicas = index_settings.delete(:replicas) ||
837
+ index_settings.delete('replicas')
838
+ index_settings[:replicas] = replicas unless replicas.nil? || options[:inherit]
839
+ @algolia_indexes[settings].set_settings!(index_settings)
840
+ end
841
+
842
+ @algolia_indexes[settings]
843
+ end
844
+
845
+ private
846
+
847
+ def algolia_configurations
848
+ raise ArgumentError.new('No `algoliasearch` block found in your model.') if algoliasearch_settings.nil?
849
+ if @configurations.nil?
850
+ @configurations = {}
851
+ @configurations[algoliasearch_options] = algoliasearch_settings
852
+ algoliasearch_settings.additional_indexes.each do |k,v|
853
+ @configurations[k] = v
854
+
855
+ if v.additional_indexes.any?
856
+ v.additional_indexes.each do |options, index|
857
+ @configurations[options] = index
858
+ end
859
+ end
860
+ end
861
+ end
862
+ @configurations
863
+ end
864
+
865
+ def algolia_object_id_method(options = nil)
866
+ options ||= algoliasearch_options
867
+ options[:id] || options[:object_id] || :id
868
+ end
869
+
870
+ def algolia_object_id_of(o, options = nil)
871
+ o.send(algolia_object_id_method(options)).to_s
872
+ end
873
+
874
+ def algolia_object_id_changed?(o, options = nil)
875
+ changed = algolia_attribute_changed?(o, algolia_object_id_method(options))
876
+ changed.nil? ? false : changed
877
+ end
878
+
879
+ def algoliasearch_settings_changed?(prev, current)
880
+ return true if prev.nil?
881
+ current.each do |k, v|
882
+ prev_v = prev[k.to_s]
883
+ if v.is_a?(Array) and prev_v.is_a?(Array)
884
+ # compare array of strings, avoiding symbols VS strings comparison
885
+ return true if v.map { |x| x.to_s } != prev_v.map { |x| x.to_s }
886
+ else
887
+ return true if prev_v != v
888
+ end
889
+ end
890
+ false
891
+ end
892
+
893
+ def algolia_full_const_get(name)
894
+ list = name.split('::')
895
+ list.shift if list.first.blank?
896
+ obj = Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f < 1.9 ? Object : self
897
+ list.each do |x|
898
+ # This is required because const_get tries to look for constants in the
899
+ # ancestor chain, but we only want constants that are HERE
900
+ obj = obj.const_defined?(x) ? obj.const_get(x) : obj.const_missing(x)
901
+ end
902
+ obj
903
+ end
904
+
905
+ def algolia_conditional_index?(options = nil)
906
+ options ||= algoliasearch_options
907
+ options[:if].present? || options[:unless].present?
908
+ end
909
+
910
+ def algolia_indexable?(object, options = nil)
911
+ options ||= algoliasearch_options
912
+ if_passes = options[:if].blank? || algolia_constraint_passes?(object, options[:if])
913
+ unless_passes = options[:unless].blank? || !algolia_constraint_passes?(object, options[:unless])
914
+ if_passes && unless_passes
915
+ end
916
+
917
+ def algolia_constraint_passes?(object, constraint)
918
+ case constraint
919
+ when Symbol
920
+ object.send(constraint)
921
+ when String
922
+ object.send(constraint.to_sym)
923
+ when Enumerable
924
+ # All constraints must pass
925
+ constraint.all? { |inner_constraint| algolia_constraint_passes?(object, inner_constraint) }
926
+ else
927
+ if constraint.respond_to?(:call) # Proc
928
+ constraint.call(object)
929
+ else
930
+ raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})"
931
+ end
932
+ end
933
+ end
934
+
935
+ def algolia_indexing_disabled?(options = nil)
936
+ options ||= algoliasearch_options
937
+ constraint = options[:disable_indexing] || options['disable_indexing']
938
+ case constraint
939
+ when nil
940
+ return false
941
+ when true, false
942
+ return constraint
943
+ when String, Symbol
944
+ return send(constraint)
945
+ else
946
+ return constraint.call if constraint.respond_to?(:call) # Proc
947
+ end
948
+ raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})"
949
+ end
950
+
951
+ def algolia_find_in_batches(batch_size, &block)
952
+ if (defined?(::ActiveRecord) && ancestors.include?(::ActiveRecord::Base)) || respond_to?(:find_in_batches)
953
+ find_in_batches(:batch_size => batch_size, &block)
954
+ elsif defined?(::Sequel) && self < Sequel::Model
955
+ dataset.extension(:pagination).each_page(batch_size, &block)
956
+ else
957
+ # don't worry, mongoid has its own underlying cursor/streaming mechanism
958
+ items = []
959
+ all.each do |item|
960
+ items << item
961
+ if items.length % batch_size == 0
962
+ yield items
963
+ items = []
964
+ end
965
+ end
966
+ yield items unless items.empty?
967
+ end
968
+ end
969
+
970
+ def algolia_attribute_changed?(object, attr_name)
971
+ # if one of two method is implemented, we return its result
972
+ # true/false means whether it has changed or not
973
+ # +#{attr_name}_changed?+ always defined for automatic attributes but deprecated after Rails 5.2
974
+ # +will_save_change_to_#{attr_name}?+ should be use instead for Rails 5.2+, also defined for automatic attributes.
975
+ # If none of the method are defined, it's a dynamic attribute
976
+
977
+ method_name = "#{attr_name}_changed?"
978
+ if object.respond_to?(method_name)
979
+ # If +#{attr_name}_changed?+ respond we want to see if the method is user defined or if it's automatically
980
+ # defined by Rails.
981
+ # If it's user-defined, we call it.
982
+ # If it's automatic we check ActiveRecord version to see if this method is deprecated
983
+ # and try to call +will_save_change_to_#{attr_name}?+ instead.
984
+ # See: https://github.com/algolia/algoliasearch-rails/pull/338
985
+ # This feature is not compatible with Ruby 1.8
986
+ # In this case, we always call #{attr_name}_changed?
987
+ if Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f < 1.9
988
+ return object.send(method_name)
989
+ end
990
+ unless automatic_changed_method?(object, method_name) && automatic_changed_method_deprecated?
991
+ return object.send(method_name)
992
+ end
993
+ end
994
+
995
+ if object.respond_to?("will_save_change_to_#{attr_name}?")
996
+ return object.send("will_save_change_to_#{attr_name}?")
997
+ end
998
+
999
+ # We don't know if the attribute has changed, so conservatively assume it has
1000
+ true
1001
+ end
1002
+
1003
+ def automatic_changed_method?(object, method_name)
1004
+ raise ArgumentError.new("Method #{method_name} doesn't exist on #{object.class.name}") unless object.respond_to?(method_name)
1005
+ file = object.method(method_name).source_location[0]
1006
+ file.end_with?("active_model/attribute_methods.rb")
1007
+ end
1008
+
1009
+ def automatic_changed_method_deprecated?
1010
+ (defined?(::ActiveRecord) && ActiveRecord::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 1) ||
1011
+ (defined?(::ActiveRecord) && ActiveRecord::VERSION::MAJOR > 5)
1012
+ end
1013
+ end
1014
+
1015
+ # these are the instance methods included
1016
+ module InstanceMethods
1017
+
1018
+ def self.included(base)
1019
+ base.instance_eval do
1020
+ alias_method :index!, :algolia_index! unless method_defined? :index!
1021
+ alias_method :remove_from_index!, :algolia_remove_from_index! unless method_defined? :remove_from_index!
1022
+ end
1023
+ end
1024
+
1025
+ def algolia_index!(synchronous = false)
1026
+ self.class.algolia_index!(self, synchronous || algolia_synchronous?)
1027
+ end
1028
+
1029
+ def algolia_remove_from_index!(synchronous = false)
1030
+ self.class.algolia_remove_from_index!(self, synchronous || algolia_synchronous?)
1031
+ end
1032
+
1033
+ def algolia_enqueue_remove_from_index!(synchronous)
1034
+ if algoliasearch_options[:enqueue]
1035
+ algoliasearch_options[:enqueue].call(self, true) unless self.class.send(:algolia_indexing_disabled?, algoliasearch_options)
1036
+ else
1037
+ algolia_remove_from_index!(synchronous || algolia_synchronous?)
1038
+ end
1039
+ end
1040
+
1041
+ def algolia_enqueue_index!(synchronous)
1042
+ if algoliasearch_options[:enqueue]
1043
+ algoliasearch_options[:enqueue].call(self, false) unless self.class.send(:algolia_indexing_disabled?, algoliasearch_options)
1044
+ else
1045
+ algolia_index!(synchronous)
1046
+ end
1047
+ end
1048
+
1049
+ private
1050
+
1051
+ def algolia_synchronous?
1052
+ @algolia_synchronous == true
1053
+ end
1054
+
1055
+ def algolia_mark_synchronous
1056
+ @algolia_synchronous = true
1057
+ end
1058
+
1059
+ def algolia_mark_for_auto_indexing
1060
+ @algolia_auto_indexing = true
1061
+ end
1062
+
1063
+ def algolia_mark_must_reindex
1064
+ # algolia_must_reindex flag is reset after every commit as part. If we must reindex at any point in
1065
+ # a stransaction, keep flag set until it is explicitly unset
1066
+ @algolia_must_reindex ||=
1067
+ if defined?(::Sequel) && is_a?(Sequel::Model)
1068
+ new? || self.class.algolia_must_reindex?(self)
1069
+ else
1070
+ new_record? || self.class.algolia_must_reindex?(self)
1071
+ end
1072
+ true
1073
+ end
1074
+
1075
+ def algolia_perform_index_tasks
1076
+ return if !@algolia_auto_indexing || @algolia_must_reindex == false
1077
+ algolia_enqueue_index!(algolia_synchronous?)
1078
+ remove_instance_variable(:@algolia_auto_indexing) if instance_variable_defined?(:@algolia_auto_indexing)
1079
+ remove_instance_variable(:@algolia_synchronous) if instance_variable_defined?(:@algolia_synchronous)
1080
+ remove_instance_variable(:@algolia_must_reindex) if instance_variable_defined?(:@algolia_must_reindex)
1081
+ end
1082
+ end
1083
+ end