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