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:
@@ -18,7 +18,7 @@
|
||||
|
||||
## 能力
|
||||
|
||||
- 线性或多步图:`StepTransition` + 可选 `RoutingCondition`
|
||||
- 线性或多步图:`StepTransition` + 内置谓词 / 宿主 `RoutingCondition`
|
||||
- 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人)
|
||||
- ACTION 步骤:事务提交后调用宿主 `ActionHandler`
|
||||
- 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED`
|
||||
@@ -72,7 +72,7 @@ ordo.approve(manager.id(), "maria", "ok");
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "notify-submitted", "to": "manager" },
|
||||
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
|
||||
{ "from": "manager", "to": "hr", "when": { "gt": ["days", 3] }, "priority": 0 },
|
||||
{ "from": "manager", "to": "notify-approved", "priority": 1 },
|
||||
{ "from": "hr", "to": "notify-approved" },
|
||||
{ "from": "notify-approved", "to": null }
|
||||
@@ -81,7 +81,7 @@ ordo.approve(manager.id(), "maria", "ok");
|
||||
```
|
||||
|
||||
- `kind` 默认 `APPROVAL`;ACTION 用 `"action"` 作为 handler 查找键。
|
||||
- 转移按 `priority` 升序匹配;`when` 为空或条件命中则走该边;`to: null` 表示结束。
|
||||
- 带 `when` 的边先按 `priority` 匹配,都未命中再走无条件边;`to: null` 表示结束。
|
||||
- 代码侧可用 `ProcessDefinitionParser.fromJson(...)`。
|
||||
- `replace` 会整体替换同 id 定义;存在 `RUNNING` 实例时拒绝替换。
|
||||
|
||||
@@ -111,7 +111,7 @@ ordo:
|
||||
| Bean | 默认 |
|
||||
|---|---|
|
||||
| `AssigneeResolver` | 候选人即办理人 |
|
||||
| `RoutingCondition` | 始终匹配(全局单例;宿主可按 key 分发) |
|
||||
| `RoutingCondition` | 始终匹配(`ref` 边;宿主可按 key+args 分发) |
|
||||
| `ActionHandler` | 空操作 |
|
||||
| `OrdoEventListener` | 可注册多个,提交后按顺序调用 |
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ paths:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
description: conditionKey catalog
|
||||
description: host condition catalog (ref + optional params)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -458,6 +458,18 @@ components:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
params:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [number, string, boolean]
|
||||
label:
|
||||
type: string
|
||||
ProcessDefinitionDocument:
|
||||
type: object
|
||||
required: [id, name, steps, transitions]
|
||||
@@ -485,6 +497,7 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
additionalProperties: true
|
||||
priority:
|
||||
type: integer
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
- ANY/ALL 会签/或签
|
||||
- JDBC 存储(PostgreSQL / MySQL)+ 按方言 Flyway 基线
|
||||
- 定义不可变多版本:`publish`;实例锁定 `definitionVersion`
|
||||
- 条件路由 `StepTransition` + `RoutingCondition`
|
||||
- 条件路由 `StepTransition`:内置谓词 AST 或宿主 `RoutingCondition`(`ref` + `args`)
|
||||
- ACTION + `ActionHandler`;执行记录持久化
|
||||
- 发起人撤回 `WITHDRAWN`
|
||||
- 分页查询:任务 / 实例 / 定义
|
||||
|
||||
+10
-11
@@ -76,7 +76,7 @@ ordo:
|
||||
| Bean | 默认 |
|
||||
|---|---|
|
||||
| `AssigneeResolver` | 候选人字符串即办理人 |
|
||||
| `RoutingCondition` | 始终匹配(无条件边) |
|
||||
| `RoutingCondition` | `ref` 边默认恒 true;无 `when` 的边不经过该 Bean |
|
||||
| `ActionHandler` | 空操作 |
|
||||
| `OrdoEventListener` | 可有多个,提交后按 Spring 顺序调用 |
|
||||
| `OrdoCatalog` | 空列表(仅 REST 打开时装配) |
|
||||
@@ -144,8 +144,8 @@ new ProcessDefinition("leave-request-routed", "Leave request",
|
||||
),
|
||||
List.of(
|
||||
StepTransition.always("notify-submitted", "manager"),
|
||||
StepTransition.when("manager", "hr", "days-gt-3", 0),
|
||||
StepTransition.when("manager", "notify-approved", null, 1),
|
||||
StepTransition.when("manager", "hr", RoutingPredicate.gt("days", 3), 0),
|
||||
new StepTransition("manager", "notify-approved", null, 1),
|
||||
StepTransition.always("hr", "notify-approved"),
|
||||
StepTransition.end("notify-approved")
|
||||
));
|
||||
@@ -192,7 +192,7 @@ new ProcessDefinition("leave-request-routed", "Leave request",
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "notify-submitted", "to": "manager" },
|
||||
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
|
||||
{ "from": "manager", "to": "hr", "when": { "gt": ["days", 3] }, "priority": 0 },
|
||||
{ "from": "manager", "to": "notify-approved", "priority": 1 },
|
||||
{ "from": "hr", "to": "notify-approved" },
|
||||
{ "from": "notify-approved", "to": null }
|
||||
@@ -209,7 +209,7 @@ new ProcessDefinition("leave-request-routed", "Leave request",
|
||||
| `candidates` / `policy` | 仅审批步。`policy` 默认 `ANY`。审批步至少一名候选人,禁止重复。 |
|
||||
| `action` | 仅 ACTION 步,对应 `ActionHandler.execute` 的 key。审批步禁止带 `action`。 |
|
||||
| `due` | 仅审批步。可选。`after` 为 ISO-8601 时长;`then` 为 `reassign` / `notify` / `goto`。进入该步时任务 `dueAt = now + after`。 |
|
||||
| `from` / `to` / `when` / `priority` | `to` 为 `null` 或 JSON `null` 表示结束。`when` 空则无条件。`priority` 默认 0,**越小越先匹配**。 |
|
||||
| `from` / `to` / `when` / `priority` | `to` 为 `null` 或 JSON `null` 表示结束。`when` 省略则无条件(else)。`priority` 默认 0,**仅在同类边之间越小越先匹配**(条件边一组,无条件边一组)。 |
|
||||
|
||||
## 4. 实例与任务
|
||||
|
||||
@@ -274,18 +274,17 @@ ordo.cancel(instance.id(), "admin", "政策变更");
|
||||
|
||||
## 6. 条件路由
|
||||
|
||||
离开当前步时,取出全部 `from = 当前步` 的边,按 `priority` 升序。第一条满足下列条件的边生效:
|
||||
离开当前步时,取出全部 `from = 当前步` 的边。**先**按 `priority` 升序匹配带 `when` 的边(谓词为真,或 `ref` 且 `routingCondition.matches` 为真);都未命中再按 `priority` 走无 `when` 的边(else)。无条件边不再与条件边抢数字顺序。
|
||||
|
||||
- `conditionKey` 为空,或
|
||||
- `routingCondition.matches(conditionKey, instance.context()) == true`
|
||||
`when` 必须是 JSON **对象**,不能再写字符串 key。谓词闭集:`eq/ne/gt/gte/lt/lte`、`in`、`and/or/not`(深度 ≤ 8、叶子 ≤ 32)。宿主复杂逻辑用 `ref` + `args`,例如 `{ "ref": "amountGt", "args": { "threshold": 50000 } }`。
|
||||
|
||||
没有匹配边:`NoRouteFoundException`,**整次 approve/start 事务回滚**(JDBC 下任务/实例都不会半更新)。
|
||||
|
||||
无条件边通常作为默认分支,`priority` 应大于带 `when` 的边。
|
||||
无条件边是默认分支;`priority` 只在同类边之间比较(多条条件边之间,或多条无条件边之间)。
|
||||
|
||||
`RoutingCondition` 只看到 `ProcessContext`,看不到任务意见,也**没有**流程定义 id。上下文在 `start` 时写入,运行中引擎**不会**改 context。
|
||||
`RoutingCondition` 只看到 `ProcessContext` 与 `args`,看不到任务意见,也没有流程定义 id。上下文在 `start` 时写入,运行中引擎不会改 context。未知 `ref` 由宿主返回 `false`,该边不匹配。
|
||||
|
||||
引擎只注入**一个** `RoutingCondition`(与 `ActionHandler` 相同)。Spring 下多个该类型 Bean 会冲突。宿主用一个门面按 `conditionKey` 分发到多套规则;不同流程靠 key 约定隔离(例如 `leave.days-gt-3`),不要指望引擎按定义拆 bean。
|
||||
引擎只注入**一个** `RoutingCondition`。Spring 下多个该类型 Bean 会冲突。宿主用一个门面按 `ref` 分发;不要指望引擎按定义拆 bean。
|
||||
|
||||
## 7. ACTION 步骤
|
||||
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Host-registered keys for designers and REST dropdowns; not used by the engine at runtime. */
|
||||
public interface OrdoCatalog {
|
||||
Set<String> PARAM_TYPES = Set.of("number", "string", "boolean");
|
||||
|
||||
List<Item> conditions();
|
||||
|
||||
List<Item> actions();
|
||||
|
||||
List<Item> assignees();
|
||||
|
||||
record Item(String key, String label) {
|
||||
record Item(String key, String label, List<Param> params) {
|
||||
public Item {
|
||||
Objects.requireNonNull(key, "key must not be null");
|
||||
Objects.requireNonNull(label, "label must not be null");
|
||||
params = params == null ? List.of() : List.copyOf(params);
|
||||
}
|
||||
|
||||
public Item(String key, String label) {
|
||||
this(key, label, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
record Param(String name, String type, String label) {
|
||||
public Param {
|
||||
ApprovalStep.requireText(name, "param name");
|
||||
ApprovalStep.requireText(type, "param type");
|
||||
ApprovalStep.requireText(label, "param label");
|
||||
type = type.strip().toLowerCase(Locale.ROOT);
|
||||
if (!PARAM_TYPES.contains(type)) {
|
||||
throw new IllegalArgumentException("param type must be number, string or boolean");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static OrdoCatalog empty() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.jetlumen.ordo.api;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -72,7 +73,8 @@ public final class ProcessDefinitionParser {
|
||||
throw new IllegalArgumentException("transition must not be null");
|
||||
}
|
||||
int priority = transition.priority() == null ? 0 : transition.priority();
|
||||
transitions.add(new StepTransition(transition.from(), transition.to(), transition.when(), priority));
|
||||
transitions.add(new StepTransition(transition.from(), transition.to(),
|
||||
RoutingWhen.parse(transition.when()), priority));
|
||||
}
|
||||
}
|
||||
return new ProcessDefinition(document.id(), document.name(), steps, transitions);
|
||||
@@ -93,7 +95,7 @@ public final class ProcessDefinitionParser {
|
||||
List<TransitionDocument> transitions = new ArrayList<>(definition.transitions().size());
|
||||
for (StepTransition transition : definition.transitions()) {
|
||||
transitions.add(new TransitionDocument(transition.fromStepId(), transition.toStepId(),
|
||||
transition.conditionKey(), transition.priority()));
|
||||
transition.when() == null ? null : transition.when().toTree(), transition.priority()));
|
||||
}
|
||||
return new DefinitionDocument(definition.id(), definition.name(), definition.version(),
|
||||
definition.steps().get(0).id(), steps, transitions);
|
||||
@@ -171,7 +173,7 @@ public final class ProcessDefinitionParser {
|
||||
private record TransitionDocument(
|
||||
@JsonProperty("from") String from,
|
||||
@JsonProperty("to") String to,
|
||||
@JsonProperty("when") String when,
|
||||
@JsonProperty("when") JsonNode when,
|
||||
Integer priority) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Evaluates a named routing condition against the running instance's context. Hosts supply a
|
||||
* singleton implementation (same pattern as {@link AssigneeResolver}); the database only stores
|
||||
* the {@code conditionKey} string.
|
||||
* Evaluates a named routing condition ({@code when.ref}) against the running instance's context.
|
||||
* Hosts supply a singleton implementation (same pattern as {@link AssigneeResolver}); the
|
||||
* definition stores the {@code ref} key and optional {@code args}.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RoutingCondition {
|
||||
boolean matches(String conditionKey, ProcessContext context);
|
||||
boolean matches(String conditionKey, Map<String, Object> args, ProcessContext context);
|
||||
|
||||
static RoutingCondition always() {
|
||||
return (key, context) -> true;
|
||||
return (key, args, context) -> true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Closed-set boolean tree evaluated against {@link ProcessContext} variables. */
|
||||
public sealed interface RoutingPredicate permits RoutingPredicate.Compare, RoutingPredicate.In,
|
||||
RoutingPredicate.And, RoutingPredicate.Or, RoutingPredicate.Not {
|
||||
|
||||
int MAX_DEPTH = 8;
|
||||
int MAX_LEAVES = 32;
|
||||
|
||||
enum Op {
|
||||
EQ, NE, GT, GTE, LT, LTE
|
||||
}
|
||||
|
||||
record Compare(Op op, String var, Object value) implements RoutingPredicate {
|
||||
public Compare {
|
||||
Objects.requireNonNull(op, "op must not be null");
|
||||
ApprovalStep.requireText(var, "var");
|
||||
value = requireLiteral(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
record In(String var, List<Object> values) implements RoutingPredicate {
|
||||
public In {
|
||||
ApprovalStep.requireText(var, "var");
|
||||
Objects.requireNonNull(values, "values must not be null");
|
||||
if (values.isEmpty()) {
|
||||
throw new IllegalArgumentException("in values must not be empty");
|
||||
}
|
||||
List<Object> copied = new ArrayList<>(values.size());
|
||||
for (Object value : values) {
|
||||
copied.add(requireLiteral(value, "in value"));
|
||||
}
|
||||
values = List.copyOf(copied);
|
||||
}
|
||||
}
|
||||
|
||||
record And(List<RoutingPredicate> args) implements RoutingPredicate {
|
||||
public And {
|
||||
args = copyPredicates(args, "and");
|
||||
}
|
||||
}
|
||||
|
||||
record Or(List<RoutingPredicate> args) implements RoutingPredicate {
|
||||
public Or {
|
||||
args = copyPredicates(args, "or");
|
||||
}
|
||||
}
|
||||
|
||||
record Not(RoutingPredicate arg) implements RoutingPredicate {
|
||||
public Not {
|
||||
Objects.requireNonNull(arg, "not arg must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
static Compare eq(String var, Object value) {
|
||||
return new Compare(Op.EQ, var, value);
|
||||
}
|
||||
|
||||
static Compare ne(String var, Object value) {
|
||||
return new Compare(Op.NE, var, value);
|
||||
}
|
||||
|
||||
static Compare gt(String var, Object value) {
|
||||
return new Compare(Op.GT, var, value);
|
||||
}
|
||||
|
||||
static Compare gte(String var, Object value) {
|
||||
return new Compare(Op.GTE, var, value);
|
||||
}
|
||||
|
||||
static Compare lt(String var, Object value) {
|
||||
return new Compare(Op.LT, var, value);
|
||||
}
|
||||
|
||||
static Compare lte(String var, Object value) {
|
||||
return new Compare(Op.LTE, var, value);
|
||||
}
|
||||
|
||||
static In in(String var, List<Object> values) {
|
||||
return new In(var, values);
|
||||
}
|
||||
|
||||
static And and(RoutingPredicate... args) {
|
||||
return new And(List.of(args));
|
||||
}
|
||||
|
||||
static Or or(RoutingPredicate... args) {
|
||||
return new Or(List.of(args));
|
||||
}
|
||||
|
||||
static Not not(RoutingPredicate arg) {
|
||||
return new Not(arg);
|
||||
}
|
||||
|
||||
static RoutingPredicate parse(JsonNode node) {
|
||||
int[] leaves = {0};
|
||||
return parse(node, 0, leaves);
|
||||
}
|
||||
|
||||
static RoutingPredicate parse(JsonNode node, int depth, int[] leaves) {
|
||||
if (node == null || !node.isObject()) {
|
||||
throw new IllegalArgumentException("predicate must be a JSON object");
|
||||
}
|
||||
if (depth > MAX_DEPTH) {
|
||||
throw new IllegalArgumentException("predicate depth must not exceed " + MAX_DEPTH);
|
||||
}
|
||||
if (node.size() != 1) {
|
||||
throw new IllegalArgumentException("predicate must contain exactly one operator");
|
||||
}
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
String op = field.getKey();
|
||||
JsonNode value = field.getValue();
|
||||
return switch (op) {
|
||||
case "eq" -> compare(Op.EQ, value, leaves);
|
||||
case "ne" -> compare(Op.NE, value, leaves);
|
||||
case "gt" -> compare(Op.GT, value, leaves);
|
||||
case "gte" -> compare(Op.GTE, value, leaves);
|
||||
case "lt" -> compare(Op.LT, value, leaves);
|
||||
case "lte" -> compare(Op.LTE, value, leaves);
|
||||
case "in" -> inOp(value, leaves);
|
||||
case "and" -> new And(parseList(value, depth, leaves));
|
||||
case "or" -> new Or(parseList(value, depth, leaves));
|
||||
case "not" -> new Not(parse(value, depth + 1, leaves));
|
||||
default -> throw new IllegalArgumentException("unknown predicate operator: " + op);
|
||||
};
|
||||
}
|
||||
|
||||
default JsonNode toTree() {
|
||||
return toTree(JsonNodeFactory.instance);
|
||||
}
|
||||
|
||||
default JsonNode toTree(JsonNodeFactory factory) {
|
||||
ObjectNode object = factory.objectNode();
|
||||
if (this instanceof Compare compare) {
|
||||
ArrayNode pair = object.putArray(compare.op().name().toLowerCase(Locale.ROOT));
|
||||
pair.add(compare.var());
|
||||
addLiteral(pair, compare.value());
|
||||
return object;
|
||||
}
|
||||
if (this instanceof In in) {
|
||||
ArrayNode pair = object.putArray("in");
|
||||
pair.add(in.var());
|
||||
ArrayNode values = pair.addArray();
|
||||
for (Object value : in.values()) {
|
||||
addLiteral(values, value);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
if (this instanceof And and) {
|
||||
ArrayNode args = object.putArray("and");
|
||||
for (RoutingPredicate arg : and.args()) {
|
||||
args.add(arg.toTree(factory));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
if (this instanceof Or or) {
|
||||
ArrayNode args = object.putArray("or");
|
||||
for (RoutingPredicate arg : or.args()) {
|
||||
args.add(arg.toTree(factory));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
Not not = (Not) this;
|
||||
object.set("not", not.arg().toTree(factory));
|
||||
return object;
|
||||
}
|
||||
|
||||
private static Compare compare(Op op, JsonNode value, int[] leaves) {
|
||||
bumpLeaf(leaves);
|
||||
if (value == null || !value.isArray() || value.size() != 2) {
|
||||
throw new IllegalArgumentException(op.name().toLowerCase(Locale.ROOT) + " must be [var, literal]");
|
||||
}
|
||||
return new Compare(op, requireVar(value.get(0)), literal(value.get(1)));
|
||||
}
|
||||
|
||||
private static In inOp(JsonNode value, int[] leaves) {
|
||||
bumpLeaf(leaves);
|
||||
if (value == null || !value.isArray() || value.size() != 2 || !value.get(1).isArray()) {
|
||||
throw new IllegalArgumentException("in must be [var, [literals...]]");
|
||||
}
|
||||
List<Object> values = new ArrayList<>();
|
||||
for (JsonNode item : value.get(1)) {
|
||||
values.add(literal(item));
|
||||
}
|
||||
return new In(requireVar(value.get(0)), values);
|
||||
}
|
||||
|
||||
private static List<RoutingPredicate> parseList(JsonNode value, int depth, int[] leaves) {
|
||||
if (value == null || !value.isArray() || value.isEmpty()) {
|
||||
throw new IllegalArgumentException("logical operator requires a non-empty array");
|
||||
}
|
||||
List<RoutingPredicate> args = new ArrayList<>(value.size());
|
||||
for (JsonNode child : value) {
|
||||
args.add(parse(child, depth + 1, leaves));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
private static void bumpLeaf(int[] leaves) {
|
||||
leaves[0]++;
|
||||
if (leaves[0] > MAX_LEAVES) {
|
||||
throw new IllegalArgumentException("predicate leaves must not exceed " + MAX_LEAVES);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireVar(JsonNode node) {
|
||||
if (node == null || !node.isTextual()) {
|
||||
throw new IllegalArgumentException("variable path must be a string");
|
||||
}
|
||||
return node.textValue();
|
||||
}
|
||||
|
||||
private static Object literal(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
throw new IllegalArgumentException("literal must not be null");
|
||||
}
|
||||
if (node.isBoolean()) {
|
||||
return node.booleanValue();
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return node.textValue();
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return node.numberValue();
|
||||
}
|
||||
throw new IllegalArgumentException("literal must be string, number or boolean");
|
||||
}
|
||||
|
||||
private static Object requireLiteral(Object value, String name) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException(name + " must not be null");
|
||||
}
|
||||
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
|
||||
return value;
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be string, number or boolean");
|
||||
}
|
||||
|
||||
private static List<RoutingPredicate> copyPredicates(List<RoutingPredicate> args, String name) {
|
||||
Objects.requireNonNull(args, name + " args must not be null");
|
||||
if (args.isEmpty()) {
|
||||
throw new IllegalArgumentException(name + " args must not be empty");
|
||||
}
|
||||
for (RoutingPredicate arg : args) {
|
||||
Objects.requireNonNull(arg, name + " arg must not be null");
|
||||
}
|
||||
return List.copyOf(args);
|
||||
}
|
||||
|
||||
private static void addLiteral(ArrayNode array, Object value) {
|
||||
if (value instanceof String text) {
|
||||
array.add(text);
|
||||
} else if (value instanceof Boolean bool) {
|
||||
array.add(bool);
|
||||
} else if (value instanceof Integer number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Long number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Double number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Float number) {
|
||||
array.add(number);
|
||||
} else if (value instanceof Number number) {
|
||||
array.add(number.doubleValue());
|
||||
} else {
|
||||
throw new IllegalArgumentException("unsupported literal: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Outgoing-edge condition: either a built-in {@link RoutingPredicate} or a host
|
||||
* {@link RoutingCondition} reference with arguments.
|
||||
*/
|
||||
public sealed interface RoutingWhen permits RoutingWhen.Predicate, RoutingWhen.Ref {
|
||||
ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
record Predicate(RoutingPredicate tree) implements RoutingWhen {
|
||||
public Predicate {
|
||||
Objects.requireNonNull(tree, "predicate must not be null");
|
||||
}
|
||||
}
|
||||
|
||||
record Ref(String key, Map<String, Object> args) implements RoutingWhen {
|
||||
public Ref {
|
||||
ApprovalStep.requireText(key, "ref");
|
||||
args = Map.copyOf(args == null ? Map.of() : args);
|
||||
}
|
||||
}
|
||||
|
||||
static Predicate predicate(RoutingPredicate tree) {
|
||||
return new Predicate(tree);
|
||||
}
|
||||
|
||||
static Ref ref(String key) {
|
||||
return new Ref(key, Map.of());
|
||||
}
|
||||
|
||||
static Ref ref(String key, Map<String, Object> args) {
|
||||
return new Ref(key, args);
|
||||
}
|
||||
|
||||
static RoutingWhen parse(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isTextual() && node.textValue().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (!node.isObject()) {
|
||||
throw new IllegalArgumentException("when must be a JSON object");
|
||||
}
|
||||
if (node.has("ref")) {
|
||||
if (node.size() > 2 || (node.size() == 2 && !node.has("args"))) {
|
||||
throw new IllegalArgumentException("ref when may only contain ref and optional args");
|
||||
}
|
||||
JsonNode refNode = node.get("ref");
|
||||
if (refNode == null || !refNode.isTextual()) {
|
||||
throw new IllegalArgumentException("ref must be a string");
|
||||
}
|
||||
Map<String, Object> args = Map.of();
|
||||
if (node.has("args")) {
|
||||
JsonNode argsNode = node.get("args");
|
||||
if (argsNode == null || argsNode.isNull()) {
|
||||
args = Map.of();
|
||||
} else if (!argsNode.isObject()) {
|
||||
throw new IllegalArgumentException("args must be a JSON object");
|
||||
} else {
|
||||
args = MAPPER.convertValue(argsNode, new TypeReference<>() {
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Ref(refNode.textValue(), args);
|
||||
}
|
||||
return new Predicate(RoutingPredicate.parse(node));
|
||||
}
|
||||
|
||||
static RoutingWhen parseJson(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parse(MAPPER.readTree(json));
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("invalid when json", e);
|
||||
}
|
||||
}
|
||||
|
||||
default JsonNode toTree() {
|
||||
if (this instanceof Predicate predicate) {
|
||||
return predicate.tree().toTree();
|
||||
}
|
||||
Ref ref = (Ref) this;
|
||||
ObjectNode object = JsonNodeFactory.instance.objectNode();
|
||||
object.put("ref", ref.key());
|
||||
if (!ref.args().isEmpty()) {
|
||||
object.set("args", MAPPER.valueToTree(ref.args()));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
default String toJson() {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(toTree());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("failed to serialize when", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ package com.jetlumen.ordo.api;
|
||||
|
||||
/**
|
||||
* An outgoing edge from an approval step. When {@link #toStepId()} is {@code null}, matching this
|
||||
* transition terminates the instance as approved. A blank {@link #conditionKey()} is unconditional
|
||||
* transition terminates the instance as approved. A null {@link #when()} is unconditional
|
||||
* (always matches) and is typically used as the else/default branch.
|
||||
*/
|
||||
public record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority) {
|
||||
public record StepTransition(String fromStepId, String toStepId, RoutingWhen when, int priority) {
|
||||
public StepTransition {
|
||||
ApprovalStep.requireText(fromStepId, "fromStepId");
|
||||
if (toStepId != null && toStepId.isBlank()) {
|
||||
@@ -17,15 +17,32 @@ public record StepTransition(String fromStepId, String toStepId, String conditio
|
||||
return new StepTransition(fromStepId, toStepId, null, 0);
|
||||
}
|
||||
|
||||
public static StepTransition when(String fromStepId, String toStepId, String conditionKey, int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, conditionKey, priority);
|
||||
public static StepTransition when(String fromStepId, String toStepId, RoutingWhen when, int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, when, priority);
|
||||
}
|
||||
|
||||
public static StepTransition when(String fromStepId, String toStepId, RoutingPredicate predicate, int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, RoutingWhen.predicate(predicate), priority);
|
||||
}
|
||||
|
||||
public static StepTransition ref(String fromStepId, String toStepId, String key, java.util.Map<String, Object> args,
|
||||
int priority) {
|
||||
return new StepTransition(fromStepId, toStepId, RoutingWhen.ref(key, args), priority);
|
||||
}
|
||||
|
||||
public static StepTransition end(String fromStepId) {
|
||||
return new StepTransition(fromStepId, null, null, 0);
|
||||
}
|
||||
|
||||
public static StepTransition endWhen(String fromStepId, String conditionKey, int priority) {
|
||||
return new StepTransition(fromStepId, null, conditionKey, priority);
|
||||
public static StepTransition endWhen(String fromStepId, RoutingWhen when, int priority) {
|
||||
return new StepTransition(fromStepId, null, when, priority);
|
||||
}
|
||||
|
||||
public static StepTransition endWhen(String fromStepId, RoutingPredicate predicate, int priority) {
|
||||
return new StepTransition(fromStepId, null, RoutingWhen.predicate(predicate), priority);
|
||||
}
|
||||
|
||||
public static StepTransition endRef(String fromStepId, String key, java.util.Map<String, Object> args, int priority) {
|
||||
return new StepTransition(fromStepId, null, RoutingWhen.ref(key, args), priority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class ProcessDefinitionParserTest {
|
||||
{ "id": "hr", "name": "HR approval", "candidates": ["henry"], "policy": "ANY" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
|
||||
{ "from": "manager", "to": "hr", "when": { "gt": ["days", 3] }, "priority": 0 },
|
||||
{ "from": "manager", "to": null, "priority": 1 },
|
||||
{ "from": "hr", "to": null }
|
||||
]
|
||||
@@ -33,7 +33,7 @@ class ProcessDefinitionParserTest {
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("hr", "HR approval", "henry")
|
||||
), List.of(
|
||||
StepTransition.when("manager", "hr", "days-gt-3", 0),
|
||||
StepTransition.when("manager", "hr", RoutingPredicate.gt("days", 3), 0),
|
||||
new StepTransition("manager", null, null, 1),
|
||||
StepTransition.end("hr")
|
||||
));
|
||||
@@ -76,7 +76,7 @@ class ProcessDefinitionParserTest {
|
||||
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
|
||||
{ "from": "manager", "to": "hr", "when": { "gt": ["days", 3] }, "priority": 0 },
|
||||
{ "from": "manager", "to": null, "priority": 1 },
|
||||
{ "from": "hr", "to": null }
|
||||
]
|
||||
@@ -165,8 +165,8 @@ class ProcessDefinitionParserTest {
|
||||
{ "id": "hr", "name": "HR approval", "candidates": ["henry"] }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "hr", "when": "high", "priority": 1 },
|
||||
{ "from": "manager", "to": null, "when": "low", "priority": 1 },
|
||||
{ "from": "manager", "to": "hr", "when": { "ref": "high" }, "priority": 1 },
|
||||
{ "from": "manager", "to": null, "when": { "ref": "low" }, "priority": 1 },
|
||||
{ "from": "hr", "to": null }
|
||||
]
|
||||
}
|
||||
@@ -179,6 +179,23 @@ class ProcessDefinitionParserTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStringWhen() {
|
||||
String json = """
|
||||
{
|
||||
"id": "leave",
|
||||
"name": "Leave request",
|
||||
"steps": [
|
||||
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": null, "when": "days-gt-3" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson(json));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesActionStepsAndDefaultsKindToApproval() {
|
||||
String json = """
|
||||
|
||||
@@ -81,8 +81,8 @@ class ProcessDefinitionTest {
|
||||
ApprovalStep.single("hr", "HR approval", "henry"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
||||
List.of(
|
||||
StepTransition.when("manager", "hr", "high", 1),
|
||||
StepTransition.endWhen("manager", "low", 1))));
|
||||
StepTransition.when("manager", "hr", RoutingWhen.ref("high"), 1),
|
||||
StepTransition.endWhen("manager", RoutingWhen.ref("low"), 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class RoutingWhenTest {
|
||||
|
||||
@Test
|
||||
void parsesPredicateAndRef() {
|
||||
RoutingWhen predicate = RoutingWhen.parseJson("{\"gt\":[\"days\",3]}");
|
||||
assertInstanceOf(RoutingWhen.Predicate.class, predicate);
|
||||
RoutingWhen ref = RoutingWhen.parseJson("{\"ref\":\"amountGt\",\"args\":{\"threshold\":50000}}");
|
||||
RoutingWhen.Ref parsed = assertInstanceOf(RoutingWhen.Ref.class, ref);
|
||||
assertEquals("amountGt", parsed.key());
|
||||
assertEquals(50000, ((Number) parsed.args().get("threshold")).intValue());
|
||||
assertEquals(predicate, RoutingWhen.parseJson(predicate.toJson()));
|
||||
assertEquals(ref, RoutingWhen.parseJson(ref.toJson()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMixedRefAndOperator() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> RoutingWhen.parseJson("{\"ref\":\"x\",\"gt\":[\"days\",1]}"));
|
||||
}
|
||||
}
|
||||
@@ -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,7 +352,8 @@ class InMemoryOrdoEngineTest {
|
||||
|
||||
@Test
|
||||
void resolvesAssigneesFromTheProcessContext() {
|
||||
InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((candidate, step, context) -> context.value(step.id())
|
||||
InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((AssigneeResolver) (candidate, step, context) ->
|
||||
context.value(step.id())
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.orElse(candidate));
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -153,7 +153,7 @@ class OrdoJdbcAutoConfigurationTest {
|
||||
withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(OrdoEngine.class);
|
||||
assertThat(context.getBean(RoutingCondition.class).matches("any", null)).isTrue();
|
||||
assertThat(context.getBean(RoutingCondition.class).matches("any", java.util.Map.of(), null)).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ class OrdoJdbcAutoConfigurationTest {
|
||||
static class CustomRoutingConditionConfig {
|
||||
@Bean
|
||||
RoutingCondition ordoRoutingCondition() {
|
||||
return (key, context) -> true;
|
||||
return (key, args, context) -> true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{ "id": "hr", "name": "HR approval", "candidates": ["henry"], "policy": "ANY" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "manager", "to": "hr", "when": "days-gt-3", "priority": 0 },
|
||||
{ "from": "manager", "to": "hr", "when": { "gt": ["days", 3] }, "priority": 0 },
|
||||
{ "from": "manager", "to": null, "priority": 1 },
|
||||
{ "from": "hr", "to": null }
|
||||
]
|
||||
|
||||
+4
-4
@@ -45,7 +45,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
||||
+ " VALUES (?, ?, ?, ?, ?)";
|
||||
private static final String INSERT_TRANSITION =
|
||||
"INSERT INTO ordo_step_transition (definition_id, definition_version, from_step_id, to_step_id,"
|
||||
+ " condition_key, priority) VALUES (?, ?, ?, ?, ?, ?)";
|
||||
+ " condition_json, priority) VALUES (?, ?, ?, ?, ?, ?)";
|
||||
private static final String SELECT_DEFINITION =
|
||||
"SELECT name FROM ordo_process_definition WHERE id = ? AND version = ?";
|
||||
private static final String SELECT_STEPS =
|
||||
@@ -56,7 +56,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
||||
"SELECT step_id, candidate FROM ordo_step_candidate"
|
||||
+ " WHERE definition_id = ? AND definition_version = ? ORDER BY step_id, candidate_order";
|
||||
private static final String SELECT_TRANSITIONS =
|
||||
"SELECT from_step_id, to_step_id, condition_key, priority FROM ordo_step_transition "
|
||||
"SELECT from_step_id, to_step_id, condition_json, priority FROM ordo_step_transition "
|
||||
+ "WHERE definition_id = ? AND definition_version = ? ORDER BY from_step_id, priority";
|
||||
private static final String SELECT_LATEST_PAGE_BASE =
|
||||
"SELECT p.id, p.current_version, d.name FROM ordo_process p"
|
||||
@@ -260,7 +260,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
||||
try (ResultSet resultSet = selectTransitions.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
TransitionRow row = StepTransitionMapper.readRow(resultSet);
|
||||
transitions.add(new StepTransition(row.fromStepId(), row.toStepId(), row.conditionKey(),
|
||||
transitions.add(new StepTransition(row.fromStepId(), row.toStepId(), row.when(),
|
||||
row.priority()));
|
||||
}
|
||||
}
|
||||
@@ -377,7 +377,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
||||
insertTransition.setInt(2, definition.version());
|
||||
insertTransition.setString(3, transition.fromStepId());
|
||||
insertTransition.setString(4, transition.toStepId());
|
||||
insertTransition.setString(5, transition.conditionKey());
|
||||
insertTransition.setString(5, transition.when() == null ? null : transition.when().toJson());
|
||||
insertTransition.setInt(6, transition.priority());
|
||||
insertTransition.executeUpdate();
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||
|
||||
import com.jetlumen.ordo.api.RoutingWhen;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
@@ -12,10 +14,10 @@ public final class StepTransitionMapper {
|
||||
return new TransitionRow(
|
||||
resultSet.getString("from_step_id"),
|
||||
resultSet.getString("to_step_id"),
|
||||
resultSet.getString("condition_key"),
|
||||
RoutingWhen.parseJson(resultSet.getString("condition_json")),
|
||||
resultSet.getInt("priority"));
|
||||
}
|
||||
|
||||
public record TransitionRow(String fromStepId, String toStepId, String conditionKey, int priority) {
|
||||
public record TransitionRow(String fromStepId, String toStepId, RoutingWhen when, int priority) {
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- Replace opaque condition_key with the full when JSON object.
|
||||
|
||||
ALTER TABLE ordo_step_transition ADD COLUMN condition_json TEXT;
|
||||
ALTER TABLE ordo_step_transition DROP COLUMN condition_key;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- Replace opaque condition_key with the full when JSON object.
|
||||
|
||||
ALTER TABLE ordo_step_transition ADD COLUMN condition_json TEXT;
|
||||
ALTER TABLE ordo_step_transition DROP COLUMN condition_key;
|
||||
+1
-1
@@ -76,7 +76,7 @@ class JdbcMysqlIntegrationTest {
|
||||
}
|
||||
|
||||
MysqlDataSource schemaDataSource = newDataSource(DATABASE);
|
||||
JdbcTestSupport.applySchema(schemaDataSource, JdbcTestSupport.MYSQL_BASELINE);
|
||||
JdbcTestSupport.applySchema(schemaDataSource, JdbcTestSupport.MYSQL_BASELINE, JdbcTestSupport.MYSQL_V2);
|
||||
dataSource = schemaDataSource;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -183,7 +183,7 @@ class JdbcOrdoEngineIntegrationTest {
|
||||
@Test
|
||||
void rollsBackTheWholeApprovalWhenNoRouteMatches() {
|
||||
OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
AssigneeResolver.direct(), (key, context) -> false, ActionHandler.noop(),
|
||||
AssigneeResolver.direct(), (key, args, context) -> false, ActionHandler.noop(),
|
||||
new JdbcTransactionExecutor(connectionProvider),
|
||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||
new JdbcProcessInstanceRepository(connectionProvider),
|
||||
@@ -193,7 +193,7 @@ class JdbcOrdoEngineIntegrationTest {
|
||||
List.of());
|
||||
failingEngine.publish(new ProcessDefinition("leave-noroute", "Leave request",
|
||||
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
|
||||
List.of(StepTransition.endWhen("manager", "never", 0))));
|
||||
List.of(StepTransition.endRef("manager", "never", java.util.Map.of(), 0))));
|
||||
|
||||
ProcessInstance instance = failingEngine.start("leave-noroute", "alice");
|
||||
ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).get(0);
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import com.jetlumen.ordo.api.ApprovalStep;
|
||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||
import com.jetlumen.ordo.api.StepDue;
|
||||
import com.jetlumen.ordo.api.StepKind;
|
||||
import com.jetlumen.ordo.api.RoutingPredicate;
|
||||
import com.jetlumen.ordo.api.StepTransition;
|
||||
import com.jetlumen.ordo.api.query.Page;
|
||||
import com.jetlumen.ordo.api.query.PageRequest;
|
||||
@@ -73,7 +74,7 @@ class JdbcProcessDefinitionRepositoryTest {
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("director", "Director approval", "diana")),
|
||||
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")));
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import java.util.UUID;
|
||||
/** Creates isolated in-memory H2 databases with the Ordo schema applied. */
|
||||
final class JdbcTestSupport {
|
||||
static final String POSTGRES_BASELINE = "/db/postgresql/migration/V1__baseline.sql";
|
||||
static final String POSTGRES_V2 = "/db/postgresql/migration/V2__transition_condition_json.sql";
|
||||
static final String MYSQL_BASELINE = "/db/mysql/migration/V1__baseline.sql";
|
||||
static final String MYSQL_V2 = "/db/mysql/migration/V2__transition_condition_json.sql";
|
||||
|
||||
private JdbcTestSupport() {
|
||||
}
|
||||
@@ -29,17 +31,19 @@ final class JdbcTestSupport {
|
||||
}
|
||||
|
||||
static void applySchema(DataSource dataSource) {
|
||||
applySchema(dataSource, POSTGRES_BASELINE);
|
||||
applySchema(dataSource, POSTGRES_BASELINE, POSTGRES_V2);
|
||||
}
|
||||
|
||||
static void applySchema(DataSource dataSource, String resourcePath) {
|
||||
String migration = loadSchema(resourcePath);
|
||||
static void applySchema(DataSource dataSource, String... resourcePaths) {
|
||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
||||
for (String resourcePath : resourcePaths) {
|
||||
String migration = loadSchema(resourcePath);
|
||||
for (String sql : stripComments(migration).split(";")) {
|
||||
if (!sql.isBlank()) {
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to apply the Ordo schema", e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user