02.04.03 — .otherwise(...) and _any_of#
How Ocarina expresses a logical OR between two predicates without breaking error aggregation.
The problem#
Concrete case: you want “age == 18 OR age <= 65.” The ROP/invariants syntax is a strict chain of assertions, and every .assert_that() is an AND. How do you express an 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() # replace the last 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()has to follow anassert_that(). With no prior predicate it raises. (In practice the type-checker already rules it out —ValidationStartBlock.otherwisedoesn’t exist.) - Combination: combine previous predicate + all accumulated
otherwisepredicates + the new one, via_any_of. Result: a composite predicate. - Step replacement: pop the last step from
_ValidationChainand push the combined one. Otherwise the original predicate AND the combined one would both be present, and the original error would resurface.
_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 # ✅ one predicate passes → all pass
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: the moment one predicate passes,
combinedreturnsNone. - All fail: build an aggregated message listing every failure, separated by
|. - Result is a
_PredicateWithMsg: plug it back into the chain like any other predicate. - Aggregation is recursive: 3 successive
otherwise()calls fold(P_orig OR P_otherwise1 OR P_otherwise2 OR P_otherwise3)into one predicate.
Example#
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()- If
age == 18:is_equal_to(18)passes → OK. - If
age == 30:is_equal_to(18)raises,is_less_than_or_equal_to(65)passes → OK. - If
age == 70: both raise →InvariantViolationErrorwith:
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(The duplicated message comes from passing the same msg= twice. Intentional here — we want a single statement of intent.)
For a cleaner error (one statement of the contract that actually diagnoses what broke), split the msg=:
validate(age, name="age")
.assert_that(is_equal_to(18), msg="Must be exactly 18")
.otherwise(is_less_than_or_equal_to(65), msg="Can be 65 or younger")
.execute()
.raise_if_invalid()age == 70:
age: All predicates failed for value 70.
» At least one of the following conditions must be satisfied:
| age: Must be exactly 18
| age: Can be 65 or youngerMulti-otherwise example#
validate(age, name="age")
.assert_that(is_equal_to(18), msg="Must be 18")
.otherwise(is_equal_to(21), msg="Or must be 21")
.otherwise(is_equal_to(65), msg="Or must be 65")
.execute()age == 30:
age: All predicates failed for value 30.
» At least one of the following conditions must be satisfied:
| age: Must be 18
| age: Or must be 21
| age: Or must be 65On the CLI side#
"profile_path": field(
validate=lambda chain: chain.assert_that(is_none, msg="Maybe you could omit --profile-path")
.otherwise(is_dir, msg="--profile-path should be a path to a directory")
),Reads as: profile_path is valid if it’s None, or if it points to a directory.
Related type tests#
tests/dsl/invariants/test_types.yml:
- case: otherwise_with_same_type
description: .otherwise() should accept predicates of the same type
main: |
validate(5).assert_that(is_positive).otherwise(is_not_zero)
- case: multiple_otherwise_preserves_type
description: Multiple .otherwise() calls should preserve the same type throughout
main: |
validate(5) \
.assert_that(is_equal_to(10)) \
.otherwise(is_equal_to(20)) \
.otherwise(is_positive) \
.otherwise(is_not_zero)The type holds throughout. Mixing a Predicate[int] with a Predicate[str] in the same chain won’t compile.