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:
2026-05-05 16:52:54 +02:00
parent 90d95b9ff8
commit 3876e647b2
15 changed files with 1793 additions and 20 deletions
@@ -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;
}
}