M4 AP-004 Dokument-Stammsatz-Repository implementieren
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user