diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ed0ba4 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Boruto + +## Documentation + +- [SQL Builder](docs/sql-builder.md) diff --git a/docs/sql-builder.md b/docs/sql-builder.md new file mode 100644 index 0000000..863dae5 --- /dev/null +++ b/docs/sql-builder.md @@ -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.