feat: support multi-approver steps with ANY/ALL policy
- ApprovalStep now has candidates + ApprovalPolicy (ANY/ALL) instead of a single assignee; ApprovalStep.single(...) kept as a convenience factory for the single-approver case - AssigneeResolver resolves per-candidate; TaskStatus gains SKIPPED for auto-skipped sibling tasks - DefaultOrdoEngine creates one task per candidate and advances per-step according to policy (ANY: first approval wins and skips the rest, only rejects once everyone rejects; ALL: fail-fast on first rejection, only advances once everyone approves) - Added ApprovalTaskRepository.findByInstanceIdAndStepId and JDBC/in-memory implementations - Added V2 migration: ordo_approval_step.policy column + ordo_step_candidate table - Updated ApprovalStepMapper/ApprovalTaskMapper/JdbcProcessDefinitionRepository for the new schema, handling null action for SKIPPED tasks - Migrated all call sites (tests, ordo-example, rhizome demo) to ApprovalStep.single(...) and the 3-arg AssigneeResolver - Added ANY/ALL policy test coverage; fixed JdbcTestSupport to apply the V2 migration and strip SQL comments before splitting on ';'
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decision rule applied when an approval step has more than one candidate.
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #ANY} - the first candidate to approve advances the process; the remaining
|
||||||
|
* pending candidate tasks for the step are marked {@link TaskStatus#SKIPPED}. The step
|
||||||
|
* (and the whole process instance) is only rejected once every candidate has rejected.</li>
|
||||||
|
* <li>{@link #ALL} - every candidate must approve before the process advances. A single
|
||||||
|
* rejection immediately rejects the step (and the process instance), skipping any
|
||||||
|
* remaining pending candidate tasks.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public enum ApprovalPolicy {
|
||||||
|
ANY, ALL
|
||||||
|
}
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
package com.jetlumen.ordo.api;
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
/** A single, named approval step in a linear process definition. */
|
import java.util.HashSet;
|
||||||
public record ApprovalStep(String id, String name, String assignee) {
|
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<String> candidates, ApprovalPolicy policy) {
|
||||||
public ApprovalStep {
|
public ApprovalStep {
|
||||||
requireText(id, "step id");
|
requireText(id, "step id");
|
||||||
requireText(name, "step name");
|
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<String> 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) {
|
static void requireText(String value, String field) {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.jetlumen.ordo.api;
|
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
|
@FunctionalInterface
|
||||||
public interface AssigneeResolver {
|
public interface AssigneeResolver {
|
||||||
String resolve(ApprovalStep step, ProcessContext context);
|
String resolve(String candidate, ApprovalStep step, ProcessContext context);
|
||||||
|
|
||||||
static AssigneeResolver direct() {
|
static AssigneeResolver direct() {
|
||||||
return (step, context) -> step.assignee();
|
return (candidate, step, context) -> candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.jetlumen.ordo.api;
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
public enum TaskStatus {
|
public enum TaskStatus {
|
||||||
PENDING, APPROVED, REJECTED
|
PENDING, APPROVED, REJECTED,
|
||||||
|
/** Auto-completed because a sibling candidate task already settled the step's outcome. */
|
||||||
|
SKIPPED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public interface ApprovalTaskRepository {
|
|||||||
|
|
||||||
List<ApprovalTask> findByInstanceId(String instanceId);
|
List<ApprovalTask> findByInstanceId(String instanceId);
|
||||||
|
|
||||||
|
/** Returns every candidate task created for a single step instance, in creation order. */
|
||||||
|
List<ApprovalTask> findByInstanceIdAndStepId(String instanceId, String stepId);
|
||||||
|
|
||||||
List<ApprovalTask> findPendingByAssignee(String assignee);
|
List<ApprovalTask> findPendingByAssignee(String assignee);
|
||||||
|
|
||||||
List<ApprovalTask> findPendingByInstanceId(String instanceId);
|
List<ApprovalTask> findPendingByInstanceId(String instanceId);
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ class ProcessDefinitionTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsBlankStepFields() {
|
void rejectsBlankStepFields() {
|
||||||
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep(" ", "Manager", "maria"));
|
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single(" ", "Manager", "maria"));
|
||||||
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", " ", "maria"));
|
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single("manager", " ", "maria"));
|
||||||
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", "Manager", " "));
|
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
|
@Test
|
||||||
@@ -22,14 +30,14 @@ class ProcessDefinitionTest {
|
|||||||
assertThrows(IllegalArgumentException.class,
|
assertThrows(IllegalArgumentException.class,
|
||||||
() -> new ProcessDefinition("leave", "Leave request", List.of()));
|
() -> new ProcessDefinition("leave", "Leave request", List.of()));
|
||||||
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"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("manager", "HR approval", "henry")
|
ApprovalStep.single("manager", "HR approval", "henry")
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsBlankDefinitionFields() {
|
void rejectsBlankDefinitionFields() {
|
||||||
List<ApprovalStep> steps = List.of(new ApprovalStep("manager", "Manager approval", "maria"));
|
List<ApprovalStep> steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria"));
|
||||||
|
|
||||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition(" ", "Leave request", steps));
|
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition(" ", "Leave request", steps));
|
||||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", " ", steps));
|
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", " ", steps));
|
||||||
@@ -38,13 +46,13 @@ class ProcessDefinitionTest {
|
|||||||
@Test
|
@Test
|
||||||
void copiesTheSuppliedStepList() {
|
void copiesTheSuppliedStepList() {
|
||||||
List<ApprovalStep> suppliedSteps = new ArrayList<>();
|
List<ApprovalStep> 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);
|
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());
|
assertEquals(1, definition.steps().size());
|
||||||
assertThrows(UnsupportedOperationException.class,
|
assertThrows(UnsupportedOperationException.class,
|
||||||
() -> definition.steps().add(new ApprovalStep("lead", "Lead approval", "lee")));
|
() -> definition.steps().add(ApprovalStep.single("lead", "Lead approval", "lee")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
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;
|
||||||
import com.jetlumen.ordo.api.AssigneeResolver;
|
import com.jetlumen.ordo.api.AssigneeResolver;
|
||||||
@@ -27,6 +28,7 @@ import java.util.Objects;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository-backed implementation of the v0.1 linear approval runtime.
|
* Repository-backed implementation of the v0.1 linear approval runtime.
|
||||||
* Multistep write operations run inside a {@link TransactionExecutor} and
|
* 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,
|
ProcessInstance instance = new ProcessInstance(nextId(), definition.id(), initiator,
|
||||||
ProcessStatus.RUNNING, now, null, context);
|
ProcessStatus.RUNNING, now, null, context);
|
||||||
instanceRepository.insert(instance);
|
instanceRepository.insert(instance);
|
||||||
createTask(instance, definition.steps().getFirst(), now);
|
createStepTasks(instance, definition.steps().getFirst(), now);
|
||||||
return instance;
|
return instance;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -85,14 +87,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.APPROVED, new TaskAction(actor, comment, now));
|
ApprovalTask completedTask = completeTask(task, TaskStatus.APPROVED, new TaskAction(actor, comment, now));
|
||||||
ProcessInstance instance = requireInstance(task.instanceId());
|
advanceAfterDecision(completedTask, now);
|
||||||
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);
|
|
||||||
}
|
|
||||||
return completedTask;
|
return completedTask;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,7 +98,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));
|
||||||
completeInstance(requireInstance(task.instanceId()), ProcessStatus.REJECTED, now);
|
advanceAfterDecision(completedTask, now);
|
||||||
return completedTask;
|
return completedTask;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -135,11 +130,13 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
return taskRepository.findPendingByInstanceId(instanceId);
|
return taskRepository.findPendingByInstanceId(instanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createTask(ProcessInstance instance, ApprovalStep step, Instant now) {
|
private void createStepTasks(ProcessInstance instance, ApprovalStep step, Instant now) {
|
||||||
String assignee = assigneeResolver.resolve(step, instance.context());
|
for (String candidate : step.candidates()) {
|
||||||
requireText(assignee, "resolved assignee");
|
String assignee = assigneeResolver.resolve(candidate, step, instance.context());
|
||||||
taskRepository.save(new ApprovalTask(nextId(), instance.id(), step.id(), step.name(), assignee,
|
requireText(assignee, "resolved assignee");
|
||||||
TaskStatus.PENDING, now, null, null));
|
taskRepository.save(new ApprovalTask(nextId(), instance.id(), step.id(), step.name(), assignee,
|
||||||
|
TaskStatus.PENDING, now, null, null));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ApprovalTask requirePendingTaskForActor(String taskId, String actor) {
|
private ApprovalTask requirePendingTaskForActor(String taskId, String actor) {
|
||||||
@@ -164,6 +161,68 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
return completed;
|
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<ApprovalTask> 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<ApprovalTask> 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) {
|
private void completeInstance(ProcessInstance instance, ProcessStatus status, Instant now) {
|
||||||
instanceRepository.update(new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
instanceRepository.update(new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
||||||
status, instance.startedAt(), now, instance.context()));
|
status, instance.startedAt(), now, instance.context()));
|
||||||
@@ -179,6 +238,13 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
.orElseThrow(() -> new IllegalStateException("instance not found: " + instanceId));
|
.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) {
|
private static int indexOf(ProcessDefinition definition, String stepId) {
|
||||||
for (int index = 0; index < definition.steps().size(); index++) {
|
for (int index = 0; index < definition.steps().size(); index++) {
|
||||||
if (definition.steps().get(index).id().equals(stepId)) {
|
if (definition.steps().get(index).id().equals(stepId)) {
|
||||||
|
|||||||
+8
@@ -28,6 +28,14 @@ public final class InMemoryApprovalTaskRepository implements ApprovalTaskReposit
|
|||||||
return tasks.values().stream().filter(task -> task.instanceId().equals(instanceId)).toList();
|
return tasks.values().stream().filter(task -> task.instanceId().equals(instanceId)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<ApprovalTask> findByInstanceIdAndStepId(String instanceId, String stepId) {
|
||||||
|
return tasks.values().stream()
|
||||||
|
.filter(task -> task.instanceId().equals(instanceId))
|
||||||
|
.filter(task -> task.stepId().equals(stepId))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized List<ApprovalTask> findPendingByAssignee(String assignee) {
|
public synchronized List<ApprovalTask> findPendingByAssignee(String assignee) {
|
||||||
return tasks.values().stream()
|
return tasks.values().stream()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.jetlumen.ordo.core;
|
package com.jetlumen.ordo.core;
|
||||||
|
|
||||||
|
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;
|
||||||
import com.jetlumen.ordo.api.ProcessContext;
|
import com.jetlumen.ordo.api.ProcessContext;
|
||||||
@@ -30,8 +31,8 @@ class InMemoryOrdoEngineTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
engine = new InMemoryOrdoEngine();
|
engine = new InMemoryOrdoEngine();
|
||||||
engine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
engine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry")
|
ApprovalStep.single("hr", "HR approval", "henry")
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice"));
|
assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice"));
|
||||||
assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria"));
|
assertThrows(TaskNotFoundException.class, () -> engine.approve("missing", "maria"));
|
||||||
assertThrows(DefinitionAlreadyExistsException.class, () -> engine.register(new ProcessDefinition(
|
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
|
@Test
|
||||||
@@ -155,13 +156,13 @@ class InMemoryOrdoEngineTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolvesAssigneesFromTheProcessContext() {
|
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)
|
.filter(String.class::isInstance)
|
||||||
.map(String.class::cast)
|
.map(String.class::cast)
|
||||||
.orElse(step.assignee()));
|
.orElse(candidate));
|
||||||
contextAwareEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
contextAwareEngine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry")
|
ApprovalStep.single("hr", "HR approval", "henry")
|
||||||
)));
|
)));
|
||||||
|
|
||||||
var instance = contextAwareEngine.start("leave", "alice", new ProcessContext(Map.of(
|
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("helena", contextAwareEngine.findPendingTasksByInstanceId(instance.id()).getFirst().assignee());
|
||||||
assertEquals("david", instance.context().value("manager").orElseThrow());
|
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<ApprovalTask> 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<ApprovalTask> 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<ApprovalTask> 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<ApprovalTask> 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ public final class LeaveRequestExample {
|
|||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
OrdoEngine ordo = new InMemoryOrdoEngine();
|
OrdoEngine ordo = new InMemoryOrdoEngine();
|
||||||
ordo.register(new ProcessDefinition("leave-request", "Leave request", List.of(
|
ordo.register(new ProcessDefinition("leave-request", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry")
|
ApprovalStep.single("hr", "HR approval", "henry")
|
||||||
)));
|
)));
|
||||||
|
|
||||||
var instance = ordo.start("leave-request", "alice", new ProcessContext(Map.of(
|
var instance = ordo.start("leave-request", "alice", new ProcessContext(Map.of(
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
class OrdoJdbcAutoConfigurationTest {
|
class OrdoJdbcAutoConfigurationTest {
|
||||||
|
|
||||||
private static final ProcessDefinition LEAVE_REQUEST = new ProcessDefinition("leave-request", "Leave request",
|
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()
|
private final ApplicationContextRunner withDataSourceRunner = new ApplicationContextRunner()
|
||||||
.withConfiguration(AutoConfigurations.of(
|
.withConfiguration(AutoConfigurations.of(
|
||||||
@@ -71,7 +71,7 @@ class OrdoJdbcAutoConfigurationTest {
|
|||||||
.run(context -> {
|
.run(context -> {
|
||||||
assertThat(context).hasSingleBean(OrdoEngine.class);
|
assertThat(context).hasSingleBean(OrdoEngine.class);
|
||||||
assertThat(context.getBean(AssigneeResolver.class)).isNotNull();
|
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");
|
.isEqualTo("custom-assignee");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ class OrdoJdbcAutoConfigurationTest {
|
|||||||
static class CustomAssigneeResolverConfig {
|
static class CustomAssigneeResolverConfig {
|
||||||
@Bean
|
@Bean
|
||||||
AssigneeResolver ordoAssigneeResolver() {
|
AssigneeResolver ordoAssigneeResolver() {
|
||||||
return (step, ctx) -> "custom-assignee";
|
return (candidate, step, ctx) -> "custom-assignee";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -27,6 +27,9 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
|
|||||||
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE id = ?";
|
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE id = ?";
|
||||||
private static final String SELECT_BY_INSTANCE =
|
private static final String SELECT_BY_INSTANCE =
|
||||||
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE instance_id = ? ORDER BY created_at, id";
|
"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 =
|
private static final String SELECT_PENDING_BY_ASSIGNEE =
|
||||||
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND assignee = ?"
|
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND assignee = ?"
|
||||||
+ " ORDER BY created_at, id";
|
+ " ORDER BY created_at, id";
|
||||||
@@ -64,6 +67,26 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
|
|||||||
return findAll(SELECT_BY_INSTANCE, instanceId);
|
return findAll(SELECT_BY_INSTANCE, instanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ApprovalTask> 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<ApprovalTask> 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
|
@Override
|
||||||
public List<ApprovalTask> findPendingByAssignee(String assignee) {
|
public List<ApprovalTask> findPendingByAssignee(String assignee) {
|
||||||
return findAll(SELECT_PENDING_BY_ASSIGNEE, assignee);
|
return findAll(SELECT_PENDING_BY_ASSIGNEE, assignee);
|
||||||
|
|||||||
+40
-8
@@ -4,26 +4,33 @@ import com.jetlumen.ordo.api.ApprovalStep;
|
|||||||
import com.jetlumen.ordo.api.ProcessDefinition;
|
import com.jetlumen.ordo.api.ProcessDefinition;
|
||||||
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
|
||||||
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper;
|
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper;
|
||||||
|
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper.StepRow;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
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 {
|
public final class JdbcProcessDefinitionRepository implements ProcessDefinitionRepository {
|
||||||
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, 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 =
|
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, 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;
|
private final JdbcConnectionProvider connectionProvider;
|
||||||
|
|
||||||
@@ -46,16 +53,26 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
}
|
}
|
||||||
throw new JdbcStorageException("failed to insert definition: " + definition.id(), e);
|
throw new JdbcStorageException("failed to insert definition: " + definition.id(), e);
|
||||||
}
|
}
|
||||||
int order = 0;
|
int stepOrder = 0;
|
||||||
for (ApprovalStep step : definition.steps()) {
|
for (ApprovalStep step : definition.steps()) {
|
||||||
try (PreparedStatement insertStep = connection.prepareStatement(INSERT_STEP)) {
|
try (PreparedStatement insertStep = connection.prepareStatement(INSERT_STEP)) {
|
||||||
insertStep.setString(1, definition.id());
|
insertStep.setString(1, definition.id());
|
||||||
insertStep.setString(2, step.id());
|
insertStep.setString(2, step.id());
|
||||||
insertStep.setString(3, step.name());
|
insertStep.setString(3, step.name());
|
||||||
insertStep.setString(4, step.assignee());
|
insertStep.setString(4, step.policy().name());
|
||||||
insertStep.setInt(5, order++);
|
insertStep.setInt(5, stepOrder++);
|
||||||
insertStep.executeUpdate();
|
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;
|
return true;
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
@@ -79,15 +96,30 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
|
|||||||
name = resultSet.getString("name");
|
name = resultSet.getString("name");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<ApprovalStep> steps = new ArrayList<>();
|
List<StepRow> stepRows = new ArrayList<>();
|
||||||
try (PreparedStatement selectSteps = connection.prepareStatement(SELECT_STEPS)) {
|
try (PreparedStatement selectSteps = connection.prepareStatement(SELECT_STEPS)) {
|
||||||
selectSteps.setString(1, definitionId);
|
selectSteps.setString(1, definitionId);
|
||||||
try (ResultSet resultSet = selectSteps.executeQuery()) {
|
try (ResultSet resultSet = selectSteps.executeQuery()) {
|
||||||
while (resultSet.next()) {
|
while (resultSet.next()) {
|
||||||
steps.add(ApprovalStepMapper.read(resultSet));
|
stepRows.add(ApprovalStepMapper.readRow(resultSet));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Map<String, List<String>> 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<ApprovalStep> steps = new ArrayList<>();
|
||||||
|
for (StepRow row : stepRows) {
|
||||||
|
List<String> 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));
|
return Optional.of(new ProcessDefinition(definitionId, name, steps));
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new JdbcStorageException("failed to load definition: " + definitionId, e);
|
throw new JdbcStorageException("failed to load definition: " + definitionId, e);
|
||||||
|
|||||||
+12
-5
@@ -1,17 +1,24 @@
|
|||||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
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.ResultSet;
|
||||||
import java.sql.SQLException;
|
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 {
|
public final class ApprovalStepMapper {
|
||||||
private ApprovalStepMapper() {
|
private ApprovalStepMapper() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ApprovalStep read(ResultSet resultSet) throws SQLException {
|
public static StepRow readRow(ResultSet resultSet) throws SQLException {
|
||||||
return new ApprovalStep(resultSet.getString("step_id"), resultSet.getString("step_name"),
|
return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"),
|
||||||
resultSet.getString("assignee"));
|
ApprovalPolicy.valueOf(resultSet.getString("policy")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StepRow(String stepId, String stepName, ApprovalPolicy policy) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -8,6 +8,7 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
|
import java.sql.Types;
|
||||||
|
|
||||||
/** Maps rows of {@code ordo_approval_task} to {@link ApprovalTask} objects and back. */
|
/** Maps rows of {@code ordo_approval_task} to {@link ApprovalTask} objects and back. */
|
||||||
public final class ApprovalTaskMapper {
|
public final class ApprovalTaskMapper {
|
||||||
@@ -28,9 +29,16 @@ public final class ApprovalTaskMapper {
|
|||||||
TaskAction action = completedTask.action();
|
TaskAction action = completedTask.action();
|
||||||
statement.setString(1, completedTask.status().name());
|
statement.setString(1, completedTask.status().name());
|
||||||
statement.setTimestamp(2, Timestamp.from(completedTask.completedAt()));
|
statement.setTimestamp(2, Timestamp.from(completedTask.completedAt()));
|
||||||
statement.setString(3, action.actor());
|
if (action == null) {
|
||||||
statement.setString(4, action.comment());
|
// SKIPPED tasks have no actor - a sibling candidate's decision already settled the step.
|
||||||
statement.setTimestamp(5, Timestamp.from(action.operatedAt()));
|
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());
|
statement.setString(6, completedTask.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
@@ -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;
|
||||||
+1
-1
@@ -39,7 +39,7 @@ class JdbcApprovalTaskRepositoryTest {
|
|||||||
/** Tasks reference their instance, which references its definition; both parent rows must exist. */
|
/** Tasks reference their instance, which references its definition; both parent rows must exist. */
|
||||||
private void insertFixtureData() {
|
private void insertFixtureData() {
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition("leave",
|
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);
|
JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider);
|
||||||
instanceRepository.insert(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING,
|
instanceRepository.insert(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING,
|
||||||
CREATED_AT, null, ProcessContext.empty()));
|
CREATED_AT, null, ProcessContext.empty()));
|
||||||
|
|||||||
+6
-6
@@ -36,8 +36,8 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource());
|
connectionProvider = new JdbcConnectionProvider(JdbcTestSupport.newDataSource());
|
||||||
engine = newEngine(AssigneeResolver.direct());
|
engine = newEngine(AssigneeResolver.direct());
|
||||||
engine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
engine.register(new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry"))));
|
ApprovalStep.single("hr", "HR approval", "henry"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -88,15 +88,15 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() {
|
void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() {
|
||||||
OrdoEngine failingEngine = newEngine((step, context) -> {
|
OrdoEngine failingEngine = newEngine((candidate, step, context) -> {
|
||||||
if (step.id().equals("hr")) {
|
if (step.id().equals("hr")) {
|
||||||
throw new IllegalStateException("no hr approval today");
|
throw new IllegalStateException("no hr approval today");
|
||||||
}
|
}
|
||||||
return step.assignee();
|
return candidate;
|
||||||
});
|
});
|
||||||
failingEngine.register(new ProcessDefinition("leave2", "Leave request", List.of(
|
failingEngine.register(new ProcessDefinition("leave2", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry"))));
|
ApprovalStep.single("hr", "HR approval", "henry"))));
|
||||||
|
|
||||||
ProcessInstance instance = failingEngine.start("leave2", "alice");
|
ProcessInstance instance = failingEngine.start("leave2", "alice");
|
||||||
ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
|
ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
|
||||||
|
|||||||
+11
-11
@@ -103,8 +103,8 @@ class JdbcPostgresIntegrationTest {
|
|||||||
void engineCompletesASequentialApprovalProcessOverPostgres() {
|
void engineCompletesASequentialApprovalProcessOverPostgres() {
|
||||||
OrdoEngine engine = newEngine(AssigneeResolver.direct());
|
OrdoEngine engine = newEngine(AssigneeResolver.direct());
|
||||||
engine.register(new ProcessDefinition("leave-pg", "Leave request", List.of(
|
engine.register(new ProcessDefinition("leave-pg", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry"))));
|
ApprovalStep.single("hr", "HR approval", "henry"))));
|
||||||
|
|
||||||
ProcessInstance instance = engine.start("leave-pg", "alice",
|
ProcessInstance instance = engine.start("leave-pg", "alice",
|
||||||
new ProcessContext(Map.of("requestId", "LEAVE-2026-001", "days", 5)));
|
new ProcessContext(Map.of("requestId", "LEAVE-2026-001", "days", 5)));
|
||||||
@@ -123,18 +123,18 @@ class JdbcPostgresIntegrationTest {
|
|||||||
void rejectsDuplicateDefinitionIdsViaTheDatabaseUniqueConstraint() {
|
void rejectsDuplicateDefinitionIdsViaTheDatabaseUniqueConstraint() {
|
||||||
JdbcProcessDefinitionRepository repository = new JdbcProcessDefinitionRepository(connectionProvider);
|
JdbcProcessDefinitionRepository repository = new JdbcProcessDefinitionRepository(connectionProvider);
|
||||||
ProcessDefinition definition = new ProcessDefinition("leave-dup-pg", "Leave request",
|
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));
|
assertTrue(repository.insertIfAbsent(definition));
|
||||||
assertFalse(repository.insertIfAbsent(new ProcessDefinition("leave-dup-pg", "Second attempt",
|
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());
|
assertEquals("Leave request", repository.findById("leave-dup-pg").orElseThrow().name());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsDuplicateInstanceIdsViaTheDatabaseUniqueConstraint() {
|
void rejectsDuplicateInstanceIdsViaTheDatabaseUniqueConstraint() {
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition(
|
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);
|
JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider);
|
||||||
ProcessInstance instance = new ProcessInstance("inst-dup-pg", "leave-dup-inst-pg", "alice",
|
ProcessInstance instance = new ProcessInstance("inst-dup-pg", "leave-dup-inst-pg", "alice",
|
||||||
ProcessStatus.RUNNING, NOW, null, ProcessContext.empty());
|
ProcessStatus.RUNNING, NOW, null, ProcessContext.empty());
|
||||||
@@ -146,7 +146,7 @@ class JdbcPostgresIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void roundsTimestampsToMicrosecondPrecision() {
|
void roundsTimestampsToMicrosecondPrecision() {
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition(
|
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);
|
JdbcProcessInstanceRepository repository = new JdbcProcessInstanceRepository(connectionProvider);
|
||||||
|
|
||||||
Instant microAligned = Instant.parse("2026-01-15T09:00:00.123456Z");
|
Instant microAligned = Instant.parse("2026-01-15T09:00:00.123456Z");
|
||||||
@@ -165,7 +165,7 @@ class JdbcPostgresIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void onlyOneOfTwoConcurrentCompletionsWinsOnPostgres() throws Exception {
|
void onlyOneOfTwoConcurrentCompletionsWinsOnPostgres() throws Exception {
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition(
|
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);
|
JdbcProcessInstanceRepository instanceRepository = new JdbcProcessInstanceRepository(connectionProvider);
|
||||||
instanceRepository.insert(new ProcessInstance("inst-race-pg", "leave-race-pg", "alice",
|
instanceRepository.insert(new ProcessInstance("inst-race-pg", "leave-race-pg", "alice",
|
||||||
ProcessStatus.RUNNING, NOW, null, ProcessContext.empty()));
|
ProcessStatus.RUNNING, NOW, null, ProcessContext.empty()));
|
||||||
@@ -194,15 +194,15 @@ class JdbcPostgresIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() {
|
void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() {
|
||||||
OrdoEngine failingEngine = newEngine((step, context) -> {
|
OrdoEngine failingEngine = newEngine((candidate, step, context) -> {
|
||||||
if (step.id().equals("hr")) {
|
if (step.id().equals("hr")) {
|
||||||
throw new IllegalStateException("no hr approval today");
|
throw new IllegalStateException("no hr approval today");
|
||||||
}
|
}
|
||||||
return step.assignee();
|
return candidate;
|
||||||
});
|
});
|
||||||
failingEngine.register(new ProcessDefinition("leave-rollback-pg", "Leave request", List.of(
|
failingEngine.register(new ProcessDefinition("leave-rollback-pg", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry"))));
|
ApprovalStep.single("hr", "HR approval", "henry"))));
|
||||||
|
|
||||||
ProcessInstance instance = failingEngine.start("leave-rollback-pg", "alice");
|
ProcessInstance instance = failingEngine.start("leave-rollback-pg", "alice");
|
||||||
ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
|
ApprovalTask managerTask = failingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
|
||||||
|
|||||||
+3
-3
@@ -23,8 +23,8 @@ class JdbcProcessDefinitionRepositoryTest {
|
|||||||
@Test
|
@Test
|
||||||
void insertsAndReadsBackADefinitionWithItsStepsInOrder() {
|
void insertsAndReadsBackADefinitionWithItsStepsInOrder() {
|
||||||
ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", List.of(
|
ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", List.of(
|
||||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||||
new ApprovalStep("hr", "HR approval", "henry")));
|
ApprovalStep.single("hr", "HR approval", "henry")));
|
||||||
|
|
||||||
assertTrue(repository.insertIfAbsent(definition));
|
assertTrue(repository.insertIfAbsent(definition));
|
||||||
assertEquals(definition, repository.findById("leave").orElseThrow());
|
assertEquals(definition, repository.findById("leave").orElseThrow());
|
||||||
@@ -44,6 +44,6 @@ class JdbcProcessDefinitionRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ProcessDefinition definition(String id, String name) {
|
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")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ class JdbcProcessInstanceRepositoryTest {
|
|||||||
repository = new JdbcProcessInstanceRepository(connectionProvider);
|
repository = new JdbcProcessInstanceRepository(connectionProvider);
|
||||||
// instances reference their definition, so the parent row must exist
|
// instances reference their definition, so the parent row must exist
|
||||||
new JdbcProcessDefinitionRepository(connectionProvider).insertIfAbsent(new ProcessDefinition("leave",
|
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
|
@Test
|
||||||
|
|||||||
+37
-9
@@ -13,7 +13,11 @@ import java.util.UUID;
|
|||||||
|
|
||||||
/** Creates isolated in-memory H2 databases with the Ordo schema applied. */
|
/** Creates isolated in-memory H2 databases with the Ordo schema applied. */
|
||||||
final class JdbcTestSupport {
|
final class JdbcTestSupport {
|
||||||
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() {
|
private JdbcTestSupport() {
|
||||||
}
|
}
|
||||||
@@ -27,12 +31,14 @@ final class JdbcTestSupport {
|
|||||||
return dataSource;
|
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) {
|
static void applySchema(DataSource dataSource) {
|
||||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
||||||
for (String sql : SCHEMA_SQL.split(";")) {
|
for (String migration : SCHEMA_SQL) {
|
||||||
if (!sql.isBlank()) {
|
for (String sql : stripComments(migration).split(";")) {
|
||||||
statement.execute(sql);
|
if (!sql.isBlank()) {
|
||||||
|
statement.execute(sql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} 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) {
|
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);
|
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new IllegalStateException("failed to read V1__create_ordo_tables.sql", e);
|
throw new IllegalStateException("failed to read " + resourcePath, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user