diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java new file mode 100644 index 0000000..a077826 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java @@ -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}. + *
+ * Provides CRUD operations for the processing attempt history (Versuchshistorie) + * with explicit mapping between application types and the SQLite schema. + *
+ * Architecture boundary: 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 next attempt for the given + * fingerprint. + *
+ * 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. + *
+ * 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. + *
+ * 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
+ * Intended for logging and diagnostics only.
+ *
+ * @return the JDBC URL; never null or blank
+ */
+ public String getJdbcUrl() {
+ return jdbcUrl;
+ }
+}
\ No newline at end of file
diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java
new file mode 100644
index 0000000..9fbdc85
--- /dev/null
+++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapterTest.java
@@ -0,0 +1,434 @@
+package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.*;
+import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
+import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
+import de.gecheckt.pdf.umbenenner.domain.model.RunId;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * Tests for {@link SqliteProcessingAttemptRepositoryAdapter}.
+ *
+ * @since M4-AP-005
+ */
+class SqliteProcessingAttemptRepositoryAdapterTest {
+
+ private SqliteProcessingAttemptRepositoryAdapter repository;
+ private String jdbcUrl;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void setUp() {
+ Path dbFile = tempDir.resolve("test.db");
+ jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();
+
+ // Initialize schema first
+ SqliteSchemaInitializationAdapter schemaInitializer =
+ new SqliteSchemaInitializationAdapter(jdbcUrl);
+ schemaInitializer.initializeSchema();
+
+ repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
+ }
+
+ // -------------------------------------------------------------------------
+ // Construction
+ // -------------------------------------------------------------------------
+
+ @Test
+ void constructor_rejectsNullJdbcUrl() {
+ assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("jdbcUrl");
+ }
+
+ @Test
+ void constructor_rejectsBlankJdbcUrl() {
+ assertThatThrownBy(() -> new SqliteProcessingAttemptRepositoryAdapter(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("jdbcUrl");
+ }
+
+ @Test
+ void getJdbcUrl_returnsConfiguredUrl() {
+ String url = "jdbc:sqlite:/some/path/test.db";
+ SqliteProcessingAttemptRepositoryAdapter adapter = new SqliteProcessingAttemptRepositoryAdapter(url);
+ assertThat(adapter.getJdbcUrl()).isEqualTo(url);
+ }
+
+ // -------------------------------------------------------------------------
+ // loadNextAttemptNumber
+ // -------------------------------------------------------------------------
+
+ @Test
+ void loadNextAttemptNumber_shouldReturnOne_whenNoAttemptsExist() {
+ // Given
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "0000000000000000000000000000000000000000000000000000000000000000");
+
+ // When
+ int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
+
+ // Then
+ assertThat(nextAttemptNumber).isEqualTo(1);
+ }
+
+ @Test
+ void loadNextAttemptNumber_shouldReturnNextNumber_whenAttemptsExist() {
+ // Given
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "1111111111111111111111111111111111111111111111111111111111111111");
+ RunId runId = new RunId("test-run-1");
+ Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+
+ // Insert a document record first (FK constraint)
+ insertDocumentRecord(fingerprint);
+
+ // Insert first attempt
+ ProcessingAttempt firstAttempt = new ProcessingAttempt(
+ fingerprint,
+ runId,
+ 1,
+ now,
+ now.plusSeconds(10),
+ ProcessingStatus.FAILED_RETRYABLE,
+ "IOException",
+ "File not found",
+ true
+ );
+ repository.save(firstAttempt);
+
+ // When
+ int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
+
+ // Then
+ assertThat(nextAttemptNumber).isEqualTo(2);
+ }
+
+ @Test
+ void loadNextAttemptNumber_shouldBeMonotonicAcrossMultipleCalls() {
+ // Given
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "2222222222222222222222222222222222222222222222222222222222222222");
+ RunId runId = new RunId("test-run-2");
+ Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
+
+ // Insert a document record first (FK constraint)
+ insertDocumentRecord(fingerprint);
+
+ // Insert multiple attempts
+ for (int i = 1; i <= 5; i++) {
+ ProcessingAttempt attempt = new ProcessingAttempt(
+ fingerprint,
+ runId,
+ i,
+ now.plusSeconds(i * 10),
+ now.plusSeconds(i * 10 + 5),
+ ProcessingStatus.FAILED_RETRYABLE,
+ "IOException",
+ "File not found",
+ true
+ );
+ repository.save(attempt);
+ }
+
+ // When
+ int nextAttemptNumber = repository.loadNextAttemptNumber(fingerprint);
+
+ // Then
+ assertThat(nextAttemptNumber).isEqualTo(6);
+ }
+
+ @Test
+ void loadNextAttemptNumber_shouldRejectNullFingerprint() {
+ assertThatThrownBy(() -> repository.loadNextAttemptNumber(null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("fingerprint");
+ }
+
+ // -------------------------------------------------------------------------
+ // save
+ // -------------------------------------------------------------------------
+
+ @Test
+ void save_shouldPersistProcessingAttempt_withAllFields() {
+ // Given
+ DocumentFingerprint fingerprint = new DocumentFingerprint(
+ "3333333333333333333333333333333333333333333333333333333333333333");
+ RunId runId = new RunId("test-run-3");
+ Instant startedAt = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS);
+ Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
+
+ // Insert a document record first (FK constraint)
+ insertDocumentRecord(fingerprint);
+
+ ProcessingAttempt attempt = new ProcessingAttempt(
+ fingerprint,
+ runId,
+ 1,
+ startedAt,
+ endedAt,
+ ProcessingStatus.FAILED_RETRYABLE,
+ "IOException",
+ "File not found",
+ true
+ );
+
+ // When
+ repository.save(attempt);
+
+ // Then
+ List