feat: Implement pagination and filtering for approval tasks and process instances

- Added query method to InMemoryApprovalTaskRepository for filtering and pagination of approval tasks.
- Enhanced InMemoryProcessInstanceRepository with query method for filtering and pagination of process instances.
- Introduced Page and PageRequest classes for handling paginated results.
- Updated JdbcApprovalTaskRepository and JdbcProcessInstanceRepository to support querying with pagination.
- Added tests for querying, filtering, and pagination in InMemory and JDBC repositories.
- Implemented dynamic WHERE clause building for SQL queries in PageSupport.
This commit is contained in:
0264408
2026-09-15 08:56:05 +08:00
parent e83234e98a
commit 77d1e5198b
26 changed files with 963 additions and 39 deletions
@@ -26,6 +26,10 @@ import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.TaskNotFoundException;
import com.jetlumen.ordo.api.exception.UnauthorizedInstanceOperationException;
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
import com.jetlumen.ordo.api.query.InstanceQuery;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.query.TaskQuery;
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
@@ -198,6 +202,26 @@ public final class DefaultOrdoEngine implements OrdoEngine {
return taskRepository.findPendingByInstanceId(instanceId);
}
@Override
public synchronized Page<ApprovalTask> queryTasks(TaskQuery query, PageRequest pageRequest) {
Objects.requireNonNull(query, "query must not be null");
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
return taskRepository.query(query, pageRequest);
}
@Override
public synchronized Page<ProcessInstance> queryInstances(InstanceQuery query, PageRequest pageRequest) {
Objects.requireNonNull(query, "query must not be null");
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
return instanceRepository.query(query, pageRequest);
}
@Override
public synchronized Page<ProcessDefinition> listDefinitions(PageRequest pageRequest) {
Objects.requireNonNull(pageRequest, "pageRequest must not be null");
return definitionRepository.findAll(pageRequest);
}
private void createStepTasks(ProcessInstance instance, ApprovalStep step, Instant now) {
for (String candidate : step.candidates()) {
String assignee = assigneeResolver.resolve(candidate, step, instance.context());
@@ -8,6 +8,10 @@ import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.RoutingCondition;
import com.jetlumen.ordo.api.query.InstanceQuery;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.query.TaskQuery;
import com.jetlumen.ordo.core.repository.InMemoryApprovalTaskRepository;
import com.jetlumen.ordo.core.repository.InMemoryProcessDefinitionRepository;
import com.jetlumen.ordo.core.repository.InMemoryProcessInstanceRepository;
@@ -50,11 +54,12 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
public InMemoryOrdoEngine(Clock clock, AssigneeResolver assigneeResolver, RoutingCondition routingCondition,
ActionHandler actionHandler) {
InMemoryProcessInstanceRepository instanceRepository = new InMemoryProcessInstanceRepository();
this.delegate = new DefaultOrdoEngine(clock, assigneeResolver, routingCondition, actionHandler,
new NoopTransactionExecutor(),
new InMemoryProcessDefinitionRepository(),
new InMemoryProcessInstanceRepository(),
new InMemoryApprovalTaskRepository());
instanceRepository,
new InMemoryApprovalTaskRepository(instanceRepository));
}
@Override
@@ -111,4 +116,19 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
public List<ApprovalTask> findPendingTasksByInstanceId(String instanceId) {
return delegate.findPendingTasksByInstanceId(instanceId);
}
@Override
public Page<ApprovalTask> queryTasks(TaskQuery query, PageRequest pageRequest) {
return delegate.queryTasks(query, pageRequest);
}
@Override
public Page<ProcessInstance> queryInstances(InstanceQuery query, PageRequest pageRequest) {
return delegate.queryInstances(query, pageRequest);
}
@Override
public Page<ProcessDefinition> listDefinitions(PageRequest pageRequest) {
return delegate.listDefinitions(pageRequest);
}
}
@@ -1,9 +1,15 @@
package com.jetlumen.ordo.core.repository;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.query.TaskQuery;
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -12,6 +18,21 @@ import java.util.Optional;
/** Development-only in-memory implementation of the task storage port. */
public final class InMemoryApprovalTaskRepository implements ApprovalTaskRepository {
private final Map<String, ApprovalTask> tasks = new LinkedHashMap<>();
private final ProcessInstanceRepository instanceRepository;
public InMemoryApprovalTaskRepository() {
this(null);
}
/**
* @param instanceRepository used to resolve a task's owning instance to its definition id when
* {@link TaskQuery#definitionId()} is used as a filter; pass {@code null}
* if that filter will never be used (it then fails fast instead of silently
* being ignored).
*/
public InMemoryApprovalTaskRepository(ProcessInstanceRepository instanceRepository) {
this.instanceRepository = instanceRepository;
}
@Override
public synchronized void save(ApprovalTask task) {
@@ -61,4 +82,49 @@ public final class InMemoryApprovalTaskRepository implements ApprovalTaskReposit
tasks.put(completedTask.id(), completedTask);
return true;
}
@Override
public synchronized Page<ApprovalTask> query(TaskQuery query, PageRequest pageRequest) {
List<ApprovalTask> matched = tasks.values().stream().filter(task -> matches(task, query)).toList();
List<ApprovalTask> sorted = matched.stream()
.sorted(Comparator.comparing(ApprovalTask::createdAt).thenComparing(ApprovalTask::id).reversed())
.toList();
List<ApprovalTask> page = sorted.stream()
.skip((long) pageRequest.offset())
.limit(pageRequest.size())
.toList();
return new Page<>(page, matched.size(), pageRequest.page(), pageRequest.size());
}
private boolean matches(ApprovalTask task, TaskQuery query) {
if (query.assignee() != null && !query.assignee().equals(task.assignee())) {
return false;
}
if (query.instanceId() != null && !query.instanceId().equals(task.instanceId())) {
return false;
}
if (query.status() != null && query.status() != task.status()) {
return false;
}
if (query.createdFrom() != null && task.createdAt().isBefore(query.createdFrom())) {
return false;
}
if (query.createdTo() != null && task.createdAt().isAfter(query.createdTo())) {
return false;
}
if (query.definitionId() != null && !query.definitionId().equals(definitionIdOf(task))) {
return false;
}
return true;
}
private String definitionIdOf(ApprovalTask task) {
if (instanceRepository == null) {
throw new UnsupportedOperationException(
"TaskQuery.definitionId() filtering requires an InMemoryApprovalTaskRepository "
+ "constructed with a ProcessInstanceRepository");
}
Optional<ProcessInstance> instance = instanceRepository.findById(task.instanceId());
return instance.map(ProcessInstance::definitionId).orElse(null);
}
}
@@ -1,9 +1,13 @@
package com.jetlumen.ordo.core.repository;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -25,4 +29,16 @@ public final class InMemoryProcessDefinitionRepository implements ProcessDefinit
public synchronized Optional<ProcessDefinition> findById(String definitionId) {
return Optional.ofNullable(definitions.get(definitionId));
}
@Override
public synchronized Page<ProcessDefinition> findAll(PageRequest pageRequest) {
List<ProcessDefinition> sorted = definitions.values().stream()
.sorted(Comparator.comparing(ProcessDefinition::id))
.toList();
List<ProcessDefinition> page = sorted.stream()
.skip((long) pageRequest.offset())
.limit(pageRequest.size())
.toList();
return new Page<>(page, sorted.size(), pageRequest.page(), pageRequest.size());
}
}
@@ -2,9 +2,14 @@ package com.jetlumen.ordo.core.repository;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.query.InstanceQuery;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -43,4 +48,36 @@ public final class InMemoryProcessInstanceRepository implements ProcessInstanceR
instances.put(completed.id(), completed);
return true;
}
@Override
public synchronized Page<ProcessInstance> query(InstanceQuery query, PageRequest pageRequest) {
List<ProcessInstance> matched = instances.values().stream().filter(instance -> matches(instance, query)).toList();
List<ProcessInstance> sorted = matched.stream()
.sorted(Comparator.comparing(ProcessInstance::startedAt).thenComparing(ProcessInstance::id).reversed())
.toList();
List<ProcessInstance> page = sorted.stream()
.skip((long) pageRequest.offset())
.limit(pageRequest.size())
.toList();
return new Page<>(page, matched.size(), pageRequest.page(), pageRequest.size());
}
private static boolean matches(ProcessInstance instance, InstanceQuery query) {
if (query.definitionId() != null && !query.definitionId().equals(instance.definitionId())) {
return false;
}
if (query.status() != null && query.status() != instance.status()) {
return false;
}
if (query.initiator() != null && !query.initiator().equals(instance.initiator())) {
return false;
}
if (query.startedFrom() != null && instance.startedAt().isBefore(query.startedFrom())) {
return false;
}
if (query.startedTo() != null && instance.startedAt().isAfter(query.startedTo())) {
return false;
}
return true;
}
}
@@ -21,6 +21,10 @@ import com.jetlumen.ordo.api.exception.TaskAlreadyCompletedException;
import com.jetlumen.ordo.api.exception.TaskNotFoundException;
import com.jetlumen.ordo.api.exception.UnauthorizedInstanceOperationException;
import com.jetlumen.ordo.api.exception.UnauthorizedTaskOperationException;
import com.jetlumen.ordo.api.query.InstanceQuery;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.query.TaskQuery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -458,4 +462,54 @@ class InMemoryOrdoEngineTest {
ApprovalTask task = routingEngine.findPendingTasksByInstanceId(instance.id()).getFirst();
assertThrows(NoRouteFoundException.class, () -> routingEngine.approve(task.id(), "maria"));
}
@Test
void queryTasksFiltersByAssigneeDefinitionAndPaginates() {
engine.register(ProcessDefinition.linear("expense", "Expense request", List.of(
ApprovalStep.single("finance", "Finance approval", "frank"))));
var leaveInstance = engine.start("leave", "alice");
engine.start("expense", "alice");
Page<ApprovalTask> byAssignee = engine.queryTasks(TaskQuery.any().withAssignee("maria"), new PageRequest(0, 10));
assertEquals(1, byAssignee.totalElements());
assertEquals("maria", byAssignee.content().getFirst().assignee());
Page<ApprovalTask> byDefinition = engine.queryTasks(TaskQuery.any().withDefinitionId("leave"),
new PageRequest(0, 10));
assertEquals(1, byDefinition.totalElements());
assertEquals(leaveInstance.id(), byDefinition.content().getFirst().instanceId());
Page<ApprovalTask> firstPage = engine.queryTasks(TaskQuery.any(), new PageRequest(0, 1));
assertEquals(2, firstPage.totalElements());
assertEquals(2, firstPage.totalPages());
assertEquals(1, firstPage.content().size());
assertTrue(firstPage.hasNext());
}
@Test
void queryInstancesFiltersByDefinitionAndStatus() {
var running = engine.start("leave", "alice");
var toWithdraw = engine.start("leave", "bob");
engine.withdraw(toWithdraw.id(), "bob");
Page<ProcessInstance> runningOnly = engine.queryInstances(
InstanceQuery.any().withStatus(ProcessStatus.RUNNING), new PageRequest(0, 10));
assertEquals(1, runningOnly.totalElements());
assertEquals(running.id(), runningOnly.content().getFirst().id());
Page<ProcessInstance> byInitiator = engine.queryInstances(InstanceQuery.any().withInitiator("bob"),
new PageRequest(0, 10));
assertEquals(1, byInitiator.totalElements());
assertEquals(ProcessStatus.WITHDRAWN, byInitiator.content().getFirst().status());
}
@Test
void listDefinitionsPaginatesRegisteredDefinitions() {
engine.register(ProcessDefinition.linear("expense", "Expense request", List.of(
ApprovalStep.single("finance", "Finance approval", "frank"))));
Page<ProcessDefinition> all = engine.listDefinitions(new PageRequest(0, 10));
assertEquals(2, all.totalElements());
assertEquals(List.of("expense", "leave"), all.content().stream().map(ProcessDefinition::id).toList());
}
}
@@ -0,0 +1,81 @@
package com.jetlumen.ordo.core.repository;
import com.jetlumen.ordo.api.ApprovalTask;
import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.TaskStatus;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import com.jetlumen.ordo.api.query.TaskQuery;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InMemoryApprovalTaskRepositoryTest {
private static final Instant CREATED_AT = Instant.parse("2026-01-15T09:00:00Z");
@Test
void queryFiltersPaginatesAndOrdersNewestFirst() {
InMemoryApprovalTaskRepository repository = new InMemoryApprovalTaskRepository();
ApprovalTask t1 = task("task-1", "inst-1", "maria", TaskStatus.PENDING, CREATED_AT);
ApprovalTask t2 = task("task-2", "inst-1", "henry", TaskStatus.PENDING, CREATED_AT.plusSeconds(5));
ApprovalTask t3 = task("task-3", "inst-2", "maria", TaskStatus.APPROVED, CREATED_AT.plusSeconds(10));
repository.save(t1);
repository.save(t2);
repository.save(t3);
Page<ApprovalTask> byAssignee = repository.query(TaskQuery.any().withAssignee("maria"), new PageRequest(0, 10));
assertEquals(2, byAssignee.totalElements());
assertEquals(List.of(t3, t1), byAssignee.content());
Page<ApprovalTask> byStatus = repository.query(TaskQuery.any().withStatus(TaskStatus.PENDING), new PageRequest(0, 10));
assertEquals(List.of(t2, t1), byStatus.content());
Page<ApprovalTask> pageOne = repository.query(TaskQuery.any(), new PageRequest(0, 2));
assertEquals(3, pageOne.totalElements());
assertEquals(2, pageOne.totalPages());
assertTrue(pageOne.hasNext());
assertEquals(List.of(t3, t2), pageOne.content());
Page<ApprovalTask> pageTwo = repository.query(TaskQuery.any(), new PageRequest(1, 2));
assertEquals(List.of(t1), pageTwo.content());
assertFalse(pageTwo.hasNext());
}
@Test
void definitionIdFilterFailsFastWithoutAnInstanceRepository() {
InMemoryApprovalTaskRepository repository = new InMemoryApprovalTaskRepository();
repository.save(task("task-1", "inst-1", "maria", TaskStatus.PENDING, CREATED_AT));
assertThrows(UnsupportedOperationException.class,
() -> repository.query(TaskQuery.any().withDefinitionId("leave"), new PageRequest(0, 10)));
}
@Test
void definitionIdFilterResolvesThroughTheInstanceRepository() {
InMemoryProcessInstanceRepository instanceRepository = new InMemoryProcessInstanceRepository();
instanceRepository.insert(new ProcessInstance("inst-1", "leave", "alice", ProcessStatus.RUNNING,
CREATED_AT, null, ProcessContext.empty()));
instanceRepository.insert(new ProcessInstance("inst-2", "expense", "alice", ProcessStatus.RUNNING,
CREATED_AT, null, ProcessContext.empty()));
InMemoryApprovalTaskRepository repository = new InMemoryApprovalTaskRepository(instanceRepository);
ApprovalTask leaveTask = task("task-1", "inst-1", "maria", TaskStatus.PENDING, CREATED_AT);
ApprovalTask expenseTask = task("task-2", "inst-2", "frank", TaskStatus.PENDING, CREATED_AT);
repository.save(leaveTask);
repository.save(expenseTask);
Page<ApprovalTask> byDefinition = repository.query(TaskQuery.any().withDefinitionId("leave"), new PageRequest(0, 10));
assertEquals(List.of(leaveTask), byDefinition.content());
}
private static ApprovalTask task(String id, String instanceId, String assignee, TaskStatus status, Instant createdAt) {
return new ApprovalTask(id, instanceId, "step", "Step", assignee, status, createdAt, null, null);
}
}
@@ -0,0 +1,37 @@
package com.jetlumen.ordo.core.repository;
import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InMemoryProcessDefinitionRepositoryTest {
@Test
void findAllPaginatesDefinitionsOrderedById() {
InMemoryProcessDefinitionRepository repository = new InMemoryProcessDefinitionRepository();
repository.insertIfAbsent(definition("c-def"));
repository.insertIfAbsent(definition("a-def"));
repository.insertIfAbsent(definition("b-def"));
Page<ProcessDefinition> pageOne = repository.findAll(new PageRequest(0, 2));
assertEquals(3, pageOne.totalElements());
assertEquals(2, pageOne.totalPages());
assertTrue(pageOne.hasNext());
assertEquals(List.of("a-def", "b-def"), pageOne.content().stream().map(ProcessDefinition::id).toList());
Page<ProcessDefinition> pageTwo = repository.findAll(new PageRequest(1, 2));
assertEquals(List.of("c-def"), pageTwo.content().stream().map(ProcessDefinition::id).toList());
assertFalse(pageTwo.hasNext());
}
private static ProcessDefinition definition(String id) {
return ProcessDefinition.linear(id, id, List.of(ApprovalStep.single("lead", "Lead approval", "lee")));
}
}
@@ -0,0 +1,55 @@
package com.jetlumen.ordo.core.repository;
import com.jetlumen.ordo.api.ProcessContext;
import com.jetlumen.ordo.api.ProcessInstance;
import com.jetlumen.ordo.api.ProcessStatus;
import com.jetlumen.ordo.api.query.InstanceQuery;
import com.jetlumen.ordo.api.query.Page;
import com.jetlumen.ordo.api.query.PageRequest;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InMemoryProcessInstanceRepositoryTest {
private static final Instant STARTED_AT = Instant.parse("2026-01-15T09:00:00Z");
@Test
void queryFiltersPaginatesAndOrdersNewestFirst() {
InMemoryProcessInstanceRepository repository = new InMemoryProcessInstanceRepository();
ProcessInstance i1 = instance("inst-1", "leave", "alice", ProcessStatus.RUNNING, STARTED_AT);
ProcessInstance i2 = instance("inst-2", "leave", "bob", ProcessStatus.RUNNING, STARTED_AT.plusSeconds(5));
ProcessInstance i3 = instance("inst-3", "expense", "alice", ProcessStatus.WITHDRAWN, STARTED_AT.plusSeconds(10));
repository.insert(i1);
repository.insert(i2);
repository.insert(i3);
Page<ProcessInstance> byDefinition = repository.query(InstanceQuery.any().withDefinitionId("leave"),
new PageRequest(0, 10));
assertEquals(2, byDefinition.totalElements());
assertEquals(List.of(i2, i1), byDefinition.content());
Page<ProcessInstance> byStatus = repository.query(InstanceQuery.any().withStatus(ProcessStatus.WITHDRAWN),
new PageRequest(0, 10));
assertEquals(List.of(i3), byStatus.content());
Page<ProcessInstance> pageOne = repository.query(InstanceQuery.any(), new PageRequest(0, 2));
assertEquals(3, pageOne.totalElements());
assertEquals(2, pageOne.totalPages());
assertTrue(pageOne.hasNext());
assertEquals(List.of(i3, i2), pageOne.content());
Page<ProcessInstance> pageTwo = repository.query(InstanceQuery.any(), new PageRequest(1, 2));
assertEquals(List.of(i1), pageTwo.content());
assertFalse(pageTwo.hasNext());
}
private static ProcessInstance instance(String id, String definitionId, String initiator, ProcessStatus status,
Instant startedAt) {
return new ProcessInstance(id, definitionId, initiator, status, startedAt, null, ProcessContext.empty());
}
}