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

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.

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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

Raises:

  • (ArgumentError)


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.

Raises:

  • (ArgumentError)


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

Returns:

  • (Boolean)


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

Raises:

  • (ArgumentError)


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

Returns:

  • (Boolean)


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

Raises:

  • (ArgumentError)


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