02.04.03 — .otherwise(...) et _any_of#
Comment Ocarina exprime un OR logique entre deux prédicats sans casser l’agrégation d’erreurs.
Le problème#
Cas concret : on veut « age == 18 OR age <= 65 ». La syntaxe ROP/invariants est strictement chaîne d’assertions ; chaque .assert_that() est un AND. Comment exprimer un OR ?
Code#
def otherwise(self, fallback: Predicate[T], *, msg: str | None = None) -> ValidationAssertBlock[T]:
if self._last_predicate is None:
raise RuntimeError("otherwise() must follow assert_that().")
fallback_with_msg = _with_msg(fallback, msg, self._name)
combined = _any_of(
self._last_predicate, *([*self._otherwise_predicates, fallback_with_msg])
)
self._chain._steps.pop() # remplace le dernier step
self._chain.add_assertion(self._value, combined, self._name)
self._last_predicate = combined
self._otherwise_predicates.append(fallback_with_msg)
return self
- Guard.
otherwise() doit suivre un assert_that(). Sans prédicat précédent, on lève. (En pratique, le type-checker l’interdit déjà : ValidationStartBlock.otherwise n’existe pas.) - Combinaison. On combine le prédicat précédent + tous les
otherwise déjà accumulés + le nouveau, via _any_of. Le résultat est un prédicat composite. - Remplacement du step. On pop le dernier step du
_ValidationChain et on push le step combiné. Sans ça, on aurait à la fois le prédicat original ET le combiné, donc l’erreur originale resurgirait.
_any_of#
def _any_of[T](*predicates: _PredicateWithMsg[T]) -> _PredicateWithMsg[T]:
def _try_predicate(p: _PredicateWithMsg[T], value: T) -> InvariantViolationError | None:
try:
p(value)
except InvariantViolationError as exc:
return exc
else:
return None
def combined(value: T) -> None:
errors = []
for p in predicates:
error = _try_predicate(p, value)
if error is None:
return # ✅ un prédicat passe → tout passe
errors.append(error)
formatted_error_messages = " | " + "\n | ".join(str(e) for e in errors)
msg = (
f"All predicates failed for value {value!r}.\n"
"» At least one of the following conditions must be satisfied:\n"
f"{formatted_error_messages}"
)
raise InvariantViolationError(msg)
return _PredicateWithMsg(combined)
- First success wins : dès qu’un prédicat passe,
combined retourne None (succès). - Si tous échouent : on construit un message agrégé listant toutes les raisons d’échec, séparées par
| - Le résultat est un
_PredicateWithMsg : on peut le rebrancher dans la chaîne comme n’importe quel autre prédicat. - L’agrégation est récursive : si on a 3
otherwise() successifs, le 3e combine (P_orig OR P_otherwise1 OR P_otherwise2 OR P_otherwise3) en un seul prédicat.
Exemple#
validate(age, name="age")
.assert_that(is_equal_to(18), msg="Must be 18 or at most 65")
.otherwise(is_less_than_or_equal_to(65), msg="Must be 18 or at most 65")
.execute()
.raise_if_invalid()
- Si
age == 18 : is_equal_to(18) passe → OK. - Si
age == 30 : is_equal_to(18) lève, is_less_than_or_equal_to(65) passe → OK. - Si
age == 70 : les deux lèvent → InvariantViolationError avec :
age: All predicates failed for value 70.
» At least one of the following conditions must be satisfied:
| age: Must be 18 or at most 65
| age: Must be 18 or at most 65
(Le doublon de message vient du fait qu’on a passé le même msg= aux deux ; c’est volontaire ici, on veut exprimer une intention unique.)