document sql builder
This commit is contained in:
parent
10a8622e7c
commit
da8362e88c
5
README.md
Normal file
5
README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Boruto
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [SQL Builder](docs/sql-builder.md)
|
||||||
315
docs/sql-builder.md
Normal file
315
docs/sql-builder.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user