The Woes of SQL Strings
JPA, SQLDelight
SQL lives in external .sq files or @Query annotations can't reference each other. So you need to duplicate logic, over and over and over!
The only Kotlin library with true query composition. Not another ORM.
docker compose up -d dbmain() function!plugins {
id("io.exoquery.exoquery-plugin") version "2.2.20-1.7.1.PL"
kotlin("plugin.serialization") version "2.2.20"
}
dependencies {
implementation("io.exoquery:exoquery-runner-jdbc:1.7.1.PL")
// ── Remember to include the right JDBC Driver ─────────────
implementation("org.postgresql:postgresql:42.7.0")
} import io.exoquery.*
import io.exoquery.controller.JdbcControllers
import javax.sql.DataSource
val ds: DataSource = createMyDataSource()
val ctl = JdbcControllers.Postgres(ds) data class Person(val name: String, val age: Int)
val query = sql {
Table<Person>().filter { p -> p.name == "Joe" }
}
fun main() = println(query.buildFor.Postgres().runOn(ctl)) plugins {
id("io.exoquery.exoquery-plugin") version "2.2.20-1.7.1.PL"
kotlin("plugin.serialization") version "2.2.20"
}
dependencies {
implementation("io.exoquery:exoquery-runner-r2dbc:1.7.1.PL")
// ── Remember to include the right R2DBC Driver ─────────────
implementation("org.postgresql:r2dbc-postgresql:1.0.5.RELEASE")
} import io.r2dbc.spi.ConnectionFactories
import io.r2dbc.spi.ConnectionFactoryOptions
import io.exoquery.controller.r2dbc.R2dbcControllers
val connectionFactory = ConnectionFactories.get(
ConnectionFactoryOptions.builder()
.option(ConnectionFactoryOptions.DRIVER, "postgresql")
.option(ConnectionFactoryOptions.HOST, "localhost")
.option(ConnectionFactoryOptions.PORT, 5432)
.option(ConnectionFactoryOptions.DATABASE, "mydb")
.option(ConnectionFactoryOptions.USER, "user")
.option(ConnectionFactoryOptions.PASSWORD, "password")
.build()
)
val ctl = R2dbcControllers.Postgres(connectionFactory = connectionFactory) data class Person(val name: String, val age: Int)
val query = sql {
Table<Person>().filter { p -> p.name == "Joe" }
}
fun main() = println(query.buildFor.Postgres().runOn(ctl)) plugins {
id("io.exoquery.exoquery-plugin") version "2.2.20-1.7.1.PL"
kotlin("plugin.serialization") version "2.2.20"
}
dependencies {
implementation("io.exoquery:exoquery-runner-jdbc:1.7.1.PL")
// ── Remember to include the right JDBC Driver ─────────────
implementation("org.postgresql:postgresql:42.7.0")
} // src/main/resources/application.conf
myDatabase {
dataSourceClassName=org.postgresql.ds.PGSimpleDataSource
dataSource.databaseName=quill_test
dataSource.serverName=localhost
dataSource.portNumber=5432
dataSource.user=postgres
dataSource.password=${?POSTGRES_PASSWORD_ENV_VAR}
} import io.exoquery.*
import io.exoquery.controller.JdbcControllers
import javax.sql.DataSource
// Loads the "myDatabase" config you just created
val ctl = JdbcControllers.Postgres.fromConfig("myDatabase") data class Person(val name: String, val age: Int)
val query = sql {
Table<Person>().filter { p -> p.name == "Joe" }
}
fun main() = println(query.buildFor.Postgres().runOn(ctl)) plugins {
kotlin("android") version "2.2.20"
id("io.exoquery.exoquery-plugin") version "2.2.20-1.7.1.PL"
kotlin("plugin.serialization") version "2.2.20"
} dependencies {
implementation("io.exoquery:exoquery-runner-android:1.7.1.PL")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
implementation("androidx.sqlite:sqlite-framework:2.4.0")
} import io.exoquery.controller.android.TerpalAndroidDriver
import androidx.test.core.app.ApplicationProvider
val controller = TerpalAndroidDriver.fromApplicationContext(
databaseName = "mydb",
applicationContext = ApplicationProvider.getApplicationContext(),
schema = MyTerpalSchema // Optional: define your schema
) data class Person(val name: String, val age: Int)
val query = sql {
Table<Person>().filter { p -> p.name == "Joe" }
}
fun main() = println(query.buildFor.Sqlite().runOn(controller)) plugins {
kotlin("multiplatform") version "2.2.20"
id("io.exoquery.exoquery-plugin") version "2.2.20-1.7.1.PL"
kotlin("plugin.serialization") version "2.2.20"
} kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("io.exoquery:exoquery-runner-native:1.7.1.PL")
}
}
}
} import io.exoquery.controller.native.TerpalNativeDriver
val controller = TerpalNativeDriver.fromSchema(
schema = MyTerpalSchema,
databaseName = "mydb"
) data class Person(val name: String, val age: Int)
val query = sql {
Table<Person>().filter { p -> p.name == "Joe" }
}
fun main() = println(query.buildFor.Sqlite().runOn(controller)) Every Kotlin SQL library hits the same fundamental limitation: they can't compose queries. Here's the proof.
JPA, SQLDelight
SQL lives in external .sq files or @Query annotations can't reference each other. So you need to duplicate logic, over and over and over!
usersByStatus:
SELECT * FROM users WHERE status = ? AND another_50_conditions.........
userAccounts:
SELECT * FROM accounts
WHERE user_id IN (
-- Can't reference usersByStatus. Must duplicate the query!
SELECT id FROM users WHERE status = ? AND another_50_conditions.........
)@CapturedFunction
fun usersBy(status: String) =
sql { users.filter { it.status == status } }
//sampleStart
val usersByStatus = sql {
usersBy(status)
}
val userAccounts = sql {
accounts.filter {
it.userId in usersBy(status).map { it.id }
}
}
//sampleEnd
Criteria, Exposed
DSLs like .eq(), .and(), .or() are needed. You can't use real Kotlin or Java control flow, so when
logic gets complex, you end up with a mess.
Player.select(
// Can't use a normal Kotlin when expression!
case().When(
(Player.score greater 300) or Player.bonusCompleted,
// Can't use if/else, let, or Elvis operator!
case().When(
Player.score.isNotNull(),
case().When(Player.bonusCompleted eq true, Player.score * 3)
.Else(Player.score * 2)
).Else(defaultScore)
).Else(staticBonus)
)@Serializable
data class Player(val name: String, val score: Int?, val bonusCompleted: Boolean)
val players = sql { Table<Player>() }
val defaultScoreRuntime = 100
val defaultScore = sql { param(defaultScoreRuntime) }
//sampleStart
// Native Kotlin control flow!
players.map { a ->
when {
a.score > 300 || a.bonusCompleted ->
a.score?.let { if (a.bonusCompleted) 3*it else 2*it } ?: defaultScore
else ->
staticBonus
}
}
//sampleEnd
Hibernate, JPA
Impedance mismatch, hidden lazy-loads, runtime reflection, and the dreaded N+1 problem.
// Queries aren't composable values
TypedQuery<User> q1 = em.createQuery(...);
TypedQuery<Account> q2 = // Can't use q1!
// Classic N+1 problem
users.forEach { user ->
user.accounts.forEach { account ->
// Executes query for EACH user!
}
}
data class User(val id: Int, val name: String)
data class Account(val id: Int, val userId: Int, val balance: Double)
data class Transaction(val id: Int, val accountId: Int, val amount: Double)
@SqlFragment
fun accountsOf(user: User) = sql {
composeFrom.join(Table<Account>()) { account -> account.userId == user.id }
}
@SqlFragment
fun transactionsOf(account: Account) = sql {
composeFrom.join(Table<Transaction>()) { transaction -> transaction.accountId == account.id }
}
//sampleStart
// Take an existing query...
val userAccounts = sql.select {
val u = from(Users)
val a = from(accountsOf(u))
u to a
}
// Composes to single flat SQL with JOINs, not N+1!
sql.select {
val (u, a) = from(userAccounts)
val t = from(transactionsOf(a))
a to t
}
//sampleEnd| Feature | ExoQuery | SQLDelight | Exposed |
|---|---|---|---|
| Syntax | ✓ Native Kotlin | SQL Strings | Specialized DSL |
| Basic Entity | ✓ Regular Data Class | Generated Function | Table Object DSL |
| Type Safety | ✓ Full | ⚠ Only function-based | ⚠ Only column-based |
| IDE Support | ✓ Full | ⚠ IntelliJ plugin | ✓ IntelliJ Plugin Optional |
| Learning Curve | ✓ Minimal | SQL + Tooling | DSL + Customizations |
Compile-time SQL generation means zero runtime overhead and maximum efficiency
Execution profiles show the performance difference.
Runtime reflection & SQL generation
Loading...
Compile-time SQL generation
Loading...
Check out these resources to see ExoQuery in action
Explore live code samples and see how ExoQuery simplifies your queries
View Examples →4-minute overview of ExoQuery's key features