Class: RoundhouseUi::FilterQuery

Inherits:
Object
  • Object
show all
Defined in:
lib/roundhouse_ui/filter_query.rb

Overview

One search box, parsed into the exact filters the scan already understands.

class=Billing::SyncWorker error=Timeout::Error stripe

key=value tokens become structured, EXACT filters; whatever follows them is one verbatim substring needle. That is the whole point: the box becomes the single visible, editable, shareable home for filter state, without a class name in it silently widening the "delete all matching" button below it.

Two rules do the work:

1. Split on the FIRST `=` only. Colons are never a delimiter, so
 `Billing::SyncWorker` and `Timeout::Error` need no escaping — which is why
 the separator is `=` and not Datadog's `:`. Ruby names are colon-dense.
2. Facets lead. Parsing walks from index 0; the first position where a facet
 cannot be read ends the facet region, and everything from that byte to the
 end is the text needle, as one contiguous slice. With no facets the slice
 starts at 0, so `text` is byte-identical to the old `params[:q].strip`,
 interior double spaces included. That is not a special case — it falls out
 of the rule, which is what keeps parse/to_s compositional.

Everything invalid is REFUSED, whole. Never "ignore the token I did not understand": discarding is the only resolution that can select MORE than what was typed, and this feeds a destructive action. clas=Foo becoming a substring search for the literal text "clas=Foo" would find nothing and read as "no such jobs exist"; worse, dropping it entirely would widen a delete.

Defined Under Namespace

Classes: Parser, Pattern

Constant Summary collapse

KEYS =
%w[class error queue tag text].freeze
MAX_LENGTH =

Checked before any comparison runs, the same ordering as RoundhouseUi.job_class: a length test first, so a pathological input is bounded before anything walks it. Regexp.timeout is Ruby 3.2+ and the gemspec floor is 3.1, so bounding the input is the only portable defence.

500
MAX_VALUE =

No facet count cap, deliberately. There are five keys and a conflicting duplicate is refused, so the maximum number of facets in any accepted query is five. A cap of eight was written, could never fire, and its test was actually exercising the duplicate rule — a bound that cannot be reached reads as a risk that does not exist. MAX_LENGTH bounds the total work. Same bound as RoundhouseUi::MAX_JOB_CLASS_NAME, written out because this file is required before that constant is defined — the entry point's require block runs above its own module body.

200
UNSUPPORTED =

Things a Datadog-trained operator will type. Refused BY NAME rather than matched literally: entry_selected? is a pure conjunction, which is exactly what makes a stray token harmless, and every one of these is disjunction or a substring in a facet's clothing. Refused by name, because a Datadog-trained operator will type them and a literal match reads as "no such jobs exist". Narrow on purpose: only a NEGATED KNOWN FACET is unambiguous. A bare "-1" or "*" or "OR" in free text is legitimate text — an error message can contain any of them — and refusing those would break searching for what is actually in the data.

[
  [ /\A-(?:#{KEYS.join('|')})=/,
    "Negation is not supported. Every filter narrows; none excludes." ]
].freeze
LEGACY =

Old links and bookmarks. ?class=X&error=Y predates the grammar, and find_like_link, the tag chips and the queue pills all emitted it — so somebody's saved URL, and somebody's shared one, is that shape.

Folded by RE-PARSING rather than by assignment. build() does not validate, so a legacy ?error=A"B assigned straight in would produce a query whose own to_s the parser then refuses — displayed one filter, applied another. Going back through parse means nothing can enter that the grammar would not accept, and conflicts (?q=class=A&class=B) hit the parser's existing refusal instead of a second, differently-written precedence rule.

{ class: "class", error: "error", queue: "queue", tag: "tag" }.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(klass: nil, error: nil, queue: nil, tag: nil, text: "", raw: nil, ignored: []) ⇒ FilterQuery

Returns a new instance of FilterQuery.



194
195
196
197
198
199
200
201
202
203
# File 'lib/roundhouse_ui/filter_query.rb', line 194

def initialize(klass: nil, error: nil, queue: nil, tag: nil, text: "", raw: nil, ignored: [])
  @klass = presence(klass)
  @error = presence(error)
  @queue = presence(queue)
  @tag   = tag
  @text  = text.to_s
  @raw   = raw || to_s
  @ignored = ignored.freeze
  @message = nil
end

Instance Attribute Details

#errorObject (readonly)

Returns the value of attribute error.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def error
  @error
end

#ignoredObject (readonly)

Returns the value of attribute ignored.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def ignored
  @ignored
end

#klassObject (readonly)

Returns the value of attribute klass.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def klass
  @klass
end

#messageObject (readonly)

Returns the value of attribute message.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def message
  @message
end

#queueObject (readonly)

Returns the value of attribute queue.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def queue
  @queue
end

#rawObject (readonly)

Returns the value of attribute raw.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def raw
  @raw
end

#tagObject (readonly)

Returns the value of attribute tag.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def tag
  @tag
end

#textObject (readonly)

Returns the value of attribute text.



117
118
119
# File 'lib/roundhouse_ui/filter_query.rb', line 117

def text
  @text
end

Class Method Details

.build(klass: nil, error: nil, queue: nil, tag: nil, text: nil, ignored: []) ⇒ Object



180
181
182
# File 'lib/roundhouse_ui/filter_query.rb', line 180

def build(klass: nil, error: nil, queue: nil, tag: nil, text: nil, ignored: [])
  new(klass: klass, error: error, queue: queue, tag: tag, text: text.to_s, ignored: ignored)
end

.from_params(params, keys: KEYS) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/roundhouse_ui/filter_query.rb', line 154

def from_params(params, keys: KEYS)
  # `qf` is the search FORM's facet companion. The bar renders each facet as a
  # pill and leaves the visible input holding only the free text, so the facets
  # need a way back — joined here, facets first, at the single parse point.
  #
  # `q` stays the canonical parameter on its own: a bookmark or a shared link
  # carries the whole filter in q and never mentions qf. If both arrive and
  # they disagree, the parser's existing duplicate rule refuses.
  facets = params[:qf].to_s.strip
  typed = params[:q].to_s.strip
  q = parse(facets.empty? ? params[:q] : [ facets, typed ].reject(&:empty?).join(" "), keys: keys)
  return q if q.invalid?

  extra = LEGACY.filter_map do |param, key|
    next unless keys.include?(key)

    value = params[param].to_s.strip
    next if value.empty?

    "#{key}=#{value.match?(/\s/) ? %("#{value}") : value}"
  end
  return q if extra.empty?

  parse((q.facet_parts + extra + q.text_parts).join(" "), keys: keys)
end

.noneObject



184
# File 'lib/roundhouse_ui/filter_query.rb', line 184

def none = @none ||= build.freeze

.parse(raw, keys: KEYS) ⇒ Object

Never raises. A refusal is a value, not an exception, because this runs in a before_action on every page including the ones that only browse. keys restricts which facets this PAGE honours. Errors groups by class and error, so queue= has nothing to apply to there — and a facet that renders as a pill while filtering nothing is precisely the invisible/phantom filter this whole bar exists to prevent. Out of scope refuses, and says where it works.



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/roundhouse_ui/filter_query.rb', line 126

def parse(raw, keys: KEYS)
  return refused(raw, "Search is not a single value.") unless raw.nil? || raw.is_a?(String)

  str = raw.to_s
  # Encoding first, then length, then anything that inspects characters.
  # String#strip raises Encoding::CompatibilityError on invalid bytes, so
  # checking validity third meant a malformed query crashed the page instead
  # of being refused — the same ordering trap RoundhouseUi.job_class avoids by
  # testing length before it matches.
  return refused("", "That search contains invalid characters.") unless str.valid_encoding?
  return refused(str, "That search is too long (#{str.length} characters, limit #{MAX_LENGTH}).") if str.length > MAX_LENGTH
  return none if str.strip.empty?

  Parser.new(str, keys).call
end

.refused(raw, message) ⇒ Object



186
187
188
189
190
191
# File 'lib/roundhouse_ui/filter_query.rb', line 186

def refused(raw, message)
  q = build
  q.instance_variable_set(:@raw, raw.to_s)
  q.instance_variable_set(:@message, message)
  q.freeze
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?



296
# File 'lib/roundhouse_ui/filter_query.rb', line 296

def ==(other) = other.is_a?(FilterQuery) && to_s == other.to_s && invalid? == other.invalid?

#any?Boolean

Returns:

  • (Boolean)


242
# File 'lib/roundhouse_ui/filter_query.rb', line 242

def any? = any_facets? || !text.empty?

#any_facets?Boolean

Returns:

  • (Boolean)


241
# File 'lib/roundhouse_ui/filter_query.rb', line 241

def any_facets? = !(klass.nil? && error.nil? && queue.nil? && tag.nil?)

#chipsObject



276
277
278
279
# File 'lib/roundhouse_ui/filter_query.rb', line 276

def chips
  [ [ :class, klass ], [ :error, error ], [ :queue, queue ], [ :tag, tag ] ]
    .select { |_k, v| v }
end

#degraded?Boolean

Was any facet dropped? A degraded query browses normally — the point of dropping rather than refusing — but authorises no bulk action, because the filter that survived selects a SUPERSET of what was asked for.

Returns:

  • (Boolean)


232
# File 'lib/roundhouse_ui/filter_query.rb', line 232

def degraded? = !ignored.empty?

#displayObject

What the search box shows. Canonical when the query parsed, but the RAW input when it did not: a refused query has no canonical form, and rendering the empty one would swallow the typo along with any chance of fixing it.



240
# File 'lib/roundhouse_ui/filter_query.rb', line 240

def display = invalid? ? raw : to_s

#facet_partsObject

The facet region alone, in KEYS order. Public because from_params splices more facets in here — between the facets and the text, never after it.



262
263
264
265
266
267
268
269
# File 'lib/roundhouse_ui/filter_query.rb', line 262

def facet_parts
  parts = []
  parts << "class=#{quoted(klass)}" if klass
  parts << "error=#{quoted(error)}" if error
  parts << "queue=#{quoted(queue)}" if queue
  parts << "tag=#{quoted(tag)}"     if tag
  parts
end

#hashObject



298
# File 'lib/roundhouse_ui/filter_query.rb', line 298

def hash = [ self.class, to_s, invalid? ].hash

#invalid?Boolean

Returns:

  • (Boolean)


205
# File 'lib/roundhouse_ui/filter_query.rb', line 205

def invalid? = !@message.nil?

#matches_facet?(field, value) ⇒ Boolean

Does this facet's value match? Exact when there is no wildcard — queue=default must never also select default_low, which is the invariant the whole facet design rests on.

Returns:

  • (Boolean)


219
220
221
222
223
224
225
# File 'lib/roundhouse_ui/filter_query.rb', line 219

def matches_facet?(field, value)
  wanted = public_send(field)
  return true if wanted.nil?

  pattern = pattern_for(field)
  pattern ? pattern.match?(value) : value.to_s == wanted
end

#merge(**over) ⇒ Object



281
282
283
284
285
286
287
288
289
# File 'lib/roundhouse_ui/filter_query.rb', line 281

def merge(**over)
  self.class.build(
    klass: over.key?(:klass) ? over[:klass] : klass,
    error: over.key?(:error) ? over[:error] : error,
    queue: over.key?(:queue) ? over[:queue] : queue,
    tag:   over.key?(:tag)   ? over[:tag]   : tag,
    text:  over.key?(:text)  ? over[:text].to_s : text
  )
end

#notesObject

One sentence per dropped facet, for the banner above the table.



235
# File 'lib/roundhouse_ui/filter_query.rb', line 235

def notes = ignored.map { |token, why| %(Ignored "#{token}" — #{why}.) }

#pattern_for(field) ⇒ Object

A matcher per facet, compiled once. nil where the value has no %, which is the overwhelming case and stays a plain string comparison.



209
210
211
212
213
214
# File 'lib/roundhouse_ui/filter_query.rb', line 209

def pattern_for(field)
  @patterns ||= {}
  return @patterns[field] if @patterns.key?(field)

  @patterns[field] = Pattern.for(public_send(field))
end

#serialized_textObject

Text whose first token looks like key= has to go back out through the text= escape hatch, or re-parsing it would read that token as a facet — and since the key would be an unknown one, refuse the whole query. Pressing Enter twice would then mean something different from pressing it once.

Always representable, and worth knowing why: text can only BEGIN with a facet-shaped token if it arrived as text="…" in the first place (a known key would have been parsed as a facet, an unknown one refused), and a quoted value cannot contain a quote, because reading one stops at the first closing quote. So there is no text needing the wrapper that also breaks it.



323
324
325
# File 'lib/roundhouse_ui/filter_query.rb', line 323

public def serialized_text
  text.match?(/\A[a-z][a-z0-9_]*=/) ? %(text="#{text}") : text
end

#tag_pairObject

The tag filter's existing two-element shape, so entry_tagged? is untouched.



245
246
247
248
249
250
251
252
# File 'lib/roundhouse_ui/filter_query.rb', line 245

def tag_pair
  return nil if tag.nil?

  key, value = tag.split(":", 2)
  return nil if key.to_s.empty? || value.to_s.empty?

  [ key, value ]
end

#text_partsObject



271
272
273
# File 'lib/roundhouse_ui/filter_query.rb', line 271

def text_parts
  text.empty? ? [] : [ serialized_text ]
end

#to_sObject Also known as: to_param

Canonical, and round-trips: parse(q.to_s) == q. Facets in KEYS order so the box does not reshuffle itself when you press Enter.



256
257
258
# File 'lib/roundhouse_ui/filter_query.rb', line 256

def to_s
  @canonical ||= (facet_parts + text_parts).join(" ")
end

#wildcard?Boolean

Returns:

  • (Boolean)


227
# File 'lib/roundhouse_ui/filter_query.rb', line 227

def wildcard? = %i[klass error queue tag].any? { |f| pattern_for(f) }

#without(*keys) ⇒ Object



291
292
293
294
# File 'lib/roundhouse_ui/filter_query.rb', line 291

def without(*keys)
  over = keys.to_h { |k| [ k, k == :text ? "" : nil ] }
  merge(**over)
end