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`(多候选人)
|
- 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人)
|
||||||
- ACTION 步骤:事务提交后调用宿主 `ActionHandler`
|
- ACTION 步骤:事务提交后调用宿主 `ActionHandler`
|
||||||
- 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED`
|
- 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED`
|
||||||
@@ -72,7 +72,7 @@ ordo.approve(manager.id(), "maria", "ok");
|
|||||||
],
|
],
|
||||||
"transitions": [
|
"transitions": [
|
||||||
{ "from": "notify-submitted", "to": "manager" },
|
{ "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": "manager", "to": "notify-approved", "priority": 1 },
|
||||||
{ "from": "hr", "to": "notify-approved" },
|
{ "from": "hr", "to": "notify-approved" },
|
||||||
{ "from": "notify-approved", "to": null }
|
{ "from": "notify-approved", "to": null }
|
||||||
@@ -81,7 +81,7 @@ ordo.approve(manager.id(), "maria", "ok");
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `kind` 默认 `APPROVAL`;ACTION 用 `"action"` 作为 handler 查找键。
|
- `kind` 默认 `APPROVAL`;ACTION 用 `"action"` 作为 handler 查找键。
|
||||||
- 转移按 `priority` 升序匹配;`when` 为空或条件命中则走该边;`to: null` 表示结束。
|
- 带 `when` 的边先按 `priority` 匹配,都未命中再走无条件边;`to: null` 表示结束。
|
||||||
- 代码侧可用 `ProcessDefinitionParser.fromJson(...)`。
|
- 代码侧可用 `ProcessDefinitionParser.fromJson(...)`。
|
||||||
- `replace` 会整体替换同 id 定义;存在 `RUNNING` 实例时拒绝替换。
|
- `replace` 会整体替换同 id 定义;存在 `RUNNING` 实例时拒绝替换。
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ ordo:
|
|||||||
| Bean | 默认 |
|
| Bean | 默认 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `AssigneeResolver` | 候选人即办理人 |
|
| `AssigneeResolver` | 候选人即办理人 |
|
||||||
| `RoutingCondition` | 始终匹配(全局单例;宿主可按 key 分发) |
|
| `RoutingCondition` | 始终匹配(`ref` 边;宿主可按 key+args 分发) |
|
||||||
| `ActionHandler` | 空操作 |
|
| `ActionHandler` | 空操作 |
|
||||||
| `OrdoEventListener` | 可注册多个,提交后按顺序调用 |
|
| `OrdoEventListener` | 可注册多个,提交后按顺序调用 |
|
||||||
|
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: conditionKey catalog
|
description: host condition catalog (ref + optional params)
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -458,6 +458,18 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
label:
|
label:
|
||||||
type: string
|
type: string
|
||||||
|
params:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [number, string, boolean]
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
ProcessDefinitionDocument:
|
ProcessDefinitionDocument:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, steps, transitions]
|
required: [id, name, steps, transitions]
|
||||||
@@ -485,6 +497,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
when:
|
when:
|
||||||
type: string
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
priority:
|
priority:
|
||||||
type: integer
|
type: integer
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
- ANY/ALL 会签/或签
|
- ANY/ALL 会签/或签
|
||||||
- JDBC 存储(PostgreSQL / MySQL)+ 按方言 Flyway 基线
|
- JDBC 存储(PostgreSQL / MySQL)+ 按方言 Flyway 基线
|
||||||
- 定义不可变多版本:`publish`;实例锁定 `definitionVersion`
|
- 定义不可变多版本:`publish`;实例锁定 `definitionVersion`
|
||||||
- 条件路由 `StepTransition` + `RoutingCondition`
|
- 条件路由 `StepTransition`:内置谓词 AST 或宿主 `RoutingCondition`(`ref` + `args`)
|
||||||
- ACTION + `ActionHandler`;执行记录持久化
|
- ACTION + `ActionHandler`;执行记录持久化
|
||||||
- 发起人撤回 `WITHDRAWN`
|
- 发起人撤回 `WITHDRAWN`
|
||||||
- 分页查询:任务 / 实例 / 定义
|
- 分页查询:任务 / 实例 / 定义
|
||||||
|
|||||||
+10
-11
@@ -76,7 +76,7 @@ ordo:
|
|||||||
| Bean | 默认 |
|
| Bean | 默认 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `AssigneeResolver` | 候选人字符串即办理人 |
|
| `AssigneeResolver` | 候选人字符串即办理人 |
|
||||||
| `RoutingCondition` | 始终匹配(无条件边) |
|
| `RoutingCondition` | `ref` 边默认恒 true;无 `when` 的边不经过该 Bean |
|
||||||
| `ActionHandler` | 空操作 |
|
| `ActionHandler` | 空操作 |
|
||||||
| `OrdoEventListener` | 可有多个,提交后按 Spring 顺序调用 |
|
| `OrdoEventListener` | 可有多个,提交后按 Spring 顺序调用 |
|
||||||
| `OrdoCatalog` | 空列表(仅 REST 打开时装配) |
|
| `OrdoCatalog` | 空列表(仅 REST 打开时装配) |
|
||||||
@@ -144,8 +144,8 @@ new ProcessDefinition("leave-request-routed", "Leave request",
|
|||||||
),
|
),
|
||||||
List.of(
|
List.of(
|
||||||
StepTransition.always("notify-submitted", "manager"),
|
StepTransition.always("notify-submitted", "manager"),
|
||||||
StepTransition.when("manager", "hr", "days-gt-3", 0),
|
StepTransition.when("manager", "hr", RoutingPredicate.gt("days", 3), 0),
|
||||||
StepTransition.when("manager", "notify-approved", null, 1),
|
new StepTransition("manager", "notify-approved", null, 1),
|
||||||
StepTransition.always("hr", "notify-approved"),
|
StepTransition.always("hr", "notify-approved"),
|
||||||
StepTransition.end("notify-approved")
|
StepTransition.end("notify-approved")
|
||||||
));
|
));
|
||||||
@@ -192,7 +192,7 @@ new ProcessDefinition("leave-request-routed", "Leave request",
|
|||||||
],
|
],
|
||||||
"transitions": [
|
"transitions": [
|
||||||
{ "from": "notify-submitted", "to": "manager" },
|
{ "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": "manager", "to": "notify-approved", "priority": 1 },
|
||||||
{ "from": "hr", "to": "notify-approved" },
|
{ "from": "hr", "to": "notify-approved" },
|
||||||
{ "from": "notify-approved", "to": null }
|
{ "from": "notify-approved", "to": null }
|
||||||
@@ -209,7 +209,7 @@ new ProcessDefinition("leave-request-routed", "Leave request",
|
|||||||
| `candidates` / `policy` | 仅审批步。`policy` 默认 `ANY`。审批步至少一名候选人,禁止重复。 |
|
| `candidates` / `policy` | 仅审批步。`policy` 默认 `ANY`。审批步至少一名候选人,禁止重复。 |
|
||||||
| `action` | 仅 ACTION 步,对应 `ActionHandler.execute` 的 key。审批步禁止带 `action`。 |
|
| `action` | 仅 ACTION 步,对应 `ActionHandler.execute` 的 key。审批步禁止带 `action`。 |
|
||||||
| `due` | 仅审批步。可选。`after` 为 ISO-8601 时长;`then` 为 `reassign` / `notify` / `goto`。进入该步时任务 `dueAt = now + after`。 |
|
| `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. 实例与任务
|
## 4. 实例与任务
|
||||||
|
|
||||||
@@ -274,18 +274,17 @@ ordo.cancel(instance.id(), "admin", "政策变更");
|
|||||||
|
|
||||||
## 6. 条件路由
|
## 6. 条件路由
|
||||||
|
|
||||||
离开当前步时,取出全部 `from = 当前步` 的边,按 `priority` 升序。第一条满足下列条件的边生效:
|
离开当前步时,取出全部 `from = 当前步` 的边。**先**按 `priority` 升序匹配带 `when` 的边(谓词为真,或 `ref` 且 `routingCondition.matches` 为真);都未命中再按 `priority` 走无 `when` 的边(else)。无条件边不再与条件边抢数字顺序。
|
||||||
|
|
||||||
- `conditionKey` 为空,或
|
`when` 必须是 JSON **对象**,不能再写字符串 key。谓词闭集:`eq/ne/gt/gte/lt/lte`、`in`、`and/or/not`(深度 ≤ 8、叶子 ≤ 32)。宿主复杂逻辑用 `ref` + `args`,例如 `{ "ref": "amountGt", "args": { "threshold": 50000 } }`。
|
||||||
- `routingCondition.matches(conditionKey, instance.context()) == true`
|
|
||||||
|
|
||||||
没有匹配边:`NoRouteFoundException`,**整次 approve/start 事务回滚**(JDBC 下任务/实例都不会半更新)。
|
没有匹配边:`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 步骤
|
## 7. ACTION 步骤
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,42 @@
|
|||||||
package com.jetlumen.ordo.api;
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
import java.util.List;
|
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. */
|
/** Host-registered keys for designers and REST dropdowns; not used by the engine at runtime. */
|
||||||
public interface OrdoCatalog {
|
public interface OrdoCatalog {
|
||||||
|
Set<String> PARAM_TYPES = Set.of("number", "string", "boolean");
|
||||||
|
|
||||||
List<Item> conditions();
|
List<Item> conditions();
|
||||||
|
|
||||||
List<Item> actions();
|
List<Item> actions();
|
||||||
|
|
||||||
List<Item> assignees();
|
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() {
|
static OrdoCatalog empty() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.jetlumen.ordo.api;
|
|||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -72,7 +73,8 @@ public final class ProcessDefinitionParser {
|
|||||||
throw new IllegalArgumentException("transition must not be null");
|
throw new IllegalArgumentException("transition must not be null");
|
||||||
}
|
}
|
||||||
int priority = transition.priority() == null ? 0 : transition.priority();
|
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);
|
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());
|
List<TransitionDocument> transitions = new ArrayList<>(definition.transitions().size());
|
||||||
for (StepTransition transition : definition.transitions()) {
|
for (StepTransition transition : definition.transitions()) {
|
||||||
transitions.add(new TransitionDocument(transition.fromStepId(), transition.toStepId(),
|
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(),
|
return new DefinitionDocument(definition.id(), definition.name(), definition.version(),
|
||||||
definition.steps().get(0).id(), steps, transitions);
|
definition.steps().get(0).id(), steps, transitions);
|
||||||
@@ -171,7 +173,7 @@ public final class ProcessDefinitionParser {
|
|||||||
private record TransitionDocument(
|
private record TransitionDocument(
|
||||||
@JsonProperty("from") String from,
|
@JsonProperty("from") String from,
|
||||||
@JsonProperty("to") String to,
|
@JsonProperty("to") String to,
|
||||||
@JsonProperty("when") String when,
|
@JsonProperty("when") JsonNode when,
|
||||||
Integer priority) {
|
Integer priority) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
package com.jetlumen.ordo.api;
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluates a named routing condition against the running instance's context. Hosts supply a
|
* Evaluates a named routing condition ({@code when.ref}) against the running instance's context.
|
||||||
* singleton implementation (same pattern as {@link AssigneeResolver}); the database only stores
|
* Hosts supply a singleton implementation (same pattern as {@link AssigneeResolver}); the
|
||||||
* the {@code conditionKey} string.
|
* definition stores the {@code ref} key and optional {@code args}.
|
||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface RoutingCondition {
|
public interface RoutingCondition {
|
||||||
boolean matches(String conditionKey, ProcessContext context);
|
boolean matches(String conditionKey, Map<String, Object> args, ProcessContext context);
|
||||||
|
|
||||||
static RoutingCondition always() {
|
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
|
* 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.
|
* (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 {
|
public StepTransition {
|
||||||
ApprovalStep.requireText(fromStepId, "fromStepId");
|
ApprovalStep.requireText(fromStepId, "fromStepId");
|
||||||
if (toStepId != null && toStepId.isBlank()) {
|
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);
|
return new StepTransition(fromStepId, toStepId, null, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StepTransition when(String fromStepId, String toStepId, String conditionKey, int priority) {
|
public static StepTransition when(String fromStepId, String toStepId, RoutingWhen when, int priority) {
|
||||||
return new StepTransition(fromStepId, toStepId, conditionKey, 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) {
|
public static StepTransition end(String fromStepId) {
|
||||||
return new StepTransition(fromStepId, null, null, 0);
|
return new StepTransition(fromStepId, null, null, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StepTransition endWhen(String fromStepId, String conditionKey, int priority) {
|
public static StepTransition endWhen(String fromStepId, RoutingWhen when, int priority) {
|
||||||
return new StepTransition(fromStepId, null, conditionKey, 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" }
|
{ "id": "hr", "name": "HR approval", "candidates": ["henry"], "policy": "ANY" }
|
||||||
],
|
],
|
||||||
"transitions": [
|
"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": "manager", "to": null, "priority": 1 },
|
||||||
{ "from": "hr", "to": null }
|
{ "from": "hr", "to": null }
|
||||||
]
|
]
|
||||||
@@ -33,7 +33,7 @@ class ProcessDefinitionParserTest {
|
|||||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
ApprovalStep.single("hr", "HR approval", "henry")
|
ApprovalStep.single("hr", "HR approval", "henry")
|
||||||
), List.of(
|
), 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),
|
new StepTransition("manager", null, null, 1),
|
||||||
StepTransition.end("hr")
|
StepTransition.end("hr")
|
||||||
));
|
));
|
||||||
@@ -76,7 +76,7 @@ class ProcessDefinitionParserTest {
|
|||||||
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
|
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] }
|
||||||
],
|
],
|
||||||
"transitions": [
|
"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": "manager", "to": null, "priority": 1 },
|
||||||
{ "from": "hr", "to": null }
|
{ "from": "hr", "to": null }
|
||||||
]
|
]
|
||||||
@@ -165,8 +165,8 @@ class ProcessDefinitionParserTest {
|
|||||||
{ "id": "hr", "name": "HR approval", "candidates": ["henry"] }
|
{ "id": "hr", "name": "HR approval", "candidates": ["henry"] }
|
||||||
],
|
],
|
||||||
"transitions": [
|
"transitions": [
|
||||||
{ "from": "manager", "to": "hr", "when": "high", "priority": 1 },
|
{ "from": "manager", "to": "hr", "when": { "ref": "high" }, "priority": 1 },
|
||||||
{ "from": "manager", "to": null, "when": "low", "priority": 1 },
|
{ "from": "manager", "to": null, "when": { "ref": "low" }, "priority": 1 },
|
||||||
{ "from": "hr", "to": null }
|
{ "from": "hr", "to": null }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -179,6 +179,23 @@ class ProcessDefinitionParserTest {
|
|||||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
|
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
|
@Test
|
||||||
void parsesActionStepsAndDefaultsKindToApproval() {
|
void parsesActionStepsAndDefaultsKindToApproval() {
|
||||||
String json = """
|
String json = """
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ class ProcessDefinitionTest {
|
|||||||
ApprovalStep.single("hr", "HR approval", "henry"));
|
ApprovalStep.single("hr", "HR approval", "henry"));
|
||||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps,
|
||||||
List.of(
|
List.of(
|
||||||
StepTransition.when("manager", "hr", "high", 1),
|
StepTransition.when("manager", "hr", RoutingWhen.ref("high"), 1),
|
||||||
StepTransition.endWhen("manager", "low", 1))));
|
StepTransition.endWhen("manager", RoutingWhen.ref("low"), 1))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.ProcessStatus;
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
|
import com.jetlumen.ordo.api.RoutingWhen;
|
||||||
import com.jetlumen.ordo.api.StepDue;
|
import com.jetlumen.ordo.api.StepDue;
|
||||||
import com.jetlumen.ordo.api.StepKind;
|
import com.jetlumen.ordo.api.StepKind;
|
||||||
import com.jetlumen.ordo.api.StepTransition;
|
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) {
|
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()))
|
.filter(transition -> transition.fromStepId().equals(step.id()))
|
||||||
.sorted(Comparator.comparingInt(StepTransition::priority))
|
|
||||||
.toList();
|
.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) {
|
for (StepTransition transition : candidates) {
|
||||||
String conditionKey = transition.conditionKey();
|
RoutingWhen when = transition.when();
|
||||||
if (conditionKey == null || conditionKey.isBlank()
|
if (when == null || matches(when, instance.context())) {
|
||||||
|| routingCondition.matches(conditionKey, instance.context())) {
|
|
||||||
return transition;
|
return transition;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new NoRouteFoundException(step.id(), instance.id());
|
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. */
|
/** 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,
|
private void skipPendingSiblings(List<ApprovalTask> siblings, String decidedTaskId, String actor, Instant now,
|
||||||
List<ProcessEvent> events) {
|
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.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.ProcessStatus;
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
|
import com.jetlumen.ordo.api.RoutingPredicate;
|
||||||
import com.jetlumen.ordo.api.StepDue;
|
import com.jetlumen.ordo.api.StepDue;
|
||||||
import com.jetlumen.ordo.api.StepKind;
|
import com.jetlumen.ordo.api.StepKind;
|
||||||
import com.jetlumen.ordo.api.StepTransition;
|
import com.jetlumen.ordo.api.StepTransition;
|
||||||
@@ -351,7 +352,8 @@ class InMemoryOrdoEngineTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolvesAssigneesFromTheProcessContext() {
|
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)
|
.filter(String.class::isInstance)
|
||||||
.map(String.class::cast)
|
.map(String.class::cast)
|
||||||
.orElse(candidate));
|
.orElse(candidate));
|
||||||
@@ -466,23 +468,13 @@ class InMemoryOrdoEngineTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void routesToDifferentNextStepsBasedOnConditionKey() {
|
void routesToDifferentNextStepsBasedOnPredicate() {
|
||||||
RoutingCondition routingCondition = (key, context) -> {
|
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine();
|
||||||
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);
|
|
||||||
List<ApprovalStep> steps = List.of(
|
List<ApprovalStep> steps = List.of(
|
||||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
ApprovalStep.single("director", "Director approval", "diana"));
|
ApprovalStep.single("director", "Director approval", "diana"));
|
||||||
routingEngine.publish(new ProcessDefinition("expense", "Expense request", steps, List.of(
|
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),
|
new StepTransition("manager", null, null, 1),
|
||||||
StepTransition.end("director"))));
|
StepTransition.end("director"))));
|
||||||
|
|
||||||
@@ -498,6 +490,53 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
|
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
|
@Test
|
||||||
void runsActionStepsAfterApprovalThenCreatesTheNextApprovalTask() {
|
void runsActionStepsAfterApprovalThenCreatesTheNextApprovalTask() {
|
||||||
List<String> executed = new java.util.ArrayList<>();
|
List<String> executed = new java.util.ArrayList<>();
|
||||||
@@ -559,10 +598,10 @@ class InMemoryOrdoEngineTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void throwsWhenNoTransitionMatches() {
|
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",
|
routingEngine.publish(new ProcessDefinition("expense", "Expense request",
|
||||||
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
|
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");
|
var instance = routingEngine.start("expense", "alice");
|
||||||
ApprovalTask task = routingEngine.findPendingTasksByInstanceId(instance.id()).get(0);
|
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)
|
withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class)
|
||||||
.run(context -> {
|
.run(context -> {
|
||||||
assertThat(context).hasSingleBean(OrdoEngine.class);
|
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 {
|
static class CustomRoutingConditionConfig {
|
||||||
@Bean
|
@Bean
|
||||||
RoutingCondition ordoRoutingCondition() {
|
RoutingCondition ordoRoutingCondition() {
|
||||||
return (key, context) -> true;
|
return (key, args, context) -> true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{ "id": "hr", "name": "HR approval", "candidates": ["henry"], "policy": "ANY" }
|
{ "id": "hr", "name": "HR approval", "candidates": ["henry"], "policy": "ANY" }
|
||||||
],
|
],
|
||||||
"transitions": [
|
"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": "manager", "to": null, "priority": 1 },
|
||||||
{ "from": "hr", "to": null }
|
{ "from": "hr", "to": null }
|
||||||
]
|
]
|
||||||
|
|||||||
+4
-4
@@ -45,7 +45,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
+ " VALUES (?, ?, ?, ?, ?)";
|
+ " VALUES (?, ?, ?, ?, ?)";
|
||||||
private static final String INSERT_TRANSITION =
|
private static final String INSERT_TRANSITION =
|
||||||
"INSERT INTO ordo_step_transition (definition_id, definition_version, from_step_id, to_step_id,"
|
"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 =
|
private static final String SELECT_DEFINITION =
|
||||||
"SELECT name FROM ordo_process_definition WHERE id = ? AND version = ?";
|
"SELECT name FROM ordo_process_definition WHERE id = ? AND version = ?";
|
||||||
private static final String SELECT_STEPS =
|
private static final String SELECT_STEPS =
|
||||||
@@ -56,7 +56,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
"SELECT step_id, candidate FROM ordo_step_candidate"
|
"SELECT step_id, candidate FROM ordo_step_candidate"
|
||||||
+ " WHERE definition_id = ? AND definition_version = ? ORDER BY step_id, candidate_order";
|
+ " WHERE definition_id = ? AND definition_version = ? ORDER BY step_id, candidate_order";
|
||||||
private static final String SELECT_TRANSITIONS =
|
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";
|
+ "WHERE definition_id = ? AND definition_version = ? ORDER BY from_step_id, priority";
|
||||||
private static final String SELECT_LATEST_PAGE_BASE =
|
private static final String SELECT_LATEST_PAGE_BASE =
|
||||||
"SELECT p.id, p.current_version, d.name FROM ordo_process p"
|
"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()) {
|
try (ResultSet resultSet = selectTransitions.executeQuery()) {
|
||||||
while (resultSet.next()) {
|
while (resultSet.next()) {
|
||||||
TransitionRow row = StepTransitionMapper.readRow(resultSet);
|
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()));
|
row.priority()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
insertTransition.setInt(2, definition.version());
|
insertTransition.setInt(2, definition.version());
|
||||||
insertTransition.setString(3, transition.fromStepId());
|
insertTransition.setString(3, transition.fromStepId());
|
||||||
insertTransition.setString(4, transition.toStepId());
|
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.setInt(6, transition.priority());
|
||||||
insertTransition.executeUpdate();
|
insertTransition.executeUpdate();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.RoutingWhen;
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
|
||||||
@@ -12,10 +14,10 @@ public final class StepTransitionMapper {
|
|||||||
return new TransitionRow(
|
return new TransitionRow(
|
||||||
resultSet.getString("from_step_id"),
|
resultSet.getString("from_step_id"),
|
||||||
resultSet.getString("to_step_id"),
|
resultSet.getString("to_step_id"),
|
||||||
resultSet.getString("condition_key"),
|
RoutingWhen.parseJson(resultSet.getString("condition_json")),
|
||||||
resultSet.getInt("priority"));
|
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);
|
MysqlDataSource schemaDataSource = newDataSource(DATABASE);
|
||||||
JdbcTestSupport.applySchema(schemaDataSource, JdbcTestSupport.MYSQL_BASELINE);
|
JdbcTestSupport.applySchema(schemaDataSource, JdbcTestSupport.MYSQL_BASELINE, JdbcTestSupport.MYSQL_V2);
|
||||||
dataSource = schemaDataSource;
|
dataSource = schemaDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -183,7 +183,7 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void rollsBackTheWholeApprovalWhenNoRouteMatches() {
|
void rollsBackTheWholeApprovalWhenNoRouteMatches() {
|
||||||
OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC),
|
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 JdbcTransactionExecutor(connectionProvider),
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
new JdbcProcessInstanceRepository(connectionProvider),
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
@@ -193,7 +193,7 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
List.of());
|
List.of());
|
||||||
failingEngine.publish(new ProcessDefinition("leave-noroute", "Leave request",
|
failingEngine.publish(new ProcessDefinition("leave-noroute", "Leave request",
|
||||||
List.of(ApprovalStep.single("manager", "Manager approval", "maria")),
|
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");
|
ProcessInstance instance = failingEngine.start("leave-noroute", "alice");
|
||||||
ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).get(0);
|
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.ProcessDefinition;
|
||||||
import com.jetlumen.ordo.api.StepDue;
|
import com.jetlumen.ordo.api.StepDue;
|
||||||
import com.jetlumen.ordo.api.StepKind;
|
import com.jetlumen.ordo.api.StepKind;
|
||||||
|
import com.jetlumen.ordo.api.RoutingPredicate;
|
||||||
import com.jetlumen.ordo.api.StepTransition;
|
import com.jetlumen.ordo.api.StepTransition;
|
||||||
import com.jetlumen.ordo.api.query.Page;
|
import com.jetlumen.ordo.api.query.Page;
|
||||||
import com.jetlumen.ordo.api.query.PageRequest;
|
import com.jetlumen.ordo.api.query.PageRequest;
|
||||||
@@ -73,7 +74,7 @@ class JdbcProcessDefinitionRepositoryTest {
|
|||||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
ApprovalStep.single("director", "Director approval", "diana")),
|
ApprovalStep.single("director", "Director approval", "diana")),
|
||||||
List.of(
|
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),
|
new StepTransition("manager", null, null, 1),
|
||||||
StepTransition.end("director")));
|
StepTransition.end("director")));
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import java.util.UUID;
|
|||||||
/** Creates isolated in-memory H2 databases with the Ordo schema applied. */
|
/** Creates isolated in-memory H2 databases with the Ordo schema applied. */
|
||||||
final class JdbcTestSupport {
|
final class JdbcTestSupport {
|
||||||
static final String POSTGRES_BASELINE = "/db/postgresql/migration/V1__baseline.sql";
|
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_BASELINE = "/db/mysql/migration/V1__baseline.sql";
|
||||||
|
static final String MYSQL_V2 = "/db/mysql/migration/V2__transition_condition_json.sql";
|
||||||
|
|
||||||
private JdbcTestSupport() {
|
private JdbcTestSupport() {
|
||||||
}
|
}
|
||||||
@@ -29,17 +31,19 @@ final class JdbcTestSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void applySchema(DataSource dataSource) {
|
static void applySchema(DataSource dataSource) {
|
||||||
applySchema(dataSource, POSTGRES_BASELINE);
|
applySchema(dataSource, POSTGRES_BASELINE, POSTGRES_V2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void applySchema(DataSource dataSource, String resourcePath) {
|
static void applySchema(DataSource dataSource, String... resourcePaths) {
|
||||||
String migration = loadSchema(resourcePath);
|
|
||||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
||||||
|
for (String resourcePath : resourcePaths) {
|
||||||
|
String migration = loadSchema(resourcePath);
|
||||||
for (String sql : stripComments(migration).split(";")) {
|
for (String sql : stripComments(migration).split(";")) {
|
||||||
if (!sql.isBlank()) {
|
if (!sql.isBlank()) {
|
||||||
statement.execute(sql);
|
statement.execute(sql);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new JdbcStorageException("failed to apply the Ordo schema", e);
|
throw new JdbcStorageException("failed to apply the Ordo schema", e);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user