Lege neue leere SQLite-Datenbank atomar via Use-Case und GUI an
Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen…" mit FileChooser, normalisierter Pfadprüfung gegen die aktive DB, gesammelter Überschreib- Bestätigung, DB-Busy-Sperre auf Verlauf-Tab, Flyway-Migration auf den neuesten Stand gegen eine Temp-Datei, Verbindungstest, atomarem Move (ATOMIC_MOVE + REPLACE_EXISTING) und Umstellen der aktiven DB-Referenz über einen neuen ActiveDatabaseContextPort. Konfig-Tab wechselt nach Wechsel automatisch in den Dirty-State; Hinweismeldung mit Speichern- Aufforderung wird im zentralen Meldungsbereich angezeigt. Architektur entspricht Fall B aus der Spezifikation: Bootstrap hält den Override prozessweit und verwendet ihn in resolveActiveJdbcUrl statt des Werts aus der .properties-Datei. Bei Fehlern wird die Temp-Datei zuverlässig entfernt; die aktive DB bleibt unverändert in Betrieb. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+199
@@ -0,0 +1,199 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.sqlite.SQLiteConfig;
|
||||
import org.sqlite.SQLiteDataSource;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||
|
||||
/**
|
||||
* SQLite-Implementierung des {@link DatabaseCreationPort}.
|
||||
* <p>
|
||||
* Erzeugt eine neue, leere SQLite-Datenbank gegen einen vom Aufrufer übergebenen
|
||||
* temporären Zielpfad und führt eine vollständige Flyway-Migration auf den neuesten
|
||||
* Schema-Stand aus. Anschließend wird ein Verbindungstest durchgeführt, der drei
|
||||
* Aspekte verifiziert:
|
||||
* <ol>
|
||||
* <li>Eine SQLite-Verbindung kann erfolgreich geöffnet werden.</li>
|
||||
* <li>Die Flyway-History-Tabelle (Standardname {@code flyway_schema_history}) ist
|
||||
* vorhanden und enthält mindestens einen erfolgreichen Migrationseintrag.</li>
|
||||
* <li>Eine einfache Leseabfrage gegen Schema-Metadaten
|
||||
* ({@code sqlite_master}) liefert ohne Fehler.</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Im Fehlerfall wird die temporäre Datei zuverlässig wieder entfernt; aufrufende
|
||||
* Komponenten erhalten ein klassifiziertes
|
||||
* {@link DatabaseCreationPort.DatabaseCreationResult.Failure}-Ergebnis.
|
||||
*
|
||||
* <h2>Architekturgrenze</h2>
|
||||
* <p>JDBC, SQLite-Konfiguration und Flyway-spezifische Typen verbleiben vollständig in
|
||||
* dieser Klasse. Nach außen wird ausschließlich der versiegelte Port-Ergebnistyp
|
||||
* herausgereicht.
|
||||
*/
|
||||
public class SqliteDatabaseCreationAdapter implements DatabaseCreationPort {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(SqliteDatabaseCreationAdapter.class);
|
||||
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
|
||||
|
||||
/**
|
||||
* Standardkonstruktor.
|
||||
*/
|
||||
public SqliteDatabaseCreationAdapter() {
|
||||
// keine Felder, kein Zustand
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine neue, leere SQLite-Datenbank an, migriert sie auf den neuesten Stand
|
||||
* und führt einen Verbindungstest durch. Bei Fehlern wird die Temp-Datei entfernt.
|
||||
*
|
||||
* @param tempFile Pfad der zu erzeugenden temporären SQLite-Datei; darf nicht
|
||||
* {@code null} sein und sollte vor dem Aufruf nicht existieren
|
||||
* @return {@link DatabaseCreationResult.Success} bei Erfolg oder
|
||||
* {@link DatabaseCreationResult.Failure} mit klassifizierter Phase
|
||||
*/
|
||||
@Override
|
||||
public DatabaseCreationResult createAndInitialize(Path tempFile) {
|
||||
if (tempFile == null) {
|
||||
throw new NullPointerException("tempFile darf nicht null sein");
|
||||
}
|
||||
Path absoluteTemp = tempFile.toAbsolutePath().normalize();
|
||||
LOG.info("Lege neue temporäre SQLite-Datenbank an: {}", absoluteTemp);
|
||||
|
||||
// Verhindern, dass eine versehentlich vorhandene Temp-Datei mitmigiert wird
|
||||
try {
|
||||
if (Files.exists(absoluteTemp)) {
|
||||
Files.delete(absoluteTemp);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.error("Vorhandene temporäre Datei konnte nicht entfernt werden: {}",
|
||||
absoluteTemp, e);
|
||||
return new DatabaseCreationResult.Failure(
|
||||
DatabaseCreationResult.Phase.FILE_CREATION,
|
||||
"Vorhandene temporäre Datei konnte nicht entfernt werden: " + e.getMessage(),
|
||||
e);
|
||||
}
|
||||
|
||||
String jdbcUrl = buildJdbcUrl(absoluteTemp);
|
||||
DataSource dataSource = createDataSource(jdbcUrl);
|
||||
|
||||
// Schema-Migration auf neuesten Stand
|
||||
try {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.connectRetries(0)
|
||||
.load();
|
||||
flyway.migrate();
|
||||
LOG.info("Flyway-Migration auf neuesten Stand abgeschlossen für: {}", absoluteTemp);
|
||||
} catch (RuntimeException e) {
|
||||
LOG.error("Flyway-Migration fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
|
||||
cleanup(absoluteTemp);
|
||||
return new DatabaseCreationResult.Failure(
|
||||
DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
|
||||
"Schema-Migration fehlgeschlagen: " + e.getMessage(),
|
||||
e);
|
||||
}
|
||||
|
||||
// Verbindungstest gegen die migrierte Temp-Datei
|
||||
try {
|
||||
verifyConnection(dataSource);
|
||||
LOG.info("Verbindungstest gegen neue SQLite-Datenbank erfolgreich: {}", absoluteTemp);
|
||||
} catch (SQLException | IllegalStateException e) {
|
||||
LOG.error("Verbindungstest fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
|
||||
cleanup(absoluteTemp);
|
||||
return new DatabaseCreationResult.Failure(
|
||||
DatabaseCreationResult.Phase.CONNECTION_TEST,
|
||||
"Verbindungstest fehlgeschlagen: " + e.getMessage(),
|
||||
e);
|
||||
}
|
||||
|
||||
return new DatabaseCreationResult.Success(absoluteTemp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert die migrierte Datenbank durch drei aufeinander aufbauende Prüfungen.
|
||||
*
|
||||
* @param dataSource die DataSource gegen die Temp-Datei
|
||||
* @throws SQLException bei JDBC-Fehlern
|
||||
* @throws IllegalStateException wenn eine fachliche Erwartung (z. B. Flyway-History
|
||||
* vorhanden, mind. ein erfolgreicher Eintrag) verletzt ist
|
||||
*/
|
||||
private void verifyConnection(DataSource dataSource) throws SQLException {
|
||||
try (Connection conn = dataSource.getConnection()) {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='"
|
||||
+ FLYWAY_HISTORY_TABLE + "'")) {
|
||||
if (!rs.next() || rs.getInt(1) != 1) {
|
||||
throw new IllegalStateException(
|
||||
"Flyway-History-Tabelle fehlt nach der Migration.");
|
||||
}
|
||||
}
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT count(*) FROM " + FLYWAY_HISTORY_TABLE + " WHERE success = 1")) {
|
||||
if (!rs.next() || rs.getInt(1) < 1) {
|
||||
throw new IllegalStateException(
|
||||
"Flyway-History enthält keinen erfolgreichen Migrationseintrag.");
|
||||
}
|
||||
}
|
||||
// einfache Leseabfrage gegen Schema-Metadaten
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'")) {
|
||||
int tableCount = 0;
|
||||
while (rs.next()) {
|
||||
tableCount++;
|
||||
}
|
||||
if (tableCount < 1) {
|
||||
throw new IllegalStateException(
|
||||
"Schema-Metadatenabfrage lieferte keine Tabellen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanup(Path tempFile) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
LOG.warn("Temporäre SQLite-Datei konnte nach Fehler nicht entfernt werden: {} – {}",
|
||||
tempFile, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die JDBC-URL für eine SQLite-Datei nach dem im Projekt etablierten Schema.
|
||||
*
|
||||
* @param dbFile absoluter Pfad der SQLite-Datei; darf nicht {@code null} sein
|
||||
* @return die JDBC-URL in der Form {@code jdbc:sqlite:/pfad/zur/datei.db}
|
||||
*/
|
||||
private static String buildJdbcUrl(Path dbFile) {
|
||||
return "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine SQLite-DataSource mit aktivierten Fremdschlüsseln.
|
||||
*
|
||||
* @param jdbcUrl die JDBC-URL der SQLite-Datei
|
||||
* @return eine konfigurierte {@link DataSource}; nie {@code null}
|
||||
*/
|
||||
private static DataSource createDataSource(String jdbcUrl) {
|
||||
SQLiteConfig config = new SQLiteConfig();
|
||||
config.enforceForeignKeys(true);
|
||||
SQLiteDataSource ds = new SQLiteDataSource(config);
|
||||
ds.setUrl(jdbcUrl);
|
||||
return ds;
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort.DatabaseCreationResult;
|
||||
|
||||
/**
|
||||
* Tests für {@link SqliteDatabaseCreationAdapter}.
|
||||
* <p>
|
||||
* Prüft, dass eine neue, leere SQLite-Datei am übergebenen Temp-Pfad angelegt und
|
||||
* vollständig per Flyway migriert wird, dass der Verbindungstest die Flyway-History
|
||||
* verifiziert und dass Fehler im Verlauf zur Bereinigung der Temp-Datei führen.
|
||||
*/
|
||||
class SqliteDatabaseCreationAdapterTest {
|
||||
|
||||
@Test
|
||||
void createAndInitialize_shouldRejectNullPath() {
|
||||
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||
assertThatThrownBy(() -> adapter.createAndInitialize(null))
|
||||
.isInstanceOf(NullPointerException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAndInitialize_shouldCreateAndMigrateNewSqliteFile(@TempDir Path tempDir) throws Exception {
|
||||
Path tempFile = tempDir.resolve("new-db.sqlite.tmp");
|
||||
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||
|
||||
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
|
||||
|
||||
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
|
||||
assertThat(Files.exists(tempFile)).isTrue();
|
||||
assertThat(Files.size(tempFile)).isGreaterThan(0);
|
||||
|
||||
// Schema verifizieren: Flyway-History und fachliche Tabellen müssen existieren
|
||||
String jdbcUrl = "jdbc:sqlite:" + tempFile.toAbsolutePath().toString().replace('\\', '/');
|
||||
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||
Statement stmt = conn.createStatement()) {
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' "
|
||||
+ "AND name IN ('flyway_schema_history','document_record','processing_attempt')")) {
|
||||
assertThat(rs.next()).isTrue();
|
||||
assertThat(rs.getInt(1)).isEqualTo(3);
|
||||
}
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT count(*) FROM flyway_schema_history WHERE success = 1")) {
|
||||
assertThat(rs.next()).isTrue();
|
||||
assertThat(rs.getInt(1)).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAndInitialize_shouldOverwriteExistingTempFileBeforeMigration(@TempDir Path tempDir) throws Exception {
|
||||
Path tempFile = tempDir.resolve("existing.tmp");
|
||||
Files.writeString(tempFile, "rest-zustand");
|
||||
|
||||
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
|
||||
|
||||
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
|
||||
// Die Datei wurde durch eine leere SQLite-Datei ersetzt — der ursprüngliche Inhalt darf nicht mehr
|
||||
// sichtbar sein.
|
||||
assertThat(Files.size(tempFile)).isGreaterThan(0);
|
||||
assertThat(Files.readString(tempFile, java.nio.charset.StandardCharsets.ISO_8859_1))
|
||||
.doesNotContain("rest-zustand");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAndInitialize_shouldFailAndCleanup_whenParentDirectoryDoesNotExist(@TempDir Path tempDir)
|
||||
throws SQLException {
|
||||
Path missingParent = tempDir.resolve("does-not-exist").resolve("child.tmp");
|
||||
|
||||
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||
DatabaseCreationResult result = adapter.createAndInitialize(missingParent);
|
||||
|
||||
assertThat(result).isInstanceOf(DatabaseCreationResult.Failure.class);
|
||||
DatabaseCreationResult.Failure failure = (DatabaseCreationResult.Failure) result;
|
||||
assertThat(failure.phase())
|
||||
.isIn(DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
|
||||
DatabaseCreationPort.DatabaseCreationResult.Phase.CONNECTION_TEST,
|
||||
DatabaseCreationPort.DatabaseCreationResult.Phase.FILE_CREATION);
|
||||
assertThat(Files.exists(missingParent)).isFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user