Class: RoundhouseUi::FilterQuery
- Inherits:
-
Object
- Object
- RoundhouseUi::FilterQuery
- 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
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=Ypredates 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"Bassigned 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
-
#error ⇒ Object
readonly
Returns the value of attribute error.
-
#ignored ⇒ Object
readonly
Returns the value of attribute ignored.
-
#klass ⇒ Object
readonly
Returns the value of attribute klass.
-
#message ⇒ Object
readonly
Returns the value of attribute message.
-
#queue ⇒ Object
readonly
Returns the value of attribute queue.
-
#raw ⇒ Object
readonly
Returns the value of attribute raw.
-
#tag ⇒ Object
readonly
Returns the value of attribute tag.
-
#text ⇒ Object
readonly
Returns the value of attribute text.
Class Method Summary collapse
- .build(klass: nil, error: nil, queue: nil, tag: nil, text: nil, ignored: []) ⇒ Object
- .from_params(params, keys: KEYS) ⇒ Object
- .none ⇒ Object
-
.parse(raw, keys: KEYS) ⇒ Object
Never raises.
- .refused(raw, message) ⇒ Object
Instance Method Summary collapse
- #==(other) ⇒ Object (also: #eql?)
- #any? ⇒ Boolean
- #any_facets? ⇒ Boolean
- #chips ⇒ Object
-
#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.
-
#display ⇒ Object
What the search box shows.
-
#facet_parts ⇒ Object
The facet region alone, in KEYS order.
- #hash ⇒ Object
-
#initialize(klass: nil, error: nil, queue: nil, tag: nil, text: "", raw: nil, ignored: []) ⇒ FilterQuery
constructor
A new instance of FilterQuery.
- #invalid? ⇒ Boolean
-
#matches_facet?(field, value) ⇒ Boolean
Does this facet's value match? Exact when there is no wildcard —
queue=defaultmust never also selectdefault_low, which is the invariant the whole facet design rests on. - #merge(**over) ⇒ Object
-
#notes ⇒ Object
One sentence per dropped facet, for the banner above the table.
-
#pattern_for(field) ⇒ Object
A matcher per facet, compiled once.
-
#serialized_text ⇒ Object
Text whose first token looks like
key=has to go back out through thetext=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. -
#tag_pair ⇒ Object
The tag filter's existing two-element shape, so entry_tagged? is untouched.
- #text_parts ⇒ Object
-
#to_s ⇒ Object
(also: #to_param)
Canonical, and round-trips: parse(q.to_s) == q.
- #wildcard? ⇒ Boolean
- #without(*keys) ⇒ Object
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
#error ⇒ Object (readonly)
Returns the value of attribute error.
117 118 119 |
# File 'lib/roundhouse_ui/filter_query.rb', line 117 def error @error end |
#ignored ⇒ Object (readonly)
Returns the value of attribute ignored.
117 118 119 |
# File 'lib/roundhouse_ui/filter_query.rb', line 117 def ignored @ignored end |
#klass ⇒ Object (readonly)
Returns the value of attribute klass.
117 118 119 |
# File 'lib/roundhouse_ui/filter_query.rb', line 117 def klass @klass end |
#message ⇒ Object (readonly)
Returns the value of attribute message.
117 118 119 |
# File 'lib/roundhouse_ui/filter_query.rb', line 117 def @message end |
#queue ⇒ Object (readonly)
Returns the value of attribute queue.
117 118 119 |
# File 'lib/roundhouse_ui/filter_query.rb', line 117 def queue @queue end |
#raw ⇒ Object (readonly)
Returns the value of attribute raw.
117 118 119 |
# File 'lib/roundhouse_ui/filter_query.rb', line 117 def raw @raw end |
#tag ⇒ Object (readonly)
Returns the value of attribute tag.
117 118 119 |
# File 'lib/roundhouse_ui/filter_query.rb', line 117 def tag @tag end |
#text ⇒ Object (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 |
.none ⇒ Object
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, ) q = build q.instance_variable_set(:@raw, raw.to_s) q.instance_variable_set(:@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
242 |
# File 'lib/roundhouse_ui/filter_query.rb', line 242 def any? = any_facets? || !text.empty? |
#any_facets? ⇒ Boolean
241 |
# File 'lib/roundhouse_ui/filter_query.rb', line 241 def any_facets? = !(klass.nil? && error.nil? && queue.nil? && tag.nil?) |
#chips ⇒ Object
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.
232 |
# File 'lib/roundhouse_ui/filter_query.rb', line 232 def degraded? = !ignored.empty? |
#display ⇒ Object
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_parts ⇒ Object
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 |
#hash ⇒ Object
298 |
# File 'lib/roundhouse_ui/filter_query.rb', line 298 def hash = [ self.class, to_s, invalid? ].hash |
#invalid? ⇒ 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.
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 |
#notes ⇒ Object
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_text ⇒ Object
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_pair ⇒ Object
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_parts ⇒ Object
271 272 273 |
# File 'lib/roundhouse_ui/filter_query.rb', line 271 def text_parts text.empty? ? [] : [ serialized_text ] end |
#to_s ⇒ Object 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
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 |