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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/CONTRIBUTING.md +118 -0
  4. data/LICENSE +21 -0
  5. data/README.md +287 -0
  6. data/SECURITY.md +29 -0
  7. data/app/assets/stylesheets/querylane.css +300 -0
  8. data/app/helpers/querylane/search_helper.rb +111 -0
  9. data/app/javascript/querylane/controllers/box_controller.js +170 -0
  10. data/app/javascript/querylane/controllers/options_controller.js +54 -0
  11. data/app/javascript/querylane/controllers/results_controller.js +137 -0
  12. data/app/javascript/querylane/controllers/suggest_controller.js +187 -0
  13. data/app/javascript/querylane/index.js +33 -0
  14. data/app/javascript/querylane/overlay.js +46 -0
  15. data/app/javascript/querylane/package.json +7 -0
  16. data/app/views/querylane/_chips.html.erb +15 -0
  17. data/app/views/querylane/_denied.html.erb +6 -0
  18. data/app/views/querylane/_empty.html.erb +17 -0
  19. data/app/views/querylane/_facets.html.erb +30 -0
  20. data/app/views/querylane/_first_load.html.erb +15 -0
  21. data/app/views/querylane/_form.html.erb +46 -0
  22. data/app/views/querylane/_group.html.erb +21 -0
  23. data/app/views/querylane/_help.html.erb +29 -0
  24. data/app/views/querylane/_options.html.erb +32 -0
  25. data/app/views/querylane/_options_row.html.erb +51 -0
  26. data/app/views/querylane/_outcome.html.erb +16 -0
  27. data/app/views/querylane/_pager.html.erb +19 -0
  28. data/app/views/querylane/_preview.html.erb +39 -0
  29. data/app/views/querylane/_record.html.erb +16 -0
  30. data/app/views/querylane/_related.html.erb +17 -0
  31. data/app/views/querylane/_results.html.erb +66 -0
  32. data/app/views/querylane/_rows.html.erb +13 -0
  33. data/app/views/querylane/_saved.html.erb +31 -0
  34. data/app/views/querylane/_suggest_box.html.erb +45 -0
  35. data/app/views/querylane/_suggestions.html.erb +47 -0
  36. data/app/views/querylane/_toolbar.html.erb +28 -0
  37. data/app/views/querylane/_unavailable.html.erb +7 -0
  38. data/app/views/querylane/_writes.html.erb +16 -0
  39. data/config/importmap.rb +34 -0
  40. data/config/locales/querylane.en.yml +136 -0
  41. data/lib/querylane/ast.rb +17 -0
  42. data/lib/querylane/chips.rb +85 -0
  43. data/lib/querylane/compiler.rb +52 -0
  44. data/lib/querylane/configuration.rb +49 -0
  45. data/lib/querylane/context.rb +11 -0
  46. data/lib/querylane/controller.rb +85 -0
  47. data/lib/querylane/definition.rb +447 -0
  48. data/lib/querylane/doctor.rb +52 -0
  49. data/lib/querylane/engine.rb +61 -0
  50. data/lib/querylane/errors.rb +27 -0
  51. data/lib/querylane/filter.rb +75 -0
  52. data/lib/querylane/group_token.rb +33 -0
  53. data/lib/querylane/parser.rb +178 -0
  54. data/lib/querylane/presenter.rb +160 -0
  55. data/lib/querylane/preview.rb +45 -0
  56. data/lib/querylane/query.rb +183 -0
  57. data/lib/querylane/request.rb +66 -0
  58. data/lib/querylane/result.rb +90 -0
  59. data/lib/querylane/rewriter.rb +245 -0
  60. data/lib/querylane/saving.rb +49 -0
  61. data/lib/querylane/scope.rb +53 -0
  62. data/lib/querylane/search.rb +347 -0
  63. data/lib/querylane/searchers/meilisearch.rb +226 -0
  64. data/lib/querylane/suggest.rb +67 -0
  65. data/lib/querylane/version.rb +5 -0
  66. data/lib/querylane.rb +60 -0
  67. data/lib/tasks/querylane.rake +14 -0
  68. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ VERSION = "0.1.0"
5
+ end