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