searchkick 4.6.3 → 5.0.2

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.
@@ -0,0 +1,57 @@
1
+ # based on https://gist.github.com/mnutt/566725
2
+ module Searchkick
3
+ class LogSubscriber < ActiveSupport::LogSubscriber
4
+ def self.runtime=(value)
5
+ Thread.current[:searchkick_runtime] = value
6
+ end
7
+
8
+ def self.runtime
9
+ Thread.current[:searchkick_runtime] ||= 0
10
+ end
11
+
12
+ def self.reset_runtime
13
+ rt = runtime
14
+ self.runtime = 0
15
+ rt
16
+ end
17
+
18
+ def search(event)
19
+ self.class.runtime += event.duration
20
+ return unless logger.debug?
21
+
22
+ payload = event.payload
23
+ name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
24
+
25
+ index = payload[:query][:index].is_a?(Array) ? payload[:query][:index].join(",") : payload[:query][:index]
26
+ type = payload[:query][:type]
27
+ request_params = payload[:query].except(:index, :type, :body)
28
+
29
+ params = []
30
+ request_params.each do |k, v|
31
+ params << "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}"
32
+ end
33
+
34
+ debug " #{color(name, YELLOW, true)} #{index}#{type ? "/#{type.join(',')}" : ''}/_search#{params.any? ? '?' + params.join('&') : nil} #{payload[:query][:body].to_json}"
35
+ end
36
+
37
+ def request(event)
38
+ self.class.runtime += event.duration
39
+ return unless logger.debug?
40
+
41
+ payload = event.payload
42
+ name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
43
+
44
+ debug " #{color(name, YELLOW, true)} #{payload.except(:name).to_json}"
45
+ end
46
+
47
+ def multi_search(event)
48
+ self.class.runtime += event.duration
49
+ return unless logger.debug?
50
+
51
+ payload = event.payload
52
+ name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
53
+
54
+ debug " #{color(name, YELLOW, true)} _msearch #{payload[:body]}"
55
+ end
56
+ end
57
+ end
@@ -1,4 +1,4 @@
1
- require "faraday/middleware"
1
+ require "faraday"
2
2
 
3
3
  module Searchkick
4
4
  class Middleware < Faraday::Middleware
@@ -7,7 +7,7 @@ module Searchkick
7
7
  :filterable, :geo_shape, :highlight, :ignore_above, :index_name, :index_prefix, :inheritance, :language,
8
8
  :locations, :mappings, :match, :merge_mappings, :routing, :searchable, :search_synonyms, :settings, :similarity,
9
9
  :special_characters, :stem, :stemmer, :stem_conversions, :stem_exclusion, :stemmer_override, :suggest, :synonyms, :text_end,
10
- :text_middle, :text_start, :word, :wordnet, :word_end, :word_middle, :word_start]
10
+ :text_middle, :text_start, :unscope, :word, :word_end, :word_middle, :word_start]
11
11
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
12
12
 
13
13
  raise "Only call searchkick once per model" if respond_to?(:searchkick_index)
@@ -22,52 +22,78 @@ module Searchkick
22
22
  raise ArgumentError, "Invalid value for callbacks"
23
23
  end
24
24
 
25
- index_name =
26
- if options[:index_name]
27
- options[:index_name]
28
- elsif options[:index_prefix].respond_to?(:call)
29
- -> { [options[:index_prefix].call, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_") }
30
- else
31
- [options.key?(:index_prefix) ? options[:index_prefix] : Searchkick.index_prefix, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_")
32
- end
25
+ base = self
26
+
27
+ mod = Module.new
28
+ include(mod)
29
+ mod.module_eval do
30
+ def reindex(method_name = nil, mode: nil, refresh: false)
31
+ self.class.searchkick_index.reindex([self], method_name: method_name, mode: mode, refresh: refresh, single: true)
32
+ end unless base.method_defined?(:reindex)
33
+
34
+ def similar(**options)
35
+ self.class.searchkick_index.similar_record(self, **options)
36
+ end unless base.method_defined?(:similar)
37
+
38
+ def search_data
39
+ data = respond_to?(:to_hash) ? to_hash : serializable_hash
40
+ data.delete("id")
41
+ data.delete("_id")
42
+ data.delete("_type")
43
+ data
44
+ end unless base.method_defined?(:search_data)
45
+
46
+ def should_index?
47
+ true
48
+ end unless base.method_defined?(:should_index?)
49
+ end
33
50
 
34
51
  class_eval do
35
- cattr_reader :searchkick_options, :searchkick_klass
52
+ cattr_reader :searchkick_options, :searchkick_klass, instance_reader: false
36
53
 
37
54
  class_variable_set :@@searchkick_options, options.dup
38
55
  class_variable_set :@@searchkick_klass, self
39
- class_variable_set :@@searchkick_index, index_name
40
- class_variable_set :@@searchkick_index_cache, {}
56
+ class_variable_set :@@searchkick_index_cache, Searchkick::IndexCache.new
41
57
 
42
58
  class << self
43
59
  def searchkick_search(term = "*", **options, &block)
44
- # TODO throw error in next major version
45
- Searchkick.warn("calling search on a relation is deprecated") if Searchkick.relation?(self)
60
+ if Searchkick.relation?(self)
61
+ raise Searchkick::Error, "search must be called on model, not relation"
62
+ end
46
63
 
47
64
  Searchkick.search(term, model: self, **options, &block)
48
65
  end
49
66
  alias_method Searchkick.search_method_name, :searchkick_search if Searchkick.search_method_name
50
67
 
51
68
  def searchkick_index(name: nil)
52
- index = name || class_variable_get(:@@searchkick_index)
53
- index = index.call if index.respond_to?(:call)
69
+ index_name = name || searchkick_index_name
70
+ index_name = index_name.call if index_name.respond_to?(:call)
54
71
  index_cache = class_variable_get(:@@searchkick_index_cache)
55
- index_cache[index] ||= Searchkick::Index.new(index, searchkick_options)
72
+ index_cache.fetch(index_name) { Searchkick::Index.new(index_name, searchkick_options) }
56
73
  end
57
74
  alias_method :search_index, :searchkick_index unless method_defined?(:search_index)
58
75
 
59
76
  def searchkick_reindex(method_name = nil, **options)
60
- # TODO relation = Searchkick.relation?(self)
61
- relation = (respond_to?(:current_scope) && respond_to?(:default_scoped) && current_scope && current_scope.to_sql != default_scoped.to_sql) ||
62
- (respond_to?(:queryable) && queryable != unscoped.with_default_scope)
63
-
64
- searchkick_index.reindex(searchkick_klass, method_name, scoped: relation, **options)
77
+ searchkick_index.reindex(self, method_name: method_name, **options)
65
78
  end
66
79
  alias_method :reindex, :searchkick_reindex unless method_defined?(:reindex)
67
80
 
68
81
  def searchkick_index_options
69
82
  searchkick_index.index_options
70
83
  end
84
+
85
+ def searchkick_index_name
86
+ @searchkick_index_name ||= begin
87
+ options = class_variable_get(:@@searchkick_options)
88
+ if options[:index_name]
89
+ options[:index_name]
90
+ elsif options[:index_prefix].respond_to?(:call)
91
+ -> { [options[:index_prefix].call, searchkick_klass.model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_") }
92
+ else
93
+ [options.key?(:index_prefix) ? options[:index_prefix] : Searchkick.index_prefix, searchkick_klass.model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_")
94
+ end
95
+ end
96
+ end
71
97
  end
72
98
 
73
99
  # always add callbacks, even when callbacks is false
@@ -78,33 +104,6 @@ module Searchkick
78
104
  after_save :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
79
105
  after_destroy :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
80
106
  end
81
-
82
- def reindex(method_name = nil, **options)
83
- RecordIndexer.new(self).reindex(method_name, **options)
84
- end unless method_defined?(:reindex)
85
-
86
- # TODO switch to keyword arguments
87
- def similar(options = {})
88
- self.class.searchkick_index.similar_record(self, **options)
89
- end unless method_defined?(:similar)
90
-
91
- def search_data
92
- data = respond_to?(:to_hash) ? to_hash : serializable_hash
93
- data.delete("id")
94
- data.delete("_id")
95
- data.delete("_type")
96
- data
97
- end unless method_defined?(:search_data)
98
-
99
- def should_index?
100
- true
101
- end unless method_defined?(:should_index?)
102
-
103
- if defined?(Cequel) && self < Cequel::Record && !method_defined?(:destroyed?)
104
- def destroyed?
105
- transient?
106
- end
107
- end
108
107
  end
109
108
  end
110
109
  end
@@ -3,34 +3,18 @@ module Searchkick
3
3
  queue_as { Searchkick.queue_name }
4
4
 
5
5
  def perform(class_name:, record_ids:, index_name: nil)
6
- # separate routing from id
7
- routing = Hash[record_ids.map { |r| r.split(/(?<!\|)\|(?!\|)/, 2).map { |v| v.gsub("||", "|") } }]
8
- record_ids = routing.keys
6
+ model = Searchkick.load_model(class_name)
7
+ index = model.searchkick_index(name: index_name)
9
8
 
10
- klass = class_name.constantize
11
- scope = Searchkick.load_records(klass, record_ids)
12
- scope = scope.search_import if scope.respond_to?(:search_import)
13
- records = scope.select(&:should_index?)
14
-
15
- # determine which records to delete
16
- delete_ids = record_ids - records.map { |r| r.id.to_s }
17
- delete_records = delete_ids.map do |id|
18
- m = klass.new
19
- m.id = id
20
- if routing[id]
21
- m.define_singleton_method(:search_routing) do
22
- routing[id]
23
- end
9
+ items =
10
+ record_ids.map do |r|
11
+ parts = r.split(/(?<!\|)\|(?!\|)/, 2)
12
+ .map { |v| v.gsub("||", "|") }
13
+ {id: parts[0], routing: parts[1]}
24
14
  end
25
- m
26
- end
27
15
 
28
- # bulk reindex
29
- index = klass.searchkick_index(name: index_name)
30
- Searchkick.callbacks(:bulk) do
31
- index.bulk_index(records) if records.any?
32
- index.bulk_delete(delete_records) if delete_records.any?
33
- end
16
+ relation = Searchkick.scope(model)
17
+ RecordIndexer.new(index).reindex_items(relation, items, method_name: nil)
34
18
  end
35
19
  end
36
20
  end
@@ -3,11 +3,12 @@ module Searchkick
3
3
  queue_as { Searchkick.queue_name }
4
4
 
5
5
  def perform(class_name:, index_name: nil, inline: false)
6
- model = class_name.constantize
6
+ model = Searchkick.load_model(class_name)
7
+ index = model.searchkick_index(name: index_name)
7
8
  limit = model.searchkick_options[:batch_size] || 1000
8
9
 
9
10
  loop do
10
- record_ids = model.searchkick_index(name: index_name).reindex_queue.reserve(limit: limit)
11
+ record_ids = index.reindex_queue.reserve(limit: limit)
11
12
  if record_ids.any?
12
13
  batch_options = {
13
14
  class_name: class_name,
@@ -18,7 +18,7 @@ module Searchkick
18
18
 
19
19
  def initialize(klass, term = "*", **options)
20
20
  unknown_keywords = options.keys - [:aggs, :block, :body, :body_options, :boost,
21
- :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,
22
22
  :fields, :highlight, :includes, :index_name, :indices_boost, :limit, :load,
23
23
  :match, :misspellings, :models, :model_includes, :offset, :operator, :order, :padding, :page, :per_page, :profile,
24
24
  :request_params, :routing, :scope_results, :scroll, :select, :similar, :smart_aggs, :suggest, :total_entries, :track, :type, :where]
@@ -148,9 +148,6 @@ module Searchkick
148
148
  }
149
149
 
150
150
  if options[:debug]
151
- # can remove when minimum Ruby version is 2.5
152
- require "pp"
153
-
154
151
  puts "Searchkick Version: #{Searchkick::VERSION}"
155
152
  puts "Elasticsearch Version: #{Searchkick.server_version}"
156
153
  puts
@@ -190,11 +187,11 @@ module Searchkick
190
187
  end
191
188
 
192
189
  # set execute for multi search
193
- @execute = Searchkick::Results.new(searchkick_klass, response, opts)
190
+ @execute = Results.new(searchkick_klass, response, opts)
194
191
  end
195
192
 
196
193
  def retry_misspellings?(response)
197
- @misspellings_below && Searchkick::Results.new(searchkick_klass, response).total_count < @misspellings_below
194
+ @misspellings_below && Results.new(searchkick_klass, response).total_count < @misspellings_below
198
195
  end
199
196
 
200
197
  private
@@ -210,14 +207,14 @@ module Searchkick
210
207
  e.message.include?("No query registered for [function_score]")
211
208
  )
212
209
 
213
- raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
210
+ raise UnsupportedVersionError
214
211
  elsif status_code == 400
215
212
  if (
216
213
  e.message.include?("bool query does not support [filter]") ||
217
214
  e.message.include?("[bool] filter does not support [filter]")
218
215
  )
219
216
 
220
- raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
217
+ raise UnsupportedVersionError
221
218
  elsif e.message =~ /analyzer \[searchkick_.+\] not found/
222
219
  raise InvalidQueryError, "Bad mapping - run #{reindex_command}"
223
220
  else
@@ -233,7 +230,14 @@ module Searchkick
233
230
  end
234
231
 
235
232
  def execute_search
236
- Searchkick.client.search(params)
233
+ name = searchkick_klass ? "#{searchkick_klass.name} Search" : "Search"
234
+ event = {
235
+ name: name,
236
+ query: params
237
+ }
238
+ ActiveSupport::Notifications.instrument("search.searchkick", event) do
239
+ Searchkick.client.search(params)
240
+ end
237
241
  end
238
242
 
239
243
  def prepare
@@ -268,9 +272,10 @@ module Searchkick
268
272
  should = []
269
273
 
270
274
  if options[:similar]
275
+ like = options[:similar] == true ? term : options[:similar]
271
276
  query = {
272
277
  more_like_this: {
273
- like: term,
278
+ like: like,
274
279
  min_doc_freq: 1,
275
280
  min_term_freq: 1,
276
281
  analyzer: "searchkick_search2"
@@ -383,11 +388,6 @@ module Searchkick
383
388
 
384
389
  if field.start_with?("*.")
385
390
  q2 = qs.map { |q| {multi_match: q.merge(fields: [field], type: match_type == :match_phrase ? "phrase" : "best_fields")} }
386
- if below61?
387
- q2.each do |q|
388
- q[:multi_match].delete(:fuzzy_transpositions)
389
- end
390
- end
391
391
  else
392
392
  q2 = qs.map { |q| {match_type => {field => q}} }
393
393
  end
@@ -439,7 +439,7 @@ module Searchkick
439
439
  payload = {}
440
440
 
441
441
  # type when inheritance
442
- where = (options[:where] || {}).dup
442
+ where = ensure_permitted(options[:where] || {}).dup
443
443
  if searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
444
444
  where[:type] = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v, true) }
445
445
  end
@@ -696,9 +696,9 @@ module Searchkick
696
696
  def set_boost_by(multiply_filters, custom_filters)
697
697
  boost_by = options[:boost_by] || {}
698
698
  if boost_by.is_a?(Array)
699
- boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
699
+ boost_by = boost_by.to_h { |f| [f, {factor: 1}] }
700
700
  elsif boost_by.is_a?(Hash)
701
- multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map { |i| Hash[i] }
701
+ multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map(&:to_h)
702
702
  end
703
703
  boost_by[options[:boost]] = {factor: 1} if options[:boost]
704
704
 
@@ -763,7 +763,7 @@ module Searchkick
763
763
 
764
764
  def set_highlights(payload, fields)
765
765
  payload[:highlight] = {
766
- fields: Hash[fields.map { |f| [f, {}] }],
766
+ fields: fields.to_h { |f| [f, {}] },
767
767
  fragment_size: 0
768
768
  }
769
769
 
@@ -797,7 +797,7 @@ module Searchkick
797
797
  aggs = options[:aggs]
798
798
  payload[:aggs] = {}
799
799
 
800
- aggs = Hash[aggs.map { |f| [f, {}] }] if aggs.is_a?(Array) # convert to more advanced syntax
800
+ aggs = aggs.to_h { |f| [f, {}] } if aggs.is_a?(Array) # convert to more advanced syntax
801
801
  aggs.each do |field, agg_options|
802
802
  size = agg_options[:limit] ? agg_options[:limit] : 1_000
803
803
  shared_agg_options = agg_options.except(:limit, :field, :ranges, :date_ranges, :where)
@@ -836,8 +836,9 @@ module Searchkick
836
836
  end
837
837
 
838
838
  where = {}
839
- where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
840
- agg_filters = where_filters(where.merge(agg_options[:where] || {}))
839
+ where = ensure_permitted(options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
840
+ agg_where = ensure_permitted(agg_options[:where] || {})
841
+ agg_filters = where_filters(where.merge(agg_where))
841
842
 
842
843
  # only do one level comparison for simplicity
843
844
  filters.select! do |filter|
@@ -873,19 +874,16 @@ module Searchkick
873
874
  end
874
875
 
875
876
  def set_order(payload)
876
- order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
877
- id_field = :_id
878
- # TODO no longer map id to _id in Searchkick 5
879
- # since sorting on _id is deprecated in Elasticsearch
880
- payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? id_field : k, v] }]
877
+ payload[:sort] = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
881
878
  end
882
879
 
883
- def where_filters(where)
884
- # if where.respond_to?(:permitted?) && !where.permitted?
885
- # # TODO check in more places
886
- # Searchkick.warn("Passing unpermitted parameters will raise an exception in Searchkick 5")
887
- # end
880
+ # provides *very* basic protection from unfiltered parameters
881
+ # this is not meant to be comprehensive and may be expanded in the future
882
+ def ensure_permitted(obj)
883
+ obj.to_h
884
+ end
888
885
 
886
+ def where_filters(where)
889
887
  filters = []
890
888
  (where || {}).each do |field, value|
891
889
  field = :_id if field.to_s == "id"
@@ -1007,7 +1005,7 @@ module Searchkick
1007
1005
  when :lte
1008
1006
  {to: op_value, include_upper: true}
1009
1007
  else
1010
- raise "Unknown where operator: #{op.inspect}"
1008
+ raise ArgumentError, "Unknown where operator: #{op.inspect}"
1011
1009
  end
1012
1010
  # issue 132
1013
1011
  if (existing = filters.find { |f| f[:range] && f[:range][field] })
@@ -1036,29 +1034,25 @@ module Searchkick
1036
1034
  {bool: {must_not: {exists: {field: field}}}}
1037
1035
  elsif value.is_a?(Regexp)
1038
1036
  source = value.source
1039
- unless source.start_with?("\\A") && source.end_with?("\\z")
1040
- # https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html
1041
- Searchkick.warn("Regular expressions are always anchored in Elasticsearch")
1042
- end
1037
+
1038
+ # TODO handle other regexp options
1043
1039
 
1044
1040
  # TODO handle other anchor characters, like ^, $, \Z
1045
1041
  if source.start_with?("\\A")
1046
1042
  source = source[2..-1]
1047
1043
  else
1048
- # TODO uncomment in Searchkick 5
1049
- # source = ".*#{source}"
1044
+ source = ".*#{source}"
1050
1045
  end
1051
1046
 
1052
1047
  if source.end_with?("\\z")
1053
1048
  source = source[0..-3]
1054
1049
  else
1055
- # TODO uncomment in Searchkick 5
1056
- # source = "#{source}.*"
1050
+ source = "#{source}.*"
1057
1051
  end
1058
1052
 
1059
1053
  if below710?
1060
1054
  if value.casefold?
1061
- Searchkick.warn("Case-insensitive flag does not work with Elasticsearch < 7.10")
1055
+ raise ArgumentError, "Case-insensitive flag does not work with Elasticsearch < 7.10"
1062
1056
  end
1063
1057
  {regexp: {field => {value: source, flags: "NONE"}}}
1064
1058
  else
@@ -1069,9 +1063,7 @@ module Searchkick
1069
1063
  if value.as_json.is_a?(Enumerable)
1070
1064
  # query will fail, but this is better
1071
1065
  # same message as Active Record
1072
- # TODO make TypeError
1073
- # raise InvalidQueryError for backward compatibility
1074
- raise Searchkick::InvalidQueryError, "can't cast #{value.class.name}"
1066
+ raise TypeError, "can't cast #{value.class.name}"
1075
1067
  end
1076
1068
 
1077
1069
  {term: {field => {value: value}}}
@@ -1150,21 +1142,13 @@ module Searchkick
1150
1142
  end
1151
1143
 
1152
1144
  def track_total_hits?
1153
- (searchkick_options[:deep_paging] && !below70?) || body_options[:track_total_hits]
1145
+ searchkick_options[:deep_paging] || body_options[:track_total_hits]
1154
1146
  end
1155
1147
 
1156
1148
  def body_options
1157
1149
  options[:body_options] || {}
1158
1150
  end
1159
1151
 
1160
- def below61?
1161
- Searchkick.server_below?("6.1.0")
1162
- end
1163
-
1164
- def below70?
1165
- Searchkick.server_below?("7.0.0")
1166
- end
1167
-
1168
1152
  def below73?
1169
1153
  Searchkick.server_below?("7.3.0")
1170
1154
  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