Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,28 @@ public void severe(String string) {
}
};

boolean ok = this.mysql.connect(config);
boolean ok = this.mysql.connect(config, false);
if (!ok) {
logSevere("Failed to connect to database. host=" + config.getHostName() + " db=" + config.getDatabase());
IllegalStateException failure = new IllegalStateException("Failed to initialize SQL connection pool",
this.mysql.getLastConnectFailure());
try { mysql.disconnect(); }
catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); }
throw failure;
}

// Keep the old behavior: USE db for MySQL/MariaDB
if (this.dbType == DbType.MYSQL || this.dbType == DbType.MARIADB) {
try {
new Query(mysql, "USE `" + config.getDatabase() + "`;").executeUpdate();
} catch (SQLException e) {
logSevere("Failed to send USE database query: " + config.getDatabase()
+ " (DB may still work depending on JDBC URL). Error: " + e.getMessage());
debug(e);
// Query.executeUpdate retains legacy logging/zero-result behavior on SQL
// failure. Constructor setup needs a checked path without subclass hooks.
try (Connection connection = mysql.getConnectionManager().getConnectionChecked();
PreparedStatement statement = connection.prepareStatement("USE " + qi(config.getDatabase()) + ";")) {
statement.executeUpdate();
} catch (SQLException | RuntimeException | Error failure) {
try { mysql.disconnect(); }
catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); }
if (failure instanceof RuntimeException unchecked) { throw unchecked; }
if (failure instanceof Error fatal) { throw fatal; }
throw new IllegalStateException("Failed to initialize SQL database selection", failure);
Comment on lines +148 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rethrow unchecked database-selection failures unchanged

If a JDBC driver throws a RuntimeException or the JVM raises an Error while preparing or executing USE, this catch converts it to IllegalStateException; previously those unchecked failures propagated unchanged, and wrapping an Error can also defeat fatal-error handling. Perform cleanup but rethrow the original unchecked failure, reserving the initialization wrapper for the checked SQLException path.

AGENTS.md reference: AGENTS.md:L44-L46

Useful? React with 👍 / 👎.

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ public class ConnectionManager {

@Getter
private volatile HikariDataSource dataSource;
/** The original initialization failure from the most recent open attempt. */
private volatile Exception lastOpenFailure;

public Exception getLastOpenFailure() {
return lastOpenFailure;
}

@Getter
@Setter
Expand Down Expand Up @@ -193,7 +199,7 @@ public Connection getConnectionChecked() throws SQLException {
HikariDataSource current;
synchronized (this) {
if (isClosed() && !open()) {
throw new SQLException("Unable to initialize SQL connection pool");
throw new SQLException("Unable to initialize SQL connection pool", lastOpenFailure);
}
current = dataSource;
}
Expand Down Expand Up @@ -384,6 +390,7 @@ private void rejectConflictingPostgreSqlTlsOptions(String extra) {
// --- Pool Configuration ---

public synchronized boolean open() {
lastOpenFailure = null;
try {
// If someone only set legacy flag but not dbType explicitly, keep it consistent
if (dbType == null) {
Expand Down Expand Up @@ -438,7 +445,7 @@ public synchronized boolean open() {
replaceDataSource(replacement);
return true;
} catch (Exception e) {
e.printStackTrace();
lastOpenFailure = e;
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public abstract class MySQL {
private ConnectionManager connectionManager;
private int maxConnections;
private ExecutorService threadPool;
private volatile Exception lastConnectFailure;

public Exception getLastConnectFailure() {
return lastConnectFailure;
}

/**
* Create a new MySQL object with a default of 10 maximum threads.
Expand All @@ -31,6 +36,12 @@ public MySQL(int maxConnections) {
}

public boolean connect(MysqlConfig config) {
return connect(config, true);
}

/** Connect without subclass diagnostics when called by a superclass constructor. */
public boolean connect(MysqlConfig config, boolean emitDiagnostics) {
lastConnectFailure = null;
// (Re)size thread pool to match MaxConnections from config
this.maxConnections = Math.max(1, config.getMaxThreads());
if (threadPool != null) {
Expand Down Expand Up @@ -89,9 +100,13 @@ public boolean connect(MysqlConfig config) {
}

boolean ok = connectionManager.open();
if (!ok) {
lastConnectFailure = connectionManager.getLastOpenFailure();
// Legacy callers can retry through the connection manager. The owner
// disconnects explicitly when construction is aborted instead.
}

// Optional debug output
if (config.isDebug()) {
if (emitDiagnostics && config.isDebug()) {
if (ok) {
debug("DB connected. type=" + connectionManager.getDbType() + " host=" + config.getHostName() + " db="
+ config.getDatabase() + " maxPool=" + maxConnections);
Expand All @@ -100,7 +115,6 @@ public boolean connect(MysqlConfig config) {
+ " Check host/port/credentials and timeouts.");
}
}

return ok;
}

Expand All @@ -112,12 +126,17 @@ public boolean connect(MysqlConfig config) {
* Close all connections and the data source.
*/
public void disconnect() {
if (connectionManager != null) {
connectionManager.close();
}
if (threadPool != null) {
threadPool.shutdown();
}
try {
if (connectionManager != null) connectionManager.close();
} catch (RuntimeException | Error failure) {
// Pool cleanup and worker retirement are independent responsibilities.
try { if (threadPool != null) threadPool.shutdown(); }
catch (RuntimeException | Error cleanup) {
if (cleanup != failure) failure.addSuppressed(cleanup);
}
throw failure;
}
if (threadPool != null) threadPool.shutdown();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package com.bencodez.simpleapi.sql.mysql;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverPropertyInfo;
import java.sql.SQLException;
import java.util.Properties;
import java.util.logging.Logger;

import org.junit.jupiter.api.Test;

import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;

class AbstractSqlTableInitializationTest {
@Test
void deferredExistingPoolConstructorDoesNotNeedSubclassState() {
MySQL mysql = mock(MySQL.class);
ConnectionManager manager = mock(ConnectionManager.class);
when(mysql.getConnectionManager()).thenReturn(manager);
when(manager.getDbType()).thenReturn(DbType.MYSQL);
DeferredTable table = new DeferredTable("votes", mysql);
assertFalse(table.initialized);
table.initialized = true;
assertEquals("votes", table.getTableName());
}
@Test
void authenticationFailureStopsConstructorBeforeSubclassHooksOrQueries() {
RejectingTable.hookCalls = 0;
RejectingDriver.connectionAttempts = 0;
MysqlConfig config = config();
config.setDriver(RejectingDriver.class.getName());

IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> new RejectingTable("votes", config, true));
assertTrue(hasCause(failure, AUTH_FAILURE));
assertEquals(0, RejectingTable.hookCalls);
assertTrue(RejectingDriver.connectionAttempts > 0);
}

@Test void databaseSelectionFailureClosesPoolWithoutCallingSubclassHooks() throws Exception {
SQLException denied = new SQLException("controlled database selection rejected", "42000");
RejectingTable.hookCalls = 0;
try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> {
when(manager.open()).thenReturn(true);
Connection connection = mock(Connection.class);
when(manager.getConnectionChecked()).thenReturn(connection);
when(connection.prepareStatement(anyString())).thenThrow(denied);
})) {
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> new RejectingTable("votes", config(), true));
assertSame(denied, failure.getCause());
assertEquals(0, RejectingTable.hookCalls);
verify(managers.constructed().get(0)).close();
}
}

@Test void uncheckedDatabaseSelectionFailuresKeepIdentityAndCleanupFailures() throws Exception {
for (Throwable failure : new Throwable[] {new IllegalArgumentException("driver rejected selection"),
new LinkageError("driver linkage failure")}) {
RuntimeException cleanupFailure = new IllegalStateException("controlled cleanup failure");
RejectingTable.hookCalls = 0;
try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> {
when(manager.open()).thenReturn(true);
Connection connection = mock(Connection.class);
when(manager.getConnectionChecked()).thenReturn(connection);
when(connection.prepareStatement(anyString())).thenThrow(failure);
doThrow(cleanupFailure).when(manager).close();
})) {
Throwable thrown = assertThrows(failure.getClass(),
() -> new RejectingTable("votes", config(), true));
assertSame(failure, thrown);
assertArrayEquals(new Throwable[] {cleanupFailure}, thrown.getSuppressed());
assertEquals(0, RejectingTable.hookCalls);
verify(managers.constructed().get(0)).close();
verify(managers.constructed().get(0).getConnectionChecked()).close();
}
}
}

@Test void databaseSelectionEscapesQuotedDatabaseNames() throws Exception {
MysqlConfig config = config();
config.setDatabase("vote`data");
try (var managers = mockConstruction(ConnectionManager.class, (manager, context) -> {
when(manager.open()).thenReturn(true);
Connection connection = mock(Connection.class);
when(manager.getConnectionChecked()).thenReturn(connection);
when(connection.prepareStatement("USE `vote``data`;"))
.thenReturn(mock(java.sql.PreparedStatement.class));
})) {
AbstractSqlTable table = new AbstractSqlTable("votes", config, true, true) {
public String getPrimaryKeyColumn() { return "id"; }
public String buildCreateTableSql(DbType type) { return "CREATE TABLE votes (id TEXT)"; }
public void logSevere(String message) { fail("early hook"); }
public void logInfo(String message) { fail("early hook"); }
public void debug(Throwable failure) { fail("early hook"); }
public void debug(String message) { fail("early hook"); }
};
verify(managers.constructed().get(0).getConnectionChecked())
.prepareStatement("USE `vote``data`;");
table.close();
}
}

private static MysqlConfig config() {
MysqlConfig config = new MysqlConfig();
config.setHostName("127.0.0.1");
config.setPort(3306);
config.setUser("user");
config.setPass("password");
config.setDatabase("database");
config.setMinimumIdle(0);
config.setMaxThreads(1);
config.setConnectionTimeout(1000);
return config;
}

private static boolean hasCause(Throwable failure, String message) {
for (Throwable current = failure; current != null; current = current.getCause()) {
if (message.equals(current.getMessage())) return true;
}
return false;
}

private static final String AUTH_FAILURE = "controlled authentication rejected";

private static final class DeferredTable extends AbstractSqlTable {
boolean initialized;
DeferredTable(String name, MySQL mysql) { super(name, mysql, true); }
public String getPrimaryKeyColumn() { return "id"; }
public String buildCreateTableSql(DbType type) { return "CREATE TABLE votes (id TEXT)"; }
public void logSevere(String message) { if (!initialized) fail("early hook"); }
public void logInfo(String message) { if (!initialized) fail("early hook"); }
public void debug(Throwable failure) { if (!initialized) fail("early hook"); }
public void debug(String message) { if (!initialized) fail("early hook"); }
}

private static final class RejectingTable extends AbstractSqlTable {
static int hookCalls;

RejectingTable(String name, MysqlConfig config, boolean debug) {
super(name, config, debug);
}

public String getPrimaryKeyColumn() { return "id"; }
public String buildCreateTableSql(DbType type) { hookCalls++; return "CREATE TABLE votes (id TEXT)"; }
public void logSevere(String message) { hookCalls++; }
public void logInfo(String message) { hookCalls++; }
public void debug(Throwable failure) { hookCalls++; }
public void debug(String message) { hookCalls++; }
}

public static final class RejectingDriver implements Driver {
static int connectionAttempts;

public Connection connect(String url, Properties properties) throws SQLException {
connectionAttempts++;
throw new SQLException(AUTH_FAILURE);
}
public boolean acceptsURL(String url) { return url != null && url.startsWith("jdbc:"); }
public DriverPropertyInfo[] getPropertyInfo(String url, Properties properties) { return new DriverPropertyInfo[0]; }
public int getMajorVersion() { return 1; }
public int getMinorVersion() { return 0; }
public boolean jdbcCompliant() { return false; }
public Logger getParentLogger() { return Logger.getGlobal(); }
}
}
Loading
Loading