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
@@ -57,6 +57,7 @@ import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
@@ -422,6 +423,33 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiPromptEditorPortFactory promptEditorPortFactory;
/**
* Bridge zur DB-Anlage- und Wechsellogik. Wird vom Menüpunkt
* „Datenbank → Neue Datenbank anlegen…" ausgelöst.
*/
private final GuiCreateNewDatabasePort createNewDatabasePort;
/**
* Aktiver DB-Busy-Zustand während einer laufenden Datenbank-Anlage. Solange
* dieser Zustand aktiv ist, sind alle DB-lesenden und DB-schreibenden Aktionen
* der GUI gesperrt (vgl. {@link #applyDbBusyLock()}).
* <p>
* Als JavaFX-Property realisiert, damit die Menüleiste den Zustand direkt
* über {@code disableProperty().bind(...)} auswerten kann.
*/
private final javafx.beans.property.SimpleBooleanProperty dbBusyForDatabaseCreation =
new javafx.beans.property.SimpleBooleanProperty(false);
/**
* Hintergrund-Worker-Thread für die DB-Anlage; einzel-threadig, damit nicht
* mehrere DB-Anlagen gleichzeitig laufen können.
*/
private final ExecutorService createNewDatabaseExecutor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "gui-create-new-database");
thread.setDaemon(true);
return thread;
});
/**
* Second main tab of the window that drives the live processing-run view. Created
* during workspace construction and wired into the shared {@link #tabPane} alongside
@@ -513,6 +541,7 @@ public final class GuiConfigurationEditorWorkspace {
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
this.createNewDatabasePort = effectiveContext.createNewDatabasePort();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
@@ -1018,6 +1047,218 @@ public final class GuiConfigurationEditorWorkspace {
checkExistsAndSave(targetPath, () -> { });
}
/**
* Liefert {@code true}, wenn aktuell gerade eine Datenbank-Anlage läuft und der
* Menüpunkt „Datenbank → Neue Datenbank anlegen…" daher gesperrt ist.
*
* @return aktueller DB-Busy-Zustand
*/
public boolean isDbBusyForDatabaseCreation() {
return dbBusyForDatabaseCreation.get();
}
/**
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty} für den
* DB-Busy-Zustand. Wird von der Menüleiste genutzt, um den Menüpunkt
* „Neue Datenbank anlegen…" während einer laufenden Anlage automatisch zu
* deaktivieren.
*
* @return read-only Property; nie {@code null}
*/
public javafx.beans.property.BooleanProperty dbBusyForDatabaseCreationProperty() {
return dbBusyForDatabaseCreation;
}
/**
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty}, die den
* Lauf-aktiv-Zustand des Verarbeitungslauf-Tabs spiegelt. Wird von der
* Menüleiste genutzt, um den Menüpunkt „Neue Datenbank anlegen…" während
* eines laufenden Verarbeitungslaufs zu deaktivieren.
*
* @return read-only Property; nie {@code null}
*/
public javafx.beans.property.ReadOnlyBooleanProperty batchRunRunningProperty() {
return batchRunTab.runningProperty();
}
/**
* Behandelt die Aktion „Datenbank → Neue Datenbank anlegen…".
* <p>
* Öffnet einen FileChooser (Filter {@code *.sqlite}), prüft den Zielpfad
* gegen die aktive Datenbank, holt ggf. eine Überschreib-Bestätigung ein und
* delegiert die eigentliche Anlage an
* {@link GuiCreateNewDatabasePort#createNewDatabase(Path)} auf einem
* Hintergrund-Worker-Thread.
* <p>
* Während der Ausführung ist die GUI in einem DB-Busy-Zustand: alle
* DB-lesenden und DB-schreibenden Aktionen sind deaktiviert. Der Zustand
* wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void requestCreateNewDatabase() {
if (dbBusyForDatabaseCreation.get()) {
LOG.debug("GUI-Editor: Anlage einer neuen Datenbank ist bereits in Arbeit Klick ignoriert.");
return;
}
if (batchRunTab != null && batchRunTab.isRunning()) {
showError("Während eines Verarbeitungslaufs kann keine neue Datenbank angelegt werden.");
return;
}
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
FileChooser fileChooser = saveFileChooserFactory.get();
fileChooser.setTitle("Neue Datenbank anlegen");
fileChooser.getExtensionFilters().add(
new FileChooser.ExtensionFilter("SQLite-Datenbanken", "*.sqlite"));
fileChooser.setInitialFileName("pdf-umbenenner.sqlite");
// Vorschlagsverzeichnis: SQLite-Pfad aus der aktuellen Konfiguration, sofern gesetzt
String currentSqlite = editorState.values().sqliteFile();
if (currentSqlite != null && !currentSqlite.isBlank()) {
try {
Path proposedDir = Path.of(currentSqlite).toAbsolutePath().getParent();
if (proposedDir != null && proposedDir.toFile().isDirectory()) {
fileChooser.setInitialDirectory(proposedDir.toFile());
}
} catch (Exception ignore) {
// bei ungültigem Pfad: kein Vorschlagsverzeichnis
}
}
File selectedFile;
try {
selectedFile = saveDialogFunction.apply(fileChooser, owner);
} catch (UnsupportedOperationException e) {
LOG.debug("GUI-Editor: Datenbank-Speichern-Dialog nicht verfügbar (headless).");
return;
}
if (selectedFile == null) {
return;
}
Path requestedTarget = selectedFile.toPath().toAbsolutePath().normalize();
// Bestätigungsdialog wenn Datei bereits existiert (egal ob fremd oder aktive DB —
// die endgültige Sicherheitsprüfung gegen die aktive DB übernimmt der Use-Case).
if (java.nio.file.Files.exists(requestedTarget)) {
ButtonType ueberschreiben = new ButtonType("Überschreiben", ButtonBar.ButtonData.OK_DONE);
ButtonType abbrechen = new ButtonType("Abbrechen", ButtonBar.ButtonData.CANCEL_CLOSE);
Optional<ButtonType> choice = showConfirmation(
"Datei überschreiben?",
"Die Datei existiert bereits:\n" + requestedTarget
+ "\n\nDie vorhandene Datei wird durch eine neue, leere SQLite-Datenbank ersetzt.\nFortfahren?",
abbrechen,
ueberschreiben);
if (choice.isEmpty() || !choice.get().equals(ueberschreiben)) {
LOG.info("GUI-Editor: Anlage einer neuen Datenbank vom Benutzer abgebrochen.");
return;
}
}
startCreateNewDatabaseWorker(requestedTarget);
}
/**
* Aktiviert die DB-Busy-Sperre auf der Oberfläche und reicht den eigentlichen
* Aufruf des {@link GuiCreateNewDatabasePort} an einen Daemon-Worker-Thread weiter.
*
* @param targetFile der bereits geprüfte Zielpfad; nie {@code null}
*/
private void startCreateNewDatabaseWorker(Path targetFile) {
dbBusyForDatabaseCreation.set(true);
applyDbBusyLock();
showStatusMessage("Neue SQLite-Datenbank wird angelegt …");
createNewDatabaseExecutor.submit(() -> {
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
.CreateNewDatabaseResult result;
try {
result = createNewDatabasePort.createNewDatabase(loadedConfigurationPath(), targetFile);
} catch (RuntimeException e) {
LOG.error("GUI-Editor: Unerwarteter Fehler beim Anlegen der neuen Datenbank: {}",
e.getMessage(), e);
Platform.runLater(() -> {
dbBusyForDatabaseCreation.set(false);
applyDbBusyLock();
showError("Neue Datenbank konnte nicht angelegt werden: " + safeMessage(e));
});
return;
}
Platform.runLater(() -> handleCreateNewDatabaseResult(targetFile, result));
});
}
/**
* Übersetzt das Ergebnis des Use-Cases in die UI-Reaktion: Dirty-State,
* Statuszeile, Verlauf-Reload, Hinweismeldung oder Fehlerdialog.
*
* @param targetFile der vom Benutzer gewählte Zielpfad
* @param result das Ergebnis des Use-Cases
*/
private void handleCreateNewDatabaseResult(
Path targetFile,
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
.CreateNewDatabaseResult result) {
try {
switch (result) {
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Success success -> {
Path effectiveTarget = success.targetFile();
LOG.info("GUI-Editor: Neue Datenbank ist aktiv: {}", effectiveTarget);
// Konfigurationsmodell aktualisieren → Dirty-State
GuiConfigurationValues updated = editorState.values()
.withSqliteFile(effectiveTarget.toString());
updateValues(updated);
// Verlauf-Tab neu laden, damit die neue (leere) DB sichtbar wird
if (historyTab != null) {
historyTab.reloadAfterDatabaseSwitch();
}
// Hinweismeldung im zentralen Meldungsbereich
pendingMessages.add(GuiMessageEntry.of(
GuiMessageSeverity.INFO,
"Neue Datenbank ist aktiv. Konfiguration speichern, damit "
+ "diese DB beim nächsten Start verwendet wird.",
"Datenbank-Anlage"));
refreshAfterValidation();
showStatusMessage("Neue Datenbank ist aktiv: " + effectiveTarget);
}
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.SameAsActiveDatabase same -> {
LOG.warn("GUI-Editor: Anlage abgelehnt Zielpfad ist die aktuell aktive DB: {}",
same.targetFile());
showError("Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei. "
+ "Bitte einen anderen Pfad wählen.");
}
case de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed failure -> {
LOG.error("GUI-Editor: Anlage fehlgeschlagen ({}): {}",
failure.phase(), failure.message());
showError("Neue Datenbank konnte nicht angelegt werden: " + failure.message());
}
}
} finally {
dbBusyForDatabaseCreation.set(false);
applyDbBusyLock();
}
}
/**
* Wendet die aktuelle DB-Busy-Sperre auf die betroffenen UI-Komponenten an.
* <p>
* Während der Sperre sind die DB-lesenden und DB-schreibenden Aktionen des
* Verlauf-Tabs deaktiviert. Andere DB-Operationen laufen pro Aufruf frisch in
* Bootstrap und greifen automatisch den DB-Override des
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort} ab.
*/
private void applyDbBusyLock() {
if (historyTab != null) {
historyTab.setDbBusy(dbBusyForDatabaseCreation.get());
}
}
/**
* Checks on a background worker thread whether the target path already exists and, if so,
* asks the user to confirm overwriting on the FX Application Thread before writing.
@@ -0,0 +1,41 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
import java.nio.file.Path;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
/**
* GUI-internes Bridge-Interface zwischen dem Workspace und dem
* {@link de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase}.
* <p>
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Es ist eine modul-interne Brücke, über die Bootstrap die DB-Anlage- und Wechsellogik
* für die GUI bereitstellt, ohne dass der GUI-Adapter direkt auf den Use-Case oder die
* darunterliegenden Outbound-Ports zugreift.
* <p>
* <strong>Threading:</strong> Implementierungen dürfen blockierende Operationen
* ausführen (Flyway-Migration, Verbindungstest, atomares Verschieben einer Datei).
* Sie müssen daher von einem Hintergrund-Worker-Thread aufgerufen werden. Der Aufruf
* blockiert, bis das Ergebnis vollständig vorliegt.
*/
@FunctionalInterface
public interface GuiCreateNewDatabasePort {
/**
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und
* stellt die aktive Datenbankreferenz auf diese Datei um.
*
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei,
* oder {@code null}, wenn (noch) keine Konfiguration
* geladen ist. Die Bootstrap-Implementierung leitet
* daraus den Pfad der aktuell aktiven SQLite-Datei ab,
* sofern noch kein Override vom
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}
* gesetzt ist.
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht
* {@code null} sein
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler;
* nie {@code null}
*/
CreateNewDatabaseResult createNewDatabase(Path configFilePath, Path targetFile);
}
@@ -77,7 +77,8 @@ public record GuiStartupContext(
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory) {
GuiPromptEditorPortFactory promptEditorPortFactory,
GuiCreateNewDatabasePort createNewDatabasePort) {
/**
* Creates a fully wired startup context.
@@ -155,6 +156,8 @@ public record GuiStartupContext(
"deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null");
createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
"createNewDatabasePort must not be null");
}
/**
@@ -198,7 +201,8 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort());
}
/**
@@ -236,7 +240,8 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort());
}
/**
@@ -274,7 +279,8 @@ public record GuiStartupContext(
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -396,7 +402,25 @@ public record GuiStartupContext(
noOpHistoryDetailsPort(),
noOpHistoryResetPort(),
noOpDeleteHistoryPort(),
noOpPromptEditorPortFactory());
noOpPromptEditorPortFactory(),
rejectingCreateNewDatabasePort());
}
/**
* Liefert einen ablehnenden {@link GuiCreateNewDatabasePort}, der jede Anlage
* sofort als Fehler zurückgibt. Wird verwendet, wenn kein Bootstrap-seitig
* verdrahteter Port vorliegt (z. B. in Tests oder vor dem Laden einer
* Konfiguration).
*
* @return ein ablehnender Port; nie {@code null}
*/
private static GuiCreateNewDatabasePort rejectingCreateNewDatabasePort() {
return (configFilePath, targetFile) -> new de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed(
de.gecheckt.pdf.umbenenner.application.port.in
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
"Kein DB-Anlage-Port in diesem Startkontext verfügbar.",
null);
}
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
@@ -7,6 +7,9 @@ import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuBar;
import javafx.scene.control.MenuItem;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
@@ -74,8 +77,12 @@ public class PdfUmbenennerGuiApplication extends Application {
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion());
workspace.statusBarStateListener = statusBar::applyEditorState;
// Menüleiste mit Datenbank-Menü („Neue Datenbank anlegen…")
MenuBar menuBar = buildMenuBar(workspace);
// Statuszeile unterhalb des Workspace-Inhalts einbetten
BorderPane outerLayout = new BorderPane();
outerLayout.setTop(menuBar);
outerLayout.setCenter(workspace.root());
outerLayout.setBottom(statusBar.root());
@@ -115,6 +122,31 @@ public class PdfUmbenennerGuiApplication extends Application {
}
}
/**
* Baut die Menüleiste für das Hauptfenster auf.
* <p>
* Aktuell enthält sie genau einen Eintrag: das Menü „Datenbank" mit der Aktion
* „Neue Datenbank anlegen…". Diese delegiert an
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
* <p>
* Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
* bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
* der Workspace die DB-Busy-Sperre wieder aufhebt.
*
* @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
* @return die fertig konfigurierte Menüleiste
*/
private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
Menu databaseMenu = new Menu("Datenbank");
MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
// Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
.or(workspace.dbBusyForDatabaseCreationProperty()));
databaseMenu.getItems().add(createNewItem);
return new MenuBar(databaseMenu);
}
/**
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
* System-Tray minimiert statt es zu schließen.
@@ -150,6 +150,15 @@ public final class GuiHistoryTab {
*/
private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(300));
/**
* Sperre für DB-lesende und DB-schreibende Aktionen während einer
* laufenden Datenbank-Anlage (vgl. „Neue Datenbank anlegen"). Wird auf {@code true}
* gesetzt, solange die Anlage einer neuen SQLite-Datenbank läuft, und nach Erfolg
* oder Fehler zuverlässig zurückgesetzt. Während dieser Zeit sind Suche, Filter,
* Aktualisieren, Status-Reset und Löschen deaktiviert.
*/
private boolean dbBusy = false;
/**
* Erzeugt den Historien-Tab.
*
@@ -217,6 +226,49 @@ public final class GuiHistoryTab {
}
}
/**
* Schaltet die DB-Busy-Sperre des Verlauf-Tabs an oder aus.
* <p>
* Während der Sperre sind Suche, Statusfilter, Aktualisieren, Status-Reset und
* Eintrag-Löschen deaktiviert. Wird typischerweise vom Workspace aufgerufen,
* solange eine neue SQLite-Datenbank angelegt und aktiviert wird.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param busy {@code true} aktiviert die Sperre, {@code false} hebt sie wieder auf
*/
public void setDbBusy(boolean busy) {
this.dbBusy = busy;
searchField.setDisable(busy);
statusFilterBox.setDisable(busy);
refreshButton.setDisable(busy);
if (busy) {
resetButton.setDisable(true);
deleteButton.setDisable(true);
} else if (!overviewTable.getSelectionModel().getSelectedItems().isEmpty()
&& !runningCheck.getAsBoolean()) {
resetButton.setDisable(false);
deleteButton.setDisable(false);
}
}
/**
* Lädt die Übersicht erneut, sofern keine DB-Busy-Sperre aktiv ist.
* <p>
* Wird vom Workspace nach erfolgreichem Datenbank-Wechsel aufgerufen, damit der
* Detailbereich und die Liste die neue (leere) Datenbank wiedergeben.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*/
public void reloadAfterDatabaseSwitch() {
// Selektion aufheben, damit der Detailbereich nicht mit Stammdaten
// aus der vorherigen Datenbank weiterzeigt.
overviewTable.getSelectionModel().clearSelection();
overviewItems.clear();
clearDetailPane();
loadOverview();
}
// =========================================================================
// UI-Aufbau
// =========================================================================
@@ -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;
}
}
@@ -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();
}
}
@@ -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());
}
};
}
}
@@ -22,6 +22,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileLoader;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationLoadException;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiCreateNewDatabasePort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiPromptEditorPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
@@ -46,6 +47,7 @@ import de.gecheckt.pdf.umbenenner.adapter.out.pathcheck.FilesystemPathCheckAdapt
import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.prompt.FilesystemPromptPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDatabaseCreationAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
@@ -59,6 +61,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfigurat
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
@@ -68,9 +71,11 @@ import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameUseCase;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.ResolveHistoricalDocumentContextUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
@@ -95,6 +100,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocument
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteHistoryQueryAdapter;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultCreateNewDatabaseUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase;
@@ -111,6 +117,7 @@ import de.gecheckt.pdf.umbenenner.bootstrap.adapter.AiModelCatalogDispatcher;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.GuiConfigurationPropertiesWriter;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jLogDiagnosticsAdapter;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.SqliteActiveDatabaseContextAdapter;
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.AnotherInstanceRunningException;
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.SingleInstanceGuard;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
@@ -207,6 +214,23 @@ public class BootstrapRunner {
private final GuiAdapterFactory guiAdapterFactory;
private final SingleInstanceGuardFactory singleInstanceGuardFactory;
/**
* Eigentümer der zur Laufzeit aktiven SQLite-Datenbankreferenz. Wird im
* GUI-Pfad genutzt, damit der Use-Case
* {@link DefaultCreateNewDatabaseUseCase} die aktive Datenbank umstellen kann,
* ohne die {@code .properties}-Datei sofort schreiben zu müssen.
* <p>
* Solange kein Override gesetzt ist, verhalten sich alle DB-Adapter wie
* bisher (Pfad aus der jeweils geladenen {@link StartConfiguration}).
*/
private final SqliteActiveDatabaseContextAdapter activeDatabaseContext = new SqliteActiveDatabaseContextAdapter();
/**
* Adapter zum Anlegen einer neuen, leeren SQLite-Datenbank. Zustandslos und
* threadsicher; wird im GUI-Pfad pro Anlage-Aufruf wiederverwendet.
*/
private final DatabaseCreationPort databaseCreationPort = new SqliteDatabaseCreationAdapter();
/**
* Functional interface encapsulating the legacy configuration migration step.
* <p>
@@ -416,7 +440,7 @@ public class BootstrapRunner {
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
String jdbcUrl = buildJdbcUrl(startConfig);
String jdbcUrl = resolveActiveJdbcUrl(startConfig);
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
@@ -823,6 +847,7 @@ public class BootstrapRunner {
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
GuiCreateNewDatabasePort createNewDatabasePort = this::createNewDatabaseForGui;
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui;
// Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
@@ -852,7 +877,8 @@ public class BootstrapRunner {
historyDetailsPort,
historyResetPort,
deleteHistoryPort,
this::buildGuiPromptEditorPort);
this::buildGuiPromptEditorPort,
createNewDatabasePort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -883,7 +909,8 @@ public class BootstrapRunner {
historyDetailsPort,
historyResetPort,
deleteHistoryPort,
this::buildGuiPromptEditorPort);
this::buildGuiPromptEditorPort,
createNewDatabasePort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -897,7 +924,7 @@ public class BootstrapRunner {
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
this::buildGuiPromptEditorPort);
this::buildGuiPromptEditorPort, createNewDatabasePort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -924,7 +951,8 @@ public class BootstrapRunner {
historyDetailsPort,
historyResetPort,
deleteHistoryPort,
this::buildGuiPromptEditorPort);
this::buildGuiPromptEditorPort,
createNewDatabasePort);
}
}
@@ -1171,7 +1199,7 @@ public class BootstrapRunner {
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
String jdbcUrl = buildJdbcUrl(config);
String jdbcUrl = resolveActiveJdbcUrl(config);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
ProcessingLogger resetLogger = new Log4jProcessingLogger(
DefaultResetDocumentStatusUseCase.class,
@@ -1229,7 +1257,7 @@ public class BootstrapRunner {
*/
private ManualFileRenameUseCase buildProductionManualFileRenameUseCase(
StartConfiguration startConfig) {
String jdbcUrl = buildJdbcUrl(startConfig);
String jdbcUrl = resolveActiveJdbcUrl(startConfig);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
@@ -1263,7 +1291,7 @@ public class BootstrapRunner {
*/
private ManualFileCopyUseCase buildProductionManualFileCopyUseCase(
StartConfiguration startConfig) {
String jdbcUrl = buildJdbcUrl(startConfig);
String jdbcUrl = resolveActiveJdbcUrl(startConfig);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
@@ -1442,7 +1470,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
String jdbcUrl = buildJdbcUrl(config);
String jdbcUrl = resolveActiveJdbcUrl(config);
DocumentRecordRepository documentRecordRepository =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
ResolveHistoricalDocumentContextUseCase useCase =
@@ -1475,7 +1503,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
String jdbcUrl = buildJdbcUrl(config);
String jdbcUrl = resolveActiveJdbcUrl(config);
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort);
return useCase.loadOverview(query);
@@ -1505,7 +1533,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
String jdbcUrl = buildJdbcUrl(config);
String jdbcUrl = resolveActiveJdbcUrl(config);
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort);
return useCase.loadDetails(fingerprint);
@@ -1536,7 +1564,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
String jdbcUrl = buildJdbcUrl(config);
String jdbcUrl = resolveActiveJdbcUrl(config);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
DefaultHistoryResetDocumentStatusUseCase useCase =
new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort);
@@ -1568,7 +1596,7 @@ public class BootstrapRunner {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
initializeSchema(config);
String jdbcUrl = buildJdbcUrl(config);
String jdbcUrl = resolveActiveJdbcUrl(config);
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
DefaultDeleteDocumentHistoryUseCase useCase =
new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort);
@@ -1582,6 +1610,53 @@ public class BootstrapRunner {
}
}
/**
* Bridge-Methode zum Anlegen einer neuen, leeren SQLite-Datenbank für die GUI.
* <p>
* Verdrahtet pro Aufruf einen frischen {@link DefaultCreateNewDatabaseUseCase} mit dem
* gemeinsam gehaltenen {@link #databaseCreationPort} und dem
* {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven
* DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den
* {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück.
* <p>
* Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt
* Anlage, Migration und Verbindungstest aus.
*
* @param configFilePath der aktuell in der GUI bekannte Konfigurationspfad oder
* {@code null}, wenn noch keine Konfiguration geladen wurde
* @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht {@code null} sein
* @return strukturiertes Ergebnis des Use-Cases; nie {@code null}
*/
CreateNewDatabaseUseCase.CreateNewDatabaseResult createNewDatabaseForGui(
Path configFilePath, Path targetFile) {
Objects.requireNonNull(targetFile, "targetFile must not be null");
LOG.info("GUI-Datenbankanlage: angeforderter Zielpfad = {}", targetFile);
DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier activeDbSupplier = () -> {
Optional<Path> override = activeDatabaseContext.activeDatabaseOverride();
if (override.isPresent()) {
return override.get();
}
if (configFilePath == null || !Files.exists(configFilePath)) {
// Ohne Konfiguration und ohne Override: Zielpfad selbst als „aktiv" werten,
// damit die Pfadprüfung nur das Selbst-Überschreiben verhindert.
return targetFile.toAbsolutePath().normalize();
}
try {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
return config.sqliteFile().toAbsolutePath().normalize();
} catch (Exception e) {
LOG.warn("GUI-Datenbankanlage: aktive DB konnte nicht aus {} ermittelt werden: {} — "
+ "Zielpfad wird stattdessen als aktiv angenommen.",
configFilePath, e.getMessage());
return targetFile.toAbsolutePath().normalize();
}
};
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
databaseCreationPort, activeDatabaseContext, activeDbSupplier);
return useCase.createNewDatabase(targetFile);
}
/**
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
* recorded as a failure with the given error message.
@@ -1724,7 +1799,7 @@ public class BootstrapRunner {
* @param config the validated startup configuration containing the SQLite file path
*/
private void initializeSchema(StartConfiguration config) {
schemaInitPortFactory.create(buildJdbcUrl(config)).initializeSchema();
schemaInitPortFactory.create(resolveActiveJdbcUrl(config)).initializeSchema();
}
/**
@@ -1870,11 +1945,48 @@ public class BootstrapRunner {
/**
* Builds the JDBC URL for the SQLite database from the configured file path.
* <p>
* Diese Variante berücksichtigt {@em kein} Override sie wird ausschließlich für
* den Headless-Pfad und für GUI-Tests aufgerufen, in denen kein
* {@link ActiveDatabaseContextPort} verfügbar ist.
*
* @param config the startup configuration containing the SQLite file path
* @return the JDBC URL in the form {@code jdbc:sqlite:/path/to/file.db}
*/
static String buildJdbcUrl(StartConfiguration config) {
return "jdbc:sqlite:" + config.sqliteFile().toAbsolutePath().toString().replace('\\', '/');
return buildSqliteJdbcUrl(config.sqliteFile());
}
/**
* Liefert die JDBC-URL der zur Laufzeit aktiven SQLite-Datenbank.
* <p>
* Hat der {@link ActiveDatabaseContextPort} einen Override gesetzt (z. B. nachdem
* der Benutzer im GUI eine neue Datenbank angelegt hat), wird dieser verwendet.
* Andernfalls greift der Wert aus der übergebenen {@link StartConfiguration}.
*
* @param config die geladene Startup-Konfiguration; nie {@code null}
* @return die JDBC-URL der aktiven SQLite-Datei
*/
String resolveActiveJdbcUrl(StartConfiguration config) {
Optional<Path> override = activeDatabaseContext.activeDatabaseOverride();
if (override.isPresent()) {
return buildSqliteJdbcUrl(override.get());
}
return buildSqliteJdbcUrl(config.sqliteFile());
}
/**
* Liefert den Pfad der zur Laufzeit aktiven SQLite-Datei.
*
* @param config die geladene Startup-Konfiguration; nie {@code null}
* @return der absolute Pfad der aktuell aktiven Datenbankdatei
*/
Path resolveActiveDatabasePath(StartConfiguration config) {
return activeDatabaseContext.activeDatabaseOverride()
.orElseGet(() -> config.sqliteFile().toAbsolutePath().normalize());
}
private static String buildSqliteJdbcUrl(Path sqliteFile) {
return "jdbc:sqlite:" + sqliteFile.toAbsolutePath().toString().replace('\\', '/');
}
}
@@ -0,0 +1,92 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
/**
* Bootstrap-interne Implementierung des {@link ActiveDatabaseContextPort}.
* <p>
* Hält einen prozessweiten, threadsicheren Override-Pfad, der in der Bootstrap-Schicht
* Vorrang vor dem in der {@code .properties}-Datei konfigurierten SQLite-Pfad hat.
* Solange kein Override gesetzt ist, verhält sich die Anwendung wie bisher: alle
* SQLite-Adapter werden mit der aus der jeweils geladenen Konfigurationsdatei
* abgeleiteten JDBC-URL verdrahtet.
* <p>
* Setzt der Use-Case
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultCreateNewDatabaseUseCase}
* den Override über {@link #switchActiveDatabase(Path)}, verwenden alle nachfolgenden
* GUI-DB-Operationen (History, Reset, Löschen, Verarbeitungsläufe) die hinterlegte
* Datei auch wenn der Benutzer die Konfigurationsdatei noch nicht gespeichert hat.
*
* <h2>Lebensdauer</h2>
* <p>Die gehaltene Referenz lebt für die Dauer des laufenden GUI-Prozesses. Sie ist
* nicht persistent: nach einem Neustart greift wieder der Wert aus der
* Konfigurationsdatei.
*
* <h2>Threading</h2>
* <p>Alle Lese- und Schreibzugriffe gehen über eine {@link AtomicReference}; die Klasse
* ist daher uneingeschränkt threadsicher.
*/
public class SqliteActiveDatabaseContextAdapter implements ActiveDatabaseContextPort {
private static final Logger LOG = LogManager.getLogger(SqliteActiveDatabaseContextAdapter.class);
private final AtomicReference<Path> override = new AtomicReference<>();
/**
* Standardkonstruktor.
*/
public SqliteActiveDatabaseContextAdapter() {
// kein Zustand außer der AtomicReference
}
/**
* Setzt den Override-Pfad auf die übergebene Datei. Nachfolgende DB-Operationen
* verwenden diese Datei, sofern sie {@link #activeDatabaseOverride()} abfragen,
* statt direkt die Konfigurationsdatei zu konsultieren.
*
* @param newDbFile absoluter Pfad der neuen aktiven Datenbankdatei; darf nicht
* {@code null} sein
* @throws NullPointerException wenn {@code newDbFile} {@code null} ist
*/
@Override
public void switchActiveDatabase(Path newDbFile) {
Objects.requireNonNull(newDbFile, "newDbFile darf nicht null sein");
Path absolute = newDbFile.toAbsolutePath().normalize();
Path previous = override.getAndSet(absolute);
if (previous == null) {
LOG.info("Aktive SQLite-Datenbank wurde umgestellt auf: {}", absolute);
} else {
LOG.info("Aktive SQLite-Datenbank wurde von {} auf {} umgestellt.",
previous, absolute);
}
}
/**
* Liefert den aktuell gesetzten Override, falls vorhanden.
*
* @return der Override-Pfad oder {@link Optional#empty()}
*/
@Override
public Optional<Path> activeDatabaseOverride() {
return Optional.ofNullable(override.get());
}
/**
* Entfernt den Override (für Tests oder kontrolliertes Zurücksetzen). Nach dem
* Aufruf greift wieder der Wert aus der konfigurierten {@code .properties}-Datei.
*/
public void clearOverride() {
Path previous = override.getAndSet(null);
if (previous != null) {
LOG.info("Aktiver SQLite-Datenbank-Override wurde entfernt (zuletzt: {}).", previous);
}
}
}
@@ -0,0 +1,65 @@
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.Test;
/**
* Tests für {@link SqliteActiveDatabaseContextAdapter}.
* <p>
* Prüft das Setzen, Auslesen und Zurücksetzen des Override-Pfads sowie die Pflicht-Prüfung
* gegen {@code null}-Argumente.
*/
class SqliteActiveDatabaseContextAdapterTest {
@Test
void activeDatabaseOverride_returnsEmpty_initially() {
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
assertThat(adapter.activeDatabaseOverride()).isEmpty();
}
@Test
void switchActiveDatabase_setsAbsoluteOverride() {
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
Path target = Path.of("data/foo.sqlite");
adapter.switchActiveDatabase(target);
Optional<Path> override = adapter.activeDatabaseOverride();
assertThat(override).isPresent();
assertThat(override.get()).isEqualTo(target.toAbsolutePath().normalize());
}
@Test
void switchActiveDatabase_replacesPreviousOverride() {
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
adapter.switchActiveDatabase(Path.of("first.sqlite"));
adapter.switchActiveDatabase(Path.of("second.sqlite"));
assertThat(adapter.activeDatabaseOverride())
.isPresent()
.get()
.isEqualTo(Path.of("second.sqlite").toAbsolutePath().normalize());
}
@Test
void clearOverride_removesPreviouslySetOverride() {
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
adapter.switchActiveDatabase(Path.of("foo.sqlite"));
adapter.clearOverride();
assertThat(adapter.activeDatabaseOverride()).isEmpty();
}
@Test
void switchActiveDatabase_rejectsNull() {
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
assertThatThrownBy(() -> adapter.switchActiveDatabase(null))
.isInstanceOf(NullPointerException.class);
}
}