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)
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.
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.
-
.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).
-
.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_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.
109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/phlex/reactive/show_conditions.rb', line 109 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.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/phlex/reactive/show_conditions.rb', line 75 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
135 136 137 138 139 |
# File 'lib/phlex/reactive/show_conditions.rb', line 135 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).
65 66 67 |
# File 'lib/phlex/reactive/show_conditions.rb', line 65 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.
91 92 93 94 95 96 97 98 |
# File 'lib/phlex/reactive/show_conditions.rb', line 91 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 |
.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
59 60 61 |
# File 'lib/phlex/reactive/show_conditions.rb', line 59 def match?(groups, values) groups.any? { |group| group.all? { |term| term_matches?(term, values) } } end |
.membership_term(name, array) ⇒ Object
141 142 143 144 145 146 |
# File 'lib/phlex/reactive/show_conditions.rb', line 141 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).
181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/phlex/reactive/show_conditions.rb', line 181 def negative_alternatives(field, value) name = field.to_s case value when Range then range_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.
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/phlex/reactive/show_conditions.rb', line 38 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).
233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/phlex/reactive/show_conditions.rb', line 233 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).
126 127 128 129 130 131 132 133 |
# File 'lib/phlex/reactive/show_conditions.rb', line 126 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)] else [{ "field" => name, "equals" => equals_literal(value) }] end 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.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/phlex/reactive/show_conditions.rb', line 199 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).
151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/phlex/reactive/show_conditions.rb', line 151 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) -----------------------------------
219 220 221 222 223 224 225 226 227 228 |
# File 'lib/phlex/reactive/show_conditions.rb', line 219 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) else numeric_term_matches?(term, value) end end |
.validate_range_endpoints!(name, first, last) ⇒ Object
164 165 166 167 168 169 170 171 172 |
# File 'lib/phlex/reactive/show_conditions.rb', line 164 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 |