searchkick 4.4.0 → 5.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,6 @@
1
1
  module Searchkick
2
2
  class Query
3
+ include Enumerable
3
4
  extend Forwardable
4
5
 
5
6
  @@metric_aggs = [:avg, :cardinality, :max, :min, :sum]
@@ -12,11 +13,12 @@ module Searchkick
12
13
  :took, :error, :model_name, :entry_name, :total_count, :total_entries,
13
14
  :current_page, :per_page, :limit_value, :padding, :total_pages, :num_pages,
14
15
  :offset_value, :offset, :previous_page, :prev_page, :next_page, :first_page?, :last_page?,
15
- :out_of_range?, :hits, :response, :to_a, :first, :scroll
16
+ :out_of_range?, :hits, :response, :to_a, :first, :scroll, :highlights, :with_highlights,
17
+ :with_score, :misspellings?, :scroll_id, :clear_scroll, :missing_records, :with_hit
16
18
 
17
19
  def initialize(klass, term = "*", **options)
18
20
  unknown_keywords = options.keys - [:aggs, :block, :body, :body_options, :boost,
19
- :boost_by, :boost_by_distance, :boost_by_recency, :boost_where, :conversions, :conversions_term, :debug, :emoji, :exclude, :execute, :explain,
21
+ :boost_by, :boost_by_distance, :boost_by_recency, :boost_where, :conversions, :conversions_term, :debug, :emoji, :exclude, :explain,
20
22
  :fields, :highlight, :includes, :index_name, :indices_boost, :limit, :load,
21
23
  :match, :misspellings, :models, :model_includes, :offset, :operator, :order, :padding, :page, :per_page, :profile,
22
24
  :request_params, :routing, :scope_results, :scroll, :select, :similar, :smart_aggs, :suggest, :total_entries, :track, :type, :where]
@@ -25,7 +27,7 @@ module Searchkick
25
27
  term = term.to_s
26
28
 
27
29
  if options[:emoji]
28
- term = EmojiParser.parse_unicode(term) { |e| " #{e.name} " }.strip
30
+ term = EmojiParser.parse_unicode(term) { |e| " #{e.name.tr('_', ' ')} " }.strip
29
31
  end
30
32
 
31
33
  @klass = klass
@@ -73,7 +75,8 @@ module Searchkick
73
75
  elsif searchkick_index
74
76
  searchkick_index.name
75
77
  else
76
- "_all"
78
+ # fixes warning about accessing system indices
79
+ "*,-.*"
77
80
  end
78
81
 
79
82
  params = {
@@ -109,7 +112,12 @@ module Searchkick
109
112
  request_params = query.except(:index, :type, :body)
110
113
 
111
114
  # no easy way to tell which host the client will use
112
- host = Searchkick.client.transport.hosts.first
115
+ host =
116
+ if Searchkick.client.transport.respond_to?(:transport)
117
+ Searchkick.client.transport.transport.hosts.first
118
+ else
119
+ Searchkick.client.transport.hosts.first
120
+ end
113
121
  credentials = host[:user] || host[:password] ? "#{host[:user]}:#{host[:password]}@" : nil
114
122
  params = ["pretty"]
115
123
  request_params.each do |k, v|
@@ -140,9 +148,6 @@ module Searchkick
140
148
  }
141
149
 
142
150
  if options[:debug]
143
- # can remove when minimum Ruby version is 2.5
144
- require "pp"
145
-
146
151
  puts "Searchkick Version: #{Searchkick::VERSION}"
147
152
  puts "Elasticsearch Version: #{Searchkick.server_version}"
148
153
  puts
@@ -182,11 +187,11 @@ module Searchkick
182
187
  end
183
188
 
184
189
  # set execute for multi search
185
- @execute = Searchkick::Results.new(searchkick_klass, response, opts)
190
+ @execute = Results.new(searchkick_klass, response, opts)
186
191
  end
187
192
 
188
193
  def retry_misspellings?(response)
189
- @misspellings_below && Searchkick::Results.new(searchkick_klass, response).total_count < @misspellings_below
194
+ @misspellings_below && response["error"].nil? && Results.new(searchkick_klass, response).total_count < @misspellings_below
190
195
  end
191
196
 
192
197
  private
@@ -194,7 +199,11 @@ module Searchkick
194
199
  def handle_error(e)
195
200
  status_code = e.message[1..3].to_i
196
201
  if status_code == 404
197
- raise MissingIndexError, "Index missing - run #{reindex_command}"
202
+ if e.message.include?("No search context found for id")
203
+ raise MissingIndexError, "No search context found for id"
204
+ else
205
+ raise MissingIndexError, "Index missing - run #{reindex_command}"
206
+ end
198
207
  elsif status_code == 500 && (
199
208
  e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") ||
200
209
  e.message.include?("No query registered for [multi_match]") ||
@@ -202,14 +211,14 @@ module Searchkick
202
211
  e.message.include?("No query registered for [function_score]")
203
212
  )
204
213
 
205
- raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
214
+ raise UnsupportedVersionError
206
215
  elsif status_code == 400
207
216
  if (
208
217
  e.message.include?("bool query does not support [filter]") ||
209
218
  e.message.include?("[bool] filter does not support [filter]")
210
219
  )
211
220
 
212
- raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
221
+ raise UnsupportedVersionError
213
222
  elsif e.message =~ /analyzer \[searchkick_.+\] not found/
214
223
  raise InvalidQueryError, "Bad mapping - run #{reindex_command}"
215
224
  else
@@ -225,7 +234,14 @@ module Searchkick
225
234
  end
226
235
 
227
236
  def execute_search
228
- Searchkick.client.search(params)
237
+ name = searchkick_klass ? "#{searchkick_klass.name} Search" : "Search"
238
+ event = {
239
+ name: name,
240
+ query: params
241
+ }
242
+ ActiveSupport::Notifications.instrument("search.searchkick", event) do
243
+ Searchkick.client.search(params)
244
+ end
229
245
  end
230
246
 
231
247
  def prepare
@@ -239,9 +255,15 @@ module Searchkick
239
255
  default_limit = searchkick_options[:deep_paging] ? 1_000_000_000 : 10_000
240
256
  per_page = (options[:limit] || options[:per_page] || default_limit).to_i
241
257
  padding = [options[:padding].to_i, 0].max
242
- offset = options[:offset] || (page - 1) * per_page + padding
258
+ offset = (options[:offset] || (page - 1) * per_page + padding).to_i
243
259
  scroll = options[:scroll]
244
260
 
261
+ max_result_window = searchkick_options[:max_result_window]
262
+ if max_result_window
263
+ offset = max_result_window if offset > max_result_window
264
+ per_page = max_result_window - offset if offset + per_page > max_result_window
265
+ end
266
+
245
267
  # model and eager loading
246
268
  load = options[:load].nil? ? true : options[:load]
247
269
 
@@ -260,9 +282,10 @@ module Searchkick
260
282
  should = []
261
283
 
262
284
  if options[:similar]
285
+ like = options[:similar] == true ? term : options[:similar]
263
286
  query = {
264
287
  more_like_this: {
265
- like: term,
288
+ like: like,
266
289
  min_doc_freq: 1,
267
290
  min_term_freq: 1,
268
291
  analyzer: "searchkick_search2"
@@ -350,11 +373,11 @@ module Searchkick
350
373
  field_misspellings = misspellings && (!misspellings_fields || misspellings_fields.include?(base_field(field)))
351
374
 
352
375
  if field == "_all" || field.end_with?(".analyzed")
353
- shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || field_misspellings == false || (!below73? && !track_total_hits?)
376
+ shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || field_misspellings == false || (!below73? && !track_total_hits?) || match_type == :match_phrase || !below80? || Searchkick.opensearch?
354
377
  qs << shared_options.merge(analyzer: "searchkick_search")
355
378
 
356
- # searchkick_search and searchkick_search2 are the same for ukrainian
357
- unless %w(japanese korean polish ukrainian vietnamese).include?(searchkick_options[:language])
379
+ # searchkick_search and searchkick_search2 are the same for some languages
380
+ unless %w(japanese japanese2 korean polish ukrainian vietnamese).include?(searchkick_options[:language])
358
381
  qs << shared_options.merge(analyzer: "searchkick_search2")
359
382
  end
360
383
  exclude_analyzer = "searchkick_search2"
@@ -375,11 +398,6 @@ module Searchkick
375
398
 
376
399
  if field.start_with?("*.")
377
400
  q2 = qs.map { |q| {multi_match: q.merge(fields: [field], type: match_type == :match_phrase ? "phrase" : "best_fields")} }
378
- if below61?
379
- q2.each do |q|
380
- q[:multi_match].delete(:fuzzy_transpositions)
381
- end
382
- end
383
401
  else
384
402
  q2 = qs.map { |q| {match_type => {field => q}} }
385
403
  end
@@ -431,7 +449,7 @@ module Searchkick
431
449
  payload = {}
432
450
 
433
451
  # type when inheritance
434
- where = (options[:where] || {}).dup
452
+ where = ensure_permitted(options[:where] || {}).dup
435
453
  if searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
436
454
  where[:type] = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v, true) }
437
455
  end
@@ -491,7 +509,7 @@ module Searchkick
491
509
  set_highlights(payload, fields) if options[:highlight]
492
510
 
493
511
  # timeout shortly after client times out
494
- payload[:timeout] ||= "#{Searchkick.search_timeout + 1}s"
512
+ payload[:timeout] ||= "#{((Searchkick.search_timeout + 1) * 1000).round}ms"
495
513
 
496
514
  # An empty array will cause only the _id and _type for each hit to be returned
497
515
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
@@ -688,9 +706,9 @@ module Searchkick
688
706
  def set_boost_by(multiply_filters, custom_filters)
689
707
  boost_by = options[:boost_by] || {}
690
708
  if boost_by.is_a?(Array)
691
- boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
709
+ boost_by = boost_by.to_h { |f| [f, {factor: 1}] }
692
710
  elsif boost_by.is_a?(Hash)
693
- multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map { |i| Hash[i] }
711
+ multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map(&:to_h)
694
712
  end
695
713
  boost_by[options[:boost]] = {factor: 1} if options[:boost]
696
714
 
@@ -755,7 +773,7 @@ module Searchkick
755
773
 
756
774
  def set_highlights(payload, fields)
757
775
  payload[:highlight] = {
758
- fields: Hash[fields.map { |f| [f, {}] }],
776
+ fields: fields.to_h { |f| [f, {}] },
759
777
  fragment_size: 0
760
778
  }
761
779
 
@@ -789,7 +807,7 @@ module Searchkick
789
807
  aggs = options[:aggs]
790
808
  payload[:aggs] = {}
791
809
 
792
- aggs = Hash[aggs.map { |f| [f, {}] }] if aggs.is_a?(Array) # convert to more advanced syntax
810
+ aggs = aggs.to_h { |f| [f, {}] } if aggs.is_a?(Array) # convert to more advanced syntax
793
811
  aggs.each do |field, agg_options|
794
812
  size = agg_options[:limit] ? agg_options[:limit] : 1_000
795
813
  shared_agg_options = agg_options.except(:limit, :field, :ranges, :date_ranges, :where)
@@ -828,8 +846,9 @@ module Searchkick
828
846
  end
829
847
 
830
848
  where = {}
831
- where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
832
- agg_filters = where_filters(where.merge(agg_options[:where] || {}))
849
+ where = ensure_permitted(options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
850
+ agg_where = ensure_permitted(agg_options[:where] || {})
851
+ agg_filters = where_filters(where.merge(agg_where))
833
852
 
834
853
  # only do one level comparison for simplicity
835
854
  filters.select! do |filter|
@@ -864,19 +883,17 @@ module Searchkick
864
883
  }
865
884
  end
866
885
 
867
- # TODO id transformation for arrays
868
886
  def set_order(payload)
869
- order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
870
- id_field = :_id
871
- payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? id_field : k, v] }]
887
+ payload[:sort] = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
872
888
  end
873
889
 
874
- def where_filters(where)
875
- # if where.respond_to?(:permitted?) && !where.permitted?
876
- # # TODO check in more places
877
- # Searchkick.warn("Passing unpermitted parameters will raise an exception in Searchkick 5")
878
- # end
890
+ # provides *very* basic protection from unfiltered parameters
891
+ # this is not meant to be comprehensive and may be expanded in the future
892
+ def ensure_permitted(obj)
893
+ obj.to_h
894
+ end
879
895
 
896
+ def where_filters(where)
880
897
  filters = []
881
898
  (where || {}).each do |field, value|
882
899
  field = :_id if field.to_s == "id"
@@ -896,12 +913,7 @@ module Searchkick
896
913
  else
897
914
  # expand ranges
898
915
  if value.is_a?(Range)
899
- # infinite? added in Ruby 2.4
900
- if value.end.nil? || (value.end.respond_to?(:infinite?) && value.end.infinite?)
901
- value = {gte: value.first}
902
- else
903
- value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
904
- end
916
+ value = expand_range(value)
905
917
  end
906
918
 
907
919
  value = {in: value} if value.is_a?(Array)
@@ -953,7 +965,7 @@ module Searchkick
953
965
  }
954
966
  }
955
967
  }
956
- when :like
968
+ when :like, :ilike
957
969
  # based on Postgres
958
970
  # https://www.postgresql.org/docs/current/functions-matching.html
959
971
  # % matches zero or more characters
@@ -961,13 +973,22 @@ module Searchkick
961
973
  # \ is escape character
962
974
  # escape Lucene reserved characters
963
975
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/regexp-syntax.html#regexp-optional-operators
964
- reserved = %w(. ? + * | { } [ ] ( ) " \\)
976
+ reserved = %w(\\ . ? + * | { } [ ] ( ) ")
965
977
  regex = op_value.dup
966
978
  reserved.each do |v|
967
- regex.gsub!(v, "\\" + v)
979
+ regex.gsub!(v, "\\\\" + v)
968
980
  end
969
981
  regex = regex.gsub(/(?<!\\)%/, ".*").gsub(/(?<!\\)_/, ".").gsub("\\%", "%").gsub("\\_", "_")
970
- filters << {regexp: {field => {value: regex}}}
982
+
983
+ if op == :ilike
984
+ if below710?
985
+ raise ArgumentError, "ilike requires Elasticsearch 7.10+"
986
+ else
987
+ filters << {regexp: {field => {value: regex, flags: "NONE", case_insensitive: true}}}
988
+ end
989
+ else
990
+ filters << {regexp: {field => {value: regex, flags: "NONE"}}}
991
+ end
971
992
  when :prefix
972
993
  filters << {prefix: {field => {value: op_value}}}
973
994
  when :regexp # support for regexp queries without using a regexp ruby object
@@ -994,7 +1015,7 @@ module Searchkick
994
1015
  when :lte
995
1016
  {to: op_value, include_upper: true}
996
1017
  else
997
- raise "Unknown where operator: #{op.inspect}"
1018
+ raise ArgumentError, "Unknown where operator: #{op.inspect}"
998
1019
  end
999
1020
  # issue 132
1000
1021
  if (existing = filters.find { |f| f[:range] && f[:range][field] })
@@ -1022,40 +1043,37 @@ module Searchkick
1022
1043
  elsif value.nil?
1023
1044
  {bool: {must_not: {exists: {field: field}}}}
1024
1045
  elsif value.is_a?(Regexp)
1025
- if value.casefold?
1026
- Searchkick.warn("Case-insensitive flag does not work with Elasticsearch")
1027
- end
1028
-
1029
1046
  source = value.source
1030
- unless source.start_with?("\\A") && source.end_with?("\\z")
1031
- # https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html
1032
- Searchkick.warn("Regular expressions are always anchored in Elasticsearch")
1033
- end
1047
+
1048
+ # TODO handle other regexp options
1034
1049
 
1035
1050
  # TODO handle other anchor characters, like ^, $, \Z
1036
1051
  if source.start_with?("\\A")
1037
1052
  source = source[2..-1]
1038
1053
  else
1039
- # TODO uncomment in Searchkick 5
1040
- # source = ".*#{source}"
1054
+ source = ".*#{source}"
1041
1055
  end
1042
1056
 
1043
1057
  if source.end_with?("\\z")
1044
1058
  source = source[0..-3]
1045
1059
  else
1046
- # TODO uncomment in Searchkick 5
1047
- # source = "#{source}.*"
1060
+ source = "#{source}.*"
1048
1061
  end
1049
1062
 
1050
- {regexp: {field => {value: source, flags: "NONE"}}}
1063
+ if below710?
1064
+ if value.casefold?
1065
+ raise ArgumentError, "Case-insensitive flag does not work with Elasticsearch < 7.10"
1066
+ end
1067
+ {regexp: {field => {value: source, flags: "NONE"}}}
1068
+ else
1069
+ {regexp: {field => {value: source, flags: "NONE", case_insensitive: value.casefold?}}}
1070
+ end
1051
1071
  else
1052
1072
  # TODO add this for other values
1053
1073
  if value.as_json.is_a?(Enumerable)
1054
1074
  # query will fail, but this is better
1055
1075
  # same message as Active Record
1056
- # TODO make TypeError
1057
- # raise InvalidQueryError for backward compatibility
1058
- raise Searchkick::InvalidQueryError, "can't cast #{value.class.name}"
1076
+ raise TypeError, "can't cast #{value.class.name}"
1059
1077
  end
1060
1078
 
1061
1079
  {term: {field => {value: value}}}
@@ -1118,26 +1136,29 @@ module Searchkick
1118
1136
  end
1119
1137
  end
1120
1138
 
1139
+ def expand_range(range)
1140
+ expanded = {}
1141
+ expanded[:gte] = range.begin if range.begin
1142
+
1143
+ if range.end && !(range.end.respond_to?(:infinite?) && range.end.infinite?)
1144
+ expanded[range.exclude_end? ? :lt : :lte] = range.end
1145
+ end
1146
+
1147
+ expanded
1148
+ end
1149
+
1121
1150
  def base_field(k)
1122
1151
  k.sub(/\.(analyzed|word_start|word_middle|word_end|text_start|text_middle|text_end|exact)\z/, "")
1123
1152
  end
1124
1153
 
1125
1154
  def track_total_hits?
1126
- (searchkick_options[:deep_paging] && !below70?) || body_options[:track_total_hits]
1155
+ searchkick_options[:deep_paging] || body_options[:track_total_hits]
1127
1156
  end
1128
1157
 
1129
1158
  def body_options
1130
1159
  options[:body_options] || {}
1131
1160
  end
1132
1161
 
1133
- def below61?
1134
- Searchkick.server_below?("6.1.0")
1135
- end
1136
-
1137
- def below70?
1138
- Searchkick.server_below?("7.0.0")
1139
- end
1140
-
1141
1162
  def below73?
1142
1163
  Searchkick.server_below?("7.3.0")
1143
1164
  end
@@ -1145,5 +1166,13 @@ module Searchkick
1145
1166
  def below75?
1146
1167
  Searchkick.server_below?("7.5.0")
1147
1168
  end
1169
+
1170
+ def below710?
1171
+ Searchkick.server_below?("7.10.0")
1172
+ end
1173
+
1174
+ def below80?
1175
+ Searchkick.server_below?("8.0.0")
1176
+ end
1148
1177
  end
1149
1178
  end
@@ -25,6 +25,7 @@ module Searchkick
25
25
  {delete: record_data}
26
26
  end
27
27
 
28
+ # custom id can be useful for load: false
28
29
  def search_id
29
30
  id = record.respond_to?(:search_document_id) ? record.search_document_id : record.id
30
31
  id.is_a?(Numeric) ? id : id.to_s
@@ -39,7 +40,6 @@ module Searchkick
39
40
  _index: index.name,
40
41
  _id: search_id
41
42
  }
42
- data[:_type] = document_type if Searchkick.server_below7?
43
43
  data[:routing] = record.search_routing if record.respond_to?(:search_routing)
44
44
  data
45
45
  end
@@ -1,78 +1,163 @@
1
1
  module Searchkick
2
2
  class RecordIndexer
3
- attr_reader :record, :index
3
+ attr_reader :index
4
4
 
5
- def initialize(record)
6
- @record = record
7
- @index = record.class.searchkick_index
5
+ def initialize(index)
6
+ @index = index
8
7
  end
9
8
 
10
- def reindex(method_name = nil, refresh: false, mode: nil)
11
- unless [:inline, true, nil, :async, :queue].include?(mode)
12
- raise ArgumentError, "Invalid value for mode"
13
- end
14
-
15
- mode ||= Searchkick.callbacks_value || index.options[:callbacks] || true
9
+ def reindex(records, mode:, method_name:, full: false, single: false)
10
+ # prevents exists? check if records is a relation
11
+ records = records.to_a
12
+ return if records.empty?
16
13
 
17
14
  case mode
15
+ when :async
16
+ unless defined?(ActiveJob)
17
+ raise Error, "Active Job not found"
18
+ end
19
+
20
+ # we could likely combine ReindexV2Job, BulkReindexJob, and ProcessBatchJob
21
+ # but keep them separate for now
22
+ if single
23
+ record = records.first
24
+
25
+ # always pass routing in case record is deleted
26
+ # before the async job runs
27
+ if record.respond_to?(:search_routing)
28
+ routing = record.search_routing
29
+ end
30
+
31
+ Searchkick::ReindexV2Job.perform_later(
32
+ record.class.name,
33
+ record.id.to_s,
34
+ method_name ? method_name.to_s : nil,
35
+ routing: routing,
36
+ index_name: index.name
37
+ )
38
+ else
39
+ Searchkick::BulkReindexJob.perform_later(
40
+ class_name: records.first.class.searchkick_options[:class_name],
41
+ record_ids: records.map { |r| r.id.to_s },
42
+ index_name: index.name,
43
+ method_name: method_name ? method_name.to_s : nil
44
+ )
45
+ end
18
46
  when :queue
19
47
  if method_name
20
- raise Searchkick::Error, "Partial reindex not supported with queue option"
48
+ raise Error, "Partial reindex not supported with queue option"
21
49
  end
22
50
 
23
- # always pass routing in case record is deleted
24
- # before the queue job runs
25
- if record.respond_to?(:search_routing)
26
- routing = record.search_routing
27
- end
51
+ index.reindex_queue.push_records(records)
52
+ when true, :inline
53
+ index_records, other_records = records.partition { |r| index_record?(r) }
54
+ import_inline(index_records, !full ? other_records : [], method_name: method_name, single: single)
55
+ else
56
+ raise ArgumentError, "Invalid value for mode"
57
+ end
28
58
 
29
- # escape pipe with double pipe
30
- value = queue_escape(record.id.to_s)
31
- value = "#{value}|#{queue_escape(routing)}" if routing
32
- index.reindex_queue.push(value)
33
- when :async
34
- unless defined?(ActiveJob)
35
- raise Searchkick::Error, "Active Job not found"
36
- end
59
+ # return true like model and relation reindex for now
60
+ true
61
+ end
37
62
 
38
- # always pass routing in case record is deleted
39
- # before the async job runs
40
- if record.respond_to?(:search_routing)
41
- routing = record.search_routing
42
- end
63
+ def reindex_items(klass, items, method_name:, single: false)
64
+ routing = items.to_h { |r| [r[:id], r[:routing]] }
65
+ record_ids = routing.keys
43
66
 
44
- Searchkick::ReindexV2Job.perform_later(
45
- record.class.name,
46
- record.id.to_s,
47
- method_name ? method_name.to_s : nil,
48
- routing: routing
49
- )
50
- else # bulk, inline/true/nil
51
- reindex_record(method_name)
67
+ relation = Searchkick.load_records(klass, record_ids)
68
+ # call search_import even for single records for nested associations
69
+ relation = relation.search_import if relation.respond_to?(:search_import)
70
+ records = relation.select(&:should_index?)
52
71
 
53
- index.refresh if refresh
54
- end
72
+ # determine which records to delete
73
+ delete_ids = record_ids - records.map { |r| r.id.to_s }
74
+ delete_records =
75
+ delete_ids.map do |id|
76
+ construct_record(klass, id, routing[id])
77
+ end
78
+
79
+ import_inline(records, delete_records, method_name: method_name, single: single)
55
80
  end
56
81
 
57
82
  private
58
83
 
59
- def queue_escape(value)
60
- value.gsub("|", "||")
84
+ def index_record?(record)
85
+ record.persisted? && !record.destroyed? && record.should_index?
61
86
  end
62
87
 
63
- def reindex_record(method_name)
64
- if record.destroyed? || !record.persisted? || !record.should_index?
65
- begin
66
- index.remove(record)
67
- rescue Elasticsearch::Transport::Transport::Errors::NotFound
68
- # do nothing
88
+ # import in single request with retries
89
+ def import_inline(index_records, delete_records, method_name:, single:)
90
+ return if index_records.empty? && delete_records.empty?
91
+
92
+ maybe_bulk(index_records, delete_records, method_name, single) do
93
+ if index_records.any?
94
+ if method_name
95
+ index.bulk_update(index_records, method_name)
96
+ else
97
+ index.bulk_index(index_records)
98
+ end
69
99
  end
100
+
101
+ if delete_records.any?
102
+ index.bulk_delete(delete_records)
103
+ end
104
+ end
105
+ end
106
+
107
+ def maybe_bulk(index_records, delete_records, method_name, single)
108
+ if Searchkick.callbacks_value == :bulk
109
+ yield
70
110
  else
71
- if method_name
72
- index.update_record(record, method_name)
73
- else
74
- index.store(record)
111
+ # set action and data
112
+ action =
113
+ if single && index_records.empty?
114
+ "Remove"
115
+ elsif method_name
116
+ "Update"
117
+ else
118
+ single ? "Store" : "Import"
119
+ end
120
+ record = index_records.first || delete_records.first
121
+ name = record.class.searchkick_klass.name
122
+ message = lambda do |event|
123
+ event[:name] = "#{name} #{action}"
124
+ if single
125
+ event[:id] = index.search_id(record)
126
+ else
127
+ event[:count] = index_records.size + delete_records.size
128
+ end
129
+ end
130
+
131
+ with_retries do
132
+ Searchkick.callbacks(:bulk, message: message) do
133
+ yield
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ def construct_record(klass, id, routing)
140
+ record = klass.new
141
+ record.id = id
142
+ if routing
143
+ record.define_singleton_method(:search_routing) do
144
+ routing
145
+ end
146
+ end
147
+ record
148
+ end
149
+
150
+ def with_retries
151
+ retries = 0
152
+
153
+ begin
154
+ yield
155
+ rescue Faraday::ClientError => e
156
+ if retries < 1
157
+ retries += 1
158
+ retry
75
159
  end
160
+ raise e
76
161
  end
77
162
  end
78
163
  end