Lege neue leere SQLite-Datenbank atomar via Use-Case und GUI an
Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen…" mit FileChooser, normalisierter Pfadprüfung gegen die aktive DB, gesammelter Überschreib- Bestätigung, DB-Busy-Sperre auf Verlauf-Tab, Flyway-Migration auf den neuesten Stand gegen eine Temp-Datei, Verbindungstest, atomarem Move (ATOMIC_MOVE + REPLACE_EXISTING) und Umstellen der aktiven DB-Referenz über einen neuen ActiveDatabaseContextPort. Konfig-Tab wechselt nach Wechsel automatisch in den Dirty-State; Hinweismeldung mit Speichern- Aufforderung wird im zentralen Meldungsbereich angezeigt. Architektur entspricht Fall B aus der Spezifikation: Bootstrap hält den Override prozessweit und verwendet ihn in resolveActiveJdbcUrl statt des Werts aus der .properties-Datei. Bei Fehlern wird die Temp-Datei zuverlässig entfernt; die aktive DB bleibt unverändert in Betrieb. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+241
@@ -57,6 +57,7 @@ import javafx.scene.Node;
|
|||||||
import javafx.scene.Parent;
|
import javafx.scene.Parent;
|
||||||
import javafx.scene.control.Alert;
|
import javafx.scene.control.Alert;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonBar;
|
||||||
import javafx.scene.control.ButtonType;
|
import javafx.scene.control.ButtonType;
|
||||||
import javafx.scene.control.CheckBox;
|
import javafx.scene.control.CheckBox;
|
||||||
import javafx.scene.control.ComboBox;
|
import javafx.scene.control.ComboBox;
|
||||||
@@ -422,6 +423,33 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private final GuiPromptEditorPortFactory promptEditorPortFactory;
|
private final GuiPromptEditorPortFactory promptEditorPortFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge zur DB-Anlage- und Wechsellogik. Wird vom Menüpunkt
|
||||||
|
* „Datenbank → Neue Datenbank anlegen…" ausgelöst.
|
||||||
|
*/
|
||||||
|
private final GuiCreateNewDatabasePort createNewDatabasePort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiver DB-Busy-Zustand während einer laufenden Datenbank-Anlage. Solange
|
||||||
|
* dieser Zustand aktiv ist, sind alle DB-lesenden und DB-schreibenden Aktionen
|
||||||
|
* der GUI gesperrt (vgl. {@link #applyDbBusyLock()}).
|
||||||
|
* <p>
|
||||||
|
* Als JavaFX-Property realisiert, damit die Menüleiste den Zustand direkt
|
||||||
|
* über {@code disableProperty().bind(...)} auswerten kann.
|
||||||
|
*/
|
||||||
|
private final javafx.beans.property.SimpleBooleanProperty dbBusyForDatabaseCreation =
|
||||||
|
new javafx.beans.property.SimpleBooleanProperty(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hintergrund-Worker-Thread für die DB-Anlage; einzel-threadig, damit nicht
|
||||||
|
* mehrere DB-Anlagen gleichzeitig laufen können.
|
||||||
|
*/
|
||||||
|
private final ExecutorService createNewDatabaseExecutor = Executors.newSingleThreadExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "gui-create-new-database");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Second main tab of the window that drives the live processing-run view. Created
|
* 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
|
* during workspace construction and wired into the shared {@link #tabPane} alongside
|
||||||
@@ -513,6 +541,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
||||||
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
||||||
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
|
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
|
||||||
|
this.createNewDatabasePort = effectiveContext.createNewDatabasePort();
|
||||||
this.batchRunTab = new GuiBatchRunTab(
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
() -> this.batchRunLauncher,
|
() -> this.batchRunLauncher,
|
||||||
() -> this.miniRunLauncher,
|
() -> this.miniRunLauncher,
|
||||||
@@ -1018,6 +1047,218 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
checkExistsAndSave(targetPath, () -> { });
|
checkExistsAndSave(targetPath, () -> { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true}, wenn aktuell gerade eine Datenbank-Anlage läuft und der
|
||||||
|
* Menüpunkt „Datenbank → Neue Datenbank anlegen…" daher gesperrt ist.
|
||||||
|
*
|
||||||
|
* @return aktueller DB-Busy-Zustand
|
||||||
|
*/
|
||||||
|
public boolean isDbBusyForDatabaseCreation() {
|
||||||
|
return dbBusyForDatabaseCreation.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty} für den
|
||||||
|
* DB-Busy-Zustand. Wird von der Menüleiste genutzt, um den Menüpunkt
|
||||||
|
* „Neue Datenbank anlegen…" während einer laufenden Anlage automatisch zu
|
||||||
|
* deaktivieren.
|
||||||
|
*
|
||||||
|
* @return read-only Property; nie {@code null}
|
||||||
|
*/
|
||||||
|
public javafx.beans.property.BooleanProperty dbBusyForDatabaseCreationProperty() {
|
||||||
|
return dbBusyForDatabaseCreation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty}, die den
|
||||||
|
* Lauf-aktiv-Zustand des Verarbeitungslauf-Tabs spiegelt. Wird von der
|
||||||
|
* Menüleiste genutzt, um den Menüpunkt „Neue Datenbank anlegen…" während
|
||||||
|
* eines laufenden Verarbeitungslaufs zu deaktivieren.
|
||||||
|
*
|
||||||
|
* @return read-only Property; nie {@code null}
|
||||||
|
*/
|
||||||
|
public javafx.beans.property.ReadOnlyBooleanProperty batchRunRunningProperty() {
|
||||||
|
return batchRunTab.runningProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behandelt die Aktion „Datenbank → Neue Datenbank anlegen…".
|
||||||
|
* <p>
|
||||||
|
* Öffnet einen FileChooser (Filter {@code *.sqlite}), prüft den Zielpfad
|
||||||
|
* gegen die aktive Datenbank, holt ggf. eine Überschreib-Bestätigung ein und
|
||||||
|
* delegiert die eigentliche Anlage an
|
||||||
|
* {@link GuiCreateNewDatabasePort#createNewDatabase(Path)} auf einem
|
||||||
|
* Hintergrund-Worker-Thread.
|
||||||
|
* <p>
|
||||||
|
* Während der Ausführung ist die GUI in einem DB-Busy-Zustand: alle
|
||||||
|
* DB-lesenden und DB-schreibenden Aktionen sind deaktiviert. Der Zustand
|
||||||
|
* wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void requestCreateNewDatabase() {
|
||||||
|
if (dbBusyForDatabaseCreation.get()) {
|
||||||
|
LOG.debug("GUI-Editor: Anlage einer neuen Datenbank ist bereits in Arbeit – Klick ignoriert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (batchRunTab != null && batchRunTab.isRunning()) {
|
||||||
|
showError("Während eines Verarbeitungslaufs kann keine neue Datenbank angelegt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
|
||||||
|
FileChooser fileChooser = saveFileChooserFactory.get();
|
||||||
|
fileChooser.setTitle("Neue Datenbank anlegen");
|
||||||
|
fileChooser.getExtensionFilters().add(
|
||||||
|
new FileChooser.ExtensionFilter("SQLite-Datenbanken", "*.sqlite"));
|
||||||
|
fileChooser.setInitialFileName("pdf-umbenenner.sqlite");
|
||||||
|
|
||||||
|
// Vorschlagsverzeichnis: SQLite-Pfad aus der aktuellen Konfiguration, sofern gesetzt
|
||||||
|
String currentSqlite = editorState.values().sqliteFile();
|
||||||
|
if (currentSqlite != null && !currentSqlite.isBlank()) {
|
||||||
|
try {
|
||||||
|
Path proposedDir = Path.of(currentSqlite).toAbsolutePath().getParent();
|
||||||
|
if (proposedDir != null && proposedDir.toFile().isDirectory()) {
|
||||||
|
fileChooser.setInitialDirectory(proposedDir.toFile());
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// bei ungültigem Pfad: kein Vorschlagsverzeichnis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File selectedFile;
|
||||||
|
try {
|
||||||
|
selectedFile = saveDialogFunction.apply(fileChooser, owner);
|
||||||
|
} catch (UnsupportedOperationException e) {
|
||||||
|
LOG.debug("GUI-Editor: Datenbank-Speichern-Dialog nicht verfügbar (headless).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedFile == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path requestedTarget = selectedFile.toPath().toAbsolutePath().normalize();
|
||||||
|
|
||||||
|
// Bestätigungsdialog wenn Datei bereits existiert (egal ob fremd oder aktive DB —
|
||||||
|
// die endgültige Sicherheitsprüfung gegen die aktive DB übernimmt der Use-Case).
|
||||||
|
if (java.nio.file.Files.exists(requestedTarget)) {
|
||||||
|
ButtonType ueberschreiben = new ButtonType("Überschreiben", ButtonBar.ButtonData.OK_DONE);
|
||||||
|
ButtonType abbrechen = new ButtonType("Abbrechen", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||||
|
Optional<ButtonType> choice = showConfirmation(
|
||||||
|
"Datei überschreiben?",
|
||||||
|
"Die Datei existiert bereits:\n" + requestedTarget
|
||||||
|
+ "\n\nDie vorhandene Datei wird durch eine neue, leere SQLite-Datenbank ersetzt.\nFortfahren?",
|
||||||
|
abbrechen,
|
||||||
|
ueberschreiben);
|
||||||
|
if (choice.isEmpty() || !choice.get().equals(ueberschreiben)) {
|
||||||
|
LOG.info("GUI-Editor: Anlage einer neuen Datenbank vom Benutzer abgebrochen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreateNewDatabaseWorker(requestedTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiviert die DB-Busy-Sperre auf der Oberfläche und reicht den eigentlichen
|
||||||
|
* Aufruf des {@link GuiCreateNewDatabasePort} an einen Daemon-Worker-Thread weiter.
|
||||||
|
*
|
||||||
|
* @param targetFile der bereits geprüfte Zielpfad; nie {@code null}
|
||||||
|
*/
|
||||||
|
private void startCreateNewDatabaseWorker(Path targetFile) {
|
||||||
|
dbBusyForDatabaseCreation.set(true);
|
||||||
|
applyDbBusyLock();
|
||||||
|
showStatusMessage("Neue SQLite-Datenbank wird angelegt …");
|
||||||
|
|
||||||
|
createNewDatabaseExecutor.submit(() -> {
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
|
||||||
|
.CreateNewDatabaseResult result;
|
||||||
|
try {
|
||||||
|
result = createNewDatabasePort.createNewDatabase(loadedConfigurationPath(), targetFile);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Editor: Unerwarteter Fehler beim Anlegen der neuen Datenbank: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
dbBusyForDatabaseCreation.set(false);
|
||||||
|
applyDbBusyLock();
|
||||||
|
showError("Neue Datenbank konnte nicht angelegt werden: " + safeMessage(e));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Platform.runLater(() -> handleCreateNewDatabaseResult(targetFile, result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzt das Ergebnis des Use-Cases in die UI-Reaktion: Dirty-State,
|
||||||
|
* Statuszeile, Verlauf-Reload, Hinweismeldung oder Fehlerdialog.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer gewählte Zielpfad
|
||||||
|
* @param result das Ergebnis des Use-Cases
|
||||||
|
*/
|
||||||
|
private void handleCreateNewDatabaseResult(
|
||||||
|
Path targetFile,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
|
||||||
|
.CreateNewDatabaseResult result) {
|
||||||
|
try {
|
||||||
|
switch (result) {
|
||||||
|
case de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Success success -> {
|
||||||
|
Path effectiveTarget = success.targetFile();
|
||||||
|
LOG.info("GUI-Editor: Neue Datenbank ist aktiv: {}", effectiveTarget);
|
||||||
|
|
||||||
|
// Konfigurationsmodell aktualisieren → Dirty-State
|
||||||
|
GuiConfigurationValues updated = editorState.values()
|
||||||
|
.withSqliteFile(effectiveTarget.toString());
|
||||||
|
updateValues(updated);
|
||||||
|
|
||||||
|
// Verlauf-Tab neu laden, damit die neue (leere) DB sichtbar wird
|
||||||
|
if (historyTab != null) {
|
||||||
|
historyTab.reloadAfterDatabaseSwitch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinweismeldung im zentralen Meldungsbereich
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.INFO,
|
||||||
|
"Neue Datenbank ist aktiv. Konfiguration speichern, damit "
|
||||||
|
+ "diese DB beim nächsten Start verwendet wird.",
|
||||||
|
"Datenbank-Anlage"));
|
||||||
|
refreshAfterValidation();
|
||||||
|
showStatusMessage("Neue Datenbank ist aktiv: " + effectiveTarget);
|
||||||
|
}
|
||||||
|
case de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.SameAsActiveDatabase same -> {
|
||||||
|
LOG.warn("GUI-Editor: Anlage abgelehnt – Zielpfad ist die aktuell aktive DB: {}",
|
||||||
|
same.targetFile());
|
||||||
|
showError("Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei. "
|
||||||
|
+ "Bitte einen anderen Pfad wählen.");
|
||||||
|
}
|
||||||
|
case de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed failure -> {
|
||||||
|
LOG.error("GUI-Editor: Anlage fehlgeschlagen ({}): {}",
|
||||||
|
failure.phase(), failure.message());
|
||||||
|
showError("Neue Datenbank konnte nicht angelegt werden: " + failure.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dbBusyForDatabaseCreation.set(false);
|
||||||
|
applyDbBusyLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet die aktuelle DB-Busy-Sperre auf die betroffenen UI-Komponenten an.
|
||||||
|
* <p>
|
||||||
|
* Während der Sperre sind die DB-lesenden und DB-schreibenden Aktionen des
|
||||||
|
* Verlauf-Tabs deaktiviert. Andere DB-Operationen laufen pro Aufruf frisch in
|
||||||
|
* Bootstrap und greifen automatisch den DB-Override des
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort} ab.
|
||||||
|
*/
|
||||||
|
private void applyDbBusyLock() {
|
||||||
|
if (historyTab != null) {
|
||||||
|
historyTab.setDbBusy(dbBusyForDatabaseCreation.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks on a background worker thread whether the target path already exists and, if so,
|
* 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.
|
* asks the user to confirm overwriting on the FX Application Thread before writing.
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Workspace und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die DB-Anlage- und Wechsellogik
|
||||||
|
* für die GUI bereitstellt, ohne dass der GUI-Adapter direkt auf den Use-Case oder die
|
||||||
|
* darunterliegenden Outbound-Ports zugreift.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen dürfen blockierende Operationen
|
||||||
|
* ausführen (Flyway-Migration, Verbindungstest, atomares Verschieben einer Datei).
|
||||||
|
* Sie müssen daher von einem Hintergrund-Worker-Thread aufgerufen werden. Der Aufruf
|
||||||
|
* blockiert, bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiCreateNewDatabasePort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und
|
||||||
|
* stellt die aktive Datenbankreferenz auf diese Datei um.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei,
|
||||||
|
* oder {@code null}, wenn (noch) keine Konfiguration
|
||||||
|
* geladen ist. Die Bootstrap-Implementierung leitet
|
||||||
|
* daraus den Pfad der aktuell aktiven SQLite-Datei ab,
|
||||||
|
* sofern noch kein Override vom
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}
|
||||||
|
* gesetzt ist.
|
||||||
|
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler;
|
||||||
|
* nie {@code null}
|
||||||
|
*/
|
||||||
|
CreateNewDatabaseResult createNewDatabase(Path configFilePath, Path targetFile);
|
||||||
|
}
|
||||||
+29
-5
@@ -77,7 +77,8 @@ public record GuiStartupContext(
|
|||||||
GuiHistoryDetailsPort historyDetailsPort,
|
GuiHistoryDetailsPort historyDetailsPort,
|
||||||
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||||
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
||||||
GuiPromptEditorPortFactory promptEditorPortFactory) {
|
GuiPromptEditorPortFactory promptEditorPortFactory,
|
||||||
|
GuiCreateNewDatabasePort createNewDatabasePort) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fully wired startup context.
|
* Creates a fully wired startup context.
|
||||||
@@ -155,6 +156,8 @@ public record GuiStartupContext(
|
|||||||
"deleteDocumentHistoryPort must not be null");
|
"deleteDocumentHistoryPort must not be null");
|
||||||
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
|
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
|
||||||
"promptEditorPortFactory must not be null");
|
"promptEditorPortFactory must not be null");
|
||||||
|
createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
|
||||||
|
"createNewDatabasePort must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,7 +201,8 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,7 +240,8 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -274,7 +279,8 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -396,7 +402,25 @@ public record GuiStartupContext(
|
|||||||
noOpHistoryDetailsPort(),
|
noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(),
|
noOpHistoryResetPort(),
|
||||||
noOpDeleteHistoryPort(),
|
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() {
|
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
|
||||||
|
|||||||
+32
@@ -7,6 +7,9 @@ import javafx.application.Application;
|
|||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.event.EventHandler;
|
import javafx.event.EventHandler;
|
||||||
import javafx.scene.Scene;
|
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.image.Image;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
@@ -74,8 +77,12 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion());
|
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion());
|
||||||
workspace.statusBarStateListener = statusBar::applyEditorState;
|
workspace.statusBarStateListener = statusBar::applyEditorState;
|
||||||
|
|
||||||
|
// Menüleiste mit Datenbank-Menü („Neue Datenbank anlegen…")
|
||||||
|
MenuBar menuBar = buildMenuBar(workspace);
|
||||||
|
|
||||||
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
||||||
BorderPane outerLayout = new BorderPane();
|
BorderPane outerLayout = new BorderPane();
|
||||||
|
outerLayout.setTop(menuBar);
|
||||||
outerLayout.setCenter(workspace.root());
|
outerLayout.setCenter(workspace.root());
|
||||||
outerLayout.setBottom(statusBar.root());
|
outerLayout.setBottom(statusBar.root());
|
||||||
|
|
||||||
@@ -115,6 +122,31 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Menüleiste für das Hauptfenster auf.
|
||||||
|
* <p>
|
||||||
|
* Aktuell enthält sie genau einen Eintrag: das Menü „Datenbank" mit der Aktion
|
||||||
|
* „Neue Datenbank anlegen…". Diese delegiert an
|
||||||
|
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
|
||||||
|
* <p>
|
||||||
|
* Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
|
||||||
|
* bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
|
||||||
|
* der Workspace die DB-Busy-Sperre wieder aufhebt.
|
||||||
|
*
|
||||||
|
* @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
|
||||||
|
* @return die fertig konfigurierte Menüleiste
|
||||||
|
*/
|
||||||
|
private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
|
||||||
|
Menu databaseMenu = new Menu("Datenbank");
|
||||||
|
MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
|
||||||
|
createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
|
||||||
|
// Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
|
||||||
|
createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
|
||||||
|
.or(workspace.dbBusyForDatabaseCreationProperty()));
|
||||||
|
databaseMenu.getItems().add(createNewItem);
|
||||||
|
return new MenuBar(databaseMenu);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
|
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
|
||||||
* System-Tray minimiert statt es zu schließen.
|
* System-Tray minimiert statt es zu schließen.
|
||||||
|
|||||||
+52
@@ -150,6 +150,15 @@ public final class GuiHistoryTab {
|
|||||||
*/
|
*/
|
||||||
private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(300));
|
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.
|
* Erzeugt den Historien-Tab.
|
||||||
*
|
*
|
||||||
@@ -217,6 +226,49 @@ public final class GuiHistoryTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schaltet die DB-Busy-Sperre des Verlauf-Tabs an oder aus.
|
||||||
|
* <p>
|
||||||
|
* Während der Sperre sind Suche, Statusfilter, Aktualisieren, Status-Reset und
|
||||||
|
* Eintrag-Löschen deaktiviert. Wird typischerweise vom Workspace aufgerufen,
|
||||||
|
* solange eine neue SQLite-Datenbank angelegt und aktiviert wird.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param busy {@code true} aktiviert die Sperre, {@code false} hebt sie wieder auf
|
||||||
|
*/
|
||||||
|
public void setDbBusy(boolean busy) {
|
||||||
|
this.dbBusy = busy;
|
||||||
|
searchField.setDisable(busy);
|
||||||
|
statusFilterBox.setDisable(busy);
|
||||||
|
refreshButton.setDisable(busy);
|
||||||
|
if (busy) {
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
} else if (!overviewTable.getSelectionModel().getSelectedItems().isEmpty()
|
||||||
|
&& !runningCheck.getAsBoolean()) {
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
deleteButton.setDisable(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Übersicht erneut, sofern keine DB-Busy-Sperre aktiv ist.
|
||||||
|
* <p>
|
||||||
|
* Wird vom Workspace nach erfolgreichem Datenbank-Wechsel aufgerufen, damit der
|
||||||
|
* Detailbereich und die Liste die neue (leere) Datenbank wiedergeben.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void reloadAfterDatabaseSwitch() {
|
||||||
|
// Selektion aufheben, damit der Detailbereich nicht mit Stammdaten
|
||||||
|
// aus der vorherigen Datenbank weiterzeigt.
|
||||||
|
overviewTable.getSelectionModel().clearSelection();
|
||||||
|
overviewItems.clear();
|
||||||
|
clearDetailPane();
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// UI-Aufbau
|
// UI-Aufbau
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.sqlite.SQLiteConfig;
|
||||||
|
import org.sqlite.SQLiteDataSource;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-Implementierung des {@link DatabaseCreationPort}.
|
||||||
|
* <p>
|
||||||
|
* Erzeugt eine neue, leere SQLite-Datenbank gegen einen vom Aufrufer übergebenen
|
||||||
|
* temporären Zielpfad und führt eine vollständige Flyway-Migration auf den neuesten
|
||||||
|
* Schema-Stand aus. Anschließend wird ein Verbindungstest durchgeführt, der drei
|
||||||
|
* Aspekte verifiziert:
|
||||||
|
* <ol>
|
||||||
|
* <li>Eine SQLite-Verbindung kann erfolgreich geöffnet werden.</li>
|
||||||
|
* <li>Die Flyway-History-Tabelle (Standardname {@code flyway_schema_history}) ist
|
||||||
|
* vorhanden und enthält mindestens einen erfolgreichen Migrationseintrag.</li>
|
||||||
|
* <li>Eine einfache Leseabfrage gegen Schema-Metadaten
|
||||||
|
* ({@code sqlite_master}) liefert ohne Fehler.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Im Fehlerfall wird die temporäre Datei zuverlässig wieder entfernt; aufrufende
|
||||||
|
* Komponenten erhalten ein klassifiziertes
|
||||||
|
* {@link DatabaseCreationPort.DatabaseCreationResult.Failure}-Ergebnis.
|
||||||
|
*
|
||||||
|
* <h2>Architekturgrenze</h2>
|
||||||
|
* <p>JDBC, SQLite-Konfiguration und Flyway-spezifische Typen verbleiben vollständig in
|
||||||
|
* dieser Klasse. Nach außen wird ausschließlich der versiegelte Port-Ergebnistyp
|
||||||
|
* herausgereicht.
|
||||||
|
*/
|
||||||
|
public class SqliteDatabaseCreationAdapter implements DatabaseCreationPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(SqliteDatabaseCreationAdapter.class);
|
||||||
|
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardkonstruktor.
|
||||||
|
*/
|
||||||
|
public SqliteDatabaseCreationAdapter() {
|
||||||
|
// keine Felder, kein Zustand
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine neue, leere SQLite-Datenbank an, migriert sie auf den neuesten Stand
|
||||||
|
* und führt einen Verbindungstest durch. Bei Fehlern wird die Temp-Datei entfernt.
|
||||||
|
*
|
||||||
|
* @param tempFile Pfad der zu erzeugenden temporären SQLite-Datei; darf nicht
|
||||||
|
* {@code null} sein und sollte vor dem Aufruf nicht existieren
|
||||||
|
* @return {@link DatabaseCreationResult.Success} bei Erfolg oder
|
||||||
|
* {@link DatabaseCreationResult.Failure} mit klassifizierter Phase
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public DatabaseCreationResult createAndInitialize(Path tempFile) {
|
||||||
|
if (tempFile == null) {
|
||||||
|
throw new NullPointerException("tempFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
Path absoluteTemp = tempFile.toAbsolutePath().normalize();
|
||||||
|
LOG.info("Lege neue temporäre SQLite-Datenbank an: {}", absoluteTemp);
|
||||||
|
|
||||||
|
// Verhindern, dass eine versehentlich vorhandene Temp-Datei mitmigiert wird
|
||||||
|
try {
|
||||||
|
if (Files.exists(absoluteTemp)) {
|
||||||
|
Files.delete(absoluteTemp);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Vorhandene temporäre Datei konnte nicht entfernt werden: {}",
|
||||||
|
absoluteTemp, e);
|
||||||
|
return new DatabaseCreationResult.Failure(
|
||||||
|
DatabaseCreationResult.Phase.FILE_CREATION,
|
||||||
|
"Vorhandene temporäre Datei konnte nicht entfernt werden: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
String jdbcUrl = buildJdbcUrl(absoluteTemp);
|
||||||
|
DataSource dataSource = createDataSource(jdbcUrl);
|
||||||
|
|
||||||
|
// Schema-Migration auf neuesten Stand
|
||||||
|
try {
|
||||||
|
Flyway flyway = Flyway.configure()
|
||||||
|
.dataSource(dataSource)
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.connectRetries(0)
|
||||||
|
.load();
|
||||||
|
flyway.migrate();
|
||||||
|
LOG.info("Flyway-Migration auf neuesten Stand abgeschlossen für: {}", absoluteTemp);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("Flyway-Migration fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
|
||||||
|
cleanup(absoluteTemp);
|
||||||
|
return new DatabaseCreationResult.Failure(
|
||||||
|
DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
|
||||||
|
"Schema-Migration fehlgeschlagen: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbindungstest gegen die migrierte Temp-Datei
|
||||||
|
try {
|
||||||
|
verifyConnection(dataSource);
|
||||||
|
LOG.info("Verbindungstest gegen neue SQLite-Datenbank erfolgreich: {}", absoluteTemp);
|
||||||
|
} catch (SQLException | IllegalStateException e) {
|
||||||
|
LOG.error("Verbindungstest fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
|
||||||
|
cleanup(absoluteTemp);
|
||||||
|
return new DatabaseCreationResult.Failure(
|
||||||
|
DatabaseCreationResult.Phase.CONNECTION_TEST,
|
||||||
|
"Verbindungstest fehlgeschlagen: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DatabaseCreationResult.Success(absoluteTemp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifiziert die migrierte Datenbank durch drei aufeinander aufbauende Prüfungen.
|
||||||
|
*
|
||||||
|
* @param dataSource die DataSource gegen die Temp-Datei
|
||||||
|
* @throws SQLException bei JDBC-Fehlern
|
||||||
|
* @throws IllegalStateException wenn eine fachliche Erwartung (z. B. Flyway-History
|
||||||
|
* vorhanden, mind. ein erfolgreicher Eintrag) verletzt ist
|
||||||
|
*/
|
||||||
|
private void verifyConnection(DataSource dataSource) throws SQLException {
|
||||||
|
try (Connection conn = dataSource.getConnection()) {
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='"
|
||||||
|
+ FLYWAY_HISTORY_TABLE + "'")) {
|
||||||
|
if (!rs.next() || rs.getInt(1) != 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Flyway-History-Tabelle fehlt nach der Migration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM " + FLYWAY_HISTORY_TABLE + " WHERE success = 1")) {
|
||||||
|
if (!rs.next() || rs.getInt(1) < 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Flyway-History enthält keinen erfolgreichen Migrationseintrag.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// einfache Leseabfrage gegen Schema-Metadaten
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'")) {
|
||||||
|
int tableCount = 0;
|
||||||
|
while (rs.next()) {
|
||||||
|
tableCount++;
|
||||||
|
}
|
||||||
|
if (tableCount < 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Schema-Metadatenabfrage lieferte keine Tabellen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanup(Path tempFile) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warn("Temporäre SQLite-Datei konnte nach Fehler nicht entfernt werden: {} – {}",
|
||||||
|
tempFile, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die JDBC-URL für eine SQLite-Datei nach dem im Projekt etablierten Schema.
|
||||||
|
*
|
||||||
|
* @param dbFile absoluter Pfad der SQLite-Datei; darf nicht {@code null} sein
|
||||||
|
* @return die JDBC-URL in der Form {@code jdbc:sqlite:/pfad/zur/datei.db}
|
||||||
|
*/
|
||||||
|
private static String buildJdbcUrl(Path dbFile) {
|
||||||
|
return "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine SQLite-DataSource mit aktivierten Fremdschlüsseln.
|
||||||
|
*
|
||||||
|
* @param jdbcUrl die JDBC-URL der SQLite-Datei
|
||||||
|
* @return eine konfigurierte {@link DataSource}; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static DataSource createDataSource(String jdbcUrl) {
|
||||||
|
SQLiteConfig config = new SQLiteConfig();
|
||||||
|
config.enforceForeignKeys(true);
|
||||||
|
SQLiteDataSource ds = new SQLiteDataSource(config);
|
||||||
|
ds.setUrl(jdbcUrl);
|
||||||
|
return ds;
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort.DatabaseCreationResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link SqliteDatabaseCreationAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass eine neue, leere SQLite-Datei am übergebenen Temp-Pfad angelegt und
|
||||||
|
* vollständig per Flyway migriert wird, dass der Verbindungstest die Flyway-History
|
||||||
|
* verifiziert und dass Fehler im Verlauf zur Bereinigung der Temp-Datei führen.
|
||||||
|
*/
|
||||||
|
class SqliteDatabaseCreationAdapterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldRejectNullPath() {
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
assertThatThrownBy(() -> adapter.createAndInitialize(null))
|
||||||
|
.isInstanceOf(NullPointerException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldCreateAndMigrateNewSqliteFile(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path tempFile = tempDir.resolve("new-db.sqlite.tmp");
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
|
||||||
|
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
|
||||||
|
assertThat(Files.exists(tempFile)).isTrue();
|
||||||
|
assertThat(Files.size(tempFile)).isGreaterThan(0);
|
||||||
|
|
||||||
|
// Schema verifizieren: Flyway-History und fachliche Tabellen müssen existieren
|
||||||
|
String jdbcUrl = "jdbc:sqlite:" + tempFile.toAbsolutePath().toString().replace('\\', '/');
|
||||||
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' "
|
||||||
|
+ "AND name IN ('flyway_schema_history','document_record','processing_attempt')")) {
|
||||||
|
assertThat(rs.next()).isTrue();
|
||||||
|
assertThat(rs.getInt(1)).isEqualTo(3);
|
||||||
|
}
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM flyway_schema_history WHERE success = 1")) {
|
||||||
|
assertThat(rs.next()).isTrue();
|
||||||
|
assertThat(rs.getInt(1)).isGreaterThanOrEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldOverwriteExistingTempFileBeforeMigration(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path tempFile = tempDir.resolve("existing.tmp");
|
||||||
|
Files.writeString(tempFile, "rest-zustand");
|
||||||
|
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
|
||||||
|
// Die Datei wurde durch eine leere SQLite-Datei ersetzt — der ursprüngliche Inhalt darf nicht mehr
|
||||||
|
// sichtbar sein.
|
||||||
|
assertThat(Files.size(tempFile)).isGreaterThan(0);
|
||||||
|
assertThat(Files.readString(tempFile, java.nio.charset.StandardCharsets.ISO_8859_1))
|
||||||
|
.doesNotContain("rest-zustand");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldFailAndCleanup_whenParentDirectoryDoesNotExist(@TempDir Path tempDir)
|
||||||
|
throws SQLException {
|
||||||
|
Path missingParent = tempDir.resolve("does-not-exist").resolve("child.tmp");
|
||||||
|
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
DatabaseCreationResult result = adapter.createAndInitialize(missingParent);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(DatabaseCreationResult.Failure.class);
|
||||||
|
DatabaseCreationResult.Failure failure = (DatabaseCreationResult.Failure) result;
|
||||||
|
assertThat(failure.phase())
|
||||||
|
.isIn(DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult.Phase.CONNECTION_TEST,
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult.Phase.FILE_CREATION);
|
||||||
|
assertThat(Files.exists(missingParent)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound-Port zum Anlegen einer neuen, leeren SQLite-Datenbankdatei und zum Umstellen
|
||||||
|
* der aktiven Datenbankreferenz der Anwendung auf diese neue Datei.
|
||||||
|
* <p>
|
||||||
|
* Der Use-Case orchestriert den vollständigen, aus Anwendungssicht atomaren Ablauf:
|
||||||
|
* <ol>
|
||||||
|
* <li>Pfad-Sicherheitsprüfung: aktive DB darf nicht überschrieben werden;</li>
|
||||||
|
* <li>Erzeugung einer temporären SQLite-Datei im Zielverzeichnis;</li>
|
||||||
|
* <li>vollständige Schema-Migration auf den neuesten Stand;</li>
|
||||||
|
* <li>Verbindungstest gegen die migrierte Temp-Datei;</li>
|
||||||
|
* <li>atomarer Move auf den endgültigen Zielpfad
|
||||||
|
* ({@link java.nio.file.StandardCopyOption#ATOMIC_MOVE},
|
||||||
|
* {@link java.nio.file.StandardCopyOption#REPLACE_EXISTING});</li>
|
||||||
|
* <li>Umstellung der aktiven DB-Referenz über den
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Schlägt ein Schritt fehl, bleibt die bisher aktive DB unverändert in Betrieb. Die
|
||||||
|
* temporäre Datei wird im Fehlerfall zuverlässig entfernt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface CreateNewDatabaseUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und stellt
|
||||||
|
* die aktive Datenbankreferenz der Anwendung auf diese Datei um.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null}
|
||||||
|
* sein. Bei einer bereits existierenden Datei muss der Aufrufer
|
||||||
|
* vorab die Bestätigung des Benutzers eingeholt haben.
|
||||||
|
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie
|
||||||
|
* {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
CreateNewDatabaseResult createNewDatabase(Path targetFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis-Interface für {@link CreateNewDatabaseUseCase#createNewDatabase(Path)}.
|
||||||
|
*/
|
||||||
|
sealed interface CreateNewDatabaseResult
|
||||||
|
permits CreateNewDatabaseResult.Success,
|
||||||
|
CreateNewDatabaseResult.SameAsActiveDatabase,
|
||||||
|
CreateNewDatabaseResult.CreationFailed {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfolgsfall. Die neue Datenbank wurde angelegt, migriert, getestet und ist
|
||||||
|
* jetzt die aktive Datenbank der Anwendung.
|
||||||
|
*
|
||||||
|
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; nie
|
||||||
|
* {@code null}
|
||||||
|
*/
|
||||||
|
record Success(Path targetFile) implements CreateNewDatabaseResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung.
|
||||||
|
*
|
||||||
|
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; darf
|
||||||
|
* nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
public Success {
|
||||||
|
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlerfall: Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei.
|
||||||
|
* Diese darf nicht überschrieben werden. Die aktive DB bleibt unverändert.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer gewählte Zielpfad; nie {@code null}
|
||||||
|
*/
|
||||||
|
record SameAsActiveDatabase(Path targetFile) implements CreateNewDatabaseResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
public SameAsActiveDatabase {
|
||||||
|
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlerfall: Beim Anlegen, Migrieren, Testen oder beim atomaren Move ist ein
|
||||||
|
* technischer Fehler aufgetreten. Die aktive DB bleibt unverändert; eine evtl.
|
||||||
|
* angelegte Temp-Datei wurde entfernt.
|
||||||
|
*
|
||||||
|
* @param phase technische Phase, in der der Fehler auftrat; nie {@code null}
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
*/
|
||||||
|
record CreationFailed(Phase phase, String message, Throwable cause)
|
||||||
|
implements CreateNewDatabaseResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
|
||||||
|
*
|
||||||
|
* @param phase technische Phase; darf nicht {@code null} sein
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code phase} oder {@code message}
|
||||||
|
* {@code null} ist
|
||||||
|
*/
|
||||||
|
public CreationFailed {
|
||||||
|
Objects.requireNonNull(phase, "phase darf nicht null sein");
|
||||||
|
Objects.requireNonNull(message, "message darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technische Phase, in der ein Fehler aufgetreten ist.
|
||||||
|
*/
|
||||||
|
enum Phase {
|
||||||
|
/** Pfad-Sicherheitsprüfung (z. B. Auflösung über {@code toRealPath()}) ist fehlgeschlagen. */
|
||||||
|
PATH_RESOLUTION,
|
||||||
|
/** Anlage der temporären Datei ist fehlgeschlagen. */
|
||||||
|
FILE_CREATION,
|
||||||
|
/** Schema-Migration der temporären Datei ist fehlgeschlagen. */
|
||||||
|
SCHEMA_MIGRATION,
|
||||||
|
/** Verbindungstest gegen die migrierte Datei ist fehlgeschlagen. */
|
||||||
|
CONNECTION_TEST,
|
||||||
|
/**
|
||||||
|
* Atomarer Move der temporären Datei zum Zielpfad ist fehlgeschlagen –
|
||||||
|
* insbesondere wenn das Dateisystem die Kombination
|
||||||
|
* {@code ATOMIC_MOVE + REPLACE_EXISTING} nicht unterstützt. Es wird
|
||||||
|
* absichtlich kein nicht-atomarer Fallback durchgeführt.
|
||||||
|
*/
|
||||||
|
ATOMIC_MOVE,
|
||||||
|
/** Umstellung der aktiven DB-Referenz ist fehlgeschlagen. */
|
||||||
|
CONTEXT_SWITCH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port, der die zur Laufzeit aktive SQLite-Datenbankdatei der Anwendung kapselt.
|
||||||
|
* <p>
|
||||||
|
* Eigentümer der „aktiven DB-Referenz" zur Laufzeit. Der Port erlaubt es, die aktive
|
||||||
|
* Datenbank über einen In-Memory-Override umzustellen, ohne die Konfigurationsdatei
|
||||||
|
* (`.properties`) zu verändern. Die GUI nutzt diesen Mechanismus, damit nach dem Anlegen
|
||||||
|
* einer neuen Datenbank sofort sämtliche DB-Operationen (Verlauf, Reset, Löschen,
|
||||||
|
* Verarbeitungsläufe) gegen die neue Datei laufen, bevor der Benutzer die Konfiguration
|
||||||
|
* speichert.
|
||||||
|
* <p>
|
||||||
|
* <strong>Architekturgrenze:</strong> Der Port arbeitet ausschließlich mit
|
||||||
|
* {@link java.nio.file.Path} und kennt keine JDBC- oder SQLite-spezifischen Typen.
|
||||||
|
* Wie die Implementierung den Override technisch wirksam macht (z. B. durch Ersetzen
|
||||||
|
* der JDBC-URL beim Verdrahten neuer SQLite-Adapter), ist Adapter-Detail.
|
||||||
|
*/
|
||||||
|
public interface ActiveDatabaseContextPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die aktive SQLite-Datenbankdatei der Anwendung um.
|
||||||
|
* <p>
|
||||||
|
* Nach dem Aufruf verwenden alle nachfolgenden DB-Operationen die übergebene Datei
|
||||||
|
* als aktive Datenbank, sofern keine andere Datei explizit übergeben wird.
|
||||||
|
*
|
||||||
|
* @param newDbFile absoluter Pfad der neuen aktiven Datenbankdatei; darf nicht
|
||||||
|
* {@code null} sein. Die Datei muss zum Zeitpunkt des Aufrufs
|
||||||
|
* existieren, ein gültiges SQLite-Schema enthalten und lesbar sein
|
||||||
|
* (Verbindung muss bereits durch den Aufrufer verifiziert worden sein).
|
||||||
|
* @throws NullPointerException wenn {@code newDbFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
void switchActiveDatabase(Path newDbFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den aktuell aktiven DB-Pfad als Override, sofern einer gesetzt wurde.
|
||||||
|
* <p>
|
||||||
|
* Solange kein Override gesetzt wurde, gilt die in der jeweiligen
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration}
|
||||||
|
* konfigurierte Datenbankdatei. Erst nach dem ersten Aufruf von
|
||||||
|
* {@link #switchActiveDatabase(Path)} liefert diese Methode einen nicht-leeren Wert.
|
||||||
|
*
|
||||||
|
* @return das gesetzte Override (nicht-leer) oder {@link Optional#empty()}, wenn die
|
||||||
|
* konfigurierte Datenbank weiterhin verwendet werden soll; nie {@code null}
|
||||||
|
*/
|
||||||
|
Optional<Path> activeDatabaseOverride();
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port zum Anlegen und Initialisieren einer neuen, leeren SQLite-Datenbankdatei
|
||||||
|
* gegen eine bereits vom Aufrufer reservierte temporäre Zieldatei.
|
||||||
|
* <p>
|
||||||
|
* Der Aufrufer (Use-Case) verantwortet die Lebensdauer der temporären Datei: er wählt den
|
||||||
|
* Pfad, übergibt ihn an diesen Port und führt nach Erfolg den atomaren Move auf den
|
||||||
|
* endgültigen Zieldateipfad selbst aus. Der Adapter beschränkt sich strikt auf:
|
||||||
|
* <ol>
|
||||||
|
* <li>Anlage und Migration der temporären SQLite-Datei auf den neuesten Schema-Stand
|
||||||
|
* (z. B. via Flyway {@code migrate()});</li>
|
||||||
|
* <li>technischer Verbindungstest gegen die migrierte Datei (Verbindung öffnen,
|
||||||
|
* Flyway-History prüfen, einfache Leseabfrage gegen Schema-Metadaten);</li>
|
||||||
|
* <li>Aufräumen der temporären Datei im Fehlerfall.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* <strong>Architekturgrenze:</strong> Provider- und SQLite-spezifische Details
|
||||||
|
* (JDBC-URL-Schema, DataSource-Konfiguration, Flyway-Konfiguration) bleiben
|
||||||
|
* ausschließlich im Adapter. Der Port arbeitet mit einem opaken {@link Path} und gibt
|
||||||
|
* ein versiegeltes Ergebnis zurück.
|
||||||
|
*/
|
||||||
|
public interface DatabaseCreationPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue, leere SQLite-Datenbankdatei am übergebenen temporären
|
||||||
|
* Zielpfad und führt eine vollständige Schema-Migration auf den neuesten Stand aus.
|
||||||
|
* <p>
|
||||||
|
* Bei Fehlern in einem der Teilschritte (Anlage, Migration, Verbindungstest) wird
|
||||||
|
* die temporäre Datei zuverlässig wieder entfernt; aufrufende Komponenten müssen
|
||||||
|
* diesen Aufräumschritt nicht selbst durchführen.
|
||||||
|
*
|
||||||
|
* @param tempFile Pfad der zu erstellenden temporären SQLite-Datei; darf nicht
|
||||||
|
* {@code null} sein. Die Datei darf vor dem Aufruf noch nicht
|
||||||
|
* existieren; das Elternverzeichnis muss existieren und schreibbar
|
||||||
|
* sein.
|
||||||
|
* @return ein versiegeltes Ergebnis: {@link DatabaseCreationResult.Success} bei Erfolg
|
||||||
|
* oder {@link DatabaseCreationResult.Failure} mit Fehlerklasse und Meldung
|
||||||
|
* im Fehlerfall; nie {@code null}.
|
||||||
|
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
DatabaseCreationResult createAndInitialize(Path tempFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis-Interface für {@link DatabaseCreationPort#createAndInitialize(Path)}.
|
||||||
|
*/
|
||||||
|
sealed interface DatabaseCreationResult
|
||||||
|
permits DatabaseCreationResult.Success, DatabaseCreationResult.Failure {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfolgsergebnis. Die temporäre Datei wurde erfolgreich erstellt, migriert
|
||||||
|
* und durch den Verbindungstest verifiziert. Der Aufrufer kann sie nun atomar
|
||||||
|
* an den endgültigen Zielpfad verschieben.
|
||||||
|
*
|
||||||
|
* @param tempFile der temporäre, erfolgreich migrierte Pfad; nie {@code null}
|
||||||
|
*/
|
||||||
|
record Success(Path tempFile) implements DatabaseCreationResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung.
|
||||||
|
*
|
||||||
|
* @param tempFile der temporäre, erfolgreich migrierte Pfad; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
public Success {
|
||||||
|
Objects.requireNonNull(tempFile, "tempFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlerergebnis. Die temporäre Datei wurde – falls bereits angelegt – wieder
|
||||||
|
* entfernt; die aktive DB der Anwendung wurde nicht angetastet.
|
||||||
|
*
|
||||||
|
* @param phase die Phase, in der der Fehler auftrat; nie {@code null}
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
*/
|
||||||
|
record Failure(Phase phase, String message, Throwable cause) implements DatabaseCreationResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
|
||||||
|
*
|
||||||
|
* @param phase die Phase, in der der Fehler auftrat; darf nicht {@code null} sein
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code phase} oder {@code message} {@code null} ist
|
||||||
|
*/
|
||||||
|
public Failure {
|
||||||
|
Objects.requireNonNull(phase, "phase darf nicht null sein");
|
||||||
|
Objects.requireNonNull(message, "message darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase der Erstellung einer neuen Datenbank, in der ein Fehler auftrat.
|
||||||
|
*/
|
||||||
|
enum Phase {
|
||||||
|
/** Die temporäre Datei konnte nicht erzeugt oder beschrieben werden. */
|
||||||
|
FILE_CREATION,
|
||||||
|
/** Die Schema-Migration (Flyway) ist fehlgeschlagen. */
|
||||||
|
SCHEMA_MIGRATION,
|
||||||
|
/** Der nachgelagerte Verbindungstest ist fehlgeschlagen. */
|
||||||
|
CONNECTION_TEST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardimplementierung des {@link CreateNewDatabaseUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Orchestriert den vollständigen Anlage- und Wechselvorgang einer neuen, leeren
|
||||||
|
* SQLite-Datenbankdatei und delegiert die technischen Teilschritte an die Ports
|
||||||
|
* {@link DatabaseCreationPort} und {@link ActiveDatabaseContextPort}. Der Adapter
|
||||||
|
* darunter (z. B. SQLite/Flyway) bleibt für den Use-Case unsichtbar.
|
||||||
|
*
|
||||||
|
* <h2>Atomarität</h2>
|
||||||
|
* Aus Anwendungssicht ist der Wechsel atomar:
|
||||||
|
* <ul>
|
||||||
|
* <li>Bei einem Fehler in einem der Schritte wird die temporäre Datei zuverlässig
|
||||||
|
* entfernt; die aktive Datenbank bleibt unverändert in Betrieb.</li>
|
||||||
|
* <li>Erst nach erfolgreichem Verbindungstest wird die temporäre Datei via
|
||||||
|
* {@link StandardCopyOption#ATOMIC_MOVE} mit
|
||||||
|
* {@link StandardCopyOption#REPLACE_EXISTING} an den endgültigen Zielpfad
|
||||||
|
* verschoben. Bei nicht unterstützter Kombination wird der Vorgang mit
|
||||||
|
* klarer Fehlermeldung abgebrochen – kein stiller Fallback.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Pfad-Sicherheitsprüfung</h2>
|
||||||
|
* Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
|
||||||
|
* normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
|
||||||
|
* real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
|
||||||
|
* Vergleich case-insensitive.
|
||||||
|
*/
|
||||||
|
public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(DefaultCreateNewDatabaseUseCase.class);
|
||||||
|
private static final String OS_NAME = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
private final DatabaseCreationPort databaseCreationPort;
|
||||||
|
private final ActiveDatabaseContextPort activeDatabaseContextPort;
|
||||||
|
private final ActiveDatabasePathSupplier activeDatabasePathSupplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
|
||||||
|
* <p>
|
||||||
|
* Diese Indirektion erlaubt es dem Bootstrap, sowohl den
|
||||||
|
* {@link ActiveDatabaseContextPort}-Override als auch den Wert aus der geladenen
|
||||||
|
* Konfigurationsdatei zu berücksichtigen, ohne dass der Use-Case Konfigurationstypen
|
||||||
|
* kennen muss.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ActiveDatabasePathSupplier {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
|
||||||
|
*
|
||||||
|
* @return den absoluten Pfad der aktiven Datei; nie {@code null}
|
||||||
|
*/
|
||||||
|
Path get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Use-Case mit den drei erforderlichen Ports/Lieferanten.
|
||||||
|
*
|
||||||
|
* @param databaseCreationPort Port zum Anlegen und Initialisieren der Temp-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param activeDatabaseContextPort Port zum Umstellen der aktiven DB-Referenz;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param activeDatabasePathSupplier Lieferant für den Pfad der aktuell aktiven
|
||||||
|
* SQLite-Datei; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public DefaultCreateNewDatabaseUseCase(DatabaseCreationPort databaseCreationPort,
|
||||||
|
ActiveDatabaseContextPort activeDatabaseContextPort,
|
||||||
|
ActiveDatabasePathSupplier activeDatabasePathSupplier) {
|
||||||
|
this.databaseCreationPort = Objects.requireNonNull(databaseCreationPort,
|
||||||
|
"databaseCreationPort darf nicht null sein");
|
||||||
|
this.activeDatabaseContextPort = Objects.requireNonNull(activeDatabaseContextPort,
|
||||||
|
"activeDatabaseContextPort darf nicht null sein");
|
||||||
|
this.activeDatabasePathSupplier = Objects.requireNonNull(activeDatabasePathSupplier,
|
||||||
|
"activeDatabasePathSupplier darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestriert den vollständigen Anlage- und Wechselvorgang.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
|
||||||
|
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CreateNewDatabaseResult createNewDatabase(Path targetFile) {
|
||||||
|
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
|
||||||
|
Path absoluteTarget = targetFile.toAbsolutePath().normalize();
|
||||||
|
LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
|
||||||
|
|
||||||
|
// Schritt 1: Pfad-Sicherheitsprüfung
|
||||||
|
Path activeDb = activeDatabasePathSupplier.get();
|
||||||
|
if (activeDb == null) {
|
||||||
|
LOG.error("Aktiver Datenbankpfad ist nicht ermittelbar – Anlage abgebrochen.");
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
|
"Aktiver Datenbankpfad konnte nicht ermittelt werden.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
Path absoluteActive = activeDb.toAbsolutePath().normalize();
|
||||||
|
boolean sameFile;
|
||||||
|
try {
|
||||||
|
sameFile = isSameFile(absoluteActive, absoluteTarget);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Pfad-Sicherheitsprüfung fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
|
"Pfad-Sicherheitsprüfung fehlgeschlagen: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
if (sameFile) {
|
||||||
|
LOG.warn("Anlage abgelehnt: Zielpfad entspricht der aktuell aktiven Datenbank: {}",
|
||||||
|
absoluteTarget);
|
||||||
|
return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
|
||||||
|
Path parent = absoluteTarget.getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
LOG.error("Zielpfad besitzt kein Elternverzeichnis: {}", absoluteTarget);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
|
"Zielpfad besitzt kein Elternverzeichnis.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!Files.isDirectory(parent)) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Zielverzeichnis konnte nicht angelegt werden: {}", parent, e);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.FILE_CREATION,
|
||||||
|
"Zielverzeichnis konnte nicht angelegt werden: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
Path tempFile = parent.resolve(absoluteTarget.getFileName().toString()
|
||||||
|
+ ".new-" + UUID.randomUUID() + ".tmp");
|
||||||
|
|
||||||
|
// Schritt 3: Adapter führt Anlage + Schema-Migration + Verbindungstest aus
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult creationResult;
|
||||||
|
try {
|
||||||
|
creationResult = databaseCreationPort.createAndInitialize(tempFile);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("Unerwarteter Fehler beim Anlegen der temporären Datenbank: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.FILE_CREATION,
|
||||||
|
"Unerwarteter Fehler beim Anlegen der temporären Datenbank: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
if (creationResult instanceof DatabaseCreationPort.DatabaseCreationResult.Failure failure) {
|
||||||
|
CreateNewDatabaseResult.Phase phase = mapPhase(failure.phase());
|
||||||
|
LOG.error("Anlage der neuen Datenbank fehlgeschlagen ({}): {}",
|
||||||
|
failure.phase(), failure.message());
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(phase, failure.message(),
|
||||||
|
failure.cause());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 4: atomarer Move auf Zielpfad
|
||||||
|
try {
|
||||||
|
Files.move(tempFile, absoluteTarget,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AtomicMoveNotSupportedException e) {
|
||||||
|
LOG.error("Atomarer Move nicht unterstützt für Zielpfad {}: {}",
|
||||||
|
absoluteTarget, e.getMessage(), e);
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
|
||||||
|
"Atomarer Move (ATOMIC_MOVE + REPLACE_EXISTING) wird vom Dateisystem nicht "
|
||||||
|
+ "unterstützt. Kein nicht-atomarer Fallback. Ziel: "
|
||||||
|
+ absoluteTarget,
|
||||||
|
e);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Atomarer Move fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
|
||||||
|
"Verschieben der temporären Datenbank zum Zielpfad fehlgeschlagen: "
|
||||||
|
+ e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 5: Aktive DB-Referenz umstellen
|
||||||
|
try {
|
||||||
|
activeDatabaseContextPort.switchActiveDatabase(absoluteTarget);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("Umstellen der aktiven DB-Referenz fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.CONTEXT_SWITCH,
|
||||||
|
"Aktive DB-Referenz konnte nicht umgestellt werden: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Neue Datenbank erfolgreich angelegt und aktiviert: {}", absoluteTarget);
|
||||||
|
return new CreateNewDatabaseResult.Success(absoluteTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleicht zwei Datenbankpfade mit Berücksichtigung von Symlinks und
|
||||||
|
* (auf Windows) Case-Insensitivität.
|
||||||
|
* <p>
|
||||||
|
* Existieren beide Dateien, wird {@code Files.isSameFile(...)} verwendet.
|
||||||
|
* Existiert eine der beiden Dateien (typischerweise das Ziel) noch nicht, werden
|
||||||
|
* Elternverzeichnisse via {@link Path#toRealPath(java.nio.file.LinkOption...)}
|
||||||
|
* aufgelöst und mit den Dateinamen kombiniert verglichen. Auf Windows erfolgt der
|
||||||
|
* abschließende String-Vergleich case-insensitive.
|
||||||
|
*
|
||||||
|
* @param a Pfad A; darf nicht {@code null} sein
|
||||||
|
* @param b Pfad B; darf nicht {@code null} sein
|
||||||
|
* @return {@code true}, wenn beide Pfade auf dieselbe Datei zeigen
|
||||||
|
* @throws IOException bei Auflösungsfehlern existierender Pfadbestandteile
|
||||||
|
*/
|
||||||
|
static boolean isSameFile(Path a, Path b) throws IOException {
|
||||||
|
Objects.requireNonNull(a, "a darf nicht null sein");
|
||||||
|
Objects.requireNonNull(b, "b darf nicht null sein");
|
||||||
|
if (Files.exists(a) && Files.exists(b)) {
|
||||||
|
return Files.isSameFile(a, b);
|
||||||
|
}
|
||||||
|
Path realA = resolveBest(a);
|
||||||
|
Path realB = resolveBest(b);
|
||||||
|
if (isWindows()) {
|
||||||
|
return realA.toString().equalsIgnoreCase(realB.toString());
|
||||||
|
}
|
||||||
|
return realA.equals(realB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst die existierenden Bestandteile eines Pfades soweit möglich real auf und
|
||||||
|
* normalisiert den Rest. Wird verwendet, wenn die Datei selbst noch nicht existiert.
|
||||||
|
*
|
||||||
|
* @param path der zu normalisierende Pfad
|
||||||
|
* @return ein bestmöglich aufgelöster, normalisierter Pfad
|
||||||
|
* @throws IOException bei {@link Path#toRealPath(java.nio.file.LinkOption...)}-Fehlern
|
||||||
|
*/
|
||||||
|
private static Path resolveBest(Path path) throws IOException {
|
||||||
|
if (Files.exists(path)) {
|
||||||
|
return path.toRealPath();
|
||||||
|
}
|
||||||
|
Path parent = path.toAbsolutePath().normalize().getParent();
|
||||||
|
Path fileName = path.getFileName();
|
||||||
|
if (parent != null && Files.exists(parent)) {
|
||||||
|
return parent.toRealPath().resolve(fileName == null ? "" : fileName.toString());
|
||||||
|
}
|
||||||
|
return path.toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true}, wenn die laufende JVM auf Windows läuft.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn Windows; sonst {@code false}
|
||||||
|
*/
|
||||||
|
private static boolean isWindows() {
|
||||||
|
return OS_NAME.contains("win");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteTempQuietly(Path tempFile) {
|
||||||
|
if (tempFile == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warn("Temporäre Datenbankdatei konnte nicht gelöscht werden: {} – {}",
|
||||||
|
tempFile, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateNewDatabaseResult.Phase mapPhase(
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult.Phase phase) {
|
||||||
|
return switch (phase) {
|
||||||
|
case FILE_CREATION -> CreateNewDatabaseResult.Phase.FILE_CREATION;
|
||||||
|
case SCHEMA_MIGRATION -> CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION;
|
||||||
|
case CONNECTION_TEST -> CreateNewDatabaseResult.Phase.CONNECTION_TEST;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das gesetzte Override (sofern vorhanden), für Diagnose- und Logging-Zwecke.
|
||||||
|
* Nicht Teil der öffentlichen Use-Case-API.
|
||||||
|
*
|
||||||
|
* @return das Override aus dem {@link ActiveDatabaseContextPort}; nie {@code null}
|
||||||
|
*/
|
||||||
|
Optional<Path> currentOverride() {
|
||||||
|
return activeDatabaseContextPort.activeDatabaseOverride();
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link DefaultCreateNewDatabaseUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Prüft den Orchestrierungsablauf, die Pfad-Sicherheitsprüfung, das Aufräumen der
|
||||||
|
* temporären Datei im Fehlerfall und das Umstellen der aktiven DB-Referenz im
|
||||||
|
* Erfolgsfall. Die DB-Adapter werden über Stubs ersetzt, damit die Tests ohne
|
||||||
|
* SQLite-/Flyway-Infrastruktur laufen.
|
||||||
|
*/
|
||||||
|
class DefaultCreateNewDatabaseUseCaseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructor_shouldThrowNullPointerException_whenAnyPortIsNull() {
|
||||||
|
DatabaseCreationPort creation = tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
|
||||||
|
ActiveDatabaseContextPort context = stubActiveContext();
|
||||||
|
DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier supplier = () -> Path.of(".");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(null, context, supplier))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessageContaining("databaseCreationPort");
|
||||||
|
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, null, supplier))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessageContaining("activeDatabaseContextPort");
|
||||||
|
assertThatThrownBy(() -> new DefaultCreateNewDatabaseUseCase(creation, context, null))
|
||||||
|
.isInstanceOf(NullPointerException.class)
|
||||||
|
.hasMessageContaining("activeDatabasePathSupplier");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNewDatabase_shouldThrowNullPointerException_whenTargetIsNull() {
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
tempFile -> new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile),
|
||||||
|
stubActiveContext(),
|
||||||
|
() -> Path.of("active.sqlite"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> useCase.createNewDatabase(null))
|
||||||
|
.isInstanceOf(NullPointerException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNewDatabase_shouldRejectSameAsActiveDatabase(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path active = tempDir.resolve("active.sqlite");
|
||||||
|
Files.writeString(active, "stub");
|
||||||
|
|
||||||
|
AtomicReference<Path> contextOverride = new AtomicReference<>();
|
||||||
|
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
tempFile -> {
|
||||||
|
throw new AssertionError("DatabaseCreationPort darf bei selber Datei nicht aufgerufen werden");
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
active::toAbsolutePath);
|
||||||
|
|
||||||
|
CreateNewDatabaseResult result = useCase.createNewDatabase(active);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(CreateNewDatabaseResult.SameAsActiveDatabase.class);
|
||||||
|
assertThat(contextOverride.get()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNewDatabase_shouldDeleteTempFileAndKeepActiveContext_whenAdapterReportsFailure(
|
||||||
|
@TempDir Path tempDir) throws IOException {
|
||||||
|
Path active = tempDir.resolve("active.sqlite");
|
||||||
|
Files.writeString(active, "stub");
|
||||||
|
Path target = tempDir.resolve("new.sqlite");
|
||||||
|
|
||||||
|
AtomicReference<Path> capturedTemp = new AtomicReference<>();
|
||||||
|
DatabaseCreationPort creation = tempFile -> {
|
||||||
|
// Simuliere, dass der Adapter die Temp-Datei zwar anlegt, aber wegen Migration scheitert
|
||||||
|
try {
|
||||||
|
Files.writeString(tempFile, "x");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
capturedTemp.set(tempFile);
|
||||||
|
return new DatabaseCreationPort.DatabaseCreationResult.Failure(
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
|
||||||
|
"Migration fehlgeschlagen", null);
|
||||||
|
};
|
||||||
|
AtomicReference<Path> contextOverride = new AtomicReference<>();
|
||||||
|
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
creation, context, active::toAbsolutePath);
|
||||||
|
|
||||||
|
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(CreateNewDatabaseResult.CreationFailed.class);
|
||||||
|
CreateNewDatabaseResult.CreationFailed failed = (CreateNewDatabaseResult.CreationFailed) result;
|
||||||
|
assertThat(failed.phase()).isEqualTo(CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION);
|
||||||
|
assertThat(capturedTemp.get()).isNotNull();
|
||||||
|
assertThat(Files.exists(capturedTemp.get())).isFalse();
|
||||||
|
assertThat(Files.exists(target)).isFalse();
|
||||||
|
assertThat(contextOverride.get()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNewDatabase_shouldMoveTempToTargetAndSwitchContext_onSuccess(@TempDir Path tempDir)
|
||||||
|
throws IOException {
|
||||||
|
Path active = tempDir.resolve("active.sqlite");
|
||||||
|
Files.writeString(active, "stub");
|
||||||
|
Path target = tempDir.resolve("new.sqlite");
|
||||||
|
|
||||||
|
AtomicReference<Path> capturedTemp = new AtomicReference<>();
|
||||||
|
DatabaseCreationPort creation = tempFile -> {
|
||||||
|
try {
|
||||||
|
Files.writeString(tempFile, "migrated-content");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
capturedTemp.set(tempFile);
|
||||||
|
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
|
||||||
|
};
|
||||||
|
AtomicReference<Path> contextOverride = new AtomicReference<>();
|
||||||
|
ActiveDatabaseContextPort context = trackingActiveContext(contextOverride);
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
creation, context, active::toAbsolutePath);
|
||||||
|
|
||||||
|
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
|
||||||
|
assertThat(Files.exists(target)).isTrue();
|
||||||
|
assertThat(Files.readString(target)).isEqualTo("migrated-content");
|
||||||
|
assertThat(capturedTemp.get()).isNotNull();
|
||||||
|
assertThat(Files.exists(capturedTemp.get())).isFalse();
|
||||||
|
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createNewDatabase_shouldOverwriteExistingTargetFileAtomically(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path active = tempDir.resolve("active.sqlite");
|
||||||
|
Files.writeString(active, "active");
|
||||||
|
Path target = tempDir.resolve("existing.sqlite");
|
||||||
|
Files.writeString(target, "alter-inhalt");
|
||||||
|
|
||||||
|
DatabaseCreationPort creation = tempFile -> {
|
||||||
|
try {
|
||||||
|
Files.writeString(tempFile, "neu-und-leer");
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
return new DatabaseCreationPort.DatabaseCreationResult.Success(tempFile);
|
||||||
|
};
|
||||||
|
AtomicReference<Path> contextOverride = new AtomicReference<>();
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
creation, trackingActiveContext(contextOverride), active::toAbsolutePath);
|
||||||
|
|
||||||
|
CreateNewDatabaseResult result = useCase.createNewDatabase(target);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(CreateNewDatabaseResult.Success.class);
|
||||||
|
assertThat(Files.readString(target)).isEqualTo("neu-und-leer");
|
||||||
|
assertThat(contextOverride.get()).isEqualTo(target.toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSameFile_shouldReturnTrue_forIdenticalNonExistingFiles(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path a = tempDir.resolve("data.sqlite");
|
||||||
|
Path b = tempDir.resolve("data.sqlite");
|
||||||
|
assertThat(DefaultCreateNewDatabaseUseCase.isSameFile(a, b)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSameFile_shouldReturnFalse_forDifferentFilesInSameDirectory(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path a = tempDir.resolve("a.sqlite");
|
||||||
|
Path b = tempDir.resolve("b.sqlite");
|
||||||
|
assertThat(DefaultCreateNewDatabaseUseCase.isSameFile(a, b)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActiveDatabaseContextPort stubActiveContext() {
|
||||||
|
return new ActiveDatabaseContextPort() {
|
||||||
|
@Override
|
||||||
|
public void switchActiveDatabase(Path newDbFile) { /* no-op */ }
|
||||||
|
@Override
|
||||||
|
public Optional<Path> activeDatabaseOverride() { return Optional.empty(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktive Context-Implementierung, die das übergebene Override in der gegebenen
|
||||||
|
* {@link AtomicReference} festhält, damit Tests die Aufruf-Sequenz prüfen können.
|
||||||
|
*/
|
||||||
|
private static ActiveDatabaseContextPort trackingActiveContext(AtomicReference<Path> sink) {
|
||||||
|
return new ActiveDatabaseContextPort() {
|
||||||
|
@Override
|
||||||
|
public void switchActiveDatabase(Path newDbFile) {
|
||||||
|
sink.set(newDbFile);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public Optional<Path> activeDatabaseOverride() {
|
||||||
|
return Optional.ofNullable(sink.get());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
-15
@@ -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.GuiConfigurationFileLoader;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationFileWriter;
|
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.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.GuiPromptEditorPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
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.pdfextraction.PdfTextExtractionPortAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.out.prompt.FilesystemPromptPortAdapter;
|
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.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.SqliteDocumentRecordRepositoryAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter;
|
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
|
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.config.startup.StartConfiguration;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
|
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.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.HistoricalDocumentContext;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
|
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.ManualFileRenameUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
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.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.AiContentSensitivity;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
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.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.ConfigurationPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
|
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.adapter.out.sqlite.SqliteHistoryQueryAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
|
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.DefaultBatchRunProcessingUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultCreateNewDatabaseUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase;
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase;
|
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.GuiConfigurationPropertiesWriter;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jLogDiagnosticsAdapter;
|
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jLogDiagnosticsAdapter;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
|
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.AnotherInstanceRunningException;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.SingleInstanceGuard;
|
import de.gecheckt.pdf.umbenenner.bootstrap.singleinstance.SingleInstanceGuard;
|
||||||
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
|
||||||
@@ -207,6 +214,23 @@ public class BootstrapRunner {
|
|||||||
private final GuiAdapterFactory guiAdapterFactory;
|
private final GuiAdapterFactory guiAdapterFactory;
|
||||||
private final SingleInstanceGuardFactory singleInstanceGuardFactory;
|
private final SingleInstanceGuardFactory singleInstanceGuardFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigentümer der zur Laufzeit aktiven SQLite-Datenbankreferenz. Wird im
|
||||||
|
* GUI-Pfad genutzt, damit der Use-Case
|
||||||
|
* {@link DefaultCreateNewDatabaseUseCase} die aktive Datenbank umstellen kann,
|
||||||
|
* ohne die {@code .properties}-Datei sofort schreiben zu müssen.
|
||||||
|
* <p>
|
||||||
|
* Solange kein Override gesetzt ist, verhalten sich alle DB-Adapter wie
|
||||||
|
* bisher (Pfad aus der jeweils geladenen {@link StartConfiguration}).
|
||||||
|
*/
|
||||||
|
private final SqliteActiveDatabaseContextAdapter activeDatabaseContext = new SqliteActiveDatabaseContextAdapter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter zum Anlegen einer neuen, leeren SQLite-Datenbank. Zustandslos und
|
||||||
|
* threadsicher; wird im GUI-Pfad pro Anlage-Aufruf wiederverwendet.
|
||||||
|
*/
|
||||||
|
private final DatabaseCreationPort databaseCreationPort = new SqliteDatabaseCreationAdapter();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Functional interface encapsulating the legacy configuration migration step.
|
* Functional interface encapsulating the legacy configuration migration step.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -416,7 +440,7 @@ public class BootstrapRunner {
|
|||||||
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
|
ProviderConfiguration providerConfig = startConfig.multiProviderConfiguration().activeProviderConfiguration();
|
||||||
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
|
AiInvocationPort aiInvocationPort = new AiProviderSelector().select(activeFamily, providerConfig);
|
||||||
|
|
||||||
String jdbcUrl = buildJdbcUrl(startConfig);
|
String jdbcUrl = resolveActiveJdbcUrl(startConfig);
|
||||||
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
|
FingerprintPort fingerprintPort = new Sha256FingerprintAdapter();
|
||||||
DocumentRecordRepository documentRecordRepository =
|
DocumentRecordRepository documentRecordRepository =
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
@@ -823,6 +847,7 @@ public class BootstrapRunner {
|
|||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
||||||
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
|
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
|
||||||
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
|
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
|
||||||
|
GuiCreateNewDatabasePort createNewDatabasePort = this::createNewDatabaseForGui;
|
||||||
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
|
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
|
||||||
GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui;
|
GuiDeleteDocumentHistoryPort deleteHistoryPort = this::deleteDocumentHistoryForGui;
|
||||||
// Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
|
// Versionsnummer aus dem MANIFEST.MF des gepackten JARs lesen; Fallback "dev" bei IDE-Start
|
||||||
@@ -852,7 +877,8 @@ public class BootstrapRunner {
|
|||||||
historyDetailsPort,
|
historyDetailsPort,
|
||||||
historyResetPort,
|
historyResetPort,
|
||||||
deleteHistoryPort,
|
deleteHistoryPort,
|
||||||
this::buildGuiPromptEditorPort);
|
this::buildGuiPromptEditorPort,
|
||||||
|
createNewDatabasePort);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path configPath = Paths.get(configPathOverride.get());
|
Path configPath = Paths.get(configPathOverride.get());
|
||||||
@@ -883,7 +909,8 @@ public class BootstrapRunner {
|
|||||||
historyDetailsPort,
|
historyDetailsPort,
|
||||||
historyResetPort,
|
historyResetPort,
|
||||||
deleteHistoryPort,
|
deleteHistoryPort,
|
||||||
this::buildGuiPromptEditorPort);
|
this::buildGuiPromptEditorPort,
|
||||||
|
createNewDatabasePort);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||||
@@ -897,7 +924,7 @@ public class BootstrapRunner {
|
|||||||
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
|
||||||
historicalDocumentContextPort, applicationVersion, promptEditorPort,
|
historicalDocumentContextPort, applicationVersion, promptEditorPort,
|
||||||
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
|
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
|
||||||
this::buildGuiPromptEditorPort);
|
this::buildGuiPromptEditorPort, createNewDatabasePort);
|
||||||
} catch (GuiConfigurationLoadException e) {
|
} catch (GuiConfigurationLoadException e) {
|
||||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -924,7 +951,8 @@ public class BootstrapRunner {
|
|||||||
historyDetailsPort,
|
historyDetailsPort,
|
||||||
historyResetPort,
|
historyResetPort,
|
||||||
deleteHistoryPort,
|
deleteHistoryPort,
|
||||||
this::buildGuiPromptEditorPort);
|
this::buildGuiPromptEditorPort,
|
||||||
|
createNewDatabasePort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1171,7 +1199,7 @@ public class BootstrapRunner {
|
|||||||
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
initializeSchema(config);
|
initializeSchema(config);
|
||||||
|
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
String jdbcUrl = resolveActiveJdbcUrl(config);
|
||||||
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
ProcessingLogger resetLogger = new Log4jProcessingLogger(
|
ProcessingLogger resetLogger = new Log4jProcessingLogger(
|
||||||
DefaultResetDocumentStatusUseCase.class,
|
DefaultResetDocumentStatusUseCase.class,
|
||||||
@@ -1229,7 +1257,7 @@ public class BootstrapRunner {
|
|||||||
*/
|
*/
|
||||||
private ManualFileRenameUseCase buildProductionManualFileRenameUseCase(
|
private ManualFileRenameUseCase buildProductionManualFileRenameUseCase(
|
||||||
StartConfiguration startConfig) {
|
StartConfiguration startConfig) {
|
||||||
String jdbcUrl = buildJdbcUrl(startConfig);
|
String jdbcUrl = resolveActiveJdbcUrl(startConfig);
|
||||||
DocumentRecordRepository documentRecordRepository =
|
DocumentRecordRepository documentRecordRepository =
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
@@ -1263,7 +1291,7 @@ public class BootstrapRunner {
|
|||||||
*/
|
*/
|
||||||
private ManualFileCopyUseCase buildProductionManualFileCopyUseCase(
|
private ManualFileCopyUseCase buildProductionManualFileCopyUseCase(
|
||||||
StartConfiguration startConfig) {
|
StartConfiguration startConfig) {
|
||||||
String jdbcUrl = buildJdbcUrl(startConfig);
|
String jdbcUrl = resolveActiveJdbcUrl(startConfig);
|
||||||
DocumentRecordRepository documentRecordRepository =
|
DocumentRecordRepository documentRecordRepository =
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
@@ -1442,7 +1470,7 @@ public class BootstrapRunner {
|
|||||||
migrateConfigurationIfNeeded(configFilePath);
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
initializeSchema(config);
|
initializeSchema(config);
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
String jdbcUrl = resolveActiveJdbcUrl(config);
|
||||||
DocumentRecordRepository documentRecordRepository =
|
DocumentRecordRepository documentRecordRepository =
|
||||||
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
|
||||||
ResolveHistoricalDocumentContextUseCase useCase =
|
ResolveHistoricalDocumentContextUseCase useCase =
|
||||||
@@ -1475,7 +1503,7 @@ public class BootstrapRunner {
|
|||||||
migrateConfigurationIfNeeded(configFilePath);
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
initializeSchema(config);
|
initializeSchema(config);
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
String jdbcUrl = resolveActiveJdbcUrl(config);
|
||||||
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
||||||
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort);
|
DefaultHistoryOverviewUseCase useCase = new DefaultHistoryOverviewUseCase(historyQueryPort);
|
||||||
return useCase.loadOverview(query);
|
return useCase.loadOverview(query);
|
||||||
@@ -1505,7 +1533,7 @@ public class BootstrapRunner {
|
|||||||
migrateConfigurationIfNeeded(configFilePath);
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
initializeSchema(config);
|
initializeSchema(config);
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
String jdbcUrl = resolveActiveJdbcUrl(config);
|
||||||
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
HistoryQueryPort historyQueryPort = new SqliteHistoryQueryAdapter(jdbcUrl);
|
||||||
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort);
|
DefaultHistoryDetailsUseCase useCase = new DefaultHistoryDetailsUseCase(historyQueryPort);
|
||||||
return useCase.loadDetails(fingerprint);
|
return useCase.loadDetails(fingerprint);
|
||||||
@@ -1536,7 +1564,7 @@ public class BootstrapRunner {
|
|||||||
migrateConfigurationIfNeeded(configFilePath);
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
initializeSchema(config);
|
initializeSchema(config);
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
String jdbcUrl = resolveActiveJdbcUrl(config);
|
||||||
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
DefaultHistoryResetDocumentStatusUseCase useCase =
|
DefaultHistoryResetDocumentStatusUseCase useCase =
|
||||||
new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort);
|
new DefaultHistoryResetDocumentStatusUseCase(unitOfWorkPort);
|
||||||
@@ -1568,7 +1596,7 @@ public class BootstrapRunner {
|
|||||||
migrateConfigurationIfNeeded(configFilePath);
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
initializeSchema(config);
|
initializeSchema(config);
|
||||||
String jdbcUrl = buildJdbcUrl(config);
|
String jdbcUrl = resolveActiveJdbcUrl(config);
|
||||||
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
UnitOfWorkPort unitOfWorkPort = new SqliteUnitOfWorkAdapter(jdbcUrl);
|
||||||
DefaultDeleteDocumentHistoryUseCase useCase =
|
DefaultDeleteDocumentHistoryUseCase useCase =
|
||||||
new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort);
|
new DefaultDeleteDocumentHistoryUseCase(unitOfWorkPort);
|
||||||
@@ -1582,6 +1610,53 @@ public class BootstrapRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge-Methode zum Anlegen einer neuen, leeren SQLite-Datenbank für die GUI.
|
||||||
|
* <p>
|
||||||
|
* Verdrahtet pro Aufruf einen frischen {@link DefaultCreateNewDatabaseUseCase} mit dem
|
||||||
|
* gemeinsam gehaltenen {@link #databaseCreationPort} und dem
|
||||||
|
* {@link #activeDatabaseContext}. Die zugrundeliegende Liefer-Lambda für den aktiven
|
||||||
|
* DB-Pfad bevorzugt das Override im Kontext-Port; ist keines gesetzt, fällt sie auf den
|
||||||
|
* {@code sqliteFile}-Wert der aus {@code configFilePath} geladenen Konfiguration zurück.
|
||||||
|
* <p>
|
||||||
|
* Pfad-Validierungs- und Move-Schritte verbleiben im Use-Case; der Adapter führt
|
||||||
|
* Anlage, Migration und Verbindungstest aus.
|
||||||
|
*
|
||||||
|
* @param configFilePath der aktuell in der GUI bekannte Konfigurationspfad oder
|
||||||
|
* {@code null}, wenn noch keine Konfiguration geladen wurde
|
||||||
|
* @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht {@code null} sein
|
||||||
|
* @return strukturiertes Ergebnis des Use-Cases; nie {@code null}
|
||||||
|
*/
|
||||||
|
CreateNewDatabaseUseCase.CreateNewDatabaseResult createNewDatabaseForGui(
|
||||||
|
Path configFilePath, Path targetFile) {
|
||||||
|
Objects.requireNonNull(targetFile, "targetFile must not be null");
|
||||||
|
LOG.info("GUI-Datenbankanlage: angeforderter Zielpfad = {}", targetFile);
|
||||||
|
DefaultCreateNewDatabaseUseCase.ActiveDatabasePathSupplier activeDbSupplier = () -> {
|
||||||
|
Optional<Path> override = activeDatabaseContext.activeDatabaseOverride();
|
||||||
|
if (override.isPresent()) {
|
||||||
|
return override.get();
|
||||||
|
}
|
||||||
|
if (configFilePath == null || !Files.exists(configFilePath)) {
|
||||||
|
// Ohne Konfiguration und ohne Override: Zielpfad selbst als „aktiv" werten,
|
||||||
|
// damit die Pfadprüfung nur das Selbst-Überschreiben verhindert.
|
||||||
|
return targetFile.toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
migrateConfigurationIfNeeded(configFilePath);
|
||||||
|
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
|
||||||
|
return config.sqliteFile().toAbsolutePath().normalize();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("GUI-Datenbankanlage: aktive DB konnte nicht aus {} ermittelt werden: {} — "
|
||||||
|
+ "Zielpfad wird stattdessen als aktiv angenommen.",
|
||||||
|
configFilePath, e.getMessage());
|
||||||
|
return targetFile.toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DefaultCreateNewDatabaseUseCase useCase = new DefaultCreateNewDatabaseUseCase(
|
||||||
|
databaseCreationPort, activeDatabaseContext, activeDbSupplier);
|
||||||
|
return useCase.createNewDatabase(targetFile);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
* Builds a {@link ResetDocumentStatusResult} where every requested fingerprint is
|
||||||
* recorded as a failure with the given error message.
|
* 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
|
* @param config the validated startup configuration containing the SQLite file path
|
||||||
*/
|
*/
|
||||||
private void initializeSchema(StartConfiguration config) {
|
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.
|
* Builds the JDBC URL for the SQLite database from the configured file path.
|
||||||
|
* <p>
|
||||||
|
* Diese Variante berücksichtigt {@em kein} Override – sie wird ausschließlich für
|
||||||
|
* den Headless-Pfad und für GUI-Tests aufgerufen, in denen kein
|
||||||
|
* {@link ActiveDatabaseContextPort} verfügbar ist.
|
||||||
*
|
*
|
||||||
* @param config the startup configuration containing the SQLite file path
|
* @param config the startup configuration containing the SQLite file path
|
||||||
* @return the JDBC URL in the form {@code jdbc:sqlite:/path/to/file.db}
|
* @return the JDBC URL in the form {@code jdbc:sqlite:/path/to/file.db}
|
||||||
*/
|
*/
|
||||||
static String buildJdbcUrl(StartConfiguration config) {
|
static String buildJdbcUrl(StartConfiguration config) {
|
||||||
return "jdbc:sqlite:" + config.sqliteFile().toAbsolutePath().toString().replace('\\', '/');
|
return buildSqliteJdbcUrl(config.sqliteFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die JDBC-URL der zur Laufzeit aktiven SQLite-Datenbank.
|
||||||
|
* <p>
|
||||||
|
* Hat der {@link ActiveDatabaseContextPort} einen Override gesetzt (z. B. nachdem
|
||||||
|
* der Benutzer im GUI eine neue Datenbank angelegt hat), wird dieser verwendet.
|
||||||
|
* Andernfalls greift der Wert aus der übergebenen {@link StartConfiguration}.
|
||||||
|
*
|
||||||
|
* @param config die geladene Startup-Konfiguration; nie {@code null}
|
||||||
|
* @return die JDBC-URL der aktiven SQLite-Datei
|
||||||
|
*/
|
||||||
|
String resolveActiveJdbcUrl(StartConfiguration config) {
|
||||||
|
Optional<Path> override = activeDatabaseContext.activeDatabaseOverride();
|
||||||
|
if (override.isPresent()) {
|
||||||
|
return buildSqliteJdbcUrl(override.get());
|
||||||
|
}
|
||||||
|
return buildSqliteJdbcUrl(config.sqliteFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Pfad der zur Laufzeit aktiven SQLite-Datei.
|
||||||
|
*
|
||||||
|
* @param config die geladene Startup-Konfiguration; nie {@code null}
|
||||||
|
* @return der absolute Pfad der aktuell aktiven Datenbankdatei
|
||||||
|
*/
|
||||||
|
Path resolveActiveDatabasePath(StartConfiguration config) {
|
||||||
|
return activeDatabaseContext.activeDatabaseOverride()
|
||||||
|
.orElseGet(() -> config.sqliteFile().toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildSqliteJdbcUrl(Path sqliteFile) {
|
||||||
|
return "jdbc:sqlite:" + sqliteFile.toAbsolutePath().toString().replace('\\', '/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap-interne Implementierung des {@link ActiveDatabaseContextPort}.
|
||||||
|
* <p>
|
||||||
|
* Hält einen prozessweiten, threadsicheren Override-Pfad, der in der Bootstrap-Schicht
|
||||||
|
* Vorrang vor dem in der {@code .properties}-Datei konfigurierten SQLite-Pfad hat.
|
||||||
|
* Solange kein Override gesetzt ist, verhält sich die Anwendung wie bisher: alle
|
||||||
|
* SQLite-Adapter werden mit der aus der jeweils geladenen Konfigurationsdatei
|
||||||
|
* abgeleiteten JDBC-URL verdrahtet.
|
||||||
|
* <p>
|
||||||
|
* Setzt der Use-Case
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultCreateNewDatabaseUseCase}
|
||||||
|
* den Override über {@link #switchActiveDatabase(Path)}, verwenden alle nachfolgenden
|
||||||
|
* GUI-DB-Operationen (History, Reset, Löschen, Verarbeitungsläufe) die hinterlegte
|
||||||
|
* Datei – auch wenn der Benutzer die Konfigurationsdatei noch nicht gespeichert hat.
|
||||||
|
*
|
||||||
|
* <h2>Lebensdauer</h2>
|
||||||
|
* <p>Die gehaltene Referenz lebt für die Dauer des laufenden GUI-Prozesses. Sie ist
|
||||||
|
* nicht persistent: nach einem Neustart greift wieder der Wert aus der
|
||||||
|
* Konfigurationsdatei.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>Alle Lese- und Schreibzugriffe gehen über eine {@link AtomicReference}; die Klasse
|
||||||
|
* ist daher uneingeschränkt threadsicher.
|
||||||
|
*/
|
||||||
|
public class SqliteActiveDatabaseContextAdapter implements ActiveDatabaseContextPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(SqliteActiveDatabaseContextAdapter.class);
|
||||||
|
|
||||||
|
private final AtomicReference<Path> override = new AtomicReference<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardkonstruktor.
|
||||||
|
*/
|
||||||
|
public SqliteActiveDatabaseContextAdapter() {
|
||||||
|
// kein Zustand außer der AtomicReference
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Override-Pfad auf die übergebene Datei. Nachfolgende DB-Operationen
|
||||||
|
* verwenden diese Datei, sofern sie {@link #activeDatabaseOverride()} abfragen,
|
||||||
|
* statt direkt die Konfigurationsdatei zu konsultieren.
|
||||||
|
*
|
||||||
|
* @param newDbFile absoluter Pfad der neuen aktiven Datenbankdatei; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code newDbFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void switchActiveDatabase(Path newDbFile) {
|
||||||
|
Objects.requireNonNull(newDbFile, "newDbFile darf nicht null sein");
|
||||||
|
Path absolute = newDbFile.toAbsolutePath().normalize();
|
||||||
|
Path previous = override.getAndSet(absolute);
|
||||||
|
if (previous == null) {
|
||||||
|
LOG.info("Aktive SQLite-Datenbank wurde umgestellt auf: {}", absolute);
|
||||||
|
} else {
|
||||||
|
LOG.info("Aktive SQLite-Datenbank wurde von {} auf {} umgestellt.",
|
||||||
|
previous, absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den aktuell gesetzten Override, falls vorhanden.
|
||||||
|
*
|
||||||
|
* @return der Override-Pfad oder {@link Optional#empty()}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<Path> activeDatabaseOverride() {
|
||||||
|
return Optional.ofNullable(override.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt den Override (für Tests oder kontrolliertes Zurücksetzen). Nach dem
|
||||||
|
* Aufruf greift wieder der Wert aus der konfigurierten {@code .properties}-Datei.
|
||||||
|
*/
|
||||||
|
public void clearOverride() {
|
||||||
|
Path previous = override.getAndSet(null);
|
||||||
|
if (previous != null) {
|
||||||
|
LOG.info("Aktiver SQLite-Datenbank-Override wurde entfernt (zuletzt: {}).", previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link SqliteActiveDatabaseContextAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Prüft das Setzen, Auslesen und Zurücksetzen des Override-Pfads sowie die Pflicht-Prüfung
|
||||||
|
* gegen {@code null}-Argumente.
|
||||||
|
*/
|
||||||
|
class SqliteActiveDatabaseContextAdapterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeDatabaseOverride_returnsEmpty_initially() {
|
||||||
|
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
|
||||||
|
assertThat(adapter.activeDatabaseOverride()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void switchActiveDatabase_setsAbsoluteOverride() {
|
||||||
|
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
|
||||||
|
Path target = Path.of("data/foo.sqlite");
|
||||||
|
|
||||||
|
adapter.switchActiveDatabase(target);
|
||||||
|
|
||||||
|
Optional<Path> override = adapter.activeDatabaseOverride();
|
||||||
|
assertThat(override).isPresent();
|
||||||
|
assertThat(override.get()).isEqualTo(target.toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void switchActiveDatabase_replacesPreviousOverride() {
|
||||||
|
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
|
||||||
|
adapter.switchActiveDatabase(Path.of("first.sqlite"));
|
||||||
|
adapter.switchActiveDatabase(Path.of("second.sqlite"));
|
||||||
|
|
||||||
|
assertThat(adapter.activeDatabaseOverride())
|
||||||
|
.isPresent()
|
||||||
|
.get()
|
||||||
|
.isEqualTo(Path.of("second.sqlite").toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearOverride_removesPreviouslySetOverride() {
|
||||||
|
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
|
||||||
|
adapter.switchActiveDatabase(Path.of("foo.sqlite"));
|
||||||
|
|
||||||
|
adapter.clearOverride();
|
||||||
|
|
||||||
|
assertThat(adapter.activeDatabaseOverride()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void switchActiveDatabase_rejectsNull() {
|
||||||
|
SqliteActiveDatabaseContextAdapter adapter = new SqliteActiveDatabaseContextAdapter();
|
||||||
|
assertThatThrownBy(() -> adapter.switchActiveDatabase(null))
|
||||||
|
.isInstanceOf(NullPointerException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user