document sql builder

This commit is contained in:
Maxime Duchene-Savard 2026-06-23 23:47:38 -04:00
parent 10a8622e7c
commit da8362e88c
2 changed files with 320 additions and 0 deletions

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Boruto
## Documentation
- [SQL Builder](docs/sql-builder.md)

315
docs/sql-builder.md Normal file
View File

@ -0,0 +1,315 @@
# 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`.
```kotlin
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:
```text
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 with `toSql()`.
- `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 as `RETURNING`.
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:
```kotlin
Sql.select("u.id").from("users", "u").toSql()
```
```text
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.
```kotlin
Sql.raw("CURRENT_TIMESTAMP")
```
Raw SQL can still use bind values. Each `?` is converted to the active dialect's
placeholder:
```kotlin
Sql.raw("LOWER(email) = LOWER(?)", "sakura@example.com")
```
## SELECT
```kotlin
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()
```
```text
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 CTEs
- `union(...)`, `unionAll(...)`
## INSERT
```kotlin
val statement = Sql
.insertInto("users")
.columns("email", "display_name")
.values("sakura@example.com", "Sakura")
.values("naruto@example.com", "Naruto")
.returning("id")
.toSql()
```
```text
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:
```kotlin
val statement = Sql
.insertInto("users")
.set(
mapOf(
"email" to "hinata@example.com",
"display_name" to "Hinata",
),
)
.returning("id")
.toSql()
```
For `INSERT ... SELECT`:
```kotlin
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
```kotlin
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()
```
```text
UPDATE "users"
SET "display_name" = $1, "updated_at" = CURRENT_TIMESTAMP
WHERE ("id" = $2)
RETURNING "id", "updated_at"
parameters: ["Hinata", 42]
```
## DELETE
```kotlin
val statement = Sql
.deleteFrom("sessions")
.where(Sql.col("user_id").eq(42))
.toSql()
```
```text
DELETE FROM "sessions" WHERE ("user_id" = $1)
parameters: [42]
```
## CTEs And Subqueries
```kotlin
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()
```
```text
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.
```kotlin
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:
```kotlin
val postgres = Sql
.select("id")
.from("users")
.where(Sql.col("email").eq("sakura@example.com"))
.toSql()
```
```text
SELECT "id" FROM "users" WHERE ("email" = $1)
```
Render the same builder for another dialect by passing it to `toSql(...)`:
```kotlin
val builder = Sql
.select("id")
.from("users")
.where(Sql.col("email").eq("sakura@example.com"))
val ansi = builder.toSql(AnsiDialect)
```
```text
SELECT "id" FROM "users" WHERE ("email" = ?)
```
If a builder uses a feature that the selected dialect does not support, rendering
fails early:
```kotlin
Sql
.insertInto("users")
.columns("email")
.values("sakura@example.com")
.returning("id")
.toSql(AnsiDialect) // throws IllegalArgumentException
```
## Expressions
Common expression helpers:
```kotlin
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:
```kotlin
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.