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

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

Returns:

  • (Boolean)


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

Raises:

  • (ArgumentError)


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.

Raises:

  • (ArgumentError)


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).

Returns:

  • (Boolean)


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) -----------------------------------

Returns:

  • (Boolean)


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