M4 AP-005 Repository für Versuchshistorie implementieren

This commit is contained in:
2026-04-02 20:37:21 +02:00
parent 29ea56d2cf
commit 8ee4041feb
2 changed files with 692 additions and 0 deletions
@@ -0,0 +1,258 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.sql.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* SQLite implementation of {@link ProcessingAttemptRepository}.
* <p>
* Provides CRUD operations for the processing attempt history (Versuchshistorie)
* with explicit mapping between application types and the SQLite schema.
* <p>
* <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly
* confined to this class. No JDBC types appear in the port interface or in any
* application/domain type.
*
* @since M4-AP-005
*/
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class);
private final String jdbcUrl;
private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";
/**
* Constructs the adapter with the JDBC URL of the SQLite database file.
*
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
* @throws NullPointerException if {@code jdbcUrl} is null
* @throws IllegalArgumentException if {@code jdbcUrl} is blank
*/
public SqliteProcessingAttemptRepositoryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl must not be blank");
}
this.jdbcUrl = jdbcUrl;
}
/**
* Returns the attempt number to assign to the <em>next</em> attempt for the given
* fingerprint.
* <p>
* If no prior attempts exist for the fingerprint, returns&nbsp;1.
* Otherwise returns the current maximum attempt number plus&nbsp;1.
*
* @param fingerprint the document identity; must not be null
* @return the next monotonic attempt number; always &gt;= 1
* @throws DocumentPersistenceException if the query fails due to a technical error
*/
@Override
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
if (fingerprint == null) {
throw new NullPointerException("fingerprint must not be null");
}
String sql = """
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
FROM processing_attempt
WHERE fingerprint = ?
""";
try (Connection connection = DriverManager.getConnection(jdbcUrl);
PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
try (Statement pragmaStmt = connection.createStatement()) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
}
statement.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
return rs.getInt("next_attempt_number");
} else {
// This should not happen, but fallback to 1
return 1;
}
}
} catch (SQLException e) {
String message = "Failed to load next attempt number for fingerprint '" +
fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Persists exactly one processing attempt record.
* <p>
* The {@link ProcessingAttempt#attemptNumber()} must have been obtained from
* {@link #loadNextAttemptNumber(DocumentFingerprint)} in the same run to guarantee
* monotonic ordering.
*
* @param attempt the attempt to persist; must not be null
* @throws DocumentPersistenceException if the insert fails due to a technical error
*/
@Override
public void save(ProcessingAttempt attempt) {
if (attempt == null) {
throw new NullPointerException("attempt must not be null");
}
String sql = """
INSERT INTO processing_attempt (
fingerprint,
run_id,
attempt_number,
started_at,
ended_at,
status,
failure_class,
failure_message,
retryable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = DriverManager.getConnection(jdbcUrl);
Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, attempt.fingerprint().sha256Hex());
statement.setString(2, attempt.runId().value());
statement.setInt(3, attempt.attemptNumber());
statement.setString(4, attempt.startedAt().toString());
statement.setString(5, attempt.endedAt().toString());
statement.setString(6, attempt.status().name());
// Handle nullable fields
statement.setString(7, attempt.failureClass());
statement.setString(8, attempt.failureMessage());
statement.setBoolean(9, attempt.retryable());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
throw new DocumentPersistenceException(
"Expected to insert 1 row but affected " + rowsAffected + " rows");
}
logger.debug("Saved processing attempt #{} for fingerprint: {}",
attempt.attemptNumber(), attempt.fingerprint().sha256Hex());
} catch (SQLException e) {
String message = "Failed to save processing attempt #" + attempt.attemptNumber() +
" for fingerprint '" + attempt.fingerprint().sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Returns all historised attempts for the given fingerprint, ordered by
* {@link ProcessingAttempt#attemptNumber()} ascending.
* <p>
* Returns an empty list if no attempts have been recorded yet.
* Intended for use in tests and diagnostics; not required on the primary batch path.
*
* @param fingerprint the document identity; must not be null
* @return immutable list of attempts, ordered by attempt number; never null
* @throws DocumentPersistenceException if the query fails due to a technical error
*/
@Override
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
if (fingerprint == null) {
throw new NullPointerException("fingerprint must not be null");
}
String sql = """
SELECT
fingerprint,
run_id,
attempt_number,
started_at,
ended_at,
status,
failure_class,
failure_message,
retryable
FROM processing_attempt
WHERE fingerprint = ?
ORDER BY attempt_number ASC
""";
try (Connection connection = DriverManager.getConnection(jdbcUrl);
Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = statement.executeQuery()) {
List<ProcessingAttempt> attempts = new ArrayList<>();
while (rs.next()) {
ProcessingAttempt attempt = mapResultSetToProcessingAttempt(rs);
attempts.add(attempt);
}
return List.copyOf(attempts); // Return immutable copy
}
} catch (SQLException e) {
String message = "Failed to find processing attempts for fingerprint '" +
fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
/**
* Maps a ResultSet row to a ProcessingAttempt.
*
* @param rs the ResultSet positioned at the current row
* @return the mapped ProcessingAttempt
* @throws SQLException if reading from the ResultSet fails
*/
private ProcessingAttempt mapResultSetToProcessingAttempt(ResultSet rs) throws SQLException {
return new ProcessingAttempt(
new DocumentFingerprint(rs.getString("fingerprint")),
new de.gecheckt.pdf.umbenenner.domain.model.RunId(rs.getString("run_id")),
rs.getInt("attempt_number"),
Instant.parse(rs.getString("started_at")),
Instant.parse(rs.getString("ended_at")),
de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable")
);
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
* <p>
* Intended for logging and diagnostics only.
*
* @return the JDBC URL; never null or blank
*/
public String getJdbcUrl() {
return jdbcUrl;
}
}