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:
+142
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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();
|
||||
}
|
||||
+110
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user