searchkick_evichat 0.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.
- checksums.yaml +7 -0
- data/.github/ISSUE_TEMPLATE.md +7 -0
- data/.gitignore +22 -0
- data/.travis.yml +33 -0
- data/CHANGELOG.md +571 -0
- data/CONTRIBUTING.md +51 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +22 -0
- data/README.md +1895 -0
- data/Rakefile +16 -0
- data/benchmark/Gemfile +24 -0
- data/benchmark/index.rb +99 -0
- data/benchmark/search.rb +48 -0
- data/docs/Searchkick-3-Upgrade.md +57 -0
- data/lib/searchkick.rb +255 -0
- data/lib/searchkick/bulk_indexer.rb +171 -0
- data/lib/searchkick/bulk_reindex_job.rb +27 -0
- data/lib/searchkick/hash_wrapper.rb +12 -0
- data/lib/searchkick/index.rb +331 -0
- data/lib/searchkick/index_options.rb +414 -0
- data/lib/searchkick/indexer.rb +28 -0
- data/lib/searchkick/logging.rb +243 -0
- data/lib/searchkick/middleware.rb +12 -0
- data/lib/searchkick/model.rb +105 -0
- data/lib/searchkick/multi_search.rb +41 -0
- data/lib/searchkick/process_batch_job.rb +23 -0
- data/lib/searchkick/process_queue_job.rb +23 -0
- data/lib/searchkick/query.rb +978 -0
- data/lib/searchkick/record_data.rb +128 -0
- data/lib/searchkick/record_indexer.rb +59 -0
- data/lib/searchkick/reindex_queue.rb +38 -0
- data/lib/searchkick/reindex_v2_job.rb +36 -0
- data/lib/searchkick/results.rb +235 -0
- data/lib/searchkick/tasks.rb +29 -0
- data/lib/searchkick/version.rb +3 -0
- data/searchkick.gemspec +30 -0
- data/test/aggs_test.rb +217 -0
- data/test/autocomplete_test.rb +81 -0
- data/test/boost_test.rb +207 -0
- data/test/callbacks_test.rb +59 -0
- data/test/ci/before_install.sh +17 -0
- data/test/errors_test.rb +19 -0
- data/test/gemfiles/activerecord42.gemfile +7 -0
- data/test/gemfiles/activerecord50.gemfile +7 -0
- data/test/gemfiles/activerecord51.gemfile +7 -0
- data/test/gemfiles/apartment.gemfile +8 -0
- data/test/gemfiles/cequel.gemfile +8 -0
- data/test/gemfiles/mongoid5.gemfile +7 -0
- data/test/gemfiles/mongoid6.gemfile +12 -0
- data/test/gemfiles/nobrainer.gemfile +8 -0
- data/test/gemfiles/parallel_tests.gemfile +8 -0
- data/test/geo_shape_test.rb +171 -0
- data/test/highlight_test.rb +94 -0
- data/test/index_test.rb +165 -0
- data/test/inheritance_test.rb +82 -0
- data/test/language_test.rb +79 -0
- data/test/marshal_test.rb +13 -0
- data/test/match_test.rb +293 -0
- data/test/misspellings_test.rb +56 -0
- data/test/model_test.rb +40 -0
- data/test/multi_search_test.rb +37 -0
- data/test/multi_tenancy_test.rb +22 -0
- data/test/order_test.rb +40 -0
- data/test/pagination_test.rb +96 -0
- data/test/partial_reindex_test.rb +65 -0
- data/test/query_test.rb +43 -0
- data/test/reindex_test.rb +87 -0
- data/test/reindex_v2_job_test.rb +27 -0
- data/test/routing_test.rb +23 -0
- data/test/should_index_test.rb +32 -0
- data/test/similar_test.rb +28 -0
- data/test/sql_test.rb +190 -0
- data/test/suggest_test.rb +100 -0
- data/test/support/kaminari.yml +21 -0
- data/test/synonyms_test.rb +69 -0
- data/test/test_helper.rb +593 -0
- data/test/where_test.rb +249 -0
- metadata +250 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require "faraday/middleware"
|
|
2
|
+
|
|
3
|
+
module Searchkick
|
|
4
|
+
class Middleware < Faraday::Middleware
|
|
5
|
+
def call(env)
|
|
6
|
+
if env[:method] == :get && env[:url].path.to_s.end_with?("/_search")
|
|
7
|
+
env[:request][:timeout] = Searchkick.search_timeout
|
|
8
|
+
end
|
|
9
|
+
@app.call(env)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module Searchkick
|
|
2
|
+
module Model
|
|
3
|
+
def searchkick(**options)
|
|
4
|
+
options = Searchkick.model_options.merge(options)
|
|
5
|
+
|
|
6
|
+
unknown_keywords = options.keys - [:_all, :_type, :batch_size, :callbacks, :conversions, :default_fields,
|
|
7
|
+
:filterable, :geo_shape, :highlight, :ignore_above, :index_name, :index_prefix, :inheritance, :language,
|
|
8
|
+
:locations, :mappings, :match, :merge_mappings, :routing, :searchable, :settings, :similarity,
|
|
9
|
+
:special_characters, :stem_conversions, :suggest, :synonyms, :text_end,
|
|
10
|
+
:text_middle, :text_start, :word, :wordnet, :word_end, :word_middle, :word_start]
|
|
11
|
+
raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
|
|
12
|
+
|
|
13
|
+
raise "Only call searchkick once per model" if respond_to?(:searchkick_index)
|
|
14
|
+
|
|
15
|
+
Searchkick.models << self
|
|
16
|
+
|
|
17
|
+
options[:_type] ||= -> { searchkick_index.klass_document_type(self, true) }
|
|
18
|
+
|
|
19
|
+
callbacks = options.key?(:callbacks) ? options[:callbacks] : true
|
|
20
|
+
unless [true, false, :async, :queue].include?(callbacks)
|
|
21
|
+
raise ArgumentError, "Invalid value for callbacks"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
index_name =
|
|
25
|
+
if options[:index_name]
|
|
26
|
+
options[:index_name]
|
|
27
|
+
elsif options[:index_prefix].respond_to?(:call)
|
|
28
|
+
-> { [options[:index_prefix].call, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_") }
|
|
29
|
+
else
|
|
30
|
+
[options.key?(:index_prefix) ? options[:index_prefix] : Searchkick.index_prefix, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class_eval do
|
|
34
|
+
cattr_reader :searchkick_options, :searchkick_klass
|
|
35
|
+
|
|
36
|
+
class_variable_set :@@searchkick_options, options.dup
|
|
37
|
+
class_variable_set :@@searchkick_klass, self
|
|
38
|
+
class_variable_set :@@searchkick_index, index_name
|
|
39
|
+
class_variable_set :@@searchkick_index_cache, {}
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def searchkick_search(term = "*", **options, &block)
|
|
43
|
+
Searchkick.search(term, {model: self}.merge(options), &block)
|
|
44
|
+
end
|
|
45
|
+
alias_method Searchkick.search_method_name, :searchkick_search if Searchkick.search_method_name
|
|
46
|
+
|
|
47
|
+
def searchkick_index
|
|
48
|
+
index = class_variable_get(:@@searchkick_index)
|
|
49
|
+
index = index.call if index.respond_to?(:call)
|
|
50
|
+
index_cache = class_variable_get(:@@searchkick_index_cache)
|
|
51
|
+
index_cache[index] ||= Searchkick::Index.new(index, searchkick_options)
|
|
52
|
+
end
|
|
53
|
+
alias_method :search_index, :searchkick_index unless method_defined?(:search_index)
|
|
54
|
+
|
|
55
|
+
def searchkick_reindex(method_name = nil, **options)
|
|
56
|
+
scoped = (respond_to?(:current_scope) && respond_to?(:default_scoped) && current_scope && current_scope.to_sql != default_scoped.to_sql) ||
|
|
57
|
+
(respond_to?(:queryable) && queryable != unscoped.with_default_scope)
|
|
58
|
+
|
|
59
|
+
searchkick_index.reindex(searchkick_klass, method_name, scoped: scoped, **options)
|
|
60
|
+
end
|
|
61
|
+
alias_method :reindex, :searchkick_reindex unless method_defined?(:reindex)
|
|
62
|
+
|
|
63
|
+
def searchkick_index_options
|
|
64
|
+
searchkick_index.index_options
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# always add callbacks, even when callbacks is false
|
|
69
|
+
# so Model.callbacks block can be used
|
|
70
|
+
if respond_to?(:after_commit)
|
|
71
|
+
after_commit :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
|
|
72
|
+
elsif respond_to?(:after_save)
|
|
73
|
+
after_save :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
|
|
74
|
+
after_destroy :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reindex(method_name = nil, **options)
|
|
78
|
+
RecordIndexer.new(self).reindex(method_name, **options)
|
|
79
|
+
end unless method_defined?(:reindex)
|
|
80
|
+
|
|
81
|
+
def similar(options = {})
|
|
82
|
+
self.class.searchkick_index.similar_record(self, options)
|
|
83
|
+
end unless method_defined?(:similar)
|
|
84
|
+
|
|
85
|
+
def search_data
|
|
86
|
+
data = respond_to?(:to_hash) ? to_hash : serializable_hash
|
|
87
|
+
data.delete("id")
|
|
88
|
+
data.delete("_id")
|
|
89
|
+
data.delete("_type")
|
|
90
|
+
data
|
|
91
|
+
end unless method_defined?(:search_data)
|
|
92
|
+
|
|
93
|
+
def should_index?
|
|
94
|
+
true
|
|
95
|
+
end unless method_defined?(:should_index?)
|
|
96
|
+
|
|
97
|
+
if defined?(Cequel) && self < Cequel::Record && !method_defined?(:destroyed?)
|
|
98
|
+
def destroyed?
|
|
99
|
+
transient?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Searchkick
|
|
2
|
+
class MultiSearch
|
|
3
|
+
attr_reader :queries
|
|
4
|
+
|
|
5
|
+
def initialize(queries)
|
|
6
|
+
@queries = queries
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def perform
|
|
10
|
+
if queries.any?
|
|
11
|
+
perform_search(queries)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def perform_search(search_queries, perform_retry: true)
|
|
18
|
+
responses = client.msearch(body: search_queries.flat_map { |q| [q.params.except(:body), q.body] })["responses"]
|
|
19
|
+
|
|
20
|
+
retry_queries = []
|
|
21
|
+
search_queries.each_with_index do |query, i|
|
|
22
|
+
if perform_retry && query.retry_misspellings?(responses[i])
|
|
23
|
+
query.send(:prepare) # okay, since we don't want to expose this method outside Searchkick
|
|
24
|
+
retry_queries << query
|
|
25
|
+
else
|
|
26
|
+
query.handle_response(responses[i])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if retry_queries.any?
|
|
31
|
+
perform_search(retry_queries, perform_retry: false)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
search_queries
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def client
|
|
38
|
+
Searchkick.client
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Searchkick
|
|
2
|
+
class ProcessBatchJob < ActiveJob::Base
|
|
3
|
+
queue_as { Searchkick.queue_name }
|
|
4
|
+
|
|
5
|
+
def perform(class_name:, record_ids:)
|
|
6
|
+
klass = class_name.constantize
|
|
7
|
+
scope = Searchkick.load_records(klass, record_ids)
|
|
8
|
+
scope = scope.search_import if scope.respond_to?(:search_import)
|
|
9
|
+
records = scope.select(&:should_index?)
|
|
10
|
+
|
|
11
|
+
# determine which records to delete
|
|
12
|
+
delete_ids = record_ids - records.map { |r| r.id.to_s }
|
|
13
|
+
delete_records = delete_ids.map { |id| m = klass.new; m.id = id; m }
|
|
14
|
+
|
|
15
|
+
# bulk reindex
|
|
16
|
+
index = klass.searchkick_index
|
|
17
|
+
Searchkick.callbacks(:bulk) do
|
|
18
|
+
index.bulk_index(records) if records.any?
|
|
19
|
+
index.bulk_delete(delete_records) if delete_records.any?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Searchkick
|
|
2
|
+
class ProcessQueueJob < ActiveJob::Base
|
|
3
|
+
queue_as { Searchkick.queue_name }
|
|
4
|
+
|
|
5
|
+
def perform(class_name:)
|
|
6
|
+
model = class_name.constantize
|
|
7
|
+
|
|
8
|
+
limit = model.searchkick_index.options[:batch_size] || 1000
|
|
9
|
+
record_ids = model.searchkick_index.reindex_queue.reserve(limit: limit)
|
|
10
|
+
if record_ids.any?
|
|
11
|
+
Searchkick::ProcessBatchJob.perform_later(
|
|
12
|
+
class_name: model.name,
|
|
13
|
+
record_ids: record_ids
|
|
14
|
+
)
|
|
15
|
+
# TODO when moving to reliable queuing, mark as complete
|
|
16
|
+
|
|
17
|
+
if record_ids.size == limit
|
|
18
|
+
Searchkick::ProcessQueueJob.perform_later(class_name: class_name)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
module Searchkick
|
|
2
|
+
class Query
|
|
3
|
+
extend Forwardable
|
|
4
|
+
|
|
5
|
+
@@metric_aggs = [:avg, :cardinality, :max, :min, :sum]
|
|
6
|
+
|
|
7
|
+
attr_reader :klass, :term, :options
|
|
8
|
+
attr_accessor :body
|
|
9
|
+
|
|
10
|
+
def_delegators :execute, :map, :each, :any?, :empty?, :size, :length, :slice, :[], :to_ary,
|
|
11
|
+
:records, :results, :suggestions, :each_with_hit, :with_details, :aggregations, :aggs,
|
|
12
|
+
:took, :error, :model_name, :entry_name, :total_count, :total_entries,
|
|
13
|
+
:current_page, :per_page, :limit_value, :padding, :total_pages, :num_pages,
|
|
14
|
+
:offset_value, :offset, :previous_page, :prev_page, :next_page, :first_page?, :last_page?,
|
|
15
|
+
:out_of_range?, :hits, :response, :to_a, :first
|
|
16
|
+
|
|
17
|
+
def initialize(klass, term = "*", **options)
|
|
18
|
+
unknown_keywords = options.keys - [:aggs, :body, :body_options, :boost,
|
|
19
|
+
:boost_by, :boost_by_distance, :boost_where, :conversions, :conversions_term, :debug, :emoji, :exclude, :execute, :explain,
|
|
20
|
+
:fields, :highlight, :includes, :index_name, :indices_boost, :limit, :load,
|
|
21
|
+
:match, :misspellings, :model_includes, :offset, :operator, :order, :padding, :page, :per_page, :profile,
|
|
22
|
+
:request_params, :routing, :scope_results, :select, :similar, :smart_aggs, :suggest, :track, :type, :where]
|
|
23
|
+
raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
|
|
24
|
+
|
|
25
|
+
term = term.to_s
|
|
26
|
+
|
|
27
|
+
if options[:emoji]
|
|
28
|
+
term = EmojiParser.parse_unicode(term) { |e| " #{e.name} " }.strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@klass = klass
|
|
32
|
+
@term = term
|
|
33
|
+
@options = options
|
|
34
|
+
@match_suffix = options[:match] || searchkick_options[:match] || "analyzed"
|
|
35
|
+
|
|
36
|
+
# prevent Ruby warnings
|
|
37
|
+
@type = nil
|
|
38
|
+
@routing = nil
|
|
39
|
+
@misspellings = false
|
|
40
|
+
@misspellings_below = nil
|
|
41
|
+
@highlighted_fields = nil
|
|
42
|
+
|
|
43
|
+
prepare
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def searchkick_index
|
|
47
|
+
klass ? klass.searchkick_index : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def searchkick_options
|
|
51
|
+
klass ? klass.searchkick_options : {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def searchkick_klass
|
|
55
|
+
klass ? klass.searchkick_klass : nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def params
|
|
59
|
+
index =
|
|
60
|
+
if options[:index_name]
|
|
61
|
+
Array(options[:index_name]).map { |v| v.respond_to?(:searchkick_index) ? v.searchkick_index.name : v }.join(",")
|
|
62
|
+
elsif searchkick_index
|
|
63
|
+
searchkick_index.name
|
|
64
|
+
else
|
|
65
|
+
"_all"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
params = {
|
|
69
|
+
index: index,
|
|
70
|
+
body: body
|
|
71
|
+
}
|
|
72
|
+
params[:type] = @type if @type
|
|
73
|
+
params[:routing] = @routing if @routing
|
|
74
|
+
params.merge!(options[:request_params]) if options[:request_params]
|
|
75
|
+
params
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def execute
|
|
79
|
+
@execute ||= begin
|
|
80
|
+
begin
|
|
81
|
+
response = execute_search
|
|
82
|
+
if retry_misspellings?(response)
|
|
83
|
+
prepare
|
|
84
|
+
response = execute_search
|
|
85
|
+
end
|
|
86
|
+
rescue => e # TODO rescue type
|
|
87
|
+
handle_error(e)
|
|
88
|
+
end
|
|
89
|
+
handle_response(response)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def to_curl
|
|
94
|
+
query = params
|
|
95
|
+
type = query[:type]
|
|
96
|
+
index = query[:index].is_a?(Array) ? query[:index].join(",") : query[:index]
|
|
97
|
+
|
|
98
|
+
# no easy way to tell which host the client will use
|
|
99
|
+
host = Searchkick.client.transport.hosts.first
|
|
100
|
+
credentials = host[:user] || host[:password] ? "#{host[:user]}:#{host[:password]}@" : nil
|
|
101
|
+
"curl #{host[:protocol]}://#{credentials}#{host[:host]}:#{host[:port]}/#{CGI.escape(index)}#{type ? "/#{type.map { |t| CGI.escape(t) }.join(',')}" : ''}/_search?pretty -H 'Content-Type: application/json' -d '#{query[:body].to_json}'"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def handle_response(response)
|
|
105
|
+
opts = {
|
|
106
|
+
page: @page,
|
|
107
|
+
per_page: @per_page,
|
|
108
|
+
padding: @padding,
|
|
109
|
+
load: @load,
|
|
110
|
+
includes: options[:includes],
|
|
111
|
+
model_includes: options[:model_includes],
|
|
112
|
+
json: !@json.nil?,
|
|
113
|
+
match_suffix: @match_suffix,
|
|
114
|
+
highlighted_fields: @highlighted_fields || [],
|
|
115
|
+
misspellings: @misspellings,
|
|
116
|
+
term: term,
|
|
117
|
+
scope_results: options[:scope_results],
|
|
118
|
+
index_name: options[:index_name]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if options[:debug]
|
|
122
|
+
require "pp"
|
|
123
|
+
|
|
124
|
+
puts "Searchkick Version: #{Searchkick::VERSION}"
|
|
125
|
+
puts "Elasticsearch Version: #{Searchkick.server_version}"
|
|
126
|
+
puts
|
|
127
|
+
|
|
128
|
+
puts "Model Searchkick Options"
|
|
129
|
+
pp searchkick_options
|
|
130
|
+
puts
|
|
131
|
+
|
|
132
|
+
puts "Search Options"
|
|
133
|
+
pp options
|
|
134
|
+
puts
|
|
135
|
+
|
|
136
|
+
if searchkick_index
|
|
137
|
+
puts "Model Search Data"
|
|
138
|
+
begin
|
|
139
|
+
pp klass.limit(3).map { |r| RecordData.new(searchkick_index, r).index_data }
|
|
140
|
+
rescue => e
|
|
141
|
+
puts "#{e.class.name}: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
puts
|
|
144
|
+
|
|
145
|
+
puts "Elasticsearch Mapping"
|
|
146
|
+
puts JSON.pretty_generate(searchkick_index.mapping)
|
|
147
|
+
puts
|
|
148
|
+
|
|
149
|
+
puts "Elasticsearch Settings"
|
|
150
|
+
puts JSON.pretty_generate(searchkick_index.settings)
|
|
151
|
+
puts
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
puts "Elasticsearch Query"
|
|
155
|
+
puts to_curl
|
|
156
|
+
puts
|
|
157
|
+
|
|
158
|
+
puts "Elasticsearch Results"
|
|
159
|
+
puts JSON.pretty_generate(response)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# set execute for multi search
|
|
163
|
+
@execute = Searchkick::Results.new(searchkick_klass, response, opts)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def retry_misspellings?(response)
|
|
167
|
+
@misspellings_below && response["hits"]["total"] < @misspellings_below
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def handle_error(e)
|
|
173
|
+
status_code = e.message[1..3].to_i
|
|
174
|
+
if status_code == 404
|
|
175
|
+
raise MissingIndexError, "Index missing - run #{reindex_command}"
|
|
176
|
+
elsif status_code == 500 && (
|
|
177
|
+
e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") ||
|
|
178
|
+
e.message.include?("No query registered for [multi_match]") ||
|
|
179
|
+
e.message.include?("[match] query does not support [cutoff_frequency]") ||
|
|
180
|
+
e.message.include?("No query registered for [function_score]")
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
|
|
184
|
+
elsif status_code == 400
|
|
185
|
+
if (
|
|
186
|
+
e.message.include?("bool query does not support [filter]") ||
|
|
187
|
+
e.message.include?("[bool] filter does not support [filter]")
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
|
|
191
|
+
elsif e.message.include?("[multi_match] analyzer [searchkick_search] not found")
|
|
192
|
+
raise InvalidQueryError, "Bad mapping - run #{reindex_command}"
|
|
193
|
+
else
|
|
194
|
+
raise InvalidQueryError, e.message
|
|
195
|
+
end
|
|
196
|
+
else
|
|
197
|
+
raise e
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def reindex_command
|
|
202
|
+
searchkick_klass ? "#{searchkick_klass.name}.reindex" : "reindex"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def execute_search
|
|
206
|
+
Searchkick.client.search(params)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def prepare
|
|
210
|
+
boost_fields, fields = set_fields
|
|
211
|
+
|
|
212
|
+
operator = options[:operator] || "and"
|
|
213
|
+
|
|
214
|
+
# pagination
|
|
215
|
+
page = [options[:page].to_i, 1].max
|
|
216
|
+
per_page = (options[:limit] || options[:per_page] || 10_000).to_i
|
|
217
|
+
padding = [options[:padding].to_i, 0].max
|
|
218
|
+
offset = options[:offset] || (page - 1) * per_page + padding
|
|
219
|
+
|
|
220
|
+
# model and eager loading
|
|
221
|
+
load = options[:load].nil? ? true : options[:load]
|
|
222
|
+
|
|
223
|
+
all = term == "*"
|
|
224
|
+
|
|
225
|
+
@json = options[:body]
|
|
226
|
+
if @json
|
|
227
|
+
ignored_options = options.keys & [:aggs, :boost,
|
|
228
|
+
:boost_by, :boost_by_distance, :boost_where, :conversions, :conversions_term, :exclude, :explain,
|
|
229
|
+
:fields, :highlight, :indices_boost, :match, :misspellings, :operator, :order,
|
|
230
|
+
:profile, :select, :smart_aggs, :suggest, :where]
|
|
231
|
+
raise ArgumentError, "Options incompatible with body option: #{ignored_options.join(", ")}" if ignored_options.any?
|
|
232
|
+
payload = @json
|
|
233
|
+
else
|
|
234
|
+
must_not = []
|
|
235
|
+
should = []
|
|
236
|
+
|
|
237
|
+
if options[:similar]
|
|
238
|
+
query = {
|
|
239
|
+
more_like_this: {
|
|
240
|
+
like: term,
|
|
241
|
+
min_doc_freq: 1,
|
|
242
|
+
min_term_freq: 1,
|
|
243
|
+
analyzer: "searchkick_search2"
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if fields.all? { |f| f.start_with?("*.") }
|
|
247
|
+
raise ArgumentError, "Must specify fields to search"
|
|
248
|
+
end
|
|
249
|
+
if fields != ["_all"]
|
|
250
|
+
query[:more_like_this][:fields] = fields
|
|
251
|
+
end
|
|
252
|
+
elsif all
|
|
253
|
+
query = {
|
|
254
|
+
match_all: {}
|
|
255
|
+
}
|
|
256
|
+
else
|
|
257
|
+
queries = []
|
|
258
|
+
|
|
259
|
+
misspellings =
|
|
260
|
+
if options.key?(:misspellings)
|
|
261
|
+
options[:misspellings]
|
|
262
|
+
else
|
|
263
|
+
true
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if misspellings.is_a?(Hash) && misspellings[:below] && !@misspellings_below
|
|
267
|
+
@misspellings_below = misspellings[:below].to_i
|
|
268
|
+
misspellings = false
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
if misspellings != false
|
|
272
|
+
edit_distance = (misspellings.is_a?(Hash) && (misspellings[:edit_distance] || misspellings[:distance])) || 1
|
|
273
|
+
transpositions =
|
|
274
|
+
if misspellings.is_a?(Hash) && misspellings.key?(:transpositions)
|
|
275
|
+
{fuzzy_transpositions: misspellings[:transpositions]}
|
|
276
|
+
else
|
|
277
|
+
{fuzzy_transpositions: true}
|
|
278
|
+
end
|
|
279
|
+
prefix_length = (misspellings.is_a?(Hash) && misspellings[:prefix_length]) || 0
|
|
280
|
+
default_max_expansions = @misspellings_below ? 20 : 3
|
|
281
|
+
max_expansions = (misspellings.is_a?(Hash) && misspellings[:max_expansions]) || default_max_expansions
|
|
282
|
+
@misspellings = true
|
|
283
|
+
else
|
|
284
|
+
@misspellings = false
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
fields.each do |field|
|
|
288
|
+
queries_to_add = []
|
|
289
|
+
qs = []
|
|
290
|
+
|
|
291
|
+
factor = boost_fields[field] || 1
|
|
292
|
+
shared_options = {
|
|
293
|
+
query: term,
|
|
294
|
+
boost: 10 * factor
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
match_type =
|
|
298
|
+
if field.end_with?(".phrase")
|
|
299
|
+
field =
|
|
300
|
+
if field == "_all.phrase"
|
|
301
|
+
"_all"
|
|
302
|
+
else
|
|
303
|
+
field.sub(/\.phrase\z/, ".analyzed")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
:match_phrase
|
|
307
|
+
else
|
|
308
|
+
:match
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
shared_options[:operator] = operator if match_type == :match
|
|
312
|
+
|
|
313
|
+
exclude_analyzer = nil
|
|
314
|
+
exclude_field = field
|
|
315
|
+
|
|
316
|
+
if field == "_all" || field.end_with?(".analyzed")
|
|
317
|
+
shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || misspellings == false
|
|
318
|
+
qs << shared_options.merge(analyzer: "searchkick_search")
|
|
319
|
+
|
|
320
|
+
# searchkick_search and searchkick_search2 are the same for ukrainian
|
|
321
|
+
unless %w(japanese korean polish ukrainian vietnamese).include?(searchkick_options[:language])
|
|
322
|
+
qs << shared_options.merge(analyzer: "searchkick_search2")
|
|
323
|
+
end
|
|
324
|
+
exclude_analyzer = "searchkick_search2"
|
|
325
|
+
elsif field.end_with?(".exact")
|
|
326
|
+
f = field.split(".")[0..-2].join(".")
|
|
327
|
+
queries_to_add << {match: {f => shared_options.merge(analyzer: "keyword")}}
|
|
328
|
+
exclude_field = f
|
|
329
|
+
exclude_analyzer = "keyword"
|
|
330
|
+
else
|
|
331
|
+
analyzer = field =~ /\.word_(start|middle|end)\z/ ? "searchkick_word_search" : "searchkick_autocomplete_search"
|
|
332
|
+
qs << shared_options.merge(analyzer: analyzer)
|
|
333
|
+
exclude_analyzer = analyzer
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
if misspellings != false && match_type == :match
|
|
337
|
+
qs.concat(qs.map { |q| q.except(:cutoff_frequency).merge(fuzziness: edit_distance, prefix_length: prefix_length, max_expansions: max_expansions, boost: factor).merge(transpositions) })
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
if field.start_with?("*.")
|
|
341
|
+
q2 = qs.map { |q| {multi_match: q.merge(fields: [field], type: match_type == :match_phrase ? "phrase" : "best_fields")} }
|
|
342
|
+
if below61?
|
|
343
|
+
q2.each do |q|
|
|
344
|
+
q[:multi_match].delete(:fuzzy_transpositions)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
else
|
|
348
|
+
q2 = qs.map { |q| {match_type => {field => q}} }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# boost exact matches more
|
|
352
|
+
if field =~ /\.word_(start|middle|end)\z/ && searchkick_options[:word] != false
|
|
353
|
+
queries_to_add << {
|
|
354
|
+
bool: {
|
|
355
|
+
must: {
|
|
356
|
+
bool: {
|
|
357
|
+
should: q2
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
should: {match_type => {field.sub(/\.word_(start|middle|end)\z/, ".analyzed") => qs.first}}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else
|
|
364
|
+
queries_to_add.concat(q2)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
queries.concat(queries_to_add)
|
|
368
|
+
|
|
369
|
+
if options[:exclude]
|
|
370
|
+
must_not.concat(set_exclude(exclude_field, exclude_analyzer))
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
payload = {
|
|
375
|
+
dis_max: {
|
|
376
|
+
queries: queries
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
should.concat(set_conversions)
|
|
381
|
+
|
|
382
|
+
query = payload
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
payload = {}
|
|
386
|
+
|
|
387
|
+
# type when inheritance
|
|
388
|
+
where = (options[:where] || {}).dup
|
|
389
|
+
if searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
|
|
390
|
+
where[:type] = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v, true) }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# start everything as efficient filters
|
|
394
|
+
# move to post_filters as aggs demand
|
|
395
|
+
filters = where_filters(where)
|
|
396
|
+
post_filters = []
|
|
397
|
+
|
|
398
|
+
# aggregations
|
|
399
|
+
set_aggregations(payload, filters, post_filters) if options[:aggs]
|
|
400
|
+
|
|
401
|
+
# post filters
|
|
402
|
+
set_post_filters(payload, post_filters) if post_filters.any?
|
|
403
|
+
|
|
404
|
+
custom_filters = []
|
|
405
|
+
multiply_filters = []
|
|
406
|
+
|
|
407
|
+
set_boost_by(multiply_filters, custom_filters)
|
|
408
|
+
set_boost_where(custom_filters)
|
|
409
|
+
set_boost_by_distance(custom_filters) if options[:boost_by_distance]
|
|
410
|
+
|
|
411
|
+
payload[:query] = build_query(query, filters, should, must_not, custom_filters, multiply_filters)
|
|
412
|
+
|
|
413
|
+
payload[:explain] = options[:explain] if options[:explain]
|
|
414
|
+
payload[:profile] = options[:profile] if options[:profile]
|
|
415
|
+
|
|
416
|
+
# order
|
|
417
|
+
set_order(payload) if options[:order]
|
|
418
|
+
|
|
419
|
+
# indices_boost
|
|
420
|
+
set_boost_by_indices(payload)
|
|
421
|
+
|
|
422
|
+
# suggestions
|
|
423
|
+
set_suggestions(payload, options[:suggest]) if options[:suggest]
|
|
424
|
+
|
|
425
|
+
# highlight
|
|
426
|
+
set_highlights(payload, fields) if options[:highlight]
|
|
427
|
+
|
|
428
|
+
# timeout shortly after client times out
|
|
429
|
+
payload[:timeout] ||= "#{Searchkick.search_timeout + 1}s"
|
|
430
|
+
|
|
431
|
+
# An empty array will cause only the _id and _type for each hit to be returned
|
|
432
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
|
|
433
|
+
if options[:select]
|
|
434
|
+
if options[:select] == []
|
|
435
|
+
# intuitively [] makes sense to return no fields, but ES by default returns all fields
|
|
436
|
+
payload[:_source] = false
|
|
437
|
+
else
|
|
438
|
+
payload[:_source] = options[:select]
|
|
439
|
+
end
|
|
440
|
+
elsif load
|
|
441
|
+
payload[:_source] = false
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# pagination
|
|
446
|
+
pagination_options = options[:page] || options[:limit] || options[:per_page] || options[:offset] || options[:padding]
|
|
447
|
+
if !options[:body] || pagination_options
|
|
448
|
+
payload[:size] = per_page
|
|
449
|
+
payload[:from] = offset
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# type
|
|
453
|
+
if !searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
|
|
454
|
+
@type = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v) }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# routing
|
|
458
|
+
@routing = options[:routing] if options[:routing]
|
|
459
|
+
|
|
460
|
+
# merge more body options
|
|
461
|
+
payload = payload.deep_merge(options[:body_options]) if options[:body_options]
|
|
462
|
+
|
|
463
|
+
@body = payload
|
|
464
|
+
@page = page
|
|
465
|
+
@per_page = per_page
|
|
466
|
+
@padding = padding
|
|
467
|
+
@load = load
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def set_fields
|
|
471
|
+
boost_fields = {}
|
|
472
|
+
fields = options[:fields] || searchkick_options[:default_fields] || searchkick_options[:searchable]
|
|
473
|
+
all = searchkick_options.key?(:_all) ? searchkick_options[:_all] : false
|
|
474
|
+
default_match = options[:match] || searchkick_options[:match] || :word
|
|
475
|
+
fields =
|
|
476
|
+
if fields
|
|
477
|
+
fields.map do |value|
|
|
478
|
+
k, v = value.is_a?(Hash) ? value.to_a.first : [value, default_match]
|
|
479
|
+
k2, boost = k.to_s.split("^", 2)
|
|
480
|
+
field = "#{k2}.#{v == :word ? 'analyzed' : v}"
|
|
481
|
+
boost_fields[field] = boost.to_f if boost
|
|
482
|
+
field
|
|
483
|
+
end
|
|
484
|
+
elsif all && default_match == :word
|
|
485
|
+
["_all"]
|
|
486
|
+
elsif all && default_match == :phrase
|
|
487
|
+
["_all.phrase"]
|
|
488
|
+
elsif term == "*"
|
|
489
|
+
[]
|
|
490
|
+
elsif default_match == :exact
|
|
491
|
+
raise ArgumentError, "Must specify fields to search"
|
|
492
|
+
else
|
|
493
|
+
[default_match == :word ? "*.analyzed" : "*.#{default_match}"]
|
|
494
|
+
end
|
|
495
|
+
[boost_fields, fields]
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def build_query(query, filters, should, must_not, custom_filters, multiply_filters)
|
|
499
|
+
if filters.any? || must_not.any? || should.any?
|
|
500
|
+
bool = {must: query}
|
|
501
|
+
bool[:filter] = filters if filters.any? # where
|
|
502
|
+
bool[:must_not] = must_not if must_not.any? # exclude
|
|
503
|
+
bool[:should] = should if should.any? # conversions
|
|
504
|
+
query = {bool: bool}
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
if custom_filters.any?
|
|
508
|
+
query = {
|
|
509
|
+
function_score: {
|
|
510
|
+
functions: custom_filters,
|
|
511
|
+
query: query,
|
|
512
|
+
score_mode: "sum"
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
if multiply_filters.any?
|
|
518
|
+
query = {
|
|
519
|
+
function_score: {
|
|
520
|
+
functions: multiply_filters,
|
|
521
|
+
query: query,
|
|
522
|
+
score_mode: "multiply"
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
query
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def set_conversions
|
|
531
|
+
conversions_fields = Array(options[:conversions] || searchkick_options[:conversions]).map(&:to_s)
|
|
532
|
+
if conversions_fields.present? && options[:conversions] != false
|
|
533
|
+
conversions_fields.map do |conversions_field|
|
|
534
|
+
{
|
|
535
|
+
nested: {
|
|
536
|
+
path: conversions_field,
|
|
537
|
+
score_mode: "sum",
|
|
538
|
+
query: {
|
|
539
|
+
function_score: {
|
|
540
|
+
boost_mode: "replace",
|
|
541
|
+
query: {
|
|
542
|
+
match: {
|
|
543
|
+
"#{conversions_field}.query" => options[:conversions_term] || term
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
field_value_factor: {
|
|
547
|
+
field: "#{conversions_field}.count"
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
end
|
|
554
|
+
else
|
|
555
|
+
[]
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def set_exclude(field, analyzer)
|
|
560
|
+
Array(options[:exclude]).map do |phrase|
|
|
561
|
+
{
|
|
562
|
+
multi_match: {
|
|
563
|
+
fields: [field],
|
|
564
|
+
query: phrase,
|
|
565
|
+
analyzer: analyzer,
|
|
566
|
+
type: "phrase"
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def set_boost_by_distance(custom_filters)
|
|
573
|
+
boost_by_distance = options[:boost_by_distance] || {}
|
|
574
|
+
|
|
575
|
+
# legacy format
|
|
576
|
+
if boost_by_distance[:field]
|
|
577
|
+
boost_by_distance = {boost_by_distance[:field] => boost_by_distance.except(:field)}
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
boost_by_distance.each do |field, attributes|
|
|
581
|
+
attributes = {function: :gauss, scale: "5mi"}.merge(attributes)
|
|
582
|
+
unless attributes[:origin]
|
|
583
|
+
raise ArgumentError, "boost_by_distance requires :origin"
|
|
584
|
+
end
|
|
585
|
+
function_params = attributes.select { |k, _| [:origin, :scale, :offset, :decay].include?(k) }
|
|
586
|
+
function_params[:origin] = location_value(function_params[:origin])
|
|
587
|
+
custom_filters << {
|
|
588
|
+
weight: attributes[:factor] || 1,
|
|
589
|
+
attributes[:function] => {
|
|
590
|
+
field => function_params
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def set_boost_by(multiply_filters, custom_filters)
|
|
597
|
+
boost_by = options[:boost_by] || {}
|
|
598
|
+
if boost_by.is_a?(Array)
|
|
599
|
+
boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
|
|
600
|
+
elsif boost_by.is_a?(Hash)
|
|
601
|
+
multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map { |i| Hash[i] }
|
|
602
|
+
end
|
|
603
|
+
boost_by[options[:boost]] = {factor: 1} if options[:boost]
|
|
604
|
+
|
|
605
|
+
custom_filters.concat boost_filters(boost_by, modifier: "ln2p")
|
|
606
|
+
multiply_filters.concat boost_filters(multiply_by || {})
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def set_boost_where(custom_filters)
|
|
610
|
+
boost_where = options[:boost_where] || {}
|
|
611
|
+
boost_where.each do |field, value|
|
|
612
|
+
if value.is_a?(Array) && value.first.is_a?(Hash)
|
|
613
|
+
value.each do |value_factor|
|
|
614
|
+
custom_filters << custom_filter(field, value_factor[:value], value_factor[:factor])
|
|
615
|
+
end
|
|
616
|
+
elsif value.is_a?(Hash)
|
|
617
|
+
custom_filters << custom_filter(field, value[:value], value[:factor])
|
|
618
|
+
else
|
|
619
|
+
factor = 1000
|
|
620
|
+
custom_filters << custom_filter(field, value, factor)
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def set_boost_by_indices(payload)
|
|
626
|
+
return unless options[:indices_boost]
|
|
627
|
+
|
|
628
|
+
indices_boost = options[:indices_boost].each_with_object({}) do |(key, boost), memo|
|
|
629
|
+
index = key.respond_to?(:searchkick_index) ? key.searchkick_index.name : key
|
|
630
|
+
# try to use index explicitly instead of alias: https://github.com/elasticsearch/elasticsearch/issues/4756
|
|
631
|
+
index_by_alias = Searchkick.client.indices.get_alias(index: index).keys.first
|
|
632
|
+
memo[index_by_alias || index] = boost
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
payload[:indices_boost] = indices_boost
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def set_suggestions(payload, suggest)
|
|
639
|
+
suggest_fields = nil
|
|
640
|
+
|
|
641
|
+
if suggest.is_a?(Array)
|
|
642
|
+
suggest_fields = suggest
|
|
643
|
+
else
|
|
644
|
+
suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
|
|
645
|
+
|
|
646
|
+
# intersection
|
|
647
|
+
if options[:fields]
|
|
648
|
+
suggest_fields &= options[:fields].map { |v| (v.is_a?(Hash) ? v.keys.first : v).to_s.split("^", 2).first }
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
if suggest_fields.any?
|
|
653
|
+
payload[:suggest] = {text: term}
|
|
654
|
+
suggest_fields.each do |field|
|
|
655
|
+
payload[:suggest][field] = {
|
|
656
|
+
phrase: {
|
|
657
|
+
field: "#{field}.suggest"
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
end
|
|
661
|
+
else
|
|
662
|
+
raise ArgumentError, "Must pass fields to suggest option"
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def set_highlights(payload, fields)
|
|
667
|
+
payload[:highlight] = {
|
|
668
|
+
fields: Hash[fields.map { |f| [f, {}] }],
|
|
669
|
+
fragment_size: below60? ? 30000 : 0
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if options[:highlight].is_a?(Hash)
|
|
673
|
+
if (tag = options[:highlight][:tag])
|
|
674
|
+
payload[:highlight][:pre_tags] = [tag]
|
|
675
|
+
payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A<(\w+).+/, "</\\1>")]
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
if (fragment_size = options[:highlight][:fragment_size])
|
|
679
|
+
payload[:highlight][:fragment_size] = fragment_size
|
|
680
|
+
end
|
|
681
|
+
if (encoder = options[:highlight][:encoder])
|
|
682
|
+
payload[:highlight][:encoder] = encoder
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
highlight_fields = options[:highlight][:fields]
|
|
686
|
+
if highlight_fields
|
|
687
|
+
payload[:highlight][:fields] = {}
|
|
688
|
+
|
|
689
|
+
highlight_fields.each do |name, opts|
|
|
690
|
+
payload[:highlight][:fields]["#{name}.#{@match_suffix}"] = opts || {}
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
@highlighted_fields = payload[:highlight][:fields].keys
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def set_aggregations(payload, filters, post_filters)
|
|
699
|
+
aggs = options[:aggs]
|
|
700
|
+
payload[:aggs] = {}
|
|
701
|
+
|
|
702
|
+
aggs = Hash[aggs.map { |f| [f, {}] }] if aggs.is_a?(Array) # convert to more advanced syntax
|
|
703
|
+
aggs.each do |field, agg_options|
|
|
704
|
+
size = agg_options[:limit] ? agg_options[:limit] : 1_000
|
|
705
|
+
shared_agg_options = agg_options.slice(:order, :min_doc_count)
|
|
706
|
+
|
|
707
|
+
if agg_options[:ranges]
|
|
708
|
+
payload[:aggs][field] = {
|
|
709
|
+
range: {
|
|
710
|
+
field: agg_options[:field] || field,
|
|
711
|
+
ranges: agg_options[:ranges]
|
|
712
|
+
}.merge(shared_agg_options)
|
|
713
|
+
}
|
|
714
|
+
elsif agg_options[:date_ranges]
|
|
715
|
+
payload[:aggs][field] = {
|
|
716
|
+
date_range: {
|
|
717
|
+
field: agg_options[:field] || field,
|
|
718
|
+
ranges: agg_options[:date_ranges]
|
|
719
|
+
}.merge(shared_agg_options)
|
|
720
|
+
}
|
|
721
|
+
elsif (histogram = agg_options[:date_histogram])
|
|
722
|
+
interval = histogram[:interval]
|
|
723
|
+
payload[:aggs][field] = {
|
|
724
|
+
date_histogram: {
|
|
725
|
+
field: histogram[:field],
|
|
726
|
+
interval: interval
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
elsif (metric = @@metric_aggs.find { |k| agg_options.has_key?(k) })
|
|
730
|
+
payload[:aggs][field] = {
|
|
731
|
+
metric => {
|
|
732
|
+
field: agg_options[metric][:field] || field
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else
|
|
736
|
+
payload[:aggs][field] = {
|
|
737
|
+
terms: {
|
|
738
|
+
field: agg_options[:field] || field,
|
|
739
|
+
size: size
|
|
740
|
+
}.merge(shared_agg_options)
|
|
741
|
+
}
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
where = {}
|
|
745
|
+
where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
|
|
746
|
+
agg_filters = where_filters(where.merge(agg_options[:where] || {}))
|
|
747
|
+
|
|
748
|
+
# only do one level comparison for simplicity
|
|
749
|
+
filters.select! do |filter|
|
|
750
|
+
if agg_filters.include?(filter)
|
|
751
|
+
true
|
|
752
|
+
else
|
|
753
|
+
post_filters << filter
|
|
754
|
+
false
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
if agg_filters.any?
|
|
759
|
+
payload[:aggs][field] = {
|
|
760
|
+
filter: {
|
|
761
|
+
bool: {
|
|
762
|
+
must: agg_filters
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
aggs: {
|
|
766
|
+
field => payload[:aggs][field]
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def set_post_filters(payload, post_filters)
|
|
774
|
+
payload[:post_filter] = {
|
|
775
|
+
bool: {
|
|
776
|
+
filter: post_filters
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# TODO id transformation for arrays
|
|
782
|
+
def set_order(payload)
|
|
783
|
+
order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
|
|
784
|
+
id_field = :_uid
|
|
785
|
+
payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? id_field : k, v] }]
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def where_filters(where)
|
|
789
|
+
filters = []
|
|
790
|
+
(where || {}).each do |field, value|
|
|
791
|
+
field = :_id if field.to_s == "id"
|
|
792
|
+
|
|
793
|
+
if field == :or
|
|
794
|
+
value.each do |or_clause|
|
|
795
|
+
filters << {bool: {should: or_clause.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
|
|
796
|
+
end
|
|
797
|
+
elsif field == :_or
|
|
798
|
+
filters << {bool: {should: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
|
|
799
|
+
elsif field == :_not
|
|
800
|
+
filters << {bool: {must_not: where_filters(value)}}
|
|
801
|
+
elsif field == :_and
|
|
802
|
+
filters << {bool: {must: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
|
|
803
|
+
else
|
|
804
|
+
# expand ranges
|
|
805
|
+
if value.is_a?(Range)
|
|
806
|
+
value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
value = {in: value} if value.is_a?(Array)
|
|
810
|
+
|
|
811
|
+
if value.is_a?(Hash)
|
|
812
|
+
value.each do |op, op_value|
|
|
813
|
+
case op
|
|
814
|
+
when :within, :bottom_right, :bottom_left
|
|
815
|
+
# do nothing
|
|
816
|
+
when :near
|
|
817
|
+
filters << {
|
|
818
|
+
geo_distance: {
|
|
819
|
+
field => location_value(op_value),
|
|
820
|
+
distance: value[:within] || "50mi"
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
when :geo_polygon
|
|
824
|
+
filters << {
|
|
825
|
+
geo_polygon: {
|
|
826
|
+
field => op_value
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
when :geo_shape
|
|
830
|
+
shape = op_value.except(:relation)
|
|
831
|
+
shape[:coordinates] = coordinate_array(shape[:coordinates]) if shape[:coordinates]
|
|
832
|
+
filters << {
|
|
833
|
+
geo_shape: {
|
|
834
|
+
field => {
|
|
835
|
+
relation: op_value[:relation] || "intersects",
|
|
836
|
+
shape: shape
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
when :top_left
|
|
841
|
+
filters << {
|
|
842
|
+
geo_bounding_box: {
|
|
843
|
+
field => {
|
|
844
|
+
top_left: location_value(op_value),
|
|
845
|
+
bottom_right: location_value(value[:bottom_right])
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
when :top_right
|
|
850
|
+
filters << {
|
|
851
|
+
geo_bounding_box: {
|
|
852
|
+
field => {
|
|
853
|
+
top_right: location_value(op_value),
|
|
854
|
+
bottom_left: location_value(value[:bottom_left])
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
when :regexp # support for regexp queries without using a regexp ruby object
|
|
859
|
+
filters << {regexp: {field => {value: op_value}}}
|
|
860
|
+
when :not, :_not # not equal
|
|
861
|
+
filters << {bool: {must_not: term_filters(field, op_value)}}
|
|
862
|
+
when :all
|
|
863
|
+
op_value.each do |val|
|
|
864
|
+
filters << term_filters(field, val)
|
|
865
|
+
end
|
|
866
|
+
when :in
|
|
867
|
+
filters << term_filters(field, op_value)
|
|
868
|
+
else
|
|
869
|
+
range_query =
|
|
870
|
+
case op
|
|
871
|
+
when :gt
|
|
872
|
+
{from: op_value, include_lower: false}
|
|
873
|
+
when :gte
|
|
874
|
+
{from: op_value, include_lower: true}
|
|
875
|
+
when :lt
|
|
876
|
+
{to: op_value, include_upper: false}
|
|
877
|
+
when :lte
|
|
878
|
+
{to: op_value, include_upper: true}
|
|
879
|
+
else
|
|
880
|
+
raise "Unknown where operator: #{op.inspect}"
|
|
881
|
+
end
|
|
882
|
+
# issue 132
|
|
883
|
+
if (existing = filters.find { |f| f[:range] && f[:range][field] })
|
|
884
|
+
existing[:range][field].merge!(range_query)
|
|
885
|
+
else
|
|
886
|
+
filters << {range: {field => range_query}}
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
else
|
|
891
|
+
filters << term_filters(field, value)
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
end
|
|
895
|
+
filters
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def term_filters(field, value)
|
|
899
|
+
if value.is_a?(Array) # in query
|
|
900
|
+
if value.any?(&:nil?)
|
|
901
|
+
{bool: {should: [term_filters(field, nil), term_filters(field, value.compact)]}}
|
|
902
|
+
else
|
|
903
|
+
{terms: {field => value}}
|
|
904
|
+
end
|
|
905
|
+
elsif value.nil?
|
|
906
|
+
{bool: {must_not: {exists: {field: field}}}}
|
|
907
|
+
elsif value.is_a?(Regexp)
|
|
908
|
+
{regexp: {field => {value: value.source, flags: "NONE"}}}
|
|
909
|
+
else
|
|
910
|
+
{term: {field => value}}
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def custom_filter(field, value, factor)
|
|
915
|
+
{
|
|
916
|
+
filter: where_filters(field => value),
|
|
917
|
+
weight: factor
|
|
918
|
+
}
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
def boost_filter(field, factor: 1, modifier: nil, missing: nil)
|
|
922
|
+
script_score = {
|
|
923
|
+
field_value_factor: {
|
|
924
|
+
field: field,
|
|
925
|
+
factor: factor.to_f,
|
|
926
|
+
modifier: modifier
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if missing
|
|
931
|
+
script_score[:field_value_factor][:missing] = missing.to_f
|
|
932
|
+
else
|
|
933
|
+
script_score[:filter] = {
|
|
934
|
+
exists: {
|
|
935
|
+
field: field
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
script_score
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def boost_filters(boost_by, modifier: nil)
|
|
944
|
+
boost_by.map do |field, value|
|
|
945
|
+
boost_filter(field, modifier: modifier, **value)
|
|
946
|
+
end
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# Recursively descend through nesting of arrays until we reach either a lat/lon object or an array of numbers,
|
|
950
|
+
# eventually returning the same structure with all values transformed to [lon, lat].
|
|
951
|
+
#
|
|
952
|
+
def coordinate_array(value)
|
|
953
|
+
if value.is_a?(Hash)
|
|
954
|
+
[value[:lon], value[:lat]]
|
|
955
|
+
elsif value.is_a?(Array) and !value[0].is_a?(Numeric)
|
|
956
|
+
value.map { |a| coordinate_array(a) }
|
|
957
|
+
else
|
|
958
|
+
value
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
def location_value(value)
|
|
963
|
+
if value.is_a?(Array)
|
|
964
|
+
value.map(&:to_f).reverse
|
|
965
|
+
else
|
|
966
|
+
value
|
|
967
|
+
end
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def below60?
|
|
971
|
+
Searchkick.server_below?("6.0.0")
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def below61?
|
|
975
|
+
Searchkick.server_below?("6.1.0")
|
|
976
|
+
end
|
|
977
|
+
end
|
|
978
|
+
end
|