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:
0264408
2026-09-18 11:09:50 +08:00
co-authored by Cursor
parent e177e9592a
commit 1f6e33c14b
27 changed files with 781 additions and 83 deletions
+4 -4
View File
@@ -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` | 可注册多个,提交后按顺序调用 |
+15 -2
View File
@@ -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
View File
@@ -9,7 +9,7 @@
- ANY/ALL 会签/或签
- JDBC 存储(PostgreSQL / MySQL)+ 按方言 Flyway 基线
- 定义不可变多版本:`publish`;实例锁定 `definitionVersion`
- 条件路由 `StepTransition` + `RoutingCondition`
- 条件路由 `StepTransition`:内置谓词 AST 或宿主 `RoutingCondition`(`ref` + `args`)
- ACTION + `ActionHandler`;执行记录持久化
- 发起人撤回 `WITHDRAWN`
- 分页查询:任务 / 实例 / 定义
+10 -11
View File
@@ -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));
}
}
@@ -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 }
]
@@ -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();
}
@@ -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) {
}
}
@@ -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;
@@ -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;
@@ -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;
}
@@ -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);
@@ -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);
}