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,142 @@
package de.gecheckt.pdf.umbenenner.application.port.in;
import java.nio.file.Path;
import java.util.Objects;
/**
* Inbound-Port zum Anlegen einer neuen, leeren SQLite-Datenbankdatei und zum Umstellen
* der aktiven Datenbankreferenz der Anwendung auf diese neue Datei.
* <p>
* Der Use-Case orchestriert den vollständigen, aus Anwendungssicht atomaren Ablauf:
* <ol>
* <li>Pfad-Sicherheitsprüfung: aktive DB darf nicht überschrieben werden;</li>
* <li>Erzeugung einer temporären SQLite-Datei im Zielverzeichnis;</li>
* <li>vollständige Schema-Migration auf den neuesten Stand;</li>
* <li>Verbindungstest gegen die migrierte Temp-Datei;</li>
* <li>atomarer Move auf den endgültigen Zielpfad
* ({@link java.nio.file.StandardCopyOption#ATOMIC_MOVE},
* {@link java.nio.file.StandardCopyOption#REPLACE_EXISTING});</li>
* <li>Umstellung der aktiven DB-Referenz über den
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}.</li>
* </ol>
* <p>
* Schlägt ein Schritt fehl, bleibt die bisher aktive DB unverändert in Betrieb. Die
* temporäre Datei wird im Fehlerfall zuverlässig entfernt.
*/
@FunctionalInterface
public interface CreateNewDatabaseUseCase {
/**
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und stellt
* die aktive Datenbankreferenz der Anwendung auf diese Datei um.
*
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null}
* sein. Bei einer bereits existierenden Datei muss der Aufrufer
* vorab die Bestätigung des Benutzers eingeholt haben.
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie
* {@code null}
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
*/
CreateNewDatabaseResult createNewDatabase(Path targetFile);
/**
* Versiegeltes Ergebnis-Interface für {@link CreateNewDatabaseUseCase#createNewDatabase(Path)}.
*/
sealed interface CreateNewDatabaseResult
permits CreateNewDatabaseResult.Success,
CreateNewDatabaseResult.SameAsActiveDatabase,
CreateNewDatabaseResult.CreationFailed {
/**
* Erfolgsfall. Die neue Datenbank wurde angelegt, migriert, getestet und ist
* jetzt die aktive Datenbank der Anwendung.
*
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; nie
* {@code null}
*/
record Success(Path targetFile) implements CreateNewDatabaseResult {
/**
* Konstruktor mit Pflichtprüfung.
*
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; darf
* nicht {@code null} sein
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
*/
public Success {
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
}
}
/**
* Fehlerfall: Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei.
* Diese darf nicht überschrieben werden. Die aktive DB bleibt unverändert.
*
* @param targetFile der vom Benutzer gewählte Zielpfad; nie {@code null}
*/
record SameAsActiveDatabase(Path targetFile) implements CreateNewDatabaseResult {
/**
* Konstruktor mit Pflichtprüfung.
*
* @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht
* {@code null} sein
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
*/
public SameAsActiveDatabase {
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
}
}
/**
* Fehlerfall: Beim Anlegen, Migrieren, Testen oder beim atomaren Move ist ein
* technischer Fehler aufgetreten. Die aktive DB bleibt unverändert; eine evtl.
* angelegte Temp-Datei wurde entfernt.
*
* @param phase technische Phase, in der der Fehler auftrat; nie {@code null}
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
* @param cause ursächliche Ausnahme; kann {@code null} sein
*/
record CreationFailed(Phase phase, String message, Throwable cause)
implements CreateNewDatabaseResult {
/**
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
*
* @param phase technische Phase; darf nicht {@code null} sein
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht
* {@code null} sein
* @param cause ursächliche Ausnahme; kann {@code null} sein
* @throws NullPointerException wenn {@code phase} oder {@code message}
* {@code null} ist
*/
public CreationFailed {
Objects.requireNonNull(phase, "phase darf nicht null sein");
Objects.requireNonNull(message, "message darf nicht null sein");
}
}
/**
* Technische Phase, in der ein Fehler aufgetreten ist.
*/
enum Phase {
/** Pfad-Sicherheitsprüfung (z. B. Auflösung über {@code toRealPath()}) ist fehlgeschlagen. */
PATH_RESOLUTION,
/** Anlage der temporären Datei ist fehlgeschlagen. */
FILE_CREATION,
/** Schema-Migration der temporären Datei ist fehlgeschlagen. */
SCHEMA_MIGRATION,
/** Verbindungstest gegen die migrierte Datei ist fehlgeschlagen. */
CONNECTION_TEST,
/**
* Atomarer Move der temporären Datei zum Zielpfad ist fehlgeschlagen
* insbesondere wenn das Dateisystem die Kombination
* {@code ATOMIC_MOVE + REPLACE_EXISTING} nicht unterstützt. Es wird
* absichtlich kein nicht-atomarer Fallback durchgeführt.
*/
ATOMIC_MOVE,
/** Umstellung der aktiven DB-Referenz ist fehlgeschlagen. */
CONTEXT_SWITCH
}
}
}
@@ -0,0 +1,49 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.nio.file.Path;
import java.util.Optional;
/**
* Outbound-Port, der die zur Laufzeit aktive SQLite-Datenbankdatei der Anwendung kapselt.
* <p>
* Eigentümer der „aktiven DB-Referenz" zur Laufzeit. Der Port erlaubt es, die aktive
* Datenbank über einen In-Memory-Override umzustellen, ohne die Konfigurationsdatei
* (`.properties`) zu verändern. Die GUI nutzt diesen Mechanismus, damit nach dem Anlegen
* einer neuen Datenbank sofort sämtliche DB-Operationen (Verlauf, Reset, Löschen,
* Verarbeitungsläufe) gegen die neue Datei laufen, bevor der Benutzer die Konfiguration
* speichert.
* <p>
* <strong>Architekturgrenze:</strong> Der Port arbeitet ausschließlich mit
* {@link java.nio.file.Path} und kennt keine JDBC- oder SQLite-spezifischen Typen.
* Wie die Implementierung den Override technisch wirksam macht (z. B. durch Ersetzen
* der JDBC-URL beim Verdrahten neuer SQLite-Adapter), ist Adapter-Detail.
*/
public interface ActiveDatabaseContextPort {
/**
* Stellt die aktive SQLite-Datenbankdatei der Anwendung um.
* <p>
* Nach dem Aufruf verwenden alle nachfolgenden DB-Operationen die übergebene Datei
* als aktive Datenbank, sofern keine andere Datei explizit übergeben wird.
*
* @param newDbFile absoluter Pfad der neuen aktiven Datenbankdatei; darf nicht
* {@code null} sein. Die Datei muss zum Zeitpunkt des Aufrufs
* existieren, ein gültiges SQLite-Schema enthalten und lesbar sein
* (Verbindung muss bereits durch den Aufrufer verifiziert worden sein).
* @throws NullPointerException wenn {@code newDbFile} {@code null} ist
*/
void switchActiveDatabase(Path newDbFile);
/**
* Liefert den aktuell aktiven DB-Pfad als Override, sofern einer gesetzt wurde.
* <p>
* Solange kein Override gesetzt wurde, gilt die in der jeweiligen
* {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration}
* konfigurierte Datenbankdatei. Erst nach dem ersten Aufruf von
* {@link #switchActiveDatabase(Path)} liefert diese Methode einen nicht-leeren Wert.
*
* @return das gesetzte Override (nicht-leer) oder {@link Optional#empty()}, wenn die
* konfigurierte Datenbank weiterhin verwendet werden soll; nie {@code null}
*/
Optional<Path> activeDatabaseOverride();
}
@@ -0,0 +1,110 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.nio.file.Path;
import java.util.Objects;
/**
* Outbound-Port zum Anlegen und Initialisieren einer neuen, leeren SQLite-Datenbankdatei
* gegen eine bereits vom Aufrufer reservierte temporäre Zieldatei.
* <p>
* Der Aufrufer (Use-Case) verantwortet die Lebensdauer der temporären Datei: er wählt den
* Pfad, übergibt ihn an diesen Port und führt nach Erfolg den atomaren Move auf den
* endgültigen Zieldateipfad selbst aus. Der Adapter beschränkt sich strikt auf:
* <ol>
* <li>Anlage und Migration der temporären SQLite-Datei auf den neuesten Schema-Stand
* (z. B. via Flyway {@code migrate()});</li>
* <li>technischer Verbindungstest gegen die migrierte Datei (Verbindung öffnen,
* Flyway-History prüfen, einfache Leseabfrage gegen Schema-Metadaten);</li>
* <li>Aufräumen der temporären Datei im Fehlerfall.</li>
* </ol>
* <p>
* <strong>Architekturgrenze:</strong> Provider- und SQLite-spezifische Details
* (JDBC-URL-Schema, DataSource-Konfiguration, Flyway-Konfiguration) bleiben
* ausschließlich im Adapter. Der Port arbeitet mit einem opaken {@link Path} und gibt
* ein versiegeltes Ergebnis zurück.
*/
public interface DatabaseCreationPort {
/**
* Erstellt eine neue, leere SQLite-Datenbankdatei am übergebenen temporären
* Zielpfad und führt eine vollständige Schema-Migration auf den neuesten Stand aus.
* <p>
* Bei Fehlern in einem der Teilschritte (Anlage, Migration, Verbindungstest) wird
* die temporäre Datei zuverlässig wieder entfernt; aufrufende Komponenten müssen
* diesen Aufräumschritt nicht selbst durchführen.
*
* @param tempFile Pfad der zu erstellenden temporären SQLite-Datei; darf nicht
* {@code null} sein. Die Datei darf vor dem Aufruf noch nicht
* existieren; das Elternverzeichnis muss existieren und schreibbar
* sein.
* @return ein versiegeltes Ergebnis: {@link DatabaseCreationResult.Success} bei Erfolg
* oder {@link DatabaseCreationResult.Failure} mit Fehlerklasse und Meldung
* im Fehlerfall; nie {@code null}.
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
*/
DatabaseCreationResult createAndInitialize(Path tempFile);
/**
* Versiegeltes Ergebnis-Interface für {@link DatabaseCreationPort#createAndInitialize(Path)}.
*/
sealed interface DatabaseCreationResult
permits DatabaseCreationResult.Success, DatabaseCreationResult.Failure {
/**
* Erfolgsergebnis. Die temporäre Datei wurde erfolgreich erstellt, migriert
* und durch den Verbindungstest verifiziert. Der Aufrufer kann sie nun atomar
* an den endgültigen Zielpfad verschieben.
*
* @param tempFile der temporäre, erfolgreich migrierte Pfad; nie {@code null}
*/
record Success(Path tempFile) implements DatabaseCreationResult {
/**
* Konstruktor mit Pflichtprüfung.
*
* @param tempFile der temporäre, erfolgreich migrierte Pfad; darf nicht
* {@code null} sein
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
*/
public Success {
Objects.requireNonNull(tempFile, "tempFile darf nicht null sein");
}
}
/**
* Fehlerergebnis. Die temporäre Datei wurde falls bereits angelegt wieder
* entfernt; die aktive DB der Anwendung wurde nicht angetastet.
*
* @param phase die Phase, in der der Fehler auftrat; nie {@code null}
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
* @param cause ursächliche Ausnahme; kann {@code null} sein
*/
record Failure(Phase phase, String message, Throwable cause) implements DatabaseCreationResult {
/**
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
*
* @param phase die Phase, in der der Fehler auftrat; darf nicht {@code null} sein
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht {@code null} sein
* @param cause ursächliche Ausnahme; kann {@code null} sein
* @throws NullPointerException wenn {@code phase} oder {@code message} {@code null} ist
*/
public Failure {
Objects.requireNonNull(phase, "phase darf nicht null sein");
Objects.requireNonNull(message, "message darf nicht null sein");
}
}
/**
* Phase der Erstellung einer neuen Datenbank, in der ein Fehler auftrat.
*/
enum Phase {
/** Die temporäre Datei konnte nicht erzeugt oder beschrieben werden. */
FILE_CREATION,
/** Die Schema-Migration (Flyway) ist fehlgeschlagen. */
SCHEMA_MIGRATION,
/** Der nachgelagerte Verbindungstest ist fehlgeschlagen. */
CONNECTION_TEST
}
}
}
@@ -0,0 +1,307 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
/**
* Standardimplementierung des {@link CreateNewDatabaseUseCase}.
* <p>
* Orchestriert den vollständigen Anlage- und Wechselvorgang einer neuen, leeren
* SQLite-Datenbankdatei und delegiert die technischen Teilschritte an die Ports
* {@link DatabaseCreationPort} und {@link ActiveDatabaseContextPort}. Der Adapter
* darunter (z. B. SQLite/Flyway) bleibt für den Use-Case unsichtbar.
*
* <h2>Atomarität</h2>
* Aus Anwendungssicht ist der Wechsel atomar:
* <ul>
* <li>Bei einem Fehler in einem der Schritte wird die temporäre Datei zuverlässig
* entfernt; die aktive Datenbank bleibt unverändert in Betrieb.</li>
* <li>Erst nach erfolgreichem Verbindungstest wird die temporäre Datei via
* {@link StandardCopyOption#ATOMIC_MOVE} mit
* {@link StandardCopyOption#REPLACE_EXISTING} an den endgültigen Zielpfad
* verschoben. Bei nicht unterstützter Kombination wird der Vorgang mit
* klarer Fehlermeldung abgebrochen kein stiller Fallback.</li>
* </ul>
*
* <h2>Pfad-Sicherheitsprüfung</h2>
* Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
* normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
* real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
* Vergleich case-insensitive.
*/
public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
private static final Logger LOG = LogManager.getLogger(DefaultCreateNewDatabaseUseCase.class);
private static final String OS_NAME = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
private final DatabaseCreationPort databaseCreationPort;
private final ActiveDatabaseContextPort activeDatabaseContextPort;
private final ActiveDatabasePathSupplier activeDatabasePathSupplier;
/**
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
* <p>
* Diese Indirektion erlaubt es dem Bootstrap, sowohl den
* {@link ActiveDatabaseContextPort}-Override als auch den Wert aus der geladenen
* Konfigurationsdatei zu berücksichtigen, ohne dass der Use-Case Konfigurationstypen
* kennen muss.
*/
@FunctionalInterface
public interface ActiveDatabasePathSupplier {
/**
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
*
* @return den absoluten Pfad der aktiven Datei; nie {@code null}
*/
Path get();
}
/**
* Erzeugt den Use-Case mit den drei erforderlichen Ports/Lieferanten.
*
* @param databaseCreationPort Port zum Anlegen und Initialisieren der Temp-Datei;
* darf nicht {@code null} sein
* @param activeDatabaseContextPort Port zum Umstellen der aktiven DB-Referenz;
* darf nicht {@code null} sein
* @param activeDatabasePathSupplier Lieferant für den Pfad der aktuell aktiven
* SQLite-Datei; darf nicht {@code null} sein
* @throws NullPointerException wenn ein Parameter {@code null} ist
*/
public DefaultCreateNewDatabaseUseCase(DatabaseCreationPort databaseCreationPort,
ActiveDatabaseContextPort activeDatabaseContextPort,
ActiveDatabasePathSupplier activeDatabasePathSupplier) {
this.databaseCreationPort = Objects.requireNonNull(databaseCreationPort,
"databaseCreationPort darf nicht null sein");
this.activeDatabaseContextPort = Objects.requireNonNull(activeDatabaseContextPort,
"activeDatabaseContextPort darf nicht null sein");
this.activeDatabasePathSupplier = Objects.requireNonNull(activeDatabasePathSupplier,
"activeDatabasePathSupplier darf nicht null sein");
}
/**
* Orchestriert den vollständigen Anlage- und Wechselvorgang.
*
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
*/
@Override
public CreateNewDatabaseResult createNewDatabase(Path targetFile) {
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
Path absoluteTarget = targetFile.toAbsolutePath().normalize();
LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
// Schritt 1: Pfad-Sicherheitsprüfung
Path activeDb = activeDatabasePathSupplier.get();
if (activeDb == null) {
LOG.error("Aktiver Datenbankpfad ist nicht ermittelbar Anlage abgebrochen.");
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Aktiver Datenbankpfad konnte nicht ermittelt werden.",
null);
}
Path absoluteActive = activeDb.toAbsolutePath().normalize();
boolean sameFile;
try {
sameFile = isSameFile(absoluteActive, absoluteTarget);
} catch (IOException e) {
LOG.error("Pfad-Sicherheitsprüfung fehlgeschlagen: {}", e.getMessage(), e);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Pfad-Sicherheitsprüfung fehlgeschlagen: " + e.getMessage(),
e);
}
if (sameFile) {
LOG.warn("Anlage abgelehnt: Zielpfad entspricht der aktuell aktiven Datenbank: {}",
absoluteTarget);
return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
}
// Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
Path parent = absoluteTarget.getParent();
if (parent == null) {
LOG.error("Zielpfad besitzt kein Elternverzeichnis: {}", absoluteTarget);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Zielpfad besitzt kein Elternverzeichnis.",
null);
}
try {
if (!Files.isDirectory(parent)) {
Files.createDirectories(parent);
}
} catch (IOException e) {
LOG.error("Zielverzeichnis konnte nicht angelegt werden: {}", parent, e);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.FILE_CREATION,
"Zielverzeichnis konnte nicht angelegt werden: " + e.getMessage(),
e);
}
Path tempFile = parent.resolve(absoluteTarget.getFileName().toString()
+ ".new-" + UUID.randomUUID() + ".tmp");
// Schritt 3: Adapter führt Anlage + Schema-Migration + Verbindungstest aus
DatabaseCreationPort.DatabaseCreationResult creationResult;
try {
creationResult = databaseCreationPort.createAndInitialize(tempFile);
} catch (RuntimeException e) {
LOG.error("Unerwarteter Fehler beim Anlegen der temporären Datenbank: {}",
e.getMessage(), e);
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.FILE_CREATION,
"Unerwarteter Fehler beim Anlegen der temporären Datenbank: " + e.getMessage(),
e);
}
if (creationResult instanceof DatabaseCreationPort.DatabaseCreationResult.Failure failure) {
CreateNewDatabaseResult.Phase phase = mapPhase(failure.phase());
LOG.error("Anlage der neuen Datenbank fehlgeschlagen ({}): {}",
failure.phase(), failure.message());
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(phase, failure.message(),
failure.cause());
}
// Schritt 4: atomarer Move auf Zielpfad
try {
Files.move(tempFile, absoluteTarget,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
LOG.error("Atomarer Move nicht unterstützt für Zielpfad {}: {}",
absoluteTarget, e.getMessage(), e);
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
"Atomarer Move (ATOMIC_MOVE + REPLACE_EXISTING) wird vom Dateisystem nicht "
+ "unterstützt. Kein nicht-atomarer Fallback. Ziel: "
+ absoluteTarget,
e);
} catch (IOException e) {
LOG.error("Atomarer Move fehlgeschlagen: {}", e.getMessage(), e);
deleteTempQuietly(tempFile);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
"Verschieben der temporären Datenbank zum Zielpfad fehlgeschlagen: "
+ e.getMessage(),
e);
}
// Schritt 5: Aktive DB-Referenz umstellen
try {
activeDatabaseContextPort.switchActiveDatabase(absoluteTarget);
} catch (RuntimeException e) {
LOG.error("Umstellen der aktiven DB-Referenz fehlgeschlagen: {}", e.getMessage(), e);
return new CreateNewDatabaseResult.CreationFailed(
CreateNewDatabaseResult.Phase.CONTEXT_SWITCH,
"Aktive DB-Referenz konnte nicht umgestellt werden: " + e.getMessage(),
e);
}
LOG.info("Neue Datenbank erfolgreich angelegt und aktiviert: {}", absoluteTarget);
return new CreateNewDatabaseResult.Success(absoluteTarget);
}
/**
* Vergleicht zwei Datenbankpfade mit Berücksichtigung von Symlinks und
* (auf Windows) Case-Insensitivität.
* <p>
* Existieren beide Dateien, wird {@code Files.isSameFile(...)} verwendet.
* Existiert eine der beiden Dateien (typischerweise das Ziel) noch nicht, werden
* Elternverzeichnisse via {@link Path#toRealPath(java.nio.file.LinkOption...)}
* aufgelöst und mit den Dateinamen kombiniert verglichen. Auf Windows erfolgt der
* abschließende String-Vergleich case-insensitive.
*
* @param a Pfad A; darf nicht {@code null} sein
* @param b Pfad B; darf nicht {@code null} sein
* @return {@code true}, wenn beide Pfade auf dieselbe Datei zeigen
* @throws IOException bei Auflösungsfehlern existierender Pfadbestandteile
*/
static boolean isSameFile(Path a, Path b) throws IOException {
Objects.requireNonNull(a, "a darf nicht null sein");
Objects.requireNonNull(b, "b darf nicht null sein");
if (Files.exists(a) && Files.exists(b)) {
return Files.isSameFile(a, b);
}
Path realA = resolveBest(a);
Path realB = resolveBest(b);
if (isWindows()) {
return realA.toString().equalsIgnoreCase(realB.toString());
}
return realA.equals(realB);
}
/**
* Löst die existierenden Bestandteile eines Pfades soweit möglich real auf und
* normalisiert den Rest. Wird verwendet, wenn die Datei selbst noch nicht existiert.
*
* @param path der zu normalisierende Pfad
* @return ein bestmöglich aufgelöster, normalisierter Pfad
* @throws IOException bei {@link Path#toRealPath(java.nio.file.LinkOption...)}-Fehlern
*/
private static Path resolveBest(Path path) throws IOException {
if (Files.exists(path)) {
return path.toRealPath();
}
Path parent = path.toAbsolutePath().normalize().getParent();
Path fileName = path.getFileName();
if (parent != null && Files.exists(parent)) {
return parent.toRealPath().resolve(fileName == null ? "" : fileName.toString());
}
return path.toAbsolutePath().normalize();
}
/**
* Liefert {@code true}, wenn die laufende JVM auf Windows läuft.
*
* @return {@code true}, wenn Windows; sonst {@code false}
*/
private static boolean isWindows() {
return OS_NAME.contains("win");
}
private void deleteTempQuietly(Path tempFile) {
if (tempFile == null) {
return;
}
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
LOG.warn("Temporäre Datenbankdatei konnte nicht gelöscht werden: {} {}",
tempFile, e.getMessage());
}
}
private static CreateNewDatabaseResult.Phase mapPhase(
DatabaseCreationPort.DatabaseCreationResult.Phase phase) {
return switch (phase) {
case FILE_CREATION -> CreateNewDatabaseResult.Phase.FILE_CREATION;
case SCHEMA_MIGRATION -> CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION;
case CONNECTION_TEST -> CreateNewDatabaseResult.Phase.CONNECTION_TEST;
};
}
/**
* Liefert das gesetzte Override (sofern vorhanden), für Diagnose- und Logging-Zwecke.
* Nicht Teil der öffentlichen Use-Case-API.
*
* @return das Override aus dem {@link ActiveDatabaseContextPort}; nie {@code null}
*/
Optional<Path> currentOverride() {
return activeDatabaseContextPort.activeDatabaseOverride();
}
}
@@ -0,0 +1,210 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
/**
* Unit-Tests für {@link DefaultCreateNewDatabaseUseCase}.
* <p>
* Prüft den Orchestrierungsablauf, die Pfad-Sicherheitsprüfung, das Aufräumen der
* temporären Datei im Fehlerfall und das Umstellen der aktiven DB-Referenz im
* Erfolgsfall. Die DB-Adapter werden über Stubs ersetzt, damit die Tests ohne
* SQLite-/Flyway-Infrastruktur laufen.
*/
class DefaultCreateNewDatabaseUseCaseTest {
@Test
void constructor_shouldThrowNullPointerException_whenAnyPortIsNull() {
DatabaseCreationPort creation = tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
ActiveDatabaseContextPort context = stubActiveContext();
DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier supplier = () -> Path.of(".");
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(null, context, supplier))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("databaseCreationPort");
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, null, supplier))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("activeDatabaseContextPort");
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, context, null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("activeDatabasePathSupplier");
}
@Test
void createNewDatabase_shouldThrowNullPointerException_whenTargetIsNull() {
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile),
stubActiveContext(),
() -> Path.of("active.sqlite"));
assertThatThrownBy(() -> useCase.createNewDatabase(null))
.isInstanceOf(NullPointerException.class);
}
@Test
void createNewDatabase_shouldRejectSameAsActiveDatabase(@TempDir Path tempDir) throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "stub");
AtomicReference<Path> contextOverride = new AtomicReference<>();
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
tempFile -> {
throw new AssertionError("DatabaseCreationPort darf bei selber Datei nicht aufgerufen werden");
},
context,
active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(active);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.SameAsActiveDatabase.class);
assertThat(contextOverride.get()).isNull();
}
@Test
void createNewDatabase_shouldDeleteTempFileAndKeepActiveContext_whenAdapterReportsFailure(
@TempDir Path tempDir) throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "stub");
Path target = tempDir.resolve("new.sqlite");
AtomicReference<Path> capturedTemp = new AtomicReference<>();
DatabaseCreationPort creation = tempFile -> {
// Simuliere, dass der Adapter die Temp-Datei zwar anlegt, aber wegen Migration scheitert
try {
Files.writeString(tempFile, "x");
} catch (IOException e) {
throw new IllegalStateException(e);
}
capturedTemp.set(tempFile);
return new DatabaseCreationPort.DatabaseCreationResult.Failure(
DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
"Migration fehlgeschlagen", null);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, context, active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.CreationFailed.class);
CreateNewDatabaseResult.CreationFailed failed = (CreateNewDatabaseResult.CreationFailed) result;
assertThat(failed.phase()).isEqualTo(CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION);
assertThat(capturedTemp.get()).isNotNull();
assertThat(Files.exists(capturedTemp.get())).isFalse();
assertThat(Files.exists(target)).isFalse();
assertThat(contextOverride.get()).isNull();
}
@Test
void createNewDatabase_shouldMoveTempToTargetAndSwitchContext_onSuccess(@TempDir Path tempDir)
throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "stub");
Path target = tempDir.resolve("new.sqlite");
AtomicReference<Path> capturedTemp = new AtomicReference<>();
DatabaseCreationPort creation = tempFile -> {
try {
Files.writeString(tempFile, "migrated-content");
} catch (IOException e) {
throw new IllegalStateException(e);
}
capturedTemp.set(tempFile);
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, context, active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
assertThat(Files.exists(target)).isTrue();
assertThat(Files.readString(target)).isEqualTo("migrated-content");
assertThat(capturedTemp.get()).isNotNull();
assertThat(Files.exists(capturedTemp.get())).isFalse();
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
}
@Test
void createNewDatabase_shouldOverwriteExistingTargetFileAtomically(@TempDir Path tempDir) throws IOException {
Path active = tempDir.resolve("active.sqlite");
Files.writeString(active, "active");
Path target = tempDir.resolve("existing.sqlite");
Files.writeString(target, "alter-inhalt");
DatabaseCreationPort creation = tempFile -> {
try {
Files.writeString(tempFile, "neu-und-leer");
} catch (IOException e) {
throw new IllegalStateException(e);
}
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
};
AtomicReference<Path> contextOverride = new AtomicReference<>();
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
creation, trackingActiveContext(contextOverride), active::toAbsolutePath);
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
assertThat(Files.readString(target)).isEqualTo("neu-und-leer");
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
}
@Test
void isSameFile_shouldReturnTrue_forIdenticalNonExistingFiles(@TempDir Path tempDir) throws IOException {
Path a = tempDir.resolve("data.sqlite");
Path b = tempDir.resolve("data.sqlite");
assertThat(DefaultCreateNewDatabaseUseCase.isSameFile(a, b)).isTrue();
}
@Test
void isSameFile_shouldReturnFalse_forDifferentFilesInSameDirectory(@TempDir Path tempDir) throws IOException {
Path a = tempDir.resolve("a.sqlite");
Path b = tempDir.resolve("b.sqlite");
assertThat(DefaultCreateNewDatabaseUseCase.isSameFile(a, b)).isFalse();
}
private static ActiveDatabaseContextPort stubActiveContext() {
return new ActiveDatabaseContextPort() {
@Override
public void switchActiveDatabase(Path newDbFile) { /* no-op */ }
@Override
public Optional<Path> activeDatabaseOverride() { return Optional.empty(); }
};
}
/**
* Aktive Context-Implementierung, die das übergebene Override in der gegebenen
* {@link AtomicReference} festhält, damit Tests die Aufruf-Sequenz prüfen können.
*/
private static ActiveDatabaseContextPort trackingActiveContext(AtomicReference<Path> sink) {
return new ActiveDatabaseContextPort() {
@Override
public void switchActiveDatabase(Path newDbFile) {
sink.set(newDbFile);
}
@Override
public Optional<Path> activeDatabaseOverride() {
return Optional.ofNullable(sink.get());
}
};
}
}