7.0 KiB
SQL Builder
The SQL builder creates parameterized SQL statements without tying query code to a
specific database driver. It currently defaults to Postgres output, while keeping
database-specific behavior behind SqlDialect.
import dev.mduchene.sql.*
val statement = Sql
.select("id", "email")
.from("users")
.where(Sql.col("status").eq("active"))
.toSql()
statement contains the SQL text and JDBC bind values separately:
sql: SELECT "id", "email" FROM "users" WHERE ("status" = $1)
parameters: ["active"]
Core Types
SqlStatement: rendered SQL plus ordered parameters.SqlStatementBuilder: implemented by statement builders and rendered withtoSql().SqlDialect: controls placeholders, identifier quoting, and dialect feature flags.PostgresDialect: default dialect. Uses$1,$2, etc.AnsiDialect: portable baseline. Uses?placeholders and rejects unsupported features such asRETURNING.
The builder only renders SQL. It does not execute JDBC statements or manage connections.
Identifiers And Raw SQL
Column, table, and alias names passed as plain strings are treated as identifiers. They are validated and quoted:
Sql.select("u.id").from("users", "u").toSql()
SELECT "u"."id" FROM "users" AS "u"
Use Sql.raw(...) only for trusted SQL fragments that are not identifiers or bind
values, such as database functions, casts, or explicit SQL operators.
Sql.raw("CURRENT_TIMESTAMP")
Raw SQL can still use bind values. Each ? is converted to the active dialect's
placeholder:
Sql.raw("LOWER(email) = LOWER(?)", "sakura@example.com")
SELECT
val statement = Sql
.select(
Sql.col("u.id"),
Sql.col("u.email"),
Sql.count(Sql.col("p.id")).asAlias("post_count"),
)
.from("users", "u")
.leftJoin("posts", "p", Sql.col("p.user_id").eq(Sql.col("u.id")))
.where(Sql.col("u.status").eq("active"))
.andWhere(Sql.col("u.deleted_at").isNull())
.groupBy("u.id", "u.email")
.having(Sql.count(Sql.col("p.id")).gt(0))
.orderBy(Sql.col("u.created_at").desc(NullsOrder.LAST))
.limit(25)
.offset(50)
.toSql()
SELECT "u"."id", "u"."email", COUNT("p"."id") AS "post_count"
FROM "users" AS "u"
LEFT JOIN "posts" AS "p" ON ("p"."user_id" = "u"."id")
WHERE (("u"."status" = $1) AND ("u"."deleted_at" IS NULL))
GROUP BY "u"."id", "u"."email"
HAVING (COUNT("p"."id") > $2)
ORDER BY "u"."created_at" DESC NULLS LAST
LIMIT 25 OFFSET 50
parameters: ["active", 0]
Supported select features include:
distinct()from(...)join(...),leftJoin(...),rightJoin(...),fullJoin(...),crossJoin(...)where(...),andWhere(...),orWhere(...)groupBy(...),having(...),andHaving(...)orderBy(...)limit(...),offset(...)with(...)for CTEsunion(...),unionAll(...)
INSERT
val statement = Sql
.insertInto("users")
.columns("email", "display_name")
.values("sakura@example.com", "Sakura")
.values("naruto@example.com", "Naruto")
.returning("id")
.toSql()
INSERT INTO "users" ("email", "display_name")
VALUES ($1, $2), ($3, $4)
RETURNING "id"
parameters: ["sakura@example.com", "Sakura", "naruto@example.com", "Naruto"]
You can also insert from a map:
val statement = Sql
.insertInto("users")
.set(
mapOf(
"email" to "hinata@example.com",
"display_name" to "Hinata",
),
)
.returning("id")
.toSql()
For INSERT ... SELECT:
val source = Sql
.select("user_id", "email")
.from("pending_invites")
.where(Sql.col("accepted_at").isNotNull())
val statement = Sql
.insertInto("users")
.fromSelect(listOf("id", "email"), source)
.toSql()
UPDATE
val statement = Sql
.update("users")
.set("display_name", "Hinata")
.set("updated_at", Sql.raw("CURRENT_TIMESTAMP"))
.where(Sql.col("id").eq(42))
.returning("id", "updated_at")
.toSql()
UPDATE "users"
SET "display_name" = $1, "updated_at" = CURRENT_TIMESTAMP
WHERE ("id" = $2)
RETURNING "id", "updated_at"
parameters: ["Hinata", 42]
DELETE
val statement = Sql
.deleteFrom("sessions")
.where(Sql.col("user_id").eq(42))
.toSql()
DELETE FROM "sessions" WHERE ("user_id" = $1)
parameters: [42]
CTEs And Subqueries
val inactiveUsers = Sql
.select("id")
.from("users")
.where(Sql.col("last_seen_at").lt(Sql.raw("CURRENT_DATE - INTERVAL '1 year'")))
val statement = Sql
.deleteFrom("sessions")
.with("inactive_users", inactiveUsers)
.where(
Sql.col("user_id").inSubquery(
Sql.select("id").from("inactive_users"),
),
)
.toSql()
WITH "inactive_users" AS (
SELECT "id"
FROM "users"
WHERE ("last_seen_at" < CURRENT_DATE - INTERVAL '1 year')
)
DELETE FROM "sessions"
WHERE ("user_id" IN (SELECT "id" FROM "inactive_users"))
Dynamic Conditions
Builders are mutable, so query code can add conditions as request filters are present.
fun findUsers(status: String?, email: String?): SqlStatement {
val query = Sql
.select("id", "email", "status")
.from("users")
.where(Sql.col("deleted_at").isNull())
if (status != null) {
query.andWhere(Sql.col("status").eq(status))
}
if (email != null) {
query.andWhere(Sql.raw("LOWER(email) = LOWER(?)", email))
}
return query.toSql()
}
Dialects
Postgres is the default:
val postgres = Sql
.select("id")
.from("users")
.where(Sql.col("email").eq("sakura@example.com"))
.toSql()
SELECT "id" FROM "users" WHERE ("email" = $1)
Render the same builder for another dialect by passing it to toSql(...):
val builder = Sql
.select("id")
.from("users")
.where(Sql.col("email").eq("sakura@example.com"))
val ansi = builder.toSql(AnsiDialect)
SELECT "id" FROM "users" WHERE ("email" = ?)
If a builder uses a feature that the selected dialect does not support, rendering fails early:
Sql
.insertInto("users")
.columns("email")
.values("sakura@example.com")
.returning("id")
.toSql(AnsiDialect) // throws IllegalArgumentException
Expressions
Common expression helpers:
Sql.col("age").gte(18)
Sql.col("email").like("%@example.com")
Sql.col("deleted_at").isNull()
Sql.col("role").inValues("admin", "moderator")
Sql.col("id").inSubquery(Sql.select("user_id").from("sessions"))
Sql.exists(Sql.select().from("sessions").where(Sql.col("sessions.user_id").eq(Sql.col("users.id"))))
Sql.and(Sql.col("status").eq("active"), Sql.col("deleted_at").isNull())
Sql.or(Sql.col("role").eq("admin"), Sql.col("role").eq("owner"))
Comparison helpers bind ordinary Kotlin values as parameters. Passing another
SqlExpression compares SQL expressions directly:
Sql.col("posts.user_id").eq(Sql.col("users.id"))
Current Scope
The builder is intentionally small. Add new SQL features through the dialect and fragment APIs when the application needs them, rather than introducing a larger ORM-style abstraction early.