From 3876e647b29775b20fa673711ed9d44dcd713001 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 5 May 2026 16:52:54 +0200 Subject: [PATCH] Lege neue leere SQLite-Datenbank atomar via Use-Case und GUI an MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../gui/GuiConfigurationEditorWorkspace.java | 241 ++++++++++++++ .../in/gui/GuiCreateNewDatabasePort.java | 41 +++ .../adapter/in/gui/GuiStartupContext.java | 34 +- .../in/gui/PdfUmbenennerGuiApplication.java | 32 ++ .../adapter/in/gui/history/GuiHistoryTab.java | 52 +++ .../sqlite/SqliteDatabaseCreationAdapter.java | 199 ++++++++++++ .../SqliteDatabaseCreationAdapterTest.java | 97 ++++++ .../port/in/CreateNewDatabaseUseCase.java | 142 ++++++++ .../port/out/ActiveDatabaseContextPort.java | 49 +++ .../port/out/DatabaseCreationPort.java | 110 +++++++ .../DefaultCreateNewDatabaseUseCase.java | 307 ++++++++++++++++++ .../DefaultCreateNewDatabaseUseCaseTest.java | 210 ++++++++++++ .../umbenenner/bootstrap/BootstrapRunner.java | 142 +++++++- .../SqliteActiveDatabaseContextAdapter.java | 92 ++++++ ...qliteActiveDatabaseContextAdapterTest.java | 65 ++++ 15 files changed, 1793 insertions(+), 20 deletions(-) create mode 100644 pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCreateNewDatabasePort.java create mode 100644 pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapter.java create mode 100644 pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapterTest.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/CreateNewDatabaseUseCase.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ActiveDatabaseContextPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/DatabaseCreationPort.java create mode 100644 pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCase.java create mode 100644 pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCaseTest.java create mode 100644 pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapter.java create mode 100644 pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapterTest.java diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 728e7b0..1667ade 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -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()}). + *

+ * 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…". + *

+ * Ö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. + *

+ * 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. + *

+ * 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 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. + *

+ * 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. diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCreateNewDatabasePort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCreateNewDatabasePort.java new file mode 100644 index 0000000..878e6a8 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiCreateNewDatabasePort.java @@ -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}. + *

+ * Dieses Interface ist kein 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. + *

+ * Threading: 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); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 85c6dfa..6f0f08f 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -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() { diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java index d0b79ac..effa84b 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/PdfUmbenennerGuiApplication.java @@ -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. + *

+ * Aktuell enthält sie genau einen Eintrag: das Menü „Datenbank" mit der Aktion + * „Neue Datenbank anlegen…". Diese delegiert an + * {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}. + *

+ * 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. diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java index b741a9c..0c7f03a 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java @@ -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. + *

+ * 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. + *

+ * 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. + *

+ * Wird vom Workspace nach erfolgreichem Datenbank-Wechsel aufgerufen, damit der + * Detailbereich und die Liste die neue (leere) Datenbank wiedergeben. + *

+ * 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 // ========================================================================= diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapter.java new file mode 100644 index 0000000..e3f5ef5 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapter.java @@ -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}. + *

+ * 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: + *

    + *
  1. Eine SQLite-Verbindung kann erfolgreich geöffnet werden.
  2. + *
  3. Die Flyway-History-Tabelle (Standardname {@code flyway_schema_history}) ist + * vorhanden und enthält mindestens einen erfolgreichen Migrationseintrag.
  4. + *
  5. Eine einfache Leseabfrage gegen Schema-Metadaten + * ({@code sqlite_master}) liefert ohne Fehler.
  6. + *
+ *

+ * Im Fehlerfall wird die temporäre Datei zuverlässig wieder entfernt; aufrufende + * Komponenten erhalten ein klassifiziertes + * {@link DatabaseCreationPort.DatabaseCreationResult.Failure}-Ergebnis. + * + *

Architekturgrenze

+ *

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; + } +} diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapterTest.java new file mode 100644 index 0000000..c4f6c2e --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDatabaseCreationAdapterTest.java @@ -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}. + *

+ * 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(); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/CreateNewDatabaseUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/CreateNewDatabaseUseCase.java new file mode 100644 index 0000000..3d40882 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/in/CreateNewDatabaseUseCase.java @@ -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. + *

+ * Der Use-Case orchestriert den vollständigen, aus Anwendungssicht atomaren Ablauf: + *

    + *
  1. Pfad-Sicherheitsprüfung: aktive DB darf nicht überschrieben werden;
  2. + *
  3. Erzeugung einer temporären SQLite-Datei im Zielverzeichnis;
  4. + *
  5. vollständige Schema-Migration auf den neuesten Stand;
  6. + *
  7. Verbindungstest gegen die migrierte Temp-Datei;
  8. + *
  9. atomarer Move auf den endgültigen Zielpfad + * ({@link java.nio.file.StandardCopyOption#ATOMIC_MOVE}, + * {@link java.nio.file.StandardCopyOption#REPLACE_EXISTING});
  10. + *
  11. Umstellung der aktiven DB-Referenz über den + * {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}.
  12. + *
+ *

+ * 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 + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ActiveDatabaseContextPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ActiveDatabaseContextPort.java new file mode 100644 index 0000000..a5f2669 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ActiveDatabaseContextPort.java @@ -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. + *

+ * 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. + *

+ * Architekturgrenze: 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. + *

+ * 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. + *

+ * 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 activeDatabaseOverride(); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/DatabaseCreationPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/DatabaseCreationPort.java new file mode 100644 index 0000000..deae7a9 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/DatabaseCreationPort.java @@ -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. + *

+ * 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: + *

    + *
  1. Anlage und Migration der temporären SQLite-Datei auf den neuesten Schema-Stand + * (z. B. via Flyway {@code migrate()});
  2. + *
  3. technischer Verbindungstest gegen die migrierte Datei (Verbindung öffnen, + * Flyway-History prüfen, einfache Leseabfrage gegen Schema-Metadaten);
  4. + *
  5. Aufräumen der temporären Datei im Fehlerfall.
  6. + *
+ *

+ * Architekturgrenze: 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. + *

+ * 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 + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCase.java new file mode 100644 index 0000000..2ed78de --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCase.java @@ -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}. + *

+ * 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. + * + *

Atomarität

+ * Aus Anwendungssicht ist der Wechsel atomar: + * + * + *

Pfad-Sicherheitsprüfung

+ * 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. + *

+ * 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. + *

+ * 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 currentOverride() { + return activeDatabaseContextPort.activeDatabaseOverride(); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCaseTest.java new file mode 100644 index 0000000..bbdd1d0 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultCreateNewDatabaseUseCaseTest.java @@ -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}. + *

+ * 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 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 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 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 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 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 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 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 sink) { + return new ActiveDatabaseContextPort() { + @Override + public void switchActiveDatabase(Path newDbFile) { + sink.set(newDbFile); + } + @Override + public Optional activeDatabaseOverride() { + return Optional.ofNullable(sink.get()); + } + }; + } +} diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 7f275df..d1369ae 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -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. + *

+ * 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. *

@@ -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. + *

+ * 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. + *

+ * 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 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. + *

+ * 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. + *

+ * 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 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('\\', '/'); } } diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapter.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapter.java new file mode 100644 index 0000000..5e655c6 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapter.java @@ -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}. + *

+ * 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. + *

+ * 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. + * + *

Lebensdauer

+ *

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. + * + *

Threading

+ *

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 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 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); + } + } +} diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapterTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapterTest.java new file mode 100644 index 0000000..b793e88 --- /dev/null +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/adapter/SqliteActiveDatabaseContextAdapterTest.java @@ -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}. + *

+ * 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 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); + } +}