1
0

M4 AP-004 Dokument-Stammsatz-Repository implementieren

This commit is contained in:
2026-04-02 20:27:29 +02:00
parent 6a44def89b
commit 29ea56d2cf
2 changed files with 484 additions and 0 deletions

View File

@@ -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}.
* <p>
* Provides CRUD operations for the document master record (Dokument-Stammsatz)
* 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-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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* Intended for logging and diagnostics only.
*
* @return the JDBC URL; never null or blank
*/
public String getJdbcUrl() {
return jdbcUrl;
}
}

View File

@@ -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");
}
}