Persistence¶
Katalyst's persistence layer (katalyst-persistence) wraps Exposed 1.3.x and HikariCP. You
define tables and repositories; Katalyst manages the connection pool, discovers your types,
and injects repositories. This page documents the public surface. For a task walkthrough, see
Define tables and repositories.
Use the org.jetbrains.exposed.v1.* import set (the v1 JDBC API) throughout.
Identifiable¶
Entities used with Table and CrudRepository must implement Identifiable<Id>. This is the
contract that tells the framework where the primary key lives, so it is part of both type
bounds — not optional.
import io.github.darkryh.katalyst.repositories.Identifiable
data class AuthAccount(
override val id: Long? = null, // null before insert; save() uses this to insert vs update
val email: String,
val status: String = "active"
) : Identifiable<Long>
Table¶
interface Table<Id, Entity : Identifiable<Id>> where Id : Any, Id : Comparable<Id> {
val mapping: EntityMapping<Id, Entity>
}
A table is an Exposed IdTable (commonly LongIdTable) that also implements
Table<Id, Entity>. The mapping property tells Katalyst how to read rows and write inserts
and updates.
mapping DSL¶
mapping<Id, Entity> { … } builds an EntityMapping. Inside the block:
| Call | Purpose |
|---|---|
generatedId(column, property) |
Map an auto-generated primary key. |
field(column, property) |
Map a column to a property in both directions. |
reference(column, property) |
Map a foreign-key reference column. |
construct { … } |
Build an entity from a MappedRow (this[column]). |
object AuthAccountsTable : LongIdTable("auth_accounts"), Table<Long, AuthAccount> {
val email = varchar("email", 150).uniqueIndex()
val status = varchar("status", 32).default("active")
override val mapping = mapping<Long, AuthAccount> {
generatedId(id, AuthAccount::id)
field(email, AuthAccount::email)
field(status, AuthAccount::status)
construct { AuthAccount(id = this[id], email = this[email], status = this[status]) }
}
}
Mappings use raw ID values; the layer delegates to Exposed's typed setters internally, so you
do not wrap inserts/updates in EntityID. Custom query predicates that compare a reference
column directly may still need EntityID.
CrudRepository¶
interface CrudRepository<Id, Entity : Identifiable<Id>> where Id : Any, Id : Comparable<Id> {
val table: IdTable<Id>
// inherited helpers: save (insert if id == null, else update),
// findById, findAll, deleteById, map, …
}
What save() does with a non-null id¶
save() inserts when id is null and updates otherwise. The interesting case is an update that
matches no row, and the answer depends on who owns the primary key:
generatedId(...)— the database issued the id, so a non-null id can only have come from a row that once existed. If it is gone, the row was deleted, andsave()raisesStaleEntityExceptionnaming the table and id rather than re-inserting under that id. Silently recreating it would undo someone else's delete. Recover by re-reading the entity, or by saving withid = nullif a new row is genuinely wanted.assignedId(...)— the caller owns the id and every insert arrives with one, so there is nothing suspicious about an update matching no row.save()inserts, giving create-or-update semantics.
Implement it and point table at your table. Repositories are discovered under scanned
packages and injected by type. Add custom queries with the Exposed DSL and map(row) to
convert results:
class AuthAccountRepository : CrudRepository<Long, AuthAccount> {
override val table: LongIdTable = AuthAccountsTable
fun findByEmail(email: String): AuthAccount? =
AuthAccountsTable.selectAll().where { AuthAccountsTable.email eq email }
.limit(1).firstOrNull()?.let(::map)
}
Supporting model types: PageInfo, QueryFilter, SortOrder (see Identifiable
for the entity contract).
Query operators¶
The Exposed DSL operators used in where { … } clauses:
| Operator | Meaning | Example |
|---|---|---|
eq |
Equals | name eq "John" |
neq |
Not equals | status neq "disabled" |
less, lessEq |
<, <= |
age less 18 |
greater, greaterEq |
>, >= |
score greater 100 |
inList |
IN |
status inList listOf("active", "pending") |
notInList |
NOT IN |
status notInList listOf("deleted") |
like |
LIKE |
email like "%@example.com" |
isNull, isNotNull |
Null checks | deletedAt.isNull() |
between |
BETWEEN |
createdAt between (start to end) |
and, or |
Combine conditions | (a eq 1) and (b eq 2) |
Common imports:
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
SqlExecutor¶
A managed low-level JDBC API for bootstrap DDL and custom SQL that Exposed does not cover. Inject it like any dependency.
interface SqlExecutor {
fun executeUpdate(sql: String, params: List<Any?> = emptyList()): Int
fun <T> query(sql: String, params: List<Any?> = emptyList(), map: (ResultSet) -> T): List<T>
fun <T> queryOne(sql: String, params: List<Any?> = emptyList(), map: (ResultSet) -> T): T?
fun executeBatch(statements: List<String>)
}
val affected = sqlExecutor.executeUpdate(
"UPDATE auth_accounts SET status = ? WHERE id = ?",
listOf("active", accountId)
)
val emails = sqlExecutor.query(
"SELECT email FROM auth_accounts WHERE status = ?",
listOf("active")
) { row -> row.getString("email") }
Behavior:
- Uses the Katalyst-managed datasource and pool — no raw
DriverManager. - Reuses the active Exposed transaction's connection when called inside one.
- Outside a transaction, runs with pooled connections and commit/rollback handling.
- Wraps low-level failures in
SqlExecutionException.
DatabaseFactory¶
Manages the Exposed Database instance, the HikariCP pool, lifecycle, and the schema policy.
Injected automatically; accessible in migrations and advanced scenarios.
class MyMigration(private val databaseFactory: DatabaseFactory) : KatalystMigration {
override val id = "001_fix"
override fun up() {
transaction(databaseFactory.database) { exec("SELECT 1") }
}
}
DatabasePoolSnapshot exposes pool metrics for monitoring.
DatabaseConfig¶
The programmatic database configuration passed to database(DatabaseConfig(...)) or built by
database { fromConfiguration() }.
| Field | Type | Notes |
|---|---|---|
url |
String |
JDBC URL. |
driver |
String |
JDBC driver class. |
username |
String |
|
password |
String |
|
maxPoolSize |
Int |
Hikari max pool size. |
minIdleConnections |
Int |
Hikari minimum idle. |
connectionTimeout |
Long (ms) |
|
idleTimeout |
Long (ms) |
|
maxLifetime |
Long (ms) |
|
autoCommit |
Boolean |
|
transactionIsolation |
String |
Isolation level name. |
Undo strategies¶
katalyst-persistence also ships an undo/compensation toolkit for reversible operations:
UndoStrategy, UndoStrategyRegistry, SimpleUndoEngine, EnhancedUndoEngine, the
built-in InsertUndoStrategy / UpdateUndoStrategy / DeleteUndoStrategy /
APICallUndoStrategy, and a RetryPolicy. These support building compensating actions for
workflow-style operations.
See also¶
- Define tables and repositories
- Transactions — wrapping repository calls transactionally.
- Migrations — evolving the schema over time.