diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java new file mode 100644 index 0000000..30adaef --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java @@ -0,0 +1,285 @@ +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.SourceDocumentLocator; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.sql.*; +import java.time.Instant; +import java.util.Objects; + +/** + * SQLite implementation of {@link DocumentRecordRepository}. + *
+ * Provides CRUD operations for the document master record (Dokument-Stammsatz) + * 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-004 + */ +public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepository { + + private static final Logger logger = LogManager.getLogger(SqliteDocumentRecordRepositoryAdapter.class); + + private final String jdbcUrl; + + /** + * 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 SqliteDocumentRecordRepositoryAdapter(String jdbcUrl) { + Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null"); + if (jdbcUrl.isBlank()) { + throw new IllegalArgumentException("jdbcUrl must not be blank"); + } + this.jdbcUrl = jdbcUrl; + } + + /** + * Looks up the master record for the given fingerprint. + *
+ * Returns a {@link DocumentRecordLookupResult} that encodes all possible outcomes + * including technical failures; this method never throws. + * + * @param fingerprint the content-based document identity to look up; must not be null + * @return {@link DocumentUnknown} if no record exists, + * {@link DocumentKnownProcessable} if the document is known but not terminal, + * {@link DocumentTerminalSuccess} if the document succeeded, + * {@link DocumentTerminalFinalFailure} if the document finally failed, or + * {@link PersistenceLookupTechnicalFailure} if the lookup itself failed + */ + @Override + public DocumentRecordLookupResult findByFingerprint(DocumentFingerprint fingerprint) { + if (fingerprint == null) { + throw new NullPointerException("fingerprint must not be null"); + } + + String sql = """ + SELECT + last_known_source_locator, + last_known_source_file_name, + overall_status, + content_error_count, + transient_error_count, + last_failure_instant, + last_success_instant, + created_at, + updated_at + FROM document_record + WHERE fingerprint = ? + """; + + try (Connection connection = DriverManager.getConnection(jdbcUrl); + PreparedStatement statement = connection.prepareStatement(sql)) { + + statement.setString(1, fingerprint.sha256Hex()); + + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + // Document exists - map to appropriate result type based on status + DocumentRecord record = mapResultSetToDocumentRecord(rs, fingerprint); + + return switch (record.overallStatus()) { + case SUCCESS -> new DocumentTerminalSuccess(record); + case FAILED_FINAL -> new DocumentTerminalFinalFailure(record); + case PROCESSING, FAILED_RETRYABLE, SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> + new DocumentKnownProcessable(record); + }; + } else { + // Document not found + return new DocumentUnknown(); + } + } + + } catch (SQLException e) { + String message = "Failed to lookup document record for fingerprint '" + + fingerprint.sha256Hex() + "': " + e.getMessage(); + logger.error(message, e); + return new PersistenceLookupTechnicalFailure(message, e); + } + } + + /** + * Persists a new master record for a previously unknown document. + *
+ * The fingerprint within {@code record} must not yet exist in the persistence store. + * + * @param record the new master record to persist; must not be null + * @throws DocumentPersistenceException if the insert fails due to a technical error + */ + @Override + public void create(DocumentRecord record) { + if (record == null) { + throw new NullPointerException("record must not be null"); + } + + String sql = """ + INSERT INTO document_record ( + fingerprint, + last_known_source_locator, + last_known_source_file_name, + overall_status, + content_error_count, + transient_error_count, + last_failure_instant, + last_success_instant, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (Connection connection = DriverManager.getConnection(jdbcUrl); + PreparedStatement statement = connection.prepareStatement(sql)) { + + statement.setString(1, record.fingerprint().sha256Hex()); + statement.setString(2, record.lastKnownSourceLocator().value()); + statement.setString(3, record.lastKnownSourceFileName()); + statement.setString(4, record.overallStatus().name()); + statement.setInt(5, record.failureCounters().contentErrorCount()); + statement.setInt(6, record.failureCounters().transientErrorCount()); + statement.setString(7, instantToString(record.lastFailureInstant())); + statement.setString(8, instantToString(record.lastSuccessInstant())); + statement.setString(9, instantToString(record.createdAt())); + statement.setString(10, instantToString(record.updatedAt())); + + int rowsAffected = statement.executeUpdate(); + if (rowsAffected != 1) { + throw new DocumentPersistenceException( + "Expected to insert 1 row but affected " + rowsAffected + " rows"); + } + + logger.debug("Created document record for fingerprint: {}", record.fingerprint().sha256Hex()); + + } catch (SQLException e) { + String message = "Failed to create document record for fingerprint '" + + record.fingerprint().sha256Hex() + "': " + e.getMessage(); + logger.error(message, e); + throw new DocumentPersistenceException(message, e); + } + } + + /** + * Updates the mutable fields of an existing master record. + *
+ * The record is identified by its {@link DocumentFingerprint}; the fingerprint + * itself is never changed. Mutable fields include the overall status, failure + * counters, last known source location, and all timestamp fields. + * + * @param record the updated master record; must not be null; fingerprint must exist + * @throws DocumentPersistenceException if the update fails due to a technical error + */ + @Override + public void update(DocumentRecord record) { + if (record == null) { + throw new NullPointerException("record must not be null"); + } + + String sql = """ + UPDATE document_record SET + last_known_source_locator = ?, + last_known_source_file_name = ?, + overall_status = ?, + content_error_count = ?, + transient_error_count = ?, + last_failure_instant = ?, + last_success_instant = ?, + updated_at = ? + WHERE fingerprint = ? + """; + + try (Connection connection = DriverManager.getConnection(jdbcUrl); + PreparedStatement statement = connection.prepareStatement(sql)) { + + statement.setString(1, record.lastKnownSourceLocator().value()); + statement.setString(2, record.lastKnownSourceFileName()); + statement.setString(3, record.overallStatus().name()); + statement.setInt(4, record.failureCounters().contentErrorCount()); + statement.setInt(5, record.failureCounters().transientErrorCount()); + statement.setString(6, instantToString(record.lastFailureInstant())); + statement.setString(7, instantToString(record.lastSuccessInstant())); + statement.setString(8, instantToString(record.updatedAt())); + statement.setString(9, record.fingerprint().sha256Hex()); + + int rowsAffected = statement.executeUpdate(); + if (rowsAffected != 1) { + throw new DocumentPersistenceException( + "Expected to update 1 row but affected " + rowsAffected + " rows. " + + "Fingerprint may not exist: " + record.fingerprint().sha256Hex()); + } + + logger.debug("Updated document record for fingerprint: {}", record.fingerprint().sha256Hex()); + + } catch (SQLException e) { + String message = "Failed to update document record for fingerprint '" + + record.fingerprint().sha256Hex() + "': " + e.getMessage(); + logger.error(message, e); + throw new DocumentPersistenceException(message, e); + } + } + + /** + * Maps a ResultSet row to a DocumentRecord. + * + * @param rs the ResultSet positioned at the current row + * @param fingerprint the fingerprint for this record + * @return the mapped DocumentRecord + * @throws SQLException if reading from the ResultSet fails + */ + private DocumentRecord mapResultSetToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException { + return new DocumentRecord( + fingerprint, + new SourceDocumentLocator(rs.getString("last_known_source_locator")), + rs.getString("last_known_source_file_name"), + ProcessingStatus.valueOf(rs.getString("overall_status")), + new FailureCounters( + rs.getInt("content_error_count"), + rs.getInt("transient_error_count") + ), + stringToInstant(rs.getString("last_failure_instant")), + stringToInstant(rs.getString("last_success_instant")), + stringToInstant(rs.getString("created_at")), + stringToInstant(rs.getString("updated_at")) + ); + } + + /** + * Converts an Instant to a string representation for storage. + * + * @param instant the instant to convert, may be null + * @return the ISO-8601 string representation, or null if instant is null + */ + private String instantToString(Instant instant) { + return instant != null ? instant.toString() : null; + } + + /** + * Converts a string representation back to an Instant. + * + * @param stringValue the ISO-8601 string representation, may be null + * @return the parsed Instant, or null if stringValue is null or blank + */ + private Instant stringToInstant(String stringValue) { + return stringValue != null && !stringValue.isBlank() ? Instant.parse(stringValue) : null; + } + + /** + * 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/SqliteDocumentRecordRepositoryAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java new file mode 100644 index 0000000..8016e23 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapterTest.java @@ -0,0 +1,199 @@ +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.SourceDocumentLocator; + +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 static org.assertj.core.api.Assertions.*; + +/** + * Tests for {@link SqliteDocumentRecordRepositoryAdapter}. + */ +class SqliteDocumentRecordRepositoryAdapterTest { + + private SqliteDocumentRecordRepositoryAdapter 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 SqliteDocumentRecordRepositoryAdapter(jdbcUrl); + } + + @Test + void findByFingerprint_shouldReturnDocumentUnknown_whenRecordDoesNotExist() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "0000000000000000000000000000000000000000000000000000000000000000"); + + // When + DocumentRecordLookupResult result = repository.findByFingerprint(fingerprint); + + // Then + assertThat(result).isInstanceOf(DocumentUnknown.class); + } + + @Test + void create_and_findByFingerprint_shouldWorkForNewRecord() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "1111111111111111111111111111111111111111111111111111111111111111"); + DocumentRecord record = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/path/to/document.pdf"), + "document.pdf", + ProcessingStatus.PROCESSING, + FailureCounters.zero(), + null, + null, + Instant.now().truncatedTo(ChronoUnit.MICROS), + Instant.now().truncatedTo(ChronoUnit.MICROS) + ); + + // When + repository.create(record); + DocumentRecordLookupResult result = repository.findByFingerprint(fingerprint); + + // Then + assertThat(result).isInstanceOf(DocumentKnownProcessable.class); + DocumentKnownProcessable known = (DocumentKnownProcessable) result; + DocumentRecord foundRecord = known.record(); + + assertThat(foundRecord.fingerprint()).isEqualTo(fingerprint); + assertThat(foundRecord.lastKnownSourceLocator().value()).isEqualTo("/path/to/document.pdf"); + assertThat(foundRecord.lastKnownSourceFileName()).isEqualTo("document.pdf"); + assertThat(foundRecord.overallStatus()).isEqualTo(ProcessingStatus.PROCESSING); + assertThat(foundRecord.failureCounters()).isEqualTo(FailureCounters.zero()); + assertThat(foundRecord.lastFailureInstant()).isNull(); + assertThat(foundRecord.lastSuccessInstant()).isNull(); + assertThat(foundRecord.createdAt()).isEqualTo(record.createdAt()); + assertThat(foundRecord.updatedAt()).isEqualTo(record.updatedAt()); + } + + @Test + void update_shouldModifyExistingRecord() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "2222222222222222222222222222222222222222222222222222222222222222"); + DocumentRecord initialRecord = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/initial/path.pdf"), + "initial.pdf", + ProcessingStatus.PROCESSING, + FailureCounters.zero(), + null, + null, + Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS), + Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS) + ); + + repository.create(initialRecord); + + // Updated record + Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); + DocumentRecord updatedRecord = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/updated/path.pdf"), + "updated.pdf", + ProcessingStatus.SUCCESS, + new FailureCounters(0, 0), + null, + now, + initialRecord.createdAt(), + now + ); + + // When + repository.update(updatedRecord); + DocumentRecordLookupResult result = repository.findByFingerprint(fingerprint); + + // Then + assertThat(result).isInstanceOf(DocumentTerminalSuccess.class); + DocumentTerminalSuccess success = (DocumentTerminalSuccess) result; + DocumentRecord foundRecord = success.record(); + + assertThat(foundRecord.lastKnownSourceLocator().value()).isEqualTo("/updated/path.pdf"); + assertThat(foundRecord.lastKnownSourceFileName()).isEqualTo("updated.pdf"); + assertThat(foundRecord.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS); + assertThat(foundRecord.lastSuccessInstant()).isEqualTo(now); + assertThat(foundRecord.updatedAt()).isEqualTo(now); + } + + @Test + void create_shouldThrowException_whenFingerprintAlreadyExists() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "3333333333333333333333333333333333333333333333333333333333333333"); + DocumentRecord record1 = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/path1.pdf"), + "doc1.pdf", + ProcessingStatus.PROCESSING, + FailureCounters.zero(), + null, + null, + Instant.now().truncatedTo(ChronoUnit.MICROS), + Instant.now().truncatedTo(ChronoUnit.MICROS) + ); + + repository.create(record1); + + DocumentRecord record2 = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/path2.pdf"), + "doc2.pdf", + ProcessingStatus.PROCESSING, + FailureCounters.zero(), + null, + null, + Instant.now().truncatedTo(ChronoUnit.MICROS), + Instant.now().truncatedTo(ChronoUnit.MICROS) + ); + + // When / Then + assertThatThrownBy(() -> repository.create(record2)) + .isInstanceOf(DocumentPersistenceException.class); + } + + @Test + void update_shouldThrowException_whenFingerprintDoesNotExist() { + // Given + DocumentFingerprint fingerprint = new DocumentFingerprint( + "4444444444444444444444444444444444444444444444444444444444444444"); + DocumentRecord record = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/nonexistent.pdf"), + "nonexistent.pdf", + ProcessingStatus.PROCESSING, + FailureCounters.zero(), + null, + null, + Instant.now().truncatedTo(ChronoUnit.MICROS), + Instant.now().truncatedTo(ChronoUnit.MICROS) + ); + + // When / Then + assertThatThrownBy(() -> repository.update(record)) + .isInstanceOf(DocumentPersistenceException.class) + .hasMessageContaining("Expected to update 1 row but affected 0 rows"); + } +} \ No newline at end of file