A coroutine-first SQL toolkit with compile-time query validations for Kotlin Multiplatform. PostgreSQL, MySQL/MariaDB, and SQLite are supported.
sqlx4k is not an ORM. Instead, it provides a comprehensive toolkit of primitives and utilities to communicate directly with your database. The focus is on giving you control while catching errors early through compile-time query validation—preventing runtime surprises before they happen (see SQL syntax validation (compile-time) and SQL schema validation (compile-time) for more details).
The library is designed to be extensible, with a growing ecosystem of tools and extensions like PGMQ (PostgreSQL Message Queue), SQLDelight integration, and more.
🏠 Homepage (under construction)
Short deep‑dive posts covering Kotlin/Native, FFI, and Rust ↔ Kotlin interop used in sqlx4k:
- Introduction to the Kotlin Native and FFI: [Part 1], [Part 2]
- Interoperability between Kotlin and Rust, using FFI: [Part 1], (Part 2 soon)
implementation("io.github.smyrgeorge:sqlx4k-postgres:x.y.z")
// or for MySQL
implementation("io.github.smyrgeorge:sqlx4k-mysql:x.y.z")
// or for SQLite
implementation("io.github.smyrgeorge:sqlx4k-sqlite:x.y.z")
// or for SQLite with encryption (SQLCipher)
implementation("io.github.smyrgeorge:sqlx4k-sqlite-cipher:x.y.z")Or let the Gradle plugin add the driver and set up the code generation for you:
plugins {
kotlin("multiplatform") // or kotlin("jvm")
id("io.github.smyrgeorge.sqlx4k") version "x.y.z"
}
sqlx4k {
driver = PostgreSQL // also: MySQL, MariaDB, SQLite, SQLiteCipher
generatedCodePackage = "com.example.generated"
}- Supported databases
- Gradle plugin (sqlx4k-gradle-plugin)
- Async I/O & coroutines
- Connection pool and settings
- Acquiring and using connections
- Running queries
- Prepared statements (named and positional parameters)
- Row mappers
- Custom Value Converters
- Transactions
- Code generation: CRUD and @Repository implementations
- Customizing columns with @Column
- Excluding properties with @Transient
- Optimistic locking with @Version
- Auto-Generated RowMapper
- Batch Operations
- Property-Level Converters
- Context-Parameters
- Repository hooks
- List of Repository interfaces
- SQL syntax validation (compile-time)
- SQL schema validation (compile-time)
- Query validations and optimizations
- In-memory repositories (for unit testing)
- Database migrations
- Extensions
- Supported targets
- Driver-level interceptor. A QueryListener on the driver gives query logging, slow-query warnings, and metrics.
- Pool lifecycle hooks and health. afterConnect for session setup such as search_path, timezone, or application_name, plus ping () and acquire-wait metrics.
- Postgres COPY. COPY FROM STDIN is an order of magnitude faster than multi-row INSERT for bulk loads.
kotlinx.serializationmodule. JSON and JSONB columns to and from @Serializable classes, and a generic @Converter for any serializable type.- Type coverage, NUMERIC/decimal, Duration or interval, enum or composite types.
- WASM support (?).
The Gradle plugin sets up sqlx4k in a project from a single sqlx4k { } block, replacing the manual KSP wiring shown
in Code-Generation. When applied, it:
- applies the KSP Gradle plugin and registers the sqlx4k code generator (
sqlx4k-codegen) on the configured source sets (commonMainby default), at the plugin's own version, - passes to the code generator the SQL dialect of the chosen
driverand thegeneratedCodePackage, followed by any option you put inargs(so anargsentry can override both), - adds the generated sources of
commonMainto the project and orders every Kotlin compilation and KSP task after thecommonMaincode generation, so the generated code exists before anything compiles, - adds the driver (e.g.
sqlx4k-postgres) and the enabled extensions (e.g.sqlx4k-postgres-pgmq) as dependencies at the matching version, unlessaddDependencies = false.
A complete build script (see the examples, which are built with the plugin):
plugins {
kotlin("multiplatform") // or kotlin("jvm")
id("io.github.smyrgeorge.sqlx4k") version "x.y.z"
}
kotlin {
jvm()
macosArm64 { binaries { executable() } }
// Include other targets as needed
}
sqlx4k {
driver = PostgreSQL // also: MySQL, MariaDB, SQLite, SQLiteCipher
generatedCodePackage = "io.github.smyrgeorge.sqlx4k.examples.postgres"
extensions(Pgmq) // sqlx4k extensions; Pgmq (`sqlx4k-postgres-pgmq`) is PostgreSQL only, Arrow (`sqlx4k-arrow`)
// Any sqlx4k code-generator option, applied last.
// See "Code-Generation" below for the full list.
args = mapOf("expand-select-star" to "false")
}| Option | Default | Description |
|---|---|---|
driver |
required | The database driver. It also selects the SQL dialect of the code generator. |
generatedCodePackage |
required | The package of the generated sources (the code generator's output-package). |
sourceSets |
["commonMain"] |
The source sets the code generator processes: commonMain (generated once, visible to every target), a target's <target>Main, or main for plain JVM projects. |
extensions(...) |
none | The sqlx4k extensions to add: Pgmq (PostgreSQL only) and Arrow. |
args / arg(k, v) |
none | Code-generator options, applied last (an entry under dialect or output-package overrides the derived value). |
addDependencies |
true |
Whether the driver and extension dependencies are added at the plugin's version. Disable to manage them (and their versions) yourself. |
The options are documented in detail in
Sqlx4kExtension.kt.
For a plain JVM project (kotlin("jvm")) set sourceSets = listOf("main"); KSP then wires the generated sources into
the compilation itself.
Every query API in sqlx4k is a suspend function that returns a Result: execute, fetchAll, begin,
transaction { } and the generated repository methods suspend instead of blocking the calling thread, and report
failures through the Result instead of throwing. Database calls therefore compose with the rest of your coroutine
code, from async and withTimeout to structured concurrency, and the I/O underneath is non-blocking on every
platform.
Every driver manages a pool of connections. A query issued directly on the driver (db.fetchAll(...)) borrows a
connection for its duration and returns it; db.acquire() and db.begin() hold one until it is released, committed
or rolled back. The pool is configured with ConnectionPool.Options, passed to the driver's constructor.
| Option | Default | What it controls |
|---|---|---|
minConnections |
driver default | The number of connections the pool keeps open at all times, so they are ready during a burst instead of being opened on demand. Must not exceed maxConnections. |
maxConnections |
10 |
The upper bound on open connections. Size it to what the database can serve: once reached, callers wait for a connection to be returned. |
acquireTimeout |
driver default | How long a caller waits for a free connection before the operation fails with SQLError.Code.PoolTimedOut. Set it so a saturated pool surfaces as a timely error rather than a hang. |
idleTimeout |
driver default | How long an unused connection may stay in the pool before it is closed, letting the pool shrink back after a burst. Must not exceed maxLifetime. |
maxLifetime |
driver default | The maximum age of a connection. Once reached it is closed and replaced, even if healthy, which keeps long-lived connections from accumulating server-side state or outliving a failover. |
An option left unset keeps the default of the underlying driver (sqlx on native targets, the R2DBC pool on the JVM).
Every value must be positive, minConnections must not exceed maxConnections and idleTimeout must not exceed
maxLifetime; a violation fails at construction, before any connection is opened.
val options = ConnectionPool.Options.builder()
.minConnections(2)
.maxConnections(10)
.acquireTimeout(10.seconds)
.idleTimeout(10.minutes)
.maxLifetime(30.minutes)
.build()
/**
* The following urls are supported:
* postgresql://
* postgresql://localhost
* postgresql://localhost:5433
* postgresql://localhost/mydb
*
* Additionally, you can use the `postgreSQL` function, if you are working in a multiplatform setup.
*/
val db = PostgreSQL(
url = "postgresql://localhost:15432/test",
username = "postgres",
password = "postgres",
options = options
)
/**
* The connection URL should follow the nex pattern,
* as described by [MySQL](https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-reference-jdbc-url-format.html).
* The generic format of the connection URL:
* mysql://[host][/database][?properties]
*/
val db = MySQL(
url = "mysql://localhost:13306/test",
username = "mysql",
password = "mysql"
)
/**
* The following urls are supported:
* `sqlite::memory:` | Open an in-memory database.
* `sqlite:data.db` | Open the file `data.db` in the current directory.
* `sqlite://data.db` | Open the file `data.db` in the current directory.
* `sqlite:///data.db` | Open the file `data.db` from the root (`/`) directory.
* `sqlite://data.db?mode=ro` | Open the file `data.db` for read-only access.
*/
val db = SQLite(
url = "sqlite://test.db", // If the `test.db` file is not found, a new db will be created.
options = options
)
/**
* Encrypted SQLite via SQLCipher (the `sqlx4k-sqlite-cipher` module). Same URL forms as SQLite,
* but every target — native (FFI) and JVM/Android (JNI) — is backed by the same Rust `sqlx` +
* SQLCipher core. The `password` is applied as the SQLCipher `PRAGMA key`, and the database file
* is created encrypted on first use.
*
* In a multiplatform setup use the `sqliteCipher(...)` function instead; on Android it additionally
* takes a `Context` (to resolve a relative database filename into the app's private storage).
*/
val db = SQLiteCipher(
url = "sqlite://test.db", // If not found, a new (encrypted) db will be created.
password = "a-strong-passphrase",
options = options
)The driver provides two complementary ways to run queries:
- Directly through the database instance (recommended). Each call acquires a pooled connection, executes the work, and returns it to the pool automatically.
- Manually acquire a connection from the pool when you need to batch multiple operations on the same connection without starting a transaction.
Notes:
- When you manually acquire a connection, you must release it to return it to the pool.
Examples (PostgreSQL shown, similar to MySQL/SQLite):
// Manual connection acquisition (remember to release)
val conn: Connection = db.acquire().getOrThrow()
try {
conn.execute("insert into users(id, name) values (2, 'Bob');").getOrThrow()
val rs = conn.fetchAll("select * from users;").getOrThrow()
// ...
} finally {
conn.close().getOrThrow() // Return to pool
}You can set the transaction isolation level on a connection to control the degree of visibility between concurrent transactions.
val conn: Connection = db.acquire().getOrThrow()
// Set the isolation level before starting operations
conn.setTransactionIsolationLevel(Transaction.IsolationLevel.Serializable).getOrThrow()All database interactions go through the QueryExecutor interface, which provides a consistent, coroutine-based API for executing SQL statements. This interface is implemented by:
- Database drivers (
PostgreSQL,MySQL,SQLite) - for direct query execution using pooled connections - Connection - for manual connection management
- Transaction - for transactional query execution
The QueryExecutor interface provides two primary methods for running queries:
Returns the number of affected rows (INSERT, UPDATE, DELETE, DDL statements):
// With raw SQL string
val affected: Long = db.execute("insert into users(id, name) values (1, 'Alice');").getOrThrow()Returns a ResultSet containing all rows (SELECT queries):
// With raw SQL string
val result: ResultSet = db.fetchAll("select * from users;").getOrThrow()Returns a cold Flow of rows (SELECT queries). Unlike fetchAll, the rows are not collected in memory: the query
starts when the flow is collected and the rows are emitted as the database produces them, in chunks of fetchSize
rows (1,000 by default). The connection serving the query is busy until the flow completes or the collector is
cancelled; a flow of a Connection or Transaction holds it for the whole collection. A failure fails the
collection with an SQLError.
db.fetch("select * from events order by id;", fetchSize = 1_000)
.map { it.get("payload").asString() }
.collect { println(it) }
// With a RowMapper.
db.fetch("select * from users;", UserRowMapper).collect { user -> println(user) }// With named parameters:
val st1 = Statement
.create("select * from sqlx4k where id = :id")
.bind("id", 65)
db.fetchAll(st1).getOrThrow().map {
val id: ResultSet.Row.Column = it.get("id")
Test(id = id.asInt())
}
// With positional parameters:
val st2 = Statement
.create("select * from sqlx4k where id = ?")
.bind(0, 65)
db.fetchAll(st2).getOrThrow().map {
val id: ResultSet.Row.Column = it.get("id")
Test(id = id.asInt())
}object Sqlx4kRowMapper : RowMapper<Sqlx4k> {
override fun map(row: ResultSet.Row, converters: ValueEncoderRegistry): Sqlx4k {
val id: ResultSet.Row.Column = row.get(0)
val test: ResultSet.Row.Column = row.get(1)
// Use built-in mapping methods to map the values to the corresponding type.
return Sqlx4k(id = id.asInt(), test = test.asString())
}
}
val res: List<Sqlx4k> = db.fetchAll("select * from sqlx4k limit 100;", Sqlx4kRowMapper).getOrThrow()For custom types that don't have builtin decoders, you can register custom ValueEncoder implementations. A
ValueEncoder provides bidirectional conversion between your custom type and the database representation.
Creating a Custom Encoder:
// Define your custom type
data class Money(val amount: BigDecimal, val currency: String) {
override fun toString(): String = "$amount $currency"
companion object {
fun parse(value: String): Money {
val parts = value.split(" ")
return Money(BigDecimal(parts[0]), parts[1])
}
}
}
// Create a ValueEncoder for your type
object MoneyEncoder : ValueEncoder<Money> {
override fun encode(value: Money): Any = value.toString()
override fun decode(value: ResultSet.Row.Column): Money = Money.parse(value.asString())
}Registering and Using Custom Encoders:
// Create a registry and register your encoder
val registry = ValueEncoderRegistry()
.register<Money>(MoneyEncoder)
// Use the registry when mapping rows
val mapper = Sqlx4kAutoRowMapper
val entity = mapper.map(row, registry)val tx1: Transaction = db.begin().getOrThrow()
tx1.execute("delete from sqlx4k;").getOrThrow()
tx1.fetchAll("select * from sqlx4k;").getOrThrow().forEach { println(it) }
tx1.commit().getOrThrow()You can also execute entire blocks in a transaction scope.
db.transaction {
execute("delete from sqlx4k;").getOrThrow()
fetchAll("select * from sqlx4k;").getOrThrow().forEach { println(it) }
// At the end of the block will auto commit the transaction.
// If any error occurs, it will automatically trigger the rollback method.
}A savepoint lets you roll back part of a transaction without ending it. The block form releases the savepoint on
success and rolls back to it on failure. Either way it returns a Result and never propagates the error.
db.transaction {
execute("insert into orders (id, status) values (1, 'new');").getOrThrow()
// If this fails, only the audit insert is undone.
val audit: Result<Long> = savepoint {
execute("insert into audit_log (order_id, event) values (1, 'created');").getOrThrow()
}
if (audit.isFailure) println("Audit insert skipped.")
}You can also call savepoint(name), rollbackToSavepoint(name) and releaseSavepoint(name) directly.
When using coroutines, you can propagate a transaction through the coroutine context using TransactionContext. This
allows you to write small, composable suspend functions that either:
- start a transaction at the boundary of your use case, and
- inside helper functions call
TransactionContext.current()to participate in the same transaction without having to propagateTransactionorDriverparameters everywhere.
val db = PostgreSQL(
url = "postgresql://localhost:15432/test",
username = "postgres",
password = "postgres",
options = options
)
fun main() = runBlocking {
TransactionContext.new(db) {
// `this` is a TransactionContext and also a Transaction (delegation),
// so you can call query methods directly:
execute("insert into sqlx4k (id, test) values (66, 'test');").getOrThrow()
// In deeper code, fetch the same context and keep using the same tx
doBusinessLogic()
doMoreBusinessLogic()
doExtraBusinessLogic()
}
}
suspend fun doBusinessLogic() {
// Get the active transaction from the coroutine context
val tx = TransactionContext.current()
// Continue operating within the same database transaction
tx.execute("update sqlx4k set test = 'updated' where id = 66;").getOrThrow()
}
// Or you can use the `withCurrent` method to get the transaction and execute the block in an ongoing transaction.
suspend fun doMoreBusinessLogic(): Unit = TransactionContext.withCurrent {
// Continue operating within the same database transaction
}
// You can also pass the db instance to `withCurrent`.
// If a transaction is already active, the block runs within it; otherwise, a new transaction is started for the block.
suspend fun doExtraBusinessLogic(): Unit = TransactionContext.withCurrent(db) {
// Continue operating within the same database transaction
}Tip
The Gradle plugin does the whole setup below for you from a single
sqlx4k { } block. The manual setup is shown here for reference, and for the full list of code-generator options.
For this operation you will need to include the KSP plugin to your project.
plugins {
alias(libs.plugins.ksp)
}
// Then you need to configure the processor (it will generate the necessary code files).
ksp {
// Optional: pick the SQL dialect for CRUD generation from @Table classes.
// Supported dialects:
// arg("dialect", "mysql")
// arg("dialect", "mariadb")
// arg("dialect", "postgresql")
// arg("dialect", "sqlite")
// Required: where to place the generated sources.
arg("output-package", "io.github.smyrgeorge.sqlx4k.examples.postgres")
// Compile-time SQL syntax checking for @Query methods (default = true).
// Set to "false" to turn it off if you use vendor-specific syntax not understood by the parser.
// arg("validate-sql-syntax", "false")
}
dependencies {
// Will generate code for macosArm64. Add more targets if you want.
add("kspMacosArm64", implementation("io.github.smyrgeorge:sqlx4k-codegen:x.y.z"))
}Then create your data class that will be mapped to a table:
@Table("sqlx4k")
data class Sqlx4k(
@Id(insert = true) // Will be included in the insert query.
val id: Int,
val test: String
)
@Repository
interface Sqlx4kRepository : CrudRepository<Sqlx4k> {
// The processor will validate the SQL syntax in the @Query methods.
// If you want to disable this validation, you can set the "validate-sql-syntax" arg to "false".
@Query("SELECT * FROM sqlx4k WHERE id = :id")
suspend fun findOneById(context: QueryExecutor, id: Int): Result<Sqlx4k?>
@Query("SELECT * FROM sqlx4k")
suspend fun findAll(context: QueryExecutor): Result<List<Sqlx4k>>
@Query("SELECT count(*) FROM sqlx4k")
suspend fun countAll(context: QueryExecutor): Result<Long>
@Query("SELECT * FROM sqlx4k WHERE id = :id")
suspend fun existsById(context: QueryExecutor, id: Int): Result<Boolean>
}Note
A @Query method's name prefix determines its shape and expected return type: findAll/findAllBy… →
Result<List<T>>, findOneBy… → Result<T?>, countAll/countBy… → Result<Long>, existsBy… →
Result<Boolean>, deleteAll/deleteBy… and execute… → Result<Long> (affected rows).
Note
Besides your @Query methods, because your interface extends CrudRepository<T>, the generator also adds the CRUD
helper methods automatically: insert, update, delete, and save.
Note
By default, the code generator automatically uses the auto-generated RowMapper for your entity (e.g.,
Sqlx4kAutoRowMapper). You can override this behavior by explicitly providing a custom mapper:
@Repository(mapper = CustomRowMapper::class).
Then in your code you can use it like:
// Insert a new record.
val record = Sqlx4k(id = 1, test = "test")
val res: Sqlx4k = Sqlx4kRepositoryImpl.insert(db, record).getOrThrow()
// Execute a generated query.
val res: List<Sqlx4k> = Sqlx4kRepositoryImpl.findAll(db).getOrThrow()For more details, take a look at the examples.
You can use the @Column annotation to override the column name a property is mapped to, and to control how the
property participates in the generated INSERT and UPDATE statements. The latter is useful for database-generated or
read-only columns that should be excluded from write operations but still retrieved afterward (via the RETURNING
clause).
| Property | Effect |
|---|---|
name = "..." |
Overrides the column name used in all generated SQL and the generated RowMapper |
insert = false |
Excluded from the INSERT's column list, included in the INSERT's RETURNING clause |
update = false |
Excluded from the UPDATE's SET clause, included in the UPDATE's RETURNING clause |
By default — when name is empty, or when the property has no @Column annotation at all — the column name is derived
from the property name using snake_case conversion (e.g., createdAt → created_at). Providing an explicit name is
useful for legacy or non-conventional schemas:
@Table("legacy_users")
data class LegacyUser(
@Id
val id: Long,
// Maps to the legacy "USER_NAME" column instead of the derived "user_name".
@Column(name = "USER_NAME")
val userName: String,
// A custom name can be combined with the insert/update flags.
@Column(name = "mail_address", update = false)
val email: String,
// No @Column: maps to "is_active" (default snake_case conversion).
val isActive: Boolean
)Note
An explicit name must be a plain (unquoted) SQL identifier ([A-Za-z_][A-Za-z0-9_]*) and is used verbatim in the
generated SQL and when reading result columns by name — so it must match the column name as reported by the database.
PostgreSQL folds unquoted identifiers to lowercase, so for PostgreSQL use the lowercase form unless the column was
created with a quoted (case-sensitive) name.
@Table("articles")
data class Article(
@Id
val id: Long,
val title: String,
val content: String,
// Set only on INSERT by a DB default, never modified afterwards.
@Column(insert = false, update = false)
val createdAt: LocalDateTime,
// Auto-updated by a DB trigger on every write.
@Column(insert = false, update = false)
val updatedAt: LocalDateTime
)
@Table("comments")
data class Comment(
@Id
val id: Long,
val content: String,
// Set on INSERT but never updated.
@Column(update = false)
val createdAt: LocalDateTime
)Use the @Transient annotation for derived, computed, or cached properties that have no corresponding database
column. Unlike @Column(insert = false, update = false) — which still maps the property from query results — a
@Transient property is treated as if it were not a column at all. It is excluded from INSERT/UPDATE statements,
from the RETURNING clause, and is not read by the generated RowMapper.
If the annotated property is a primary-constructor parameter, it must declare a default value (the generated
RowMapper omits it and relies on the default). Properties declared in the class body (e.g. by lazy or computed
get()) need no default.
@Table("accounts")
data class Account(
@Id
val id: Long,
val email: String,
// Transient constructor parameter — requires a default value.
@Transient
val displayName: String = email.substringBefore('@')
) {
// Transient body property — derived lazily, no default required.
@Transient
val domain: String by lazy { email.substringAfter('@') }
}Mark a single non-nullable Int or Long property with @Version to enable optimistic locking for the generated
CRUD operations. The generator manages the column for you — never set it yourself, just keep the entity instance
returned by the repository for the next write:
insert()writes the entity's value as-is (start new entities at0) and reads the column back viaRETURNING.update()setsversion = version + 1and addsAND version = ?(the entity's current value) to theWHEREclause. The incremented value is read back viaRETURNINGand merged into the returned entity.delete()addsAND version = ?to theWHEREclause as well.batchUpdate()matches every row on both the id and the version, and increments the version.
When the row has been modified — or deleted — since the entity was read, the statement matches no rows and the
repository method fails with an SQLError whose code is OptimisticLockFailed:
@Table("documents")
data class Document(
@Id
val id: Long,
val title: String,
@Version
val version: Long = 0,
)
@Repository
interface DocumentRepository : CrudRepository<Document> {
@Query("SELECT * FROM documents WHERE id = :id")
suspend fun findOneById(context: QueryExecutor, id: Long): Result<Document?>
}
val doc = DocumentRepositoryImpl.findOneById(db, 1).getOrThrow()!! // version = 3
val updated = DocumentRepositoryImpl.update(db, doc.copy(title = "v2")).getOrThrow() // version = 4
// `doc` still carries version 3, so this write is stale:
val stale = DocumentRepositoryImpl.update(db, doc.copy(title = "v3"))
val error = stale.exceptionOrNull() as SQLError
check(error.code == SQLError.Code.OptimisticLockFailed)The generated update() statement looks like this:
update documents
set title = ?,
version = version + 1
where id = ?
and version = ? returning id, version;Note
Rules enforced at compile time: at most one @Version per entity, it must be a non-nullable Int or Long, the
entity must also declare an @Id, and it cannot be combined with @Id, @Transient or @Column(update = false).
@Column(name = "...") and @Column(insert = false) (to rely on a database default) are fine.
Warning
A batch update is a single statement: the rows whose version matched are updated, the stale ones are not, and the
call fails with OptimisticLockFailed. Run versioned batchUpdate calls inside a transaction and roll back on
failure if you need all-or-nothing semantics.
Note
MySQL and MariaDB have no UPDATE ... RETURNING, so update() re-selects the row afterwards. For versioned entities
that SELECT is keyed on the expected new version, so a stale update still surfaces as OptimisticLockFailed
instead of silently returning the unchanged row.
When you annotate a class with @Table, the code generator automatically creates a RowMapper implementation for
mapping database rows to your entity. The mapper is named {ClassName}AutoRowMapper and is generated in the same file
as your CRUD queries.
For example, for a class named Sqlx4k, the generator creates Sqlx4kAutoRowMapper:
object Sqlx4kAutoRowMapper : RowMapper<Sqlx4k> {
override fun map(row: ResultSet.Row, converters: ValueEncoderRegistry): Sqlx4k {
val id = row.get("id").asInt()
val test = row.get("test").asString()
return Sqlx4k(id = id, test = test)
}
}Dialect-Specific Decoders:
When using PostgreSQL, set the dialect in your KSP configuration to enable PostgreSQL-specific decoders:
ksp {
arg("dialect", "postgresql") // Enables PostgreSQL array decoders
arg("output-package", "io.github.smyrgeorge.sqlx4k.examples.postgres")
}Supported dialects:
"mysql"- Adjusts CRUD query generation for MySQL compatibility"mariadb"- Like"mysql", but usesINSERT ... RETURNING(MariaDB 10.5+), which enablesbatchInsert"postgresql"- Enables PostgreSQL-specific extensions (array types)"sqlite"- Adjusts CRUD query generation for SQLite compatibility- Default (or
"generic") - Uses standard SQL with builtin decoders
The code generator creates batch INSERT and UPDATE operations for efficiently processing multiple entities in a
single database round-trip. These operations use multi-row SQL statements with RETURNING clauses to retrieve
database-generated values.
Batch Insert:
// Insert multiple entities at once
val users = listOf(
User(name = "Alice", email = "alice@example.com"),
User(name = "Bob", email = "bob@example.com"),
User(name = "Charlie", email = "charlie@example.com")
)
// Returns all inserted entities with generated IDs
val insertedUsers: Result<List<User>> = userRepository.batchInsert(db, users)Batch Update:
// Update multiple entities at once
val updatedUsers = users.map { it.copy(status = "active") }
// Returns all updated entities with any DB-modified values
val result: Result<List<User>> = userRepository.batchUpdate(db, updatedUsers)Database Support:
| Operation | PostgreSQL | SQLite | MySQL | MariaDB | Generic |
|---|---|---|---|---|---|
batchInsert |
✅ | ✅ | ❌ | ✅ | ✅ |
batchUpdate |
✅ | ✅ | ❌ | ❌ | ✅ |
- PostgreSQL: Full support for both batch operations using multi-row
INSERT ... RETURNINGandUPDATE ... FROM (VALUES ...) ... RETURNINGsyntax. - SQLite: Full support for both batch operations using multi-row
INSERT ... RETURNINGandWITH ... UPDATE ... FROM ... RETURNINGsyntax (CTE-based approach). - MySQL: Neither batch operation is supported because MySQL lacks
RETURNINGclause support. - MariaDB:
batchInsertis supported using multi-rowINSERT ... RETURNING(MariaDB 10.5+).batchUpdateis not supported because MariaDB has noUPDATE ... RETURNING(norUPDATE ... FROM (VALUES ...)). - Generic: Generates code for both operations, but actual support depends on the underlying database.
Note
For unsupported operations, the generated repository methods throw UnsupportedOperationException at runtime.
The generated code includes documentation indicating which dialects support each operation.
For custom types, you can use the @Converter annotation to specify a ValueEncoder directly on the property. This
provides compile-time type safety, avoids runtime registry lookups, and eliminates object instantiation overhead.
Defining a Custom Encoder:
// Define your custom type
data class Money(val amount: Double, val currency: String) {
override fun toString(): String = "$amount:$currency"
companion object {
fun parse(value: String): Money {
val parts = value.split(":")
return Money(parts[0].toDouble(), parts[1])
}
}
}
// Create a ValueEncoder as an object (singleton) - NOT a class
object MoneyEncoder : ValueEncoder<Money> {
override fun encode(value: Money): Any = value.toString()
override fun decode(value: ResultSet.Row.Column): Money = Money.parse(value.asString())
}Using @Converter on Properties:
@Table("invoices")
data class Invoice(
@Id
val id: Long,
val description: String,
@Converter(MoneyEncoder::class)
val totalAmount: Money
)Note
When using @Converter, you don't need to register the encoder in a ValueEncoderRegistry.
The encoder object is referenced directly in the generated code, avoiding any instantiation overhead.
Optional: Using ContextCrudRepository with context-parameters.
You can opt in to generated repositories that use Kotlin context-parameters instead of passing a QueryExecutor parameter to every method. This switches your repository to ContextCrudRepository and makes all generated CRUD and @Query methods require an ambient QueryExecutor provided via a context-parameter.
To enable this mode:
- Make your repository interface extend ContextCrudRepository instead of CrudRepository.
- Declare your @Query methods with a
context(context: QueryExecutor)context parameter instead of an explicitcontextargument.
Repository interface example with context parameters:
@Repository
interface Sqlx4kRepository : ContextCrudRepository<Sqlx4k> {
@Query("SELECT * FROM sqlx4k WHERE id = :id")
context(context: QueryExecutor)
suspend fun findOneById(id: Int): Result<Sqlx4k?>
@Query("SELECT * FROM sqlx4k")
context(context: QueryExecutor)
suspend fun findAll(): Result<List<Sqlx4k>>
}Usage with a context-parameter (no explicit db parameter on each call):
val record = Sqlx4k(id = 1, test = "test")
with(db) {
val inserted = Sqlx4kRepositoryImpl.insert(record).getOrThrow()
val one = Sqlx4kRepositoryImpl.findOneById(1).getOrThrow()
}If you prefer the explicit-parameter style, extend CrudRepository instead. In that case, each generated method takes a QueryExecutor (e.g., db or transaction) as the first argument.
The repository system provides powerful hooks to implement cross-cutting concerns like metrics, tracing, logging, and
monitoring across all database operations. All repository interfaces extend CrudRepositoryHooks<T>, which provides the
following hooks:
Entity-Level Hooks:
preInsertHook(context: QueryExecutor, entity: T): T- Called before an entity is insertedpreUpdateHook(context: QueryExecutor, entity: T): T- Called before an entity is updatedpreDeleteHook(context: QueryExecutor, entity: T): T- Called before an entity is deletedafterInsertHook(context: QueryExecutor, entity: T): T- Called after an entity is insertedafterUpdateHook(context: QueryExecutor, entity: T): T- Called after an entity is updatedafterDeleteHook(context: QueryExecutor, entity: T): T- Called after an entity is deleted
Query-Level Hook:
aroundQuery(methodName: String, statement: Statement, block: suspend () -> R): R- Wraps all query executions
The aroundQuery hook is particularly powerful as it wraps all database operations (both @Query methods and CRUD
operations), giving you a single interception point for implementing metrics, distributed tracing, query logging, and
other observability features.
- CrudRepository
- ContextCrudRepository
- ArrowCrudRepository
(using the
sqlx4k-arrowpackage) - ArrowContextCrudRepository
(using the
sqlx4k-arrowpackage)
- What it is: during code generation, sqlx4k parses the SQL string in each
@Querymethod usingJSqlParser. If the parser detects a syntax error, the build fails early with a clear error message pointing to the offending repository method. - What it checks: only SQL syntax. It does not verify that tables/columns exist, parameter names match, or types are compatible.
- When it runs: at KSP processing time, before your code is compiled/run.
- Dialect notes: validation is dialect-agnostic and aims for an ANSI/portable subset. Some vendor-specific features
(e.g., certain MySQL or PostgreSQL extensions) may not be recognized. If you hit a false positive, you can disable
validation per module with ksp arg validate-sql-syntax=false, or disable it per query with
@Query(checkSyntax = false). - Most reliable with: SELECT, INSERT, UPDATE, DELETE statements. DDL or very advanced constructs may not be fully supported.
Example of a build error you might see if your query is malformed:
> Task :compileKotlin
Invalid SQL in function findAllBy: Encountered "FROMM" at line 1, column 15
Tip: keep it enabled to catch typos early; if you rely heavily on vendor-specific syntax not yet supported by the parser, turn it off either globally or just for a specific method:
- Globally (module-wide):
ksp { arg("validate-sql-syntax", "false") }- Per query:
@Repository
interface UserRepository {
@Query("select * from users where id = :id", checkSyntax = false)
suspend fun findOneById(context: QueryExecutor, id: Int): Result<User?>
}Note
Experimental Feature: SQL schema validation is currently in early development and may have limitations.
- What it is: during code generation, sqlx4k can also validate your
@QuerySQL against a known database schema. It loads your migration files, builds an in-memory schema, and uses Apache Calcite to validate that tables, columns, and basic types referenced by the query exist and are compatible. - What it checks:
- Existence of referenced tables and columns.
- Basic type compatibility for literals and simple expressions.
- It does not execute queries or connect to a database.
- When it runs: at KSP processing time, right after syntax validation.
- Default: disabled. You must enable it explicitly per module.
- Requirements: point the processor to your migrations directory so it can reconstruct the schema. The loader supports a pragmatic subset of DDL: CREATE TABLE, ALTER TABLE ADD/DROP COLUMN, and DROP TABLE, processed in migration order.
- Dialect notes: validation is based on Calcite’s SQL semantics and a simplified schema model derived from your migrations. Some vendor-specific features and advanced DDL may not be fully supported.
Enable module-wide schema validation by adding KSP args in your build.gradle.kts:
ksp {
arg("validate-sql-schema", "true")
// Path to your migration .sql files (processed in ascending file version order)
arg("schema-migrations-path", "./db/migrations")
}You can also disable schema checks for a specific query:
@Repository
interface UserRepository {
@Query("select * from users where id = :id", checkSchema = false)
suspend fun findOneById(context: QueryExecutor, id: Int): Result<User?>
}Beyond syntax and schema checks, the processor runs a set of extra compile-time checks and codegen rewrites over each
@Query (and the entity it maps to). Each is controlled by a global KSP option and enabled by default. They fall into
two groups: validations that fail the build when a @Query or entity looks wrong, and optimizations that
rewrite the generated SQL for efficiency without changing its result.
Compile-time safeguards that fail the build when a @Query — or the entity it maps to — is inconsistent (an unknown
column or table, the wrong statement kind, an unsupported key shape, …). They never change the generated SQL.
| KSP option | Default | What it does |
|---|---|---|
reject-stacked-statements |
true |
Fails the build if a single @Query contains more than one SQL statement (a stacked-query guard). |
validate-sql-columns |
true |
Fails the build if a @Query references a column that does not exist on the entity — across the SELECT list, WHERE, GROUP BY, HAVING, ORDER BY, and UPDATE ... SET. Applies to single-table queries only (joins/subselects/other tables are skipped). |
validate-sql-table |
true |
Fails the build if a single-table @Query targets a table other than the entity's own (@Table). Skips subselect FROM clauses. |
validate-count-projection |
true |
Fails the build if a count* method does not select a single count(...) aggregate. |
validate-statement-kind |
true |
Fails the build if a @Query's statement kind doesn't match its prefix: find*/count*/exists* must be a SELECT, delete* a DELETE, and execute* a write (INSERT/UPDATE/DELETE). |
validate-projection |
true |
Fails the build if a find* method bound by the generated row mapper selects only some columns (which would leave the mapper without a column at runtime). Requires SELECT * or every entity column. Skipped when a custom @Repository(mapper = ...) is used. |
validate-returning-columns |
true |
Fails the build if a RETURNING clause references a column that does not exist on the entity. |
reject-grouping-in-scalar |
true |
Fails the build if a count*, findOne*, or exists* method's @Query contains a GROUP BY/HAVING clause. A grouped query returns one row per group, but these methods read only the first row — so the result would be wrong. |
validate-single-id |
true |
Fails the build if the repository entity declares more than one @Id property. Zero (a keyless entity) and one are both allowed; composite primary keys are not supported by the CRUD generator. |
Disable any of them module-wide in your build.gradle.kts:
ksp {
// arg("reject-stacked-statements", "false")
// arg("validate-sql-columns", "false")
// arg("validate-sql-table", "false")
// arg("validate-count-projection", "false")
// arg("validate-statement-kind", "false")
// arg("validate-projection", "false")
// arg("validate-returning-columns", "false")
// arg("reject-grouping-in-scalar", "false")
// arg("validate-single-id", "false")
}Codegen rewrites that make the generated SQL more efficient. They never fail the build; a query that doesn't fit the rewrite is emitted unchanged.
| KSP option | Default | What it does |
|---|---|---|
expand-select-star |
true |
Rewrites a bare SELECT * over the entity's table into the entity's explicit columns in the generated statement (e.g. select * from users → select id, name, email from users). Leaves count(*), joins, and explicit column lists untouched. |
findone-limit |
true |
Appends LIMIT 2 to findOne* queries so the driver fetches at most two rows — it still detects a multi-row result, but transfers far less on wide tables. Skips queries that already declare a LIMIT. |
expand-returning-star |
true |
Rewrites a bare RETURNING * on a write over the entity's table into the entity's explicit columns (mirrors expand-select-star). Leaves an explicit RETURNING list, or a write against a different table, untouched. |
drop-redundant-order-by |
true |
Strips a meaningless ORDER BY from exists*/count* queries — their result is a single scalar, so ordering only costs the database a sort. Leaves queries with no ORDER BY untouched. |
Disable any of them module-wide in your build.gradle.kts:
ksp {
// arg("expand-select-star", "false")
// arg("findone-limit", "false")
// arg("expand-returning-star", "false")
// arg("drop-redundant-order-by", "false")
}Note
Column/table validation and SELECT * expansion map columns back to properties using the same rules as the CRUD
generator (snake_case, or an explicit @Column(name = "...")), so they always stay consistent with the generated SQL.
Note
Experimental Feature: in-memory repository generation is currently in early development; its behavior and the generated API may change in future releases.
The companion sqlx4k-codegen-test module generates a thread-safe, in-memory implementation of every @Repository
interface, so you can unit-test the code that depends on your repositories without a real database. For a repository
named Sqlx4kRepository, it generates a class named InMemorySqlx4kRepository.
It is a separate module from sqlx4k-codegen, so in-memory generation is opt-in per dependency — add it only to the KSP
configuration that processes your repositories:
ksp {
// The generated implementations are emitted into this package (same option used by sqlx4k-codegen).
arg("output-package", "io.github.smyrgeorge.sqlx4k.examples.postgres")
}
dependencies {
// Real @Repository implementations (production).
add("kspMacosArm64", implementation("io.github.smyrgeorge:sqlx4k-codegen:x.y.z"))
// In-memory implementations for unit tests.
add("kspMacosArm64Test", "io.github.smyrgeorge:sqlx4k-codegen-test:x.y.z")
}Given the Sqlx4kRepository from the examples above, use the generated implementation directly in your tests:
val repo = InMemorySqlx4kRepository()
// A QueryExecutor is still accepted to match the interface, but it is never touched —
// it works entirely in memory, so any instance (even a no-op fake) will do.
val saved = repo.insert(db, Sqlx4k(id = 1, test = "test")).getOrThrow()
val all = repo.findAll(db).getOrThrow()What the generated implementation provides:
- Thread-safe storage — entities live in a
HashMapguarded by aMutex. - Full CRUD —
insert,update,delete,save,batchInsert,batchUpdate. Numeric@Idkeys withinsert = falseget auto-incrementing ids; application-provided ids (@Id(insert = true)) are stored as given.@Versionentities get the same optimistic locking as the real implementation:update/deleteonly apply when the stored version matches (failing withOptimisticLockFailedotherwise) andupdateincrements it. - Whole-table queries —
findAll,countAll,deleteAll. - Derived
@Querymethods — the in-memory behavior is derived from each method's@QuerySQL: the statement is parsed and itsWHEREclause becomes a predicate over the stored entities (columns are mapped back to properties via snake_case /@Column). Supports=,<>,<,<=,>,>=,IS [NOT] NULL,AND/OR, named parameters, and simple literals — forfind*/count*(SELECT),delete*(DELETE), andexecute*(UPDATESET …/DELETE). For example@Query("select * from users where name = :name")behaves likefilter { it.name == name }. - Repository hooks — the overridden
preInsert/afterInsert/... /aroundQueryhooks are honored exactly like the real generated implementations. - Test helpers —
clear()empties the store (and resets the id sequence) andfindAllStored()returns a snapshot.
Anything the generator cannot translate — a WHERE it doesn't understand (e.g. LIKE, IN, subqueries, function
calls), or an execute* that isn't a plain UPDATE/DELETE — is emitted as a stub that throws NotImplementedError. The
generated class is open, so provide the behavior yourself by subclassing and overriding just those methods. Use the
withStore { } helper for thread-safe access to the backing map (the MutableMap is the lambda receiver):
class TestSqlx4kRepository : InMemorySqlx4kRepository() {
// A method whose WHERE the generator could not translate (e.g. LIKE), so it was left as a stub.
override suspend fun findAllByTestLike(context: QueryExecutor, pattern: String): Result<List<Sqlx4k>> =
withStore {
val prefix = pattern.removeSuffix("%")
runCatching { values.filter { it.test.startsWith(prefix) } }
}
}Run any pending migrations against the database; and validate previously applied migrations against the current migration source to detect accidental changes in previously applied migrations.
val res = db.migrate(
path = "./db/migrations",
table = "_sqlx4k_migrations",
afterFileMigration = { m, d -> println("Migration of file: $m, took $d") }
).getOrThrow()
println("Migration completed. $res")This process will create a table with name _sqlx4k_migrations. For more information, take a look at
the examples.
sqlx4k provides several extensions to enhance functionality:
db.listen("chan0") { notification: Postgres.Notification ->
println(notification)
}
(1..10).forEach {
db.notify("chan0", "Hello $it")
delay(1000)
}A Kotlin Multiplatform client for building reliable, asynchronous message queues using PostgreSQL and the PGMQ extension.
Features:
- Full PGMQ operations support (create, drop, list queues)
- Send and receive messages with headers and delays
- Message acknowledgment (ack/nack) with visibility timeout
- Batch operations for high throughput
- High-level consumer API with automatic retry and exponential backoff
- PostgreSQL LISTEN/NOTIFY integration for real-time notifications
- Queue metrics and monitoring
Installation:
implementation("io.github.smyrgeorge:sqlx4k-postgres-pgmq:x.y.z")Quick Example:
// Create PGMQ client
val pgmq = PgmqClient(
pg = PgmqDbAdapterImpl(db),
options = PgmqClient.Options(autoInstall = true)
)
// Create a queue and send messages
pgmq.create(PgmqClient.Queue(name = "my_queue")).getOrThrow()
pgmq.send("my_queue", """{"order": 123}""").getOrThrow()
// High-level consumer with automatic retry
val consumer = PgmqConsumer(
pgmq = pgmq,
options = PgmqConsumer.Options(queue = "my_queue"),
onMessage = { message -> processMessage(message) }
)For complete documentation, see sqlx4k-postgres-pgmq/README.md
SQLDelight integration for type-safe SQL queries with sqlx4k.
Repository: https://github.com/smyrgeorge/sqlx4k-sqldelight
- jvm
- android (
sqlx4k-sqliteandsqlx4k-sqlite-cipheronly, minSdk 26) - iosArm64
- iosSimulatorArm64
- androidNativeX64
- androidNativeArm64
- macosArm64
- linuxArm64
- linuxX64
- mingwX64
- wasmWasi (potential future candidate)
If you are building your project on Windows, for target mingwX64, and you encounter the following error:
lld-link: error: -exclude-symbols:___chkstk_ms is not allowed in .drectve
Please look at this issue: #18
You will need the Rust toolchain to build this project. Check here: https://rustup.rs/
Note
By default, the project will build only for your system architecture-os (e.g. macosArm64, linuxArm64, etc.)
Also, make sure that you have installed all the necessary targets (only if you want to build for all targets):
rustup target add aarch64-apple-ios-sim
rustup target add x86_64-linux-android
rustup target add aarch64-linux-android
rustup target add aarch64-apple-darwin
rustup target add aarch64-unknown-linux-gnu
rustup target add x86_64-unknown-linux-gnu
rustup target add x86_64-pc-windows-gnuOn a clean checkout (and after every version bump), bootstrap the build first: it publishes the sqlx4k Gradle plugin to mavenLocal, which the example modules apply before the main build can even configure (see bootstrap.sh):
./scripts/bootstrap.shThen, run the build.
# will build only for the current target
./gradlew buildYou can also build for specific targets.
./gradlew build -Ptargets=macosArm64To build for all available targets, run:
./gradlew build -Ptargets=all./gradlew publishAllPublicationsToMavenCentralRepository -Ptargets=allFirst, you need to run start-up the postgres instance.
docker compose up -dAnd then run the examples.
# For macosArm64
./examples/postgres/build/bin/macosArm64/releaseExecutable/postgres.kexe
./examples/mysql/build/bin/macosArm64/releaseExecutable/mysql.kexe
./examples/sqlite/build/bin/macosArm64/releaseExecutable/sqlite.kexe
# If you run in another platform consider running the correct target.Here are small, self‑contained snippets for the most common tasks. For full runnable apps, see the modules under:
- PostgreSQL: examples/postgres
- MySQL: examples/mysql
- SQLite: examples/sqlite
Check for memory leaks with the leaks tool. First, sign the binary:
codesign -s - -v -f --entitlements =(echo -n '<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd"\>
<plist version="1.0">
<dict>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>') ./bench/postgres-sqlx4k/build/bin/macosArm64/releaseExecutable/postgres-sqlx4k.kexeThen run the tool:
leaks -atExit -- ./bench/postgres-sqlx4k/build/bin/macosArm64/releaseExecutable/postgres-sqlx4k.kexesqlx4k stands on the shoulders of excellent open-source projects:
-
Data access engines
- Native targets (Kotlin/Native):
- sqlx (Rust)
- JVM targets:
- PostgreSQL: r2dbc-postgresql
- MySQL: r2dbc-mysql
- SQLite: rsqlite-jdbc
- Encrypted SQLite —
sqlx4k-sqlite-cipher(the same sqlx (Rust) core on every target, via FFI on native and JNI on JVM/Android):- SQLCipher — encrypted SQLite
- Native targets (Kotlin/Native):
-
Build-time tooling
- JSqlParser — used by the code generator to parse @Query SQL at build time for syntax validation.
- Apache Calcite — used by the code generator for compile-time SQL schema validation.
Huge thanks to the maintainers and contributors of these projects.
MIT — see LICENSE.