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,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# "Searching for" chips: one per top-level term of a valid query (all name
|
|
5
|
+
# words share one chip). Sensitive values are masked; nothing here repeats a
|
|
6
|
+
# sensitive value in clear.
|
|
7
|
+
module Chips
|
|
8
|
+
# alternatives: other readings of an interpreted term ("Division 5432 instead");
|
|
9
|
+
# words: the chip of the query's words (names), not a filter.
|
|
10
|
+
Chip = Data.define(:index, :label, :ranges, :alternatives, :words) do
|
|
11
|
+
def initialize(index:, label:, ranges:, alternatives: [], words: false)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
Alternative = Data.define(:index, :label, :text)
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param query [Querylane::Query]
|
|
20
|
+
# @return [Array<Chip>]
|
|
21
|
+
def for(query)
|
|
22
|
+
return [] unless query.valid?
|
|
23
|
+
|
|
24
|
+
chips = []
|
|
25
|
+
if (texts = query.text_terms).any?
|
|
26
|
+
words = texts.map { |term| term.quoted ? %("#{term.value}") : term.value }.join(" ")
|
|
27
|
+
chips << [format(texts.first.operator, mask(words, texts.first.operator.mask)), texts.map(&:range), [], true]
|
|
28
|
+
end
|
|
29
|
+
query.nodes.each do |node|
|
|
30
|
+
next if node.is_a?(Query::Term) && node.kind == :text
|
|
31
|
+
|
|
32
|
+
chips << [label(node), [node.range], alternatives(query, node)]
|
|
33
|
+
end
|
|
34
|
+
chips.each_with_index.map do |(text, ranges, others, words), index|
|
|
35
|
+
Chip.new(index: index, label: text, ranges: ranges, alternatives: others || [], words: words || false)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Other readings of an interpreted term, each with the explicit text it
|
|
40
|
+
# would become (e.g. "div:5432").
|
|
41
|
+
def alternatives(query, node)
|
|
42
|
+
return [] unless node.is_a?(Query::Term) && node.interpreted
|
|
43
|
+
|
|
44
|
+
query.definition.alternatives(node.raw).each_with_index.filter_map do |(key, value), index|
|
|
45
|
+
operator = query.definition.operator(key)
|
|
46
|
+
next unless operator
|
|
47
|
+
|
|
48
|
+
reading = Query.analyze(query.definition, "#{key}:#{value}")
|
|
49
|
+
next unless reading.valid?
|
|
50
|
+
|
|
51
|
+
Alternative.new(index, "#{label(reading.nodes.first)} instead", "#{operator.key}:#{value}")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def label(node)
|
|
56
|
+
case node
|
|
57
|
+
when Query::Term then term_label(node)
|
|
58
|
+
when Ast::Not then "Not #{label(node.node)}"
|
|
59
|
+
when Ast::Or then node.nodes.map { |child| label(child) }.join(" or ")
|
|
60
|
+
when Ast::Group then "(#{node.nodes.map { |child| label(child) }.join(" and ")})"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def term_label(term)
|
|
65
|
+
case term.kind
|
|
66
|
+
when :filter then format(term.operator, mask(term.value, term.operator.mask))
|
|
67
|
+
when :flag then "#{term.operator.label} #{term.operator.values.fetch(term.value)}"
|
|
68
|
+
when :text then format(term.operator, term.value)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def format(operator, value) = operator.label.gsub("%{value}", value.to_s)
|
|
73
|
+
|
|
74
|
+
# @param value [String]
|
|
75
|
+
# @param mask [Symbol] :none, :all, :last4, :count
|
|
76
|
+
def mask(value, mask)
|
|
77
|
+
case mask
|
|
78
|
+
when :none then value
|
|
79
|
+
when :last4 then (value.length >= 4) ? "•••-••-#{value[-4..]}" : "•" * value.length
|
|
80
|
+
when :count then I18n.t("querylane.chips.count", count: value.length, default: "%{count} characters")
|
|
81
|
+
else "•" * value.length
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Turns a valid Query into what a searcher runs: one text query (words and
|
|
5
|
+
# the attributes to search) and a filter per index (without the scope).
|
|
6
|
+
class Compiler
|
|
7
|
+
# matching: :all (every word) or :frequency (relaxed; rare words kept).
|
|
8
|
+
Compiled = Data.define(:text, :text_attributes, :filter_builder, :matching) do
|
|
9
|
+
# @return [Querylane::Filter] the query's filter for one index
|
|
10
|
+
def filter_for(index) = filter_builder.call(index.to_sym)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param query [Querylane::Query] valid
|
|
14
|
+
# @return [Querylane::Compiler::Compiled]
|
|
15
|
+
def self.compile(query)
|
|
16
|
+
raise ArgumentError, "compile a valid query" unless query.valid?
|
|
17
|
+
|
|
18
|
+
new(query).compile
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(query)
|
|
22
|
+
@query = query
|
|
23
|
+
@definition = query.definition
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def compile
|
|
27
|
+
terms = @query.text_terms
|
|
28
|
+
# Prefix matching is built in, so a "*" typed as a wildcard is dropped.
|
|
29
|
+
text = terms.map { |term| term.quoted ? %("#{term.value}") : term.value.to_s.delete("*") }.compact_blank.join(" ").presence
|
|
30
|
+
filter_nodes = @query.nodes.reject { |node| node.is_a?(Query::Term) && node.kind == :text }
|
|
31
|
+
Compiled.new(text, @query.text_operator&.attributes, ->(index) { Filter.and(*filter_nodes.map { |node| to_filter(node, index) }) }, :all)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def to_filter(node, index)
|
|
37
|
+
case node
|
|
38
|
+
when Query::Term then term_filter(node, index)
|
|
39
|
+
when Ast::Not then Filter.not(to_filter(node.node, index))
|
|
40
|
+
when Ast::Or then Filter.or(*node.nodes.map { |child| to_filter(child, index) })
|
|
41
|
+
when Ast::Group then Filter.and(*node.nodes.map { |child| to_filter(child, index) })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def term_filter(term, index)
|
|
46
|
+
case term.kind
|
|
47
|
+
when :filter then Filter.eq(term.operator.attribute, term.value)
|
|
48
|
+
when :flag then @definition.flag_filter(term.operator, term.value, index: index)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Process-wide settings, set once in a host initializer:
|
|
5
|
+
#
|
|
6
|
+
# Querylane.configure do |config|
|
|
7
|
+
# config.searcher = -> { Querylane::Searchers::Meilisearch.new(client: MeilisearchService.client) }
|
|
8
|
+
# end
|
|
9
|
+
class Configuration
|
|
10
|
+
# Name of the query parameter (filtered from logs by the engine).
|
|
11
|
+
attr_accessor :param_name
|
|
12
|
+
# Bounds every query is checked against.
|
|
13
|
+
attr_accessor :max_length, :max_depth, :max_terms
|
|
14
|
+
# Debounce (ms) before a typed query is submitted.
|
|
15
|
+
attr_accessor :debounce_ms
|
|
16
|
+
# Allows GET requests to carry the query (off: POST only).
|
|
17
|
+
attr_accessor :allow_get_query
|
|
18
|
+
|
|
19
|
+
attr_writer :searcher
|
|
20
|
+
|
|
21
|
+
# How long a group token ("more records", preview) stays valid.
|
|
22
|
+
attr_accessor :group_token_ttl
|
|
23
|
+
|
|
24
|
+
# Records read for suggestions (header box and palette), and people shown.
|
|
25
|
+
attr_accessor :suggest_records, :suggest_people
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@group_token_ttl = 3600
|
|
29
|
+
@suggest_records = 20
|
|
30
|
+
@suggest_people = 5
|
|
31
|
+
@param_name = :search_query
|
|
32
|
+
@max_length = 256
|
|
33
|
+
@max_depth = 3
|
|
34
|
+
@max_terms = 20
|
|
35
|
+
@debounce_ms = 300
|
|
36
|
+
@allow_get_query = false
|
|
37
|
+
@searcher = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The searcher for this request. A callable is resolved on every call, so
|
|
41
|
+
# it can follow code reloading and per-request clients.
|
|
42
|
+
#
|
|
43
|
+
# @return [#search]
|
|
44
|
+
def searcher
|
|
45
|
+
found = @searcher.respond_to?(:call) ? @searcher.call : @searcher
|
|
46
|
+
found || raise(ConfigurationError, "Set Querylane.configure { |c| c.searcher = ... }")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Per-request facts hooks may use (the current user, the request, anything
|
|
5
|
+
# the host adds). Built by the host controller; never from params.
|
|
6
|
+
Context = Data.define(:user, :request, :extras) do
|
|
7
|
+
def initialize(user: nil, request: nil, extras: {})
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Include in the host controller that serves a search page.
|
|
5
|
+
#
|
|
6
|
+
# def search
|
|
7
|
+
# if querylane_frame?(:member_search, :writes)
|
|
8
|
+
# return render partial: "querylane/writes", locals: {preview: querylane_preview(MEMBERS), id: "member_search"}
|
|
9
|
+
# end
|
|
10
|
+
# @search = querylane_search(MEMBERS, scope: member_search_scope)
|
|
11
|
+
# render partial: "members/results" if querylane_frame?(:member_search)
|
|
12
|
+
# end
|
|
13
|
+
module Controller
|
|
14
|
+
# Runs the search for this request. Query text travels by POST only
|
|
15
|
+
# (unless the host opts in) and responses are never cached.
|
|
16
|
+
#
|
|
17
|
+
# @param definition [Querylane::Definition]
|
|
18
|
+
# @param scope [Querylane::Scope] built on the server for the current user
|
|
19
|
+
# @return [Querylane::Result]
|
|
20
|
+
# @param context [Querylane::Context] facts for the definition's hooks
|
|
21
|
+
def querylane_search(definition, scope:, context: Context.new(request: request), params: self.params, config: Querylane.configuration)
|
|
22
|
+
querylane_guard!(params, config)
|
|
23
|
+
Search.new(definition: definition, request: Request.from_params(params, definition: definition, config: config),
|
|
24
|
+
scope: scope, context: context, config: config).call
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Suggestions for the header box or palette (operators and people);
|
|
28
|
+
# render with the querylane_suggestions helper.
|
|
29
|
+
#
|
|
30
|
+
# @return [Querylane::Result]
|
|
31
|
+
def querylane_suggest(definition, scope:, context: Context.new(request: request), params: self.params, config: Querylane.configuration)
|
|
32
|
+
querylane_guard!(params, config)
|
|
33
|
+
Search.new(definition: definition, request: Request.from_params(params, definition: definition, config: config),
|
|
34
|
+
scope: scope, context: context, config: config).suggest
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# All records of one group ("More records", the preview), from the
|
|
38
|
+
# request's `group` token; render with querylane/related or querylane/preview.
|
|
39
|
+
#
|
|
40
|
+
# @return [Querylane::Result, nil] nil when the request carries no token
|
|
41
|
+
def querylane_related(definition, scope:, context: Context.new(request: request), params: self.params, config: Querylane.configuration)
|
|
42
|
+
return if params[:group].blank?
|
|
43
|
+
|
|
44
|
+
querylane_guard!(params, config)
|
|
45
|
+
Search.new(definition: definition, request: Request.from_params(params.except(config.param_name), definition: definition, config: config),
|
|
46
|
+
scope: scope, context: context, config: config).related
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# What the options panel would write, without searching (the panel's
|
|
50
|
+
# "Writes" line). Render it with the querylane/writes partial.
|
|
51
|
+
#
|
|
52
|
+
# @return [Querylane::Preview]
|
|
53
|
+
def querylane_preview(definition, params: self.params, config: Querylane.configuration)
|
|
54
|
+
querylane_guard!(params, config)
|
|
55
|
+
fields = params[:compose]
|
|
56
|
+
fields = fields.respond_to?(:to_unsafe_h) ? fields.to_unsafe_h : fields.to_h
|
|
57
|
+
Preview.for(definition, params[config.param_name], fields, config: config)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Query text and panel fields never travel in a URL (unless the host opts
|
|
63
|
+
# in), and no response is cached. Only the URL's own query string is
|
|
64
|
+
# checked: a host may pass a query it restored on the server (e.g. a
|
|
65
|
+
# remembered search) to a GET.
|
|
66
|
+
def querylane_guard!(_params, config)
|
|
67
|
+
url = request.query_parameters
|
|
68
|
+
carries_text = [config.param_name, :compose, :group].any? { |name| url[name.to_s].present? }
|
|
69
|
+
if request.get? && carries_text && !config.allow_get_query
|
|
70
|
+
raise ActionController::BadRequest, "Search queries are sent by POST"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
response.headers["Cache-Control"] = "no-store"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
public
|
|
77
|
+
|
|
78
|
+
# @param id [Symbol, String] the widget id given to querylane_form
|
|
79
|
+
# @param part [Symbol] :results, or :writes (the options panel preview)
|
|
80
|
+
# @return [Boolean] whether this request targets that frame of the widget
|
|
81
|
+
def querylane_frame?(id, part = :results)
|
|
82
|
+
request.headers["Turbo-Frame"] == "#{id}-#{part}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|