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 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 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. + *

+ * 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 attempts = repository.findAllByFingerprint(fingerprint); + assertThat(attempts).hasSize(1); + + ProcessingAttempt saved = attempts.get(0); + assertThat(saved.fingerprint()).isEqualTo(fingerprint); + assertThat(saved.runId()).isEqualTo(runId); + assertThat(saved.attemptNumber()).isEqualTo(1); + assertThat(saved.startedAt()).isEqualTo(startedAt); + assertThat(saved.endedAt()).isEqualTo(endedAt); + assertThat(saved.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE); + assertThat(saved.failureClass()).isEqualTo("IOException"); + assertThat(saved.failureMessage()).isEqualTo("File not found"); + assertThat(saved.retryable()).isTrue(); + } + + @Test + void save_shouldPersistProcessingAttempt_withNullFailureFields() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "4444444444444444444444444444444444444444444444444444444444444444"); + RunId runId = new RunId("test-run-4"); + 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.SUCCESS, + null, // null failure class + null, // null failure message + false // not retryable + ); + + // When + repository.save(attempt); + + // Then + List attempts = repository.findAllByFingerprint(fingerprint); + assertThat(attempts).hasSize(1); + + ProcessingAttempt saved = attempts.get(0); + assertThat(saved.failureClass()).isNull(); + assertThat(saved.failureMessage()).isNull(); + assertThat(saved.retryable()).isFalse(); + } + + @Test + void save_shouldRejectNullAttempt() { + assertThatThrownBy(() -> repository.save(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("attempt"); + } + + // ------------------------------------------------------------------------- + // findAllByFingerprint + // ------------------------------------------------------------------------- + + @Test + void findAllByFingerprint_shouldReturnEmptyList_whenNoAttemptsExist() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "5555555555555555555555555555555555555555555555555555555555555555"); + + // When + List attempts = repository.findAllByFingerprint(fingerprint); + + // Then + assertThat(attempts).isEmpty(); + } + + @Test + void findAllByFingerprint_shouldReturnAllAttemptsOrderedByNumber() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "6666666666666666666666666666666666666666666666666666666666666666"); + RunId runId1 = new RunId("test-run-5"); + RunId runId2 = new RunId("test-run-6"); + Instant baseTime = Instant.now().truncatedTo(ChronoUnit.MICROS); + + // Insert a document record first (FK constraint) + insertDocumentRecord(fingerprint); + + // Insert attempts out of order to verify sorting + ProcessingAttempt attempt3 = new ProcessingAttempt( + fingerprint, + runId2, + 3, + baseTime.plusSeconds(20), + baseTime.plusSeconds(25), + ProcessingStatus.FAILED_FINAL, + "ContentError", + "No text extractable", + false + ); + repository.save(attempt3); + + ProcessingAttempt attempt1 = new ProcessingAttempt( + fingerprint, + runId1, + 1, + baseTime, + baseTime.plusSeconds(5), + ProcessingStatus.FAILED_RETRYABLE, + "IOException", + "File not found", + true + ); + repository.save(attempt1); + + ProcessingAttempt attempt2 = new ProcessingAttempt( + fingerprint, + runId1, + 2, + baseTime.plusSeconds(10), + baseTime.plusSeconds(15), + ProcessingStatus.SKIPPED_ALREADY_PROCESSED, + null, + null, + false + ); + repository.save(attempt2); + + // When + List attempts = repository.findAllByFingerprint(fingerprint); + + // Then + assertThat(attempts).hasSize(3); + assertThat(attempts.get(0).attemptNumber()).isEqualTo(1); + assertThat(attempts.get(1).attemptNumber()).isEqualTo(2); + assertThat(attempts.get(2).attemptNumber()).isEqualTo(3); + + // Verify all fields + ProcessingAttempt first = attempts.get(0); + assertThat(first.runId()).isEqualTo(runId1); + assertThat(first.status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE); + assertThat(first.failureClass()).isEqualTo("IOException"); + + ProcessingAttempt second = attempts.get(1); + assertThat(second.status()).isEqualTo(ProcessingStatus.SKIPPED_ALREADY_PROCESSED); + assertThat(second.failureClass()).isNull(); + + ProcessingAttempt third = attempts.get(2); + assertThat(third.status()).isEqualTo(ProcessingStatus.FAILED_FINAL); + assertThat(third.retryable()).isFalse(); + } + + @Test + void findAllByFingerprint_shouldReturnImmutableList() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "7777777777777777777777777777777777777777777777777777777777777777"); + + // When + List attempts = repository.findAllByFingerprint(fingerprint); + + // Then + assertThat(attempts).isEmpty(); + assertThatThrownBy(() -> attempts.add(null)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void findAllByFingerprint_shouldRejectNullFingerprint() { + assertThatThrownBy(() -> repository.findAllByFingerprint(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("fingerprint"); + } + + // ------------------------------------------------------------------------- + // Integration with document records (FK constraints) + // ------------------------------------------------------------------------- + + @Test + void save_shouldFail_whenFingerprintDoesNotExistInDocumentRecord() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "8888888888888888888888888888888888888888888888888888888888888888"); + RunId runId = new RunId("test-run-7"); + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + + ProcessingAttempt attempt = new ProcessingAttempt( + fingerprint, + runId, + 1, + now, + now.plusSeconds(10), + ProcessingStatus.FAILED_RETRYABLE, + "IOException", + "File not found", + true + ); + + // When / Then + assertThatThrownBy(() -> repository.save(attempt)) + .isInstanceOf(DocumentPersistenceException.class); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void insertDocumentRecord(DocumentFingerprint fingerprint) { + String sql = """ + INSERT INTO document_record ( + fingerprint, + last_known_source_locator, + last_known_source_file_name, + overall_status, + content_error_count, + transient_error_count, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (Connection connection = DriverManager.getConnection(jdbcUrl); + var statement = connection.prepareStatement(sql)) { + + statement.setString(1, fingerprint.sha256Hex()); + statement.setString(2, "/test/path/document.pdf"); + statement.setString(3, "document.pdf"); + statement.setString(4, ProcessingStatus.PROCESSING.name()); + statement.setInt(5, 0); + statement.setInt(6, 0); + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + statement.setString(7, now.toString()); + statement.setString(8, now.toString()); + + statement.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException("Failed to insert document record for testing", e); + } + } +} \ No newline at end of file