org.junit.jupiter
@@ -131,19 +132,7 @@
${java.version}
-
- org.javalite
- activejdbc-instrumentation
- 3.5-j11
-
-
- process-classes
-
- instrument
-
-
-
-
+
\ 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..aa8109a 100644
--- a/src/main/java/com/obsidian/core/database/DB.java
+++ b/src/main/java/com/obsidian/core/database/DB.java
@@ -2,19 +2,58 @@
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 connections require SSL by default. Set
+ * {@code OBSIDIAN_DB_DISABLE_SSL=true} (env) only in local dev/test.
+ * 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 +61,495 @@ 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);
}
/**
- * Executes a task with database connection.
- * Opens connection if needed, closes it after execution.
+ * Builds a JDBC URL for the given database type.
*
- * @param task Task to execute
- * @param Return type
- * @return Task result
+ * SSL is enabled for both MySQL and PostgreSQL by default.
+ * Disable only for local dev/test by setting {@code OBSIDIAN_DB_DISABLE_SSL=true}.
+ */
+ private String buildJdbcUrl(DatabaseType type, String host, int port, String database) {
+ boolean disableSsl = "true".equalsIgnoreCase(System.getenv("OBSIDIAN_DB_DISABLE_SSL"));
+ 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);
+ };
+ }
+
+ // ─── 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 {@code task} with an open connection, closing it afterwards
+ * only if this call was the one that opened it.
+ *
+ * @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> findAll(String sql, Object... params) {
+ Connection conn = getConnection();
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ bindParams(stmt, params);
+ try (ResultSet rs = stmt.executeQuery()) {
+ return resultSetToList(rs);
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("SQL query failed: " + sql, e);
}
- if (pool != null) {
- pool.close();
- logger.info("Connection pool closed");
+ }
+
+ /**
+ * Executes a SELECT and returns the first cell value, or {@code null}.
+ */
+ public static Object firstCell(String sql, Object... params) {
+ Connection conn = getConnection();
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ bindParams(stmt, params);
+ try (ResultSet rs = stmt.executeQuery()) {
+ if (rs.next()) {
+ return rs.getObject(1);
+ }
+ return null;
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("SQL firstCell failed: " + sql, e);
}
}
/**
- * Gets database type.
+ * Executes a SELECT and returns the first row as a map, or {@code null}.
+ */
+ public static Map firstRow(String sql, Object... params) {
+ Connection conn = getConnection();
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ bindParams(stmt, params);
+ try (ResultSet rs = stmt.executeQuery()) {
+ List> rows = resultSetToList(rs);
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("SQL firstRow failed: " + sql, e);
+ }
+ }
+
+ /**
+ * Executes an INSERT and returns the generated key, or {@code null}.
+ */
+ public static Object insertAndGetKey(String sql, Object... params) {
+ Connection conn = getConnection();
+ try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
+ bindParams(stmt, params);
+ stmt.executeUpdate();
+ try (ResultSet keys = stmt.getGeneratedKeys()) {
+ if (keys.next()) {
+ return keys.getObject(1);
+ }
+ return null;
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("SQL insert failed: " + sql, e);
+ }
+ }
+
+ // ─── CLOSE / SHUTDOWN ────────────────────────────────────
+
+ /**
+ * Closes the current thread's connection and shuts down the pool.
*
- * @return Database type
+ * Synchronized on {@code DB.class} for the same reason as the {@code init*} methods:
+ * a thread calling {@code close()} concurrently with another thread reading {@code instance}
+ * could observe a non-null instance whose pool has already been shut down. The lock ensures
+ * that once {@code close()} completes, any subsequent {@code getInstance()} either gets
+ * a valid instance or throws — never a half-closed one.
*/
- public DatabaseType getType() {
- return type;
+ public void close()
+ {
+ synchronized (DB.class) {
+ closeConnection();
+ if (pool != null) {
+ pool.close();
+ pool = null;
+ logger.info("Connection pool closed ({})", type);
+ }
+ instance = null;
+ }
}
+ // ─── ACCESSORS ───────────────────────────────────────────
+
/**
- * Opens database connection.
- * Uses JDBC for SQLite, connection pool for MySQL/PostgreSQL.
+ * Returns the database type.
+ *
+ * @return The database type
*/
- public void connect()
+ public DatabaseType getType() { return type; }
+
+ /**
+ * Returns the logger.
+ *
+ * @return The logger
+ */
+ public Logger getLogger() { return logger; }
+
+ // ─── INTERNAL HELPERS ────────────────────────────────────
+
+ private static void bindParams(PreparedStatement stmt, Object... params) throws SQLException {
+ for (int i = 0; i < params.length; i++) {
+ Object value = params[i];
+ if (value == null) {
+ stmt.setNull(i + 1, Types.NULL);
+ } else if (value instanceof String) {
+ stmt.setString(i + 1, (String) value);
+ } else if (value instanceof Integer) {
+ stmt.setInt(i + 1, (Integer) value);
+ } else if (value instanceof Long) {
+ stmt.setLong(i + 1, (Long) value);
+ } else if (value instanceof Double) {
+ stmt.setDouble(i + 1, (Double) value);
+ } else if (value instanceof Float) {
+ stmt.setFloat(i + 1, (Float) value);
+ } else if (value instanceof Boolean) {
+ stmt.setBoolean(i + 1, (Boolean) value);
+ } else if (value instanceof java.util.Date) {
+ stmt.setTimestamp(i + 1, new Timestamp(((java.util.Date) value).getTime()));
+ } else if (value instanceof java.time.LocalDateTime) {
+ stmt.setTimestamp(i + 1, Timestamp.valueOf((java.time.LocalDateTime) value));
+ } else if (value instanceof java.time.LocalDate) {
+ stmt.setDate(i + 1, Date.valueOf((java.time.LocalDate) value));
+ } else {
+ stmt.setObject(i + 1, value);
+ }
+ }
+ }
+
+ private static List> resultSetToList(ResultSet rs) throws SQLException
{
- try {
- if (type == DatabaseType.SQLITE) {
- String url = "jdbc:sqlite:" + dbPath;
- Base.open("org.sqlite.JDBC", url, "", "");
- } else if (pool != null) {
- Base.open(pool);
+ List> results = new ArrayList<>();
+ ResultSetMetaData meta = rs.getMetaData();
+ int colCount = meta.getColumnCount();
+
+ while (rs.next()) {
+ Map row = new LinkedHashMap<>();
+ for (int i = 1; i <= colCount; i++) {
+ row.put(meta.getColumnLabel(i), rs.getObject(i));
}
- } catch (Exception e) {
- logger.error("Connection failed: " + e.getMessage());
- throw new RuntimeException(e);
+ results.add(row);
}
+ return results;
}
}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/DatabaseLoader.java b/src/main/java/com/obsidian/core/database/DatabaseLoader.java
index eecbbb5..cce07a1 100644
--- a/src/main/java/com/obsidian/core/database/DatabaseLoader.java
+++ b/src/main/java/com/obsidian/core/database/DatabaseLoader.java
@@ -13,13 +13,11 @@
*/
public class DatabaseLoader
{
- /** Logger instance */
public final static Logger logger = LoggerFactory.getLogger(DatabaseLoader.class);
/**
- * Loads and initializes database connection from environment configuration.
+ * Load Database.
*
- * @throws IllegalArgumentException if database type is not supported
*/
public static void loadDatabase()
{
@@ -38,29 +36,52 @@ public static void loadDatabase()
DB.initSQLite(dbPath, logger);
break;
case MYSQL:
- String mysqlHost = env.get(EnvKeys.DB_HOST);
- String mysqlPort = env.get(EnvKeys.DB_PORT);
DB.initMySQL(
- mysqlHost != null ? mysqlHost : "localhost",
- Integer.parseInt(mysqlPort != null ? mysqlPort : "3306"),
- env.get(EnvKeys.DB_NAME),
- env.get(EnvKeys.DB_USER),
- env.get(EnvKeys.DB_PASSWORD),
+ resolveHost(env, "localhost"),
+ resolvePort(env, 3306),
+ requireEnv(env, EnvKeys.DB_NAME, "DB_NAME"),
+ requireEnv(env, EnvKeys.DB_USER, "DB_USER"),
+ requireEnv(env, EnvKeys.DB_PASSWORD, "DB_PASSWORD"),
logger
);
break;
case POSTGRESQL:
- String pgHost = env.get(EnvKeys.DB_HOST);
- String pgPort = env.get(EnvKeys.DB_PORT);
DB.initPostgreSQL(
- pgHost != null ? pgHost : "localhost",
- Integer.parseInt(pgPort != null ? pgPort : "5432"),
- env.get(EnvKeys.DB_NAME),
- env.get(EnvKeys.DB_USER),
- env.get(EnvKeys.DB_PASSWORD),
+ resolveHost(env, "localhost"),
+ resolvePort(env, 5432),
+ requireEnv(env, EnvKeys.DB_NAME, "DB_NAME"),
+ requireEnv(env, EnvKeys.DB_USER, "DB_USER"),
+ requireEnv(env, EnvKeys.DB_PASSWORD, "DB_PASSWORD"),
logger
);
break;
}
}
+
+ private static String resolveHost(EnvLoader env, String defaultHost) {
+ String host = env.get(EnvKeys.DB_HOST);
+ return (host != null && !host.isEmpty()) ? host : defaultHost;
+ }
+
+ private static int resolvePort(EnvLoader env, int defaultPort) {
+ String port = env.get(EnvKeys.DB_PORT);
+ if (port == null || port.isEmpty()) return defaultPort;
+ try {
+ return Integer.parseInt(port.trim());
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid DB_PORT value '{}', using default {}", port, defaultPort);
+ return defaultPort;
+ }
+ }
+
+ private static String requireEnv(EnvLoader env, String key, String label) {
+ String value = env.get(key);
+ if (value == null || value.isEmpty()) {
+ throw new IllegalStateException(
+ "Missing required environment variable: " + label + ". " +
+ "Set it in your .env file or environment before starting the application."
+ );
+ }
+ return value;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/DatabaseType.java b/src/main/java/com/obsidian/core/database/DatabaseType.java
index ad5ef8e..2841e66 100644
--- a/src/main/java/com/obsidian/core/database/DatabaseType.java
+++ b/src/main/java/com/obsidian/core/database/DatabaseType.java
@@ -15,16 +15,20 @@ public enum DatabaseType
this.value = value;
}
+ /**
+ * Value.
+ *
+ * @return The string value
+ */
public String value() {
return value;
}
/**
- * Resolves a DatabaseType from a string value.
- * Defaults to SQLITE if null, empty, or unrecognized.
+ * From String.
*
- * @param value The string value (case-insensitive)
- * @return The matching DatabaseType
+ * @param value The value to compare against
+ * @return This instance for method chaining
*/
public static DatabaseType fromString(String value) {
if (value == null || value.isBlank()) return SQLITE;
@@ -33,4 +37,4 @@ public static DatabaseType fromString(String value) {
}
return SQLITE;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/obsidian/core/database/Migration.java b/src/main/java/com/obsidian/core/database/Migration.java
index 89f44fc..7451e38 100644
--- a/src/main/java/com/obsidian/core/database/Migration.java
+++ b/src/main/java/com/obsidian/core/database/Migration.java
@@ -1,6 +1,6 @@
package com.obsidian.core.database;
-import org.javalite.activejdbc.Base;
+import com.obsidian.core.database.orm.query.SqlIdentifier;
import org.slf4j.Logger;
import java.util.ArrayList;
@@ -9,6 +9,13 @@
/**
* Base class for database migrations.
* Provides schema modification methods with multi-database support.
+ * Uses DB.exec() instead of ActiveJDBC Base.exec().
+ *
+ * Security : every table name and column name passed to DDL methods
+ * is validated by {@link SqlIdentifier#requireIdentifier(String)} before being
+ * interpolated into SQL. DDL statements cannot use PreparedStatement placeholders
+ * for identifiers, so this guard is the only defence against injection through
+ * dynamically-constructed migration names.
*/
public abstract class Migration
{
@@ -31,83 +38,118 @@ public abstract class Migration
/**
* Creates a new table with specified columns.
*
- * @param tableName Name of table to create
- * @param builder Callback to define table structure
+ * @param tableName the table name — must be a valid SQL identifier
+ * @param builder the column/constraint builder callback
*/
protected void createTable(String tableName, TableBuilder builder)
{
+ SqlIdentifier.requireIdentifier(tableName);
+
StringBuilder sql = new StringBuilder();
sql.append("CREATE TABLE IF NOT EXISTS ").append(tableName).append(" (");
List columns = new ArrayList<>();
- builder.build(new Blueprint(columns, type));
+ List constraints = new ArrayList<>();
+ builder.build(new Blueprint(columns, constraints, type));
+
+ List allParts = new ArrayList<>(columns);
+ allParts.addAll(constraints);
- sql.append(String.join(", ", columns));
+ sql.append(String.join(", ", allParts));
sql.append(")");
- Base.exec(sql.toString());
- logger.info("Table created: " + tableName);
+ DB.exec(sql.toString());
+ logger.info("Table created: {}", tableName);
}
/**
* Drops a table if it exists.
*
- * @param tableName Name of table to drop
+ * @param tableName the table name — must be a valid SQL identifier
*/
protected void dropTable(String tableName) {
- Base.exec("DROP TABLE IF EXISTS " + tableName);
- logger.info("Table dropped: " + tableName);
+ SqlIdentifier.requireIdentifier(tableName);
+ DB.exec("DROP TABLE IF EXISTS " + tableName);
+ logger.info("Table dropped: {}", tableName);
}
/**
- * Adds a column to existing table.
+ * Adds a column to an existing table.
*
- * @param tableName Table name
- * @param columnName Column name
- * @param definition Column definition (type and constraints)
+ * @param tableName the table name — must be a valid SQL identifier
+ * @param columnName the column name — must be a valid SQL identifier
+ * @param definition the raw column type definition (e.g. "VARCHAR(255) NOT NULL")
*/
protected void addColumn(String tableName, String columnName, String definition) {
- Base.exec(String.format("ALTER TABLE %s ADD COLUMN %s %s", tableName, columnName, definition));
- logger.info("Column added: " + tableName + "." + columnName);
+ SqlIdentifier.requireIdentifier(tableName);
+ SqlIdentifier.requireIdentifier(columnName);
+ DB.exec("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + definition);
+ logger.info("Column added: {}.{}", tableName, columnName);
}
/**
- * Drops a column from table.
- * Note: Not supported in SQLite.
+ * Drops a column from a table.
+ * No-op on SQLite (not supported).
*
- * @param tableName Table name
- * @param columnName Column name
+ * @param tableName the table name — must be a valid SQL identifier
+ * @param columnName the column name — must be a valid SQL identifier
*/
protected void dropColumn(String tableName, String columnName) {
if (type == DatabaseType.SQLITE) {
- logger.warn("SQLite does not support DROP COLUMN - migration skipped");
+ logger.warn("SQLite does not support DROP COLUMN — migration skipped for column: {}", columnName);
return;
}
- Base.exec(String.format("ALTER TABLE %s DROP COLUMN %s", tableName, columnName));
- logger.info("Column dropped: " + tableName + "." + columnName);
+ SqlIdentifier.requireIdentifier(tableName);
+ SqlIdentifier.requireIdentifier(columnName);
+ DB.exec("ALTER TABLE " + tableName + " DROP COLUMN " + columnName);
+ logger.info("Column dropped: {}.{}", tableName, columnName);
}
/**
- * Checks if a table exists in database.
+ * Checks if a table exists in the database.
*
- * @param tableName Table name to check
- * @return true if table exists, false otherwise
+ * @param tableName the table name to check (used as a bound parameter, not interpolated)
+ * @return {@code true} if the table exists
*/
protected boolean tableExists(String tableName)
{
String checkSQL = switch (type) {
- case MYSQL -> "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?";
+ case MYSQL -> "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?";
case POSTGRESQL -> "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?";
- default -> "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?";
+ default -> "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?";
};
- Object result = Base.firstCell(checkSQL, tableName);
+ Object result = DB.firstCell(checkSQL, tableName);
if (result == null) return false;
long count = result instanceof Long ? (Long) result : Long.parseLong(result.toString());
return count > 0;
}
+ /**
+ * Executes raw SQL with bound parameters.
+ * The caller is responsible for ensuring {@code sql} is safe.
+ *
+ * @param sql raw SQL (trusted, not derived from user input)
+ * @param params values bound via PreparedStatement
+ */
+ protected void raw(String sql, Object... params) {
+ DB.exec(sql, params);
+ }
+
+ /**
+ * Renames a table.
+ *
+ * @param from the current table name — must be a valid SQL identifier
+ * @param to the new table name — must be a valid SQL identifier
+ */
+ protected void renameTable(String from, String to) {
+ SqlIdentifier.requireIdentifier(from);
+ SqlIdentifier.requireIdentifier(to);
+ DB.exec("ALTER TABLE " + from + " RENAME TO " + to);
+ logger.info("Table renamed: {} -> {}", from, to);
+ }
+
/**
* Functional interface for table building.
*/
@@ -119,150 +161,317 @@ public interface TableBuilder {
/**
* Schema builder for defining table columns.
* Provides fluent API for column definitions with database-specific syntax.
+ *
+ * Enhanced from original with:
+ * - foreignKey() support
+ * - cascadeOnDelete() / nullOnDelete()
+ * - softDeletes()
+ * - json() column type
+ * - Separate constraints list for foreign keys / composite indexes
*/
public static class Blueprint {
private final List columns;
+ private final List constraints;
private final DatabaseType dbType;
- public Blueprint(List columns, DatabaseType dbType) {
+ /**
+ * Creates a new Blueprint instance.
+ *
+ * @param columns column definitions list (mutated by builder methods)
+ * @param constraints constraint definitions list (mutated by builder methods)
+ * @param dbType the target database type
+ */
+ public Blueprint(List columns, List constraints, DatabaseType dbType) {
this.columns = columns;
+ this.constraints = constraints;
this.dbType = dbType;
}
/**
- * Adds auto-incrementing primary key named "id".
+ * Backward-compatible 2-arg constructor.
+ *
+ * @param columns column definitions list
+ * @param dbType the target database type
+ */
+ public Blueprint(List columns, DatabaseType dbType) {
+ this(columns, new ArrayList<>(), dbType);
+ }
+
+ // ─── INTERNAL COLUMN HELPERS ─────────────────────────
+
+ /**
+ * Central column-addition helper — validates the column name via
+ * {@link SqlIdentifier#requireIdentifier} before interpolating it
+ * into DDL. All public Blueprint methods that accept a column name
+ * must route through here instead of calling {@code columns.add}
+ * directly, preventing DDL injection via malformed column names.
+ *
+ * {@code type} is always a hard-coded literal produced by this
+ * class (e.g. {@code "TEXT"}, {@code "VARCHAR(255)"}), never derived
+ * from caller input, so it does not need separate validation.
+ */
+ private Blueprint col(String name, String type) {
+ SqlIdentifier.requireIdentifier(name);
+ columns.add(name + " " + type);
+ return this;
+ }
+
+ /**
+ * Validates and records a FOREIGN KEY constraint for the last column.
+ * Both the referenced table and column are validated as identifiers.
+ */
+ 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;
+ }
+
+ // ─── PRIMARY KEY ─────────────────────────────────────
+
+ /**
+ * Adds an auto-increment primary key column named {@code id}.
+ *
+ * @return This instance for method chaining
*/
public Blueprint id() {
return id("id");
}
/**
- * Adds auto-incrementing primary key with custom name.
+ * Adds an auto-increment primary key column with a custom name.
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint id(String name) {
- String column = switch (dbType) {
- case MYSQL -> name + " INT AUTO_INCREMENT PRIMARY KEY";
- case POSTGRESQL -> name + " SERIAL PRIMARY KEY";
- default -> name + " INTEGER PRIMARY KEY AUTOINCREMENT";
+ 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(column);
+ columns.add(name + " " + type);
return this;
}
/**
- * Adds VARCHAR/TEXT column with default length 255.
+ * UUID primary key.
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint uuid(String name) {
+ return col(name, "VARCHAR(36)");
+ }
+
+ // ─── STRING / TEXT ───────────────────────────────────
+
+ /**
+ * String column (VARCHAR 255 or TEXT on SQLite).
+ *
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint string(String name) {
return string(name, 255);
}
/**
- * Adds VARCHAR/TEXT column with custom length.
+ * String column with a custom length.
*
- * @param name Column name
- * @param length Maximum length
+ * @param name the column name
+ * @param length the maximum length
+ * @return This instance for method chaining
*/
public Blueprint string(String name, int length) {
String type = dbType == DatabaseType.SQLITE ? "TEXT" : "VARCHAR(" + length + ")";
- columns.add(name + " " + type);
- return this;
+ return col(name, type);
}
/**
- * Adds TEXT column.
+ * TEXT column.
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint text(String name) {
- columns.add(name + " TEXT");
- return this;
+ return col(name, "TEXT");
}
/**
- * Adds INTEGER column.
+ * MEDIUMTEXT column (TEXT on non-MySQL).
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint mediumText(String name) {
+ String type = dbType == DatabaseType.MYSQL ? "MEDIUMTEXT" : "TEXT";
+ return col(name, type);
+ }
+
+ /**
+ * LONGTEXT column (TEXT on non-MySQL).
+ *
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint longText(String name) {
+ String type = dbType == DatabaseType.MYSQL ? "LONGTEXT" : "TEXT";
+ return col(name, type);
+ }
+
+ // ─── NUMERIC ─────────────────────────────────────────
+
+ /**
+ * INT / INTEGER column.
+ *
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint integer(String name) {
String type = dbType == DatabaseType.POSTGRESQL ? "INTEGER" : "INT";
- columns.add(name + " " + type);
- return this;
+ return col(name, type);
+ }
+
+ /**
+ * TINYINT column (INTEGER on SQLite).
+ *
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint tinyInteger(String name) {
+ String type = dbType == DatabaseType.SQLITE ? "INTEGER" : "TINYINT";
+ return col(name, type);
}
/**
- * Adds BIGINT column.
+ * SMALLINT column (INTEGER on SQLite).
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint smallInteger(String name) {
+ String type = dbType == DatabaseType.SQLITE ? "INTEGER" : "SMALLINT";
+ return col(name, type);
+ }
+
+ /**
+ * BIGINT column.
+ *
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint bigInteger(String name) {
- columns.add(name + " BIGINT");
- return this;
+ return col(name, "BIGINT");
}
/**
- * Adds DECIMAL column.
+ * DECIMAL column with explicit precision and scale.
*
- * @param name Column name
- * @param precision Total digits
- * @param scale Decimal places
+ * @param name the column name
+ * @param precision total digits
+ * @param scale digits after decimal point
+ * @return This instance for method chaining
*/
public Blueprint decimal(String name, int precision, int scale) {
- columns.add(name + " DECIMAL(" + precision + "," + scale + ")");
- return this;
+ return col(name, "DECIMAL(" + precision + "," + scale + ")");
+ }
+
+ /**
+ * DECIMAL(10,2) column.
+ *
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint decimal(String name) {
+ return decimal(name, 10, 2);
}
/**
- * Adds BOOLEAN column.
+ * FLOAT column.
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint floatCol(String name) {
+ return col(name, "FLOAT");
+ }
+
+ /**
+ * DOUBLE column.
+ *
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint doubleCol(String name) {
+ return col(name, "DOUBLE");
+ }
+
+ // ─── BOOLEAN ─────────────────────────────────────────
+
+ /**
+ * BOOLEAN column (INTEGER on SQLite).
+ *
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint bool(String name) {
String type = dbType == DatabaseType.SQLITE ? "INTEGER" : "BOOLEAN";
- columns.add(name + " " + type);
- return this;
+ return col(name, type);
}
+ // ─── DATE / TIME ─────────────────────────────────────
+
/**
- * Adds DATE column.
+ * DATE column.
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint date(String name) {
- columns.add(name + " DATE");
- return this;
+ return col(name, "DATE");
}
/**
- * Adds DATETIME/TIMESTAMP column.
+ * DATETIME / TIMESTAMP / TEXT column depending on database.
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint dateTime(String name) {
String type = switch (dbType) {
case POSTGRESQL -> "TIMESTAMP";
- case MYSQL -> "DATETIME";
- default -> "TEXT";
+ case MYSQL -> "DATETIME";
+ default -> "TEXT";
};
- columns.add(name + " " + type);
- return this;
+ return col(name, type);
}
/**
- * Adds TIMESTAMP column.
+ * TIMESTAMP column.
*
- * @param name Column name
+ * @param name the column name
+ * @return This instance for method chaining
*/
public Blueprint timestamp(String name) {
- columns.add(name + " TIMESTAMP");
- return this;
+ return col(name, "TIMESTAMP");
}
/**
- * Adds created_at and updated_at timestamp columns.
+ * TIME column.
+ *
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint time(String name) {
+ return col(name, "TIME");
+ }
+
+ /**
+ * Adds {@code created_at} and {@code updated_at} columns with appropriate defaults.
+ *
+ * @return This instance for method chaining
*/
public Blueprint timestamps()
{
@@ -280,45 +489,227 @@ public Blueprint timestamps()
}
/**
- * Adds NOT NULL constraint to last column.
+ * Adds a nullable {@code deleted_at} column for soft deletes.
+ *
+ * @return This instance for method chaining
*/
- public Blueprint notNull() {
- if (!columns.isEmpty()) {
- int lastIndex = columns.size() - 1;
- columns.set(lastIndex, columns.get(lastIndex) + " NOT NULL");
+ public Blueprint softDeletes() {
+ dateTime("deleted_at");
+ nullable();
+ return this;
+ }
+
+ // ─── JSON ────────────────────────────────────────────
+
+ /**
+ * JSON column (TEXT on SQLite).
+ *
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint json(String name) {
+ String type = dbType == DatabaseType.SQLITE ? "TEXT" : "JSON";
+ return col(name, type);
+ }
+
+ // ─── BLOB ────────────────────────────────────────────
+
+ /**
+ * BLOB column.
+ *
+ * @param name the column name
+ * @return This instance for method chaining
+ */
+ public Blueprint blob(String name) {
+ return col(name, "BLOB");
+ }
+
+ // ─── ENUM ────────────────────────────────────────────
+
+ /**
+ * ENUM column (CHECK constraint on PostgreSQL / SQLite).
+ *
+ * @param name the column name
+ * @param values allowed enum values
+ * @return This instance for method chaining
+ */
+ public Blueprint enumCol(String name, String... values) {
+ if (dbType == DatabaseType.MYSQL) {
+ String vals = "'" + String.join("', '", values) + "'";
+ col(name, "ENUM(" + vals + ")");
+ } else {
+ col(name, "VARCHAR(50)");
+ String vals = "'" + String.join("', '", values) + "'";
+ constraints.add("CHECK (" + name + " IN (" + vals + "))");
}
return this;
}
+ // ─── MODIFIERS ───────────────────────────────────────
+
+ /**
+ * Appends NOT NULL to the last column definition.
+ *
+ * @return This instance for method chaining
+ */
+ public Blueprint notNull() {
+ modifyLast(" NOT NULL");
+ return this;
+ }
+
/**
- * Adds UNIQUE constraint to last column.
+ * Appends UNIQUE to the last column definition.
+ *
+ * @return This instance for method chaining
*/
public Blueprint unique() {
- if (!columns.isEmpty()) {
- int lastIndex = columns.size() - 1;
- columns.set(lastIndex, columns.get(lastIndex) + " UNIQUE");
- }
+ modifyLast(" UNIQUE");
return this;
}
/**
- * Adds DEFAULT value to last column.
+ * Appends a DEFAULT clause to the last column definition.
*
- * @param value Default value
+ * @param value the default value string (not quoted)
+ * @return This instance for method chaining
*/
public Blueprint defaultValue(String value) {
+ modifyLast(" DEFAULT " + value);
+ return this;
+ }
+
+ /**
+ * Appends a DEFAULT clause with an integer value.
+ *
+ * @param value the default integer value
+ * @return This instance for method chaining
+ */
+ public Blueprint defaultValue(int value) {
+ modifyLast(" DEFAULT " + value);
+ return this;
+ }
+
+ /**
+ * Appends a DEFAULT clause with a boolean value (1 or 0).
+ *
+ * @param value the default boolean value
+ * @return This instance for method chaining
+ */
+ public Blueprint defaultValue(boolean value) {
+ modifyLast(" DEFAULT " + (value ? "1" : "0"));
+ return this;
+ }
+
+ /**
+ * No-op: columns are nullable by default. Provided for readability.
+ *
+ * @return This instance for method chaining
+ */
+ public Blueprint nullable() {
+ return this;
+ }
+
+ /**
+ * Adds a FOREIGN KEY constraint referencing the last column.
+ *
+ * @param refTable the referenced table name
+ * @param refColumn the referenced column name
+ * @return This instance for method chaining
+ */
+ public Blueprint foreignKey(String refTable, String refColumn) {
if (!columns.isEmpty()) {
- int lastIndex = columns.size() - 1;
- columns.set(lastIndex, columns.get(lastIndex) + " DEFAULT " + value);
+ String lastCol = columns.get(columns.size() - 1);
+ String colName = lastCol.split("\\s+")[0];
+ // colName is already validated (extracted from a col() call above)
+ // refTable and refColumn are validated inside fk()
+ fk(refTable, refColumn, colName);
}
return this;
}
/**
- * Marks last column as nullable (no-op, columns are nullable by default).
+ * Appends ON DELETE CASCADE to the last foreign key constraint.
+ *
+ * @return This instance for method chaining
*/
- public Blueprint nullable() {
+ public Blueprint cascadeOnDelete() {
+ modifyLastConstraint(" ON DELETE CASCADE");
+ return this;
+ }
+
+ /**
+ * Appends ON DELETE SET NULL to the last foreign key constraint.
+ *
+ * @return This instance for method chaining
+ */
+ public Blueprint nullOnDelete() {
+ modifyLastConstraint(" ON DELETE SET NULL");
+ return this;
+ }
+
+ /**
+ * Appends ON DELETE RESTRICT to the last foreign key constraint.
+ *
+ * @return This instance for method chaining
+ */
+ public Blueprint restrictOnDelete() {
+ modifyLastConstraint(" ON DELETE RESTRICT");
+ return this;
+ }
+
+ /**
+ * Appends ON UPDATE CASCADE to the last foreign key constraint.
+ *
+ * @return This instance for method chaining
+ */
+ public Blueprint cascadeOnUpdate() {
+ modifyLastConstraint(" ON UPDATE CASCADE");
+ return this;
+ }
+
+ // ─── INDEX SHORTCUTS ─────────────────────────────────
+
+ /**
+ * Adds a composite UNIQUE constraint.
+ *
+ * @param columnNames the columns forming the unique index
+ * @return This instance for method chaining
+ */
+ public Blueprint uniqueIndex(String... columnNames) {
+ for (String col : columnNames) {
+ SqlIdentifier.requireIdentifier(col);
+ }
+ constraints.add("UNIQUE (" + String.join(", ", columnNames) + ")");
+ return this;
+ }
+
+ /**
+ * Adds {@code name_id} (BIGINT NOT NULL) and {@code name_type} (VARCHAR NOT NULL) columns
+ * for polymorphic relations.
+ *
+ * @param name the morph base name
+ * @return This instance for method chaining
+ */
+ public Blueprint morphs(String name) {
+ bigInteger(name + "_id").notNull();
+ string(name + "_type").notNull();
return this;
}
+
+ // ─── INTERNAL HELPERS ────────────────────────────────
+
+ private void modifyLast(String suffix) {
+ if (!columns.isEmpty()) {
+ int lastIndex = columns.size() - 1;
+ columns.set(lastIndex, columns.get(lastIndex) + suffix);
+ }
+ }
+
+ private void modifyLastConstraint(String suffix) {
+ if (!constraints.isEmpty()) {
+ int lastIndex = constraints.size() - 1;
+ constraints.set(lastIndex, constraints.get(lastIndex) + suffix);
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/MigrationManager.java b/src/main/java/com/obsidian/core/database/MigrationManager.java
index 9f1da46..de7322e 100644
--- a/src/main/java/com/obsidian/core/database/MigrationManager.java
+++ b/src/main/java/com/obsidian/core/database/MigrationManager.java
@@ -2,7 +2,6 @@
import com.obsidian.core.core.Obsidian;
import com.obsidian.core.di.ReflectionsProvider;
-import org.javalite.activejdbc.Base;
import org.slf4j.Logger;
import java.util.ArrayList;
@@ -14,26 +13,20 @@
/**
* Migration manager for database schema versioning.
* Discovers, executes and tracks migrations.
+ * Uses DB static methods instead of ActiveJDBC Base.
*/
public class MigrationManager
{
- /** Database instance */
private final DB database;
-
- /** Logger instance */
private final Logger logger;
-
- /** List of registered migrations */
private final List migrations;
-
- /** Database type */
private final DatabaseType dbType;
/**
- * Constructor.
+ * Creates a new MigrationManager instance.
*
- * @param database Database instance
- * @param logger Logger instance
+ * @param database The database
+ * @param logger The logger
*/
public MigrationManager(DB database, Logger logger) {
this.database = database;
@@ -43,10 +36,10 @@ public MigrationManager(DB database, Logger logger) {
}
/**
- * Adds a migration to the list.
+ * Add.
*
- * @param migration Migration instance
- * @return Current instance for chaining
+ * @param migration The migration
+ * @return This instance for method chaining
*/
public MigrationManager add(Migration migration) {
migration.type = this.dbType;
@@ -56,61 +49,66 @@ public MigrationManager add(Migration migration) {
}
/**
- * Auto-discovers migrations by scanning base package.
- * Finds all Migration subclasses and instantiates them.
+ * Discover.
*
- * @return Current instance for chaining
+ * @return This instance for method chaining
*/
public MigrationManager discover()
{
try {
+ String basePackage = Obsidian.getBasePackage();
Set> migrationClasses = ReflectionsProvider.getSubTypesOf(Migration.class);
List discoveredMigrations = new ArrayList<>();
for (Class extends Migration> migrationClass : migrationClasses) {
+ // Restrict to the application's own package — prevents third-party
+ // dependencies that happen to extend Migration from being executed.
+ if (!migrationClass.getName().startsWith(basePackage)) {
+ logger.debug("Skipping migration outside base package: {}", migrationClass.getName());
+ continue;
+ }
try {
Migration migration = migrationClass.getDeclaredConstructor().newInstance();
migration.type = this.dbType;
migration.logger = this.logger;
discoveredMigrations.add(migration);
} catch (Exception e) {
- logger.warn("Unable to instantiate the migration: " + migrationClass.getName() + " - " + e.getMessage());
+ logger.warn("Unable to instantiate migration {}: {}", migrationClass.getName(), e.getMessage());
}
}
discoveredMigrations.sort(Comparator.comparing(m -> m.getClass().getSimpleName()));
migrations.addAll(discoveredMigrations);
- logger.info(discoveredMigrations.size() + " migration(s) discovered in " + Obsidian.getBasePackage());
+ logger.info("{} migration(s) discovered in {}", discoveredMigrations.size(), basePackage);
} catch (Exception e) {
- logger.error("Error discovering migrations: " + e.getMessage());
+ logger.error("Error discovering migrations: {}", e.getMessage());
}
return this;
}
/**
- * Executes all pending migrations.
- * Loads executed migrations in a single query, then iterates locally.
- * Runs within a transaction.
+ * Migrate.
+ *
*/
public void migrate() {
database.executeWithTransaction(() -> {
createMigrationsTable();
Set executed = loadExecutedMigrations();
- for (int i = 0; i < migrations.size(); i++) {
- String migrationName = "migration_" + (i + 1);
+ for (Migration migration : migrations) {
+ String migrationName = migration.getClass().getSimpleName();
if (!executed.contains(migrationName)) {
- logger.info("Executing migration: " + migrationName);
- migrations.get(i).up();
+ logger.info("Executing migration: {}", migrationName);
+ migration.up();
recordMigration(migrationName);
- logger.info("✓ Migration completed: " + migrationName);
+ logger.info("Migration completed: {}", migrationName);
} else {
- logger.info("Migration already executed: " + migrationName);
+ logger.info("Migration already executed: {}", migrationName);
}
}
@@ -120,20 +118,22 @@ public void migrate() {
}
/**
- * Rolls back all migrations in reverse order.
+ * Rollback.
+ *
*/
public void rollback() {
database.executeWithTransaction(() -> {
Set executed = loadExecutedMigrations();
for (int i = migrations.size() - 1; i >= 0; i--) {
- String migrationName = "migration_" + (i + 1);
+ Migration migration = migrations.get(i);
+ String migrationName = migration.getClass().getSimpleName();
if (executed.contains(migrationName)) {
- logger.info("Rolling back migration: " + migrationName);
- migrations.get(i).down();
+ logger.info("Rolling back migration: {}", migrationName);
+ migration.down();
removeMigration(migrationName);
- logger.info("✓ Migration rolled back: " + migrationName);
+ logger.info("Migration rolled back: {}", migrationName);
}
}
@@ -143,20 +143,22 @@ public void rollback() {
}
/**
- * Rolls back only the last executed migration.
+ * Rollback Last.
+ *
*/
public void rollbackLast() {
database.executeWithTransaction(() -> {
Set executed = loadExecutedMigrations();
for (int i = migrations.size() - 1; i >= 0; i--) {
- String migrationName = "migration_" + (i + 1);
+ Migration migration = migrations.get(i);
+ String migrationName = migration.getClass().getSimpleName();
if (executed.contains(migrationName)) {
- logger.info("Rolling back last migration: " + migrationName);
- migrations.get(i).down();
+ logger.info("Rolling back last migration: {}", migrationName);
+ migration.down();
removeMigration(migrationName);
- logger.info("✓ Last migration rolled back");
+ logger.info("Last migration rolled back: {}", migrationName);
break;
}
}
@@ -165,8 +167,8 @@ public void rollbackLast() {
}
/**
- * Rolls back all migrations then re-runs them.
- * Useful for database reset.
+ * Fresh.
+ *
*/
public void fresh() {
rollback();
@@ -174,24 +176,24 @@ public void fresh() {
}
/**
- * Displays migration status (executed vs pending).
+ * Status.
+ *
*/
public void status() {
database.executeWithConnection(() -> {
Set executed = loadExecutedMigrations();
- for (int i = 0; i < migrations.size(); i++) {
- String migrationName = "migration_" + (i + 1);
- String status = executed.contains(migrationName) ? "✓ Executed" : "✗ Pending";
- logger.info("{} - {}", migrationName, status);
+ for (Migration migration : migrations) {
+ String migrationName = migration.getClass().getSimpleName();
+ String status = executed.contains(migrationName) ? "Executed" : "Pending";
+ logger.info("{} — {}", migrationName, status);
}
return null;
});
}
- /**
- * Creates migrations tracking table if not exists.
- */
+ // ─── PRIVATE HELPERS (using DB instead of Base) ──────────
+
private void createMigrationsTable() {
String idColumn = switch (dbType) {
case MYSQL -> "INT AUTO_INCREMENT PRIMARY KEY";
@@ -199,7 +201,7 @@ private void createMigrationsTable() {
default -> "INTEGER PRIMARY KEY AUTOINCREMENT";
};
- Base.exec(String.format("""
+ DB.exec(String.format("""
CREATE TABLE IF NOT EXISTS migrations (
id %s,
migration VARCHAR(255) NOT NULL,
@@ -208,34 +210,19 @@ migration VARCHAR(255) NOT NULL,
""", idColumn));
}
- /**
- * Loads all executed migration names in a single query.
- *
- * @return Set of executed migration names
- */
private Set loadExecutedMigrations() {
Set executed = new HashSet<>();
- Base.findAll("SELECT migration FROM migrations").forEach(row ->
+ DB.findAll("SELECT migration FROM migrations").forEach(row ->
executed.add(row.get("migration").toString())
);
return executed;
}
- /**
- * Records a migration as executed.
- *
- * @param migrationName Migration name
- */
private void recordMigration(String migrationName) {
- Base.exec("INSERT INTO migrations (migration) VALUES (?)", migrationName);
+ DB.exec("INSERT INTO migrations (migration) VALUES (?)", migrationName);
}
- /**
- * Removes a migration record.
- *
- * @param migrationName Migration name
- */
private void removeMigration(String migrationName) {
- Base.exec("DELETE FROM migrations WHERE migration = ?", migrationName);
+ DB.exec("DELETE FROM migrations WHERE migration = ?", migrationName);
}
}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/orm/model/Model.java b/src/main/java/com/obsidian/core/database/orm/model/Model.java
new file mode 100644
index 0000000..eca541c
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/Model.java
@@ -0,0 +1,197 @@
+package com.obsidian.core.database.orm.model;
+
+import com.obsidian.core.database.orm.model.observer.ModelObserver;
+import com.obsidian.core.database.orm.query.QueryBuilder;
+
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.function.Consumer;
+
+/**
+ * Base Model class — ActiveRecord pattern.
+ *
+ * Behaviour is split across a chain of abstract superclasses, each owning
+ * one concern, so this file stays focused on the things that belong together:
+ * instance state, the per-class metadata cache, configuration overrides, and
+ * the static query/factory API.
+ *
+ *
+ * {@link ModelAttributes} — get/set, type coercion, fill, dirty tracking
+ * {@link ModelPersistence} — save, delete, restore, refresh
+ * {@link ModelRelations} — relation factories + loaded-relation cache
+ * {@link ModelSerializer} — toMap, hydrate
+ * {@link Model} — metadata cache, configuration, statics, utilities
+ *
+ */
+public abstract class Model extends ModelSerializer {
+
+ // ─── Metadata cache (per-class, computed once) ───────────
+
+ private static final Map, ModelMetadata> metadataCache =
+ new java.util.concurrent.ConcurrentHashMap<>();
+
+ static ModelMetadata metadata(Class extends Model> modelClass) {
+ return metadataCache.computeIfAbsent(modelClass, cls -> {
+ java.lang.reflect.Constructor extends Model> ctor;
+ try {
+ ctor = cls.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ } catch (NoSuchMethodException e) {
+ throw new RuntimeException("Model must have a no-arg constructor: " + cls.getSimpleName(), e);
+ }
+ Model instance;
+ try {
+ instance = ctor.newInstance();
+ } catch (Exception e) {
+ throw new RuntimeException("Cannot instantiate model: " + cls.getSimpleName(), e);
+ }
+ return new ModelMetadata(
+ instance.table(), instance.primaryKey(), instance.incrementing(),
+ instance.timestamps(), instance.softDeletes(), instance.hidden(),
+ instance.fillable(), instance.guarded(), instance.defaults(),
+ instance.globalScopes(), instance.observer(), instance.casts(), ctor
+ );
+ });
+ }
+
+ @Override
+ ModelMetadata meta() { return metadata(getClass()); }
+
+ // ─── SELF REFERENCE (used by superclasses for observer callbacks) ─────
+
+ @Override
+ Model self() { return this; }
+
+ // ─── FLUENT PUBLIC API ───────────────────────────────────
+ // Superclass mutating methods are package-private (_set, _fill, etc.).
+ // These public methods delegate to them and return Model for chaining.
+
+ public Model set(String key, Object value) { _set(key, value); return this; }
+ public Model setRaw(String key, Object value) { _setRaw(key, value); return this; }
+ public Model fill(Map attrs) { _fill(attrs); return this; }
+ public Model forceFill(Map attrs){ _forceFill(attrs); return this; }
+ public Model refresh() { _refresh(); return this; }
+
+
+ // ─── Configuration (override in subclass) ────────────────
+
+ public String table() {
+ Table annotation = getClass().getAnnotation(Table.class);
+ if (annotation != null) return annotation.value();
+ return getClass().getSimpleName().toLowerCase() + "s";
+ }
+
+ public String primaryKey() { return "id"; }
+ protected boolean incrementing() { return true; }
+ protected boolean timestamps() { return true; }
+ protected boolean softDeletes() { return false; }
+ protected List hidden() { return Collections.emptyList(); }
+ protected List fillable() { return Collections.emptyList(); }
+ protected List guarded() { return Collections.singletonList("*"); }
+ protected Map defaults() { return Collections.emptyMap(); }
+ protected List> globalScopes() { return Collections.emptyList(); }
+ @SuppressWarnings("rawtypes")
+ protected ModelObserver observer() { return null; }
+ protected Map casts() { return Collections.emptyMap(); }
+
+ // ─── STATIC QUERY STARTERS ───────────────────────────────
+
+ public static ModelQueryBuilder query(Class modelClass) {
+ ModelMetadata meta = metadata(modelClass);
+ return new ModelQueryBuilder<>(modelClass, meta.table, meta.globalScopes, meta.softDeletes);
+ }
+
+ public static ModelQueryBuilder where(Class modelClass, String column, Object value) {
+ return query(modelClass).where(column, value);
+ }
+
+ public static ModelQueryBuilder where(Class modelClass, String column, String op, Object value) {
+ return query(modelClass).where(column, op, value);
+ }
+
+ // ─── STATIC FINDERS ──────────────────────────────────────
+
+ public static T find(Class modelClass, Object id) {
+ return query(modelClass).where(metadata(modelClass).primaryKey, id).first();
+ }
+
+ public static T findOrFail(Class modelClass, Object id) {
+ T model = find(modelClass, id);
+ if (model == null) throw new ModelNotFoundException(modelClass.getSimpleName() + " not found with id: " + id);
+ return model;
+ }
+
+ public static List all(Class modelClass) {
+ return query(modelClass).get();
+ }
+
+ // ─── STATIC WRITE HELPERS ────────────────────────────────
+
+ public static T create(Class modelClass, Map attributes) {
+ T model = newInstance(modelClass);
+ model.fill(attributes);
+ model.save();
+ return model;
+ }
+
+ public static T firstOrCreate(Class modelClass,
+ Map search,
+ Map extra) {
+ ModelQueryBuilder q = query(modelClass);
+ search.forEach((k, v) -> q.where(k, v));
+ T found = q.first();
+ if (found != null) return found;
+ Map merged = new LinkedHashMap<>(search);
+ merged.putAll(extra);
+ return create(modelClass, merged);
+ }
+
+ public static int destroy(Class modelClass, Object... ids) {
+ if (ids.length == 0) return 0;
+ ModelMetadata meta = metadata(modelClass);
+ if (meta.softDeletes) {
+ return new QueryBuilder(meta.table)
+ .whereIn(meta.primaryKey, Arrays.asList(ids))
+ .update(Map.of("deleted_at", LocalDateTime.now()));
+ } else {
+ return new QueryBuilder(meta.table)
+ .whereIn(meta.primaryKey, Arrays.asList(ids))
+ .delete();
+ }
+ }
+
+ // ─── UTILITIES ───────────────────────────────────────────
+
+ // ─── UTILITIES ───────────────────────────────────────────
+
+ @SuppressWarnings("unchecked")
+ public static T newInstance(Class modelClass) {
+ ModelMetadata cached = metadataCache.get(modelClass);
+ try {
+ if (cached != null && cached.constructor != null) {
+ return (T) cached.constructor.newInstance();
+ }
+ java.lang.reflect.Constructor ctor = modelClass.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ return ctor.newInstance();
+ } catch (Exception e) {
+ throw new RuntimeException("Cannot instantiate model: " + modelClass.getSimpleName(), e);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Model model = (Model) o;
+ return Objects.equals(getId(), model.getId()) && Objects.equals(table(), model.table());
+ }
+
+ @Override
+ public int hashCode() { return Objects.hash(table(), getId()); }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "(id=" + getId() + ", attributes=" + getAttributes() + ")";
+ }
+}
diff --git a/src/main/java/com/obsidian/core/database/orm/model/ModelAttributes.java b/src/main/java/com/obsidian/core/database/orm/model/ModelAttributes.java
new file mode 100644
index 0000000..adadc15
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/ModelAttributes.java
@@ -0,0 +1,157 @@
+package com.obsidian.core.database.orm.model;
+
+import com.obsidian.core.database.orm.model.cast.AttributeCaster;
+
+import java.time.LocalDateTime;
+import java.util.*;
+
+/**
+ * Attribute access, type coercion, mass-assignment, and dirty tracking.
+ * Package-private and abstract — only {@link Model} is public.
+ * Fluent methods return void here; Model redeclares them returning Model.
+ */
+abstract class ModelAttributes {
+
+ final Map attributes = new LinkedHashMap<>();
+ final Map original = new LinkedHashMap<>();
+
+ abstract ModelMetadata meta();
+
+ // ─── GET ─────────────────────────────────────────────────
+
+ public Object get(String key) {
+ Object value = attributes.get(key);
+ String castType = meta().casts.get(key);
+ if (castType != null && value != null) return AttributeCaster.castGet(value, castType);
+ return value;
+ }
+
+ public Object getRaw(String key) { return attributes.get(key); }
+
+ public String getString(String key) {
+ Object val = get(key); return val != null ? val.toString() : null;
+ }
+
+ public Integer getInteger(String key) {
+ Object val = get(key);
+ if (val == null) return null;
+ if (val instanceof Number) return ((Number) val).intValue();
+ return Integer.parseInt(val.toString());
+ }
+
+ public Long getLong(String key) {
+ Object val = get(key);
+ if (val == null) return null;
+ if (val instanceof Number) return ((Number) val).longValue();
+ return Long.parseLong(val.toString());
+ }
+
+ public Double getDouble(String key) {
+ Object val = get(key);
+ if (val == null) return null;
+ if (val instanceof Number) return ((Number) val).doubleValue();
+ return Double.parseDouble(val.toString());
+ }
+
+ public Boolean getBoolean(String key) {
+ Object val = get(key);
+ if (val == null) return null;
+ if (val instanceof Boolean) return (Boolean) val;
+ if (val instanceof Number) return ((Number) val).intValue() != 0;
+ return Boolean.parseBoolean(val.toString());
+ }
+
+ public LocalDateTime getDateTime(String key) {
+ Object val = get(key);
+ if (val == null) return null;
+ if (val instanceof LocalDateTime) return (LocalDateTime) val;
+ if (val instanceof java.sql.Timestamp) return ((java.sql.Timestamp) val).toLocalDateTime();
+ return LocalDateTime.parse(val.toString());
+ }
+
+ // ─── SET ─────────────────────────────────────────────────
+ // Return void — Model redeclares these returning Model for fluent chaining.
+ // Java allows a subclass to redeclare (hide) a void method with a covariant
+ // return type when it's not an @Override of the same signature.
+
+ void _set(String key, Object value) {
+ String castType = meta().casts.get(key);
+ if (castType != null && value != null) value = AttributeCaster.castSet(value, castType);
+ attributes.put(key, value);
+ }
+
+ void _setRaw(String key, Object value) {
+ attributes.put(key, value);
+ }
+
+ public Object getId() { return attributes.get(meta().primaryKey); }
+
+ public Map getAttributes() { return Collections.unmodifiableMap(attributes); }
+
+ // ─── MASS ASSIGNMENT ─────────────────────────────────────
+
+ void _fill(Map attrs) {
+ List fillable = meta().fillable;
+ List guarded = meta().guarded;
+ for (Map.Entry entry : attrs.entrySet()) {
+ if (isFillable(entry.getKey(), fillable, guarded))
+ _set(entry.getKey(), entry.getValue());
+ }
+ }
+
+ void _forceFill(Map attrs) {
+ attributes.putAll(attrs);
+ }
+
+ private boolean isFillable(String key, List fillable, List guarded) {
+ if (!fillable.isEmpty()) return fillable.contains(key);
+ if (guarded.contains("*")) return false;
+ return !guarded.contains(key);
+ }
+
+ // ─── DIRTY TRACKING ──────────────────────────────────────
+
+ /**
+ * Returns {@code true} if any attribute has been modified since the last sync.
+ *
+ * Short-circuits on the first dirty attribute found — does not build
+ * the full dirty map just to check emptiness.
+ */
+ public boolean isDirty() {
+ for (Map.Entry entry : attributes.entrySet()) {
+ if (!Objects.equals(entry.getValue(), original.get(entry.getKey()))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns {@code true} if the specified attribute has been modified since the last sync.
+ *
+ * O(1) — compares the single attribute directly instead of rebuilding the
+ * entire dirty map. Safe to call on models with many attributes.
+ *
+ * @param key the attribute name to check
+ * @return {@code true} if the attribute's current value differs from the original
+ */
+ public boolean isDirty(String key) {
+ return !Objects.equals(attributes.get(key), original.get(key));
+ }
+
+ public Map getDirty() {
+ Map dirty = new LinkedHashMap<>();
+ for (Map.Entry entry : attributes.entrySet()) {
+ if (!Objects.equals(entry.getValue(), original.get(entry.getKey())))
+ dirty.put(entry.getKey(), entry.getValue());
+ }
+ return dirty;
+ }
+
+ public Map getOriginal() { return Collections.unmodifiableMap(original); }
+
+ protected void syncOriginal() {
+ original.clear();
+ original.putAll(attributes);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/orm/model/ModelCollection.java b/src/main/java/com/obsidian/core/database/orm/model/ModelCollection.java
new file mode 100644
index 0000000..c06da88
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/ModelCollection.java
@@ -0,0 +1,398 @@
+package com.obsidian.core.database.orm.model;
+
+import java.util.*;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/**
+ * Enhanced collection of models with utility methods.
+ *
+ * Usage:
+ * ModelCollection users = ModelCollection.of(User.all(User.class));
+ *
+ * users.pluck("name"); // List
+ * users.keyBy("id"); // Map
+ * users.groupBy("role"); // Map>
+ * users.filter(u -> u.getBoolean("active")); // ModelCollection
+ * users.sortBy("name"); // ModelCollection
+ * users.first(); // User or null
+ * users.last(); // User or null
+ * users.chunk(10); // List>
+ * users.ids(); // List
+ * users.toMapList(); // List>
+ */
+public class ModelCollection implements Iterable {
+
+ private final List items;
+
+ /**
+ * Creates a new ModelCollection instance.
+ *
+ * @param items The items
+ */
+ public ModelCollection(List items) {
+ this.items = new ArrayList<>(items);
+ }
+
+ /**
+ * Of.
+ *
+ * @param items The items
+ * @return This instance for method chaining
+ */
+ public static ModelCollection of(List items) {
+ return new ModelCollection<>(items);
+ }
+
+ /**
+ * Empty.
+ *
+ * @return This instance for method chaining
+ */
+ public static ModelCollection empty() {
+ return new ModelCollection<>(Collections.emptyList());
+ }
+
+ // ─── ACCESS ──────────────────────────────────────────────
+
+ /**
+ * Returns all items in the collection.
+ *
+ * @return A list of results
+ */
+ public List all() {
+ return Collections.unmodifiableList(items);
+ }
+
+ /**
+ * Executes the query and returns the results.
+ *
+ * @param index The item index
+ * @return The model instance, or {@code null} if not found
+ */
+ public T get(int index) {
+ return items.get(index);
+ }
+
+ /**
+ * Executes the query and returns the first result, or null.
+ *
+ * @return The model instance, or {@code null} if not found
+ */
+ public T first() {
+ return items.isEmpty() ? null : items.get(0);
+ }
+
+ /**
+ * Returns the last N entries from the query log.
+ *
+ * @return The model instance, or {@code null} if not found
+ */
+ public T last() {
+ return items.isEmpty() ? null : items.get(items.size() - 1);
+ }
+
+ /**
+ * Returns the number of matching rows.
+ *
+ * @return The number of affected rows
+ */
+ public int count() {
+ return items.size();
+ }
+
+ /**
+ * Checks if the collection/result is empty.
+ *
+ * @return {@code true} if the operation succeeded, {@code false} otherwise
+ */
+ public boolean isEmpty() {
+ return items.isEmpty();
+ }
+
+ /**
+ * Checks if the collection/result is not empty.
+ *
+ * @return {@code true} if the operation succeeded, {@code false} otherwise
+ */
+ public boolean isNotEmpty() {
+ return !items.isEmpty();
+ }
+
+ // ─── EXTRACTION ──────────────────────────────────────────
+
+ /**
+ * Extract a single attribute from each model.
+ */
+ public List pluck(String key) {
+ return items.stream()
+ .map(m -> m.get(key))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Extract two attributes as key-value pairs.
+ */
+ public Map pluck(String valueKey, String keyKey) {
+ Map map = new LinkedHashMap<>();
+ for (T item : items) {
+ map.put(item.get(keyKey), item.get(valueKey));
+ }
+ return map;
+ }
+
+ /**
+ * Get all model IDs.
+ */
+ public List ids() {
+ return pluck("id");
+ }
+
+ /**
+ * Key the collection by an attribute.
+ */
+ public Map keyBy(String key) {
+ Map map = new LinkedHashMap<>();
+ for (T item : items) {
+ map.put(item.get(key), item);
+ }
+ return map;
+ }
+
+ /**
+ * Group by an attribute.
+ */
+ public Map> groupBy(String key) {
+ Map> grouped = new LinkedHashMap<>();
+ for (T item : items) {
+ Object val = item.get(key);
+ grouped.computeIfAbsent(val, k -> new ArrayList<>()).add(item);
+ }
+ return grouped;
+ }
+
+ // ─── FILTERING ───────────────────────────────────────────
+
+ /**
+ * Filter with a predicate.
+ */
+ public ModelCollection filter(Predicate predicate) {
+ return new ModelCollection<>(items.stream()
+ .filter(predicate)
+ .collect(Collectors.toList()));
+ }
+
+ /**
+ * Filter where attribute equals value.
+ */
+ public ModelCollection where(String key, Object value) {
+ return filter(m -> Objects.equals(m.get(key), value));
+ }
+
+ /**
+ * Filter where attribute is not null.
+ */
+ public ModelCollection whereNotNull(String key) {
+ return filter(m -> m.get(key) != null);
+ }
+
+ /**
+ * Filter where attribute is in the given list.
+ */
+ public ModelCollection whereIn(String key, List> values) {
+ return filter(m -> values.contains(m.get(key)));
+ }
+
+ /**
+ * Reject items matching predicate (inverse of filter).
+ */
+ public ModelCollection reject(Predicate predicate) {
+ return filter(predicate.negate());
+ }
+
+ /**
+ * Get unique items by attribute.
+ */
+ public ModelCollection unique(String key) {
+ Set seen = new LinkedHashSet<>();
+ List result = new ArrayList<>();
+ for (T item : items) {
+ Object val = item.get(key);
+ if (seen.add(val)) {
+ result.add(item);
+ }
+ }
+ return new ModelCollection<>(result);
+ }
+
+ // ─── SORTING ─────────────────────────────────────────────
+
+ /**
+ * Sort by attribute (ascending).
+ */
+ @SuppressWarnings("unchecked")
+ public ModelCollection sortBy(String key) {
+ List sorted = new ArrayList<>(items);
+ sorted.sort((a, b) -> {
+ Comparable va = (Comparable) a.get(key);
+ Object vb = b.get(key);
+ if (va == null && vb == null) return 0;
+ if (va == null) return -1;
+ if (vb == null) return 1;
+ return va.compareTo(vb);
+ });
+ return new ModelCollection<>(sorted);
+ }
+
+ /**
+ * Sort by attribute (descending).
+ */
+ public ModelCollection sortByDesc(String key) {
+ List sorted = sortBy(key).items;
+ Collections.reverse(sorted);
+ return new ModelCollection<>(sorted);
+ }
+
+ // ─── TRANSFORMATION ──────────────────────────────────────
+
+ /**
+ * Map each model to a value.
+ */
+ public List map(Function mapper) {
+ return items.stream().map(mapper).collect(Collectors.toList());
+ }
+
+ /**
+ * FlatMap across model lists.
+ */
+ public List flatMap(Function> mapper) {
+ return items.stream().flatMap(m -> mapper.apply(m).stream()).collect(Collectors.toList());
+ }
+
+ /**
+ * Execute action on each model.
+ */
+ public ModelCollection each(java.util.function.Consumer action) {
+ items.forEach(action);
+ return this;
+ }
+
+ // ─── SLICING ─────────────────────────────────────────────
+
+ /**
+ * Take first N items.
+ */
+ public ModelCollection take(int n) {
+ return new ModelCollection<>(items.stream().limit(n).collect(Collectors.toList()));
+ }
+
+ /**
+ * Skip first N items.
+ */
+ public ModelCollection skip(int n) {
+ return new ModelCollection<>(items.stream().skip(n).collect(Collectors.toList()));
+ }
+
+ /**
+ * Split into chunks.
+ */
+ public List> chunk(int size) {
+ List> chunks = new ArrayList<>();
+ for (int i = 0; i < items.size(); i += size) {
+ int end = Math.min(i + size, items.size());
+ chunks.add(new ModelCollection<>(items.subList(i, end)));
+ }
+ return chunks;
+ }
+
+ // ─── AGGREGATES ──────────────────────────────────────────
+
+ /**
+ * Sum a numeric attribute.
+ */
+ public double sum(String key) {
+ return items.stream()
+ .map(m -> m.get(key))
+ .filter(Objects::nonNull)
+ .mapToDouble(v -> ((Number) v).doubleValue())
+ .sum();
+ }
+
+ /**
+ * Average of a numeric attribute.
+ */
+ public double avg(String key) {
+ return items.stream()
+ .map(m -> m.get(key))
+ .filter(Objects::nonNull)
+ .mapToDouble(v -> ((Number) v).doubleValue())
+ .average()
+ .orElse(0.0);
+ }
+
+ /**
+ * Max of a numeric attribute.
+ */
+ public Object max(String key) {
+ return items.stream()
+ .map(m -> m.get(key))
+ .filter(Objects::nonNull)
+ .max((a, b) -> Double.compare(
+ ((Number) a).doubleValue(),
+ ((Number) b).doubleValue()))
+ .orElse(null);
+ }
+
+ /**
+ * Min of a numeric attribute.
+ */
+ public Object min(String key) {
+ return items.stream()
+ .map(m -> m.get(key))
+ .filter(Objects::nonNull)
+ .min((a, b) -> Double.compare(
+ ((Number) a).doubleValue(),
+ ((Number) b).doubleValue()))
+ .orElse(null);
+ }
+
+ /**
+ * Check if any model matches.
+ */
+ public boolean contains(Predicate predicate) {
+ return items.stream().anyMatch(predicate);
+ }
+
+ /**
+ * Checks if any item matches the given condition.
+ *
+ * @param key The attribute/column name
+ * @param value The value to compare against
+ * @return {@code true} if the operation succeeded, {@code false} otherwise
+ */
+ public boolean contains(String key, Object value) {
+ return contains(m -> Objects.equals(m.get(key), value));
+ }
+
+ // ─── SERIALIZATION ───────────────────────────────────────
+
+ /**
+ * Convert all models to maps (respects hidden()).
+ */
+ public List> toMapList() {
+ return items.stream().map(Model::toMap).collect(Collectors.toList());
+ }
+
+ // ─── ITERABLE ────────────────────────────────────────────
+
+ @Override
+ public Iterator iterator() {
+ return items.iterator();
+ }
+
+ @Override
+ public String toString() {
+ return "ModelCollection(size=" + items.size() + ")";
+ }
+}
diff --git a/src/main/java/com/obsidian/core/database/orm/model/ModelMetadata.java b/src/main/java/com/obsidian/core/database/orm/model/ModelMetadata.java
new file mode 100644
index 0000000..41973e8
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/ModelMetadata.java
@@ -0,0 +1,100 @@
+package com.obsidian.core.database.orm.model;
+
+import com.obsidian.core.database.orm.model.observer.ModelObserver;
+import com.obsidian.core.database.orm.query.QueryBuilder;
+
+import java.lang.reflect.Constructor;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+/**
+ * Immutable metadata cache for a Model class.
+ */
+final class ModelMetadata
+{
+
+ /** Database table name (from @Table annotation or convention). */
+ final String table;
+
+ /** Primary key column name (default: "id"). */
+ final String primaryKey;
+
+ /** Whether the primary key auto-increments. */
+ final boolean incrementing;
+
+ /** Whether created_at/updated_at are auto-managed. */
+ final boolean timestamps;
+
+ /** Whether soft deletes (deleted_at) are enabled. */
+ final boolean softDeletes;
+
+ /** Attributes excluded from toMap() serialization. */
+ final List hidden;
+
+ /** Attributes allowed for mass-assignment via fill(). */
+ final List fillable;
+
+ /** Attributes blocked from mass-assignment. */
+ final List guarded;
+
+ /** Default attribute values applied on insert. */
+ final Map defaults;
+
+ /** Global query scopes applied to every query. */
+ final List> globalScopes;
+
+ /** Lifecycle observer (creating/updating/deleting callbacks). May be null. */
+ @SuppressWarnings("rawtypes")
+ final ModelObserver observer;
+
+ /** Attribute type casts (column -> type string). */
+ final Map casts;
+
+ /**
+ * Cached no-arg constructor for this model class.
+ * Populated once during metadata creation; reused on every {@code newInstance()} call.
+ * Eliminates the per-call {@code getDeclaredConstructor()} reflection lookup during hydration.
+ */
+ @SuppressWarnings("rawtypes")
+ final Constructor constructor;
+
+ /**
+ * Creates an immutable metadata snapshot.
+ *
+ * @param table Database table name
+ * @param primaryKey Primary key column
+ * @param incrementing Whether PK auto-increments
+ * @param timestamps Whether timestamps are auto-managed
+ * @param softDeletes Whether soft deletes are enabled
+ * @param hidden Hidden attributes list
+ * @param fillable Fillable attributes list
+ * @param guarded Guarded attributes list
+ * @param defaults Default attribute values
+ * @param globalScopes Global query scopes
+ * @param observer Lifecycle observer (may be null)
+ * @param casts Attribute type casts
+ */
+ @SuppressWarnings("rawtypes")
+ ModelMetadata(String table, String primaryKey, boolean incrementing, boolean timestamps,
+ boolean softDeletes, List hidden, List fillable,
+ List guarded, Map defaults,
+ List> globalScopes, ModelObserver observer,
+ Map casts, Constructor constructor
+ ) {
+ this.table = table;
+ this.primaryKey = primaryKey;
+ this.incrementing = incrementing;
+ this.timestamps = timestamps;
+ this.softDeletes = softDeletes;
+ this.hidden = Collections.unmodifiableList(hidden);
+ this.fillable = Collections.unmodifiableList(fillable);
+ this.guarded = Collections.unmodifiableList(guarded);
+ this.defaults = Collections.unmodifiableMap(defaults);
+ this.globalScopes = Collections.unmodifiableList(globalScopes);
+ this.observer = observer;
+ this.casts = Collections.unmodifiableMap(casts);
+ this.constructor = constructor;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/orm/model/ModelNotFoundException.java b/src/main/java/com/obsidian/core/database/orm/model/ModelNotFoundException.java
new file mode 100644
index 0000000..2d2bd14
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/ModelNotFoundException.java
@@ -0,0 +1,13 @@
+package com.obsidian.core.database.orm.model;
+
+public class ModelNotFoundException extends RuntimeException {
+
+ /**
+ * Creates a new ModelNotFoundException instance.
+ *
+ * @param message The message
+ */
+ public ModelNotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/obsidian/core/database/orm/model/ModelPersistence.java b/src/main/java/com/obsidian/core/database/orm/model/ModelPersistence.java
new file mode 100644
index 0000000..6b7ec85
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/ModelPersistence.java
@@ -0,0 +1,142 @@
+package com.obsidian.core.database.orm.model;
+
+import com.obsidian.core.database.orm.model.observer.ModelObserver;
+import com.obsidian.core.database.orm.query.QueryBuilder;
+
+import java.time.LocalDateTime;
+import java.util.*;
+
+/**
+ * Persistence logic: save, insert, update, delete, restore, refresh.
+ * Observer callbacks pass Model — the concrete type known at runtime.
+ */
+@SuppressWarnings({"unchecked", "rawtypes"})
+abstract class ModelPersistence extends ModelAttributes {
+
+ boolean exists = false;
+
+ /** Implemented by Model — returns the concrete instance typed as Model. */
+ abstract Model self();
+
+ abstract ModelMetadata meta();
+
+ // ─── PUBLIC API ──────────────────────────────────────────
+
+ public boolean save() {
+ ModelObserver obs = meta().observer;
+ if (obs != null && !obs.saving(self())) return false;
+ boolean result = exists ? performUpdate() : performInsert();
+ if (result && obs != null) obs.saved(self());
+ return result;
+ }
+
+ public boolean saveIt() { return save(); }
+
+ public boolean delete() {
+ if (!exists) return false;
+ ModelMetadata m = meta();
+ ModelObserver obs = m.observer;
+ if (obs != null && !obs.deleting(self())) return false;
+
+ if (m.softDeletes) {
+ _set("deleted_at", LocalDateTime.now());
+ Map updateMap = new LinkedHashMap<>();
+ updateMap.put("deleted_at", get("deleted_at"));
+ new QueryBuilder(m.table)
+ .where(m.primaryKey, getId())
+ .update(updateMap);
+ syncOriginal();
+ } else {
+ new QueryBuilder(m.table).where(m.primaryKey, getId()).delete();
+ exists = false;
+ }
+
+ if (obs != null) obs.deleted(self());
+ return true;
+ }
+
+ public boolean restore() {
+ ModelMetadata m = meta();
+ if (!m.softDeletes) return false;
+ ModelObserver obs = m.observer;
+ if (obs != null && !obs.restoring(self())) return false;
+
+ _set("deleted_at", null);
+ Map update = new LinkedHashMap<>();
+ update.put("deleted_at", null);
+ new QueryBuilder(m.table).where(m.primaryKey, getId()).update(update);
+ syncOriginal();
+
+ if (obs != null) obs.restored(self());
+ return true;
+ }
+
+ public boolean forceDelete() {
+ ModelMetadata m = meta();
+ new QueryBuilder(m.table).where(m.primaryKey, getId()).delete();
+ exists = false;
+ return true;
+ }
+
+ void _refresh() {
+ ModelMetadata m = meta();
+ Map row = new QueryBuilder(m.table)
+ .where(m.primaryKey, getId()).first();
+ if (row != null) {
+ attributes.clear();
+ attributes.putAll(row);
+ syncOriginal();
+ }
+ }
+
+ public boolean exists() { return exists; }
+
+ // ─── INTERNAL ────────────────────────────────────────────
+
+ private boolean performInsert() {
+ ModelMetadata m = meta();
+ ModelObserver obs = m.observer;
+ if (obs != null && !obs.creating(self())) return false;
+
+ for (Map.Entry entry : m.defaults.entrySet())
+ attributes.putIfAbsent(entry.getKey(), entry.getValue());
+
+ if (m.timestamps) {
+ LocalDateTime now = LocalDateTime.now();
+ attributes.putIfAbsent("created_at", now);
+ attributes.putIfAbsent("updated_at", now);
+ }
+
+ Map insertData = new LinkedHashMap<>(attributes);
+ if (m.incrementing && insertData.get(m.primaryKey) == null)
+ insertData.remove(m.primaryKey);
+
+ Object generatedId = new QueryBuilder(m.table).insert(insertData);
+ if (m.incrementing && generatedId != null)
+ attributes.put(m.primaryKey, generatedId);
+
+ exists = true;
+ syncOriginal();
+ if (obs != null) obs.created(self());
+ return true;
+ }
+
+ private boolean performUpdate() {
+ ModelMetadata m = meta();
+ ModelObserver obs = m.observer;
+ if (obs != null && !obs.updating(self())) return false;
+
+ Map dirty = getDirty();
+ if (dirty.isEmpty()) return true;
+
+ if (m.timestamps) {
+ dirty.put("updated_at", LocalDateTime.now());
+ attributes.put("updated_at", dirty.get("updated_at"));
+ }
+
+ new QueryBuilder(m.table).where(m.primaryKey, getId()).update(dirty);
+ syncOriginal();
+ if (obs != null) obs.updated(self());
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/orm/model/ModelQueryBuilder.java b/src/main/java/com/obsidian/core/database/orm/model/ModelQueryBuilder.java
new file mode 100644
index 0000000..4b5f883
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/ModelQueryBuilder.java
@@ -0,0 +1,661 @@
+package com.obsidian.core.database.orm.model;
+
+import com.obsidian.core.database.orm.query.QueryBuilder;
+import com.obsidian.core.database.orm.model.relation.Relation;
+import com.obsidian.core.database.orm.pagination.Paginator;
+
+import java.lang.reflect.Method;
+import java.util.*;
+import java.util.function.Consumer;
+
+/**
+ * Model-aware query builder.
+ * Wraps QueryBuilder and returns hydrated Model instances.
+ *
+ * Usage:
+ * User.query(User.class)
+ * .where("active", 1)
+ * .with("posts", "profile")
+ * .orderBy("name")
+ * .get();
+ */
+public class ModelQueryBuilder {
+
+ private final Class modelClass;
+ private final QueryBuilder queryBuilder;
+ private final List eagerLoads = new ArrayList<>();
+ private final boolean softDeletesEnabled;
+ private boolean withTrashed = false;
+
+ /**
+ * Creates a new model-aware query builder.
+ *
+ * @param modelClass The model class for hydration
+ * @param table The database table name
+ * @param globalScopes List of global scope functions to apply automatically
+ * @param softDeletes Whether the model uses soft deletes (auto-adds whereNull("deleted_at"))
+ */
+ public ModelQueryBuilder(Class modelClass, String table,
+ List> globalScopes, boolean softDeletes) {
+ this.modelClass = modelClass;
+ this.softDeletesEnabled = softDeletes;
+ this.queryBuilder = new QueryBuilder(table);
+
+ // Apply global scopes
+ for (Consumer scope : globalScopes) {
+ scope.accept(queryBuilder);
+ }
+
+ // Apply soft delete scope (tracked so withTrashed() can remove it)
+ if (softDeletes) {
+ queryBuilder.whereNull("deleted_at");
+ }
+ }
+
+ // ─── DELEGATE TO QUERY BUILDER ───────────────────────────
+
+ /**
+ * Specifies which columns to retrieve.
+ *
+ * @param cols Column names
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder select(String... cols) {
+ queryBuilder.select(cols);
+ return this;
+ }
+
+ /**
+ * Adds a raw expression to the SELECT clause.
+ * The caller is responsible for ensuring {@code expression} is safe.
+ *
+ * @param expression A raw SQL expression (trusted caller input only)
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder selectRaw(String expression) {
+ queryBuilder.selectRaw(expression);
+ return this;
+ }
+
+ /**
+ * Adds DISTINCT to the SELECT clause.
+ *
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder distinct() {
+ queryBuilder.distinct();
+ return this;
+ }
+
+ /**
+ * Adds a WHERE condition to the query.
+ *
+ * @param column The column name
+ * @param operator The comparison operator (=, !=, >, <, >=, <=, LIKE, etc.)
+ * @param value The value to compare against
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder where(String column, String operator, Object value) {
+ queryBuilder.where(column, operator, value);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE condition to the query.
+ *
+ * @param column The column name
+ * @param value The value to compare against
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder where(String column, Object value) {
+ queryBuilder.where(column, value);
+ return this;
+ }
+
+ /**
+ * Adds an OR WHERE condition to the query.
+ *
+ * @param column The column name
+ * @param operator The comparison operator (=, !=, >, <, >=, <=, LIKE, etc.)
+ * @param value The value to compare against
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder orWhere(String column, String operator, Object value) {
+ queryBuilder.orWhere(column, operator, value);
+ return this;
+ }
+
+ /**
+ * Adds an OR WHERE condition to the query.
+ *
+ * @param column The column name
+ * @param value The value to compare against
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder orWhere(String column, Object value) {
+ queryBuilder.orWhere(column, value);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE column IS NULL condition.
+ *
+ * @param column The column name
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder whereNull(String column) {
+ queryBuilder.whereNull(column);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE column IS NOT NULL condition.
+ *
+ * @param column The column name
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder whereNotNull(String column) {
+ queryBuilder.whereNotNull(column);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE column IN (...) condition.
+ *
+ * @param column The column name
+ * @param values The list of values
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder whereIn(String column, List> values) {
+ queryBuilder.whereIn(column, values);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE column NOT IN (...) condition.
+ *
+ * @param column The column name
+ * @param values The list of values
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder whereNotIn(String column, List> values) {
+ queryBuilder.whereNotIn(column, values);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE column BETWEEN low AND high condition.
+ *
+ * @param column The column name
+ * @param low The lower bound of the range
+ * @param high The upper bound of the range
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder whereBetween(String column, Object low, Object high) {
+ queryBuilder.whereBetween(column, low, high);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE column LIKE pattern condition.
+ *
+ * @param column The column name
+ * @param pattern The LIKE pattern (e.g. "%john%")
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder whereLike(String column, String pattern) {
+ queryBuilder.whereLike(column, pattern);
+ return this;
+ }
+
+ /**
+ * Adds a raw WHERE clause (not escaped).
+ *
+ * @param sql Raw SQL string
+ * @param params Parameter values to bind to SQL placeholders
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder whereRaw(String sql, Object... params) {
+ queryBuilder.whereRaw(sql, params);
+ return this;
+ }
+
+ /**
+ * Adds a WHERE condition to the query.
+ *
+ * @param group A callback that receives a nested QueryBuilder for grouping conditions
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder where(Consumer group) {
+ queryBuilder.where(group);
+ return this;
+ }
+
+ /**
+ * Adds an INNER JOIN clause to the query.
+ *
+ * @param table The table name
+ * @param first The first column in the join condition
+ * @param op The comparison operator
+ * @param second The second column in the join condition
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder join(String table, String first, String op, String second) {
+ queryBuilder.join(table, first, op, second);
+ return this;
+ }
+
+ /**
+ * Adds a LEFT JOIN clause to the query.
+ *
+ * @param table The table name
+ * @param first The first column in the join condition
+ * @param op The comparison operator
+ * @param second The second column in the join condition
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder leftJoin(String table, String first, String op, String second) {
+ queryBuilder.leftJoin(table, first, op, second);
+ return this;
+ }
+
+ /**
+ * Adds an ORDER BY clause to the query.
+ *
+ * @param column The column name
+ * @param direction The sort direction ("ASC" or "DESC")
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder orderBy(String column, String direction) {
+ queryBuilder.orderBy(column, direction);
+ return this;
+ }
+
+ /**
+ * Adds an ORDER BY clause to the query.
+ *
+ * @param column The column name
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder orderBy(String column) {
+ queryBuilder.orderBy(column);
+ return this;
+ }
+
+ /**
+ * Adds an ORDER BY ... DESC clause to the query.
+ *
+ * @param column The column name
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder orderByDesc(String column) {
+ queryBuilder.orderByDesc(column);
+ return this;
+ }
+
+ /**
+ * Orders by the given column descending (default: created_at).
+ *
+ * @param column The column name
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder latest(String column) {
+ queryBuilder.latest(column);
+ return this;
+ }
+
+ /**
+ * Orders by the given column descending (default: created_at).
+ *
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder latest() {
+ queryBuilder.latest();
+ return this;
+ }
+
+ /**
+ * Orders by the given column ascending (default: created_at).
+ *
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder oldest() {
+ queryBuilder.oldest();
+ return this;
+ }
+
+ /**
+ * Adds a GROUP BY clause to the query.
+ *
+ * @param cols Column names
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder groupBy(String... cols) {
+ queryBuilder.groupBy(cols);
+ return this;
+ }
+
+ /**
+ * Adds a HAVING clause to the query.
+ *
+ * @param column The column name
+ * @param op The comparison operator
+ * @param value The value to compare against
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder having(String column, String op, Object value) {
+ queryBuilder.having(column, op, value);
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of rows to return.
+ *
+ * @param limit Maximum number of rows
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder limit(int limit) {
+ queryBuilder.limit(limit);
+ return this;
+ }
+
+ /**
+ * Sets the number of rows to skip.
+ *
+ * @param offset Number of rows to skip
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder offset(int offset) {
+ queryBuilder.offset(offset);
+ return this;
+ }
+
+ /**
+ * Sets limit and offset for pagination (page starts at 1).
+ *
+ * @param page Page number (starts at 1)
+ * @param perPage Number of items per page
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder forPage(int page, int perPage) {
+ queryBuilder.forPage(page, perPage);
+ return this;
+ }
+
+ // ─── SOFT DELETE CONTROL ─────────────────────────────────
+
+ /**
+ * Include soft-deleted records in the results.
+ * Removes the automatic {@code WHERE deleted_at IS NULL} filter.
+ *
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder withTrashed() {
+ this.withTrashed = true;
+ if (softDeletesEnabled) {
+ queryBuilder.removeWhereNull("deleted_at");
+ }
+ return this;
+ }
+
+ /**
+ * Return only soft-deleted records.
+ * Removes the IS NULL filter and adds IS NOT NULL.
+ *
+ * @return This builder instance for method chaining
+ */
+ public ModelQueryBuilder onlyTrashed() {
+ withTrashed();
+ queryBuilder.whereNotNull("deleted_at");
+ return this;
+ }
+
+ // ─── EAGER LOADING ───────────────────────────────────────
+
+ /**
+ * Eager load relations.
+ * User.query(User.class).with("posts", "profile").get();
+ */
+ public ModelQueryBuilder with(String... relations) {
+ eagerLoads.addAll(Arrays.asList(relations));
+ return this;
+ }
+
+ // ─── SCOPES ──────────────────────────────────────────────
+
+ /**
+ * Apply a local scope.
+ * User.query(User.class).scope(User::active).get();
+ */
+ public ModelQueryBuilder scope(Consumer scope) {
+ scope.accept(queryBuilder);
+ return this;
+ }
+
+ // ─── EXECUTION ───────────────────────────────────────────
+
+ /**
+ * Execute query and return hydrated models.
+ */
+ public List get() {
+ List> rows = queryBuilder.get();
+ List models = Model.hydrateList(modelClass, rows);
+
+ // Eager load relations
+ if (!eagerLoads.isEmpty() && !models.isEmpty()) {
+ eagerLoadRelations(models);
+ }
+
+ return models;
+ }
+
+ /**
+ * Get first result or null.
+ */
+ public T first() {
+ queryBuilder.limit(1);
+ List results = get();
+ return results.isEmpty() ? null : results.get(0);
+ }
+
+ /**
+ * First or throw.
+ */
+ public T firstOrFail() {
+ T model = first();
+ if (model == null) {
+ throw new ModelNotFoundException(modelClass.getSimpleName() + " not found");
+ }
+ return model;
+ }
+
+ /**
+ * Find by ID.
+ */
+ public T find(Object id) {
+ T instance = Model.newInstance(modelClass);
+ return where(instance.primaryKey(), id).first();
+ }
+
+ /**
+ * Get a single column as list.
+ */
+ public List pluck(String column) {
+ return queryBuilder.pluck(column);
+ }
+
+ /**
+ * Count.
+ */
+ public long count() {
+ return queryBuilder.count();
+ }
+
+ /**
+ * Checks if any rows match the query.
+ *
+ * @return {@code true} if the operation succeeded, {@code false} otherwise
+ */
+ public boolean exists() {
+ return queryBuilder.exists();
+ }
+
+ /**
+ * Checks if no rows match the query.
+ *
+ * @return {@code true} if the operation succeeded, {@code false} otherwise
+ */
+ public boolean doesntExist() {
+ return queryBuilder.doesntExist();
+ }
+
+ /**
+ * Aggregates.
+ */
+ public Object max(String column) { return queryBuilder.max(column); }
+ /**
+ * Returns the minimum value of a column.
+ *
+ * @param column The column name
+ * @return The result value, or {@code null} if not found
+ */
+ public Object min(String column) { return queryBuilder.min(column); }
+ /**
+ * Returns the sum of a column.
+ *
+ * @param column The column name
+ * @return The result value, or {@code null} if not found
+ */
+ public Object sum(String column) { return queryBuilder.sum(column); }
+ /**
+ * Returns the average of a column.
+ *
+ * @param column The column name
+ * @return The result value, or {@code null} if not found
+ */
+ public Object avg(String column) { return queryBuilder.avg(column); }
+
+ /**
+ * Update matching rows.
+ */
+ public int update(Map values) {
+ return queryBuilder.update(values);
+ }
+
+ /**
+ * Delete matching rows.
+ */
+ public int delete() {
+ return queryBuilder.delete();
+ }
+
+ /**
+ * Paginate results without mutating this builder.
+ *
+ * The previous implementation called {@code count()} then {@code forPage()} on the same
+ * underlying {@link QueryBuilder}, permanently adding LIMIT/OFFSET to its state. Any
+ * subsequent call on the same builder (e.g. a second {@code paginate()} or a {@code get()})
+ * would silently return wrong results.
+ *
+ * This implementation keeps the original builder untouched:
+ *
+ * The total count is obtained via {@code count()} — which already uses a separate
+ * aggregate query internally and does not mutate the builder.
+ * A fresh page query is built by copying the current SQL and bindings into a new
+ * raw {@link QueryBuilder}, then applying LIMIT/OFFSET only there.
+ *
+ *
+ * @param page page number, starting at 1
+ * @param perPage number of items per page
+ * @return a {@link Paginator} with items and metadata
+ */
+ public Paginator paginate(int page, int perPage) {
+ // Step 1: total count — non-mutating (aggregateValue runs a separate query internally)
+ long total = count();
+
+ // Step 2: fetch the page using a fresh builder scoped to this page only.
+ // We re-use toSql() + bindings so all WHERE/JOIN/ORDER clauses are preserved,
+ // then wrap in a raw QueryBuilder and apply LIMIT/OFFSET without touching `this`.
+ String baseSql = queryBuilder.toSql();
+ List baseBindings = new ArrayList<>(queryBuilder.getBindings());
+
+ int offset = (page - 1) * perPage;
+ String pageSql = baseSql + " LIMIT " + perPage + " OFFSET " + offset;
+
+ List> rows = QueryBuilder.raw(pageSql,
+ baseBindings.toArray());
+ List items = Model.hydrateList(modelClass, rows);
+
+ if (!eagerLoads.isEmpty() && !items.isEmpty()) {
+ eagerLoadRelations(items);
+ }
+
+ return new Paginator<>(items, total, perPage, page);
+ }
+
+ /**
+ * Paginate with the default page size of 15.
+ *
+ * @param page page number, starting at 1
+ * @return a {@link Paginator} with items and metadata
+ */
+ public Paginator paginate(int page) {
+ return paginate(page, 15);
+ }
+
+ /**
+ * Get the raw SQL.
+ */
+ public String toSql() {
+ return queryBuilder.toSql();
+ }
+
+ /**
+ * Returns the query builder.
+ *
+ * @return The query builder
+ */
+ public QueryBuilder getQueryBuilder() {
+ return queryBuilder;
+ }
+
+ // ─── EAGER LOADING LOGIC ─────────────────────────────────
+
+ /**
+ * Cache of reflected relation methods per model class.
+ * Avoids repeated getDeclaredMethod() calls on the same class.
+ */
+ private static final java.util.concurrent.ConcurrentHashMap relationMethodCache =
+ new java.util.concurrent.ConcurrentHashMap<>();
+
+ @SuppressWarnings("unchecked")
+ private void eagerLoadRelations(List models) {
+ for (String relationName : eagerLoads) {
+ try {
+ // Cache key: ClassName.relationName
+ String cacheKey = modelClass.getName() + "." + relationName;
+ Method method = relationMethodCache.computeIfAbsent(cacheKey, k -> {
+ try {
+ Method m = modelClass.getDeclaredMethod(relationName);
+ m.setAccessible(true);
+ return m;
+ } catch (NoSuchMethodException e) {
+ throw new RuntimeException("Relation method '" + relationName
+ + "' not found on " + modelClass.getSimpleName());
+ }
+ });
+
+ T sample = models.get(0);
+ Object relation = method.invoke(sample);
+
+ if (relation instanceof Relation) {
+ ((Relation>) relation).eagerLoad(models, relationName);
+ }
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to eager load relation: " + relationName, e);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/obsidian/core/database/orm/model/ModelRelations.java b/src/main/java/com/obsidian/core/database/orm/model/ModelRelations.java
new file mode 100644
index 0000000..c64c02b
--- /dev/null
+++ b/src/main/java/com/obsidian/core/database/orm/model/ModelRelations.java
@@ -0,0 +1,97 @@
+package com.obsidian.core.database.orm.model;
+
+import com.obsidian.core.database.orm.model.relation.*;
+
+import java.util.*;
+
+/**
+ * Relation factory methods and the loaded-relation cache.
+ */
+abstract class ModelRelations extends ModelPersistence {
+
+ private final Map> loadedRelations = new LinkedHashMap<>();
+
+ abstract String primaryKey();
+
+ // ─── RELATION FACTORIES ──────────────────────────────────
+
+ protected HasOne hasOne(Class related, String foreignKey) {
+ return new HasOne<>(self(), related, foreignKey, primaryKey());
+ }
+
+ protected HasOne hasOne(Class related) {
+ return hasOne(related, getClass().getSimpleName().toLowerCase() + "_id");
+ }
+
+ protected HasMany hasMany(Class related, String foreignKey) {
+ return new HasMany<>(self(), related, foreignKey, primaryKey());
+ }
+
+ protected HasMany hasMany(Class related) {
+ return hasMany(related, getClass().getSimpleName().toLowerCase() + "_id");
+ }
+
+ protected BelongsTo belongsTo(Class related, String foreignKey) {
+ T instance = Model.newInstance(related);
+ return new BelongsTo<>(self(), related, foreignKey, instance.primaryKey());
+ }
+
+ protected BelongsTo belongsTo(Class related) {
+ return belongsTo(related, related.getSimpleName().toLowerCase() + "_id");
+ }
+
+ protected BelongsToMany belongsToMany(Class related, String pivotTable,
+ String foreignPivotKey, String relatedPivotKey) {
+ return new BelongsToMany<>(self(), related, pivotTable, foreignPivotKey, relatedPivotKey);
+ }
+
+ protected BelongsToMany belongsToMany(Class related, String pivotTable) {
+ String fk = getClass().getSimpleName().toLowerCase() + "_id";
+ String rk = related.getSimpleName().toLowerCase() + "_id";
+ return belongsToMany(related, pivotTable, fk, rk);
+ }
+
+ protected HasManyThrough hasManyThrough(
+ Class related, Class extends Model> through,
+ String firstKey, String secondKey, String localKey, String secondLocalKey) {
+ return new HasManyThrough<>(self(), related, through, firstKey, secondKey, localKey, secondLocalKey);
+ }
+
+ protected HasManyThrough hasManyThrough(
+ Class related, Class extends Model> through) {
+ String firstKey = getClass().getSimpleName().toLowerCase() + "_id";
+ String secondKey = through.getSimpleName().toLowerCase() + "_id";
+ return hasManyThrough(related, through, firstKey, secondKey, "id", "id");
+ }
+
+ protected MorphOne morphOne(Class