diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2ec058e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[*.java] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..38468c7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto eol=lf +*.java text eol=lf +*.xml text eol=lf +*.sql text eol=lf +*.md text eol=lf +*.properties text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.gitignore text eol=lf +*.gitattributes text eol=lf +*.jar binary +*.png binary +*.jpg binary +*.gif binary +*.ico binary diff --git a/docs/plan-conditionRouting.md b/docs/plan-conditionRouting.md new file mode 100644 index 0000000..28f571f --- /dev/null +++ b/docs/plan-conditionRouting.md @@ -0,0 +1,177 @@ +# Plan: 条件路由 (Conditional Routing / RoutingCondition) + +## 已与用户确认的关键决策 +1. transitions 挂在 `ProcessDefinition` 级别(独立列表),不改动 `ApprovalStep`。 +2. `RoutingCondition` 采用与 `AssigneeResolver` 一致的模式:全局单例 bean, + `boolean matches(String conditionKey, ProcessContext context)`;数据库只存 `conditionKey` + 字符串,不持久化任何 Java lambda/表达式(因为 JDBC 场景下引擎重启要从数据库重新装配 + `ProcessDefinition`,无法反序列化任意代码逻辑)。 +3. **不保留线性回退**:每个 `ProcessDefinition` 必须显式声明 transitions,覆盖每个 step 的 + 出边;没有 transitions 完全是非法状态(构造期校验报错),不再依赖 `steps()` 列表顺序做 + 执行期推进(`step_order`/列表顺序仅用于候选人/去重校验和展示,不再决定执行流)。 +4. 匹配不到任何 transition 时,fail-fast 抛出 `NoRouteFoundException`(不静默回退)。 + +## 模型设计 (ordo-api) + +### 新增 `StepTransition` record +``` +record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority) +``` +- `toStepId == null` 表示"终止流程"(当匹配到这条边时,实例被判 APPROVED 并完成)。 +- `conditionKey == null/blank` 表示无条件(总是匹配),用作 else/默认分支。 +- 便捷工厂: + - `StepTransition.always(fromStepId, toStepId)` — 无条件跳转,priority=0 + - `StepTransition.when(fromStepId, toStepId, conditionKey, priority)` — 条件跳转 + - `StepTransition.end(fromStepId)` — 无条件终止 + - `StepTransition.endWhen(fromStepId, conditionKey, priority)` — 条件终止 +- 评估顺序:按 `priority` 升序,第一个匹配的生效(无条件 = 恒真)。 + +### 新增 `RoutingCondition` functional interface(与 AssigneeResolver 同目录/同风格) +``` +@FunctionalInterface +public interface RoutingCondition { + boolean matches(String conditionKey, ProcessContext context); + static RoutingCondition always() { return (key, context) -> true; } +} +``` + +### `ProcessDefinition` 改为 4 个分量(破坏性变更) +``` +record ProcessDefinition(String id, String name, List steps, List transitions) +``` +构造期校验(在现有 id/name/steps 非空+去重校验基础上新增): +- `transitions` 非 null,`List.copyOf` 防御性拷贝。 +- 每条 transition 的 `fromStepId` 必须是 `steps` 中存在的 id;`toStepId`(非 null 时)也必须存在。 +- 每个 step 的 id 必须至少作为一条 transition 的 `fromStepId` 出现一次(强制显式声明出边), + 否则 `IllegalArgumentException("step X has no outgoing transition")`。 +- 同一 `(fromStepId, priority)` 不能重复(避免评估顺序有歧义)。 +- 新增便捷工厂 `ProcessDefinition.linear(id, name, steps)`:自动生成"上一步无条件指向下一步、 + 最后一步无条件终止"的 transitions,等价于旧的线性行为,用于降低无需真正条件路由的测试/demo + 的迁移成本(生成的仍然是显式 transitions,满足"强制显式"的约束,只是自动拼装)。 + +### 新增异常 `NoRouteFoundException extends OrdoException` +- message 形如 `"no matching transition for step: in instance: "`。 +- 放在 `com.jetlumen.ordo.api.exception` 包,命名/风格对齐现有 `DefinitionNotFoundException` 等。 + +## 引擎改造 (ordo-core) + +### `DefaultOrdoEngine` +- 构造函数新增 `RoutingCondition routingCondition` 参数(在 `assigneeResolver` 之后插入, + 与 Spring autoconfigure bean 顺序一致)。 +- 删除 `indexOf(...)` 私有方法(不再需要基于下标推进)。 +- `advanceOrComplete(instance, definition, step, now)` 重写为: + 1. 调用新的私有 `resolveTransition(definition, step, context)`: + 过滤 `definition.transitions()` 中 `fromStepId == step.id()` 的项,按 `priority` 升序排序, + 依次判断 `conditionKey` 为空/blank(无条件恒真)或 `routingCondition.matches(conditionKey, context)`, + 返回第一个匹配的 `StepTransition`;若无匹配,抛出 `NoRouteFoundException`。 + 2. 若匹配结果 `toStepId() == null` → `completeInstance(instance, ProcessStatus.APPROVED, now)`。 + 3. 否则 → `createStepTasks(instance, requireStep(definition, matched.toStepId()), now)`。 +- `advanceAfterDecision` 中原有 ANY/ALL 策略判断逻辑不变,只是"推进到下一步"的调用点改为走上面的 + transition 解析,不再用 `stepIndex == size-1` 判断是否最后一步。 +- `NoRouteFoundException` 在 `transactionExecutor.execute(...)` 内抛出,天然触发整个 approve/reject + 事务回滚(复用现有 `rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated` 验证过的回滚机制)。 +- `start()` 的起始 step 仍取 `definition.steps().getFirst()`(未改变,属于本次范围外的假设, + 如需显式声明起始 step 可后续再提出)。 + +### `InMemoryOrdoEngine` +- 仿照现有 `AssigneeResolver` 构造函数重载模式,新增: + - `InMemoryOrdoEngine(RoutingCondition routingCondition)` + - `InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition)` + - `InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition)` + - 默认无参/仅 Clock 的构造函数内部使用 `RoutingCondition.always()`。 + +## 持久化 (ordo-storage-jdbc) + +### 新迁移 `V3__add_step_transitions.sql` +```sql +CREATE TABLE ordo_step_transition ( + definition_id VARCHAR(64) NOT NULL, + from_step_id VARCHAR(64) NOT NULL, + to_step_id VARCHAR(64), + condition_key VARCHAR(255), + priority INTEGER NOT NULL, + PRIMARY KEY (definition_id, from_step_id, priority), + CONSTRAINT fk_transition_from FOREIGN KEY (definition_id, from_step_id) + REFERENCES ordo_approval_step (definition_id, step_id), + CONSTRAINT fk_transition_to FOREIGN KEY (definition_id, to_step_id) + REFERENCES ordo_approval_step (definition_id, step_id) +); +``` +**重要提醒(已踩过的坑,来自 /memories/repo/any-all-multi-approval.md)**: +- 必须同步把这个新迁移文件加进 `ordo-storage-jdbc/src/test/java/.../JdbcTestSupport.java` 的 + `MIGRATIONS` 数组,否则测试库缺表导致全部 JDBC 测试失败。 +- 注释里不要出现分号(或依赖已有的按行剥离 `--` 注释逻辑,已经是健壮的,无需再担心)。 + +### 新增 `StepTransitionMapper`(仿照 `ApprovalStepMapper` 的 `StepRow` 模式) +- `readRow(ResultSet) -> TransitionRow(fromStepId, toStepId, conditionKey, priority)`。 + +### `JdbcProcessDefinitionRepository` +- `insertIfAbsent`:在插入 steps/candidates 之后,遍历 `definition.transitions()` 执行 + `INSERT INTO ordo_step_transition (...)`。 +- `findById`:新增 `SELECT_TRANSITIONS`(按 `from_step_id, priority` 排序),组装 + `List` 传入新的 4 参 `ProcessDefinition` 构造函数。 + +## Spring Boot 自动配置 (ordo-spring-boot-autoconfigure) +- `OrdoJdbcAutoConfiguration` 新增: + ```java + @Bean + @ConditionalOnMissingBean + public RoutingCondition ordoRoutingCondition() { + return RoutingCondition.always(); + } + ``` +- `ordoEngine(...)` 方法签名新增 `RoutingCondition ordoRoutingCondition` 参数,传给 + `DefaultOrdoEngine` 构造函数。 +- `OrdoJdbcAutoConfigurationTest`:更新 `LEAVE_REQUEST` 定义使用 `ProcessDefinition.linear(...)`; + 新增一个 `honoursUserDefinedRoutingCondition` 测试,仿照现有 + `honoursUserDefinedAssigneeResolver` 的写法。 + +## 调用点迁移(约 29 处 `new ProcessDefinition(...)`) +- 绝大多数纯线性场景(测试/demo 不需要真正的条件路由)直接把 + `new ProcessDefinition(id, name, steps)` 换成 `ProcessDefinition.linear(id, name, steps)`。 +- 受影响文件: + - `ordo-api/src/test/java/.../ProcessDefinitionTest.java`(同时新增 transitions 相关校验用例: + 缺少出边报错、fromStepId/toStepId 引用不存在的 step 报错、重复 priority 报错) + - `ordo-core/src/test/java/.../InMemoryOrdoEngineTest.java`(8 处调用点 + 新增条件路由专项测试) + - `ordo-spring-boot-autoconfigure/src/test/java/.../OrdoJdbcAutoConfigurationTest.java` + - `ordo-storage-jdbc/src/test/java/.../Jdbc*Test.java`(约 10 处,含 + `JdbcProcessDefinitionRepositoryTest` 需要新增"读写 transitions 往返"专项用例) + - `ordo-storage-jdbc/src/main/java/.../JdbcProcessDefinitionRepository.java`(`findById` 内部 + 组装 `ProcessDefinition` 那一处,改为传入真实加载到的 transitions) + - `ordo-example/src/main/java/.../LeaveRequestExample.java` + - `d:\Projects\Personal\java\rhizome\src\main\java\...\LeaveRequestDemoService.java` + +## 新增测试(条件路由专项,主要在 `InMemoryOrdoEngineTest` 或新建 +`ConditionalRoutingTest`) +1. 基础分支:manager 通过后,根据 `conditionKey`(如 `"amount-gt-1000"`)走向不同的下一 step + (高额 -> director 审批 step;低额 -> 直接 end),验证 `RoutingCondition.matches` 被正确调用 + 并影响推进结果。 +2. 无匹配 transition 时抛出 `NoRouteFoundException`,且整个 approve 事务回滚(沿用现有 + rollback 测试模式)。 +3. `ProcessDefinition` 构造期校验: + - 某 step 没有任何出边 -> `IllegalArgumentException`。 + - transition 引用不存在的 `fromStepId`/`toStepId` -> `IllegalArgumentException`。 + - 同一 `(fromStepId, priority)` 重复 -> `IllegalArgumentException`。 +4. JDBC 往返测试:`JdbcProcessDefinitionRepositoryTest` 新增用例,插入一个带条件分支的 + definition,`findById` 读回后 `transitions()` 内容与写入一致(包括 `toStepId == null` 终止边)。 + +## 范围外 / 明确不做 +- 不支持在 transition 上直接内嵌 Java lambda(会破坏 JDBC 持久化重启场景,已由用户确认)。 +- 不引入显式 `startStepId` 概念,起始步骤仍是 `steps().getFirst()`。 +- 不做并行分支/合并(fork-join),仅支持"单一当前 step -> 单一下一 step 或终止"的有向图推进, + 与现有 ANY/ALL 多候选人机制正交(transition 发生在"某个 step 整体被判定通过/拒绝"之后)。 + +## 实施顺序(有依赖关系,需按序执行) +1. ordo-api:`StepTransition`、`RoutingCondition`、`ProcessDefinition`(含 `linear` 工厂)、 + `NoRouteFoundException`。 +2. ordo-core:`DefaultOrdoEngine` 改造、`InMemoryOrdoEngine` 构造函数重载。 +3. ordo-storage-jdbc:`V3` 迁移 + `StepTransitionMapper` + `JdbcProcessDefinitionRepository` 改造 + + 同步更新 `JdbcTestSupport.MIGRATIONS`。 +4. ordo-spring-boot-autoconfigure:`RoutingCondition` bean 及 `ordoEngine(...)` 装配。 +5. 迁移全部 ~29 处旧调用点(`ProcessDefinition.linear(...)`),含 rhizome demo。 +6. 新增条件路由专项测试(引擎行为 + 校验 + JDBC 往返)。 + +## 验证 +- `ordo-api`/`ordo-core`/`ordo-storage-jdbc`/`ordo-spring-boot-autoconfigure` 全量单元测试通过。 +- 手动确认 rhizome 端到端 curl 流程(线性场景切到 `ProcessDefinition.linear` 后)仍然可用 + (非阻塞,可在实现完成后单独验证)。 diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinition.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinition.java index 24fcfc4..0b506ce 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinition.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessDefinition.java @@ -1,11 +1,14 @@ package com.jetlumen.ordo.api; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; -/** Immutable blueprint for a linear approval process. */ -public record ProcessDefinition(String id, String name, List steps) { +/** Immutable blueprint for an approval process with explicit step transitions. */ +public record ProcessDefinition(String id, String name, List steps, List transitions) { public ProcessDefinition { ApprovalStep.requireText(id, "definition id"); ApprovalStep.requireText(name, "definition name"); @@ -19,5 +22,45 @@ public record ProcessDefinition(String id, String name, List steps throw new IllegalArgumentException("duplicate step id: " + step.id()); } } + Objects.requireNonNull(transitions, "transitions must not be null"); + Set stepsWithOutgoing = new HashSet<>(); + Set fromPriorityKeys = new HashSet<>(); + for (StepTransition transition : transitions) { + if (!ids.contains(transition.fromStepId())) { + throw new IllegalArgumentException("unknown fromStepId: " + transition.fromStepId()); + } + if (transition.toStepId() != null && !ids.contains(transition.toStepId())) { + throw new IllegalArgumentException("unknown toStepId: " + transition.toStepId()); + } + stepsWithOutgoing.add(transition.fromStepId()); + String fromPriority = transition.fromStepId() + '\0' + transition.priority(); + if (!fromPriorityKeys.add(fromPriority)) { + throw new IllegalArgumentException( + "duplicate priority " + transition.priority() + " for fromStepId: " + transition.fromStepId()); + } + } + for (ApprovalStep step : steps) { + if (!stepsWithOutgoing.contains(step.id())) { + throw new IllegalArgumentException("step " + step.id() + " has no outgoing transition"); + } + } + transitions = transitions.stream() + .sorted(Comparator.comparing(StepTransition::fromStepId).thenComparingInt(StepTransition::priority)) + .toList(); + } + + /** + * Builds a definition whose transitions mirror the former linear steps order: each step + * unconditionally advances to the next, and the last step unconditionally ends. + */ + public static ProcessDefinition linear(String id, String name, List steps) { + List transitions = new ArrayList<>(); + for (int i = 0; i < steps.size() - 1; i++) { + transitions.add(StepTransition.always(steps.get(i).id(), steps.get(i + 1).id())); + } + if (!steps.isEmpty()) { + transitions.add(StepTransition.end(steps.getLast().id())); + } + return new ProcessDefinition(id, name, steps, transitions); } } 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 new file mode 100644 index 0000000..2721de0 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/RoutingCondition.java @@ -0,0 +1,15 @@ +package com.jetlumen.ordo.api; + +/** + * 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. + */ +@FunctionalInterface +public interface RoutingCondition { + boolean matches(String conditionKey, ProcessContext context); + + static RoutingCondition always() { + return (key, context) -> true; + } +} 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 new file mode 100644 index 0000000..8db39a3 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/StepTransition.java @@ -0,0 +1,31 @@ +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 + * (always matches) and is typically used as the else/default branch. + */ +public record StepTransition(String fromStepId, String toStepId, String conditionKey, int priority) { + public StepTransition { + ApprovalStep.requireText(fromStepId, "fromStepId"); + if (toStepId != null && toStepId.isBlank()) { + throw new IllegalArgumentException("toStepId must not be blank when present"); + } + } + + public static StepTransition always(String fromStepId, String toStepId) { + 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 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); + } +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/NoRouteFoundException.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/NoRouteFoundException.java new file mode 100644 index 0000000..af620c7 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/exception/NoRouteFoundException.java @@ -0,0 +1,7 @@ +package com.jetlumen.ordo.api.exception; + +public final class NoRouteFoundException extends OrdoException { + public NoRouteFoundException(String stepId, String instanceId) { + super("no matching transition for step: " + stepId + " in instance: " + instanceId); + } +} 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 197fb17..0e6df42 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 @@ -28,8 +28,8 @@ class ProcessDefinitionTest { @Test void rejectsDefinitionsWithoutStepsOrWithDuplicateStepIds() { assertThrows(IllegalArgumentException.class, - () -> new ProcessDefinition("leave", "Leave request", List.of())); - assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of( + () -> ProcessDefinition.linear("leave", "Leave request", List.of())); + assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear("leave", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("manager", "HR approval", "henry") ))); @@ -39,15 +39,15 @@ class ProcessDefinitionTest { void rejectsBlankDefinitionFields() { List steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria")); - assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition(" ", "Leave request", steps)); - assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", " ", steps)); + assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear(" ", "Leave request", steps)); + assertThrows(IllegalArgumentException.class, () -> ProcessDefinition.linear("leave", " ", steps)); } @Test void copiesTheSuppliedStepList() { List suppliedSteps = new ArrayList<>(); suppliedSteps.add(ApprovalStep.single("manager", "Manager approval", "maria")); - ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", suppliedSteps); + ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", suppliedSteps); suppliedSteps.add(ApprovalStep.single("hr", "HR approval", "henry")); @@ -55,4 +55,43 @@ class ProcessDefinitionTest { assertThrows(UnsupportedOperationException.class, () -> definition.steps().add(ApprovalStep.single("lead", "Lead approval", "lee"))); } + + @Test + void rejectsStepsWithoutOutgoingTransitions() { + List steps = List.of( + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry")); + assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps, + List.of(StepTransition.end("manager")))); + } + + @Test + void rejectsTransitionsReferencingUnknownSteps() { + List steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria")); + assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps, + List.of(StepTransition.end("missing")))); + assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", steps, + List.of(StepTransition.always("manager", "missing")))); + } + + @Test + void rejectsDuplicatePriorityForTheSameFromStep() { + List steps = List.of( + ApprovalStep.single("manager", "Manager approval", "maria"), + 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)))); + } + + @Test + void linearBuildsUnconditionalChainAndEnd() { + ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", List.of( + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry"))); + assertEquals(List.of( + StepTransition.end("hr"), + StepTransition.always("manager", "hr")), definition.transitions()); + } } 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 8d4bbeb..2e8ff2a 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 @@ -9,11 +9,14 @@ import com.jetlumen.ordo.api.ProcessContext; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessInstance; import com.jetlumen.ordo.api.ProcessStatus; +import com.jetlumen.ordo.api.RoutingCondition; +import com.jetlumen.ordo.api.StepTransition; import com.jetlumen.ordo.api.TaskAction; import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.TransactionExecutor; import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException; import com.jetlumen.ordo.api.exception.DefinitionNotFoundException; +import com.jetlumen.ordo.api.exception.NoRouteFoundException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.api.exception.TaskNotFoundException; import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException; @@ -23,6 +26,7 @@ import com.jetlumen.ordo.api.repository.ProcessInstanceRepository; import java.time.Clock; import java.time.Instant; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -30,7 +34,7 @@ import java.util.UUID; /** - * Repository-backed implementation of the v0.1 linear approval runtime. + * Repository-backed implementation of the approval runtime with explicit step transitions. * Multistep write operations run inside a {@link TransactionExecutor} and * rely on conditional repository updates, so the engine stays correct even * when several JVMs share the same storage. @@ -38,17 +42,20 @@ import java.util.UUID; public final class DefaultOrdoEngine implements OrdoEngine { private final Clock clock; private final AssigneeResolver assigneeResolver; + private final RoutingCondition routingCondition; private final TransactionExecutor transactionExecutor; private final ProcessDefinitionRepository definitionRepository; private final ProcessInstanceRepository instanceRepository; private final ApprovalTaskRepository taskRepository; - public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, TransactionExecutor transactionExecutor, + public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition, + TransactionExecutor transactionExecutor, ProcessDefinitionRepository definitionRepository, ProcessInstanceRepository instanceRepository, ApprovalTaskRepository taskRepository) { this.clock = Objects.requireNonNull(clock, "clock must not be null"); this.assigneeResolver = Objects.requireNonNull(assigneeResolver, "assigneeResolver must not be null"); + this.routingCondition = Objects.requireNonNull(routingCondition, "routingCondition must not be null"); this.transactionExecutor = Objects.requireNonNull(transactionExecutor, "transactionExecutor must not be null"); this.definitionRepository = Objects.requireNonNull(definitionRepository, "definitionRepository must not be null"); this.instanceRepository = Objects.requireNonNull(instanceRepository, "instanceRepository must not be null"); @@ -202,14 +209,29 @@ public final class DefaultOrdoEngine implements OrdoEngine { } private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step, Instant now) { - int stepIndex = indexOf(definition, step.id()); - if (stepIndex == definition.steps().size() - 1) { + StepTransition matched = resolveTransition(definition, step, instance); + if (matched.toStepId() == null) { completeInstance(instance, ProcessStatus.APPROVED, now); } else { - createStepTasks(instance, definition.steps().get(stepIndex + 1), now); + createStepTasks(instance, requireStep(definition, matched.toStepId()), now); } } + private StepTransition resolveTransition(ProcessDefinition definition, ApprovalStep step, ProcessInstance instance) { + List candidates = definition.transitions().stream() + .filter(transition -> transition.fromStepId().equals(step.id())) + .sorted(Comparator.comparingInt(StepTransition::priority)) + .toList(); + for (StepTransition transition : candidates) { + String conditionKey = transition.conditionKey(); + if (conditionKey == null || conditionKey.isBlank() + || routingCondition.matches(conditionKey, instance.context())) { + return transition; + } + } + throw new NoRouteFoundException(step.id(), instance.id()); + } + /** Marks any still-pending sibling candidate tasks for the same step as skipped. */ private void skipPendingSiblings(List siblings, String decidedTaskId, Instant now) { for (ApprovalTask sibling : siblings) { @@ -245,15 +267,6 @@ public final class DefaultOrdoEngine implements OrdoEngine { .orElseThrow(() -> new IllegalStateException("step not found in definition: " + stepId)); } - private static int indexOf(ProcessDefinition definition, String stepId) { - for (int index = 0; index < definition.steps().size(); index++) { - if (definition.steps().get(index).id().equals(stepId)) { - return index; - } - } - throw new IllegalStateException("step not found in definition: " + stepId); - } - private static String nextId() { return UUID.randomUUID().toString(); } diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java index 310ac89..0a89e22 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java @@ -6,6 +6,7 @@ import com.jetlumen.ordo.api.OrdoEngine; import com.jetlumen.ordo.api.ProcessContext; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessInstance; +import com.jetlumen.ordo.api.RoutingCondition; import com.jetlumen.ordo.core.repository.InMemoryApprovalTaskRepository; import com.jetlumen.ordo.core.repository.InMemoryProcessDefinitionRepository; import com.jetlumen.ordo.core.repository.InMemoryProcessInstanceRepository; @@ -19,19 +20,31 @@ public final class InMemoryOrdoEngine implements OrdoEngine { private final DefaultOrdoEngine delegate; public InMemoryOrdoEngine() { - this(Clock.systemUTC(), AssigneeResolver.direct()); + this(Clock.systemUTC(), AssigneeResolver.direct(), RoutingCondition.always()); } public InMemoryOrdoEngine(Clock clock) { - this(clock, AssigneeResolver.direct()); + this(clock, AssigneeResolver.direct(), RoutingCondition.always()); } public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) { - this(Clock.systemUTC(), assigneeResolver); + this(Clock.systemUTC(), assigneeResolver, RoutingCondition.always()); } public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) { - this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, new NoopTransactionExecutor(), + this(clock, assigneeResolver, RoutingCondition.always()); + } + + public InMemoryOrdoEngine(RoutingCondition routingCondition) { + this(Clock.systemUTC(), AssigneeResolver.direct(), routingCondition); + } + + public InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition) { + this(Clock.systemUTC(), assigneeResolver, routingCondition); + } + + public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition) { + this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, new NoopTransactionExecutor(), new InMemoryProcessDefinitionRepository(), new InMemoryProcessInstanceRepository(), new InMemoryApprovalTaskRepository()); 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 f4e4702..0009361 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 @@ -6,9 +6,12 @@ import com.jetlumen.ordo.api.ApprovalTask; import com.jetlumen.ordo.api.ProcessContext; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessStatus; +import com.jetlumen.ordo.api.RoutingCondition; +import com.jetlumen.ordo.api.StepTransition; import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.api.exception.DefinitionAlreadyExistsException; import com.jetlumen.ordo.api.exception.DefinitionNotFoundException; +import com.jetlumen.ordo.api.exception.NoRouteFoundException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.api.exception.TaskNotFoundException; import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException; @@ -30,7 +33,7 @@ class InMemoryOrdoEngineTest { @BeforeEach void setUp() { engine = new InMemoryOrdoEngine(); - engine.register(new ProcessDefinition("leave", "Leave request", List.of( + engine.register(ProcessDefinition.linear("leave", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry") ))); @@ -79,7 +82,7 @@ class InMemoryOrdoEngineTest { void exposesSpecificExceptionsForMissingAndDuplicateResources() { assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice")); assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria")); - assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(new ProcessDefinition( + assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(ProcessDefinition.linear( "leave", "Another leave request", List.of(ApprovalStep.single("lead", "Lead approval", "lee"))))); } @@ -160,7 +163,7 @@ class InMemoryOrdoEngineTest { .filter(String.class::isInstance) .map(String.class::cast) .orElse(candidate)); - contextAwareEngine.register(new ProcessDefinition("leave", "Leave request", List.of( + contextAwareEngine.register(ProcessDefinition.linear("leave", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry") ))); @@ -180,7 +183,7 @@ class InMemoryOrdoEngineTest { @Test void anyPolicyAdvancesOnFirstApprovalAndSkipsTheOtherCandidates() { InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine(); - anyEngine.register(new ProcessDefinition("leave-any", "Leave request", List.of( + anyEngine.register(ProcessDefinition.linear("leave-any", "Leave request", List.of( new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY), ApprovalStep.single("hr", "HR approval", "henry") ))); @@ -209,7 +212,7 @@ class InMemoryOrdoEngineTest { @Test void anyPolicyOnlyRejectsTheStepOnceEveryCandidateHasRejected() { InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine(); - anyEngine.register(new ProcessDefinition("leave-any-reject", "Leave request", List.of( + anyEngine.register(ProcessDefinition.linear("leave-any-reject", "Leave request", List.of( new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY) ))); @@ -229,7 +232,7 @@ class InMemoryOrdoEngineTest { @Test void allPolicyOnlyAdvancesOnceEveryCandidateHasApproved() { InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine(); - allEngine.register(new ProcessDefinition("leave-all", "Leave request", List.of( + allEngine.register(ProcessDefinition.linear("leave-all", "Leave request", List.of( new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL), ApprovalStep.single("hr", "HR approval", "henry") ))); @@ -254,7 +257,7 @@ class InMemoryOrdoEngineTest { @Test void allPolicyFailsFastAndSkipsRemainingCandidatesOnASingleRejection() { InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine(); - allEngine.register(new ProcessDefinition("leave-all-reject", "Leave request", List.of( + allEngine.register(ProcessDefinition.linear("leave-all-reject", "Leave request", List.of( new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL) ))); @@ -269,4 +272,49 @@ class InMemoryOrdoEngineTest { assertEquals(TaskStatus.SKIPPED, allEngine.findTask(mikeTask.id()).orElseThrow().status()); assertNull(allEngine.findTask(mikeTask.id()).orElseThrow().action()); } + + @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); + List steps = List.of( + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("director", "Director approval", "diana")); + routingEngine.register(new ProcessDefinition("expense", "Expense request", steps, List.of( + StepTransition.when("manager", "director", "amount-gt-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()).getFirst().id(), "maria"); + assertEquals("director", routingEngine.findPendingTasksByInstanceId(high.id()).getFirst().stepId()); + routingEngine.approve(routingEngine.findPendingTasksByInstanceId(high.id()).getFirst().id(), "diana"); + assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(high.id()).orElseThrow().status()); + + var low = routingEngine.start("expense", "bob", new ProcessContext(Map.of("amount", 200))); + routingEngine.approve(routingEngine.findPendingTasksByInstanceId(low.id()).getFirst().id(), "maria"); + assertEquals(ProcessStatus.APPROVED, routingEngine.findInstance(low.id()).orElseThrow().status()); + assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty()); + } + + @Test + void throwsWhenNoTransitionMatches() { + InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false); + routingEngine.register(new ProcessDefinition("expense", "Expense request", + List.of(ApprovalStep.single("manager", "Manager approval", "maria")), + List.of(StepTransition.endWhen("manager", "never", 0)))); + + var instance = routingEngine.start("expense", "alice"); + ApprovalTask task = routingEngine.findPendingTasksByInstanceId(instance.id()).getFirst(); + assertThrows(NoRouteFoundException.class, () -> routingEngine.approve(task.id(), "maria")); + } } diff --git a/ordo-example/src/main/java/com/jetlumen/ordo/example/LeaveRequestExample.java b/ordo-example/src/main/java/com/jetlumen/ordo/example/LeaveRequestExample.java index cd8ec5a..4866028 100644 --- a/ordo-example/src/main/java/com/jetlumen/ordo/example/LeaveRequestExample.java +++ b/ordo-example/src/main/java/com/jetlumen/ordo/example/LeaveRequestExample.java @@ -15,7 +15,7 @@ public final class LeaveRequestExample { public static void main(String[] args) { OrdoEngine ordo = new InMemoryOrdoEngine(); - ordo.register(new ProcessDefinition("leave-request", "Leave request", List.of( + ordo.register(ProcessDefinition.linear("leave-request", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry") ))); diff --git a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java index c66dc68..9150330 100644 --- a/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java +++ b/ordo-spring-boot-autoconfigure/src/main/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfiguration.java @@ -2,6 +2,7 @@ package com.jetlumen.ordo.spring; import com.jetlumen.ordo.api.AssigneeResolver; import com.jetlumen.ordo.api.OrdoEngine; +import com.jetlumen.ordo.api.RoutingCondition; import com.jetlumen.ordo.api.TransactionExecutor; import com.jetlumen.ordo.api.repository.ApprovalTaskRepository; import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository; @@ -58,6 +59,12 @@ public class OrdoJdbcAutoConfiguration { return AssigneeResolver.direct(); } + @Bean + @ConditionalOnMissingBean + public RoutingCondition ordoRoutingCondition() { + return RoutingCondition.always(); + } + @Bean @ConditionalOnMissingBean @DependsOnDatabaseInitialization @@ -93,11 +100,12 @@ public class OrdoJdbcAutoConfiguration { @ConditionalOnMissingBean public OrdoEngine ordoEngine(Clock ordoClock, AssigneeResolver ordoAssigneeResolver, + RoutingCondition ordoRoutingCondition, TransactionExecutor ordoTransactionExecutor, ProcessDefinitionRepository ordoProcessDefinitionRepository, ProcessInstanceRepository ordoProcessInstanceRepository, ApprovalTaskRepository ordoApprovalTaskRepository) { - return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoTransactionExecutor, + return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, ordoTransactionExecutor, ordoProcessDefinitionRepository, ordoProcessInstanceRepository, ordoApprovalTaskRepository); } } diff --git a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java index 5221a29..f95f8e5 100644 --- a/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java +++ b/ordo-spring-boot-autoconfigure/src/test/java/com/jetlumen/ordo/spring/OrdoJdbcAutoConfigurationTest.java @@ -6,6 +6,7 @@ import com.jetlumen.ordo.api.AssigneeResolver; import com.jetlumen.ordo.api.OrdoEngine; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessInstance; +import com.jetlumen.ordo.api.RoutingCondition; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; @@ -21,7 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; class OrdoJdbcAutoConfigurationTest { - private static final ProcessDefinition LEAVE_REQUEST = new ProcessDefinition("leave-request", "Leave request", + private static final ProcessDefinition LEAVE_REQUEST = ProcessDefinition.linear("leave-request", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))); private final ApplicationContextRunner withDataSourceRunner = new ApplicationContextRunner() @@ -76,6 +77,15 @@ class OrdoJdbcAutoConfigurationTest { }); } + @Test + void honoursUserDefinedRoutingCondition() { + withDataSourceRunner.withUserConfiguration(CustomRoutingConditionConfig.class) + .run(context -> { + assertThat(context).hasSingleBean(OrdoEngine.class); + assertThat(context.getBean(RoutingCondition.class).matches("any", null)).isTrue(); + }); + } + @Configuration static class CustomAssigneeResolverConfig { @Bean @@ -83,4 +93,12 @@ class OrdoJdbcAutoConfigurationTest { return (candidate, step, ctx) -> "custom-assignee"; } } + + @Configuration + static class CustomRoutingConditionConfig { + @Bean + RoutingCondition ordoRoutingCondition() { + return (key, context) -> true; + } + } } 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 3baaae2..af44c07 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 @@ -2,9 +2,12 @@ package com.jetlumen.ordo.storage.jdbc; import com.jetlumen.ordo.api.ApprovalStep; import com.jetlumen.ordo.api.ProcessDefinition; +import com.jetlumen.ordo.api.StepTransition; import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository; import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper; import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper.StepRow; +import com.jetlumen.ordo.storage.jdbc.mapper.StepTransitionMapper; +import com.jetlumen.ordo.storage.jdbc.mapper.StepTransitionMapper.TransitionRow; import java.sql.Connection; import java.sql.PreparedStatement; @@ -25,12 +28,17 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR "INSERT INTO ordo_approval_step (definition_id, step_id, step_name, policy, step_order) VALUES (?, ?, ?, ?, ?)"; private static final String INSERT_CANDIDATE = "INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) VALUES (?, ?, ?, ?)"; + private static final String INSERT_TRANSITION = + "INSERT INTO ordo_step_transition (definition_id, from_step_id, to_step_id, condition_key, priority) VALUES (?, ?, ?, ?, ?)"; private static final String SELECT_DEFINITION = "SELECT id, name FROM ordo_process_definition WHERE id = ?"; private static final String SELECT_STEPS = "SELECT step_id, step_name, policy FROM ordo_approval_step WHERE definition_id = ? ORDER BY step_order"; private static final String SELECT_CANDIDATES = "SELECT step_id, candidate FROM ordo_step_candidate WHERE definition_id = ? 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 " + + "WHERE definition_id = ? ORDER BY from_step_id, priority"; private final JdbcConnectionProvider connectionProvider; @@ -74,6 +82,16 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR } } } + for (StepTransition transition : definition.transitions()) { + try (PreparedStatement insertTransition = connection.prepareStatement(INSERT_TRANSITION)) { + insertTransition.setString(1, definition.id()); + insertTransition.setString(2, transition.fromStepId()); + insertTransition.setString(3, transition.toStepId()); + insertTransition.setString(4, transition.conditionKey()); + insertTransition.setInt(5, transition.priority()); + insertTransition.executeUpdate(); + } + } return true; } catch (SQLException e) { throw new JdbcStorageException("failed to insert definition: " + definition.id(), e); @@ -120,7 +138,18 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR List candidates = candidatesByStep.getOrDefault(row.stepId(), List.of()); steps.add(new ApprovalStep(row.stepId(), row.stepName(), candidates, row.policy())); } - return Optional.of(new ProcessDefinition(definitionId, name, steps)); + List transitions = new ArrayList<>(); + try (PreparedStatement selectTransitions = connection.prepareStatement(SELECT_TRANSITIONS)) { + selectTransitions.setString(1, definitionId); + try (ResultSet resultSet = selectTransitions.executeQuery()) { + while (resultSet.next()) { + TransitionRow row = StepTransitionMapper.readRow(resultSet); + transitions.add(new StepTransition(row.fromStepId(), row.toStepId(), row.conditionKey(), + row.priority())); + } + } + } + return Optional.of(new ProcessDefinition(definitionId, name, steps, transitions)); } catch (SQLException e) { throw new JdbcStorageException("failed to load definition: " + definitionId, e); } finally { 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 new file mode 100644 index 0000000..476ea7e --- /dev/null +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/StepTransitionMapper.java @@ -0,0 +1,21 @@ +package com.jetlumen.ordo.storage.jdbc.mapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** Maps rows of {@code ordo_step_transition}. */ +public final class StepTransitionMapper { + private StepTransitionMapper() { + } + + public static TransitionRow readRow(ResultSet resultSet) throws SQLException { + return new TransitionRow( + resultSet.getString("from_step_id"), + resultSet.getString("to_step_id"), + resultSet.getString("condition_key"), + resultSet.getInt("priority")); + } + + public record TransitionRow(String fromStepId, String toStepId, String conditionKey, int priority) { + } +} diff --git a/ordo-storage-jdbc/src/main/resources/db/migration/V3__add_step_transitions.sql b/ordo-storage-jdbc/src/main/resources/db/migration/V3__add_step_transitions.sql new file mode 100644 index 0000000..02742be --- /dev/null +++ b/ordo-storage-jdbc/src/main/resources/db/migration/V3__add_step_transitions.sql @@ -0,0 +1,12 @@ +CREATE TABLE ordo_step_transition ( + definition_id VARCHAR(64) NOT NULL, + from_step_id VARCHAR(64) NOT NULL, + to_step_id VARCHAR(64), + condition_key VARCHAR(255), + priority INTEGER NOT NULL, + PRIMARY KEY (definition_id, from_step_id, priority), + CONSTRAINT fk_transition_from FOREIGN KEY (definition_id, from_step_id) + REFERENCES ordo_approval_step (definition_id, step_id), + CONSTRAINT fk_transition_to FOREIGN KEY (definition_id, to_step_id) + REFERENCES ordo_approval_step (definition_id, step_id) +); diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepositoryTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepositoryTest.java index d9978a1..f6c98d3 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepositoryTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepositoryTest.java @@ -38,7 +38,7 @@ class JdbcApprovalTaskRepositoryTest { /** Tasks reference their instance, which references its definition; both parent rows must exist. */ private void insertFixtureData() { - new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition("leave", + new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear("leave", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider); instanceRepository.insert(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING, 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 b46f556..ca61bdb 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 @@ -8,7 +8,10 @@ import com.jetlumen.ordo.api.ProcessContext; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessInstance; import com.jetlumen.ordo.api.ProcessStatus; +import com.jetlumen.ordo.api.RoutingCondition; +import com.jetlumen.ordo.api.StepTransition; import com.jetlumen.ordo.api.TaskStatus; +import com.jetlumen.ordo.api.exception.NoRouteFoundException; import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException; import com.jetlumen.ordo.core.DefaultOrdoEngine; import org.junit.jupiter.api.BeforeEach; @@ -35,7 +38,7 @@ class JdbcOrdoEngineIntegrationTest { void setUp() { connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource()); engine = newEngine(AssigneeResolver.direct()); - engine.register(new ProcessDefinition("leave", "Leave request", List.of( + engine.register(ProcessDefinition.linear("leave", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry")))); } @@ -94,7 +97,7 @@ class JdbcOrdoEngineIntegrationTest { } return candidate; }); - failingEngine.register(new ProcessDefinition("leave2", "Leave request", List.of( + failingEngine.register(ProcessDefinition.linear("leave2", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry")))); @@ -110,6 +113,28 @@ class JdbcOrdoEngineIntegrationTest { assertEquals(ProcessStatus.RUNNING, failingEngine.findInstance(instance.id()).orElseThrow().status()); } + @Test + void rollsBackTheWholeApprovalWhenNoRouteMatches() { + OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), + AssigneeResolver.direct(), (key, context) -> false, + new JdbcTransactionExecutor(connectionProvider), + new JdbcProcessDefinitionRepository(connectionProvider), + new JdbcProcessInstanceRepository(connectionProvider), + new JdbcApprovalTaskRepository(connectionProvider)); + failingEngine.register(new ProcessDefinition("leave-noroute", "Leave request", + List.of(ApprovalStep.single("manager", "Manager approval", "maria")), + List.of(StepTransition.endWhen("manager", "never", 0)))); + + ProcessInstance instance = failingEngine.start("leave-noroute", "alice"); + ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst(); + assertThrows(NoRouteFoundException.class, () -> failingEngine.approve(managerTask.id(), "maria")); + + ApprovalTask storedTask = failingEngine.findTask(managerTask.id()).orElseThrow(); + assertEquals(TaskStatus.PENDING, storedTask.status()); + assertNull(storedTask.action()); + assertEquals(ProcessStatus.RUNNING, failingEngine.findInstance(instance.id()).orElseThrow().status()); + } + @Test void dataSurvivesAcrossEngineInstancesOverTheSameDataSource() { ProcessInstance instance = engine.start("leave", "alice"); @@ -124,7 +149,7 @@ class JdbcOrdoEngineIntegrationTest { } private OrdoEngine newEngine(AssigneeResolver assigneeResolver) { - return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, + return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(), new JdbcTransactionExecutor(connectionProvider), new JdbcProcessDefinitionRepository(connectionProvider), new JdbcProcessInstanceRepository(connectionProvider), diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcPostgresIntegrationTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcPostgresIntegrationTest.java index 8a1d08f..aa8f326 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcPostgresIntegrationTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcPostgresIntegrationTest.java @@ -8,6 +8,7 @@ import com.jetlumen.ordo.api.ProcessContext; import com.jetlumen.ordo.api.ProcessDefinition; import com.jetlumen.ordo.api.ProcessInstance; import com.jetlumen.ordo.api.ProcessStatus; +import com.jetlumen.ordo.api.RoutingCondition; import com.jetlumen.ordo.api.TaskAction; import com.jetlumen.ordo.api.TaskStatus; import com.jetlumen.ordo.core.DefaultOrdoEngine; @@ -102,7 +103,7 @@ class JdbcPostgresIntegrationTest { @Test void engineCompletesASequentialApprovalProcessOverPostgres() { OrdoEngine engine = newEngine(AssigneeResolver.direct()); - engine.register(new ProcessDefinition("leave-pg", "Leave request", List.of( + engine.register(ProcessDefinition.linear("leave-pg", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry")))); @@ -122,18 +123,18 @@ class JdbcPostgresIntegrationTest { @Test void rejectsDuplicateDefinitionIdsViaTheDatabaseUniqueConstraint() { JdbcProcessDefinitionRepository repository = new JdbcProcessDefinitionRepository(connectionProvider); - ProcessDefinition definition = new ProcessDefinition("leave-dup-pg", "Leave request", + ProcessDefinition definition = ProcessDefinition.linear("leave-dup-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))); assertTrue(repository.insertIfAbsent(definition)); - assertFalse(repository.insertIfAbsent(new ProcessDefinition("leave-dup-pg", "Second attempt", + assertFalse(repository.insertIfAbsent(ProcessDefinition.linear("leave-dup-pg", "Second attempt", List.of(ApprovalStep.single("manager", "Manager approval", "maria"))))); assertEquals("Leave request", repository.findById("leave-dup-pg").orElseThrow().name()); } @Test void rejectsDuplicateInstanceIdsViaTheDatabaseUniqueConstraint() { - new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( + new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear( "leave-dup-inst-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider); ProcessInstance instance = new ProcessInstance("inst-dup-pg", "leave-dup-inst-pg", "alice", @@ -145,7 +146,7 @@ class JdbcPostgresIntegrationTest { @Test void roundsTimestampsToMicrosecondPrecision() { - new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( + new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear( "leave-time-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider); @@ -164,7 +165,7 @@ class JdbcPostgresIntegrationTest { @Test void onlyOneOfTwoConcurrentCompletionsWinsOnPostgres() throws Exception { - new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( + new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear( "leave-race-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider); instanceRepository.insert(new ProcessInstance("inst-race-pg", "leave-race-pg", "alice", @@ -200,7 +201,7 @@ class JdbcPostgresIntegrationTest { } return candidate; }); - failingEngine.register(new ProcessDefinition("leave-rollback-pg", "Leave request", List.of( + failingEngine.register(ProcessDefinition.linear("leave-rollback-pg", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry")))); @@ -217,7 +218,7 @@ class JdbcPostgresIntegrationTest { } private OrdoEngine newEngine(AssigneeResolver assigneeResolver) { - return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, + return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(), new JdbcTransactionExecutor(connectionProvider), new JdbcProcessDefinitionRepository(connectionProvider), new JdbcProcessInstanceRepository(connectionProvider), 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 6cb1320..5cc8145 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 @@ -2,6 +2,7 @@ package com.jetlumen.ordo.storage.jdbc; import com.jetlumen.ordo.api.ApprovalStep; import com.jetlumen.ordo.api.ProcessDefinition; +import com.jetlumen.ordo.api.StepTransition; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,7 +23,7 @@ class JdbcProcessDefinitionRepositoryTest { @Test void insertsAndReadsBackADefinitionWithItsStepsInOrder() { - ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", List.of( + ProcessDefinition definition = ProcessDefinition.linear("leave", "Leave request", List.of( ApprovalStep.single("manager", "Manager approval", "maria"), ApprovalStep.single("hr", "HR approval", "henry"))); @@ -43,7 +44,21 @@ class JdbcProcessDefinitionRepositoryTest { assertTrue(repository.findById("missing").isEmpty()); } + @Test + void insertsAndReadsBackConditionalTransitionsIncludingTerminalEdges() { + ProcessDefinition definition = new ProcessDefinition("expense", "Expense request", List.of( + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("director", "Director approval", "diana")), + List.of( + StepTransition.when("manager", "director", "amount-gt-1000", 0), + new StepTransition("manager", null, null, 1), + StepTransition.end("director"))); + + assertTrue(repository.insertIfAbsent(definition)); + assertEquals(definition, repository.findById("expense").orElseThrow()); + } + private static ProcessDefinition definition(String id, String name) { - return new ProcessDefinition(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee"))); + return ProcessDefinition.linear(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee"))); } } diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java index 5e80424..96f1f33 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcProcessInstanceRepositoryTest.java @@ -27,7 +27,7 @@ class JdbcProcessInstanceRepositoryTest { connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource()); repository = new JdbcProcessInstanceRepository(connectionProvider); // instances reference their definition, so the parent row must exist - new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition("leave", + new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(ProcessDefinition.linear("leave", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); } 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 2b6f42c..c0eca19 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 @@ -15,7 +15,8 @@ import java.util.UUID; final class JdbcTestSupport { private static final String[] MIGRATIONS = { "/db/migration/V1__create_ordo_tables.sql", - "/db/migration/V2__add_step_candidates_and_policy.sql" + "/db/migration/V2__add_step_candidates_and_policy.sql", + "/db/migration/V3__add_step_transitions.sql" }; private static final String[] SCHEMA_SQL = loadSchemas();