querylane 0.1.0
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/CHANGELOG.md +84 -0
- data/CONTRIBUTING.md +118 -0
- data/LICENSE +21 -0
- data/README.md +287 -0
- data/SECURITY.md +29 -0
- data/app/assets/stylesheets/querylane.css +300 -0
- data/app/helpers/querylane/search_helper.rb +111 -0
- data/app/javascript/querylane/controllers/box_controller.js +170 -0
- data/app/javascript/querylane/controllers/options_controller.js +54 -0
- data/app/javascript/querylane/controllers/results_controller.js +137 -0
- data/app/javascript/querylane/controllers/suggest_controller.js +187 -0
- data/app/javascript/querylane/index.js +33 -0
- data/app/javascript/querylane/overlay.js +46 -0
- data/app/javascript/querylane/package.json +7 -0
- data/app/views/querylane/_chips.html.erb +15 -0
- data/app/views/querylane/_denied.html.erb +6 -0
- data/app/views/querylane/_empty.html.erb +17 -0
- data/app/views/querylane/_facets.html.erb +30 -0
- data/app/views/querylane/_first_load.html.erb +15 -0
- data/app/views/querylane/_form.html.erb +46 -0
- data/app/views/querylane/_group.html.erb +21 -0
- data/app/views/querylane/_help.html.erb +29 -0
- data/app/views/querylane/_options.html.erb +32 -0
- data/app/views/querylane/_options_row.html.erb +51 -0
- data/app/views/querylane/_outcome.html.erb +16 -0
- data/app/views/querylane/_pager.html.erb +19 -0
- data/app/views/querylane/_preview.html.erb +39 -0
- data/app/views/querylane/_record.html.erb +16 -0
- data/app/views/querylane/_related.html.erb +17 -0
- data/app/views/querylane/_results.html.erb +66 -0
- data/app/views/querylane/_rows.html.erb +13 -0
- data/app/views/querylane/_saved.html.erb +31 -0
- data/app/views/querylane/_suggest_box.html.erb +45 -0
- data/app/views/querylane/_suggestions.html.erb +47 -0
- data/app/views/querylane/_toolbar.html.erb +28 -0
- data/app/views/querylane/_unavailable.html.erb +7 -0
- data/app/views/querylane/_writes.html.erb +16 -0
- data/config/importmap.rb +34 -0
- data/config/locales/querylane.en.yml +136 -0
- data/lib/querylane/ast.rb +17 -0
- data/lib/querylane/chips.rb +85 -0
- data/lib/querylane/compiler.rb +52 -0
- data/lib/querylane/configuration.rb +49 -0
- data/lib/querylane/context.rb +11 -0
- data/lib/querylane/controller.rb +85 -0
- data/lib/querylane/definition.rb +447 -0
- data/lib/querylane/doctor.rb +52 -0
- data/lib/querylane/engine.rb +61 -0
- data/lib/querylane/errors.rb +27 -0
- data/lib/querylane/filter.rb +75 -0
- data/lib/querylane/group_token.rb +33 -0
- data/lib/querylane/parser.rb +178 -0
- data/lib/querylane/presenter.rb +160 -0
- data/lib/querylane/preview.rb +45 -0
- data/lib/querylane/query.rb +183 -0
- data/lib/querylane/request.rb +66 -0
- data/lib/querylane/result.rb +90 -0
- data/lib/querylane/rewriter.rb +245 -0
- data/lib/querylane/saving.rb +49 -0
- data/lib/querylane/scope.rb +53 -0
- data/lib/querylane/search.rb +347 -0
- data/lib/querylane/searchers/meilisearch.rb +226 -0
- data/lib/querylane/suggest.rb +67 -0
- data/lib/querylane/version.rb +5 -0
- data/lib/querylane.rb +60 -0
- data/lib/tasks/querylane.rake +14 -0
- metadata +132 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Runs one request: rewrite (facet toggle, chip ×) → analyze → compile →
|
|
5
|
+
# searcher → Result. Requires a server-built Scope; fails closed.
|
|
6
|
+
#
|
|
7
|
+
# Querylane::Search.new(definition: MEMBERS, request: request, scope: scope).call
|
|
8
|
+
class Search
|
|
9
|
+
PageRequest = Data.define(:compiled, :sort, :offset, :limit)
|
|
10
|
+
# attribute: count values via the backend's facet distribution.
|
|
11
|
+
# value_filters: {value => ->(index) { Filter }} counted one by one.
|
|
12
|
+
CountRequest = Data.define(:key, :compiled, :attribute, :value_filters)
|
|
13
|
+
|
|
14
|
+
# @param definition [Querylane::Definition]
|
|
15
|
+
# @param request [Querylane::Request]
|
|
16
|
+
# @param scope [Querylane::Scope]
|
|
17
|
+
# @param context [Querylane::Context] facts for hooks (user, request)
|
|
18
|
+
def initialize(definition:, request:, scope:, context: Context.new, searcher: nil, config: Querylane.configuration)
|
|
19
|
+
raise MissingScopeError, "Pass the Querylane::Scope built for the current user" unless scope.is_a?(Scope)
|
|
20
|
+
|
|
21
|
+
@definition = definition
|
|
22
|
+
@request = request
|
|
23
|
+
@context = context
|
|
24
|
+
@scope = scope
|
|
25
|
+
@searcher = searcher
|
|
26
|
+
@config = config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Querylane::Result]
|
|
30
|
+
def call
|
|
31
|
+
@with_saved = @definition.saved_store.present?
|
|
32
|
+
started = clock
|
|
33
|
+
query = Query.analyze(@definition, @request.query, config: @config)
|
|
34
|
+
query, rewritten = rewrite(query)
|
|
35
|
+
@notice = save(query) if @request.save_search && !@scope.denied?
|
|
36
|
+
return result(:first_load, query, rewritten) if query.valid? && query.blank?
|
|
37
|
+
return result(:invalid, query, rewritten, error: query.error) unless query.valid?
|
|
38
|
+
return result(:short, query, rewritten, chips: Chips.for(query)) if query.too_short?(@definition.min_text_length)
|
|
39
|
+
return result(:denied, query, rewritten) if @scope.denied?
|
|
40
|
+
|
|
41
|
+
response = searcher.search(definition: @definition, scope: hooked_scope, page: page_request(query), counts: count_requests(query))
|
|
42
|
+
hits, merged = run_result_hooks(response.hits)
|
|
43
|
+
group_totals = group_totals_for(hits)
|
|
44
|
+
counts = response.counts
|
|
45
|
+
facets = build_facets(query, counts || {})
|
|
46
|
+
total, total_kind = total_of(response, counts)
|
|
47
|
+
state = hits.empty? ? :empty : :ok
|
|
48
|
+
instrument(state, response, started)
|
|
49
|
+
result(state, query, rewritten, hits: hits, total: total, total_kind: total_kind, page_limit: response.page_limit,
|
|
50
|
+
facets: facets, chips: Chips.for(query), timings: {total_ms: elapsed(started)}, merged: merged, group_totals: group_totals)
|
|
51
|
+
rescue SearchUnavailable
|
|
52
|
+
instrument(:unavailable, nil, started)
|
|
53
|
+
result(:unavailable, query, rewritten || false)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Every record of one group (a GroupToken in the request), any status,
|
|
57
|
+
# within the scope and query-filter hooks. total: the group's records;
|
|
58
|
+
# shown: "index:id" of the records the page already showed (see
|
|
59
|
+
# Result#unshown_hits).
|
|
60
|
+
#
|
|
61
|
+
# @return [Querylane::Result] :unavailable when the token is invalid or expired
|
|
62
|
+
def related
|
|
63
|
+
started = clock
|
|
64
|
+
value, shown = GroupToken.read(@definition, @request.group)
|
|
65
|
+
return result(:denied, nil, false) if @scope.denied?
|
|
66
|
+
return result(:unavailable, nil, false) if value.nil? || @definition.group.nil?
|
|
67
|
+
|
|
68
|
+
group = @definition.group
|
|
69
|
+
compiled = Compiler::Compiled.new(text: nil, text_attributes: [], matching: :all,
|
|
70
|
+
filter_builder: ->(_index) { Filter.eq(group.attribute, value) })
|
|
71
|
+
response = searcher.search(definition: @definition, scope: hooked_scope,
|
|
72
|
+
page: PageRequest.new(compiled, group_sort(group), 0, group.limit), counts: [CountRequest.new(:total, compiled, nil, nil)])
|
|
73
|
+
hits, = run_result_hooks(response.hits)
|
|
74
|
+
instrument(:related, response, started)
|
|
75
|
+
result(hits.empty? ? :empty : :ok, nil, false, hits: hits, total: response.counts&.dig(:total) || response.total, shown: shown)
|
|
76
|
+
rescue SearchUnavailable
|
|
77
|
+
result(:unavailable, nil, false)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Suggestions for the request's text: operator completions for its last
|
|
81
|
+
# word, and hits (people) for the rest. Never fails the page: a backend
|
|
82
|
+
# error leaves only the completions.
|
|
83
|
+
#
|
|
84
|
+
# @return [Querylane::Result] state :ok/:empty/:first_load/:invalid/:unavailable, with completions
|
|
85
|
+
def suggest
|
|
86
|
+
completions = Suggest.completions(@definition, @request.query)
|
|
87
|
+
whole = Query.analyze(@definition, @request.query, config: @config)
|
|
88
|
+
typed = whole.valid? ? Preview.masked(whole) : nil
|
|
89
|
+
query = Query.analyze(@definition, Suggest.people_text(@definition, @request.query), config: @config)
|
|
90
|
+
return result(:invalid, query, false, completions: completions, typed: typed) unless query.valid?
|
|
91
|
+
return result(:first_load, query, false, completions: completions, typed: typed) if query.blank? || query.too_short?(@definition.min_text_length) || @scope.denied?
|
|
92
|
+
|
|
93
|
+
started = clock
|
|
94
|
+
response = searcher.search(definition: @definition, scope: hooked_scope,
|
|
95
|
+
page: PageRequest.new(compile(query), @definition.default_sort, 0, @config.suggest_records), counts: [])
|
|
96
|
+
hits, = run_result_hooks(response.hits)
|
|
97
|
+
instrument(:suggest, response, started)
|
|
98
|
+
result(hits.empty? ? :empty : :ok, query, false, hits: hits, total: response.total, completions: completions, typed: typed)
|
|
99
|
+
rescue SearchUnavailable
|
|
100
|
+
result(:unavailable, query, false, completions: completions, typed: typed)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def searcher = @searcher || @config.searcher
|
|
106
|
+
|
|
107
|
+
# A group's records come back in the group's own order (default: relevance).
|
|
108
|
+
def group_sort(group)
|
|
109
|
+
group.order ? Definition::Sort.new(:group, "", group.order, false) : @definition.default_sort
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Records per group (value, down-cased) within the scope, so "more records"
|
|
113
|
+
# shows only when there are more; nil when unknown (no group, or it failed).
|
|
114
|
+
def group_totals_for(hits)
|
|
115
|
+
group = @definition.group
|
|
116
|
+
return if group.nil? || !searcher.respond_to?(:group_counts)
|
|
117
|
+
|
|
118
|
+
values = hits.filter_map { |hit| @definition.group_value(hit.document) }.uniq
|
|
119
|
+
searcher.group_counts(definition: @definition, scope: hooked_scope, attribute: group.attribute, values: values)
|
|
120
|
+
rescue SearchUnavailable
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The required scope narrowed by every query-filter hook, per index.
|
|
125
|
+
def hooked_scope
|
|
126
|
+
hooks = @definition.hooks_for(:query_filter)
|
|
127
|
+
return @scope if hooks.empty?
|
|
128
|
+
|
|
129
|
+
extra = @scope.indexes.to_h do |index|
|
|
130
|
+
filters = hooks.map do |hook|
|
|
131
|
+
filter = guard(hook) { @definition.resolve_hook(hook).call(index: index, context: @context) }
|
|
132
|
+
raise HookError, "query_filter hook #{hook.reference} must return a Querylane::Filter" unless filter_node?(filter)
|
|
133
|
+
|
|
134
|
+
filter
|
|
135
|
+
end
|
|
136
|
+
[index, Filter.and(*filters)]
|
|
137
|
+
end
|
|
138
|
+
@scope.narrowed(extra)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Runs results hooks in order. Each may keep a subset of the hits (in
|
|
142
|
+
# order) or merge hits into one (via contributors); never add records.
|
|
143
|
+
#
|
|
144
|
+
# @return [Array(Array<Result::Hit>, Boolean)] hits and whether any were merged away
|
|
145
|
+
def run_result_hooks(hits)
|
|
146
|
+
allowed = hits.to_h { |hit| [[hit.index, hit.id], hit] }
|
|
147
|
+
merged = false
|
|
148
|
+
@definition.hooks_for(:results).each do |hook|
|
|
149
|
+
output = guard(hook) { @definition.resolve_hook(hook).call(hits: hits.freeze, context: @context) }
|
|
150
|
+
validate_result_hook!(hook, output, allowed)
|
|
151
|
+
merged ||= hook.cardinality == :changing && output.sum { |hit| 1 + hit.contributors.size } != hits.sum { |hit| 1 + hit.contributors.size }
|
|
152
|
+
merged ||= output.any? { |hit| hit.contributors.any? }
|
|
153
|
+
hits = output
|
|
154
|
+
end
|
|
155
|
+
[hits, merged]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate_result_hook!(hook, output, allowed)
|
|
159
|
+
valid = output.is_a?(Array) && output.all? do |hit|
|
|
160
|
+
hit.is_a?(Result::Hit) && allowed.key?([hit.index, hit.id]) && hit.contributors.all? { |other| allowed.key?([other.index, other.id]) }
|
|
161
|
+
end
|
|
162
|
+
raise HookError, "results hook #{hook.reference} returned records it was not given" unless valid
|
|
163
|
+
raise HookError, "results hook #{hook.reference} is :preserving but changed the hits" if hook.cardinality == :preserving && output.size != output.uniq.size
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def guard(hook)
|
|
167
|
+
yield
|
|
168
|
+
rescue HookError
|
|
169
|
+
raise
|
|
170
|
+
rescue => error
|
|
171
|
+
raise HookError, "#{hook.stage} hook #{hook.reference} failed (#{error.class.name})"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def filter_node?(filter)
|
|
175
|
+
filter.class.name.to_s.start_with?("Querylane::Filter::")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# :exact, :capped (at the backend's maxTotalHits; may be higher) or
|
|
179
|
+
# :estimated (counts unavailable).
|
|
180
|
+
def total_of(response, counts)
|
|
181
|
+
return [response.total, :estimated] unless counts&.key?(:total)
|
|
182
|
+
|
|
183
|
+
cap = searcher.respond_to?(:max_total_hits) ? searcher.max_total_hits : nil
|
|
184
|
+
total = counts[:total]
|
|
185
|
+
[total, (cap && total >= cap) ? :capped : :exact]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def rewrite(query)
|
|
189
|
+
return [Query.analyze(@definition, "", config: @config), query.source.present?] if @request.clear
|
|
190
|
+
# "Fix it": apply the error's correction (only an invalid query has one).
|
|
191
|
+
if @request.fix && !query.valid? && query.error.fix
|
|
192
|
+
text = Rewriter.replace(query.source, query.error.range, query.error.fix)
|
|
193
|
+
return [Query.analyze(@definition, text, config: @config), true]
|
|
194
|
+
end
|
|
195
|
+
# A saved search replaces the box whatever it holds (even an unfinished query).
|
|
196
|
+
if @request.saved
|
|
197
|
+
text = saved_text(@request.saved) || query.source
|
|
198
|
+
return [Query.analyze(@definition, text, config: @config), text != query.source]
|
|
199
|
+
end
|
|
200
|
+
return [query, false] unless rewrite_requested?(query)
|
|
201
|
+
|
|
202
|
+
text =
|
|
203
|
+
if @request.compose
|
|
204
|
+
Rewriter.compose(query, @request.compose)
|
|
205
|
+
elsif @request.toggle
|
|
206
|
+
Rewriter.toggle(query, *@request.toggle)
|
|
207
|
+
elsif @request.widen
|
|
208
|
+
Rewriter.without(query, @request.widen)
|
|
209
|
+
elsif @request.instead
|
|
210
|
+
chip_index, alternative_index = @request.instead
|
|
211
|
+
chip = Chips.for(query).find { |candidate| candidate.index == chip_index }
|
|
212
|
+
alternative = chip&.alternatives&.find { |candidate| candidate.index == alternative_index }
|
|
213
|
+
alternative ? Rewriter.replace(query.source, chip.ranges.first, alternative.text) : query.source
|
|
214
|
+
else
|
|
215
|
+
chip = Chips.for(query).find { |candidate| candidate.index == @request.remove }
|
|
216
|
+
chip ? Rewriter.remove(query.source, chip.ranges) : query.source
|
|
217
|
+
end
|
|
218
|
+
rewritten = Query.analyze(@definition, text, config: @config)
|
|
219
|
+
[rewritten, text != query.source]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Compiles with this request's matching strategy (hits and counts agree).
|
|
223
|
+
def compile(query)
|
|
224
|
+
compiled = Compiler.compile(query)
|
|
225
|
+
@request.relax ? compiled.with(matching: :frequency) : compiled
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def rewrite_requested?(query)
|
|
229
|
+
query.valid? && @request.rewriting?
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def page_request(query)
|
|
233
|
+
PageRequest.new(compile(query), @request.sort, @request.offset, @request.per)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def count_requests(query)
|
|
237
|
+
requests = [CountRequest.new(:total, compile(query), nil, nil)]
|
|
238
|
+
@definition.facets.each do |facet|
|
|
239
|
+
base = Query.analyze(@definition, Rewriter.without(query, facet), config: @config)
|
|
240
|
+
next unless base.valid?
|
|
241
|
+
|
|
242
|
+
requests << facet_count_request(facet, compile(base), Rewriter.ticked(query, facet))
|
|
243
|
+
end
|
|
244
|
+
requests
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def facet_count_request(facet, compiled, ticked)
|
|
248
|
+
if facet.flag
|
|
249
|
+
flags = @definition.operator(facet.flag)
|
|
250
|
+
return CountRequest.new(facet.key, compiled, flags.attribute, nil) if flags.attribute
|
|
251
|
+
|
|
252
|
+
filters = @definition.flag_values(flags, facet.values).keys.to_h { |value| [value.to_s, ->(index) { @definition.flag_filter(flags, value, index: index) }] }
|
|
253
|
+
CountRequest.new(facet.key, compiled, nil, filters)
|
|
254
|
+
else
|
|
255
|
+
operator = @definition.operator(facet.filter)
|
|
256
|
+
extra = ticked.to_h { |value| [value, ->(_index) { Filter.eq(operator.attribute, value) }] }
|
|
257
|
+
CountRequest.new(facet.key, compiled, operator.attribute, extra.presence)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def build_facets(query, counts)
|
|
262
|
+
@definition.facets.map do |facet|
|
|
263
|
+
ticked = Rewriter.ticked(query, facet)
|
|
264
|
+
found = counts[facet.key]
|
|
265
|
+
next Result::Facet.new(facet.key, facet.label, [], true, false, facet.show, facet.search) unless found
|
|
266
|
+
|
|
267
|
+
Result::Facet.new(facet.key, facet.label, facet_values(facet, found, ticked), false, truncated?(facet, found), facet.show, facet.search)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A filter facet's distribution may be cut at the backend's
|
|
272
|
+
# maxValuesPerFacet (default 100); counts are then approximate.
|
|
273
|
+
def truncated?(facet, found)
|
|
274
|
+
facet.filter && found.size >= 100
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def facet_values(facet, found, ticked)
|
|
278
|
+
if facet.flag
|
|
279
|
+
flags = @definition.operator(facet.flag)
|
|
280
|
+
@definition.flag_values(flags, facet.values).map { |value, label| Result::FacetValue.new(value.to_s, label, found.fetch(value.to_s, 0), ticked.include?(value.to_s)) }
|
|
281
|
+
else
|
|
282
|
+
top = found.sort_by { |value, count| [-count, value.to_s] }.first(facet.top).map(&:first)
|
|
283
|
+
operator = @definition.operator(facet.filter)
|
|
284
|
+
(top | ticked).map do |value|
|
|
285
|
+
label = operator.normalize ? operator.normalize.call(value) : value
|
|
286
|
+
Result::FacetValue.new(value, label, found.fetch(value, 0), ticked.include?(value))
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Saves the query through the host's store unless it holds a sensitive term.
|
|
292
|
+
#
|
|
293
|
+
# @return [Symbol, nil] :saved, :refused or :store_failed; nil when there is nothing to save
|
|
294
|
+
def save(query)
|
|
295
|
+
store = @definition.saved_searches
|
|
296
|
+
return if store.nil? || !query.valid? || query.blank?
|
|
297
|
+
return :refused if Saving.refusal(query)
|
|
298
|
+
|
|
299
|
+
store.create(context: @context, name: @request.saved_name.presence || query.source.first(80), query: query.source)
|
|
300
|
+
:saved
|
|
301
|
+
rescue => error
|
|
302
|
+
saved_store_failed(error)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def saved_text(id)
|
|
306
|
+
@definition.saved_searches&.use(context: @context, id: id)&.query
|
|
307
|
+
rescue => error
|
|
308
|
+
saved_store_failed(error)
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def saved_list
|
|
313
|
+
store = @definition.saved_searches
|
|
314
|
+
store && Array(store.all(context: @context))
|
|
315
|
+
rescue => error
|
|
316
|
+
@notice = saved_store_failed(error)
|
|
317
|
+
[]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# A failing store never breaks the search; it is reported and shown as a notice.
|
|
321
|
+
def saved_store_failed(error)
|
|
322
|
+
ActiveSupport::Notifications.instrument("saved_search_failed.querylane", definition: @definition.name, error: error.class.name)
|
|
323
|
+
:store_failed
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def result(state, query, rewritten, **attributes)
|
|
327
|
+
if @with_saved
|
|
328
|
+
attributes = attributes.merge(saved_searches: saved_list, notice: @notice,
|
|
329
|
+
save_refusal: (Saving.refusal(query) if query&.valid?))
|
|
330
|
+
end
|
|
331
|
+
if @request.options && query&.valid?
|
|
332
|
+
panel = Rewriter.panel(query)
|
|
333
|
+
attributes = attributes.merge(panel: panel, preview: Preview.for(@definition, query.source, panel.values, config: @config))
|
|
334
|
+
end
|
|
335
|
+
Result.new(definition: @definition, state: state, query: query&.source.to_s, request: @request, rewritten: rewritten, **attributes)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def instrument(state, response, started)
|
|
339
|
+
ActiveSupport::Notifications.instrument("search.querylane", definition: @definition.name, state: state,
|
|
340
|
+
backend_calls: response&.backend_calls, hits: response&.hits&.size, duration_ms: elapsed(started))
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
344
|
+
|
|
345
|
+
def elapsed(started) = ((clock - started) * 1000).round(1)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
module Searchers
|
|
5
|
+
# Default searcher: Meilisearch 1.13+ through the meilisearch gem 0.33.x
|
|
6
|
+
# (the host adds `gem "meilisearch", "~> 0.33.0"`).
|
|
7
|
+
#
|
|
8
|
+
# Two HTTP calls per search (docs/spikes/meilisearch-1.13.md):
|
|
9
|
+
# 1. a federated multi-search for the page of hits across all indexes;
|
|
10
|
+
# 2. one batched multi-search for the exact total and every facet count
|
|
11
|
+
# (`page: 1, hits_per_page: 0` queries, facet distributions).
|
|
12
|
+
class Meilisearch
|
|
13
|
+
HIGHLIGHT_PRE = "\u0001"
|
|
14
|
+
HIGHLIGHT_POST = "\u0002"
|
|
15
|
+
# Meilisearch's default pagination.maxTotalHits; hosts may raise it in
|
|
16
|
+
# index settings and pass `max_total_hits:`.
|
|
17
|
+
DEFAULT_MAX_TOTAL_HITS = 1000
|
|
18
|
+
|
|
19
|
+
Response = Data.define(:hits, :total, :counts, :page_limit, :backend_calls)
|
|
20
|
+
|
|
21
|
+
# @param client [Meilisearch::Client, #call] a client, or a callable returning one
|
|
22
|
+
# @param max_total_hits [Integer] the indexes' pagination.maxTotalHits
|
|
23
|
+
def initialize(client:, max_total_hits: DEFAULT_MAX_TOTAL_HITS)
|
|
24
|
+
require_gem!
|
|
25
|
+
@client = client
|
|
26
|
+
@max_total_hits = max_total_hits
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Response]
|
|
30
|
+
# @return [Response] counts is nil when the count batch failed (hits kept)
|
|
31
|
+
def search(definition:, scope:, page:, counts:)
|
|
32
|
+
indexes = searchable_indexes(definition, scope, page.compiled)
|
|
33
|
+
return Response.new([], 0, empty_counts(counts), 0, 0) if indexes.empty?
|
|
34
|
+
|
|
35
|
+
hits_response = call { client.multi_search(queries: page_queries(definition, scope, page, indexes), federation: {offset: page.offset, limit: page.limit}) }
|
|
36
|
+
counts_read = count_batch(definition, scope, counts, indexes)
|
|
37
|
+
Response.new(hits(hits_response, indexes), hits_response["estimatedTotalHits"].to_i, counts_read,
|
|
38
|
+
(@max_total_hits / page.limit.to_f).ceil, counts_read ? 2 : 1)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The cap Meilisearch applies to totalHits; a count equal to it may be higher.
|
|
42
|
+
attr_reader :max_total_hits
|
|
43
|
+
|
|
44
|
+
# How many records each group has within the scope: one facet count per
|
|
45
|
+
# index on the group attribute, filtered to the page's group values.
|
|
46
|
+
#
|
|
47
|
+
# @param values [Array<String>] the group values on the page
|
|
48
|
+
# @return [Hash{String => Integer}] keyed by the value, down-cased
|
|
49
|
+
def group_counts(definition:, scope:, attribute:, values:)
|
|
50
|
+
return {} if values.empty?
|
|
51
|
+
|
|
52
|
+
indexes = scope.indexes.each_with_object({}) do |key, found|
|
|
53
|
+
found[definition.index_name(key)] = key unless scope.filter_for(key).is_a?(Filter::Nothing)
|
|
54
|
+
end
|
|
55
|
+
return {} if indexes.empty?
|
|
56
|
+
|
|
57
|
+
queries = indexes.map do |name, key|
|
|
58
|
+
filter = Filter.and(scope.filter_for(key), Filter.in(attribute, values))
|
|
59
|
+
{index_uid: name, q: "", filter: Expression.render(filter), facets: [attribute.to_s], page: 1, hits_per_page: 0}
|
|
60
|
+
end
|
|
61
|
+
call { client.multi_search(queries: queries) }.fetch("results").each_with_object(Hash.new(0)) do |answer, totals|
|
|
62
|
+
answer.fetch("facetDistribution", {}).fetch(attribute.to_s, {}).each { |value, count| totals[value.to_s.downcase] += count }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def client = @client.respond_to?(:call) ? @client.call : @client
|
|
69
|
+
|
|
70
|
+
def call
|
|
71
|
+
yield
|
|
72
|
+
rescue ::Meilisearch::ApiError, ::Meilisearch::CommunicationError, ::Meilisearch::TimeoutError => error
|
|
73
|
+
raise SearchUnavailable, "Meilisearch #{error.class.name.demodulize}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Indexes whose scope and query can match something; the others are
|
|
77
|
+
# never sent (a Nothing scope must not turn into an unrestricted query).
|
|
78
|
+
def searchable_indexes(definition, scope, compiled)
|
|
79
|
+
scope.indexes.each_with_object({}) do |key, indexes|
|
|
80
|
+
next if Filter.and(scope.filter_for(key), compiled.filter_for(key)).is_a?(Filter::Nothing)
|
|
81
|
+
|
|
82
|
+
indexes[definition.index_name(key)] = key
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def empty_counts(counts)
|
|
87
|
+
counts.to_h { |count| [count.key, (count.key == :total) ? 0 : {}] }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Exact totals and facet counts; nil if the batch fails (hits are kept).
|
|
91
|
+
def count_batch(definition, scope, counts, indexes)
|
|
92
|
+
queries, readers = count_plan(definition, scope, counts, indexes)
|
|
93
|
+
return {} if queries.empty?
|
|
94
|
+
|
|
95
|
+
read_counts(readers, call { client.multi_search(queries: queries).fetch("results") })
|
|
96
|
+
rescue SearchUnavailable
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def require_gem!
|
|
101
|
+
require "meilisearch"
|
|
102
|
+
return if Gem::Requirement.new("~> 0.33.0").satisfied_by?(Gem::Version.new(::Meilisearch::VERSION))
|
|
103
|
+
|
|
104
|
+
raise ConfigurationError, "querylane's Meilisearch searcher needs the meilisearch gem ~> 0.33.0 (found #{::Meilisearch::VERSION})"
|
|
105
|
+
rescue LoadError
|
|
106
|
+
raise ConfigurationError, "Add gem \"meilisearch\", \"~> 0.33.0\" to use Querylane::Searchers::Meilisearch"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def base_query(name, key, scope, compiled)
|
|
110
|
+
query = {index_uid: name, q: compiled.text.to_s, matching_strategy: compiled.matching.to_s}
|
|
111
|
+
query[:attributes_to_search_on] = compiled.text_attributes if compiled.text
|
|
112
|
+
combined = Filter.and(scope.filter_for(key), compiled.filter_for(key))
|
|
113
|
+
filter = Expression.render(combined) unless combined.is_a?(Filter::Nothing)
|
|
114
|
+
query[:filter] = filter if filter
|
|
115
|
+
query
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def page_queries(definition, scope, page, indexes)
|
|
119
|
+
indexes.map do |name, key|
|
|
120
|
+
query = base_query(name, key, scope, page.compiled).merge(
|
|
121
|
+
attributes_to_highlight: definition.highlight, highlight_pre_tag: HIGHLIGHT_PRE, highlight_post_tag: HIGHLIGHT_POST,
|
|
122
|
+
show_ranking_score_details: true
|
|
123
|
+
)
|
|
124
|
+
query[:attributes_to_retrieve] = definition.retrieve if definition.retrieve
|
|
125
|
+
query[:sort] = page.sort.by.map { |attribute, direction| "#{attribute}:#{direction}" } if page.sort&.by
|
|
126
|
+
query
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Builds the count queries and, for each, how to read its answer.
|
|
131
|
+
def count_plan(definition, scope, counts, indexes)
|
|
132
|
+
queries = []
|
|
133
|
+
readers = []
|
|
134
|
+
counts.each do |count|
|
|
135
|
+
indexes.each do |name, key|
|
|
136
|
+
base = base_query(name, key, scope, count.compiled).merge(page: 1, hits_per_page: 0)
|
|
137
|
+
if count.attribute.nil? && count.value_filters.nil?
|
|
138
|
+
queries << base
|
|
139
|
+
readers << [count.key, :total, nil]
|
|
140
|
+
end
|
|
141
|
+
if count.attribute
|
|
142
|
+
queries << base.merge(facets: [count.attribute])
|
|
143
|
+
readers << [count.key, :distribution, count.attribute]
|
|
144
|
+
end
|
|
145
|
+
count.value_filters&.each do |value, filter|
|
|
146
|
+
combined = Filter.and(scope.filter_for(key), count.compiled.filter_for(key), filter.call(key))
|
|
147
|
+
next if combined.is_a?(Filter::Nothing)
|
|
148
|
+
|
|
149
|
+
queries << base.merge(filter: Expression.render(combined)).compact
|
|
150
|
+
readers << [count.key, :value, value]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
[queries, readers]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Sums each count across indexes. Explicit per-value counts replace the
|
|
158
|
+
# distribution's number for that value (they are exact for ticked values
|
|
159
|
+
# outside the distribution's top values).
|
|
160
|
+
def read_counts(readers, results)
|
|
161
|
+
counts = {}
|
|
162
|
+
explicit = Hash.new { |hash, key| hash[key] = Hash.new(0) }
|
|
163
|
+
readers.each_with_index do |(key, kind, detail), position|
|
|
164
|
+
answer = results.fetch(position)
|
|
165
|
+
case kind
|
|
166
|
+
when :total then counts[key] = counts.fetch(key, 0) + answer["totalHits"].to_i
|
|
167
|
+
when :distribution
|
|
168
|
+
bucket = counts[key] ||= Hash.new(0)
|
|
169
|
+
answer.fetch("facetDistribution", {}).fetch(detail, {}).each { |value, number| bucket[value.to_s] += number }
|
|
170
|
+
when :value then explicit[key][detail] += answer["totalHits"].to_i
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
explicit.each do |key, values|
|
|
174
|
+
bucket = counts[key] ||= Hash.new(0)
|
|
175
|
+
values.each { |value, number| bucket[value] = number unless bucket.key?(value) }
|
|
176
|
+
end
|
|
177
|
+
counts
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def hits(response, indexes)
|
|
181
|
+
response.fetch("hits").map do |hit|
|
|
182
|
+
index = indexes.fetch(hit.dig("_federation", "indexUid"))
|
|
183
|
+
document = hit.reject { |key, _| key.start_with?("_") }
|
|
184
|
+
Result::Hit.new(index: index, id: document["id"].to_s, document: document, formatted: hit["_formatted"] || {},
|
|
185
|
+
ranking_details: hit["_rankingScoreDetails"] || {})
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Renders a Querylane::Filter as a Meilisearch filter expression.
|
|
190
|
+
module Expression
|
|
191
|
+
module_function
|
|
192
|
+
|
|
193
|
+
# @return [String, nil] nil when the filter matches everything
|
|
194
|
+
def render(filter)
|
|
195
|
+
case filter
|
|
196
|
+
when Filter::Everything, nil then nil
|
|
197
|
+
when Filter::Nothing then raise ArgumentError, "Nothing filters are never sent; the index is skipped"
|
|
198
|
+
when Filter::Raw then filter.expression
|
|
199
|
+
when Filter::Eq then "#{filter.attribute} = #{value(filter.value)}"
|
|
200
|
+
when Filter::In then "#{filter.attribute} IN [#{filter.values.map { |item| value(item) }.join(", ")}]"
|
|
201
|
+
when Filter::Null then "#{filter.attribute} IS NULL"
|
|
202
|
+
when Filter::Missing then "#{filter.attribute} NOT EXISTS"
|
|
203
|
+
when Filter::Not then "NOT (#{render(filter.filter)})"
|
|
204
|
+
when Filter::And then join(filter.filters, "AND")
|
|
205
|
+
when Filter::Or then join(filter.filters, "OR")
|
|
206
|
+
else raise ArgumentError, "Unknown filter #{filter.class}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def join(filters, operator)
|
|
211
|
+
parts = filters.filter_map { |part| render(part) }
|
|
212
|
+
return nil if parts.empty? && operator == "AND"
|
|
213
|
+
|
|
214
|
+
parts.map { |part| "(#{part})" }.join(" #{operator} ")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def value(item)
|
|
218
|
+
case item
|
|
219
|
+
when true, false, Numeric then item.to_s
|
|
220
|
+
else "'#{item.to_s.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }}'"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Suggestions for the last word of a query (header box and palette):
|
|
5
|
+
# operator names it starts ("pol" → policy:) and flag values after a key
|
|
6
|
+
# ("is:" → is:active). The rest of the text is what people are searched by.
|
|
7
|
+
module Suggest
|
|
8
|
+
# word: replaces the query's last word (the page never repeats the query).
|
|
9
|
+
Completion = Data.define(:word, :hint)
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param definition [Querylane::Definition]
|
|
14
|
+
# @param text [String] the box text
|
|
15
|
+
# @return [Array<Completion>]
|
|
16
|
+
def completions(definition, text)
|
|
17
|
+
word = split(text).last
|
|
18
|
+
return [] if word.empty? || word.start_with?("-", "(", '"')
|
|
19
|
+
|
|
20
|
+
key, partial = word.split(":", 2)
|
|
21
|
+
partial.nil? ? operator_completions(definition, word) : value_completions(definition, key, partial)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The text people are searched by: without a trailing word that is being
|
|
25
|
+
# completed into an operator (it is not a name).
|
|
26
|
+
#
|
|
27
|
+
# @return [String]
|
|
28
|
+
def people_text(definition, text)
|
|
29
|
+
head, word = split(text)
|
|
30
|
+
(completions(definition, text).any? || word.include?(":")) ? head.rstrip : text
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def split(text)
|
|
34
|
+
text = text.to_s
|
|
35
|
+
word = text[/\S*\z/]
|
|
36
|
+
[text[0, text.length - word.length], word]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def operator_completions(definition, word)
|
|
40
|
+
return [] if word.length < 2
|
|
41
|
+
|
|
42
|
+
typed = word.downcase
|
|
43
|
+
(definition.texts + definition.filters + definition.flags).filter_map do |operator|
|
|
44
|
+
keys = [operator.key, *(operator.respond_to?(:aliases) ? operator.aliases : [])].map(&:to_s)
|
|
45
|
+
next if keys.include?(typed)
|
|
46
|
+
|
|
47
|
+
key = keys.find { |candidate| candidate.start_with?(typed) }
|
|
48
|
+
Completion.new("#{key}:", hint(operator)) if key
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def value_completions(definition, key, partial)
|
|
53
|
+
flags = definition.operator(key)
|
|
54
|
+
return [] unless flags.is_a?(Definition::Flags)
|
|
55
|
+
|
|
56
|
+
flags.values.select { |value, _| value.to_s.start_with?(partial.downcase) && value.to_s != partial.downcase }
|
|
57
|
+
.map { |value, label| Completion.new("#{key}:#{value}", label) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String] a short description of an operator
|
|
61
|
+
def hint(operator)
|
|
62
|
+
return operator.label if operator.is_a?(Definition::Flags)
|
|
63
|
+
|
|
64
|
+
operator.label.gsub("%{value}", "").strip
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|