From 1f6e33c14beaf9fae1500c843d4ac127922f1dd6 Mon Sep 17 00:00:00 2001 From: 0264408 Date: Fri, 18 Sep 2026 11:09:50 +0800 Subject: [PATCH] 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 --- README.md | 8 +- docs/ordo-rest.openapi.yaml | 17 +- docs/roadmap.md | 2 +- docs/usage.md | 21 +- .../com/jetlumen/ordo/api/OrdoCatalog.java | 28 +- .../ordo/api/ProcessDefinitionParser.java | 8 +- .../jetlumen/ordo/api/RoutingCondition.java | 12 +- .../jetlumen/ordo/api/RoutingPredicate.java | 282 ++++++++++++++++++ .../com/jetlumen/ordo/api/RoutingWhen.java | 111 +++++++ .../com/jetlumen/ordo/api/StepTransition.java | 29 +- .../ordo/api/ProcessDefinitionParserTest.java | 27 +- .../ordo/api/ProcessDefinitionTest.java | 4 +- .../jetlumen/ordo/api/RoutingWhenTest.java | 28 ++ .../jetlumen/ordo/core/DefaultOrdoEngine.java | 21 +- .../ordo/core/RoutingPredicateEvaluator.java | 109 +++++++ .../ordo/core/InMemoryOrdoEngineTest.java | 77 +++-- .../core/RoutingPredicateEvaluatorTest.java | 27 ++ .../jdbc/OrdoJdbcAutoConfigurationTest.java | 4 +- .../resources/ordo/leave-request-routed.json | 2 +- .../jdbc/JdbcProcessDefinitionRepository.java | 8 +- .../jdbc/mapper/StepTransitionMapper.java | 6 +- .../V2__transition_condition_json.sql | 4 + .../V2__transition_condition_json.sql | 4 + .../jdbc/JdbcMysqlIntegrationTest.java | 2 +- .../jdbc/JdbcOrdoEngineIntegrationTest.java | 4 +- .../JdbcProcessDefinitionRepositoryTest.java | 3 +- .../ordo/storage/jdbc/JdbcTestSupport.java | 16 +- 27 files changed, 781 insertions(+), 83 deletions(-) create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingPredicate.java create mode 100644 ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingWhen.java create mode 100644 ordo-api/src/test/java/com/jetlumen/ordo/api/RoutingWhenTest.java create mode 100644 ordo-core/src/main/java/com/jetlumen/ordo/core/RoutingPredicateEvaluator.java create mode 100644 ordo-core/src/test/java/com/jetlumen/ordo/core/RoutingPredicateEvaluatorTest.java create mode 100644 ordo-storage-jdbc/src/main/resources/db/mysql/migration/V2__transition_condition_json.sql create mode 100644 ordo-storage-jdbc/src/main/resources/db/postgresql/migration/V2__transition_condition_json.sql diff --git a/README.md b/README.md index b11febf..9a80a4f 100644 --- a/README.md +++ b/README.md @@ -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` | 可注册多个,提交后按顺序调用 | diff --git a/docs/ordo-rest.openapi.yaml b/docs/ordo-rest.openapi.yaml index dc01d4b..8278833 100644 --- a/docs/ordo-rest.openapi.yaml +++ b/docs/ordo-rest.openapi.yaml @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 983ad4b..91cf3dc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,7 @@ - ANY/ALL 会签/或签 - JDBC 存储(PostgreSQL / MySQL)+ 按方言 Flyway 基线 - 定义不可变多版本:`publish`;实例锁定 `definitionVersion` -- 条件路由 `StepTransition` + `RoutingCondition` +- 条件路由 `StepTransition`:内置谓词 AST 或宿主 `RoutingCondition`(`ref` + `args`) - ACTION + `ActionHandler`;执行记录持久化 - 发起人撤回 `WITHDRAWN` - 分页查询:任务 / 实例 / 定义 diff --git a/docs/usage.md b/docs/usage.md index ff37c36..c0bb610 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 步骤 diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java index 1a19212..c77d123 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoCatalog.java @@ -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 PARAM_TYPES = Set.of("number", "string", "boolean"); + List conditions(); List actions(); List assignees(); - record Item(String key, String label) { + record Item(String key, String label, List 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() { diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java index 8c03f20..e496480 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinitionParser.java @@ -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 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) { } } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java index 2721de0..c56299e 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java @@ -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 args, ProcessContext context); static RoutingCondition always() { - return (key, context) -> true; + return (key, args, context) -> true; } } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingPredicate.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingPredicate.java new file mode 100644 index 0000000..d4760ce --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingPredicate.java @@ -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 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 copied = new ArrayList<>(values.size()); + for (Object value : values) { + copied.add(requireLiteral(value, "in value")); + } + values = List.copyOf(copied); + } + } + + record And(List args) implements RoutingPredicate { + public And { + args = copyPredicates(args, "and"); + } + } + + record Or(List 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 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> fields = node.fields(); + Map.Entry 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 values = new ArrayList<>(); + for (JsonNode item : value.get(1)) { + values.add(literal(item)); + } + return new In(requireVar(value.get(0)), values); + } + + private static List 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 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 copyPredicates(List 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); + } + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingWhen.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingWhen.java new file mode 100644 index 0000000..f50b743 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingWhen.java @@ -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 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 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 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); + } + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/StepTransition.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/StepTransition.java index 8db39a3..d2a2956 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/StepTransition.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/StepTransition.java @@ -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 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 args, int priority) { + return new StepTransition(fromStepId, null, RoutingWhen.ref(key, args), priority); } } diff --git a/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java index 743f23a..51d4509 100644 --- a/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java +++ b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionParserTest.java @@ -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 = """ diff --git a/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionTest.java b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionTest.java index e536287..7128354 100644 --- a/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionTest.java +++ b/ordo-api/src/test/java/com/jetlumen/ordo/api/ProcessDefinitionTest.java @@ -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 diff --git a/ordo-api/src/test/java/com/jetlumen/ordo/api/RoutingWhenTest.java b/ordo-api/src/test/java/com/jetlumen/ordo/api/RoutingWhenTest.java new file mode 100644 index 0000000..a70b0a8 --- /dev/null +++ b/ordo-api/src/test/java/com/jetlumen/ordo/api/RoutingWhenTest.java @@ -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]}")); + } +} diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java index 24ce8b5..a157a8f 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java @@ -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 candidates = definition.transitions().stream() + List outgoing = definition.transitions().stream() .filter(transition -> transition.fromStepId().equals(step.id())) - .sorted(Comparator.comparingInt(StepTransition::priority)) .toList(); + Comparator byPriority = Comparator.comparingInt(StepTransition::priority); + List 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 siblings, String decidedTaskId, String actor, Instant now, List events) { diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/RoutingPredicateEvaluator.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/RoutingPredicateEvaluator.java new file mode 100644 index 0000000..1bebf59 --- /dev/null +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/RoutingPredicateEvaluator.java @@ -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 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 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 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) map).containsKey(part)) { + return Optional.empty(); + } + current = map.get(part); + } + return Optional.ofNullable(current); + } +} diff --git a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java index 0378b98..6569e5f 100644 --- a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java +++ b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java @@ -13,6 +13,7 @@ import com.jetlumen.ordo.api.ProcessEventType; import com.jetlumen.ordo.api.ProcessInstance; import com.jetlumen.ordo.api.ProcessStatus; import com.jetlumen.ordo.api.RoutingCondition; +import com.jetlumen.ordo.api.RoutingPredicate; import com.jetlumen.ordo.api.StepDue; import com.jetlumen.ordo.api.StepKind; import com.jetlumen.ordo.api.StepTransition; @@ -351,10 +352,11 @@ class InMemoryOrdoEngineTest { @Test void resolvesAssigneesFromTheProcessContext() { - InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((candidate, step, context) -> context.value(step.id()) - .filter(String.class::isInstance) - .map(String.class::cast) - .orElse(candidate)); + InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((AssigneeResolver) (candidate, step, context) -> + context.value(step.id()) + .filter(String.class::isInstance) + .map(String.class::cast) + .orElse(candidate)); contextAwareEngine.publish(ProcessDefinition.linear("leave", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry") @@ -466,23 +468,13 @@ class InMemoryOrdoEngineTest { } @Test - void routesToDifferentNextStepsBasedOnConditionKey() { - RoutingCondition routingCondition = (key, context) -> { - if (!"amount-gt-1000".equals(key)) { - return false; - } - return context.value("amount") - .filter(Number.class::isInstance) - .map(Number.class::cast) - .map(amount -> amount.doubleValue() > 1000) - .orElse(false); - }; - InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine(routingCondition); + void routesToDifferentNextStepsBasedOnPredicate() { + InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine(); List 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 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 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 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); diff --git a/ordo-core/src/test/java/com/jetlumen/ordo/core/RoutingPredicateEvaluatorTest.java b/ordo-core/src/test/java/com/jetlumen/ordo/core/RoutingPredicateEvaluatorTest.java new file mode 100644 index 0000000..96197f4 --- /dev/null +++ b/ordo-core/src/test/java/com/jetlumen/ordo/core/RoutingPredicateEvaluatorTest.java @@ -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)); + } +} diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java index 370af6d..787b53a 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/jdbc/OrdoJdbcAutoConfigurationTest.java @@ -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; } } diff --git a/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json b/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json index 698d806..c1552ef 100644 --- a/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json +++ b/ordo-spring-boot-autoconfigure/src/test/resources/ordo/leave-request-routed.json @@ -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 } ] diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java index 0aa458f..cbfd2cf 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepository.java @@ -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(); } diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/StepTransitionMapper.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/StepTransitionMapper.java index 476ea7e..5214105 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/StepTransitionMapper.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/StepTransitionMapper.java @@ -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) { } } diff --git a/ordo-storage-jdbc/src/main/resources/db/mysql/migration/V2__transition_condition_json.sql b/ordo-storage-jdbc/src/main/resources/db/mysql/migration/V2__transition_condition_json.sql new file mode 100644 index 0000000..6c6a698 --- /dev/null +++ b/ordo-storage-jdbc/src/main/resources/db/mysql/migration/V2__transition_condition_json.sql @@ -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; diff --git a/ordo-storage-jdbc/src/main/resources/db/postgresql/migration/V2__transition_condition_json.sql b/ordo-storage-jdbc/src/main/resources/db/postgresql/migration/V2__transition_condition_json.sql new file mode 100644 index 0000000..6c6a698 --- /dev/null +++ b/ordo-storage-jdbc/src/main/resources/db/postgresql/migration/V2__transition_condition_json.sql @@ -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; diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcMysqlIntegrationTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcMysqlIntegrationTest.java index e2e63e4..3fd75a5 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcMysqlIntegrationTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcMysqlIntegrationTest.java @@ -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; } diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java index 67fe71e..41c08f8 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java @@ -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); diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java index 32399ae..6cf8fb7 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessDefinitionRepositoryTest.java @@ -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"))); diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcTestSupport.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcTestSupport.java index 1305527..ea041fc 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcTestSupport.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcTestSupport.java @@ -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,15 +31,17 @@ 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 sql : stripComments(migration).split(";")) { - if (!sql.isBlank()) { - statement.execute(sql); + for (String resourcePath : resourcePaths) { + String migration = loadSchema(resourcePath); + for (String sql : stripComments(migration).split(";")) { + if (!sql.isBlank()) { + statement.execute(sql); + } } } } catch (SQLException e) {