feat: initial approval workflow engine with in-memory and JDBC storage
Lightweight linear approval engine (v0.1): - ordo-api: domain model, OrdoEngine port, repository SPI with conditional updates (insertIfAbsent, completeIfPending) and a TransactionExecutor port for atomic multi-step writes - ordo-core: DefaultOrdoEngine running register/start/approve/reject inside a transaction boundary; in-memory engine and repositories - ordo-storage-jdbc: thread-bound JDBC transactions, normalized V1 schema migration, Jackson-based ProcessContext JSON codec - tests: unit tests plus H2 integration tests; PostgreSQL integration tests run against a local instance via ordo.test.pg.* properties and skip when unreachable Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
package com.jetlumen.ordo.storage.jdbc;
|
||||
|
||||
import com.jetlumen.ordo.api.ApprovalTask;
|
||||
import com.jetlumen.ordo.api.repository.ApprovalTaskRepository;
|
||||
import com.jetlumen.ordo.storage.jdbc.mapper.ApprovalTaskMapper;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** JDBC implementation of the task storage port and its pending-task indexes. */
|
||||
public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository {
|
||||
private static final String TASK_COLUMNS =
|
||||
"id, instance_id, step_id, task_name, assignee, status, created_at, completed_at, action_actor, action_comment, action_at";
|
||||
private static final String INSERT_TASK =
|
||||
"INSERT INTO ordo_approval_task (id, instance_id, step_id, task_name, assignee, status, created_at)"
|
||||
+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
private static final String COMPLETE_IF_PENDING =
|
||||
"UPDATE ordo_approval_task SET status = ?, completed_at = ?, action_actor = ?, action_comment = ?, action_at = ?"
|
||||
+ " WHERE id = ? AND status = 'PENDING'";
|
||||
private static final String SELECT_TASK =
|
||||
"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_PENDING_BY_ASSIGNEE =
|
||||
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND assignee = ?"
|
||||
+ " ORDER BY created_at, id";
|
||||
private static final String SELECT_PENDING_BY_INSTANCE =
|
||||
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE status = 'PENDING' AND instance_id = ?"
|
||||
+ " ORDER BY created_at, id";
|
||||
|
||||
private final JdbcConnectionProvider connectionProvider;
|
||||
|
||||
public JdbcApprovalTaskRepository(JdbcConnectionProvider connectionProvider) {
|
||||
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(ApprovalTask task) {
|
||||
Objects.requireNonNull(task, "task must not be null");
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try (PreparedStatement insert = connection.prepareStatement(INSERT_TASK)) {
|
||||
ApprovalTaskMapper.bindInsert(insert, task);
|
||||
insert.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to insert task: " + task.id(), e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ApprovalTask> findById(String taskId) {
|
||||
return findOne(SELECT_TASK, taskId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalTask> findByInstanceId(String instanceId) {
|
||||
return findAll(SELECT_BY_INSTANCE, instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalTask> findPendingByAssignee(String assignee) {
|
||||
return findAll(SELECT_PENDING_BY_ASSIGNEE, assignee);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ApprovalTask> findPendingByInstanceId(String instanceId) {
|
||||
return findAll(SELECT_PENDING_BY_INSTANCE, instanceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean completeIfPending(ApprovalTask completedTask) {
|
||||
Objects.requireNonNull(completedTask, "completedTask must not be null");
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try (PreparedStatement update = connection.prepareStatement(COMPLETE_IF_PENDING)) {
|
||||
ApprovalTaskMapper.bindComplete(update, completedTask);
|
||||
return update.executeUpdate() == 1;
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to complete task: " + completedTask.id(), e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<ApprovalTask> findOne(String sql, String parameter) {
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try (PreparedStatement select = connection.prepareStatement(sql)) {
|
||||
select.setString(1, parameter);
|
||||
try (ResultSet resultSet = select.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(ApprovalTaskMapper.read(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to query task: " + parameter, e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ApprovalTask> findAll(String sql, String parameter) {
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try (PreparedStatement select = connection.prepareStatement(sql)) {
|
||||
select.setString(1, parameter);
|
||||
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: " + parameter, e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.jetlumen.ordo.storage.jdbc;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Hands out JDBC connections to the repositories. When a transaction is
|
||||
* active on the current thread, every call receives that transaction's
|
||||
* connection; otherwise a new auto-commit connection is opened per call.
|
||||
* Only {@link JdbcTransactionExecutor} starts and finishes transactions.
|
||||
*/
|
||||
public final class JdbcConnectionProvider {
|
||||
private final DataSource dataSource;
|
||||
private final ThreadLocal<Connection> transactionConnection = new ThreadLocal<>();
|
||||
|
||||
public JdbcConnectionProvider(DataSource dataSource) {
|
||||
this.dataSource = Objects.requireNonNull(dataSource, "dataSource must not be null");
|
||||
}
|
||||
|
||||
/** Returns the transaction-bound connection of the current thread, or opens a new auto-commit connection. */
|
||||
public Connection getConnection() {
|
||||
Connection connection = transactionConnection.get();
|
||||
if (connection != null) {
|
||||
return connection;
|
||||
}
|
||||
try {
|
||||
return dataSource.getConnection();
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to open a JDBC connection", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes a connection obtained from {@link #getConnection()}; a no-op while it belongs to the active transaction. */
|
||||
public void close(Connection connection) {
|
||||
if (connection == transactionConnection.get()) {
|
||||
return; // released by closeTransaction when the transaction ends
|
||||
}
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to close a JDBC connection", e);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isTransactionActive() {
|
||||
return transactionConnection.get() != null;
|
||||
}
|
||||
|
||||
Connection openTransaction() {
|
||||
if (isTransactionActive()) {
|
||||
throw new IllegalStateException("a transaction is already active on this thread");
|
||||
}
|
||||
Connection connection = getConnection();
|
||||
try {
|
||||
connection.setAutoCommit(false);
|
||||
} catch (SQLException e) {
|
||||
close(connection);
|
||||
throw new JdbcStorageException("failed to start a JDBC transaction", e);
|
||||
}
|
||||
transactionConnection.set(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
void closeTransaction(Connection connection) {
|
||||
transactionConnection.remove();
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to close a JDBC transaction connection", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.jetlumen.ordo.storage.jdbc;
|
||||
|
||||
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 java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** JDBC implementation of the definition storage port; steps live in a separate table. */
|
||||
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 (?, ?, ?, ?, ?)";
|
||||
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";
|
||||
|
||||
private final JdbcConnectionProvider connectionProvider;
|
||||
|
||||
public JdbcProcessDefinitionRepository(JdbcConnectionProvider connectionProvider) {
|
||||
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean insertIfAbsent(ProcessDefinition definition) {
|
||||
Objects.requireNonNull(definition, "definition must not be null");
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try {
|
||||
try (PreparedStatement insertDefinition = connection.prepareStatement(INSERT_DEFINITION)) {
|
||||
insertDefinition.setString(1, definition.id());
|
||||
insertDefinition.setString(2, definition.name());
|
||||
insertDefinition.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
if (isDuplicateKey(e)) {
|
||||
return false;
|
||||
}
|
||||
throw new JdbcStorageException("failed to insert definition: " + definition.id(), e);
|
||||
}
|
||||
int order = 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.executeUpdate();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to insert definition: " + definition.id(), e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProcessDefinition> findById(String definitionId) {
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try {
|
||||
String name;
|
||||
try (PreparedStatement selectDefinition = connection.prepareStatement(SELECT_DEFINITION)) {
|
||||
selectDefinition.setString(1, definitionId);
|
||||
try (ResultSet resultSet = selectDefinition.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
name = resultSet.getString("name");
|
||||
}
|
||||
}
|
||||
List<ApprovalStep> steps = 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.of(new ProcessDefinition(definitionId, name, steps));
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to load definition: " + definitionId, e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDuplicateKey(SQLException e) {
|
||||
return "23505".equals(e.getSQLState());
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.jetlumen.ordo.storage.jdbc;
|
||||
|
||||
import com.jetlumen.ordo.api.ProcessInstance;
|
||||
import com.jetlumen.ordo.api.repository.ProcessInstanceRepository;
|
||||
import com.jetlumen.ordo.storage.jdbc.mapper.ProcessInstanceMapper;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** JDBC implementation of the instance storage port. */
|
||||
public final class JdbcProcessInstanceRepository implements ProcessInstanceRepository {
|
||||
private static final String INSERT_INSTANCE =
|
||||
"INSERT INTO ordo_process_instance (id, definition_id, initiator, status, context_json, started_at, finished_at)"
|
||||
+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
private static final String UPDATE_INSTANCE =
|
||||
"UPDATE ordo_process_instance SET status = ?, finished_at = ? WHERE id = ?";
|
||||
private static final String SELECT_INSTANCE =
|
||||
"SELECT id, definition_id, initiator, status, context_json, started_at, finished_at"
|
||||
+ " FROM ordo_process_instance WHERE id = ?";
|
||||
|
||||
private final JdbcConnectionProvider connectionProvider;
|
||||
|
||||
public JdbcProcessInstanceRepository(JdbcConnectionProvider connectionProvider) {
|
||||
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insert(ProcessInstance instance) {
|
||||
Objects.requireNonNull(instance, "instance must not be null");
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try (PreparedStatement insert = connection.prepareStatement(INSERT_INSTANCE)) {
|
||||
ProcessInstanceMapper.bindInsert(insert, instance);
|
||||
insert.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to insert instance: " + instance.id(), e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(ProcessInstance instance) {
|
||||
Objects.requireNonNull(instance, "instance must not be null");
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try (PreparedStatement update = connection.prepareStatement(UPDATE_INSTANCE)) {
|
||||
ProcessInstanceMapper.bindUpdate(update, instance);
|
||||
if (update.executeUpdate() != 1) {
|
||||
throw new IllegalStateException("instance not found: " + instance.id());
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to update instance: " + instance.id(), e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ProcessInstance> findById(String instanceId) {
|
||||
Connection connection = connectionProvider.getConnection();
|
||||
try (PreparedStatement select = connection.prepareStatement(SELECT_INSTANCE)) {
|
||||
select.setString(1, instanceId);
|
||||
try (ResultSet resultSet = select.executeQuery()) {
|
||||
return resultSet.next() ? Optional.of(ProcessInstanceMapper.read(resultSet)) : Optional.empty();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to load instance: " + instanceId, e);
|
||||
} finally {
|
||||
connectionProvider.close(connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.jetlumen.ordo.storage.jdbc;
|
||||
|
||||
import com.jetlumen.ordo.api.exception.OrdoException;
|
||||
|
||||
/** Unchecked wrapper for JDBC failures raised by the JDBC storage module. */
|
||||
public final class JdbcStorageException extends OrdoException {
|
||||
public JdbcStorageException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.jetlumen.ordo.storage.jdbc;
|
||||
|
||||
import com.jetlumen.ordo.api.TransactionExecutor;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Runs actions inside a JDBC transaction bound to the current thread. Every
|
||||
* repository operation executed by the action shares the same connection and
|
||||
* is committed or rolled back together. A nested {@code execute} joins the
|
||||
* surrounding transaction.
|
||||
*/
|
||||
public final class JdbcTransactionExecutor implements TransactionExecutor {
|
||||
private final JdbcConnectionProvider connectionProvider;
|
||||
|
||||
public JdbcTransactionExecutor(JdbcConnectionProvider connectionProvider) {
|
||||
this.connectionProvider = Objects.requireNonNull(connectionProvider, "connectionProvider must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T execute(Supplier<T> action) {
|
||||
Objects.requireNonNull(action, "action must not be null");
|
||||
if (connectionProvider.isTransactionActive()) {
|
||||
return action.get(); // nested execution joins the surrounding transaction
|
||||
}
|
||||
Connection connection = connectionProvider.openTransaction();
|
||||
try {
|
||||
T result = action.get();
|
||||
commit(connection);
|
||||
return result;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
rollback(connection, failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
connectionProvider.closeTransaction(connection);
|
||||
}
|
||||
}
|
||||
|
||||
private static void commit(Connection connection) {
|
||||
try {
|
||||
connection.commit();
|
||||
} catch (SQLException e) {
|
||||
throw new JdbcStorageException("failed to commit the JDBC transaction", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rollback(Connection connection, Throwable failure) {
|
||||
try {
|
||||
connection.rollback();
|
||||
} catch (SQLException e) {
|
||||
failure.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||
|
||||
import com.jetlumen.ordo.api.ApprovalStep;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/** Maps rows of {@code ordo_approval_step} to {@link ApprovalStep} objects. */
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||
|
||||
import com.jetlumen.ordo.api.ApprovalTask;
|
||||
import com.jetlumen.ordo.api.TaskAction;
|
||||
import com.jetlumen.ordo.api.TaskStatus;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
|
||||
/** Maps rows of {@code ordo_approval_task} to {@link ApprovalTask} objects and back. */
|
||||
public final class ApprovalTaskMapper {
|
||||
private ApprovalTaskMapper() {
|
||||
}
|
||||
|
||||
public static void bindInsert(PreparedStatement statement, ApprovalTask task) throws SQLException {
|
||||
statement.setString(1, task.id());
|
||||
statement.setString(2, task.instanceId());
|
||||
statement.setString(3, task.stepId());
|
||||
statement.setString(4, task.name());
|
||||
statement.setString(5, task.assignee());
|
||||
statement.setString(6, task.status().name());
|
||||
statement.setTimestamp(7, Timestamp.from(task.createdAt()));
|
||||
}
|
||||
|
||||
public static void bindComplete(PreparedStatement statement, ApprovalTask completedTask) throws SQLException {
|
||||
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()));
|
||||
statement.setString(6, completedTask.id());
|
||||
}
|
||||
|
||||
public static ApprovalTask read(ResultSet resultSet) throws SQLException {
|
||||
String actionActor = resultSet.getString("action_actor");
|
||||
Timestamp actionAt = resultSet.getTimestamp("action_at");
|
||||
TaskAction action = actionActor == null ? null
|
||||
: new TaskAction(actionActor, resultSet.getString("action_comment"), actionAt.toInstant());
|
||||
Timestamp completedAt = resultSet.getTimestamp("completed_at");
|
||||
return new ApprovalTask(
|
||||
resultSet.getString("id"),
|
||||
resultSet.getString("instance_id"),
|
||||
resultSet.getString("step_id"),
|
||||
resultSet.getString("task_name"),
|
||||
resultSet.getString("assignee"),
|
||||
TaskStatus.valueOf(resultSet.getString("status")),
|
||||
resultSet.getTimestamp("created_at").toInstant(),
|
||||
completedAt == null ? null : completedAt.toInstant(),
|
||||
action);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.jetlumen.ordo.api.ProcessContext;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Serializes a {@link ProcessContext} to and from the {@code context_json}
|
||||
* column. v0.1 only supports JSON-compatible values: strings, numbers,
|
||||
* booleans, lists and nested maps.
|
||||
*/
|
||||
public final class ProcessContextCodec {
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private ProcessContextCodec() {
|
||||
}
|
||||
|
||||
public static String encode(ProcessContext context) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(context.variables());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("failed to serialize process context to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static ProcessContext decode(String json) {
|
||||
try {
|
||||
Map<String, Object> variables = OBJECT_MAPPER.readValue(json, new TypeReference<>() {
|
||||
});
|
||||
return new ProcessContext(variables);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException("failed to deserialize process context from JSON", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.jetlumen.ordo.storage.jdbc.mapper;
|
||||
|
||||
import com.jetlumen.ordo.api.ProcessInstance;
|
||||
import com.jetlumen.ordo.api.ProcessStatus;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
|
||||
/** Maps rows of {@code ordo_process_instance} to {@link ProcessInstance} objects and back. */
|
||||
public final class ProcessInstanceMapper {
|
||||
private ProcessInstanceMapper() {
|
||||
}
|
||||
|
||||
public static void bindInsert(PreparedStatement statement, ProcessInstance instance) throws SQLException {
|
||||
statement.setString(1, instance.id());
|
||||
statement.setString(2, instance.definitionId());
|
||||
statement.setString(3, instance.initiator());
|
||||
statement.setString(4, instance.status().name());
|
||||
statement.setString(5, ProcessContextCodec.encode(instance.context()));
|
||||
statement.setTimestamp(6, Timestamp.from(instance.startedAt()));
|
||||
statement.setTimestamp(7, instance.finishedAt() == null ? null : Timestamp.from(instance.finishedAt()));
|
||||
}
|
||||
|
||||
public static void bindUpdate(PreparedStatement statement, ProcessInstance instance) throws SQLException {
|
||||
statement.setString(1, instance.status().name());
|
||||
statement.setTimestamp(2, instance.finishedAt() == null ? null : Timestamp.from(instance.finishedAt()));
|
||||
statement.setString(3, instance.id());
|
||||
}
|
||||
|
||||
public static ProcessInstance read(ResultSet resultSet) throws SQLException {
|
||||
Timestamp startedAt = resultSet.getTimestamp("started_at");
|
||||
Timestamp finishedAt = resultSet.getTimestamp("finished_at");
|
||||
return new ProcessInstance(
|
||||
resultSet.getString("id"),
|
||||
resultSet.getString("definition_id"),
|
||||
resultSet.getString("initiator"),
|
||||
ProcessStatus.valueOf(resultSet.getString("status")),
|
||||
startedAt.toInstant(),
|
||||
finishedAt == null ? null : finishedAt.toInstant(),
|
||||
ProcessContextCodec.decode(resultSet.getString("context_json")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Ordo approval workflow tables (v1). Target database: PostgreSQL.
|
||||
-- The DDL sticks to portable types so the same script also runs on H2 in
|
||||
-- PostgreSQL compatibility mode, which the integration tests use.
|
||||
|
||||
CREATE TABLE ordo_process_definition (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ordo_approval_step (
|
||||
definition_id VARCHAR(64) NOT NULL,
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
step_name VARCHAR(255) NOT NULL,
|
||||
assignee VARCHAR(255) NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
PRIMARY KEY (definition_id, step_id),
|
||||
CONSTRAINT fk_approval_step_definition FOREIGN KEY (definition_id) REFERENCES ordo_process_definition (id)
|
||||
);
|
||||
|
||||
CREATE TABLE ordo_process_instance (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
definition_id VARCHAR(64) NOT NULL,
|
||||
initiator VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
context_json TEXT NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
finished_at TIMESTAMP,
|
||||
CONSTRAINT fk_process_instance_definition FOREIGN KEY (definition_id) REFERENCES ordo_process_definition (id)
|
||||
);
|
||||
|
||||
CREATE TABLE ordo_approval_task (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
instance_id VARCHAR(36) NOT NULL,
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
task_name VARCHAR(255) NOT NULL,
|
||||
assignee VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
completed_at TIMESTAMP,
|
||||
action_actor VARCHAR(255),
|
||||
action_comment TEXT,
|
||||
action_at TIMESTAMP,
|
||||
CONSTRAINT fk_approval_task_instance FOREIGN KEY (instance_id) REFERENCES ordo_process_instance (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approval_task_instance ON ordo_approval_task (instance_id);
|
||||
CREATE INDEX idx_approval_task_status_assignee ON ordo_approval_task (status, assignee);
|
||||
CREATE INDEX idx_process_instance_definition ON ordo_process_instance (definition_id);
|
||||
Reference in New Issue
Block a user