Module: Phlex::Reactive::ShowConditions
- Defined in:
- lib/phlex/reactive/show_conditions.rb
Overview
The ONE conditions language behind reactive_show / reactive_show_targets
(issue #180 Phase A). It compiles Ruby-native values into a DNF wire shape
and evaluates that shape in Ruby with semantics identical to the client —
so first-paint hidden: (server) and live toggling (client) can never
drift (the shared spec/fixtures/show_predicate_vectors.json proves it).
THE VALUE LANGUAGE (used by every if:/if_any:/unless: term):
String/Symbol/Integer/Float -> equals (stringified)
true / false -> equals "true"/"false" (checkbox state)
nil -> equals "" (blank)
Array -> in (each stringified; empty raises)
(10..) -> gte 10
(..10) -> lte 10
(...10) -> lt 10
(10..20) -> gte 10 AND lte 20 (two terms, one group)
{ length: 6 } -> len_eq 6 (issue #226 — the value's
{ length: 6.. } / (4..8) CODEPOINT count; Integer Ranges reuse
the threshold vocabulary as len_*)
Under unless: each term is NEGATED (De Morgan):
scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
to a disjunction (x<a ∨ x>b) that splits the group.
THE WIRE (DNF): an ARRAY OF GROUPS. Terms AND within a group; groups OR. [[{ "field"=>"a", "equals"=>"1" }], [{ "field"=>"b", "gte"=>10 }]] A single if: compiles to one group; if_any: to one group per hash; unless: distributes its negated terms into every group (multiplying groups when a bounded-range complement splits). There is NO expression surface: every term is a declared literal predicate — the pre-#180 default-deny posture.
Constant Summary collapse
- LENGTH_KEYS =
The length-predicate wire keys (issue #226) — the Ruby half; the client mirrors them in SHOW_LENGTH_KEYS. Length is counted in CODEPOINTS (String#length here, [...value].length there) so multibyte values agree — the shared fixture's emoji vector proves it.
%w[len_eq len_gte len_gt len_lte len_lt].freeze
Class Method Summary collapse
-
.apply_unless(groups, negative) ⇒ Object
Distribute the negated unless: over every base group, obeying De Morgan.
-
.base_groups(positive) ⇒ Object
The positive base: if: -> one group; if_any: -> a group per hash; neither (unless: only) -> a single empty "all true" group that unless: negates into.
- .equals_literal(value) ⇒ Object
-
.fields(groups) ⇒ Object
Every field named across all groups/terms — drives reactive_values coverage (server first paint only fires when every field is provided).
-
.group_terms(context, hash) ⇒ Object
One hash -> its ANDed positive terms.
-
.length_complement(name, hash) ⇒ Object
The complement of a length predicate, under unless:.
- .length_range_terms(name, range) ⇒ Object
-
.length_term_matches?(key, literal, value) ⇒ Boolean
len_* — compare the value's CODEPOINT count (String#length) against the Integer literal.
-
.length_terms(name, hash) ⇒ Object
A Hash value names a STRUCTURAL predicate — today only length:.
-
.length_value!(name, hash) ⇒ Object
The one Hash key must be length: — anything else is a typo'd predicate that must fail at render, never silently in the browser.
-
.match?(groups, values) ⇒ Boolean
Evaluate DNF groups against a { field => value(String) } map — true when ANY group's EVERY term matches.
- .membership_term(name, array) ⇒ Object
-
.negative_alternatives(field, value) ⇒ Object
A field => value under unless: -> a LIST of alternative term-lists.
-
.normalize(**kwargs) ⇒ Object
Compile if:/if_any:/unless: into DNF groups.
-
.numeric_term_matches?(term, value) ⇒ Boolean
gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric value is fail-closed (never matches).
-
.positive_terms(field, value) ⇒ Object
A field => value under if:/if_any: -> a list of ANDed terms (one, or two for a bounded range).
- .raise_length_kind(name, value) ⇒ Object
-
.range_complement(name, range) ⇒ Object
The complement of a range predicate.
-
.range_terms(name, range) ⇒ Object
A Range -> gte / lte / lt terms.
-
.term_matches?(term, values) ⇒ Boolean
--- evaluation (mirrors the client) -----------------------------------.
- .validate_length_literal!(name, value) ⇒ Object
- .validate_range_endpoints!(name, first, last) ⇒ Object
Class Method Details
.apply_unless(groups, negative) ⇒ Object
Distribute the negated unless: over every base group, obeying De Morgan.
unless: negates the AND of its whole hash — ¬(a ∧ b) = ¬a ∨ ¬b — so the
negation is a DISJUNCTION: the UNION of each field's alternative
term-lists, never their cartesian product. Each field contributes 1
alternative (a scalar not, or an Array's ∉ = AND-of-nots, or an endless
range complement) or 2 (a bounded range, itself a disjunction); those
alternatives OR across fields. Distributing that ORed set over the base
groups multiplies them: |base| × |alternatives| groups, each base group
ANDed with exactly one alternative.
118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/phlex/reactive/show_conditions.rb', line 118 def apply_unless(groups, negative) unless negative.is_a?(Hash) && negative.any? raise ArgumentError, "reactive_show unless: needs a Hash of field => value, got #{negative.inspect}" end alternatives = negative.flat_map { |field, value| negative_alternatives(field, value) } # rubocop:disable Style/ItBlockParameter -- nested block: `it` would shadow `group` groups.flat_map do |group| alternatives.map { |extra| group + extra } end # rubocop:enable Style/ItBlockParameter end |
.base_groups(positive) ⇒ Object
The positive base: if: -> one group; if_any: -> a group per hash; neither (unless: only) -> a single empty "all true" group that unless: negates into.
84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/phlex/reactive/show_conditions.rb', line 84 def base_groups(positive) if positive.key?(:if) [group_terms(:if, positive.fetch(:if))] elsif positive.key?(:if_any) any = positive.fetch(:if_any) unless any.is_a?(Array) && any.any? raise ArgumentError, "reactive_show if_any: needs at least one group (an Array of hashes)" end any.map { group_terms(:if_any, it) } else [[]] # unless: only — one empty group; unless distributes its nots in end end |
.equals_literal(value) ⇒ Object
145 146 147 148 149 |
# File 'lib/phlex/reactive/show_conditions.rb', line 145 def equals_literal(value) return "" if value.nil? value.to_s end |
.fields(groups) ⇒ Object
Every field named across all groups/terms — drives reactive_values coverage (server first paint only fires when every field is provided).
74 75 76 |
# File 'lib/phlex/reactive/show_conditions.rb', line 74 def fields(groups) groups.flat_map { |group| group.map { |term| term.fetch("field") } }.uniq end |
.group_terms(context, hash) ⇒ Object
One hash -> its ANDed positive terms.
100 101 102 103 104 105 106 107 |
# File 'lib/phlex/reactive/show_conditions.rb', line 100 def group_terms(context, hash) unless hash.is_a?(Hash) && hash.any? label = context == :if_any ? "each if_any: group needs at least one field" : "if: needs at least one field" raise ArgumentError, "reactive_show: #{label} (a Hash of field => value), got #{hash.inspect}" end hash.flat_map { |field, value| positive_terms(field, value) } end |
.length_complement(name, hash) ⇒ Object
The complement of a length predicate, under unless:. Exact length negates to the outside disjunction (len < n OR len > n) — two alternatives, like a bounded numeric range; ranges complement each leg (not-gte -> lt, not-lte -> gt, not-lt -> gte). Length is total, so unlike the numeric complements there is no blank/NaN fail-closed asymmetry — the complement is exact.
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 |
# File 'lib/phlex/reactive/show_conditions.rb', line 222 def length_complement(name, hash) value = length_value!(name, hash) case value when Integer validate_length_literal!(name, value) [[{ "field" => name, "len_lt" => value }], [{ "field" => name, "len_gt" => value }]] when Range first = value.begin last = value.end raise_length_kind(name, value) if first.nil? && last.nil? [first, last].compact.each { validate_length_literal!(name, it) } low = first.nil? ? nil : [{ "field" => name, "len_lt" => first }] high = if last.nil? then nil elsif value.exclude_end? then [{ "field" => name, "len_gte" => last }] else [{ "field" => name, "len_gt" => last }] end [low, high].compact else raise_length_kind(name, value) end end |
.length_range_terms(name, range) ⇒ Object
202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/phlex/reactive/show_conditions.rb', line 202 def length_range_terms(name, range) first = range.begin last = range.end raise_length_kind(name, range) if first.nil? && last.nil? [first, last].compact.each { validate_length_literal!(name, it) } terms = [] terms << { "field" => name, "len_gte" => first } unless first.nil? if last terms << { "field" => name, (range.exclude_end? ? "len_lt" : "len_lte") => last } end terms end |
.length_term_matches?(key, literal, value) ⇒ Boolean
len_* — compare the value's CODEPOINT count (String#length) against the Integer literal. Length is total (blank/absent -> 0), so every value is decidable; a malformed non-Integer literal (a hand-built term) is fail-closed, mirroring the client's warn-skip.
333 334 335 336 337 338 339 340 341 342 343 344 |
# File 'lib/phlex/reactive/show_conditions.rb', line 333 def length_term_matches?(key, literal, value) return false unless literal.is_a?(Integer) length = value.to_s.length case key when "len_eq" then length == literal when "len_gte" then length >= literal when "len_gt" then length > literal when "len_lte" then length <= literal when "len_lt" then length < literal end end |
.length_terms(name, hash) ⇒ Object
A Hash value names a STRUCTURAL predicate — today only length:. Exact Integer -> one len_eq term; an Integer Range -> len_gte/len_lte/len_lt terms exactly like range_terms. Length is a total function over the value's codepoints (blank/absent -> 0), so { length: 0 } legitimately matches a blank field.
191 192 193 194 195 196 197 198 199 200 |
# File 'lib/phlex/reactive/show_conditions.rb', line 191 def length_terms(name, hash) value = length_value!(name, hash) case value when Integer validate_length_literal!(name, value) [{ "field" => name, "len_eq" => value }] when Range then length_range_terms(name, value) else raise_length_kind(name, value) end end |
.length_value!(name, hash) ⇒ Object
The one Hash key must be length: — anything else is a typo'd predicate that must fail at render, never silently in the browser.
247 248 249 250 251 252 253 254 255 |
# File 'lib/phlex/reactive/show_conditions.rb', line 247 def length_value!(name, hash) unless hash.size == 1 && hash.keys.first.to_s == "length" raise ArgumentError, "reactive_show: #{name.inspect} Hash value supports only length: " \ "(got #{hash.keys.inspect})" end hash.values.first end |
.match?(groups, values) ⇒ Boolean
Evaluate DNF groups against a { field => value(String) } map — true when
ANY group's EVERY term matches. Mirrors the client fold exactly; a
referenced field absent from values reads as "" (fail-closed, same as
a blank field).
rubocop:disable Style/ItBlockParameter -- nested blocks: it would shadow ambiguously
68 69 70 |
# File 'lib/phlex/reactive/show_conditions.rb', line 68 def match?(groups, values) groups.any? { |group| group.all? { |term| term_matches?(term, values) } } end |
.membership_term(name, array) ⇒ Object
151 152 153 154 155 156 |
# File 'lib/phlex/reactive/show_conditions.rb', line 151 def membership_term(name, array) list = array.map(&:to_s) raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty? { "field" => name, "in" => list } end |
.negative_alternatives(field, value) ⇒ Object
A field => value under unless: -> a LIST of alternative term-lists. Scalar/Array negate to a single conjunctive alternative (an Array is ¬(x ∈ set) = AND of nots); a bounded Range complements to TWO alternatives (the disjunction x<a ∨ x>b). An empty Array raises, exactly like the positive membership path (a dead binding must fail at render).
278 279 280 281 282 283 284 285 286 287 288 289 290 |
# File 'lib/phlex/reactive/show_conditions.rb', line 278 def negative_alternatives(field, value) name = field.to_s case value when Range then range_complement(name, value) when Hash then length_complement(name, value) when Array list = value.map(&:to_s) raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty? [list.map { { "field" => name, "not" => it } }] else [[{ "field" => name, "not" => equals_literal(value) }]] end end |
.normalize(**kwargs) ⇒ Object
Compile if:/if_any:/unless: into DNF groups. Loud validation at render (the pre-#180 posture) — empty conditions, empty Array/group values, a non-Numeric Range endpoint, and if:+if_any: together all raise here so a dead binding fails at render, never silently in the browser.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/phlex/reactive/show_conditions.rb', line 47 def normalize(**kwargs) positive = kwargs.slice(:if, :if_any) negative = kwargs[:unless] if positive.key?(:if) && positive.key?(:if_any) raise ArgumentError, "reactive_show: exactly one of if:/if_any: (unless: composes with either), " \ "got both" end raise ArgumentError, "reactive_show needs if:, if_any:, or unless:" unless positive.any? || negative groups = base_groups(positive) groups = apply_unless(groups, negative) if negative groups end |
.numeric_term_matches?(term, value) ⇒ Boolean
gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric value is fail-closed (never matches). Mirrors the client's numericPredicateMatches (blank/whitespace -> NaN -> false).
349 350 351 352 353 354 355 356 357 358 359 360 361 362 |
# File 'lib/phlex/reactive/show_conditions.rb', line 349 def numeric_term_matches?(term, value) trimmed = value.to_s.strip return false if trimmed.empty? number = Float(trimmed, exception: false) return false if number.nil? if term.key?("gte") then number >= term["gte"] elsif term.key?("gt") then number > term["gt"] elsif term.key?("lte") then number <= term["lte"] elsif term.key?("lt") then number < term["lt"] else false end end |
.positive_terms(field, value) ⇒ Object
A field => value under if:/if_any: -> a list of ANDed terms (one, or two for a bounded range).
135 136 137 138 139 140 141 142 143 |
# File 'lib/phlex/reactive/show_conditions.rb', line 135 def positive_terms(field, value) name = field.to_s case value when Range then range_terms(name, value) when Array then [membership_term(name, value)] when Hash then length_terms(name, value) else [{ "field" => name, "equals" => equals_literal(value) }] end end |
.raise_length_kind(name, value) ⇒ Object
265 266 267 268 269 |
# File 'lib/phlex/reactive/show_conditions.rb', line 265 def raise_length_kind(name, value) raise ArgumentError, "reactive_show: #{name.inspect} length: takes a non-negative Integer " \ "or an Integer Range, got #{value.inspect}" end |
.range_complement(name, range) ⇒ Object
The complement of a range predicate. Endless ranges complement to a single opposite threshold (¬gte→lt, ¬lte→gt, ¬lt→gte) — itself a positive numeric predicate, so a blank/NaN field stays FALSE (fail-closed: the complement never flips a blank to visible). A bounded range complements to two alternatives.
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 |
# File 'lib/phlex/reactive/show_conditions.rb', line 297 def range_complement(name, range) first = range.begin last = range.end validate_range_endpoints!(name, first, last) low = first.nil? ? nil : [{ "field" => name, "lt" => first }] high = if last.nil? nil elsif range.exclude_end? [{ "field" => name, "gte" => last }] else [{ "field" => name, "gt" => last }] end [low, high].compact end |
.range_terms(name, range) ⇒ Object
A Range -> gte / lte / lt terms. Endless -> one term; bounded -> two (gte + lte/lt) in the SAME group. Endpoints must be numbers (a literal numeric threshold, never an expression).
161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/phlex/reactive/show_conditions.rb', line 161 def range_terms(name, range) first = range.begin last = range.end validate_range_endpoints!(name, first, last) terms = [] terms << { "field" => name, "gte" => first } unless first.nil? if last terms << { "field" => name, (range.exclude_end? ? "lt" : "lte") => last } end terms end |
.term_matches?(term, values) ⇒ Boolean
--- evaluation (mirrors the client) -----------------------------------
317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/phlex/reactive/show_conditions.rb', line 317 def term_matches?(term, values) field = term.fetch("field") value = values.fetch(field, "") if term.key?("equals") then value == term["equals"] elsif term.key?("not") then value != term["not"] elsif term.key?("in") then term["in"].include?(value) elsif (key = LENGTH_KEYS.find { term.key?(it) }) then length_term_matches?(key, term[key], value) else numeric_term_matches?(term, value) end end |
.validate_length_literal!(name, value) ⇒ Object
257 258 259 260 261 262 263 |
# File 'lib/phlex/reactive/show_conditions.rb', line 257 def validate_length_literal!(name, value) return if value.is_a?(Integer) && value >= 0 raise ArgumentError, "reactive_show: #{name.inspect} length: takes a non-negative Integer " \ "(a codepoint count), got #{value.inspect}" end |
.validate_range_endpoints!(name, first, last) ⇒ Object
174 175 176 177 178 179 180 181 182 |
# File 'lib/phlex/reactive/show_conditions.rb', line 174 def validate_range_endpoints!(name, first, last) [first, last].compact.each do next if it.is_a?(Numeric) raise ArgumentError, "reactive_show: #{name.inspect} Range endpoints must be numbers " \ "(an ordered threshold), got #{it.inspect}" end end |