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