diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ApprovalPolicy.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ApprovalPolicy.java new file mode 100644 index 0000000..0dd3f82 --- /dev/null +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ApprovalPolicy.java @@ -0,0 +1,16 @@ +package com.jetlumen.ordo.api; + +/** + * Decision rule applied when an approval step has more than one candidate. + * + */ +public enum ApprovalPolicy { + ANY, ALL +} diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ApprovalStep.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ApprovalStep.java index 25df328..0fef95e 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ApprovalStep.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ApprovalStep.java @@ -1,11 +1,38 @@ package com.jetlumen.ordo.api; -/** A single, named approval step in a linear process definition. */ -public record ApprovalStep(String id, String name, String assignee) { +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * A single, named approval step in a process definition. A step has one or more candidate + * assignees; when there is more than one candidate, {@link #policy()} decides whether any + * single approval is enough ({@link ApprovalPolicy#ANY}) or every candidate must approve + * ({@link ApprovalPolicy#ALL}). + */ +public record ApprovalStep(String id, String name, List candidates, ApprovalPolicy policy) { public ApprovalStep { requireText(id, "step id"); requireText(name, "step name"); - requireText(assignee, "step assignee"); + Objects.requireNonNull(candidates, "candidates must not be null"); + candidates = List.copyOf(candidates); + if (candidates.isEmpty()) { + throw new IllegalArgumentException("a step must have at least one candidate"); + } + Set distinct = new HashSet<>(); + for (String candidate : candidates) { + requireText(candidate, "candidate"); + if (!distinct.add(candidate)) { + throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate); + } + } + Objects.requireNonNull(policy, "policy must not be null"); + } + + /** Convenience factory for the common case of a single, fixed approver. */ + public static ApprovalStep single(String id, String name, String assignee) { + return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY); } static void requireText(String value, String field) { diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/AssigneeResolver.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/AssigneeResolver.java index e549021..c1baae9 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/AssigneeResolver.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/AssigneeResolver.java @@ -1,11 +1,16 @@ package com.jetlumen.ordo.api; -/** Resolves the current assignee for an approval step when a task is created. */ +/** + * Resolves the actual assignee for a single candidate of an approval step when its task is + * created. The default implementation treats each candidate as the literal assignee; hosts can + * supply their own resolver to translate a candidate placeholder (e.g. a role name) into a + * concrete user. + */ @FunctionalInterface public interface AssigneeResolver { - String resolve(ApprovalStep step, ProcessContext context); + String resolve(String candidate, ApprovalStep step, ProcessContext context); static AssigneeResolver direct() { - return (step, context) -> step.assignee(); + return (candidate, step, context) -> candidate; } } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/TaskStatus.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/TaskStatus.java index 9535d08..2afb8bb 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/TaskStatus.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/TaskStatus.java @@ -1,5 +1,7 @@ package com.jetlumen.ordo.api; public enum TaskStatus { - PENDING, APPROVED, REJECTED + PENDING, APPROVED, REJECTED, + /** Auto-completed because a sibling candidate task already settled the step's outcome. */ + SKIPPED } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ApprovalTaskRepository.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ApprovalTaskRepository.java index fab3f04..aeab185 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ApprovalTaskRepository.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/repository/ApprovalTaskRepository.java @@ -14,6 +14,9 @@ public interface ApprovalTaskRepository { List findByInstanceId(String instanceId); + /** Returns every candidate task created for a single step instance, in creation order. */ + List findByInstanceIdAndStepId(String instanceId, String stepId); + List findPendingByAssignee(String assignee); List findPendingByInstanceId(String 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 3f28fcf..197fb17 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 @@ -12,9 +12,17 @@ class ProcessDefinitionTest { @Test void rejectsBlankStepFields() { - assertThrows(IllegalArgumentException.class, () -> new ApprovalStep(" ", "Manager", "maria")); - assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", " ", "maria")); - assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", "Manager", " ")); + assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single(" ", "Manager", "maria")); + assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single("manager", " ", "maria")); + assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single("manager", "Manager", " ")); + } + + @Test + void rejectsStepsWithNoOrDuplicateCandidates() { + assertThrows(IllegalArgumentException.class, + () -> new ApprovalStep("manager", "Manager", List.of(), ApprovalPolicy.ANY)); + assertThrows(IllegalArgumentException.class, + () -> new ApprovalStep("manager", "Manager", List.of("maria", "maria"), ApprovalPolicy.ALL)); } @Test @@ -22,14 +30,14 @@ class ProcessDefinitionTest { assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of())); assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("manager", "HR approval", "henry") + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("manager", "HR approval", "henry") ))); } @Test void rejectsBlankDefinitionFields() { - List steps = List.of(new ApprovalStep("manager", "Manager approval", "maria")); + 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)); @@ -38,13 +46,13 @@ class ProcessDefinitionTest { @Test void copiesTheSuppliedStepList() { List suppliedSteps = new ArrayList<>(); - suppliedSteps.add(new ApprovalStep("manager", "Manager approval", "maria")); + suppliedSteps.add(ApprovalStep.single("manager", "Manager approval", "maria")); ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", suppliedSteps); - suppliedSteps.add(new ApprovalStep("hr", "HR approval", "henry")); + suppliedSteps.add(ApprovalStep.single("hr", "HR approval", "henry")); assertEquals(1, definition.steps().size()); assertThrows(UnsupportedOperationException.class, - () -> definition.steps().add(new ApprovalStep("lead", "Lead approval", "lee"))); + () -> definition.steps().add(ApprovalStep.single("lead", "Lead approval", "lee"))); } } 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 5a63ce4..8d4bbeb 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 @@ -1,5 +1,6 @@ package com.jetlumen.ordo.core; +import com.jetlumen.ordo.api.ApprovalPolicy; import com.jetlumen.ordo.api.ApprovalStep; import com.jetlumen.ordo.api.ApprovalTask; import com.jetlumen.ordo.api.AssigneeResolver; @@ -27,6 +28,7 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; + /** * Repository-backed implementation of the v0.1 linear approval runtime. * Multistep write operations run inside a {@link TransactionExecutor} and @@ -74,7 +76,7 @@ public final class DefaultOrdoEngine implements OrdoEngine { ProcessInstance instance = new ProcessInstance(nextId(), definition.id(), initiator, ProcessStatus.RUNNING, now, null, context); instanceRepository.insert(instance); - createTask(instance, definition.steps().getFirst(), now); + createStepTasks(instance, definition.steps().getFirst(), now); return instance; }); } @@ -85,14 +87,7 @@ public final class DefaultOrdoEngine implements OrdoEngine { ApprovalTask task = requirePendingTaskForActor(taskId, actor); Instant now = clock.instant(); ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now)); - ProcessInstance instance = requireInstance(task.instanceId()); - ProcessDefinition definition = requireDefinition(instance.definitionId()); - int stepIndex = indexOf(definition, task.stepId()); - if (stepIndex == definition.steps().size() - 1) { - completeInstance(instance, ProcessStatus.APPROVED, now); - } else { - createTask(instance, definition.steps().get(stepIndex + 1), now); - } + advanceAfterDecision(completedTask, now); return completedTask; }); } @@ -103,7 +98,7 @@ public final class DefaultOrdoEngine implements OrdoEngine { ApprovalTask task = requirePendingTaskForActor(taskId, actor); Instant now = clock.instant(); ApprovalTask completedTask = completeTask(task, TaskStatus.REJECTED, new TaskAction(actor, comment, now)); - completeInstance(requireInstance(task.instanceId()), ProcessStatus.REJECTED, now); + advanceAfterDecision(completedTask, now); return completedTask; }); } @@ -135,11 +130,13 @@ public final class DefaultOrdoEngine implements OrdoEngine { return taskRepository.findPendingByInstanceId(instanceId); } - private void createTask(ProcessInstance instance, ApprovalStep step, Instant now) { - String assignee = assigneeResolver.resolve(step, instance.context()); - requireText(assignee, "resolved assignee"); - taskRepository.save(new ApprovalTask(nextId(), instance.id(), step.id(), step.name(), assignee, - TaskStatus.PENDING, now, null, null)); + private void createStepTasks(ProcessInstance instance, ApprovalStep step, Instant now) { + for (String candidate : step.candidates()) { + String assignee = assigneeResolver.resolve(candidate, step, instance.context()); + requireText(assignee, "resolved assignee"); + taskRepository.save(new ApprovalTask(nextId(), instance.id(), step.id(), step.name(), assignee, + TaskStatus.PENDING, now, null, null)); + } } private ApprovalTask requirePendingTaskForActor(String taskId, String actor) { @@ -164,6 +161,68 @@ public final class DefaultOrdoEngine implements OrdoEngine { return completed; } + /** + * Decides whether the step (and the process instance) can move on after a single candidate + * task was approved or rejected, applying the step's {@link ApprovalPolicy}. + */ + private void advanceAfterDecision(ApprovalTask completedTask, Instant now) { + ProcessInstance instance = requireInstance(completedTask.instanceId()); + ProcessDefinition definition = requireDefinition(instance.definitionId()); + ApprovalStep step = requireStep(definition, completedTask.stepId()); + List siblings = taskRepository.findByInstanceIdAndStepId(instance.id(), step.id()); + + if (completedTask.status() == TaskStatus.APPROVED && step.policy() == ApprovalPolicy.ANY) { + skipPendingSiblings(siblings, completedTask.id(), now); + advanceOrComplete(instance, definition, step, now); + return; + } + + if (completedTask.status() == TaskStatus.REJECTED && step.policy() == ApprovalPolicy.ALL) { + skipPendingSiblings(siblings, completedTask.id(), now); + completeInstance(instance, ProcessStatus.REJECTED, now); + return; + } + + if (completedTask.status() == TaskStatus.REJECTED) { + // ANY policy: the step is only rejected once every candidate has rejected it. + boolean stepStillAlive = siblings.stream() + .anyMatch(sibling -> !sibling.id().equals(completedTask.id()) + && (sibling.status() == TaskStatus.PENDING || sibling.status() == TaskStatus.APPROVED)); + if (!stepStillAlive) { + completeInstance(instance, ProcessStatus.REJECTED, now); + } + return; + } + + // ALL policy: only advance once every candidate has approved. + boolean allApproved = siblings.stream().allMatch(sibling -> sibling.status() == TaskStatus.APPROVED); + if (allApproved) { + advanceOrComplete(instance, definition, step, now); + } + } + + private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step, Instant now) { + int stepIndex = indexOf(definition, step.id()); + if (stepIndex == definition.steps().size() - 1) { + completeInstance(instance, ProcessStatus.APPROVED, now); + } else { + createStepTasks(instance, definition.steps().get(stepIndex + 1), now); + } + } + + /** 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) { + if (sibling.id().equals(decidedTaskId) || sibling.status() != TaskStatus.PENDING) { + continue; + } + ApprovalTask skipped = new ApprovalTask(sibling.id(), sibling.instanceId(), sibling.stepId(), + sibling.name(), sibling.assignee(), TaskStatus.SKIPPED, sibling.createdAt(), now, null); + // Best-effort: if another concurrent decision already completed this sibling, leave it as-is. + taskRepository.completeIfPending(skipped); + } + } + private void completeInstance(ProcessInstance instance, ProcessStatus status, Instant now) { instanceRepository.update(new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(), status, instance.startedAt(), now, instance.context())); @@ -179,6 +238,13 @@ public final class DefaultOrdoEngine implements OrdoEngine { .orElseThrow(() -> new IllegalStateException("instance not found: " + instanceId)); } + private static ApprovalStep requireStep(ProcessDefinition definition, String stepId) { + return definition.steps().stream() + .filter(step -> step.id().equals(stepId)) + .findFirst() + .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)) { diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryApprovalTaskRepository.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryApprovalTaskRepository.java index 612f0c4..f3a0587 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryApprovalTaskRepository.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/repository/InMemoryApprovalTaskRepository.java @@ -28,6 +28,14 @@ public final class InMemoryApprovalTaskRepository implements ApprovalTaskReposit return tasks.values().stream().filter(task -> task.instanceId().equals(instanceId)).toList(); } + @Override + public synchronized List findByInstanceIdAndStepId(String instanceId, String stepId) { + return tasks.values().stream() + .filter(task -> task.instanceId().equals(instanceId)) + .filter(task -> task.stepId().equals(stepId)) + .toList(); + } + @Override public synchronized List findPendingByAssignee(String assignee) { return tasks.values().stream() 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 22788af..f4e4702 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 @@ -1,5 +1,6 @@ package com.jetlumen.ordo.core; +import com.jetlumen.ordo.api.ApprovalPolicy; import com.jetlumen.ordo.api.ApprovalStep; import com.jetlumen.ordo.api.ApprovalTask; import com.jetlumen.ordo.api.ProcessContext; @@ -30,8 +31,8 @@ class InMemoryOrdoEngineTest { void setUp() { engine = new InMemoryOrdoEngine(); engine.register(new ProcessDefinition("leave", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry") + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry") ))); } @@ -79,7 +80,7 @@ class InMemoryOrdoEngineTest { assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice")); assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria")); assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(new ProcessDefinition( - "leave", "Another leave request", List.of(new ApprovalStep("lead", "Lead approval", "lee"))))); + "leave", "Another leave request", List.of(ApprovalStep.single("lead", "Lead approval", "lee"))))); } @Test @@ -155,13 +156,13 @@ class InMemoryOrdoEngineTest { @Test void resolvesAssigneesFromTheProcessContext() { - InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((step, context) -> context.value(step.id()) + InMemoryOrdoEngine contextAwareEngine = new InMemoryOrdoEngine((candidate, step, context) -> context.value(step.id()) .filter(String.class::isInstance) .map(String.class::cast) - .orElse(step.assignee())); + .orElse(candidate)); contextAwareEngine.register(new ProcessDefinition("leave", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry") + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry") ))); var instance = contextAwareEngine.start("leave", "alice", new ProcessContext(Map.of( @@ -175,4 +176,97 @@ class InMemoryOrdoEngineTest { assertEquals("helena", contextAwareEngine.findPendingTasksByInstanceId(instance.id()).getFirst().assignee()); assertEquals("david", instance.context().value("manager").orElseThrow()); } + + @Test + void anyPolicyAdvancesOnFirstApprovalAndSkipsTheOtherCandidates() { + InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine(); + anyEngine.register(new ProcessDefinition("leave-any", "Leave request", List.of( + new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY), + ApprovalStep.single("hr", "HR approval", "henry") + ))); + + var instance = anyEngine.start("leave-any", "alice"); + List managerTasks = anyEngine.findTasks(instance.id()); + assertEquals(2, managerTasks.size()); + + ApprovalTask mariaTask = managerTasks.stream().filter(t -> t.assignee().equals("maria")).findFirst().orElseThrow(); + ApprovalTask mikeTask = managerTasks.stream().filter(t -> t.assignee().equals("mike")).findFirst().orElseThrow(); + + anyEngine.approve(mariaTask.id(), "maria"); + + assertEquals(TaskStatus.APPROVED, anyEngine.findTasks(instance.id()).stream() + .filter(t -> t.id().equals(mariaTask.id())).findFirst().orElseThrow().status()); + assertEquals(TaskStatus.SKIPPED, anyEngine.findTasks(instance.id()).stream() + .filter(t -> t.id().equals(mikeTask.id())).findFirst().orElseThrow().status()); + assertThrows(TaskAlreadyCompletedException.class, () -> anyEngine.approve(mikeTask.id(), "mike")); + + ApprovalTask hrTask = anyEngine.findPendingTasksByInstanceId(instance.id()).getFirst(); + assertEquals("hr", hrTask.stepId()); + anyEngine.approve(hrTask.id(), "henry"); + assertEquals(ProcessStatus.APPROVED, anyEngine.findInstance(instance.id()).orElseThrow().status()); + } + + @Test + void anyPolicyOnlyRejectsTheStepOnceEveryCandidateHasRejected() { + InMemoryOrdoEngine anyEngine = new InMemoryOrdoEngine(); + anyEngine.register(new ProcessDefinition("leave-any-reject", "Leave request", List.of( + new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ANY) + ))); + + var instance = anyEngine.start("leave-any-reject", "alice"); + List managerTasks = anyEngine.findTasks(instance.id()); + ApprovalTask mariaTask = managerTasks.stream().filter(t -> t.assignee().equals("maria")).findFirst().orElseThrow(); + ApprovalTask mikeTask = managerTasks.stream().filter(t -> t.assignee().equals("mike")).findFirst().orElseThrow(); + + anyEngine.reject(mariaTask.id(), "maria"); + assertEquals(ProcessStatus.RUNNING, anyEngine.findInstance(instance.id()).orElseThrow().status()); + assertEquals(TaskStatus.PENDING, anyEngine.findTask(mikeTask.id()).orElseThrow().status()); + + anyEngine.reject(mikeTask.id(), "mike"); + assertEquals(ProcessStatus.REJECTED, anyEngine.findInstance(instance.id()).orElseThrow().status()); + } + + @Test + void allPolicyOnlyAdvancesOnceEveryCandidateHasApproved() { + InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine(); + allEngine.register(new ProcessDefinition("leave-all", "Leave request", List.of( + new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL), + ApprovalStep.single("hr", "HR approval", "henry") + ))); + + var instance = allEngine.start("leave-all", "alice"); + List managerTasks = allEngine.findTasks(instance.id()); + ApprovalTask mariaTask = managerTasks.stream().filter(t -> t.assignee().equals("maria")).findFirst().orElseThrow(); + ApprovalTask mikeTask = managerTasks.stream().filter(t -> t.assignee().equals("mike")).findFirst().orElseThrow(); + + allEngine.approve(mariaTask.id(), "maria"); + assertTrue(allEngine.findPendingTasksByInstanceId(instance.id()).stream() + .anyMatch(t -> t.id().equals(mikeTask.id()))); + assertEquals(ProcessStatus.RUNNING, allEngine.findInstance(instance.id()).orElseThrow().status()); + + allEngine.approve(mikeTask.id(), "mike"); + ApprovalTask hrTask = allEngine.findPendingTasksByInstanceId(instance.id()).getFirst(); + assertEquals("hr", hrTask.stepId()); + allEngine.approve(hrTask.id(), "henry"); + assertEquals(ProcessStatus.APPROVED, allEngine.findInstance(instance.id()).orElseThrow().status()); + } + + @Test + void allPolicyFailsFastAndSkipsRemainingCandidatesOnASingleRejection() { + InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine(); + allEngine.register(new ProcessDefinition("leave-all-reject", "Leave request", List.of( + new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL) + ))); + + var instance = allEngine.start("leave-all-reject", "alice"); + List managerTasks = allEngine.findTasks(instance.id()); + ApprovalTask mariaTask = managerTasks.stream().filter(t -> t.assignee().equals("maria")).findFirst().orElseThrow(); + ApprovalTask mikeTask = managerTasks.stream().filter(t -> t.assignee().equals("mike")).findFirst().orElseThrow(); + + allEngine.reject(mariaTask.id(), "maria"); + + assertEquals(ProcessStatus.REJECTED, allEngine.findInstance(instance.id()).orElseThrow().status()); + assertEquals(TaskStatus.SKIPPED, allEngine.findTask(mikeTask.id()).orElseThrow().status()); + assertNull(allEngine.findTask(mikeTask.id()).orElseThrow().action()); + } } 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 2e3a14a..cd8ec5a 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 @@ -16,8 +16,8 @@ public final class LeaveRequestExample { public static void main(String[] args) { OrdoEngine ordo = new InMemoryOrdoEngine(); ordo.register(new ProcessDefinition("leave-request", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry") + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry") ))); var instance = ordo.start("leave-request", "alice", new ProcessContext(Map.of( 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 445c000..5221a29 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 @@ -22,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", - List.of(new ApprovalStep("manager", "Manager approval", "maria"))); + List.of(ApprovalStep.single("manager", "Manager approval", "maria"))); private final ApplicationContextRunner withDataSourceRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of( @@ -71,7 +71,7 @@ class OrdoJdbcAutoConfigurationTest { .run(context -> { assertThat(context).hasSingleBean(OrdoEngine.class); assertThat(context.getBean(AssigneeResolver.class)).isNotNull(); - assertThat(context.getBean(AssigneeResolver.class).resolve(null, null)) + assertThat(context.getBean(AssigneeResolver.class).resolve(null, null, null)) .isEqualTo("custom-assignee"); }); } @@ -80,7 +80,7 @@ class OrdoJdbcAutoConfigurationTest { static class CustomAssigneeResolverConfig { @Bean AssigneeResolver ordoAssigneeResolver() { - return (step, ctx) -> "custom-assignee"; + return (candidate, step, ctx) -> "custom-assignee"; } } } diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepository.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepository.java index 4f6919a..770e061 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepository.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/JdbcApprovalTaskRepository.java @@ -27,6 +27,9 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository "SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE id = ?"; private static final String SELECT_BY_INSTANCE = "SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE instance_id = ? ORDER BY created_at, id"; + private static final String SELECT_BY_INSTANCE_AND_STEP = + "SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE instance_id = ? AND step_id = ?" + + " ORDER BY created_at, id"; private static final String SELECT_PENDING_BY_ASSIGNEE = "SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND assignee = ?" + " ORDER BY created_at, id"; @@ -64,6 +67,26 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository return findAll(SELECT_BY_INSTANCE, instanceId); } + @Override + public List findByInstanceIdAndStepId(String instanceId, String stepId) { + Connection connection = connectionProvider.getConnection(); + try (PreparedStatement select = connection.prepareStatement(SELECT_BY_INSTANCE_AND_STEP)) { + select.setString(1, instanceId); + select.setString(2, stepId); + try (ResultSet resultSet = select.executeQuery()) { + List tasks = new ArrayList<>(); + while (resultSet.next()) { + tasks.add(ApprovalTaskMapper.read(resultSet)); + } + return tasks; + } + } catch (SQLException e) { + throw new JdbcStorageException("failed to query tasks for step: " + stepId, e); + } finally { + connectionProvider.close(connection); + } + } + @Override public List findPendingByAssignee(String assignee) { return findAll(SELECT_PENDING_BY_ASSIGNEE, assignee); 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 34384d2..3baaae2 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 @@ -4,26 +4,33 @@ import com.jetlumen.ordo.api.ApprovalStep; import com.jetlumen.ordo.api.ProcessDefinition; 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 java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; -/** JDBC implementation of the definition storage port; steps live in a separate table. */ +/** JDBC implementation of the definition storage port; steps and their candidates live in separate tables. */ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionRepository { private static final String INSERT_DEFINITION = "INSERT INTO ordo_process_definition (id, name) VALUES (?, ?)"; private static final String INSERT_STEP = - "INSERT INTO ordo_approval_step (definition_id, step_id, step_name, assignee, step_order) VALUES (?, ?, ?, ?, ?)"; + "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 SELECT_DEFINITION = "SELECT id, name FROM ordo_process_definition WHERE id = ?"; private static final String SELECT_STEPS = - "SELECT step_id, step_name, assignee FROM ordo_approval_step WHERE definition_id = ? ORDER BY step_order"; + "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 final JdbcConnectionProvider connectionProvider; @@ -46,16 +53,26 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR } throw new JdbcStorageException("failed to insert definition: " + definition.id(), e); } - int order = 0; + int stepOrder = 0; for (ApprovalStep step : definition.steps()) { try (PreparedStatement insertStep = connection.prepareStatement(INSERT_STEP)) { insertStep.setString(1, definition.id()); insertStep.setString(2, step.id()); insertStep.setString(3, step.name()); - insertStep.setString(4, step.assignee()); - insertStep.setInt(5, order++); + insertStep.setString(4, step.policy().name()); + insertStep.setInt(5, stepOrder++); insertStep.executeUpdate(); } + int candidateOrder = 0; + for (String candidate : step.candidates()) { + try (PreparedStatement insertCandidate = connection.prepareStatement(INSERT_CANDIDATE)) { + insertCandidate.setString(1, definition.id()); + insertCandidate.setString(2, step.id()); + insertCandidate.setString(3, candidate); + insertCandidate.setInt(4, candidateOrder++); + insertCandidate.executeUpdate(); + } + } } return true; } catch (SQLException e) { @@ -79,15 +96,30 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR name = resultSet.getString("name"); } } - List steps = new ArrayList<>(); + List stepRows = new ArrayList<>(); try (PreparedStatement selectSteps = connection.prepareStatement(SELECT_STEPS)) { selectSteps.setString(1, definitionId); try (ResultSet resultSet = selectSteps.executeQuery()) { while (resultSet.next()) { - steps.add(ApprovalStepMapper.read(resultSet)); + stepRows.add(ApprovalStepMapper.readRow(resultSet)); } } } + Map> candidatesByStep = new LinkedHashMap<>(); + try (PreparedStatement selectCandidates = connection.prepareStatement(SELECT_CANDIDATES)) { + selectCandidates.setString(1, definitionId); + try (ResultSet resultSet = selectCandidates.executeQuery()) { + while (resultSet.next()) { + candidatesByStep.computeIfAbsent(resultSet.getString("step_id"), key -> new ArrayList<>()) + .add(resultSet.getString("candidate")); + } + } + } + List steps = new ArrayList<>(); + for (StepRow row : stepRows) { + 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)); } catch (SQLException e) { throw new JdbcStorageException("failed to load definition: " + definitionId, e); diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalStepMapper.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalStepMapper.java index 4f557ff..468434e 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalStepMapper.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalStepMapper.java @@ -1,17 +1,24 @@ package com.jetlumen.ordo.storage.jdbc.mapper; -import com.jetlumen.ordo.api.ApprovalStep; +import com.jetlumen.ordo.api.ApprovalPolicy; import java.sql.ResultSet; import java.sql.SQLException; -/** Maps rows of {@code ordo_approval_step} to {@link ApprovalStep} objects. */ +/** + * Maps rows of {@code ordo_approval_step}. A step's candidate list lives in the separate + * {@code ordo_step_candidate} table, so this only reads the scalar step columns; the repository + * assembles the full {@link com.jetlumen.ordo.api.ApprovalStep} once candidates are loaded. + */ public final class ApprovalStepMapper { private ApprovalStepMapper() { } - public static ApprovalStep read(ResultSet resultSet) throws SQLException { - return new ApprovalStep(resultSet.getString("step_id"), resultSet.getString("step_name"), - resultSet.getString("assignee")); + public static StepRow readRow(ResultSet resultSet) throws SQLException { + return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"), + ApprovalPolicy.valueOf(resultSet.getString("policy"))); + } + + public record StepRow(String stepId, String stepName, ApprovalPolicy policy) { } } diff --git a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalTaskMapper.java b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalTaskMapper.java index 873bb44..86b279c 100644 --- a/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalTaskMapper.java +++ b/ordo-storage-jdbc/src/main/java/com/jetlumen/ordo/storage/jdbc/mapper/ApprovalTaskMapper.java @@ -8,6 +8,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.sql.Types; /** Maps rows of {@code ordo_approval_task} to {@link ApprovalTask} objects and back. */ public final class ApprovalTaskMapper { @@ -28,9 +29,16 @@ public final class ApprovalTaskMapper { TaskAction action = completedTask.action(); statement.setString(1, completedTask.status().name()); statement.setTimestamp(2, Timestamp.from(completedTask.completedAt())); - statement.setString(3, action.actor()); - statement.setString(4, action.comment()); - statement.setTimestamp(5, Timestamp.from(action.operatedAt())); + if (action == null) { + // SKIPPED tasks have no actor - a sibling candidate's decision already settled the step. + statement.setNull(3, Types.VARCHAR); + statement.setNull(4, Types.VARCHAR); + statement.setNull(5, Types.TIMESTAMP); + } else { + statement.setString(3, action.actor()); + statement.setString(4, action.comment()); + statement.setTimestamp(5, Timestamp.from(action.operatedAt())); + } statement.setString(6, completedTask.id()); } diff --git a/ordo-storage-jdbc/src/main/resources/db/migration/V2__add_step_candidates_and_policy.sql b/ordo-storage-jdbc/src/main/resources/db/migration/V2__add_step_candidates_and_policy.sql new file mode 100644 index 0000000..f1c7cdf --- /dev/null +++ b/ordo-storage-jdbc/src/main/resources/db/migration/V2__add_step_candidates_and_policy.sql @@ -0,0 +1,23 @@ +-- Adds multi-candidate (any/all) approval step support. +-- A step no longer has a single fixed assignee; instead it has one or more +-- candidates in ordo_step_candidate, and a policy column decides whether any +-- single candidate approval is enough (ANY) or every candidate must approve (ALL). + +ALTER TABLE ordo_approval_step ADD COLUMN policy VARCHAR(16) NOT NULL DEFAULT 'ANY'; + +CREATE TABLE ordo_step_candidate ( + definition_id VARCHAR(64) NOT NULL, + step_id VARCHAR(64) NOT NULL, + candidate VARCHAR(255) NOT NULL, + candidate_order INTEGER NOT NULL, + PRIMARY KEY (definition_id, step_id, candidate), + CONSTRAINT fk_step_candidate_step FOREIGN KEY (definition_id, step_id) + REFERENCES ordo_approval_step (definition_id, step_id) +); + +-- Migrate any existing single-assignee steps into the new candidate table before +-- the now-unused column is dropped. +INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) +SELECT definition_id, step_id, assignee, 0 FROM ordo_approval_step; + +ALTER TABLE ordo_approval_step DROP COLUMN assignee; 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 3eb690d..d9978a1 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 @@ -39,7 +39,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", - "Leave request", List.of(new ApprovalStep("manager", "Manager approval", "maria")))); + "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, CREATED_AT, null, ProcessContext.empty())); 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 281ffb5..b46f556 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 @@ -36,8 +36,8 @@ class JdbcOrdoEngineIntegrationTest { connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource()); engine = newEngine(AssigneeResolver.direct()); engine.register(new ProcessDefinition("leave", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry")))); + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry")))); } @Test @@ -88,15 +88,15 @@ class JdbcOrdoEngineIntegrationTest { @Test void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() { - OrdoEngine failingEngine = newEngine((step, context) -> { + OrdoEngine failingEngine = newEngine((candidate, step, context) -> { if (step.id().equals("hr")) { throw new IllegalStateException("no hr approval today"); } - return step.assignee(); + return candidate; }); failingEngine.register(new ProcessDefinition("leave2", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry")))); + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry")))); ProcessInstance instance = failingEngine.start("leave2", "alice"); ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst(); 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 7d5da5c..8a1d08f 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 @@ -103,8 +103,8 @@ class JdbcPostgresIntegrationTest { void engineCompletesASequentialApprovalProcessOverPostgres() { OrdoEngine engine = newEngine(AssigneeResolver.direct()); engine.register(new ProcessDefinition("leave-pg", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry")))); + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry")))); ProcessInstance instance = engine.start("leave-pg", "alice", new ProcessContext(Map.of("requestId", "LEAVE-2026-001", "days", 5))); @@ -123,18 +123,18 @@ class JdbcPostgresIntegrationTest { void rejectsDuplicateDefinitionIdsViaTheDatabaseUniqueConstraint() { JdbcProcessDefinitionRepository repository = new JdbcProcessDefinitionRepository(connectionProvider); ProcessDefinition definition = new ProcessDefinition("leave-dup-pg", "Leave request", - List.of(new ApprovalStep("manager", "Manager approval", "maria"))); + List.of(ApprovalStep.single("manager", "Manager approval", "maria"))); assertTrue(repository.insertIfAbsent(definition)); assertFalse(repository.insertIfAbsent(new ProcessDefinition("leave-dup-pg", "Second attempt", - List.of(new ApprovalStep("manager", "Manager approval", "maria"))))); + 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( - "leave-dup-inst-pg", "Leave request", List.of(new ApprovalStep("manager", "Manager approval", "maria")))); + "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", ProcessStatus.RUNNING, NOW, null, ProcessContext.empty()); @@ -146,7 +146,7 @@ class JdbcPostgresIntegrationTest { @Test void roundsTimestampsToMicrosecondPrecision() { new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( - "leave-time-pg", "Leave request", List.of(new ApprovalStep("manager", "Manager approval", "maria")))); + "leave-time-pg", "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider); Instant microAligned = Instant.parse("2026-01-15T09:00:00.123456Z"); @@ -165,7 +165,7 @@ class JdbcPostgresIntegrationTest { @Test void onlyOneOfTwoConcurrentCompletionsWinsOnPostgres() throws Exception { new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition( - "leave-race-pg", "Leave request", List.of(new ApprovalStep("manager", "Manager approval", "maria")))); + "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", ProcessStatus.RUNNING, NOW, null, ProcessContext.empty())); @@ -194,15 +194,15 @@ class JdbcPostgresIntegrationTest { @Test void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() { - OrdoEngine failingEngine = newEngine((step, context) -> { + OrdoEngine failingEngine = newEngine((candidate, step, context) -> { if (step.id().equals("hr")) { throw new IllegalStateException("no hr approval today"); } - return step.assignee(); + return candidate; }); failingEngine.register(new ProcessDefinition("leave-rollback-pg", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry")))); + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry")))); ProcessInstance instance = failingEngine.start("leave-rollback-pg", "alice"); ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst(); 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 5c07ebc..6cb1320 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 @@ -23,8 +23,8 @@ class JdbcProcessDefinitionRepositoryTest { @Test void insertsAndReadsBackADefinitionWithItsStepsInOrder() { ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", List.of( - new ApprovalStep("manager", "Manager approval", "maria"), - new ApprovalStep("hr", "HR approval", "henry"))); + ApprovalStep.single("manager", "Manager approval", "maria"), + ApprovalStep.single("hr", "HR approval", "henry"))); assertTrue(repository.insertIfAbsent(definition)); assertEquals(definition, repository.findById("leave").orElseThrow()); @@ -44,6 +44,6 @@ class JdbcProcessDefinitionRepositoryTest { } private static ProcessDefinition definition(String id, String name) { - return new ProcessDefinition(id, name, List.of(new ApprovalStep("lead", "Lead approval", "lee"))); + return new ProcessDefinition(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 5982574..5e80424 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 @@ -28,7 +28,7 @@ class JdbcProcessInstanceRepositoryTest { repository = new JdbcProcessInstanceRepository(connectionProvider); // instances reference their definition, so the parent row must exist new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition("leave", - "Leave request", List.of(new ApprovalStep("manager", "Manager approval", "maria")))); + "Leave request", List.of(ApprovalStep.single("manager", "Manager approval", "maria")))); } @Test 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 21a48ad..2b6f42c 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 @@ -13,7 +13,11 @@ import java.util.UUID; /** Creates isolated in-memory H2 databases with the Ordo schema applied. */ final class JdbcTestSupport { - private static final String SCHEMA_SQL = loadSchema(); + private static final String[] MIGRATIONS = { + "/db/migration/V1__create_ordo_tables.sql", + "/db/migration/V2__add_step_candidates_and_policy.sql" + }; + private static final String[] SCHEMA_SQL = loadSchemas(); private JdbcTestSupport() { } @@ -27,12 +31,14 @@ final class JdbcTestSupport { return dataSource; } - /** Applies the V1 migration script to an empty database, e.g. a PostgreSQL test container. */ + /** Applies every migration script in order to an empty database, e.g. a PostgreSQL test container. */ static void applySchema(DataSource dataSource) { try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { - for (String sql : SCHEMA_SQL.split(";")) { - if (!sql.isBlank()) { - statement.execute(sql); + for (String migration : SCHEMA_SQL) { + for (String sql : stripComments(migration).split(";")) { + if (!sql.isBlank()) { + statement.execute(sql); + } } } } catch (SQLException e) { @@ -40,14 +46,36 @@ final class JdbcTestSupport { } } - private static String loadSchema() { - try (InputStream input = JdbcTestSupport.class.getResourceAsStream("/db/migration/V1__create_ordo_tables.sql")) { + /** + * Removes {@code --} line comments before splitting on {@code ;}, since a semicolon inside a + * comment (e.g. "assignee; instead of...") would otherwise be mistaken for a statement break. + */ + private static String stripComments(String sql) { + StringBuilder result = new StringBuilder(); + for (String line : sql.split("\\r?\\n")) { + int commentIndex = line.indexOf("--"); + result.append(commentIndex >= 0 ? line.substring(0, commentIndex) : line).append('\n'); + } + return result.toString(); + } + + private static String[] loadSchemas() { + String[] schemas = new String[MIGRATIONS.length]; + for (int i = 0; i < MIGRATIONS.length; i++) { + schemas[i] = loadSchema(MIGRATIONS[i]); + } + return schemas; + } + + private static String loadSchema(String resourcePath) { + try (InputStream input = JdbcTestSupport.class.getResourceAsStream(resourcePath)) { if (input == null) { - throw new IllegalStateException("V1__create_ordo_tables.sql not found on the classpath"); + throw new IllegalStateException(resourcePath + " not found on the classpath"); } return new String(input.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { - throw new IllegalStateException("failed to read V1__create_ordo_tables.sql", e); + throw new IllegalStateException("failed to read " + resourcePath, e); } } } +