M4 AP-005 Repository für Versuchshistorie implementieren
This commit is contained in:
+258
@@ -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 1.
|
||||
* Otherwise returns the current maximum attempt number plus 1.
|
||||
*
|
||||
* @param fingerprint the document identity; must not be null
|
||||
* @return the next monotonic attempt number; always >= 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user