yc-algoliasearch-rails 2.1.4

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