feat: evaluate built-in routing predicates and parameterized host conditions
Store transition when as JSON instead of a lookup key so designers can compose closed-set logic while hosts still supply complex refs with args. Match conditional edges before the unconditional default. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import com.jetlumen.ordo.api.ProcessEventType;
|
||||
import com.jetlumen.ordo.api.ProcessInstance;
|
||||
import com.jetlumen.ordo.api.ProcessStatus;
|
||||
import com.jetlumen.ordo.api.RoutingCondition;
|
||||
import com.jetlumen.ordo.api.RoutingWhen;
|
||||
import com.jetlumen.ordo.api.StepDue;
|
||||
import com.jetlumen.ordo.api.StepKind;
|
||||
import com.jetlumen.ordo.api.StepTransition;
|
||||
@@ -570,20 +571,30 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
||||
}
|
||||
|
||||
private StepTransition resolveTransition(ProcessDefinition definition, ApprovalStep step, ProcessInstance instance) {
|
||||
List<StepTransition> candidates = definition.transitions().stream()
|
||||
List<StepTransition> outgoing = definition.transitions().stream()
|
||||
.filter(transition -> transition.fromStepId().equals(step.id()))
|
||||
.sorted(Comparator.comparingInt(StepTransition::priority))
|
||||
.toList();
|
||||
Comparator<StepTransition> byPriority = Comparator.comparingInt(StepTransition::priority);
|
||||
List<StepTransition> candidates = new ArrayList<>();
|
||||
outgoing.stream().filter(transition -> transition.when() != null).sorted(byPriority).forEach(candidates::add);
|
||||
outgoing.stream().filter(transition -> transition.when() == null).sorted(byPriority).forEach(candidates::add);
|
||||
for (StepTransition transition : candidates) {
|
||||
String conditionKey = transition.conditionKey();
|
||||
if (conditionKey == null || conditionKey.isBlank()
|
||||
|| routingCondition.matches(conditionKey, instance.context())) {
|
||||
RoutingWhen when = transition.when();
|
||||
if (when == null || matches(when, instance.context())) {
|
||||
return transition;
|
||||
}
|
||||
}
|
||||
throw new NoRouteFoundException(step.id(), instance.id());
|
||||
}
|
||||
|
||||
private boolean matches(RoutingWhen when, ProcessContext context) {
|
||||
if (when instanceof RoutingWhen.Predicate predicate) {
|
||||
return RoutingPredicateEvaluator.matches(predicate.tree(), context);
|
||||
}
|
||||
RoutingWhen.Ref ref = (RoutingWhen.Ref) when;
|
||||
return routingCondition.matches(ref.key(), ref.args(), context);
|
||||
}
|
||||
|
||||
/** Marks any still-pending sibling candidate tasks for the same step as skipped. */
|
||||
private void skipPendingSiblings(List<ApprovalTask> siblings, String decidedTaskId, String actor, Instant now,
|
||||
List<ProcessEvent> events) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.jetlumen.ordo.core;
|
||||
|
||||
import com.jetlumen.ordo.api.ProcessContext;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate.And;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate.Compare;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate.In;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate.Not;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate.Op;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate.Or;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Evaluates a {@link RoutingPredicate} against process context variables. */
|
||||
public final class RoutingPredicateEvaluator {
|
||||
private RoutingPredicateEvaluator() {
|
||||
}
|
||||
|
||||
public static boolean matches(RoutingPredicate predicate, ProcessContext context) {
|
||||
Objects.requireNonNull(predicate, "predicate must not be null");
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
return eval(predicate, context);
|
||||
}
|
||||
|
||||
private static boolean eval(RoutingPredicate predicate, ProcessContext context) {
|
||||
if (predicate instanceof Compare compare) {
|
||||
return compare(compare, context);
|
||||
}
|
||||
if (predicate instanceof In in) {
|
||||
Optional<Object> left = resolve(context, in.var());
|
||||
if (left.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (Object candidate : in.values()) {
|
||||
if (equalsLiteral(left.get(), candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (predicate instanceof And and) {
|
||||
for (RoutingPredicate arg : and.args()) {
|
||||
if (!eval(arg, context)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (predicate instanceof Or or) {
|
||||
for (RoutingPredicate arg : or.args()) {
|
||||
if (eval(arg, context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !eval(((Not) predicate).arg(), context);
|
||||
}
|
||||
|
||||
private static boolean compare(Compare compare, ProcessContext context) {
|
||||
Optional<Object> left = resolve(context, compare.var());
|
||||
if (left.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Object value = left.get();
|
||||
Op op = compare.op();
|
||||
if (op == Op.EQ) {
|
||||
return equalsLiteral(value, compare.value());
|
||||
}
|
||||
if (op == Op.NE) {
|
||||
return !equalsLiteral(value, compare.value());
|
||||
}
|
||||
if (!(value instanceof Number leftNumber) || !(compare.value() instanceof Number rightNumber)) {
|
||||
return false;
|
||||
}
|
||||
int cmp = Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue());
|
||||
return switch (op) {
|
||||
case GT -> cmp > 0;
|
||||
case GTE -> cmp >= 0;
|
||||
case LT -> cmp < 0;
|
||||
case LTE -> cmp <= 0;
|
||||
default -> throw new IllegalStateException("unexpected op: " + op);
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean equalsLiteral(Object left, Object right) {
|
||||
if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
|
||||
return Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue()) == 0;
|
||||
}
|
||||
return Objects.equals(left, right);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Optional<Object> resolve(ProcessContext context, String path) {
|
||||
Object current = context.variables();
|
||||
for (String part : path.split("\\.", -1)) {
|
||||
if (part.isEmpty() || !(current instanceof Map<?, ?> map)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!((Map<String, ?>) map).containsKey(part)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
current = map.get(part);
|
||||
}
|
||||
return Optional.ofNullable(current);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import com.jetlumen.ordo.api.ProcessEventType;
|
||||
import com.jetlumen.ordo.api.ProcessInstance;
|
||||
import com.jetlumen.ordo.api.ProcessStatus;
|
||||
import com.jetlumen.ordo.api.RoutingCondition;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate;
|
||||
import com.jetlumen.ordo.api.StepDue;
|
||||
import com.jetlumen.ordo.api.StepKind;
|
||||
import com.jetlumen.ordo.api.StepTransition;
|
||||
@@ -351,10 +352,11 @@ class InMemoryOrdoEngineTest {
|
||||
|
||||
@Test
|
||||
void resolvesAssigneesFromTheProcessContext() {
|
||||
InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((candidate, step, context) -> context.value(step.id())
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.orElse(candidate));
|
||||
InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((AssigneeResolver) (candidate, step, context) ->
|
||||
context.value(step.id())
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.orElse(candidate));
|
||||
contextAwareEngine.publish(ProcessDefinition.linear("leave", "Leave request", List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("hr", "HR approval", "henry")
|
||||
@@ -466,23 +468,13 @@ class InMemoryOrdoEngineTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesToDifferentNextStepsBasedOnConditionKey() {
|
||||
RoutingCondition routingCondition = (key, context) -> {
|
||||
if (!"amount-gt-1000".equals(key)) {
|
||||
return false;
|
||||
}
|
||||
return context.value("amount")
|
||||
.filter(Number.class::isInstance)
|
||||
.map(Number.class::cast)
|
||||
.map(amount -> amount.doubleValue() > 1000)
|
||||
.orElse(false);
|
||||
};
|
||||
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine(routingCondition);
|
||||
void routesToDifferentNextStepsBasedOnPredicate() {
|
||||
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine();
|
||||
List<ApprovalStep> steps = List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("director", "Director approval", "diana"));
|
||||
routingEngine.publish(new ProcessDefinition("expense", "Expense request", steps, List.of(
|
||||
StepTransition.when("manager", "director", "amount-gt-1000", 0),
|
||||
StepTransition.when("manager", "director", RoutingPredicate.gt("amount", 1000), 0),
|
||||
new StepTransition("manager", null, null, 1),
|
||||
StepTransition.end("director"))));
|
||||
|
||||
@@ -498,6 +490,53 @@ class InMemoryOrdoEngineTest {
|
||||
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void triesConditionalEdgesBeforeUnconditionalDefaultRegardlessOfPriority() {
|
||||
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine();
|
||||
List<ApprovalStep> steps = List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("director", "Director approval", "diana"));
|
||||
routingEngine.publish(new ProcessDefinition("expense", "Expense request", steps, List.of(
|
||||
new StepTransition("manager", "director", null, 0),
|
||||
StepTransition.when("manager", null, RoutingPredicate.lt("amount", 50_000), 1),
|
||||
StepTransition.end("director"))));
|
||||
|
||||
var low = routingEngine.start("expense", "bob", new ProcessContext(Map.of("amount", 1_000)));
|
||||
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(low.id()).get(0).id(), "maria");
|
||||
assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(low.id()).orElseThrow().status());
|
||||
|
||||
var high = routingEngine.start("expense", "alice", new ProcessContext(Map.of("amount", 80_000)));
|
||||
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).get(0).id(), "maria");
|
||||
assertEquals("director", routingEngine.findPendingTasksByInstanceId(high.id()).get(0).stepId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesUsingParameterizedHostCondition() {
|
||||
RoutingCondition routingCondition = (key, args, context) -> {
|
||||
if (!"amountGt".equals(key)) {
|
||||
return false;
|
||||
}
|
||||
Number threshold = (Number) args.get("threshold");
|
||||
return context.value("amount")
|
||||
.filter(Number.class::isInstance)
|
||||
.map(Number.class::cast)
|
||||
.map(amount -> amount.doubleValue() > threshold.doubleValue())
|
||||
.orElse(false);
|
||||
};
|
||||
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine(routingCondition);
|
||||
List<ApprovalStep> steps = List.of(
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("director", "Director approval", "diana"));
|
||||
routingEngine.publish(new ProcessDefinition("expense", "Expense request", steps, List.of(
|
||||
StepTransition.ref("manager", "director", "amountGt", Map.of("threshold", 1000), 0),
|
||||
new StepTransition("manager", null, null, 1),
|
||||
StepTransition.end("director"))));
|
||||
|
||||
var high = routingEngine.start("expense", "alice", new ProcessContext(Map.of("amount", 2500)));
|
||||
routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).get(0).id(), "maria");
|
||||
assertEquals("director", routingEngine.findPendingTasksByInstanceId(high.id()).get(0).stepId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runsActionStepsAfterApprovalThenCreatesTheNextApprovalTask() {
|
||||
List<String> executed = new java.util.ArrayList<>();
|
||||
@@ -559,10 +598,10 @@ class InMemoryOrdoEngineTest {
|
||||
|
||||
@Test
|
||||
void throwsWhenNoTransitionMatches() {
|
||||
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false);
|
||||
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((RoutingCondition) (key, args, context) -> false);
|
||||
routingEngine.publish(new ProcessDefinition("expense", "Expense request",
|
||||
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
|
||||
List.of(StepTransition.endWhen("manager", "never", 0))));
|
||||
List.of(StepTransition.endRef("manager", "never", Map.of(), 0))));
|
||||
|
||||
var instance = routingEngine.start("expense", "alice");
|
||||
ApprovalTask task = routingEngine.findPendingTasksByInstanceId(instance.id()).get(0);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.jetlumen.ordo.core;
|
||||
|
||||
import com.jetlumen.ordo.api.ProcessContext;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RoutingPredicateEvaluatorTest {
|
||||
|
||||
@Test
|
||||
void comparesNumbersAndMissingVars() {
|
||||
ProcessContext context = new ProcessContext(Map.of("days", 5, "type", "annual"));
|
||||
assertTrue(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("days", 3), context));
|
||||
assertFalse(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("days", 8), context));
|
||||
assertFalse(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("missing", 1), context));
|
||||
assertTrue(RoutingPredicateEvaluator.matches(
|
||||
RoutingPredicate.and(RoutingPredicate.gt("days", 3), RoutingPredicate.eq("type", "annual")), context));
|
||||
assertTrue(RoutingPredicateEvaluator.matches(
|
||||
RoutingPredicate.in("type", List.of("sick", "annual")), context));
|
||||
assertFalse(RoutingPredicateEvaluator.matches(RoutingPredicate.gt("type", 1), context));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user