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:
0264408
2026-09-10 18:20:33 +08:00
parent c524c74289
commit 581823290a
22 changed files with 440 additions and 90 deletions
@@ -27,6 +27,9 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE id = ?";
private static final String SELECT_BY_INSTANCE =
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE instance_id = ? ORDER BY created_at, id";
private static final String SELECT_BY_INSTANCE_AND_STEP =
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE instance_id = ? AND step_id = ?"
+ " ORDER BY created_at, id";
private static final String SELECT_PENDING_BY_ASSIGNEE =
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND assignee = ?"
+ " ORDER BY created_at, id";
@@ -64,6 +67,26 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
return findAll(SELECT_BY_INSTANCE, instanceId);
}
@Override
public List<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
public List<ApprovalTask> findPendingByAssignee(String assignee) {
return findAll(SELECT_PENDING_BY_ASSIGNEE, assignee);
@@ -4,26 +4,33 @@ import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ProcessDefinition;
import com.jetlumen.ordo.api.repository.ProcessDefinitionRepository;
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper;
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalStepMapper.StepRow;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** JDBC implementation of the definition storage port; steps live in a separate table. */
/** JDBC implementation of the definition storage port; steps and their candidates live in separate tables. */
public final class JdbcProcessDefinitionRepository implements ProcessDefinitionRepository {
private static final String INSERT_DEFINITION =
"INSERT INTO ordo_process_definition (id, name) VALUES (?, ?)";
private static final String INSERT_STEP =
"INSERT INTO ordo_approval_step (definition_id, step_id, step_name, assignee, step_order) VALUES (?, ?, ?, ?, ?)";
"INSERT INTO ordo_approval_step (definition_id, step_id, step_name, policy, step_order) VALUES (?, ?, ?, ?, ?)";
private static final String INSERT_CANDIDATE =
"INSERT INTO ordo_step_candidate (definition_id, step_id, candidate, candidate_order) VALUES (?, ?, ?, ?)";
private static final String SELECT_DEFINITION =
"SELECT id, name FROM ordo_process_definition WHERE id = ?";
private static final String SELECT_STEPS =
"SELECT step_id, step_name, assignee FROM ordo_approval_step WHERE definition_id = ? ORDER BY step_order";
"SELECT step_id, step_name, policy FROM ordo_approval_step WHERE definition_id = ? ORDER BY step_order";
private static final String SELECT_CANDIDATES =
"SELECT step_id, candidate FROM ordo_step_candidate WHERE definition_id = ? ORDER BY step_id, candidate_order";
private final JdbcConnectionProvider connectionProvider;
@@ -46,16 +53,26 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
}
throw new JdbcStorageException("failed to insert definition: " + definition.id(), e);
}
int order = 0;
int stepOrder = 0;
for (ApprovalStep step : definition.steps()) {
try (PreparedStatement insertStep = connection.prepareStatement(INSERT_STEP)) {
insertStep.setString(1, definition.id());
insertStep.setString(2, step.id());
insertStep.setString(3, step.name());
insertStep.setString(4, step.assignee());
insertStep.setInt(5, order++);
insertStep.setString(4, step.policy().name());
insertStep.setInt(5, stepOrder++);
insertStep.executeUpdate();
}
int candidateOrder = 0;
for (String candidate : step.candidates()) {
try (PreparedStatement insertCandidate = connection.prepareStatement(INSERT_CANDIDATE)) {
insertCandidate.setString(1, definition.id());
insertCandidate.setString(2, step.id());
insertCandidate.setString(3, candidate);
insertCandidate.setInt(4, candidateOrder++);
insertCandidate.executeUpdate();
}
}
}
return true;
} catch (SQLException e) {
@@ -79,15 +96,30 @@ public final class JdbcProcessDefinitionRepository implements ProcessDefinitionR
name = resultSet.getString("name");
}
}
List<ApprovalStep> steps = new ArrayList<>();
List<StepRow> stepRows = new ArrayList<>();
try (PreparedStatement selectSteps = connection.prepareStatement(SELECT_STEPS)) {
selectSteps.setString(1, definitionId);
try (ResultSet resultSet = selectSteps.executeQuery()) {
while (resultSet.next()) {
steps.add(ApprovalStepMapper.read(resultSet));
stepRows.add(ApprovalStepMapper.readRow(resultSet));
}
}
}
Map<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));
} catch (SQLException e) {
throw new JdbcStorageException("failed to load definition: " + definitionId, e);
@@ -1,17 +1,24 @@
package com.jetlumen.ordo.storage.jdbc.mapper;
import com.jetlumen.ordo.api.ApprovalStep;
import com.jetlumen.ordo.api.ApprovalPolicy;
import java.sql.ResultSet;
import java.sql.SQLException;
/** Maps rows of {@code ordo_approval_step} to {@link ApprovalStep} objects. */
/**
* Maps rows of {@code ordo_approval_step}. A step's candidate list lives in the separate
* {@code ordo_step_candidate} table, so this only reads the scalar step columns; the repository
* assembles the full {@link com.jetlumen.ordo.api.ApprovalStep} once candidates are loaded.
*/
public final class ApprovalStepMapper {
private ApprovalStepMapper() {
}
public static ApprovalStep read(ResultSet resultSet) throws SQLException {
return new ApprovalStep(resultSet.getString("step_id"), resultSet.getString("step_name"),
resultSet.getString("assignee"));
public static StepRow readRow(ResultSet resultSet) throws SQLException {
return new StepRow(resultSet.getString("step_id"), resultSet.getString("step_name"),
ApprovalPolicy.valueOf(resultSet.getString("policy")));
}
public record StepRow(String stepId, String stepName, ApprovalPolicy policy) {
}
}
@@ -8,6 +8,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
/** Maps rows of {@code ordo_approval_task} to {@link ApprovalTask} objects and back. */
public final class ApprovalTaskMapper {
@@ -28,9 +29,16 @@ public final class ApprovalTaskMapper {
TaskAction action = completedTask.action();
statement.setString(1, completedTask.status().name());
statement.setTimestamp(2, Timestamp.from(completedTask.completedAt()));
statement.setString(3, action.actor());
statement.setString(4, action.comment());
statement.setTimestamp(5, Timestamp.from(action.operatedAt()));
if (action == null) {
// SKIPPED tasks have no actor - a sibling candidate's decision already settled the step.
statement.setNull(3, Types.VARCHAR);
statement.setNull(4, Types.VARCHAR);
statement.setNull(5, Types.TIMESTAMP);
} else {
statement.setString(3, action.actor());
statement.setString(4, action.comment());
statement.setTimestamp(5, Timestamp.from(action.operatedAt()));
}
statement.setString(6, completedTask.id());
}
@@ -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;