Used inside {@link Migration#createTable} callbacks. Every column name
+ * is validated by {@link SqlIdentifier#requireIdentifier} before being
+ * interpolated into DDL.
+ */
+public class Blueprint {
+
+ private final List columns;
+ private final List constraints;
+ private final DatabaseType dbType;
+
+ /**
+ * Creates a Blueprint with separate column and constraint lists.
+ *
+ * @param columns column definitions list (mutated in place)
+ * @param constraints constraint definitions list (mutated in place)
+ * @param dbType target database type
+ */
+ public Blueprint(List columns, List constraints, DatabaseType dbType) {
+ this.columns = columns;
+ this.constraints = constraints;
+ this.dbType = dbType;
+ }
+
+ /**
+ * Creates a Blueprint without a constraint list.
+ *
+ * @param columns column definitions list
+ * @param dbType target database type
+ */
+ public Blueprint(List columns, DatabaseType dbType) {
+ this(columns, new ArrayList<>(), dbType);
+ }
+
+ // ─── INTERNAL ────────────────────────────────────────────
+
+ private Blueprint col(String name, String type) {
+ SqlIdentifier.requireIdentifier(name);
+ columns.add(name + " " + type);
+ return this;
+ }
+
+ private Blueprint fk(String refTable, String refColumn, String colName) {
+ SqlIdentifier.requireIdentifier(refTable);
+ SqlIdentifier.requireIdentifier(refColumn);
+ constraints.add("FOREIGN KEY (" + colName + ") REFERENCES " + refTable + "(" + refColumn + ")");
+ return this;
+ }
+
+ private void modifyLast(String suffix) {
+ if (!columns.isEmpty()) {
+ int i = columns.size() - 1;
+ columns.set(i, columns.get(i) + suffix);
+ }
+ }
+
+ private void modifyLastConstraint(String suffix) {
+ if (!constraints.isEmpty()) {
+ int i = constraints.size() - 1;
+ constraints.set(i, constraints.get(i) + suffix);
+ }
+ }
+
+ // ─── PRIMARY KEY ─────────────────────────────────────────
+
+ /** Adds an auto-increment primary key column named id. */
+ public Blueprint id() { return id("id"); }
+
+ /**
+ * Adds an auto-increment primary key column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint id(String name) {
+ SqlIdentifier.requireIdentifier(name);
+ String type = switch (dbType) {
+ case MYSQL -> "INT AUTO_INCREMENT PRIMARY KEY";
+ case POSTGRESQL -> "SERIAL PRIMARY KEY";
+ default -> "INTEGER PRIMARY KEY AUTOINCREMENT";
+ };
+ columns.add(name + " " + type);
+ return this;
+ }
+
+ /**
+ * Adds a UUID column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint uuid(String name) { return col(name, "VARCHAR(36)"); }
+
+ // ─── STRING / TEXT ───────────────────────────────────────
+
+ /**
+ * Adds a VARCHAR(255) / TEXT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint string(String name) { return string(name, 255); }
+
+ /**
+ * Adds a VARCHAR(length) / TEXT column.
+ *
+ * @param name column name
+ * @param length max length
+ * @return this blueprint
+ */
+ public Blueprint string(String name, int length) {
+ return col(name, dbType == DatabaseType.SQLITE ? "TEXT" : "VARCHAR(" + length + ")");
+ }
+
+ /**
+ * Adds a TEXT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint text(String name) { return col(name, "TEXT"); }
+
+ /**
+ * Adds a MEDIUMTEXT / TEXT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint mediumText(String name) {
+ return col(name, dbType == DatabaseType.MYSQL ? "MEDIUMTEXT" : "TEXT");
+ }
+
+ /**
+ * Adds a LONGTEXT / TEXT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint longText(String name) {
+ return col(name, dbType == DatabaseType.MYSQL ? "LONGTEXT" : "TEXT");
+ }
+
+ // ─── NUMERIC ─────────────────────────────────────────────
+
+ /**
+ * Adds an INT / INTEGER column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint integer(String name) {
+ return col(name, dbType == DatabaseType.POSTGRESQL ? "INTEGER" : "INT");
+ }
+
+ /**
+ * Adds a TINYINT / INTEGER column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint tinyInteger(String name) {
+ return col(name, dbType == DatabaseType.SQLITE ? "INTEGER" : "TINYINT");
+ }
+
+ /**
+ * Adds a SMALLINT / INTEGER column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint smallInteger(String name) {
+ return col(name, dbType == DatabaseType.SQLITE ? "INTEGER" : "SMALLINT");
+ }
+
+ /**
+ * Adds a BIGINT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint bigInteger(String name) { return col(name, "BIGINT"); }
+
+ /**
+ * Adds a DECIMAL(precision, scale) column.
+ *
+ * @param name column name
+ * @param precision total digits
+ * @param scale digits after the decimal point
+ * @return this blueprint
+ */
+ public Blueprint decimal(String name, int precision, int scale) {
+ return col(name, "DECIMAL(" + precision + "," + scale + ")");
+ }
+
+ /**
+ * Adds a DECIMAL(10,2) column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint decimal(String name) { return decimal(name, 10, 2); }
+
+ /**
+ * Adds a FLOAT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint floatCol(String name) { return col(name, "FLOAT"); }
+
+ /**
+ * Adds a DOUBLE column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint doubleCol(String name) { return col(name, "DOUBLE"); }
+
+ // ─── BOOLEAN ─────────────────────────────────────────────
+
+ /**
+ * Adds a BOOLEAN / INTEGER column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint bool(String name) {
+ return col(name, dbType == DatabaseType.SQLITE ? "INTEGER" : "BOOLEAN");
+ }
+
+ // ─── DATE / TIME ─────────────────────────────────────────
+
+ /**
+ * Adds a DATE column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint date(String name) { return col(name, "DATE"); }
+
+ /**
+ * Adds a DATETIME / TIMESTAMP / TEXT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint dateTime(String name) {
+ return col(name, switch (dbType) {
+ case POSTGRESQL -> "TIMESTAMP";
+ case MYSQL -> "DATETIME";
+ default -> "TEXT";
+ });
+ }
+
+ /**
+ * Adds a TIMESTAMP column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint timestamp(String name) { return col(name, "TIMESTAMP"); }
+
+ /**
+ * Adds a TIME column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint time(String name) { return col(name, "TIME"); }
+
+ /**
+ * Adds created_at and updated_at columns with appropriate defaults.
+ *
+ * @return this blueprint
+ */
+ public Blueprint timestamps() {
+ if (dbType == DatabaseType.MYSQL) {
+ columns.add("created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP");
+ columns.add("updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP");
+ } else if (dbType == DatabaseType.POSTGRESQL) {
+ columns.add("created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP");
+ columns.add("updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP");
+ } else {
+ columns.add("created_at TEXT DEFAULT CURRENT_TIMESTAMP");
+ columns.add("updated_at TEXT DEFAULT CURRENT_TIMESTAMP");
+ }
+ return this;
+ }
+
+ /**
+ * Adds a nullable deleted_at column for soft deletes.
+ *
+ * @return this blueprint
+ */
+ public Blueprint softDeletes() {
+ dateTime("deleted_at");
+ nullable();
+ return this;
+ }
+
+ // ─── JSON / BLOB ─────────────────────────────────────────
+
+ /**
+ * Adds a JSON / TEXT column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint json(String name) {
+ return col(name, dbType == DatabaseType.SQLITE ? "TEXT" : "JSON");
+ }
+
+ /**
+ * Adds a BLOB column.
+ *
+ * @param name column name
+ * @return this blueprint
+ */
+ public Blueprint blob(String name) { return col(name, "BLOB"); }
+
+ // ─── ENUM ────────────────────────────────────────────────
+
+ /**
+ * Adds an ENUM / CHECK column.
+ *
+ * @param name column name
+ * @param values allowed enum values
+ * @return this blueprint
+ */
+ public Blueprint enumCol(String name, String... values) {
+ if (dbType == DatabaseType.MYSQL) {
+ col(name, "ENUM('" + String.join("', '", values) + "')");
+ } else {
+ col(name, "VARCHAR(50)");
+ constraints.add("CHECK (" + name + " IN ('" + String.join("', '", values) + "'))");
+ }
+ return this;
+ }
+
+ // ─── MODIFIERS ───────────────────────────────────────────
+
+ /** Appends NOT NULL to the last column. */
+ public Blueprint notNull() { modifyLast(" NOT NULL"); return this; }
+ /** Appends UNIQUE to the last column. */
+ public Blueprint unique() { modifyLast(" UNIQUE"); return this; }
+ /** Columns are nullable by default — no-op for readability. */
+ public Blueprint nullable() { return this; }
+ /** @param value default value string */
+ public Blueprint defaultValue(String value) { modifyLast(" DEFAULT " + value); return this; }
+ /** @param value default integer value */
+ public Blueprint defaultValue(int value) { modifyLast(" DEFAULT " + value); return this; }
+ /** @param value default boolean value (stored as 1 or 0) */
+ public Blueprint defaultValue(boolean value) { modifyLast(" DEFAULT " + (value ? "1" : "0")); return this; }
+
+ // ─── FOREIGN KEYS ────────────────────────────────────────
+
+ /**
+ * Adds a FOREIGN KEY constraint referencing the last column.
+ *
+ * @param refTable referenced table name
+ * @param refColumn referenced column name
+ * @return this blueprint
+ */
+ public Blueprint foreignKey(String refTable, String refColumn) {
+ if (!columns.isEmpty()) {
+ String colName = columns.get(columns.size() - 1).split("\\s+")[0];
+ fk(refTable, refColumn, colName);
+ }
+ return this;
+ }
+
+ /** Appends ON DELETE CASCADE to the last foreign key. */
+ public Blueprint cascadeOnDelete() { modifyLastConstraint(" ON DELETE CASCADE"); return this; }
+ /** Appends ON DELETE SET NULL to the last foreign key. */
+ public Blueprint nullOnDelete() { modifyLastConstraint(" ON DELETE SET NULL"); return this; }
+ /** Appends ON DELETE RESTRICT to the last foreign key. */
+ public Blueprint restrictOnDelete() { modifyLastConstraint(" ON DELETE RESTRICT"); return this; }
+ /** Appends ON UPDATE CASCADE to the last foreign key. */
+ public Blueprint cascadeOnUpdate() { modifyLastConstraint(" ON UPDATE CASCADE"); return this; }
+
+ // ─── INDEXES ─────────────────────────────────────────────
+
+ /**
+ * Adds a composite UNIQUE constraint.
+ *
+ * @param columnNames columns forming the unique index
+ * @return this blueprint
+ */
+ public Blueprint uniqueIndex(String... columnNames) {
+ for (String c : columnNames) SqlIdentifier.requireIdentifier(c);
+ constraints.add("UNIQUE (" + String.join(", ", columnNames) + ")");
+ return this;
+ }
+
+ /**
+ * Adds name_id (BIGINT NOT NULL) and name_type (VARCHAR NOT NULL) columns for polymorphic relations.
+ *
+ * @param name morph base name
+ * @return this blueprint
+ */
+ public Blueprint morphs(String name) {
+ bigInteger(name + "_id").notNull();
+ string(name + "_type").notNull();
+ return this;
+ }
+
+ // ─── ACCESSORS ───────────────────────────────────────────
+
+ /** @return column definitions list */
+ public List getColumns() { return columns; }
+
+ /** @return constraint definitions list */
+ public List getConstraints() { return constraints; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/DB.java b/src/main/java/com/obsidian/core/database/DB.java
index b0efedc..916961f 100644
--- a/src/main/java/com/obsidian/core/database/DB.java
+++ b/src/main/java/com/obsidian/core/database/DB.java
@@ -2,19 +2,59 @@
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
-import org.javalite.activejdbc.Base;
import org.slf4j.Logger;
+import java.sql.*;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import java.util.concurrent.Callable;
/**
- * Database connection manager with support for SQLite, MySQL and PostgreSQL.
- * Provides connection pooling for MySQL/PostgreSQL and transaction management.
+ * Database connection manager with support for SQLite, MySQL, and PostgreSQL.
+ *
+ *
Security notes:
+ *
+ *
The singleton field is {@code volatile} to guarantee safe publication
+ * in multi-threaded environments without a full lock on every read.
+ *
MySQL/PostgreSQL connections require SSL by default. Set
+ * {@code OBSIDIAN_DB_DISABLE_SSL=true} (env or system property) only in
+ * local dev/test. System property takes priority over env variable.
+ *
Credentials are never written to the log.
+ *
+ *
+ *
Pool tuning: explicit timeouts prevent the common failure mode
+ * where stale connections accumulate because Hikari never evicts them.
*/
public class DB
{
- /** Singleton instance */
- private static DB instance;
+ /** Volatile ensures the reference is safely visible across threads. */
+ private static volatile DB instance;
+
+ /**
+ * Thread-local connection — one JDBC connection per thread.
+ *
+ *
Leak risk in thread pools: threads in a pool are never destroyed, so
+ * {@link ThreadLocal} values survive indefinitely unless explicitly removed.
+ * A connection left in {@code threadConnection} after a request finishes holds a
+ * pooled connection open and prevents it from being returned to HikariCP.
+ *
+ *
Required usage contract — every code path that calls {@link #connect()}
+ * or {@link #getConnection()} MUST eventually call {@link #closeConnection()}, even
+ * on exception. The safe patterns are:
+ *
+ *
{@link #withConnection(Callable)} — opens and closes automatically.
+ *
{@link #withTransaction(Callable)} — same, with rollback on failure.
+ *
Servlet/request filters that call {@code DB.closeConnection()} in a
+ * {@code finally} block or via a framework lifecycle hook.
+ *
+ *
+ *
Never call {@link #getConnection()} directly in application code without one
+ * of the above wrappers. Direct calls that miss {@link #closeConnection()} will
+ * silently exhaust the pool under load.
+ */
+ private static final ThreadLocal threadConnection = new ThreadLocal<>();
/** Logger instance */
private final Logger logger;
@@ -22,247 +62,513 @@ public class DB
/** Database type */
private final DatabaseType type;
- /** Database path (for SQLite) or name (for MySQL/PostgreSQL) */
+ /** Database path (SQLite) or database name (MySQL/PostgreSQL) */
private final String dbPath;
+ /** JDBC URL for SQLite */
+ private String jdbcUrl;
+
/** Connection pool for MySQL/PostgreSQL */
private HikariDataSource pool;
+ // ─── STATIC FACTORY METHODS ──────────────────────────────
+
/**
- * Initializes SQLite database.
+ * Initialises a SQLite database.
*
- * @param path Path to SQLite database file
+ * @param path Path to the SQLite file
* @param logger Logger instance
- * @return DB instance
+ * @return The singleton DB instance
*/
public static DB initSQLite(String path, Logger logger) {
- instance = new DB(DatabaseType.SQLITE, path, null, 0, null, null, logger);
+ synchronized (DB.class) {
+ instance = new DB(DatabaseType.SQLITE, path, null, 0, null, null, logger);
+ }
return instance;
}
/**
- * Initializes MySQL database with connection pooling.
+ * Initialises a MySQL/MariaDB connection pool.
*
- * @param host Database host
- * @param port Database port
+ * @param host Database host
+ * @param port Database port
* @param database Database name
- * @param user Database user
+ * @param user Database user
* @param password Database password
- * @param logger Logger instance
- * @return DB instance
+ * @param logger Logger instance
+ * @return The singleton DB instance
*/
- public static DB initMySQL(String host, int port, String database, String user, String password, Logger logger) {
- instance = new DB(DatabaseType.MYSQL, database, host, port, user, password, logger);
+ public static DB initMySQL(String host, int port, String database,
+ String user, String password, Logger logger) {
+ synchronized (DB.class) {
+ instance = new DB(DatabaseType.MYSQL, database, host, port, user, password, logger);
+ }
return instance;
}
/**
- * Initializes PostgreSQL database with connection pooling.
+ * Initialises a PostgreSQL connection pool.
*
- * @param host Database host
- * @param port Database port
+ * @param host Database host
+ * @param port Database port
* @param database Database name
- * @param user Database user
+ * @param user Database user
* @param password Database password
- * @param logger Logger instance
- * @return DB instance
+ * @param logger Logger instance
+ * @return The singleton DB instance
*/
- public static DB initPostgreSQL(String host, int port, String database, String user, String password, Logger logger) {
- instance = new DB(DatabaseType.POSTGRESQL, database, host, port, user, password, logger);
+ public static DB initPostgreSQL(String host, int port, String database,
+ String user, String password, Logger logger) {
+ synchronized (DB.class) {
+ instance = new DB(DatabaseType.POSTGRESQL, database, host, port, user, password, logger);
+ }
return instance;
}
/**
- * Gets the singleton DB instance.
+ * Returns the singleton instance, throwing if not yet initialised.
*
- * @return DB instance
- * @throws IllegalStateException if database not initialized
+ * @return The DB instance
*/
public static DB getInstance() {
- if (instance == null) {
- throw new IllegalStateException("Database not initialized!");
+ DB db = instance; // single volatile read
+ if (db == null) {
+ throw new IllegalStateException(
+ "Database not initialised — call initSQLite / initMySQL / initPostgreSQL first.");
}
- return instance;
+ return db;
}
+ // ─── STATIC CONVENIENCE METHODS ──────────────────────────
+
/**
- * Executes a task with database connection.
- * Static convenience method.
+ * Borrows a connection for the duration of {@code task}, then returns it.
*
- * @param task Task to execute
- * @param Return type
- * @return Task result
+ * @param task The task
+ * @return The task's return value
*/
public static T withConnection(Callable task) {
return getInstance().executeWithConnection(task);
}
/**
- * Executes a task within a transaction.
- * Static convenience method.
+ * Wraps {@code task} in a database transaction, rolling back on any exception.
*
- * @param task Task to execute
- * @param Return type
- * @return Task result
+ * @param task The task
+ * @return The task's return value
*/
public static T withTransaction(Callable task) {
return getInstance().executeWithTransaction(task);
}
- /**
- * Private constructor.
- * Initializes database connection or connection pool.
- *
- * @param type Database type
- * @param database Database path/name
- * @param host Database host (null for SQLite)
- * @param port Database port (0 for SQLite)
- * @param user Database user (null for SQLite)
- * @param password Database password (null for SQLite)
- * @param logger Logger instance
- */
- private DB(DatabaseType type, String database, String host, int port, String user, String password, Logger logger)
+ // ─── CONSTRUCTOR ─────────────────────────────────────────
+
+ private DB(DatabaseType type, String database, String host, int port,
+ String user, String password, Logger logger)
{
- this.type = type;
+ this.type = type;
this.logger = logger;
this.dbPath = database;
if (type == DatabaseType.SQLITE) {
- logger.info("SQLite database initialized: " + database);
+ this.jdbcUrl = "jdbc:sqlite:" + database;
+ logger.info("SQLite database initialised: {}", database);
} else {
setupConnectionPool(type, host, port, database, user, password);
}
}
- /**
- * Sets up HikariCP connection pool for MySQL/PostgreSQL.
- *
- * @param type Database type
- * @param host Database host
- * @param port Database port
- * @param database Database name
- * @param user Database user
- * @param password Database password
- */
- private void setupConnectionPool(DatabaseType type, String host, int port, String database, String user, String password)
+ private void setupConnectionPool(DatabaseType type, String host, int port,
+ String database, String user, String password)
{
HikariConfig config = new HikariConfig();
- String url = switch (type) {
- case MYSQL -> String.format("jdbc:mysql://%s:%d/%s?useSSL=false", host, port, database);
- case POSTGRESQL -> String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
- default -> throw new IllegalArgumentException("Unsupported type: " + type);
- };
-
+ String url = buildJdbcUrl(type, host, port, database);
config.setJdbcUrl(url);
config.setUsername(user);
config.setPassword(password);
+
+ // Pool sizing
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
+ // ── Timeouts ────────────────────────────────────────
+ // How long a caller waits for a connection before an exception is thrown.
+ config.setConnectionTimeout(30_000); // 30 s
+ // How long an idle connection may sit in the pool before being evicted.
+ config.setIdleTimeout(600_000); // 10 min
+ // Maximum lifetime of any connection in the pool, regardless of activity.
+ // Must be shorter than the server's wait_timeout to avoid "Connection reset" errors.
+ config.setMaxLifetime(1_800_000); // 30 min
+ // How often Hikari probes idle connections to keep them alive.
+ config.setKeepaliveTime(60_000); // 1 min
+ // Warn if a connection is held for longer than this — catches ThreadLocal leaks
+ // where closeConnection() was never called after a request. Set to 0 to disable.
+ config.setLeakDetectionThreshold(5_000); // 5 s
+
+ config.setAutoCommit(true);
+ config.setPoolName("ObsidianDB-" + type.name());
+
pool = new HikariDataSource(config);
- logger.info("Connection pool initialized for " + type);
+ // Log host+database but NOT credentials
+ logger.info("Connection pool initialised for {} at {}:{}/{}", type, host, port, database);
+ }
+
+ /**
+ * Builds a JDBC URL for the given database type.
+ *
+ *
SSL is enabled for both MySQL and PostgreSQL by default.
+ * Disable only for local dev/test by setting {@code OBSIDIAN_DB_DISABLE_SSL=true}
+ * as an environment variable OR as a JVM system property ({@code -DOBSIDIAN_DB_DISABLE_SSL=true}).
+ * System property takes priority — this allows {@code exec-maven-plugin} to pass the flag
+ * via {@code } without relying on OS-level env injection, which does not
+ * work when Maven runs in-process.
+ */
+ private String buildJdbcUrl(DatabaseType type, String host, int port, String database) {
+ boolean disableSsl = isSslDisabled();
+ return switch (type) {
+ case MYSQL -> {
+ if (disableSsl) {
+ logger.warn("MySQL SSL verification is DISABLED (OBSIDIAN_DB_DISABLE_SSL=true). " +
+ "Do not use this setting in production.");
+ yield String.format(
+ "jdbc:mysql://%s:%d/%s?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC",
+ host, port, database);
+ }
+ yield String.format(
+ "jdbc:mysql://%s:%d/%s?useSSL=true&verifyServerCertificate=true&serverTimezone=UTC",
+ host, port, database);
+ }
+ case POSTGRESQL -> {
+ if (disableSsl) {
+ logger.warn("PostgreSQL SSL verification is DISABLED (OBSIDIAN_DB_DISABLE_SSL=true). " +
+ "Do not use this setting in production.");
+ yield String.format("jdbc:postgresql://%s:%d/%s?ssl=false", host, port, database);
+ }
+ // sslmode=verify-full requires the server certificate to match the hostname
+ // and be signed by a trusted CA — equivalent to MySQL verifyServerCertificate=true.
+ yield String.format(
+ "jdbc:postgresql://%s:%d/%s?ssl=true&sslmode=verify-full",
+ host, port, database);
+ }
+ default -> throw new IllegalArgumentException("Unsupported database type: " + type);
+ };
+ }
+
+ /**
+ * Returns true if SSL should be disabled.
+ *
+ *
Checks system property first (set via {@code -D} or {@code exec-maven-plugin}
+ * {@code }), then falls back to the OS environment variable.
+ * System property wins because {@code exec:java} runs in-process and cannot inject
+ * environment variables after JVM startup.
+ */
+ private boolean isSslDisabled() {
+ String sysProp = System.getProperty("OBSIDIAN_DB_DISABLE_SSL");
+ if (sysProp != null) return "true".equalsIgnoreCase(sysProp);
+ return "true".equalsIgnoreCase(System.getenv("OBSIDIAN_DB_DISABLE_SSL"));
+ }
+
+ // ─── CONNECTION MANAGEMENT ───────────────────────────────
+
+ /**
+ * Opens a connection for the current thread (no-op if already open).
+ */
+ public void connect()
+ {
+ try {
+ if (threadConnection.get() != null) return;
+
+ Connection conn;
+ if (type == DatabaseType.SQLITE) {
+ conn = DriverManager.getConnection(jdbcUrl);
+ } else if (pool != null) {
+ conn = pool.getConnection();
+ } else {
+ throw new IllegalStateException("No connection pool available");
+ }
+ threadConnection.set(conn);
+ } catch (SQLException e) {
+ logger.error("Connection failed: {}", e.getMessage());
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Returns true if the current thread holds an open connection.
+ */
+ public static boolean hasConnection() {
+ Connection conn = threadConnection.get();
+ if (conn == null) return false;
+ try {
+ return !conn.isClosed();
+ } catch (SQLException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Returns the current thread's connection, opening one if necessary.
+ */
+ public static Connection getConnection() {
+ Connection conn = threadConnection.get();
+ if (conn == null) {
+ getInstance().connect();
+ conn = threadConnection.get();
+ }
+ return conn;
+ }
+
+ /**
+ * Closes and removes the current thread's connection.
+ */
+ public static void closeConnection() {
+ Connection conn = threadConnection.get();
+ if (conn != null) {
+ try {
+ conn.close();
+ } catch (SQLException ignore) {}
+ threadConnection.remove();
+ }
}
+ // ─── EXECUTE WITH CONNECTION / TRANSACTION ───────────────
+
/**
- * Executes a task with database connection.
- * Opens connection if needed, closes it after execution.
+ * Executes {@code task} with an open connection, closing it afterwards
+ * only if this call was the one that opened it.
*
- * @param task Task to execute
- * @param Return type
- * @return Task result
+ * @param task The task
+ * @return The task's return value
*/
public T executeWithConnection(Callable task)
{
boolean created = false;
try {
- if (!Base.hasConnection()) {
+ if (!hasConnection()) {
connect();
created = true;
}
return task.call();
} catch (Exception e) {
- logger.error("Database error: " + e.getMessage());
+ logger.error("Database error: {}", e.getMessage());
throw new RuntimeException(e);
} finally {
- if (created && Base.hasConnection()) {
- Base.close();
+ if (created && hasConnection()) {
+ closeConnection();
}
}
}
/**
- * Executes a task within a transaction.
- * Commits on success, rolls back on failure.
+ * Executes {@code task} inside a transaction.
+ * Rolls back and rethrows on any exception.
*
- * @param task Task to execute
- * @param Return type
- * @return Task result
+ * @param task The task
+ * @return The task's return value
*/
public T executeWithTransaction(Callable task)
{
boolean created = false;
try {
- if (!Base.hasConnection()) {
+ if (!hasConnection()) {
connect();
created = true;
}
- Base.openTransaction();
+ Connection conn = getConnection();
+ conn.setAutoCommit(false);
T result = task.call();
- Base.commitTransaction();
+ conn.commit();
+ conn.setAutoCommit(true);
return result;
} catch (Exception e) {
- if (Base.hasConnection()) {
- Base.rollbackTransaction();
+ try {
+ Connection conn = threadConnection.get();
+ if (conn != null) {
+ conn.rollback();
+ conn.setAutoCommit(true);
+ }
+ } catch (SQLException rollbackEx) {
+ logger.error("Rollback failed: {}", rollbackEx.getMessage());
}
- logger.error("Transaction failed: " + e.getMessage());
+ logger.error("Transaction failed: {}", e.getMessage());
throw new RuntimeException(e);
} finally {
- if (created && Base.hasConnection()) {
- Base.close();
+ if (created && hasConnection()) {
+ closeConnection();
}
}
}
+ // ─── RAW SQL EXECUTION ───────────────────────────────────
+
/**
- * Closes database connection and connection pool.
+ * Executes a DDL/DML statement (CREATE, INSERT, UPDATE, DELETE).
+ * All variable data must be passed as {@code params} — never interpolated.
*/
- public void close()
- {
- if (Base.hasConnection()) {
- Base.close();
+ public static void exec(String sql, Object... params) {
+ Connection conn = getConnection();
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ bindParams(stmt, params);
+ stmt.executeUpdate();
+ } catch (SQLException e) {
+ throw new RuntimeException("SQL exec failed: " + sql, e);
+ }
+ }
+
+ /**
+ * Executes a SELECT query and returns a list of rows.
+ * All variable data must be passed as {@code params} — never interpolated.
+ */
+ public static List