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,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # What a search understands: indexes, operators, facets, sorts and paging.
5
+ # Built once with a block and frozen; host classes are referenced by name and
6
+ # resolved per call, so Rails code reloading never leaves stale methods.
7
+ #
8
+ # MEMBERS = Querylane::Definition.build(:members) do |d|
9
+ # d.index :employees, name: -> { "employees_#{Rails.env}" }
10
+ # d.text :name, attributes: %i[full_name], default: true, label: "Name contains %{value}"
11
+ # d.filter :policy, attribute: :policy_number, label: "Policy %{value}"
12
+ # d.flags :is, alias: :status, attribute: :status_label, label: "Status",
13
+ # values: { active: "Active", terminated: "Terminated" }
14
+ # d.facet :status, flag: :is
15
+ # d.sort :best, default: true
16
+ # end
17
+ class Definition
18
+ Index = Data.define(:key, :name)
19
+ Text = Data.define(:key, :attributes, :sensitive, :default, :label, :mask)
20
+ FilterOperator = Data.define(:key, :attribute, :sensitive, :normalize, :format, :label, :mask)
21
+ Flags = Data.define(:key, :aliases, :attribute, :values, :rules, :label)
22
+ # values: for a flag facet, the flag values it shows (nil: all of them).
23
+ # top: filter-facet values loaded; show: how many show before "Find…";
24
+ # search: a "Find…" box filtering the loaded values in the browser.
25
+ Facet = Data.define(:key, :label, :flag, :filter, :top, :values, :show, :search)
26
+ Sort = Data.define(:key, :label, :by, :default)
27
+ # stage: :query_filter (returns a Filter per index, ANDed into every query)
28
+ # or :results (transforms the page of hits). cardinality (results only):
29
+ # :preserving (keeps every hit) or :changing (drops or merges hits).
30
+ Hook = Data.define(:stage, :reference, :cardinality)
31
+ Example = Data.define(:query, :description)
32
+ # A checkbox in the options panel standing for exact query text (e.g. "-is:terminated").
33
+ Option = Data.define(:key, :label, :text)
34
+ # Options panel layout. A row is :inputs (one input per operator, or one
35
+ # shared input when `bare`: its value is written unprefixed and read by
36
+ # the interpreter), :flags (checkboxes) or :options (Option checkboxes).
37
+ PanelGroup = Data.define(:label, :rows)
38
+ # Hits sharing `attribute` are one group (e.g. a person); `valid` (callable
39
+ # or class name) rejects values that must not group; `limit` caps "more
40
+ # records" and the preview.
41
+ # order: attributes => :asc/:desc for a group's records, the same on the
42
+ # page, in "more records" and in the preview (nil: relevance order).
43
+ GroupBy = Data.define(:attribute, :valid, :limit, :order)
44
+ # values: for a :flags row, the flag values it shows (nil: all of them).
45
+ # masked: an operator in the row masks its values (mask other than :none):
46
+ # the field starts empty and hides what is typed.
47
+ PanelRow = Data.define(:label, :keys, :kind, :bare, :masked, :values) do
48
+ # @return [String] the compose field name (unique per row, also when two
49
+ # rows show different values of one flag family)
50
+ def name
51
+ return keys.join("_or_") if bare
52
+ return "#{keys.first}-#{values.join("-")}" if values
53
+
54
+ keys.first.to_s
55
+ end
56
+ end
57
+ HOOK_STAGES = %i[query_filter results].freeze
58
+
59
+ NORMALIZERS = {
60
+ digits: ->(value) { value.delete("^0-9") },
61
+ upcase: ->(value) { value.upcase },
62
+ downcase: ->(value) { value.downcase },
63
+ strip: ->(value) { value.strip }
64
+ }.freeze
65
+ MASKS = %i[none all last4 count].freeze
66
+
67
+ attr_reader :name, :indexes, :texts, :filters, :flags, :facets, :sorts, :per_options, :retrieve, :highlight, :hooks, :group,
68
+ :saved_store, :min_text_length, :parked
69
+
70
+ # @param name [Symbol]
71
+ # @yieldparam builder [Querylane::Definition::Builder]
72
+ # @return [Querylane::Definition] frozen and validated
73
+ def self.build(name, &)
74
+ builder = Builder.new
75
+ yield builder
76
+ new(name, builder).freeze
77
+ end
78
+
79
+ # Operator by key or alias (case-insensitive), or nil.
80
+ #
81
+ # @return [Text, FilterOperator, Flags, nil]
82
+ def operator(key)
83
+ @operators[key.to_s.downcase]
84
+ end
85
+
86
+ # @return [Array<String>] every key and alias, for "did you mean"
87
+ def operator_keys = @operators.keys
88
+
89
+ # @return [Text] where bare words go
90
+ def default_text = texts.find(&:default) || texts.first
91
+
92
+ # @return [Facet, nil]
93
+ def facet(key) = facets.find { |facet| facet.key.to_s == key.to_s }
94
+
95
+ # The values a flag facet or flags panel row shows.
96
+ #
97
+ # @param flags [Flags]
98
+ # @param subset [Array<Symbol>, nil]
99
+ # @return [Hash{Symbol => String}] value → label
100
+ def flag_values(flags, subset)
101
+ subset ? flags.values.slice(*subset) : flags.values
102
+ end
103
+
104
+ # @return [Sort]
105
+ def sort(key) = sorts.find { |sort| sort.key.to_s == key.to_s } || default_sort
106
+
107
+ def default_sort = sorts.find(&:default) || sorts.first
108
+
109
+ # Reads a bare token as an operator, e.g. "5432" → [:last4, "5432"].
110
+ #
111
+ # @return [Array(Symbol, String), nil]
112
+ def interpret(token)
113
+ return unless @interpreter
114
+
115
+ resolve(@interpreter).call(token)
116
+ end
117
+
118
+ # Other readings of a bare token, offered as "… instead" on its chip.
119
+ # The interpreter may answer `alternatives(token)` → [[key, value], …].
120
+ #
121
+ # @return [Array<Array(Symbol, String)>]
122
+ def alternatives(token)
123
+ interpreter = @interpreter && resolve(@interpreter)
124
+ return [] unless interpreter.respond_to?(:alternatives)
125
+
126
+ Array(interpreter.alternatives(token))
127
+ end
128
+
129
+ # @return [Array<Example>] shown on first load and in syntax help
130
+ attr_reader :examples
131
+
132
+ # @return [Array<Option>] options-panel checkboxes
133
+ attr_reader :options
134
+
135
+ # @return [Option, nil]
136
+ def option(key) = options.find { |option| option.key.to_s == key.to_s }
137
+
138
+ # @return [Array<PanelGroup>] the options panel; one group of every
139
+ # operator and option unless the host declared `d.panel` groups
140
+ attr_reader :panel
141
+
142
+ # The group value of a document, or nil when it has none or it is invalid.
143
+ #
144
+ # @param document [Hash]
145
+ # @return [String, nil]
146
+ def group_value(document)
147
+ return unless group
148
+
149
+ value = document[group.attribute].presence&.to_s
150
+ value if value && (group.valid.nil? || resolve(group.valid).call(value))
151
+ end
152
+
153
+ # @return [Object, nil] the host's saved-search store (class names resolved per call)
154
+ def saved_searches = saved_store && resolve(saved_store)
155
+
156
+ # @return [Array<PanelRow>]
157
+ def panel_rows = panel.flat_map(&:rows)
158
+
159
+ # The filter one flag value stands for on one index.
160
+ #
161
+ # @param flags [Flags]
162
+ # @param value [Symbol]
163
+ # @param index [Symbol]
164
+ # @return [Querylane::Filter]
165
+ def flag_filter(flags, value, index:)
166
+ if flags.rules
167
+ resolve(flags.rules).public_send(value, index: index)
168
+ else
169
+ Filter.eq(flags.attribute, value.to_s)
170
+ end
171
+ end
172
+
173
+ # @param stage [Symbol] :query_filter or :results
174
+ # @return [Array<Hook>] in declaration order
175
+ def hooks_for(stage) = hooks.select { |hook| hook.stage == stage }
176
+
177
+ # The callable behind a hook (class names resolved per call).
178
+ def resolve_hook(hook) = resolve(hook.reference)
179
+
180
+ # Backend index name for a logical index.
181
+ #
182
+ # @return [String]
183
+ def index_name(key)
184
+ index = indexes.find { |candidate| candidate.key == key.to_sym } || raise(ConfigurationError, "Unknown index #{key}")
185
+ index.name.respond_to?(:call) ? index.name.call : index.name
186
+ end
187
+
188
+ private
189
+
190
+ def initialize(name, builder)
191
+ @name = name.to_sym
192
+ @indexes = builder.indexes.freeze
193
+ @texts = builder.texts.freeze
194
+ @filters = builder.filters.freeze
195
+ @flags = builder.flag_families.freeze
196
+ @facets = builder.facets.freeze
197
+ @sorts = (builder.sorts.presence || [Sort.new(:best, "Best match", nil, true)]).freeze
198
+ @per_options = (builder.per_options.presence || [25, 50, 100]).freeze
199
+ @retrieve = builder.retrieve&.map(&:to_s)&.freeze
200
+ @highlight = (builder.highlight || texts.flat_map(&:attributes)).map(&:to_s).freeze
201
+ @interpreter = builder.interpreter
202
+ @hooks = builder.hooks.freeze
203
+ @examples = builder.examples.freeze
204
+ @options = builder.options.freeze
205
+ @group = builder.group_by
206
+ @saved_store = builder.saved_store
207
+ @min_text_length = builder.min_text_length
208
+ @parked = builder.parked_keys.map(&:to_s).freeze
209
+ @operators = index_operators
210
+ @panel = (builder.panel_groups.presence || [default_panel_group]).map { |group| build_group(group) }.freeze
211
+ validate!
212
+ end
213
+
214
+ def index_operators
215
+ (texts + filters + flags).each_with_object({}) do |operator, all|
216
+ keys = [operator.key, *(operator.respond_to?(:aliases) ? operator.aliases : [])]
217
+ keys.each do |key|
218
+ raise ConfigurationError, "#{@name}: operator #{key} defined twice" if all.key?(key.to_s)
219
+
220
+ all[key.to_s] = operator
221
+ end
222
+ end.freeze
223
+ end
224
+
225
+ def default_panel_group
226
+ rows = (texts + filters + flags).map { |operator| [operator.key.to_s.humanize, [operator.key], false, nil] }
227
+ rows << [nil, options.map(&:key), false, nil] if options.any?
228
+ [nil, rows]
229
+ end
230
+
231
+ # [label, [[row_label, keys, {bare:}], ...]] → PanelGroup, checked against the operators
232
+ def build_group((label, rows))
233
+ PanelGroup.new(label, rows.map do |row_label, keys, bare, values|
234
+ if keys.all? { |key| option(key) }
235
+ PanelRow.new(row_label, keys, :options, false, false, nil)
236
+ else
237
+ operators = keys.map { |key| operator(key) || raise(ConfigurationError, "#{@name}: panel field #{key} is not an operator or option") }
238
+ kind = operators.all?(Flags) ? :flags : :inputs
239
+ raise ConfigurationError, "#{@name}: panel row #{row_label} mixes flags and inputs" if kind == :inputs && operators.any?(Flags)
240
+ raise ConfigurationError, "#{@name}: panel row #{row_label} shows one flag family" if kind == :flags && operators.size > 1
241
+
242
+ raise ConfigurationError, "#{@name}: panel row #{row_label} takes values: only for flags" if values && kind != :flags
243
+ unknown = Array(values) - ((kind == :flags) ? operators.first.values.keys : [])
244
+ raise ConfigurationError, "#{@name}: panel row #{row_label} has unknown values #{unknown.join(", ")}" if unknown.any?
245
+
246
+ PanelRow.new(row_label, keys, kind, bare, operators.any? { |operator| operator.respond_to?(:mask) && operator.mask != :none }, values)
247
+ end
248
+ end.freeze)
249
+ end
250
+
251
+ def validate!
252
+ raise ConfigurationError, "#{@name}: declare at least one index" if indexes.empty?
253
+ raise ConfigurationError, "#{@name}: declare at least one text operator" if texts.empty?
254
+
255
+ facets.each do |facet|
256
+ target = facet.flag ? flags.find { |flag| flag.key == facet.flag } : filters.find { |filter| filter.key == facet.filter }
257
+ raise ConfigurationError, "#{@name}: facet #{facet.key} points at nothing" unless target
258
+ raise ConfigurationError, "#{@name}: facet #{facet.key} cannot use sensitive operator" if target.respond_to?(:sensitive) && target.sensitive
259
+ next unless facet.values
260
+
261
+ raise ConfigurationError, "#{@name}: facet #{facet.key} takes values: only with flag:" unless facet.flag
262
+ unknown = facet.values - target.values.keys
263
+ raise ConfigurationError, "#{@name}: facet #{facet.key} has unknown values #{unknown.join(", ")}" if unknown.any?
264
+ end
265
+ flags.each do |flag|
266
+ raise ConfigurationError, "#{@name}: flags #{flag.key} need attribute: or rules:" unless flag.attribute || flag.rules
267
+ raise ConfigurationError, "#{@name}: flags #{flag.key} take attribute: or rules:, not both" if flag.attribute && flag.rules
268
+ end
269
+ raise ConfigurationError, "#{@name}: only one default sort" if sorts.count(&:default) > 1
270
+ end
271
+
272
+ def resolve(reference)
273
+ case reference
274
+ when String then reference.constantize
275
+ when Proc, Method then reference
276
+ else reference
277
+ end
278
+ end
279
+
280
+ # Collects declarations for Definition.build.
281
+ class Builder
282
+ attr_reader :indexes, :texts, :filters, :flag_families, :facets, :sorts, :per_options, :hooks, :examples, :options, :panel_groups,
283
+ :group_by, :saved_store, :parked_keys
284
+
285
+ # Attributes a searcher returns for each hit (server-side projection).
286
+ def retrieve(*attributes)
287
+ return @retrieve if attributes.empty?
288
+
289
+ @retrieve = attributes.flatten
290
+ end
291
+
292
+ # Attributes highlighted in hits (default: the text attributes).
293
+ def highlight(*attributes)
294
+ return @highlight if attributes.empty?
295
+
296
+ @highlight = attributes.flatten
297
+ end
298
+
299
+ # Reads bare tokens as operators: an object or class name responding to
300
+ # call(token) → [operator_key, value] or nil. Rules are ordered by the host.
301
+ def interpreter(reference = nil)
302
+ return @interpreter if reference.nil?
303
+
304
+ @interpreter = reference
305
+ end
306
+
307
+ def initialize
308
+ @indexes = []
309
+ @texts = []
310
+ @filters = []
311
+ @flag_families = []
312
+ @facets = []
313
+ @sorts = []
314
+ @per_options = []
315
+ @hooks = []
316
+ @examples = []
317
+ @options = []
318
+ @panel_groups = []
319
+ @parked_keys = []
320
+ end
321
+
322
+ # Operator names reserved for later: using one is an error that says
323
+ # so ("first: is not available yet") instead of "not an operator".
324
+ def parked(*keys)
325
+ @parked_keys.concat(keys.flatten.map(&:to_sym))
326
+ end
327
+
328
+ # A group of options-panel rows, in order:
329
+ #
330
+ # d.panel "Who" do |p|
331
+ # p.row "Name contains", :name
332
+ # p.row "Member ID or SSN", :id, :ssn, bare: true
333
+ # end
334
+ def panel(label)
335
+ group = PanelBuilder.new
336
+ yield group
337
+ @panel_groups << [label, group.rows]
338
+ end
339
+
340
+ # Words shorter than this, with no filter in the query, do not search
341
+ # (prefix matching on one or two letters matches most of an index).
342
+ def min_text_length(length = nil)
343
+ return @min_text_length if length.nil?
344
+
345
+ @min_text_length = length
346
+ end
347
+
348
+ # Saved searches, kept by the host's store (see Querylane::Saving).
349
+ def saved(store:)
350
+ @saved_store = store
351
+ end
352
+
353
+ # Groups hits by an attribute every index carries (e.g. a person's ID):
354
+ # rows are grouped on the page, and a group can load all its records.
355
+ def group(attribute, valid: nil, limit: 20, order: nil)
356
+ @group_by = GroupBy.new(attribute.to_s, valid, limit, order&.transform_keys(&:to_s)&.freeze)
357
+ end
358
+
359
+ # An options-panel checkbox that writes exact text, e.g.
360
+ # option :exclude_terminated, "Exclude terminated", "-is:terminated".
361
+ def option(key, label, text)
362
+ @options << Option.new(key.to_sym, label, text)
363
+ end
364
+
365
+ # An example query with what it finds (first-load state, syntax help).
366
+ def example(query, description)
367
+ @examples << Example.new(query, description)
368
+ end
369
+
370
+ # Registers a hook. Query-filter hooks respond to
371
+ # `call(index:, context:)` → Querylane::Filter; results hooks to
372
+ # `call(hits:, context:)` → Array<Querylane::Result::Hit> (a subset of
373
+ # the hits, in order, or hits merged with `contributors`). Reference is
374
+ # an object or a class name (resolved per call, reload-safe).
375
+ def hook(stage, reference, cardinality: :preserving)
376
+ raise ConfigurationError, "Unknown hook stage #{stage}" unless HOOK_STAGES.include?(stage)
377
+ raise ConfigurationError, "cardinality is :preserving or :changing" unless %i[preserving changing].include?(cardinality)
378
+
379
+ @hooks << Hook.new(stage, reference, cardinality)
380
+ end
381
+
382
+ # A logical index and its backend name (a String or a callable).
383
+ def index(key, name:)
384
+ @indexes << Index.new(key.to_sym, name)
385
+ end
386
+
387
+ # A free-text operator. `label` may use %{value}; `mask` (for chips) as for filter.
388
+ def text(key, attributes:, sensitive: false, default: false, label: nil, mask: nil)
389
+ mask ||= sensitive ? :all : :none
390
+ raise ConfigurationError, "Unknown mask #{mask}" unless MASKS.include?(mask)
391
+
392
+ @texts << Text.new(key.to_sym, attributes.map(&:to_s), sensitive, default, label || "#{key.to_s.humanize} %{value}", mask)
393
+ end
394
+
395
+ # An exact-match operator. `normalize` runs before `format` is checked.
396
+ # `mask` controls how chips show sensitive values: :none, :all, :last4, :count.
397
+ def filter(key, attribute:, sensitive: false, normalize: nil, format: nil, label: nil, mask: nil)
398
+ normalizer = normalize.is_a?(Symbol) ? NORMALIZERS.fetch(normalize) : normalize
399
+ mask ||= sensitive ? :all : :none
400
+ raise ConfigurationError, "Unknown mask #{mask}" unless MASKS.include?(mask)
401
+
402
+ @filters << FilterOperator.new(key.to_sym, attribute.to_s, sensitive, normalizer, format, label || "#{key.to_s.humanize} %{value}", mask)
403
+ end
404
+
405
+ # A family of named values (`is:active`). Either `attribute:` (value
406
+ # name = attribute value) or `rules:` (a class name whose methods take
407
+ # `index:` and return a Querylane::Filter). `values` maps value → label.
408
+ def flags(key, values:, alias: nil, attribute: nil, rules: nil, label: nil)
409
+ aliases = Array(binding.local_variable_get(:alias)).map(&:to_sym)
410
+ labels = values.is_a?(Hash) ? values.transform_keys(&:to_sym) : values.to_h { |value| [value.to_sym, value.to_s.humanize] }
411
+ @flag_families << Flags.new(key.to_sym, aliases, attribute&.to_s, labels.freeze, rules, label || key.to_s.humanize)
412
+ end
413
+
414
+ # A facet over a flag family (counts per value) or a filter operator
415
+ # (top N values by count).
416
+ def facet(key, label: nil, flag: nil, filter: nil, top: 10, values: nil, show: nil, search: false)
417
+ @facets << Facet.new(key.to_sym, label || key.to_s.humanize, flag&.to_sym, filter&.to_sym, top, values&.map(&:to_sym),
418
+ show || top, search)
419
+ end
420
+
421
+ # `by` maps attributes to :asc/:desc; nil is relevance.
422
+ def sort(key, label: nil, by: nil, default: false)
423
+ @sorts << Sort.new(key.to_sym, label || key.to_s.humanize, by, default)
424
+ end
425
+
426
+ def per(*options)
427
+ @per_options.concat(options.flatten)
428
+ end
429
+ end
430
+
431
+ # Collects the rows of one `d.panel` group.
432
+ class PanelBuilder
433
+ attr_reader :rows
434
+
435
+ def initialize
436
+ @rows = []
437
+ end
438
+
439
+ # A labelled row of operators (or option keys). `bare: true` gives the
440
+ # operators one shared input written without a prefix.
441
+ # `values:` limits a flags row to some of its values (the rest stay in the box).
442
+ def row(label, *keys, bare: false, values: nil)
443
+ @rows << [label, keys.map(&:to_sym), bare, values&.map(&:to_sym)]
444
+ end
445
+ end
446
+ end
447
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Checks that the host's Meilisearch indexes declare what a definition
5
+ # needs. Reports problems; never changes settings (the host owns them).
6
+ #
7
+ # Querylane::Doctor.check(MEMBERS, client: client).each { |problem| puts problem }
8
+ module Doctor
9
+ Problem = Data.define(:index, :message) do
10
+ def to_s = "#{index}: #{message}"
11
+ end
12
+
13
+ module_function
14
+
15
+ # @return [Array<Problem>]
16
+ def check(definition, client:)
17
+ definition.indexes.flat_map do |index|
18
+ name = definition.index_name(index.key)
19
+ settings = client.index(name).settings
20
+ problems_for(definition, name, settings)
21
+ end
22
+ end
23
+
24
+ def problems_for(definition, name, settings)
25
+ searchable = settings.fetch("searchableAttributes", ["*"])
26
+ filterable = settings.fetch("filterableAttributes", [])
27
+ sortable = settings.fetch("sortableAttributes", [])
28
+ no_typos = settings.dig("typoTolerance", "disableOnAttributes") || []
29
+ problems = []
30
+ definition.texts.flat_map(&:attributes).each do |attribute|
31
+ problems << Problem.new(name, "#{attribute} is not searchable") unless searchable == ["*"] || searchable.include?(attribute)
32
+ end
33
+ definition.texts.select(&:sensitive).flat_map(&:attributes).each do |attribute|
34
+ problems << Problem.new(name, "#{attribute} should have typo tolerance disabled") unless no_typos.include?(attribute)
35
+ end
36
+ (definition.filters.map(&:attribute) + definition.flags.filter_map(&:attribute)).uniq.each do |attribute|
37
+ problems << Problem.new(name, "#{attribute} is not filterable") unless filterable.include?(attribute)
38
+ end
39
+ definition.sorts.filter_map(&:by).flat_map(&:keys).map(&:to_s).each do |attribute|
40
+ problems << Problem.new(name, "#{attribute} is not sortable") unless sortable.include?(attribute)
41
+ end
42
+ sorted_by = settings.dig("faceting", "sortFacetValuesBy") || {}
43
+ definition.facets.select(&:filter).each do |facet|
44
+ attribute = definition.operator(facet.filter).attribute
45
+ next if (sorted_by[attribute] || sorted_by["*"]) == "count"
46
+
47
+ problems << Problem.new(name, "faceting.sortFacetValuesBy should be \"count\" for #{attribute} (top values)")
48
+ end
49
+ problems
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Ships the partials, helper, stylesheet and locale; no routes or models.
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Querylane
7
+
8
+ initializer "querylane.helper" do
9
+ ActiveSupport.on_load(:action_controller_base) { helper Querylane::SearchHelper }
10
+ end
11
+
12
+ initializer "querylane.filter_parameters" do |app|
13
+ # The query and the options panel's fields (which may hold member IDs).
14
+ app.config.filter_parameters |= [Querylane.configuration.param_name, :compose]
15
+ end
16
+
17
+ # The gem's browser modules as asset logical paths, e.g.
18
+ # "querylane/controllers/box_controller.js". config/importmap.rb pins them
19
+ # and Sprockets importmap hosts precompile them.
20
+ #
21
+ # @return [Array<String>]
22
+ def self.javascript_modules
23
+ Dir.glob("querylane/**/*.js", base: root.join("app/javascript").to_s).sort
24
+ end
25
+
26
+ # Whether the host draws an import map with importmap-rails. A host that
27
+ # only has the constant (another gem loaded it) bundles its own JavaScript.
28
+ #
29
+ # @param app [Rails::Application]
30
+ # @return [Boolean]
31
+ def self.importmap_host?(app)
32
+ defined?(::Importmap) && app.config.respond_to?(:importmap) && app.root.join("config/importmap.rb").exist?
33
+ end
34
+
35
+ # Stylesheet on the host's asset pipeline; the ES modules too, for import
36
+ # map hosts only. Sprockets needs precompile entries; Propshaft serves
37
+ # every file on its paths and has no `precompile` setting (OrderedOptions
38
+ # answers respond_to? for any key, so the guard names Sprockets itself).
39
+ initializer "querylane.assets" do |app|
40
+ next unless app.config.respond_to?(:assets)
41
+
42
+ sprockets = defined?(::Sprockets::Railtie)
43
+ app.config.assets.precompile += %w[querylane.css] if sprockets
44
+ next unless Engine.importmap_host?(app)
45
+
46
+ app.config.assets.paths << root.join("app/javascript").to_s
47
+ # Named files, not a proc: host asset helpers expect plain names.
48
+ app.config.assets.precompile += Engine.javascript_modules if sprockets
49
+ end
50
+
51
+ # Import map hosts get the gem's pins (config/importmap.rb) drawn into
52
+ # their own map, and in development a change to the gem's modules sweeps
53
+ # the map's cached digests. Bundler hosts alias `querylane` instead.
54
+ initializer "querylane.importmap", before: "importmap" do |app|
55
+ next unless Engine.importmap_host?(app)
56
+
57
+ app.config.importmap.paths << root.join("config/importmap.rb").to_s
58
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ class Error < StandardError; end
5
+
6
+ # The host wired querylane incorrectly (raised at boot or first use).
7
+ class ConfigurationError < Error; end
8
+
9
+ # A search was attempted without a server-built Scope.
10
+ class MissingScopeError < Error; end
11
+
12
+ # The backend could not answer (timeout, unavailable, bad response).
13
+ class SearchUnavailable < Error; end
14
+
15
+ # A host hook raised or returned something it may not. The search is
16
+ # aborted; results are never shown without the hook's filtering.
17
+ class HookError < Error; end
18
+
19
+ # A problem with what the user typed. The message is safe to show (it never
20
+ # repeats a sensitive value); the range points into the query text.
21
+ # fix: text that would replace `range` to correct the query (did-you-mean), or nil.
22
+ QueryError = Data.define(:message, :range, :fix) do
23
+ def initialize(message:, range:, fix: nil)
24
+ super
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Backend-neutral filter tree. Hosts build filters with these constructors;
5
+ # a searcher renders them (Meilisearch: Searchers::Meilisearch::Expression).
6
+ #
7
+ # Filter.and(Filter.eq(:status, "A"), Filter.not(Filter.eq(:category, "MNLC")))
8
+ module Filter
9
+ Eq = Data.define(:attribute, :value)
10
+ In = Data.define(:attribute, :values)
11
+ Null = Data.define(:attribute)
12
+ Missing = Data.define(:attribute)
13
+ And = Data.define(:filters)
14
+ Or = Data.define(:filters)
15
+ Not = Data.define(:filter)
16
+ # A backend expression written by the host (e.g. an existing permission
17
+ # filter string). Rendered as-is; never built from user input.
18
+ Raw = Data.define(:expression)
19
+ Everything = Data.define
20
+ Nothing = Data.define
21
+
22
+ module_function
23
+
24
+ def eq(attribute, value) = Eq.new(attribute.to_s, value)
25
+
26
+ def in(attribute, values) = In.new(attribute.to_s, Array(values))
27
+
28
+ # The attribute is present with a null value.
29
+ def null(attribute) = Null.new(attribute.to_s)
30
+
31
+ # The attribute is absent from the document.
32
+ def missing(attribute) = Missing.new(attribute.to_s)
33
+
34
+ # AND of the filters. No filters (or only Everything) matches everything;
35
+ # any Nothing matches nothing.
36
+ def and(*filters)
37
+ filters = filters.flatten.compact
38
+ return Nothing.new if filters.any?(Nothing)
39
+
40
+ filters = filters.reject { |filter| filter.is_a?(Everything) }
41
+ return Everything.new if filters.empty?
42
+
43
+ filters.one? ? filters.first : And.new(filters)
44
+ end
45
+
46
+ # OR of the filters. No filters (or only Nothing) matches nothing — so an
47
+ # empty list of grants never widens a scope; any Everything matches everything.
48
+ def or(*filters)
49
+ filters = filters.flatten.compact
50
+ return Everything.new if filters.any?(Everything)
51
+
52
+ filters = filters.reject { |filter| filter.is_a?(Nothing) }
53
+ return Nothing.new if filters.empty?
54
+
55
+ filters.one? ? filters.first : Or.new(filters)
56
+ end
57
+
58
+ def not(filter)
59
+ case filter
60
+ when Everything then Nothing.new
61
+ when Nothing then Everything.new
62
+ else Not.new(filter)
63
+ end
64
+ end
65
+
66
+ # Matches every document (an unrestricted scope).
67
+ def everything = Everything.new
68
+
69
+ # Matches no document (e.g. a user with no grants).
70
+ def nothing = Nothing.new
71
+
72
+ # @param expression [String] a backend filter written by the host
73
+ def raw(expression) = Raw.new(expression.to_s)
74
+ end
75
+ end