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