querylane 0.1.0 → 0.3.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/README.md +2 -0
  5. data/app/assets/stylesheets/querylane.css +28 -6
  6. data/app/javascript/querylane/controllers/box_controller.js +1 -1
  7. data/app/javascript/querylane/controllers/results_controller.js +12 -1
  8. data/app/views/querylane/_chips.html.erb +2 -1
  9. data/app/views/querylane/_denied.html.erb +2 -1
  10. data/app/views/querylane/_empty.html.erb +10 -4
  11. data/app/views/querylane/_facets.html.erb +3 -2
  12. data/app/views/querylane/_first_load.html.erb +2 -1
  13. data/app/views/querylane/_form.html.erb +19 -9
  14. data/app/views/querylane/_group.html.erb +2 -1
  15. data/app/views/querylane/_help.html.erb +3 -2
  16. data/app/views/querylane/_options.html.erb +5 -3
  17. data/app/views/querylane/_options_row.html.erb +18 -7
  18. data/app/views/querylane/_outcome.html.erb +5 -3
  19. data/app/views/querylane/_pager.html.erb +3 -2
  20. data/app/views/querylane/_preview.html.erb +2 -1
  21. data/app/views/querylane/_record.html.erb +2 -1
  22. data/app/views/querylane/_related.html.erb +2 -1
  23. data/app/views/querylane/_results.html.erb +2 -2
  24. data/app/views/querylane/_rows.html.erb +2 -1
  25. data/app/views/querylane/_saved.html.erb +15 -6
  26. data/app/views/querylane/_suggest_box.html.erb +10 -7
  27. data/app/views/querylane/_suggestions.html.erb +2 -2
  28. data/app/views/querylane/_toolbar.html.erb +2 -1
  29. data/app/views/querylane/_unavailable.html.erb +2 -1
  30. data/app/views/querylane/_writes.html.erb +4 -3
  31. data/config/locales/querylane.en.yml +32 -0
  32. data/lib/querylane/chips.rb +24 -7
  33. data/lib/querylane/configuration.rb +9 -0
  34. data/lib/querylane/controller.rb +5 -1
  35. data/lib/querylane/copy.rb +28 -0
  36. data/lib/querylane/definition.rb +44 -10
  37. data/lib/querylane/parser.rb +16 -13
  38. data/lib/querylane/presenter.rb +19 -7
  39. data/lib/querylane/query.rb +59 -17
  40. data/lib/querylane/request.rb +32 -13
  41. data/lib/querylane/rewriter.rb +44 -2
  42. data/lib/querylane/search.rb +12 -0
  43. data/lib/querylane/searchers/meilisearch.rb +19 -2
  44. data/lib/querylane/version.rb +1 -1
  45. data/lib/querylane.rb +1 -0
  46. metadata +3 -2
@@ -14,6 +14,9 @@ module Querylane
14
14
 
15
15
  attr_reader :definition, :source, :nodes, :error
16
16
 
17
+ # @return [Querylane::Configuration] the limits the query was read with
18
+ attr_reader :config
19
+
17
20
  # @param definition [Querylane::Definition]
18
21
  # @param source [String]
19
22
  # @return [Querylane::Query]
@@ -21,6 +24,41 @@ module Querylane
21
24
  new(definition, source.to_s, config)
22
25
  end
23
26
 
27
+ # English defaults of `querylane.errors.*` (the locale file repeats them).
28
+ MESSAGES = {
29
+ parked: "%{key}: is not available yet.",
30
+ unknown_operator: "%{key}: is not an operator.",
31
+ did_you_mean: "%{key}: is not an operator. Did you mean %{suggestion}:?",
32
+ filter_value: "\"%{value}\" is not a valid %{key}: value.",
33
+ filter_value_hidden: "That value is not a valid %{key}: value.",
34
+ flag_value: "%{value} is not a value of %{key}:. Try %{values}.",
35
+ text_in_or: "OR joins operators, not names. Search one name at a time.",
36
+ text_in_not: "- and NOT apply to operators, not names.",
37
+ text_in_group: "Parentheses group operators, not names.",
38
+ one_text_operator: "Search %{operators} one at a time, or combine with %{filters}.",
39
+ one_text_operator_alone: "Search %{operators} one at a time.",
40
+ list_or: " or ",
41
+ too_long: "Search is too long (%{max} characters at most).",
42
+ close_quote: "Close the quote.",
43
+ needs_value: "%{key}: needs a value.",
44
+ unexpected: "Unexpected %{text}.",
45
+ or_needs_terms: "OR needs a term on both sides.",
46
+ needs_term: "%{operator} needs a term.",
47
+ ended_early: "Search ended early.",
48
+ too_deep: "Too many nested parentheses (%{max} at most).",
49
+ close_parenthesis: "Add the closing ).",
50
+ empty_parentheses: "Empty parentheses.",
51
+ too_many_terms: "Too many terms (%{max} at most)."
52
+ }.freeze
53
+
54
+ # A query error message in the current locale.
55
+ #
56
+ # @param name [Symbol] a key of MESSAGES (`querylane.errors.<name>`)
57
+ # @return [String]
58
+ def self.message(name, **values)
59
+ I18n.t("querylane.errors.#{name}", **values, default: MESSAGES.fetch(name))
60
+ end
61
+
24
62
  def valid? = error.nil?
25
63
 
26
64
  def blank? = source.strip.empty?
@@ -67,6 +105,7 @@ module Querylane
67
105
  def initialize(definition, source, config)
68
106
  @definition = definition
69
107
  @source = source
108
+ @config = config
70
109
  @nodes = []
71
110
  @error = nil
72
111
  root = Parser.parse(source, config: config)
@@ -106,7 +145,7 @@ module Querylane
106
145
  end
107
146
  return text_term(definition.default_text, value, term, context, interpreted) if key.nil?
108
147
 
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)
148
+ raise Invalid.new(message(:parked, key: key), term.range) if definition.parked.include?(key.to_s.downcase)
110
149
 
111
150
  operator = definition.operator(key) || raise(unknown_key(key, term))
112
151
  resolve_operator(operator, value, term, context, interpreted)
@@ -121,22 +160,18 @@ module Querylane
121
160
  end
122
161
 
123
162
  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
163
+ raise Invalid.new(message(:"text_in_#{context}"), term.range) if context
164
+
132
165
  Term.new(:text, operator, value, term.quoted, term.range, interpreted, term.value)
133
166
  end
134
167
 
135
168
  def filter_term(operator, value, term, interpreted)
136
- normalized = operator.normalize ? operator.normalize.call(value) : value
169
+ # A copy: a normalizer that edits its argument must not change the typed value.
170
+ normalized = operator.normalize ? operator.normalize.call(value.dup) : value
137
171
  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)
172
+ # A sensitive value is never repeated, not even to the host's copy.
173
+ text = operator.sensitive ? message(:filter_value_hidden, key: operator.key) : message(:filter_value, key: operator.key, value: value)
174
+ raise Invalid.new([text, Copy.text(operator.invalid)].compact.join(" "), term.range)
140
175
  end
141
176
  Term.new(:filter, operator, normalized, term.quoted, term.range, interpreted, term.value)
142
177
  end
@@ -144,7 +179,7 @@ module Querylane
144
179
  def flag_term(operator, value, term, interpreted)
145
180
  symbol = value.to_s.downcase.to_sym
146
181
  unless operator.values.key?(symbol)
147
- raise Invalid.new("#{value} is not a value of #{operator.key}:. Try #{operator.values.keys.join(", ")}.", term.range)
182
+ raise Invalid.new(message(:flag_value, key: operator.key, value: value, values: operator.values.keys.join(", ")), term.range)
148
183
  end
149
184
  Term.new(:flag, operator, symbol, term.quoted, term.range, interpreted, term.value)
150
185
  end
@@ -154,19 +189,26 @@ module Querylane
154
189
  return if operators.size <= 1
155
190
 
156
191
  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)
192
+ joiner = message(:list_or)
193
+ keys = operators.map(&:key).join(joiner)
194
+ # The first two filters, in declaration order (a host declares its best companions first).
195
+ others = definition.filters.first(2).map { |filter| "#{filter.key}:" }.join(joiner)
196
+ text = others.empty? ? message(:one_text_operator_alone, operators: keys) : message(:one_text_operator, operators: keys, filters: others)
197
+ raise Invalid.new(text, second.range)
159
198
  end
160
199
 
161
200
  # "stauts: is not an operator. Did you mean status:?", with the corrected term as the fix.
162
201
  def unknown_key(key, term)
163
202
  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
203
+ return Invalid.new(message(:unknown_operator, key: key), term.range) unless suggestion && distance(suggestion, key) <= 2
165
204
 
166
205
  fixed = source[term.range].sub(/\A[^:]+/, suggestion)
167
- Invalid.new("#{key}: is not an operator. Did you mean #{suggestion}:?", term.range, fixed)
206
+ Invalid.new(message(:did_you_mean, key: key, suggestion: suggestion), term.range, fixed)
168
207
  end
169
208
 
209
+ # A query error from `querylane.errors.*`, English by default.
210
+ def message(name, **values) = Query.message(name, **values)
211
+
170
212
  # Levenshtein distance (short strings only).
171
213
  def distance(a, b)
172
214
  previous = (0..b.length).to_a
@@ -4,30 +4,47 @@ module Querylane
4
4
  # One search request, bounded and allowlisted against the definition.
5
5
  #
6
6
  # Params: query (Configuration#param_name), toggle ("facet:value"),
7
- # remove (chip index), clear, instead, widen, relax, compose (with
7
+ # remove (chip index), as_words (chip index), clear, instead, widen, relax, compose (with
8
8
  # compose_submit), options, sort, page, per, revision.
9
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
10
+ attr_reader :fix, :saved, :delete_saved, :save_search, :saved_name, :group, :query, :toggle, :remove, :as_words, :clear, :instead, :widen, :relax, :compose, :options, :sort, :page, :per, :revision
11
11
 
12
12
  # @param params [ActionController::Parameters, Hash]
13
13
  # @param definition [Querylane::Definition]
14
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"],
15
+ values = normalized_params(params)
16
+ values["page"] = values["delete_saved_page"] if values["delete_saved"].present? && values["delete_saved_page"].present?
17
+ new(definition: definition, query: values[config.param_name.to_s], toggle: values["toggle"], remove: values["remove"],
18
+ as_words: values["as_words"], clear: values["clear"],
18
19
  instead: values["instead"], widen: values["widen"], relax: values["relax"],
19
20
  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
+ fix: values["fix"], group: values["group"], saved: values["saved"], delete_saved: values["delete_saved"], save_search: values["save_search"], saved_name: values["saved_name"], sort: values["sort"], page: values["page"], per: values["per"], revision: values["revision"])
22
+ end
23
+
24
+ # The normalized, bounded deletion value used by both the request parser and POST guard.
25
+ #
26
+ # @param params [ActionController::Parameters, Hash]
27
+ # @return [String, nil]
28
+ def self.delete_saved_param(params)
29
+ normalized_params(params)["delete_saved"].to_s.presence&.first(64)
30
+ end
31
+
32
+ def self.normalized_params(params)
33
+ values = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
34
+ values.transform_keys(&:to_s)
21
35
  end
36
+ private_class_method :normalized_params
22
37
 
23
38
  # instead: "chip.alternative" (use another reading of an interpreted term);
24
- # widen: a facet key (drop that facet's terms); relax: match fewer words.
39
+ # widen: a facet key (drop that facet's terms); relax: match fewer words;
40
+ # as_words: a chip index (search that filter's typed value as plain words).
25
41
  # compose: options-panel fields {"name" => "…", "policy" => "1 2", "is" => [...], "options" => [...]}
26
42
  # (applied only with compose_submit); options: open the options panel;
27
43
  # 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)
44
+ # saved: a saved search id to run; save_search with saved_name: save the query;
45
+ # delete_saved: an owned saved search id to delete without rewriting the query.
46
+ def initialize(definition:, query: nil, toggle: nil, remove: nil, as_words: nil, clear: nil, instead: nil, widen: nil, relax: nil,
47
+ compose: nil, options: nil, fix: nil, group: nil, saved: nil, delete_saved: nil, save_search: nil, saved_name: nil, sort: nil, page: nil, per: nil, revision: nil)
31
48
  @query = query.to_s
32
49
  @clear = clear.present?
33
50
  @instead = instead.to_s.match(/\A(\d+)\.(\d+)\z/)&.captures&.map(&:to_i)
@@ -36,12 +53,14 @@ module Querylane
36
53
  @options = options.present?
37
54
  @group = group.presence
38
55
  @fix = fix.present?
39
- @saved = saved.to_s.presence&.first(64)
40
- @save_search = save_search.present?
56
+ @delete_saved = delete_saved.to_s.presence&.first(64)
57
+ @saved = saved.to_s.presence&.first(64) unless @delete_saved
58
+ @save_search = save_search.present? && !@delete_saved
41
59
  @saved_name = saved_name.to_s.squish.first(80)
42
60
  @compose = compose.is_a?(Hash) ? compose.to_h.transform_keys(&:to_s) : nil
43
61
  @toggle = parse_toggle(definition, toggle)
44
62
  @remove = (Integer(remove.to_s, exception: false) if remove.present?)
63
+ @as_words = (Integer(as_words.to_s, exception: false) if as_words.present?)
45
64
  @sort = definition.sort(sort)
46
65
  @per = definition.per_options.include?(per.to_i) ? per.to_i : definition.per_options.first
47
66
  @page = rewriting? ? 1 : [page.to_i, 1].max
@@ -52,7 +71,7 @@ module Querylane
52
71
  def offset = (page - 1) * per
53
72
 
54
73
  # Whether this request asks the server to rewrite the query text.
55
- def rewriting? = (@toggle || @remove || @clear || @instead || @widen || @compose || @saved) ? true : false
74
+ def rewriting? = (@toggle || @remove || @as_words || @clear || @instead || @widen || @compose || @saved) ? true : false
56
75
 
57
76
  private
58
77
 
@@ -50,6 +50,40 @@ module Querylane
50
50
  text.strip
51
51
  end
52
52
 
53
+ # The query with one filter chip's term replaced by its typed value as
54
+ # plain words ("smith last:smiht" → "smith smiht"), for a chip that offers
55
+ # it (Chip#as_words); otherwise the query unchanged.
56
+ #
57
+ # @param query [Querylane::Query] valid
58
+ # @param chip [Querylane::Chips::Chip, nil]
59
+ # @return [String]
60
+ def as_words(query, chip)
61
+ return query.source unless chip&.as_words
62
+
63
+ words_in_place(query, chip.ranges.first, chip.as_words)
64
+ end
65
+
66
+ # The query text with `range` replaced by `typed` as plain words.
67
+ #
68
+ # @return [String]
69
+ def words_in_place(query, range, typed)
70
+ words = typed.split.map { |word| bare_word(query.definition, word) }.join(" ")
71
+ # Keep the words apart from a neighbour typed without a space (`last:"x"is:active`).
72
+ before = (range.begin.positive? && !query.source[range.begin - 1].match?(/\s/)) ? " " : ""
73
+ after = (range.end < query.source.length && !query.source[range.end].match?(/\s/)) ? " " : ""
74
+ replace(query.source, range, "#{before}#{words}#{after}")
75
+ end
76
+
77
+ # A word that reads back as a plain word: anything the parser would take
78
+ # as syntax (an operator key, a leading "-", OR, NOT, brackets, quotes) or
79
+ # the interpreter would read as an operator ("1234" as a last four) is
80
+ # quoted, since quoted words are never interpreted.
81
+ def bare_word(definition, word)
82
+ plain = word.match?(/\A[^\s()"-][^\s()"]*\z/) && !word.match?(/\A#{Parser::KEY}/o) && !%w[OR NOT].include?(word) &&
83
+ definition.interpret(word).nil?
84
+ plain ? word : quoted(word)
85
+ end
86
+
53
87
  # The options panel's view of a query: each field's value (keyed by the
54
88
  # compose field name), the parts no field can show (kept as typed), and,
55
89
  # per masked field, the masked terms it leaves in the box (masked values
@@ -139,7 +173,7 @@ module Querylane
139
173
  written = found.map { |token| token.quoted ? quoted(token.text) : quote(token.text) }
140
174
  operator.default ? written.join(" ") : written.map { |text| "#{operator.key}:#{text}" }.join(" ")
141
175
  else
142
- found = found.map { |token| operator.normalize ? token.with(text: operator.normalize.call(token.text)) : token }
176
+ found = found.map { |token| (operator.normalize && !typed?(operator)) ? token.with(text: operator.normalize.call(token.text)) : token }
143
177
  join_or(found.uniq.map { |token| "#{operator.key}:#{token.quoted ? quoted(token.text) : quote(token.text)}" })
144
178
  end
145
179
  end
@@ -169,7 +203,15 @@ module Querylane
169
203
  end
170
204
  end
171
205
 
172
- def display(term) = term.quoted ? quoted(term.value.to_s) : term.value.to_s
206
+ # A field's text for a term: quoted as typed; a `display_value: :typed` filter
207
+ # shows the value as typed, others (and interpreted terms, whose typed
208
+ # token would read differently once prefixed) the normalized value.
209
+ def display(term)
210
+ value = (typed?(term.operator) && !term.interpreted) ? term.raw.to_s : term.value.to_s
211
+ term.quoted ? quoted(value) : value
212
+ end
213
+
214
+ def typed?(operator) = operator.is_a?(Definition::FilterOperator) && operator.display_value == :typed
173
215
 
174
216
  # Always quoted, with " and \ escaped.
175
217
  def quoted(value) = %("#{value.to_s.gsub(/["\\]/) { |char| "\\#{char}" }}")
@@ -33,6 +33,7 @@ module Querylane
33
33
  query = Query.analyze(@definition, @request.query, config: @config)
34
34
  query, rewritten = rewrite(query)
35
35
  @notice = save(query) if @request.save_search && !@scope.denied?
36
+ @notice = delete_saved(@request.delete_saved) if @request.delete_saved && !@scope.denied?
36
37
  return result(:first_load, query, rewritten) if query.valid? && query.blank?
37
38
  return result(:invalid, query, rewritten, error: query.error) unless query.valid?
38
39
  return result(:short, query, rewritten, chips: Chips.for(query)) if query.too_short?(@definition.min_text_length)
@@ -211,6 +212,8 @@ module Querylane
211
212
  chip = Chips.for(query).find { |candidate| candidate.index == chip_index }
212
213
  alternative = chip&.alternatives&.find { |candidate| candidate.index == alternative_index }
213
214
  alternative ? Rewriter.replace(query.source, chip.ranges.first, alternative.text) : query.source
215
+ elsif @request.as_words
216
+ Rewriter.as_words(query, Chips.for(query).find { |candidate| candidate.index == @request.as_words })
214
217
  else
215
218
  chip = Chips.for(query).find { |candidate| candidate.index == @request.remove }
216
219
  chip ? Rewriter.remove(query.source, chip.ranges) : query.source
@@ -309,6 +312,15 @@ module Querylane
309
312
  nil
310
313
  end
311
314
 
315
+ def delete_saved(id)
316
+ store = @definition.saved_searches
317
+ return :not_available unless store&.respond_to?(:delete)
318
+
319
+ store.delete(context: @context, id: id) ? :deleted : :not_available
320
+ rescue => error
321
+ saved_store_failed(error)
322
+ end
323
+
312
324
  def saved_list
313
325
  store = @definition.saved_searches
314
326
  store && Array(store.all(context: @context))
@@ -67,10 +67,27 @@ module Querylane
67
67
 
68
68
  def client = @client.respond_to?(:call) ? @client.call : @client
69
69
 
70
+ # Turns a backend failure into SearchUnavailable and logs why. An API
71
+ # error is logged by code, type and status only: its message can echo
72
+ # the filter, and filters carry what the user searched for.
70
73
  def call
71
74
  yield
72
- rescue ::Meilisearch::ApiError, ::Meilisearch::CommunicationError, ::Meilisearch::TimeoutError => error
73
- raise SearchUnavailable, "Meilisearch #{error.class.name.demodulize}"
75
+ rescue ::Meilisearch::ApiError => error
76
+ unavailable(error, "code=#{error.code} type=#{error.type} http=#{error.http_code}", error.code)
77
+ rescue ::Meilisearch::CommunicationError, ::Meilisearch::TimeoutError => error
78
+ unavailable(error, without_url_secrets(error.message))
79
+ end
80
+
81
+ # A connection error can quote the client URL; keep its host and port,
82
+ # drop any user:password@ and query string.
83
+ def without_url_secrets(message)
84
+ message.gsub(%r{(\w+://)[^\s/@]+@}, '\1').gsub(/\?[^\s)]*/, "")
85
+ end
86
+
87
+ def unavailable(error, detail, code = nil)
88
+ name = "Meilisearch #{error.class.name.demodulize}"
89
+ Querylane.configuration.logger.warn("[querylane] search unavailable: #{name} #{detail}")
90
+ raise SearchUnavailable, [name, code].compact.join(" ")
74
91
  end
75
92
 
76
93
  # Indexes whose scope and query can match something; the others are
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Querylane
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/querylane.rb CHANGED
@@ -14,6 +14,7 @@ require "strscan"
14
14
 
15
15
  require "querylane/version"
16
16
  require "querylane/errors"
17
+ require "querylane/copy"
17
18
  require "querylane/configuration"
18
19
  require "querylane/filter"
19
20
  require "querylane/scope"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: querylane
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajaya Agrawalla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -79,6 +79,7 @@ files:
79
79
  - lib/querylane/configuration.rb
80
80
  - lib/querylane/context.rb
81
81
  - lib/querylane/controller.rb
82
+ - lib/querylane/copy.rb
82
83
  - lib/querylane/definition.rb
83
84
  - lib/querylane/doctor.rb
84
85
  - lib/querylane/engine.rb