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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Everything a page needs to render one search.
5
+ #
6
+ # state: :first_load (nothing typed), :short (words too short to search),
7
+ # :ok, :empty (no hits), :invalid
8
+ # (query error), :unavailable (backend failed; fail closed).
9
+ # total_kind: :exact, :capped (backend cap reached; may be higher),
10
+ # :estimated (counts unavailable).
11
+ class Result
12
+ # contributors: other hits a results hook merged into this one.
13
+ Hit = Data.define(:index, :id, :document, :formatted, :ranking_details, :contributors) do
14
+ def initialize(index:, id:, document:, formatted:, ranking_details:, contributors: [])
15
+ super
16
+ end
17
+ end
18
+ FacetValue = Data.define(:value, :label, :count, :ticked)
19
+ # show: values shown before "Find…"; search: whether the facet has a "Find…" box.
20
+ Facet = Data.define(:key, :label, :values, :failed, :approximate, :show, :search) do
21
+ def initialize(key:, label:, values:, failed:, approximate:, show: nil, search: false)
22
+ super
23
+ end
24
+ end
25
+
26
+ attr_reader :definition, :state, :query, :rewritten, :revision, :hits, :total, :total_kind,
27
+ :page, :per, :page_limit, :sort, :facets, :chips, :error, :timings, :merged, :relaxed, :panel, :preview, :shown, :completions, :typed, :saved_searches, :save_refusal, :notice, :group_totals
28
+
29
+ def initialize(definition:, state:, query:, request:, rewritten: false, hits: [], total: 0, total_kind: :exact,
30
+ page_limit: nil, facets: [], chips: [], error: nil, timings: {}, merged: false, panel: nil, preview: nil,
31
+ shown: [], completions: [], typed: nil, saved_searches: nil, save_refusal: nil, notice: nil, group_totals: nil)
32
+ @definition = definition
33
+ @state = state
34
+ @query = query
35
+ @rewritten = rewritten
36
+ @revision = request.revision
37
+ @page = request.page
38
+ @per = request.per
39
+ @sort = request.sort
40
+ @hits = hits
41
+ @total = total
42
+ @total_kind = total_kind
43
+ @page_limit = page_limit
44
+ @facets = facets
45
+ @chips = chips
46
+ @error = error
47
+ @timings = timings
48
+ @merged = merged
49
+ @relaxed = request.relax
50
+ @panel = panel
51
+ @preview = preview
52
+ @shown = shown
53
+ @completions = completions
54
+ # suggestions: the whole typed text, sensitive values masked (for display)
55
+ @typed = typed
56
+ # saved searches: the user's list (nil without a store), why this query
57
+ # cannot be saved, and a one-line notice (:saved, :refused, :store_failed)
58
+ @saved_searches = saved_searches
59
+ @save_refusal = save_refusal
60
+ @notice = notice
61
+ # records per group value (down-cased) within the scope, or nil when unknown
62
+ @group_totals = group_totals
63
+ end
64
+
65
+ # Whether the options panel is open (it needs a valid query to read).
66
+ def options_open? = !panel.nil?
67
+
68
+ # Facets with ticked values: an empty search can offer to widen them.
69
+ def widenable_facets = facets.select { |facet| facet.values.any?(&:ticked) }
70
+
71
+ def ok? = state == :ok
72
+
73
+ # Whether the query is one of the user's saved searches (the Saved star fills).
74
+ def saved_active? = query.present? && Array(saved_searches).any? { |saved| saved.query == query }
75
+
76
+ # A group's hits the page did not already show ("more records").
77
+ def unshown_hits = hits.reject { |hit| shown.include?("#{hit.index}:#{hit.id}") }
78
+
79
+ # @return [Integer]
80
+ def pages
81
+ count = (total.to_f / per).ceil
82
+ page_limit ? [count, page_limit].min : count
83
+ end
84
+
85
+ def first_shown = hits.empty? ? 0 : ((page - 1) * per) + 1
86
+
87
+ # Records shown on this page before a results hook merged any.
88
+ def last_shown = ((page - 1) * per) + hits.sum { |hit| 1 + hit.contributors.size }
89
+ end
90
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Edits query text structurally, so facets and chips never disagree with
5
+ # the box: the query text is the only search state.
6
+ #
7
+ # A facet value is "ticked" when the query has, at top level, a positive term
8
+ # for it (`is:active`) or an OR group made only of positive terms of the same
9
+ # operator containing it (`(policy:1 OR policy:2)`). Toggling removes every
10
+ # canonical occurrence of the facet and appends one canonical expression with
11
+ # the new value set.
12
+ module Rewriter
13
+ module_function
14
+
15
+ # @param query [Querylane::Query] valid
16
+ # @param facet [Querylane::Definition::Facet]
17
+ # @return [Array<String>] ticked values, in order of appearance
18
+ def ticked(query, facet)
19
+ occurrences(query, facet).flat_map(&:last).uniq
20
+ end
21
+
22
+ # @return [String] the query text with the value ticked or unticked
23
+ def toggle(query, facet, value)
24
+ value = canonical_value(query.definition, facet, value)
25
+ return query.source if value.nil?
26
+
27
+ current = ticked(query, facet)
28
+ values = current.include?(value) ? current - [value] : current + [value]
29
+ text = remove(query.source, occurrences(query, facet).map(&:first))
30
+ [text, expression(query.definition, facet, values)].reject(&:empty?).join(" ")
31
+ end
32
+
33
+ # The query without this facet's canonical terms (the facet's counting base).
34
+ #
35
+ # @return [String]
36
+ def without(query, facet)
37
+ remove(query.source, occurrences(query, facet).map(&:first))
38
+ end
39
+
40
+ # @param ranges [Array<Range>]
41
+ # @return [String] the text with the ranges removed; whitespace is tidied
42
+ # only where a range was cut, so quoted values stay exactly as typed
43
+ def remove(source, ranges)
44
+ text = source.dup
45
+ ranges.sort_by(&:begin).reverse_each do |range|
46
+ before = text[0...range.begin].rstrip
47
+ after = text[range.end..].to_s.lstrip
48
+ text = [before, after].reject(&:empty?).join(" ")
49
+ end
50
+ text.strip
51
+ end
52
+
53
+ # The options panel's view of a query: each field's value (keyed by the
54
+ # compose field name), the parts no field can show (kept as typed), and,
55
+ # per masked field, the masked terms it leaves in the box (masked values
56
+ # never become field values, so they never reach HTML).
57
+ Panel = Data.define(:values, :kept, :held)
58
+ # One word of a field, and whether the user quoted it.
59
+ Token = Data.define(:text, :quoted)
60
+
61
+ # @param query [Querylane::Query] valid
62
+ # @return [Panel]
63
+ def panel(query)
64
+ definition = query.definition
65
+ used = []
66
+ values = {"options" => []}
67
+ held = {}
68
+ # Options first: their exact text (e.g. "-is:terminated") is not a flag value.
69
+ definition.panel_rows.sort_by { |row| (row.kind == :options) ? 0 : 1 }.each do |row|
70
+ case row.kind
71
+ when :options
72
+ row.keys.each do |key|
73
+ node = query.nodes.find { |candidate| !used.include?(candidate.range) && query.source[candidate.range] == definition.option(key).text }
74
+ next unless node
75
+
76
+ used << node.range
77
+ values["options"] << key.to_s
78
+ end
79
+ when :flags
80
+ found = occurrences_of(query, definition.operator(row.keys.first), values: row.values).reject { |pair| used.include?(pair.first) }
81
+ used.concat(found.map(&:first))
82
+ values[row.name] = found.flat_map(&:last)
83
+ else
84
+ row.keys.group_by { |key| row.bare ? row.name : key.to_s }.each do |name, keys|
85
+ found = keys.flat_map { |key| field_terms(query, definition.operator(key)) }.sort_by { |range, _| range.begin }
86
+ values[name] = ""
87
+ if row.masked
88
+ held[name] = found.map { |range, _| Preview.masked(Query.analyze(definition, query.source[range])) }.join(" ").presence
89
+ else
90
+ used.concat(found.map(&:first))
91
+ values[name] = found.flat_map(&:last).join(" ")
92
+ end
93
+ end
94
+ end
95
+ end
96
+ Panel.new(values, remove(query.source, used), held.compact)
97
+ end
98
+
99
+ # Rebuilds the query from options-panel fields: the parts the panel cannot
100
+ # show stay as typed, then each field writes its canonical text.
101
+ #
102
+ # @param fields [Hash{String => String, Array<String>}] compose field name → value
103
+ # @return [String]
104
+ def compose(query, fields)
105
+ definition = query.definition
106
+ parts = [panel(query).kept]
107
+ definition.panel_rows.each do |row|
108
+ case row.kind
109
+ when :options
110
+ chosen = Array(fields["options"]).map(&:to_s)
111
+ row.keys.each { |key| parts << definition.option(key).text if chosen.include?(key.to_s) }
112
+ when :flags
113
+ flag = definition.operator(row.keys.first)
114
+ parts << group(flag.key, Array(fields[row.name]).map(&:to_s) & definition.flag_values(flag, row.values).keys.map(&:to_s))
115
+ else
116
+ if row.bare
117
+ parts << tokens(fields[row.name]).map { |token| token.quoted ? quoted(token.text) : token.text }.join(" ")
118
+ else
119
+ row.keys.each { |key| parts << write(definition.operator(key), fields[key.to_s]) }
120
+ end
121
+ end
122
+ end
123
+ parts.reject(&:empty?).join(" ")
124
+ end
125
+
126
+ # Field text → tokens: "quoted phrases" (with \" and \\ escapes) stay one
127
+ # token and remember their quotes; everything else splits on spaces.
128
+ def tokens(value)
129
+ value.to_s.scan(/"((?:[^"\\]|\\.)*)"|(\S+)/).filter_map do |phrase, word|
130
+ phrase ? Token.new(phrase.gsub(/\\(.)/, '\1'), true) : Token.new(word, false)
131
+ end.reject { |token| token.text.empty? }
132
+ end
133
+
134
+ # One input's value as query text for its operator; quoted tokens stay quoted.
135
+ def write(operator, value)
136
+ found = tokens(value)
137
+ case operator
138
+ when Definition::Text
139
+ written = found.map { |token| token.quoted ? quoted(token.text) : quote(token.text) }
140
+ operator.default ? written.join(" ") : written.map { |text| "#{operator.key}:#{text}" }.join(" ")
141
+ else
142
+ found = found.map { |token| operator.normalize ? token.with(text: operator.normalize.call(token.text)) : token }
143
+ join_or(found.uniq.map { |token| "#{operator.key}:#{token.quoted ? quoted(token.text) : quote(token.text)}" })
144
+ end
145
+ end
146
+
147
+ def group(key, values)
148
+ join_or(values.uniq.map { |value| "#{key}:#{quote(value)}" })
149
+ end
150
+
151
+ def join_or(terms)
152
+ (terms.size > 1) ? "(#{terms.join(" OR ")})" : terms.first.to_s
153
+ end
154
+
155
+ # Ranges and display values (quoted as typed) of an operator's top-level
156
+ # terms: words for a text operator; terms or OR groups for a filter.
157
+ def field_terms(query, operator)
158
+ if operator.is_a?(Definition::Text)
159
+ query.text_terms.select { |term| term.operator.equal?(operator) }.map { |term| [term.range, [display(term)]] }
160
+ else
161
+ query.nodes.filter_map do |outer|
162
+ node = unwrap(outer)
163
+ if term_of?(node, operator)
164
+ [outer.range, [display(node)]]
165
+ elsif node.is_a?(Ast::Or) && node.nodes.all? { |child| term_of?(child, operator) }
166
+ [outer.range, node.nodes.map { |child| display(child) }]
167
+ end
168
+ end
169
+ end
170
+ end
171
+
172
+ def display(term) = term.quoted ? quoted(term.value.to_s) : term.value.to_s
173
+
174
+ # Always quoted, with " and \ escaped.
175
+ def quoted(value) = %("#{value.to_s.gsub(/["\\]/) { |char| "\\#{char}" }}")
176
+
177
+ # Top-level terms of an operator, alone or as an OR group of that operator
178
+ # only; with `values`, only those whose every value is in the list.
179
+ def occurrences_of(query, operator, values: nil)
180
+ allowed = values&.map(&:to_s)
181
+ found = occurrences_of_any(query, operator)
182
+ allowed ? found.select { |_range, found_values| (found_values - allowed).empty? } : found
183
+ end
184
+
185
+ def occurrences_of_any(query, operator)
186
+ query.nodes.filter_map do |outer|
187
+ node = unwrap(outer)
188
+ if term_of?(node, operator)
189
+ [outer.range, [node.value.to_s]]
190
+ elsif node.is_a?(Ast::Or) && node.nodes.all? { |child| term_of?(child, operator) }
191
+ [outer.range, node.nodes.map { |child| child.value.to_s }]
192
+ end
193
+ end
194
+ end
195
+
196
+ # @return [String] the text with one range replaced
197
+ def replace(source, range, text)
198
+ [source[0...range.begin], text, source[range.end..]].join
199
+ end
200
+
201
+ # A toggle value in the form the query would hold, or nil if invalid.
202
+ def canonical_value(definition, facet, value)
203
+ operator = operator_for(definition, facet)
204
+ case operator
205
+ when Definition::Flags
206
+ symbol = value.to_s.downcase.to_sym
207
+ definition.flag_values(operator, facet.values).key?(symbol) ? symbol.to_s : nil
208
+ when Definition::FilterOperator
209
+ normalized = operator.normalize ? operator.normalize.call(value.to_s) : value.to_s
210
+ (operator.format.nil? || operator.format.match?(normalized)) ? normalized : nil
211
+ end
212
+ end
213
+
214
+ # @return [Array<Array(Range, Array<String>)>]
215
+ def occurrences(query, facet)
216
+ return [] unless query.valid?
217
+
218
+ occurrences_of(query, operator_for(query.definition, facet), values: facet.values)
219
+ end
220
+
221
+ def expression(definition, facet, values)
222
+ operator = operator_for(definition, facet)
223
+ terms = values.map { |value| "#{operator.key}:#{quote(value)}" }
224
+ (terms.size > 1) ? "(#{terms.join(" OR ")})" : terms.first.to_s
225
+ end
226
+
227
+ def operator_for(definition, facet)
228
+ key = facet.flag || facet.filter
229
+ definition.operator(key)
230
+ end
231
+
232
+ # `(a OR b)` parses as a group holding one OR node; look through it.
233
+ def unwrap(node)
234
+ (node.is_a?(Ast::Group) && node.nodes.one?) ? unwrap(node.nodes.first) : node
235
+ end
236
+
237
+ def term_of?(node, operator)
238
+ node.is_a?(Query::Term) && node.operator.equal?(operator)
239
+ end
240
+
241
+ def quote(value)
242
+ value.to_s.match?(/\A[^\s()"]+\z/) ? value.to_s : quoted(value)
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Saved searches: what may be saved, and the host's store.
5
+ #
6
+ # A query holding a sensitive term (typed or worked out by the interpreter,
7
+ # e.g. "5432" read as a last four), or any value the interpreter would read
8
+ # as one (a quoted "123456789", name:123456789), is never saved; the refusal
9
+ # offers the query without it.
10
+ #
11
+ # The store is the host's (`d.saved store: "SavedSearchStore"`): an object
12
+ # or class name responding to
13
+ # all(context:) → [#id, #name, #query, #used_at]
14
+ # create(context:, name:, query:)
15
+ # use(context:, id:) → the saved search (and records its use), or nil
16
+ module Saving
17
+ # value: the sensitive text as shown (masked by its operator's mask);
18
+ # operator: what it was read as; rest: the query without sensitive terms.
19
+ Refusal = Data.define(:value, :operator, :rest)
20
+
21
+ module_function
22
+
23
+ # @param query [Querylane::Query] valid
24
+ # @return [Refusal, nil] nil when the query may be saved
25
+ def refusal(query)
26
+ definition = query.definition
27
+ sensitive = query.terms.filter_map do |term|
28
+ # A word (a name, quoted or name:) could hide an ID the box would read
29
+ # as sensitive; a value already given to a filter (div:0001) cannot.
30
+ operator = sensitive?(term.operator) ? term.operator : (term.kind == :text && reading(definition, term.value))
31
+ [term, operator] if operator
32
+ end
33
+ return if sensitive.empty?
34
+
35
+ term, operator = sensitive.first
36
+ value = Chips.mask(term.value.to_s, (operator.respond_to?(:mask) && operator.mask != :count) ? operator.mask : :all)
37
+ Refusal.new(value, Suggest.hint(operator), Rewriter.remove(query.source, sensitive.map { |found, _| found.range }))
38
+ end
39
+
40
+ def sensitive?(operator) = operator.respond_to?(:sensitive) && operator.sensitive
41
+
42
+ # The sensitive operator the interpreter would read this value as, if any.
43
+ def reading(definition, value)
44
+ key, = definition.interpret(value.to_s)
45
+ operator = key && definition.operator(key)
46
+ operator if sensitive?(operator)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # What the current user may search: the logical indexes and a permission
5
+ # filter for each. The host builds it on the server (never from params);
6
+ # every hit, count and facet query ANDs the index's permission filter.
7
+ #
8
+ # Querylane::Scope.new(employees: Querylane::Filter.in(:policy_company_id, %w[SI YI]))
9
+ class Scope
10
+ # A scope with no permission filter. Must be chosen deliberately.
11
+ #
12
+ # @param indexes [Array<Symbol>]
13
+ def self.unrestricted(*indexes)
14
+ new(indexes.flatten.to_h { |index| [index, Filter.everything] })
15
+ end
16
+
17
+ # The user's access could not be checked: nothing is searched and the
18
+ # page says so (never an unrestricted or stale answer).
19
+ def self.denied = Denied.new
20
+
21
+ # @param filters_by_index [Hash{Symbol => Querylane::Filter}]
22
+ def initialize(filters_by_index = {})
23
+ raise MissingScopeError, "A scope needs at least one index" if filters_by_index.blank?
24
+
25
+ @filters = filters_by_index.to_h { |index, filter| [index.to_sym, filter || raise(MissingScopeError, "No permission filter for #{index}")] }.freeze
26
+ end
27
+
28
+ def denied? = false
29
+
30
+ # @return [Array<Symbol>]
31
+ def indexes = @filters.keys
32
+
33
+ # @return [Querylane::Filter]
34
+ def filter_for(index) = @filters.fetch(index.to_sym)
35
+
36
+ # This scope narrowed by extra filters per index (query-filter hooks).
37
+ #
38
+ # @param extra [Hash{Symbol => Querylane::Filter}]
39
+ # @return [Querylane::Scope]
40
+ def narrowed(extra)
41
+ Scope.new(@filters.to_h { |index, filter| [index, Filter.and(filter, extra.fetch(index, Filter.everything))] })
42
+ end
43
+
44
+ # See Scope.denied.
45
+ class Denied < Scope
46
+ def initialize
47
+ @filters = {}.freeze
48
+ end
49
+
50
+ def denied? = true
51
+ end
52
+ end
53
+ end