mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-11 11:45:27 +02:00
Make DBController work on one database file per instance
The controller was a static singleton: an eagerly created static instance opened the root database during class initialization, and both the connection and the path were static fields. A second operator profile in the same process was therefore impossible, and ChatController's own "new DBController()" never opened anything - it silently adopted the eagerly opened root connection. - drop the eager static instance in favour of a lazily created default instance - turn connection and path into instance state - add a constructor taking a database file name relative to the application directory plus a flag whether a missing file is seeded from the bundled template - create additional profile databases empty instead of seeding them: the bundled template carries 3452 foreign callsigns and user_version 0, which would show a new operator foreign data and trigger the full callsign normalization rebuild. The schema is created by the existing table setup. - remember the shutdown hook so closeDBConnection can deregister it; otherwise every profile switch would leave another hook holding a dead connection All SQL statements keep referencing the plain field and are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
This commit is contained in:
@@ -6,9 +6,13 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ChatMember;
|
||||
@@ -52,34 +56,128 @@ public class DBController {
|
||||
*/
|
||||
private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L;
|
||||
|
||||
private static final DBController dbcontroller = new DBController();
|
||||
private static Connection connection;
|
||||
private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
||||
/**
|
||||
* Lazily created controller for the root installation database. It is created on
|
||||
* first use only, because an eagerly created instance would open a database file
|
||||
* before the application knows which operator profile is active.
|
||||
*/
|
||||
private static volatile DBController defaultInstance;
|
||||
|
||||
private Connection connection;
|
||||
|
||||
/**
|
||||
* File name of this database relative to the application directory, for example
|
||||
* "praktiKST.db" or "profiles/OP2/praktiKST.db".
|
||||
*/
|
||||
private final String databaseRelativeFileName;
|
||||
|
||||
/**
|
||||
* Absolute path of the database file, resolved once during construction.
|
||||
*/
|
||||
private final String databaseFilePath;
|
||||
|
||||
/**
|
||||
* True if a missing database file should be seeded from the shipped template.
|
||||
*/
|
||||
private final boolean seedFromResource;
|
||||
|
||||
/**
|
||||
* Shutdown hook of this instance. It is remembered so it can be deregistered when
|
||||
* the connection is closed. Without that, every operator profile switch would leave
|
||||
* another hook behind that keeps a dead connection alive until the process ends.
|
||||
*/
|
||||
private Thread databaseShutdownHook;
|
||||
|
||||
/**
|
||||
* Remembers the last timestamp at which the expiration cleanup had been executed.
|
||||
*/
|
||||
private long lastExpirationCleanupExecutionEpochMs = 0L;
|
||||
|
||||
/**
|
||||
* Creates a controller for the worked-station database of the root installation.
|
||||
*/
|
||||
public DBController() {
|
||||
initDBConnection();
|
||||
}
|
||||
|
||||
public static DBController getInstance() {
|
||||
return dbcontroller;
|
||||
this(DATABASE_FILE, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection if it is still open.
|
||||
* Creates a controller for the worked-station database of one operator profile.
|
||||
*
|
||||
* @param databaseRelativeFileName file name relative to the application directory
|
||||
* @param seedFromResource true to copy the shipped template database when the file
|
||||
* does not exist yet, false to create an empty database and
|
||||
* let the schema creation build all required tables
|
||||
*/
|
||||
public DBController(final String databaseRelativeFileName, final boolean seedFromResource) {
|
||||
this.databaseRelativeFileName =
|
||||
Objects.requireNonNull(databaseRelativeFileName, "databaseRelativeFileName");
|
||||
this.seedFromResource = seedFromResource;
|
||||
this.databaseFilePath = ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
databaseRelativeFileName
|
||||
);
|
||||
|
||||
initDBConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a controller for the root installation database, creating it on first use.
|
||||
*
|
||||
* @return the shared controller for the root installation database
|
||||
*/
|
||||
public static synchronized DBController getInstance() {
|
||||
|
||||
if (defaultInstance == null) {
|
||||
defaultInstance = new DBController();
|
||||
}
|
||||
|
||||
return defaultInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path of the database file this controller works on.
|
||||
*
|
||||
* @return absolute database file path
|
||||
*/
|
||||
public String getDatabaseFilePath() {
|
||||
return databaseFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection if it is still open and deregisters the shutdown
|
||||
* hook of this instance.
|
||||
*/
|
||||
public synchronized void closeDBConnection() {
|
||||
|
||||
closeConnectionQuietly();
|
||||
|
||||
if (databaseShutdownHook != null) {
|
||||
try {
|
||||
Runtime.getRuntime().removeShutdownHook(databaseShutdownHook);
|
||||
} catch (IllegalStateException shutdownAlreadyInProgress) {
|
||||
// Expected while the JVM is shutting down; the hook is running anyway.
|
||||
}
|
||||
|
||||
databaseShutdownHook = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the connection without touching the shutdown hook. This is also the body of
|
||||
* the shutdown hook itself.
|
||||
*/
|
||||
private synchronized void closeConnectionQuietly() {
|
||||
|
||||
try {
|
||||
if (connection != null && !connection.isClosed()) {
|
||||
connection.close();
|
||||
System.out.println("Connection to Database closed: " + databaseFilePath);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
connection = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,22 +189,17 @@ public class DBController {
|
||||
System.out.println("DBH: initiate new db connection");
|
||||
|
||||
try {
|
||||
ApplicationFileUtils.copyResourceIfRequired(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DATABASE_RESOURCE,
|
||||
DATABASE_FILE
|
||||
);
|
||||
|
||||
if (connection != null && !connection.isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
prepareDatabaseFile();
|
||||
|
||||
System.out.println("Creating Connection to Database...");
|
||||
|
||||
DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
||||
connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH);
|
||||
connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath);
|
||||
|
||||
System.out.println("[DBH, Info]: Path = " + DB_PATH);
|
||||
System.out.println("[DBH, Info]: Path = " + databaseFilePath);
|
||||
|
||||
if (!connection.isClosed()) {
|
||||
System.out.println("...Connection established");
|
||||
@@ -115,25 +208,50 @@ public class DBController {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread() {
|
||||
public void run() {
|
||||
try {
|
||||
if (connection != null && !connection.isClosed()) {
|
||||
connection.close();
|
||||
|
||||
if (connection.isClosed()) {
|
||||
System.out.println("Connection to Database closed");
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
databaseShutdownHook = new Thread(this::closeConnectionQuietly,
|
||||
"DBController-shutdown-" + databaseRelativeFileName);
|
||||
Runtime.getRuntime().addShutdownHook(databaseShutdownHook);
|
||||
|
||||
ensureChatMemberTableCompatibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure the database file can be opened.
|
||||
*
|
||||
* <p>The database of the root installation is seeded from the shipped template so
|
||||
* existing installations keep their historic content. A database that belongs to an
|
||||
* additional operator profile is created empty on purpose: the shipped template
|
||||
* carries several thousand foreign callsigns and an outdated schema version, which
|
||||
* would present a new operator with foreign data and trigger the full callsign
|
||||
* normalization rebuild. The required tables are created by
|
||||
* {@link #ensureChatMemberTableCompatibility()} in both cases.</p>
|
||||
*/
|
||||
private synchronized void prepareDatabaseFile() {
|
||||
|
||||
if (seedFromResource) {
|
||||
ApplicationFileUtils.copyResourceIfRequired(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DATABASE_RESOURCE,
|
||||
databaseRelativeFileName
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Path parentDirectory = Path.of(databaseFilePath).getParent();
|
||||
|
||||
if (parentDirectory == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.createDirectories(parentDirectory);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(
|
||||
"[DBH, ERROR:] Could not create database directory " + parentDirectory, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the ChatMember table exists, that all required columns are
|
||||
* available for newer software versions, that existing old callsign keys are
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.ChatMember;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies that one DBController instance works on exactly one database file, so two
|
||||
* operator profiles can keep independent worked-station data inside the same process.
|
||||
*/
|
||||
class DBControllerProfileDatabaseTest {
|
||||
|
||||
private static final String USER_HOME_PROPERTY = "user.home";
|
||||
|
||||
@TempDir
|
||||
Path temporaryHomeDirectory;
|
||||
|
||||
private String originalUserHome;
|
||||
|
||||
@BeforeEach
|
||||
void redirectUserHomeToTemporaryDirectory() {
|
||||
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
|
||||
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreUserHome() {
|
||||
if (originalUserHome == null) {
|
||||
System.clearProperty(USER_HOME_PROPERTY);
|
||||
} else {
|
||||
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileDatabaseIsCreatedEmptyAndKeepsWorkedDataSeparate() throws SQLException {
|
||||
|
||||
DBController firstProfileDatabase =
|
||||
new DBController("profiles/OP1/praktiKST.db", false);
|
||||
DBController secondProfileDatabase =
|
||||
new DBController("profiles/OP2/praktiKST.db", false);
|
||||
|
||||
try {
|
||||
// A profile database must not inherit the several thousand callsigns of the
|
||||
// bundled template database.
|
||||
assertTrue(firstProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
|
||||
assertTrue(secondProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
|
||||
|
||||
assertNotEquals(firstProfileDatabase.getDatabaseFilePath(),
|
||||
secondProfileDatabase.getDatabaseFilePath());
|
||||
assertTrue(Files.exists(Path.of(firstProfileDatabase.getDatabaseFilePath())));
|
||||
assertTrue(Files.exists(Path.of(secondProfileDatabase.getDatabaseFilePath())));
|
||||
|
||||
ChatMember workedOnFirstProfile = new ChatMember();
|
||||
workedOnFirstProfile.setCallSign("DL0XYZ");
|
||||
workedOnFirstProfile.setQra("JO51IJ");
|
||||
workedOnFirstProfile.setWorked(true);
|
||||
workedOnFirstProfile.setWorked144(true);
|
||||
|
||||
firstProfileDatabase.storeChatMember(workedOnFirstProfile);
|
||||
|
||||
Map<String, ChatMember> firstProfileContent =
|
||||
firstProfileDatabase.fetchChatMemberWkdDataFromDB();
|
||||
Map<String, ChatMember> secondProfileContent =
|
||||
secondProfileDatabase.fetchChatMemberWkdDataFromDB();
|
||||
|
||||
assertEquals(1, firstProfileContent.size());
|
||||
assertTrue(firstProfileContent.get("DL0XYZ").isWorked144());
|
||||
assertTrue(secondProfileContent.isEmpty(),
|
||||
"A worked station of one profile must not appear in the other profile");
|
||||
} finally {
|
||||
firstProfileDatabase.closeDBConnection();
|
||||
secondProfileDatabase.closeDBConnection();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoProfilesPointingAtTheSameFileShareTheirWorkedData() throws SQLException {
|
||||
|
||||
DBController sharedStationDatabase = new DBController("praktiKST.db", false);
|
||||
DBController sameSharedDatabaseAgain = new DBController("praktiKST.db", false);
|
||||
|
||||
try {
|
||||
ChatMember workedAtTheStation = new ChatMember();
|
||||
workedAtTheStation.setCallSign("DL0ABC");
|
||||
workedAtTheStation.setWorked(true);
|
||||
workedAtTheStation.setWorked432(true);
|
||||
|
||||
sharedStationDatabase.storeChatMember(workedAtTheStation);
|
||||
|
||||
Map<String, ChatMember> seenByTheOtherOperator =
|
||||
sameSharedDatabaseAgain.fetchChatMemberWkdDataFromDB();
|
||||
|
||||
assertTrue(seenByTheOtherOperator.containsKey("DL0ABC"),
|
||||
"Operators sharing one station database must see the same worked stations");
|
||||
assertTrue(seenByTheOtherOperator.get("DL0ABC").isWorked432());
|
||||
} finally {
|
||||
sharedStationDatabase.closeDBConnection();
|
||||
sameSharedDatabaseAgain.closeDBConnection();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingTheConnectionDeregistersTheShutdownHook() {
|
||||
|
||||
DBController profileDatabase = new DBController("profiles/OP3/praktiKST.db", false);
|
||||
profileDatabase.closeDBConnection();
|
||||
|
||||
// A second close must stay harmless, and the hook must already be gone.
|
||||
profileDatabase.closeDBConnection();
|
||||
|
||||
assertFalse(Files.notExists(Path.of(profileDatabase.getDatabaseFilePath())),
|
||||
"The database file stays on disk after the connection was closed");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user