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,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# An opaque, expiring handle for one group of hits (e.g. a person), so a page
|
|
5
|
+
# can ask for "more records" without the group's key (a member ID) ever
|
|
6
|
+
# reaching HTML. Encrypted and signed with the host app's secret, bound to
|
|
7
|
+
# the definition's name.
|
|
8
|
+
module GroupToken
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @param definition [Querylane::Definition]
|
|
12
|
+
# @param value [String] the group attribute's value
|
|
13
|
+
# @param shown [Array<String>] "index:id" of the group's records already on the page
|
|
14
|
+
# @return [String]
|
|
15
|
+
def write(definition, value, shown, config: Querylane.configuration)
|
|
16
|
+
encryptor.encrypt_and_sign({"v" => value.to_s, "s" => shown}, purpose: purpose(definition), expires_in: config.group_token_ttl)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Array(String, Array<String>), nil] nil when tampered, expired or for another definition
|
|
20
|
+
def read(definition, token)
|
|
21
|
+
payload = encryptor.decrypt_and_verify(token.to_s, purpose: purpose(definition))
|
|
22
|
+
[payload.fetch("v"), Array(payload["s"])] if payload.is_a?(Hash)
|
|
23
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def purpose(definition) = "querylane.group.#{definition.name}"
|
|
28
|
+
|
|
29
|
+
def encryptor
|
|
30
|
+
ActiveSupport::MessageEncryptor.new(Rails.application.key_generator.generate_key("querylane group token", 32))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Turns query text into an Ast::Root (syntax only; meaning is Query's job).
|
|
5
|
+
#
|
|
6
|
+
# Grammar (Gmail style: implicit AND, OR binds tighter):
|
|
7
|
+
# query := unit* unit := unary ("OR" unary)*
|
|
8
|
+
# unary := ("-" | "NOT") atom | atom
|
|
9
|
+
# atom := "(" query ")" | key ":" value | word | "phrase"
|
|
10
|
+
#
|
|
11
|
+
# Bounded: length, nesting depth and number of terms (Configuration).
|
|
12
|
+
class Parser
|
|
13
|
+
# A syntax error with the character range it concerns.
|
|
14
|
+
class Error < StandardError
|
|
15
|
+
attr_reader :range
|
|
16
|
+
|
|
17
|
+
def initialize(message, range)
|
|
18
|
+
super(message)
|
|
19
|
+
@range = range
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Token = Data.define(:type, :range, :key, :value, :quoted)
|
|
24
|
+
|
|
25
|
+
KEY = /([A-Za-z][A-Za-z0-9_]*):/
|
|
26
|
+
QUOTED = /"((?:[^"\\]|\\.)*)"/
|
|
27
|
+
BARE = /[^\s()"]+/
|
|
28
|
+
|
|
29
|
+
# @param source [String]
|
|
30
|
+
# @return [Querylane::Ast::Root]
|
|
31
|
+
# @raise [Querylane::Parser::Error]
|
|
32
|
+
def self.parse(source, config: Querylane.configuration)
|
|
33
|
+
new(source.to_s, config).parse
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(source, config)
|
|
37
|
+
@source = source
|
|
38
|
+
@config = config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse
|
|
42
|
+
if @source.length > @config.max_length
|
|
43
|
+
raise Error.new("Search is too long (#{@config.max_length} characters at most).", 0...@source.length)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@tokens = tokenize
|
|
47
|
+
@position = 0
|
|
48
|
+
@depth = 0
|
|
49
|
+
@terms = 0
|
|
50
|
+
nodes = parse_sequence(closing: false)
|
|
51
|
+
Ast::Root.new(nodes.freeze, @source)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def tokenize
|
|
57
|
+
scanner = StringScanner.new(@source)
|
|
58
|
+
tokens = []
|
|
59
|
+
until scanner.eos?
|
|
60
|
+
next if scanner.skip(/\s+/)
|
|
61
|
+
|
|
62
|
+
start = scanner.charpos
|
|
63
|
+
tokens << next_token(scanner, start)
|
|
64
|
+
end
|
|
65
|
+
tokens
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def next_token(scanner, start)
|
|
69
|
+
if scanner.scan("(") then token(:lparen, scanner, start)
|
|
70
|
+
elsif scanner.scan(")") then token(:rparen, scanner, start)
|
|
71
|
+
elsif scanner.scan(/OR(?=[\s()]|\z)/) then token(:or, scanner, start)
|
|
72
|
+
elsif scanner.scan(/NOT(?=\s)/) then token(:not, scanner, start)
|
|
73
|
+
elsif scanner.scan(/-(?=[^\s)])/) then token(:minus, scanner, start)
|
|
74
|
+
elsif scanner.scan(KEY) then key_token(scanner, start, scanner[1])
|
|
75
|
+
elsif scanner.scan(QUOTED) then token(:term, scanner, start, value: unescape(scanner[1]), quoted: true)
|
|
76
|
+
elsif scanner.scan('"') then raise Error.new("Close the quote.", start...@source.length)
|
|
77
|
+
else
|
|
78
|
+
scanner.scan(BARE)
|
|
79
|
+
token(:term, scanner, start, value: scanner.matched)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def key_token(scanner, start, key)
|
|
84
|
+
if scanner.scan(QUOTED)
|
|
85
|
+
token(:term, scanner, start, key: key, value: unescape(scanner[1]), quoted: true)
|
|
86
|
+
elsif scanner.scan(BARE)
|
|
87
|
+
token(:term, scanner, start, key: key, value: scanner.matched)
|
|
88
|
+
else
|
|
89
|
+
raise Error.new("#{key}: needs a value.", start...scanner.charpos)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def token(type, scanner, start, key: nil, value: nil, quoted: false)
|
|
94
|
+
Token.new(type, start...scanner.charpos, key, value, quoted)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def unescape(text) = text.gsub(/\\(.)/, '\1')
|
|
98
|
+
|
|
99
|
+
def parse_sequence(closing:)
|
|
100
|
+
nodes = []
|
|
101
|
+
until done? || (closing && peek?(:rparen))
|
|
102
|
+
raise Error.new("Unexpected ).", current.range) if peek?(:rparen)
|
|
103
|
+
|
|
104
|
+
nodes << parse_or
|
|
105
|
+
end
|
|
106
|
+
nodes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_or
|
|
110
|
+
parts = [parse_unary]
|
|
111
|
+
while peek?(:or)
|
|
112
|
+
operator = advance
|
|
113
|
+
raise Error.new("OR needs a term on both sides.", operator.range) if done? || peek?(:rparen) || peek?(:or)
|
|
114
|
+
|
|
115
|
+
parts << parse_unary
|
|
116
|
+
end
|
|
117
|
+
return parts.first if parts.one?
|
|
118
|
+
|
|
119
|
+
Ast::Or.new(parts.freeze, parts.first.range.begin...parts.last.range.end)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse_unary
|
|
123
|
+
if peek?(:minus) || peek?(:not)
|
|
124
|
+
operator = advance
|
|
125
|
+
raise Error.new("#{(operator.type == :not) ? "NOT" : "-"} needs a term.", operator.range) if done?
|
|
126
|
+
|
|
127
|
+
node = parse_atom
|
|
128
|
+
return Ast::Not.new(node, operator.range.begin...node.range.end)
|
|
129
|
+
end
|
|
130
|
+
parse_atom
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_atom
|
|
134
|
+
raise Error.new("Search ended early.", @source.length...@source.length) if done?
|
|
135
|
+
|
|
136
|
+
case current.type
|
|
137
|
+
when :lparen then parse_group
|
|
138
|
+
when :or then raise Error.new("OR needs a term on both sides.", current.range)
|
|
139
|
+
when :term then parse_term
|
|
140
|
+
else raise Error.new("Unexpected #{@source[current.range]}.", current.range)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse_group
|
|
145
|
+
open = advance
|
|
146
|
+
@depth += 1
|
|
147
|
+
raise Error.new("Too many nested parentheses (#{@config.max_depth} at most).", open.range) if @depth > @config.max_depth
|
|
148
|
+
|
|
149
|
+
nodes = parse_sequence(closing: true)
|
|
150
|
+
raise Error.new("Add the closing ).", open.range.begin...@source.length) unless peek?(:rparen)
|
|
151
|
+
raise Error.new("Empty parentheses.", open.range) if nodes.empty?
|
|
152
|
+
|
|
153
|
+
close = advance
|
|
154
|
+
@depth -= 1
|
|
155
|
+
Ast::Group.new(nodes.freeze, open.range.begin...close.range.end)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse_term
|
|
159
|
+
term = advance
|
|
160
|
+
@terms += 1
|
|
161
|
+
raise Error.new("Too many terms (#{@config.max_terms} at most).", term.range) if @terms > @config.max_terms
|
|
162
|
+
|
|
163
|
+
Ast::Term.new(term.key&.downcase, term.value, term.quoted, term.range)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def current = @tokens[@position]
|
|
167
|
+
|
|
168
|
+
def advance
|
|
169
|
+
token = current
|
|
170
|
+
@position += 1
|
|
171
|
+
token
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def peek?(type) = !done? && current.type == type
|
|
175
|
+
|
|
176
|
+
def done? = @position >= @tokens.length
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Querylane
|
|
6
|
+
# How a host's records look in the gem's result rows. Subclass and override:
|
|
7
|
+
# Ruby decides (this class), the gem's partials draw (querylane/_rows,
|
|
8
|
+
# _group, _record, _outcome). Hits are grouped on the server by `group_key`
|
|
9
|
+
# (e.g. one person); the key itself never reaches the page.
|
|
10
|
+
#
|
|
11
|
+
# class MemberPresenter < Querylane::Presenter
|
|
12
|
+
# def columns = [Column.new(:policy, "Policy", 80)]
|
|
13
|
+
# def group_key(hit) = hit.document["memberid"]
|
|
14
|
+
# def cells(hit) = [Cell.new(hit.document["policy_number"], :strong)]
|
|
15
|
+
# def outcome(hit) = Action.new("Open", select_path, {id: hit.id}, :primary)
|
|
16
|
+
# end
|
|
17
|
+
class Presenter
|
|
18
|
+
# A record lane; width in pixels, nil to take the remaining space.
|
|
19
|
+
Column = Data.define(:key, :label, :width)
|
|
20
|
+
# One lane's value; style :strong, :muted or nil.
|
|
21
|
+
Cell = Data.define(:text, :style)
|
|
22
|
+
# tone: :success, :warning, :danger, :info, :neutral.
|
|
23
|
+
Badge = Data.define(:text, :tone)
|
|
24
|
+
# A button that opens the record (full-page POST, outside the frame).
|
|
25
|
+
Action = Data.define(:label, :url, :params, :style)
|
|
26
|
+
# Why a record cannot be opened, with an optional link to go instead.
|
|
27
|
+
# more: further reasons, shown on request ("+2 more").
|
|
28
|
+
Reason = Data.define(:text, :link_label, :link_url, :more) do
|
|
29
|
+
def initialize(text:, link_label: nil, link_url: nil, more: [])
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
Group = Data.define(:key, :hits)
|
|
34
|
+
# A group in suggestions: a short detail, its status, and what choosing it
|
|
35
|
+
# does (an Action opens it; nil runs the full search).
|
|
36
|
+
Suggestion = Data.define(:detail, :badge, :action)
|
|
37
|
+
|
|
38
|
+
attr_reader :result, :view, :id
|
|
39
|
+
|
|
40
|
+
# @param result [Querylane::Result]
|
|
41
|
+
# @param view [ActionView::Base] for routes and helpers
|
|
42
|
+
# @param id [String, nil] the widget id (frames and the search form)
|
|
43
|
+
def initialize(result, view, id: nil)
|
|
44
|
+
@result = result
|
|
45
|
+
@view = view
|
|
46
|
+
@id = id&.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<Column>]
|
|
50
|
+
def columns = []
|
|
51
|
+
|
|
52
|
+
# Header of the last lane.
|
|
53
|
+
def outcome_label = "Open"
|
|
54
|
+
|
|
55
|
+
# Header of the status lane, nil for no status lane.
|
|
56
|
+
def status_label = "Status"
|
|
57
|
+
|
|
58
|
+
# Hits sharing a key form one group; nil keeps a hit on its own. Default:
|
|
59
|
+
# the definition's `group` attribute. An override must return that
|
|
60
|
+
# attribute's value (it is what "more records" searches for).
|
|
61
|
+
def group_key(hit) = result.definition.group_value(hit.document)
|
|
62
|
+
|
|
63
|
+
# An opaque token for loading every record of the group ("more records",
|
|
64
|
+
# preview), or nil when the group cannot be loaded.
|
|
65
|
+
#
|
|
66
|
+
# @return [String, nil]
|
|
67
|
+
def group_token(group)
|
|
68
|
+
value = group_key(group.hits.first)
|
|
69
|
+
return if value.nil? || result.definition.group.nil?
|
|
70
|
+
|
|
71
|
+
GroupToken.write(result.definition, value, group.hits.map { |hit| "#{hit.index}:#{hit.id}" })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# How many of the group's records the page does not show, or nil when
|
|
75
|
+
# unknown. "More records" shows only when this is positive.
|
|
76
|
+
#
|
|
77
|
+
# @return [Integer, nil]
|
|
78
|
+
def more_count(group)
|
|
79
|
+
value = group_key(group.hits.first)
|
|
80
|
+
return if value.nil? || result.group_totals.nil?
|
|
81
|
+
|
|
82
|
+
shown = group.hits.sum { |hit| 1 + hit.contributors.size }
|
|
83
|
+
more = result.group_totals.fetch(value.to_s.downcase, 0) - shown
|
|
84
|
+
more if more.positive?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [String] the id of the group's "more records" frame
|
|
88
|
+
def related_frame_id(group) = "#{id}-related-#{record_key(group.hits.first)}"
|
|
89
|
+
|
|
90
|
+
# @return [Array<Group>] in hit order, the best-ranked hit leading each group
|
|
91
|
+
def groups
|
|
92
|
+
order = []
|
|
93
|
+
grouped = {}
|
|
94
|
+
result.hits.each_with_index do |hit, position|
|
|
95
|
+
key = group_key(hit) || "__#{position}"
|
|
96
|
+
order << key unless grouped.key?(key)
|
|
97
|
+
(grouped[key] ||= []) << hit
|
|
98
|
+
end
|
|
99
|
+
order.map { |key| Group.new(key, order_records(grouped.fetch(key))) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A group's records in the definition's group order (the order "more
|
|
103
|
+
# records" and the preview use); unchanged without one. The best-ranked
|
|
104
|
+
# record still decides where the group sits on the page.
|
|
105
|
+
def order_records(hits)
|
|
106
|
+
order = result.definition.group&.order
|
|
107
|
+
return hits if order.nil? || hits.one?
|
|
108
|
+
|
|
109
|
+
hits.each_with_index.sort do |(left, first), (right, second)|
|
|
110
|
+
compared = compare_records(left, right, order)
|
|
111
|
+
compared.zero? ? first <=> second : compared
|
|
112
|
+
end.map(&:first)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def compare_records(left, right, order)
|
|
116
|
+
order.each do |attribute, direction|
|
|
117
|
+
compared = left.document[attribute].to_s <=> right.document[attribute].to_s
|
|
118
|
+
return (direction.to_sym == :desc) ? -compared : compared unless compared.zero?
|
|
119
|
+
end
|
|
120
|
+
0
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Group heading (HTML-safe); default: the first highlighted text attribute.
|
|
124
|
+
def title(group)
|
|
125
|
+
attribute = result.definition.highlight.first
|
|
126
|
+
view.querylane_highlight(group.hits.first, attribute)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Secondary heading next to the title (HTML-safe or plain), or nil.
|
|
130
|
+
def subtitle(group) = nil
|
|
131
|
+
|
|
132
|
+
# Why the group matched, or nil.
|
|
133
|
+
def note(group) = nil
|
|
134
|
+
|
|
135
|
+
# @return [Array<Cell>] one per column
|
|
136
|
+
def cells(hit) = []
|
|
137
|
+
|
|
138
|
+
# @return [Badge, nil]
|
|
139
|
+
def status(hit) = nil
|
|
140
|
+
|
|
141
|
+
# @return [Array<Badge>] small flags after the status lane
|
|
142
|
+
def flags(hit) = []
|
|
143
|
+
|
|
144
|
+
# @return [Action, Reason, nil]
|
|
145
|
+
def outcome(hit) = nil
|
|
146
|
+
|
|
147
|
+
# How a group appears in suggestions. Default: its record count, the first
|
|
148
|
+
# record's status, and its Action when exactly one record can be opened.
|
|
149
|
+
#
|
|
150
|
+
# @return [Suggestion]
|
|
151
|
+
def suggestion(group)
|
|
152
|
+
actions = group.hits.map { |hit| outcome(hit) }.grep(Action)
|
|
153
|
+
Suggestion.new(view.t("querylane.suggest.records", count: group.hits.size), status(group.hits.first),
|
|
154
|
+
actions.one? ? actions.first : nil)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# An opaque, stable key for keyboard focus and tests.
|
|
158
|
+
def record_key(hit) = Digest::SHA256.hexdigest("#{hit.index}:#{hit.id}")[0, 16]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# What the options panel would write: the composed query and the parts kept
|
|
5
|
+
# as typed, with sensitive values masked for display. Built without a search.
|
|
6
|
+
#
|
|
7
|
+
# text, kept: masked display strings (nil when the box query is invalid);
|
|
8
|
+
# error: a QueryError for the composed text, or the box's own error.
|
|
9
|
+
Preview = Data.define(:text, :kept, :error) do
|
|
10
|
+
# @param definition [Querylane::Definition]
|
|
11
|
+
# @param source [String] the box text
|
|
12
|
+
# @param fields [Hash] compose fields from the panel
|
|
13
|
+
# @return [Querylane::Preview]
|
|
14
|
+
def self.for(definition, source, fields, config: Querylane.configuration)
|
|
15
|
+
query = Query.analyze(definition, source.to_s, config: config)
|
|
16
|
+
return new(nil, nil, query.error) unless query.valid?
|
|
17
|
+
|
|
18
|
+
composed = Query.analyze(definition, Rewriter.compose(query, fields.to_h.transform_keys(&:to_s)), config: config)
|
|
19
|
+
kept = Query.analyze(definition, Rewriter.panel(query).kept, config: config)
|
|
20
|
+
new(masked(composed), masked(kept), composed.error)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The query text with every sensitive term's value masked.
|
|
24
|
+
#
|
|
25
|
+
# @param query [Querylane::Query]
|
|
26
|
+
# @return [String]
|
|
27
|
+
def self.masked(query)
|
|
28
|
+
text = query.source.dup
|
|
29
|
+
query.terms.select { |term| mask_of(term.operator) != :none }.sort_by { |term| -term.range.begin }.each do |term|
|
|
30
|
+
segment = text[term.range]
|
|
31
|
+
value = segment.split(":", 2).last
|
|
32
|
+
text[term.range] = segment.delete_suffix(value) + Chips.mask(value.delete('"'), mask_of(term.operator))
|
|
33
|
+
end
|
|
34
|
+
text
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.mask_of(operator)
|
|
38
|
+
# :count reads as prose ("9 characters"); inside query text use dots.
|
|
39
|
+
return :none unless operator.respond_to?(:mask)
|
|
40
|
+
|
|
41
|
+
(operator.mask == :count) ? :all : operator.mask
|
|
42
|
+
end
|
|
43
|
+
private_class_method :mask_of
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# The meaning of a query: parsed, bare words interpreted, every term matched
|
|
5
|
+
# to an operator of the definition and validated. One error at most.
|
|
6
|
+
#
|
|
7
|
+
# query = Query.analyze(definition, "smith 5432 is:active")
|
|
8
|
+
# query.valid? # => true
|
|
9
|
+
# query.nodes # => [Term(:text name "smith"), Term(:filter last4 "5432"), Term(:flag is :active)]
|
|
10
|
+
class Query
|
|
11
|
+
# A resolved term. kind: :text, :filter or :flag.
|
|
12
|
+
# raw: the token as typed (for "instead" readings of interpreted terms).
|
|
13
|
+
Term = Data.define(:kind, :operator, :value, :quoted, :range, :interpreted, :raw)
|
|
14
|
+
|
|
15
|
+
attr_reader :definition, :source, :nodes, :error
|
|
16
|
+
|
|
17
|
+
# @param definition [Querylane::Definition]
|
|
18
|
+
# @param source [String]
|
|
19
|
+
# @return [Querylane::Query]
|
|
20
|
+
def self.analyze(definition, source, config: Querylane.configuration)
|
|
21
|
+
new(definition, source.to_s, config)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def valid? = error.nil?
|
|
25
|
+
|
|
26
|
+
def blank? = source.strip.empty?
|
|
27
|
+
|
|
28
|
+
# Top-level text terms (all share one operator).
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<Term>]
|
|
31
|
+
def text_terms = nodes.grep(Term).select { |term| term.kind == :text }
|
|
32
|
+
|
|
33
|
+
# @return [Querylane::Definition::Text, nil]
|
|
34
|
+
def text_operator = text_terms.first&.operator
|
|
35
|
+
|
|
36
|
+
# Every term at any depth (inside NOT, OR and groups), in order.
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<Term>]
|
|
39
|
+
def terms = walk(nodes)
|
|
40
|
+
|
|
41
|
+
# Whether every word is shorter than `length` and nothing else narrows the
|
|
42
|
+
# search ("*" is not counted: legacy queries used it as a wildcard).
|
|
43
|
+
def too_short?(length)
|
|
44
|
+
return false if length.nil? || blank?
|
|
45
|
+
|
|
46
|
+
terms.all? { |term| term.kind == :text } && terms.none? { |term| term.value.to_s.delete("*").length >= length }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Terms whose operator is sensitive (member IDs, SSNs, last fours).
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<Term>]
|
|
52
|
+
def sensitive_terms = terms.select { |term| term.operator.respond_to?(:sensitive) && term.operator.sensitive }
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def walk(nodes)
|
|
57
|
+
nodes.flat_map do |node|
|
|
58
|
+
case node
|
|
59
|
+
when Term then [node]
|
|
60
|
+
when Ast::Not then walk([node.node])
|
|
61
|
+
when Ast::Or, Ast::Group then walk(node.nodes)
|
|
62
|
+
else []
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(definition, source, config)
|
|
68
|
+
@definition = definition
|
|
69
|
+
@source = source
|
|
70
|
+
@nodes = []
|
|
71
|
+
@error = nil
|
|
72
|
+
root = Parser.parse(source, config: config)
|
|
73
|
+
@nodes = root.nodes.map { |node| resolve(node, context: nil) }.freeze
|
|
74
|
+
check_single_text_operator
|
|
75
|
+
rescue Parser::Error => error
|
|
76
|
+
@error = QueryError.new(error.message, error.range)
|
|
77
|
+
rescue Invalid => invalid
|
|
78
|
+
@error = invalid.error
|
|
79
|
+
@nodes = [].freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class Invalid < StandardError
|
|
83
|
+
attr_reader :error
|
|
84
|
+
|
|
85
|
+
def initialize(message, range, fix = nil)
|
|
86
|
+
super(message)
|
|
87
|
+
@error = QueryError.new(message, range, fix)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
private_constant :Invalid
|
|
91
|
+
|
|
92
|
+
def resolve(node, context:)
|
|
93
|
+
case node
|
|
94
|
+
when Ast::Term then resolve_term(node, context)
|
|
95
|
+
when Ast::Not then Ast::Not.new(resolve(node.node, context: :not), node.range)
|
|
96
|
+
when Ast::Or then Ast::Or.new(node.nodes.map { |child| resolve(child, context: :or) }, node.range)
|
|
97
|
+
when Ast::Group then Ast::Group.new(node.nodes.map { |child| resolve(child, context: :group) }, node.range)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolve_term(term, context)
|
|
102
|
+
key, value, interpreted = term.key, term.value, false
|
|
103
|
+
if key.nil? && !term.quoted && (reading = definition.interpret(value))
|
|
104
|
+
key, value = reading
|
|
105
|
+
interpreted = true
|
|
106
|
+
end
|
|
107
|
+
return text_term(definition.default_text, value, term, context, interpreted) if key.nil?
|
|
108
|
+
|
|
109
|
+
raise Invalid.new(I18n.t("querylane.errors.parked", key: key, default: "%{key}: is not available yet."), term.range) if definition.parked.include?(key.to_s.downcase)
|
|
110
|
+
|
|
111
|
+
operator = definition.operator(key) || raise(unknown_key(key, term))
|
|
112
|
+
resolve_operator(operator, value, term, context, interpreted)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_operator(operator, value, term, context, interpreted)
|
|
116
|
+
case operator
|
|
117
|
+
when Definition::Text then text_term(operator, value, term, context, interpreted)
|
|
118
|
+
when Definition::FilterOperator then filter_term(operator, value, term, interpreted)
|
|
119
|
+
when Definition::Flags then flag_term(operator, value, term, interpreted)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def text_term(operator, value, term, context, interpreted)
|
|
124
|
+
if context
|
|
125
|
+
message = {
|
|
126
|
+
or: "OR joins operators, not names. Search one name at a time.",
|
|
127
|
+
not: "- and NOT apply to operators, not names.",
|
|
128
|
+
group: "Parentheses group operators, not names."
|
|
129
|
+
}.fetch(context)
|
|
130
|
+
raise Invalid.new(message, term.range)
|
|
131
|
+
end
|
|
132
|
+
Term.new(:text, operator, value, term.quoted, term.range, interpreted, term.value)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def filter_term(operator, value, term, interpreted)
|
|
136
|
+
normalized = operator.normalize ? operator.normalize.call(value) : value
|
|
137
|
+
if operator.format && !operator.format.match?(normalized)
|
|
138
|
+
shown = operator.sensitive ? "That value" : "\"#{value}\""
|
|
139
|
+
raise Invalid.new("#{shown} is not a valid #{operator.key}: value.", term.range)
|
|
140
|
+
end
|
|
141
|
+
Term.new(:filter, operator, normalized, term.quoted, term.range, interpreted, term.value)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def flag_term(operator, value, term, interpreted)
|
|
145
|
+
symbol = value.to_s.downcase.to_sym
|
|
146
|
+
unless operator.values.key?(symbol)
|
|
147
|
+
raise Invalid.new("#{value} is not a value of #{operator.key}:. Try #{operator.values.keys.join(", ")}.", term.range)
|
|
148
|
+
end
|
|
149
|
+
Term.new(:flag, operator, symbol, term.quoted, term.range, interpreted, term.value)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def check_single_text_operator
|
|
153
|
+
operators = text_terms.map(&:operator).uniq
|
|
154
|
+
return if operators.size <= 1
|
|
155
|
+
|
|
156
|
+
second = text_terms.find { |term| term.operator == operators.last }
|
|
157
|
+
others = definition.filters.map { |filter| "#{filter.key}:" }.first(2).join(" or ")
|
|
158
|
+
raise Invalid.new("Search #{operators.map(&:key).join(" or ")} one at a time, or combine with #{others}.", second.range)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# "stauts: is not an operator. Did you mean status:?", with the corrected term as the fix.
|
|
162
|
+
def unknown_key(key, term)
|
|
163
|
+
suggestion = definition.operator_keys.min_by { |candidate| distance(candidate, key) }
|
|
164
|
+
return Invalid.new("#{key}: is not an operator.", term.range) unless suggestion && distance(suggestion, key) <= 2
|
|
165
|
+
|
|
166
|
+
fixed = source[term.range].sub(/\A[^:]+/, suggestion)
|
|
167
|
+
Invalid.new("#{key}: is not an operator. Did you mean #{suggestion}:?", term.range, fixed)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Levenshtein distance (short strings only).
|
|
171
|
+
def distance(a, b)
|
|
172
|
+
previous = (0..b.length).to_a
|
|
173
|
+
a.each_char.with_index(1) do |char_a, i|
|
|
174
|
+
current = [i]
|
|
175
|
+
b.each_char.with_index(1) do |char_b, j|
|
|
176
|
+
current << [previous[j] + 1, current[j - 1] + 1, previous[j - 1] + ((char_a == char_b) ? 0 : 1)].min
|
|
177
|
+
end
|
|
178
|
+
previous = current
|
|
179
|
+
end
|
|
180
|
+
previous.last
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# One search request, bounded and allowlisted against the definition.
|
|
5
|
+
#
|
|
6
|
+
# Params: query (Configuration#param_name), toggle ("facet:value"),
|
|
7
|
+
# remove (chip index), clear, instead, widen, relax, compose (with
|
|
8
|
+
# compose_submit), options, sort, page, per, revision.
|
|
9
|
+
class Request
|
|
10
|
+
attr_reader :fix, :saved, :save_search, :saved_name, :group, :query, :toggle, :remove, :clear, :instead, :widen, :relax, :compose, :options, :sort, :page, :per, :revision
|
|
11
|
+
|
|
12
|
+
# @param params [ActionController::Parameters, Hash]
|
|
13
|
+
# @param definition [Querylane::Definition]
|
|
14
|
+
def self.from_params(params, definition:, config: Querylane.configuration)
|
|
15
|
+
values = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
16
|
+
values = values.transform_keys(&:to_s)
|
|
17
|
+
new(definition: definition, query: values[config.param_name.to_s], toggle: values["toggle"], remove: values["remove"], clear: values["clear"],
|
|
18
|
+
instead: values["instead"], widen: values["widen"], relax: values["relax"],
|
|
19
|
+
compose: (values["compose"] if values["compose_submit"].present?), options: values["options"],
|
|
20
|
+
fix: values["fix"], group: values["group"], saved: values["saved"], save_search: values["save_search"], saved_name: values["saved_name"], sort: values["sort"], page: values["page"], per: values["per"], revision: values["revision"])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# instead: "chip.alternative" (use another reading of an interpreted term);
|
|
24
|
+
# widen: a facet key (drop that facet's terms); relax: match fewer words.
|
|
25
|
+
# compose: options-panel fields {"name" => "…", "policy" => "1 2", "is" => [...], "options" => [...]}
|
|
26
|
+
# (applied only with compose_submit); options: open the options panel;
|
|
27
|
+
# group: a GroupToken (all records of one group, see Search#related);
|
|
28
|
+
# saved: a saved search id to run; save_search with saved_name: save the query.
|
|
29
|
+
def initialize(definition:, query: nil, toggle: nil, remove: nil, clear: nil, instead: nil, widen: nil, relax: nil,
|
|
30
|
+
compose: nil, options: nil, fix: nil, group: nil, saved: nil, save_search: nil, saved_name: nil, sort: nil, page: nil, per: nil, revision: nil)
|
|
31
|
+
@query = query.to_s
|
|
32
|
+
@clear = clear.present?
|
|
33
|
+
@instead = instead.to_s.match(/\A(\d+)\.(\d+)\z/)&.captures&.map(&:to_i)
|
|
34
|
+
@widen = definition.facet(widen)
|
|
35
|
+
@relax = relax.present?
|
|
36
|
+
@options = options.present?
|
|
37
|
+
@group = group.presence
|
|
38
|
+
@fix = fix.present?
|
|
39
|
+
@saved = saved.to_s.presence&.first(64)
|
|
40
|
+
@save_search = save_search.present?
|
|
41
|
+
@saved_name = saved_name.to_s.squish.first(80)
|
|
42
|
+
@compose = compose.is_a?(Hash) ? compose.to_h.transform_keys(&:to_s) : nil
|
|
43
|
+
@toggle = parse_toggle(definition, toggle)
|
|
44
|
+
@remove = (Integer(remove.to_s, exception: false) if remove.present?)
|
|
45
|
+
@sort = definition.sort(sort)
|
|
46
|
+
@per = definition.per_options.include?(per.to_i) ? per.to_i : definition.per_options.first
|
|
47
|
+
@page = rewriting? ? 1 : [page.to_i, 1].max
|
|
48
|
+
@revision = [revision.to_i, 0].max
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Integer] records skipped before this page
|
|
52
|
+
def offset = (page - 1) * per
|
|
53
|
+
|
|
54
|
+
# Whether this request asks the server to rewrite the query text.
|
|
55
|
+
def rewriting? = (@toggle || @remove || @clear || @instead || @widen || @compose || @saved) ? true : false
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# @return [Array(Querylane::Definition::Facet, String), nil]
|
|
60
|
+
def parse_toggle(definition, toggle)
|
|
61
|
+
key, value = toggle.to_s.split(":", 2)
|
|
62
|
+
facet = definition.facet(key) if value.present?
|
|
63
|
+
[facet, value] if facet
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|