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,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
|