feat: add ACTION steps that run after the approval transaction commits
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a named action step against the running instance's context. Hosts supply a
|
||||||
|
* singleton implementation (same pattern as {@link RoutingCondition}); the database only stores
|
||||||
|
* the {@code actionKey} string.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ActionHandler {
|
||||||
|
void execute(String actionKey, ProcessContext context);
|
||||||
|
|
||||||
|
static ActionHandler noop() {
|
||||||
|
return (key, context) -> {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,17 +6,29 @@ import java.util.Objects;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single, named approval step in a process definition. A step has one or more candidate
|
* A named step in a process definition. Approval steps have one or more candidate assignees;
|
||||||
* assignees; when there is more than one candidate, {@link #policy()} decides whether any
|
* action steps have an {@code actionKey} invoked by the host {@link ActionHandler}.
|
||||||
* single approval is enough ({@link ApprovalPolicy#ANY}) or every candidate must approve
|
|
||||||
* ({@link ApprovalPolicy#ALL}).
|
|
||||||
*/
|
*/
|
||||||
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
|
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy, StepKind kind,
|
||||||
|
String actionKey) {
|
||||||
public ApprovalStep {
|
public ApprovalStep {
|
||||||
requireText(id, "step id");
|
requireText(id, "step id");
|
||||||
requireText(name, "step name");
|
requireText(name, "step name");
|
||||||
|
kind = kind == null ? StepKind.APPROVAL : kind;
|
||||||
|
Objects.requireNonNull(policy, "policy must not be null");
|
||||||
Objects.requireNonNull(candidates, "candidates must not be null");
|
Objects.requireNonNull(candidates, "candidates must not be null");
|
||||||
candidates = List.copyOf(candidates);
|
candidates = List.copyOf(candidates);
|
||||||
|
if (kind == StepKind.ACTION) {
|
||||||
|
if (!candidates.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("an action step must not have candidates");
|
||||||
|
}
|
||||||
|
requireText(actionKey, "action key");
|
||||||
|
actionKey = actionKey.strip();
|
||||||
|
} else {
|
||||||
|
if (actionKey != null && !actionKey.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("an approval step must not have an action key");
|
||||||
|
}
|
||||||
|
actionKey = null;
|
||||||
if (candidates.isEmpty()) {
|
if (candidates.isEmpty()) {
|
||||||
throw new IllegalArgumentException("a step must have at least one candidate");
|
throw new IllegalArgumentException("a step must have at least one candidate");
|
||||||
}
|
}
|
||||||
@@ -27,7 +39,11 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
|
|||||||
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
|
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Objects.requireNonNull(policy, "policy must not be null");
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
|
||||||
|
this(id, name, candidates, policy, StepKind.APPROVAL, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convenience factory for the common case of a single, fixed approver. */
|
/** Convenience factory for the common case of a single, fixed approver. */
|
||||||
@@ -35,6 +51,10 @@ public record ApprovalStep(String id, String name, List<String> candidates, Appr
|
|||||||
return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY);
|
return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ApprovalStep action(String id, String name, String actionKey) {
|
||||||
|
return new ApprovalStep(id, name, List.of(), ApprovalPolicy.ANY, StepKind.ACTION, actionKey);
|
||||||
|
}
|
||||||
|
|
||||||
static void requireText(String value, String field) {
|
static void requireText(String value, String field) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
throw new IllegalArgumentException(field + " must not be blank");
|
throw new IllegalArgumentException(field + " must not be blank");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public record ProcessDefinition(String id, String name, List<ApprovalStep> steps
|
|||||||
ApprovalStep.requireText(name, "definition name");
|
ApprovalStep.requireText(name, "definition name");
|
||||||
steps = List.copyOf(steps);
|
steps = List.copyOf(steps);
|
||||||
if (steps.isEmpty()) {
|
if (steps.isEmpty()) {
|
||||||
throw new IllegalArgumentException("a definition must contain at least one approval step");
|
throw new IllegalArgumentException("a definition must contain at least one step");
|
||||||
}
|
}
|
||||||
Set<String> ids = new HashSet<>();
|
Set<String> ids = new HashSet<>();
|
||||||
for (ApprovalStep step : steps) {
|
for (ApprovalStep step : steps) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public final class ProcessDefinitionParser {
|
|||||||
|
|
||||||
private static ProcessDefinition toDefinition(DefinitionDocument document) {
|
private static ProcessDefinition toDefinition(DefinitionDocument document) {
|
||||||
if (document.steps() == null || document.steps().isEmpty()) {
|
if (document.steps() == null || document.steps().isEmpty()) {
|
||||||
throw new IllegalArgumentException("a definition must contain at least one approval step");
|
throw new IllegalArgumentException("a definition must contain at least one step");
|
||||||
}
|
}
|
||||||
List<ApprovalStep> steps = new ArrayList<>(document.steps().size());
|
List<ApprovalStep> steps = new ArrayList<>(document.steps().size());
|
||||||
for (StepDocument step : document.steps()) {
|
for (StepDocument step : document.steps()) {
|
||||||
@@ -45,7 +45,9 @@ public final class ProcessDefinitionParser {
|
|||||||
throw new IllegalArgumentException("step must not be null");
|
throw new IllegalArgumentException("step must not be null");
|
||||||
}
|
}
|
||||||
ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy();
|
ApprovalPolicy policy = step.policy() == null ? ApprovalPolicy.ANY : step.policy();
|
||||||
steps.add(new ApprovalStep(step.id(), step.name(), step.candidates(), policy));
|
StepKind kind = step.kind() == null ? StepKind.APPROVAL : step.kind();
|
||||||
|
List<String> candidates = step.candidates() == null ? List.of() : step.candidates();
|
||||||
|
steps.add(new ApprovalStep(step.id(), step.name(), candidates, policy, kind, step.action()));
|
||||||
}
|
}
|
||||||
rotateStartStep(steps, document.startStep());
|
rotateStartStep(steps, document.startStep());
|
||||||
List<StepTransition> transitions = new ArrayList<>();
|
List<StepTransition> transitions = new ArrayList<>();
|
||||||
@@ -90,7 +92,13 @@ public final class ProcessDefinitionParser {
|
|||||||
List<TransitionDocument> transitions) {
|
List<TransitionDocument> transitions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record StepDocument(String id, String name, List<String> candidates, ApprovalPolicy policy) {
|
private record StepDocument(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
List<String> candidates,
|
||||||
|
ApprovalPolicy policy,
|
||||||
|
StepKind kind,
|
||||||
|
@JsonProperty("action") String action) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record TransitionDocument(
|
private record TransitionDocument(
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
public enum StepKind {
|
||||||
|
APPROVAL, ACTION
|
||||||
|
}
|
||||||
@@ -153,4 +153,25 @@ class ProcessDefinitionParserTest {
|
|||||||
void rejectsMalformedJson() {
|
void rejectsMalformedJson() {
|
||||||
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
|
assertThrows(IllegalArgumentException.class, () -> ProcessDefinitionParser.fromJson("{"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesActionStepsAndDefaultsKindToApproval() {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"id": "leave",
|
||||||
|
"name": "Leave request",
|
||||||
|
"steps": [
|
||||||
|
{ "id": "manager", "name": "Manager approval", "candidates": ["maria"] },
|
||||||
|
{ "id": "notify", "name": "Notify HR", "kind": "ACTION", "action": "leave-approved-mail" }
|
||||||
|
],
|
||||||
|
"transitions": [
|
||||||
|
{ "from": "manager", "to": "notify" },
|
||||||
|
{ "from": "notify", "to": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
ProcessDefinition definition = ProcessDefinitionParser.fromJson(json);
|
||||||
|
assertEquals(StepKind.APPROVAL, definition.steps().getFirst().kind());
|
||||||
|
assertEquals(ApprovalStep.action("notify", "Notify HR", "leave-approved-mail"), definition.steps().get(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,4 +94,28 @@ class ProcessDefinitionTest {
|
|||||||
StepTransition.end("hr"),
|
StepTransition.end("hr"),
|
||||||
StepTransition.always("manager", "hr")), definition.transitions());
|
StepTransition.always("manager", "hr")), definition.transitions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsActionStepsWithCandidatesOrBlankActionKey() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new ApprovalStep("notify", "Notify", List.of("maria"), ApprovalPolicy.ANY, StepKind.ACTION,
|
||||||
|
"mail"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.action("notify", "Notify", " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsApprovalStepsWithAnActionKey() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new ApprovalStep("manager", "Manager", List.of("maria"), ApprovalPolicy.ANY, StepKind.APPROVAL,
|
||||||
|
"mail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsADefinitionThatIsOnlyActionSteps() {
|
||||||
|
ProcessDefinition definition = new ProcessDefinition("notify", "Notify", List.of(
|
||||||
|
ApprovalStep.action("mail", "Send mail", "leave-approved-mail")),
|
||||||
|
List.of(StepTransition.end("mail")));
|
||||||
|
assertEquals(StepKind.ACTION, definition.steps().getFirst().kind());
|
||||||
|
assertEquals("leave-approved-mail", definition.steps().getFirst().actionKey());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalPolicy;
|
import com.jetlumen.ordo.api.ApprovalPolicy;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
@@ -10,6 +11,7 @@ import com.jetlumen.ordo.api.ProcessDefinition;
|
|||||||
import com.jetlumen.ordo.api.ProcessInstance;
|
import com.jetlumen.ordo.api.ProcessInstance;
|
||||||
import com.jetlumen.ordo.api.ProcessStatus;
|
import com.jetlumen.ordo.api.ProcessStatus;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
|
import com.jetlumen.ordo.api.StepKind;
|
||||||
import com.jetlumen.ordo.api.StepTransition;
|
import com.jetlumen.ordo.api.StepTransition;
|
||||||
import com.jetlumen.ordo.api.TaskAction;
|
import com.jetlumen.ordo.api.TaskAction;
|
||||||
import com.jetlumen.ordo.api.TaskStatus;
|
import com.jetlumen.ordo.api.TaskStatus;
|
||||||
@@ -30,6 +32,9 @@ import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
|||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.lang.System.Logger;
|
||||||
|
import java.lang.System.Logger.Level;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -44,22 +49,27 @@ import java.util.UUID;
|
|||||||
* when several JVMs share the same storage.
|
* when several JVMs share the same storage.
|
||||||
*/
|
*/
|
||||||
public final class DefaultOrdoEngine implements OrdoEngine {
|
public final class DefaultOrdoEngine implements OrdoEngine {
|
||||||
|
private static final int MAX_CONSECUTIVE_ACTIONS = 32;
|
||||||
|
private static final Logger LOG = System.getLogger("ordo");
|
||||||
|
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final AssigneeResolver assigneeResolver;
|
private final AssigneeResolver assigneeResolver;
|
||||||
private final RoutingCondition routingCondition;
|
private final RoutingCondition routingCondition;
|
||||||
|
private final ActionHandler actionHandler;
|
||||||
private final TransactionExecutor transactionExecutor;
|
private final TransactionExecutor transactionExecutor;
|
||||||
private final ProcessDefinitionRepository definitionRepository;
|
private final ProcessDefinitionRepository definitionRepository;
|
||||||
private final ProcessInstanceRepository instanceRepository;
|
private final ProcessInstanceRepository instanceRepository;
|
||||||
private final ApprovalTaskRepository taskRepository;
|
private final ApprovalTaskRepository taskRepository;
|
||||||
|
|
||||||
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
public DefaultOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
||||||
TransactionExecutor transactionExecutor,
|
ActionHandler actionHandler, TransactionExecutor transactionExecutor,
|
||||||
ProcessDefinitionRepository definitionRepository,
|
ProcessDefinitionRepository definitionRepository,
|
||||||
ProcessInstanceRepository instanceRepository,
|
ProcessInstanceRepository instanceRepository,
|
||||||
ApprovalTaskRepository taskRepository) {
|
ApprovalTaskRepository taskRepository) {
|
||||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||||
this.assigneeResolver = Objects.requireNonNull(assigneeResolver, "assigneeResolver 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.routingCondition = Objects.requireNonNull(routingCondition, "routingCondition must not be null");
|
||||||
|
this.actionHandler = Objects.requireNonNull(actionHandler, "actionHandler must not be null");
|
||||||
this.transactionExecutor = Objects.requireNonNull(transactionExecutor, "transactionExecutor 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.definitionRepository = Objects.requireNonNull(definitionRepository, "definitionRepository must not be null");
|
||||||
this.instanceRepository = Objects.requireNonNull(instanceRepository, "instanceRepository must not be null");
|
this.instanceRepository = Objects.requireNonNull(instanceRepository, "instanceRepository must not be null");
|
||||||
@@ -93,26 +103,32 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
public synchronized ProcessInstance start(String definitionId, String initiator, ProcessContext context) {
|
public synchronized ProcessInstance start(String definitionId, String initiator, ProcessContext context) {
|
||||||
requireText(initiator, "initiator");
|
requireText(initiator, "initiator");
|
||||||
Objects.requireNonNull(context, "context must not be null");
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
return transactionExecutor.execute(() -> {
|
List<PendingAction> queued = new ArrayList<>();
|
||||||
|
ProcessInstance instance = transactionExecutor.execute(() -> {
|
||||||
ProcessDefinition definition = requireDefinition(definitionId);
|
ProcessDefinition definition = requireDefinition(definitionId);
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ProcessInstance instance = new ProcessInstance(nextId(), definition.id(), initiator,
|
ProcessInstance started = new ProcessInstance(nextId(), definition.id(), initiator,
|
||||||
ProcessStatus.RUNNING, now, null, context);
|
ProcessStatus.RUNNING, now, null, context);
|
||||||
instanceRepository.insert(instance);
|
instanceRepository.insert(started);
|
||||||
createStepTasks(instance, definition.steps().getFirst(), now);
|
enterStep(started, definition, definition.steps().getFirst(), now, queued);
|
||||||
return instance;
|
return started;
|
||||||
});
|
});
|
||||||
|
runQueuedActions(queued);
|
||||||
|
return instanceRepository.findById(instance.id()).orElse(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized ApprovalTask approve(String taskId, String actor, String comment) {
|
public synchronized ApprovalTask approve(String taskId, String actor, String comment) {
|
||||||
return transactionExecutor.execute(() -> {
|
List<PendingAction> queued = new ArrayList<>();
|
||||||
|
ApprovalTask completed = transactionExecutor.execute(() -> {
|
||||||
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now));
|
ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now));
|
||||||
advanceAfterDecision(completedTask, now);
|
advanceAfterDecision(completedTask, now, queued);
|
||||||
return completedTask;
|
return completedTask;
|
||||||
});
|
});
|
||||||
|
runQueuedActions(queued);
|
||||||
|
return completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -121,7 +137,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ApprovalTask completedTask = completeTask(task, TaskStatus.REJECTED, new TaskAction(actor, comment, now));
|
ApprovalTask completedTask = completeTask(task, TaskStatus.REJECTED, new TaskAction(actor, comment, now));
|
||||||
advanceAfterDecision(completedTask, now);
|
advanceAfterDecision(completedTask, now, List.of());
|
||||||
return completedTask;
|
return completedTask;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -217,7 +233,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
* Decides whether the step (and the process instance) can move on after a single candidate
|
* 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}.
|
* task was approved or rejected, applying the step's {@link ApprovalPolicy}.
|
||||||
*/
|
*/
|
||||||
private void advanceAfterDecision(ApprovalTask completedTask, Instant now) {
|
private void advanceAfterDecision(ApprovalTask completedTask, Instant now, List<PendingAction> queued) {
|
||||||
ProcessInstance instance = requireInstance(completedTask.instanceId());
|
ProcessInstance instance = requireInstance(completedTask.instanceId());
|
||||||
ProcessDefinition definition = requireDefinition(instance.definitionId());
|
ProcessDefinition definition = requireDefinition(instance.definitionId());
|
||||||
ApprovalStep step = requireStep(definition, completedTask.stepId());
|
ApprovalStep step = requireStep(definition, completedTask.stepId());
|
||||||
@@ -225,7 +241,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
|
|
||||||
if (completedTask.status() == TaskStatus.APPROVED && step.policy() == ApprovalPolicy.ANY) {
|
if (completedTask.status() == TaskStatus.APPROVED && step.policy() == ApprovalPolicy.ANY) {
|
||||||
skipPendingSiblings(siblings, completedTask.id(), now);
|
skipPendingSiblings(siblings, completedTask.id(), now);
|
||||||
advanceOrComplete(instance, definition, step, now);
|
advanceOrComplete(instance, definition, step, now, queued);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,16 +265,46 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
// ALL policy: only advance once every candidate has approved.
|
// ALL policy: only advance once every candidate has approved.
|
||||||
boolean allApproved = siblings.stream().allMatch(sibling -> sibling.status() == TaskStatus.APPROVED);
|
boolean allApproved = siblings.stream().allMatch(sibling -> sibling.status() == TaskStatus.APPROVED);
|
||||||
if (allApproved) {
|
if (allApproved) {
|
||||||
advanceOrComplete(instance, definition, step, now);
|
advanceOrComplete(instance, definition, step, now, queued);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step, Instant now) {
|
private void advanceOrComplete(ProcessInstance instance, ProcessDefinition definition, ApprovalStep step,
|
||||||
|
Instant now, List<PendingAction> queued) {
|
||||||
StepTransition matched = resolveTransition(definition, step, instance);
|
StepTransition matched = resolveTransition(definition, step, instance);
|
||||||
if (matched.toStepId() == null) {
|
if (matched.toStepId() == null) {
|
||||||
completeInstance(instance, ProcessStatus.APPROVED, now);
|
completeInstance(instance, ProcessStatus.APPROVED, now);
|
||||||
} else {
|
} else {
|
||||||
createStepTasks(instance, requireStep(definition, matched.toStepId()), now);
|
enterStep(instance, definition, requireStep(definition, matched.toStepId()), now, queued);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enterStep(ProcessInstance instance, ProcessDefinition definition, ApprovalStep start, Instant now,
|
||||||
|
List<PendingAction> queued) {
|
||||||
|
ApprovalStep current = start;
|
||||||
|
for (int hops = 0; hops < MAX_CONSECUTIVE_ACTIONS; hops++) {
|
||||||
|
if (current.kind() == StepKind.APPROVAL) {
|
||||||
|
createStepTasks(instance, current, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queued.add(new PendingAction(current.actionKey(), instance.context()));
|
||||||
|
StepTransition matched = resolveTransition(definition, current, instance);
|
||||||
|
if (matched.toStepId() == null) {
|
||||||
|
completeInstance(instance, ProcessStatus.APPROVED, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = requireStep(definition, matched.toStepId());
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("too many consecutive action steps in instance: " + instance.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runQueuedActions(List<PendingAction> queued) {
|
||||||
|
for (PendingAction pending : queued) {
|
||||||
|
try {
|
||||||
|
actionHandler.execute(pending.actionKey(), pending.context());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.log(Level.WARNING, "action failed: " + pending.actionKey(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,4 +370,7 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
throw new IllegalArgumentException(name + " must not be blank");
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record PendingAction(String actionKey, ProcessContext context) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.OrdoEngine;
|
import com.jetlumen.ordo.api.OrdoEngine;
|
||||||
@@ -20,31 +21,37 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
|
|||||||
private final DefaultOrdoEngine delegate;
|
private final DefaultOrdoEngine delegate;
|
||||||
|
|
||||||
public InMemoryOrdoEngine() {
|
public InMemoryOrdoEngine() {
|
||||||
this(Clock.systemUTC(), AssigneeResolver.direct(), RoutingCondition.always());
|
this(Clock.systemUTC(), AssigneeResolver.direct(), RoutingCondition.always(), ActionHandler.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryOrdoEngine(Clock clock) {
|
public InMemoryOrdoEngine(Clock clock) {
|
||||||
this(clock, AssigneeResolver.direct(), RoutingCondition.always());
|
this(clock, AssigneeResolver.direct(), RoutingCondition.always(), ActionHandler.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) {
|
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver) {
|
||||||
this(Clock.systemUTC(), assigneeResolver, RoutingCondition.always());
|
this(Clock.systemUTC(), assigneeResolver, RoutingCondition.always(), ActionHandler.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) {
|
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver) {
|
||||||
this(clock, assigneeResolver, RoutingCondition.always());
|
this(clock, assigneeResolver, RoutingCondition.always(), ActionHandler.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryOrdoEngine(RoutingCondition routingCondition) {
|
public InMemoryOrdoEngine(RoutingCondition routingCondition) {
|
||||||
this(Clock.systemUTC(), AssigneeResolver.direct(), routingCondition);
|
this(Clock.systemUTC(), AssigneeResolver.direct(), routingCondition, ActionHandler.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
|
public InMemoryOrdoEngine(AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
|
||||||
this(Clock.systemUTC(), assigneeResolver, routingCondition);
|
this(Clock.systemUTC(), assigneeResolver, routingCondition, ActionHandler.noop());
|
||||||
}
|
}
|
||||||
|
|
||||||
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
|
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition) {
|
||||||
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, new NoopTransactionExecutor(),
|
this(clock, assigneeResolver, routingCondition, ActionHandler.noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
|
||||||
|
ActionHandler actionHandler) {
|
||||||
|
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, actionHandler,
|
||||||
|
new NoopTransactionExecutor(),
|
||||||
new InMemoryProcessDefinitionRepository(),
|
new InMemoryProcessDefinitionRepository(),
|
||||||
new InMemoryProcessInstanceRepository(),
|
new InMemoryProcessInstanceRepository(),
|
||||||
new InMemoryApprovalTaskRepository());
|
new InMemoryApprovalTaskRepository());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.ApprovalPolicy;
|
import com.jetlumen.ordo.api.ApprovalPolicy;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
@@ -23,6 +24,7 @@ import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -386,6 +388,65 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
|
assertTrue(routingEngine.findPendingTasksByInstanceId(low.id()).isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runsActionStepsAfterApprovalThenCreatesTheNextApprovalTask() {
|
||||||
|
List<String> executed = new java.util.ArrayList<>();
|
||||||
|
InMemoryOrdoEngine actionEngine = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
|
||||||
|
RoutingCondition.always(), (key, context) -> executed.add(key));
|
||||||
|
actionEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
|
ApprovalStep.action("notify", "Notify HR", "leave-approved-mail"),
|
||||||
|
ApprovalStep.single("hr", "HR approval", "henry")),
|
||||||
|
List.of(
|
||||||
|
StepTransition.always("manager", "notify"),
|
||||||
|
StepTransition.always("notify", "hr"),
|
||||||
|
StepTransition.end("hr"))));
|
||||||
|
|
||||||
|
var instance = actionEngine.start("leave", "alice");
|
||||||
|
actionEngine.approve(actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
|
||||||
|
|
||||||
|
assertEquals(List.of("leave-approved-mail"), executed);
|
||||||
|
ApprovalTask hrTask = actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
|
||||||
|
assertEquals("hr", hrTask.stepId());
|
||||||
|
assertEquals(ProcessStatus.RUNNING, actionEngine.findInstance(instance.id()).orElseThrow().status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void continuesWhenAnActionHandlerThrows() {
|
||||||
|
InMemoryOrdoEngine actionEngine = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
|
||||||
|
RoutingCondition.always(), (key, context) -> {
|
||||||
|
throw new IllegalStateException("mail failed");
|
||||||
|
});
|
||||||
|
actionEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
|
ApprovalStep.action("notify", "Notify HR", "leave-approved-mail")),
|
||||||
|
List.of(
|
||||||
|
StepTransition.always("manager", "notify"),
|
||||||
|
StepTransition.end("notify"))));
|
||||||
|
|
||||||
|
var instance = actionEngine.start("leave", "alice");
|
||||||
|
actionEngine.approve(actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().id(), "maria");
|
||||||
|
assertEquals(ProcessStatus.APPROVED, actionEngine.findInstance(instance.id()).orElseThrow().status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startEntersAnActionStepThenStopsOnTheFollowingApproval() {
|
||||||
|
List<String> executed = new java.util.ArrayList<>();
|
||||||
|
InMemoryOrdoEngine actionEngine = new InMemoryOrdoEngine(Clock.systemUTC(), AssigneeResolver.direct(),
|
||||||
|
RoutingCondition.always(), (key, context) -> executed.add(key));
|
||||||
|
actionEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
|
ApprovalStep.action("notify", "Notify manager", "leave-submitted-mail"),
|
||||||
|
ApprovalStep.single("manager", "Manager approval", "maria")),
|
||||||
|
List.of(
|
||||||
|
StepTransition.always("notify", "manager"),
|
||||||
|
StepTransition.end("manager"))));
|
||||||
|
|
||||||
|
var instance = actionEngine.start("leave", "alice");
|
||||||
|
assertEquals(List.of("leave-submitted-mail"), executed);
|
||||||
|
assertEquals("manager", actionEngine.findPendingTasksByInstanceId(instance.id()).getFirst().stepId());
|
||||||
|
assertEquals(ProcessStatus.RUNNING, instance.status());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void throwsWhenNoTransitionMatches() {
|
void throwsWhenNoTransitionMatches() {
|
||||||
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false);
|
InMemoryOrdoEngine routingEngine = new InMemoryOrdoEngine((key, context) -> false);
|
||||||
|
|||||||
+11
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.spring;
|
package com.jetlumen.ordo.spring;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
import com.jetlumen.ordo.api.OrdoEngine;
|
import com.jetlumen.ordo.api.OrdoEngine;
|
||||||
import com.jetlumen.ordo.api.RoutingCondition;
|
import com.jetlumen.ordo.api.RoutingCondition;
|
||||||
@@ -65,6 +66,12 @@ public class OrdoJdbcAutoConfiguration {
|
|||||||
return RoutingCondition.always();
|
return RoutingCondition.always();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean
|
||||||
|
public ActionHandler ordoActionHandler() {
|
||||||
|
return ActionHandler.noop();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
@DependsOnDatabaseInitialization
|
@DependsOnDatabaseInitialization
|
||||||
@@ -101,12 +108,14 @@ public class OrdoJdbcAutoConfiguration {
|
|||||||
public OrdoEngine ordoEngine(Clock ordoClock,
|
public OrdoEngine ordoEngine(Clock ordoClock,
|
||||||
AssigneeResolver ordoAssigneeResolver,
|
AssigneeResolver ordoAssigneeResolver,
|
||||||
RoutingCondition ordoRoutingCondition,
|
RoutingCondition ordoRoutingCondition,
|
||||||
|
ActionHandler actionHandler,
|
||||||
TransactionExecutor ordoTransactionExecutor,
|
TransactionExecutor ordoTransactionExecutor,
|
||||||
ProcessDefinitionRepository ordoProcessDefinitionRepository,
|
ProcessDefinitionRepository ordoProcessDefinitionRepository,
|
||||||
ProcessInstanceRepository ordoProcessInstanceRepository,
|
ProcessInstanceRepository ordoProcessInstanceRepository,
|
||||||
ApprovalTaskRepository ordoApprovalTaskRepository) {
|
ApprovalTaskRepository ordoApprovalTaskRepository) {
|
||||||
return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, ordoTransactionExecutor,
|
return new DefaultOrdoEngine(ordoClock, ordoAssigneeResolver, ordoRoutingCondition, actionHandler,
|
||||||
ordoProcessDefinitionRepository, ordoProcessInstanceRepository, ordoApprovalTaskRepository);
|
ordoTransactionExecutor, ordoProcessDefinitionRepository, ordoProcessInstanceRepository,
|
||||||
|
ordoApprovalTaskRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+23
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.spring;
|
package com.jetlumen.ordo.spring;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
@@ -20,6 +21,7 @@ import java.util.List;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class OrdoJdbcAutoConfigurationTest {
|
class OrdoJdbcAutoConfigurationTest {
|
||||||
|
|
||||||
@@ -138,6 +140,17 @@ class OrdoJdbcAutoConfigurationTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void honoursUserDefinedActionHandler() {
|
||||||
|
withDataSourceRunner.withUserConfiguration(CustomActionHandlerConfig.class)
|
||||||
|
.run(context -> {
|
||||||
|
assertThat(context).hasSingleBean(OrdoEngine.class);
|
||||||
|
assertThatThrownBy(() -> context.getBean(ActionHandler.class).execute("any", null))
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessage("custom-action");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
static class CustomAssigneeResolverConfig {
|
static class CustomAssigneeResolverConfig {
|
||||||
@Bean
|
@Bean
|
||||||
@@ -153,4 +166,14 @@ class OrdoJdbcAutoConfigurationTest {
|
|||||||
return (key, context) -> true;
|
return (key, context) -> true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class CustomActionHandlerConfig {
|
||||||
|
@Bean
|
||||||
|
ActionHandler ordoActionHandler() {
|
||||||
|
return (key, context) -> {
|
||||||
|
throw new IllegalStateException("custom-action");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-4
@@ -25,7 +25,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
private static final String INSERT_DEFINITION =
|
private static final String INSERT_DEFINITION =
|
||||||
"INSERT INTO ordo_process_definition (id, name) VALUES (?, ?)";
|
"INSERT INTO ordo_process_definition (id, name) VALUES (?, ?)";
|
||||||
private static final String INSERT_STEP =
|
private static final String INSERT_STEP =
|
||||||
"INSERT INTO ordo_approval_step (definition_id, step_id, step_name, policy, step_order) VALUES (?, ?, ?, ?, ?)";
|
"INSERT INTO ordo_approval_step (definition_id, step_id, step_name, policy, step_order, kind, action_key)"
|
||||||
|
+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
private static final String INSERT_CANDIDATE =
|
private static final String INSERT_CANDIDATE =
|
||||||
"INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) VALUES (?, ?, ?, ?)";
|
"INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) VALUES (?, ?, ?, ?)";
|
||||||
private static final String INSERT_TRANSITION =
|
private static final String INSERT_TRANSITION =
|
||||||
@@ -41,7 +42,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
private static final String SELECT_DEFINITION =
|
private static final String SELECT_DEFINITION =
|
||||||
"SELECT id, name FROM ordo_process_definition WHERE id = ?";
|
"SELECT id, name FROM ordo_process_definition WHERE id = ?";
|
||||||
private static final String SELECT_STEPS =
|
private static final String SELECT_STEPS =
|
||||||
"SELECT step_id, step_name, policy FROM ordo_approval_step WHERE definition_id = ? ORDER BY step_order";
|
"SELECT step_id, step_name, policy, kind, action_key FROM ordo_approval_step"
|
||||||
|
+ " WHERE definition_id = ? ORDER BY step_order";
|
||||||
private static final String SELECT_CANDIDATES =
|
private static final String SELECT_CANDIDATES =
|
||||||
"SELECT step_id, candidate FROM ordo_step_candidate WHERE definition_id = ? ORDER BY step_id, candidate_order";
|
"SELECT step_id, candidate FROM ordo_step_candidate WHERE definition_id = ? ORDER BY step_id, candidate_order";
|
||||||
private static final String SELECT_TRANSITIONS =
|
private static final String SELECT_TRANSITIONS =
|
||||||
@@ -76,9 +78,17 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
Objects.requireNonNull(definition, "definition must not be null");
|
Objects.requireNonNull(definition, "definition must not be null");
|
||||||
Connection connection = connectionProvider.getConnection();
|
Connection connection = connectionProvider.getConnection();
|
||||||
try {
|
try {
|
||||||
if (!insertDefinitionRow(connection, definition)) {
|
// PostgreSQL aborts the current transaction on unique-constraint violations,
|
||||||
|
// so upsert must not probe existence via a failing INSERT.
|
||||||
|
if (definitionExists(connection, definition.id())) {
|
||||||
updateDefinitionName(connection, definition);
|
updateDefinitionName(connection, definition);
|
||||||
deleteGraph(connection, definition.id());
|
deleteGraph(connection, definition.id());
|
||||||
|
} else {
|
||||||
|
try (PreparedStatement insert = connection.prepareStatement(INSERT_DEFINITION)) {
|
||||||
|
insert.setString(1, definition.id());
|
||||||
|
insert.setString(2, definition.name());
|
||||||
|
insert.executeUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
insertGraph(connection, definition);
|
insertGraph(connection, definition);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
@@ -124,7 +134,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
List<ApprovalStep> steps = new ArrayList<>();
|
List<ApprovalStep> steps = new ArrayList<>();
|
||||||
for (StepRow row : stepRows) {
|
for (StepRow row : stepRows) {
|
||||||
List<String> candidates = candidatesByStep.getOrDefault(row.stepId(), List.of());
|
List<String> candidates = candidatesByStep.getOrDefault(row.stepId(), List.of());
|
||||||
steps.add(new ApprovalStep(row.stepId(), row.stepName(), candidates, row.policy()));
|
steps.add(new ApprovalStep(row.stepId(), row.stepName(), candidates, row.policy(), row.kind(),
|
||||||
|
row.actionKey()));
|
||||||
}
|
}
|
||||||
List<StepTransition> transitions = new ArrayList<>();
|
List<StepTransition> transitions = new ArrayList<>();
|
||||||
try (PreparedStatement selectTransitions = connection.prepareStatement(SELECT_TRANSITIONS)) {
|
try (PreparedStatement selectTransitions = connection.prepareStatement(SELECT_TRANSITIONS)) {
|
||||||
@@ -145,6 +156,15 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean definitionExists(Connection connection, String definitionId) throws SQLException {
|
||||||
|
try (PreparedStatement select = connection.prepareStatement(SELECT_DEFINITION)) {
|
||||||
|
select.setString(1, definitionId);
|
||||||
|
try (ResultSet resultSet = select.executeQuery()) {
|
||||||
|
return resultSet.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean insertDefinitionRow(Connection connection, ProcessDefinition definition) throws SQLException {
|
private static boolean insertDefinitionRow(Connection connection, ProcessDefinition definition) throws SQLException {
|
||||||
try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) {
|
try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) {
|
||||||
insertDefinition.setString(1, definition.id());
|
insertDefinition.setString(1, definition.id());
|
||||||
@@ -191,6 +211,8 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
insertStep.setString(3, step.name());
|
insertStep.setString(3, step.name());
|
||||||
insertStep.setString(4, step.policy().name());
|
insertStep.setString(4, step.policy().name());
|
||||||
insertStep.setInt(5, stepOrder++);
|
insertStep.setInt(5, stepOrder++);
|
||||||
|
insertStep.setString(6, step.kind().name());
|
||||||
|
insertStep.setString(7, step.actionKey());
|
||||||
insertStep.executeUpdate();
|
insertStep.executeUpdate();
|
||||||
}
|
}
|
||||||
int candidateOrder = 0;
|
int candidateOrder = 0;
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||||
|
|
||||||
import com.jetlumen.ordo.api.ApprovalPolicy;
|
import com.jetlumen.ordo.api.ApprovalPolicy;
|
||||||
|
import com.jetlumen.ordo.api.StepKind;
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
@@ -16,9 +17,11 @@ public final class ApprovalStepMapper {
|
|||||||
|
|
||||||
public static StepRow readRow(ResultSet resultSet) throws SQLException {
|
public static StepRow readRow(ResultSet resultSet) throws SQLException {
|
||||||
return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"),
|
return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"),
|
||||||
ApprovalPolicy.valueOf(resultSet.getString("policy")));
|
ApprovalPolicy.valueOf(resultSet.getString("policy")),
|
||||||
|
StepKind.valueOf(resultSet.getString("kind")),
|
||||||
|
resultSet.getString("action_key"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record StepRow(String stepId, String stepName, ApprovalPolicy policy) {
|
public record StepRow(String stepId, String stepName, ApprovalPolicy policy, StepKind kind, String actionKey) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Adds ACTION step kind and an optional action_key (host ActionHandler lookup).
|
||||||
|
ALTER TABLE ordo_approval_step ADD COLUMN kind VARCHAR(16) NOT NULL DEFAULT 'APPROVAL';
|
||||||
|
ALTER TABLE ordo_approval_step ADD COLUMN action_key VARCHAR(255);
|
||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.storage.jdbc;
|
package com.jetlumen.ordo.storage.jdbc;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
@@ -119,7 +120,7 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void rollsBackTheWholeApprovalWhenNoRouteMatches() {
|
void rollsBackTheWholeApprovalWhenNoRouteMatches() {
|
||||||
OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC),
|
OrdoEngine failingEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC),
|
||||||
AssigneeResolver.direct(), (key, context) -> false,
|
AssigneeResolver.direct(), (key, context) -> false, ActionHandler.noop(),
|
||||||
new JdbcTransactionExecutor(connectionProvider),
|
new JdbcTransactionExecutor(connectionProvider),
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
new JdbcProcessInstanceRepository(connectionProvider),
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
@@ -197,6 +198,7 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
|
|
||||||
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
|
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
|
||||||
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
|
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
|
||||||
|
ActionHandler.noop(),
|
||||||
new JdbcTransactionExecutor(connectionProvider),
|
new JdbcTransactionExecutor(connectionProvider),
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
new JdbcProcessInstanceRepository(connectionProvider),
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
|
|||||||
+2
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.storage.jdbc;
|
package com.jetlumen.ordo.storage.jdbc;
|
||||||
|
|
||||||
|
import com.jetlumen.ordo.api.ActionHandler;
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ApprovalTask;
|
import com.jetlumen.ordo.api.ApprovalTask;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
@@ -219,6 +220,7 @@ class JdbcPostgresIntegrationTest {
|
|||||||
|
|
||||||
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
|
private OrdoEngine newEngine(AssigneeResolver assigneeResolver) {
|
||||||
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
|
return new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), assigneeResolver, RoutingCondition.always(),
|
||||||
|
ActionHandler.noop(),
|
||||||
new JdbcTransactionExecutor(connectionProvider),
|
new JdbcTransactionExecutor(connectionProvider),
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider),
|
new JdbcProcessDefinitionRepository(connectionProvider),
|
||||||
new JdbcProcessInstanceRepository(connectionProvider),
|
new JdbcProcessInstanceRepository(connectionProvider),
|
||||||
|
|||||||
+16
@@ -2,6 +2,7 @@ package com.jetlumen.ordo.storage.jdbc;
|
|||||||
|
|
||||||
import com.jetlumen.ordo.api.ApprovalStep;
|
import com.jetlumen.ordo.api.ApprovalStep;
|
||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
|
import com.jetlumen.ordo.api.StepKind;
|
||||||
import com.jetlumen.ordo.api.StepTransition;
|
import com.jetlumen.ordo.api.StepTransition;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -72,6 +73,21 @@ class JdbcProcessDefinitionRepositoryTest {
|
|||||||
assertEquals(definition, repository.findById("expense").orElseThrow());
|
assertEquals(definition, repository.findById("expense").orElseThrow());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insertsAndReadsBackActionStepsWithoutCandidates() {
|
||||||
|
ProcessDefinition definition = new ProcessDefinition("notify", "Notify", List.of(
|
||||||
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
|
ApprovalStep.action("mail", "Send mail", "leave-approved-mail")),
|
||||||
|
List.of(
|
||||||
|
StepTransition.always("manager", "mail"),
|
||||||
|
StepTransition.end("mail")));
|
||||||
|
|
||||||
|
assertTrue(repository.insertIfAbsent(definition));
|
||||||
|
assertEquals(definition, repository.findById("notify").orElseThrow());
|
||||||
|
assertEquals(StepKind.ACTION, repository.findById("notify").orElseThrow().steps().get(1).kind());
|
||||||
|
assertEquals("leave-approved-mail", repository.findById("notify").orElseThrow().steps().get(1).actionKey());
|
||||||
|
}
|
||||||
|
|
||||||
private static ProcessDefinition definition(String id, String name) {
|
private static ProcessDefinition definition(String id, String name) {
|
||||||
return ProcessDefinition.linear(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee")));
|
return ProcessDefinition.linear(id, name, List.of(ApprovalStep.single("lead", "Lead approval", "lee")));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ final class JdbcTestSupport {
|
|||||||
private static final String[] MIGRATIONS = {
|
private static final String[] MIGRATIONS = {
|
||||||
"/db/migration/V1__create_ordo_tables.sql",
|
"/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"
|
"/db/migration/V3__add_step_transitions.sql",
|
||||||
|
"/db/migration/V4__add_step_kind_and_action_key.sql"
|
||||||
};
|
};
|
||||||
private static final String[] SCHEMA_SQL = loadSchemas();
|
private static final String[] SCHEMA_SQL = loadSchemas();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user