跳转到内容

Chisel

Chisel 是用 Scala 3 编写的 PetraDB 类型化数据访问层。它将 Value 映射到 Scala 类型,使用字符串插值器构建注入安全的 SQL,并为表生成 CRUD —— 无需完整 ORM 的负担(没有标识映射,没有工作单元,没有惰性代理)。它能在 PetraDB 运行的所有地方运行:JVM、Node.js(Scala.js)和 Native,使用编译时派生而非运行时反射。

Chisel 通过绑定参数直接与引擎的 Session 通信,因此不需要 JDBC,也不会将任何值渲染进 SQL 文本。

libraryDependencies += "io.github.edadma" %%% "petradb-chisel" % "1.5.1"

Chisel 依赖引擎来获取会话:

libraryDependencies += "io.github.edadma" %%% "petradb-engine" % "1.5.5"

一切都基于 Session 运行,并需要一个 ExecutionContext。Chisel 的操作返回 Future

import io.github.edadma.petradb.Session
import io.github.edadma.petradb.engine.MemoryDB
import io.github.edadma.petradb.chisel.*
import scala.concurrent.ExecutionContext.Implicits.global
given Session = new MemoryDB().connect()

会话会隐式传递给每个 Chisel 操作,因此只需在作用域中有一个 given Session 即可。

Chisel 通过四个类型类在 Scala 值和 PetraDB Value 之间进行映射:

类型类方向作用
Get[A]Value => A解码一列
Put[A]A => Value编码一个值(绑定参数)
Read[A]Row => A解码整行
Write[A]A => Seq[(String, Value)]编码整行

为常见的标量类型提供了 GetPut 实例:

Scala 类型PetraDB 类型
IntLongShortByte整数家族
DoubleFloat浮点数
BigDecimalNUMERIC
StringTEXT
BooleanBOOLEAN
java.time.LocalDateDATE
java.time.LocalTimeTIME
java.time.LocalDateTimeTIMESTAMP
java.time.OffsetDateTimeTIMESTAMPTZ
java.time.OffsetTimeTIMETZ
java.time.DurationINTERVAL
Array[Byte]BYTEA
java.util.UUIDUUID
Option[A]可空的 ANone ⇄ SQL NULL

类型不匹配的列会引发 DecodeException

为 case class 派生 ReadWrite 以映射整行。派生是按字段名进行的,因此它对列重排序是健壮的,并且可以直接配合 SELECT * 使用:

case class User(id: Long, name: String, age: Int) derives Read, Write

Option 字段映射到可空列:

case class Account(id: Long, nickname: Option[String]) derives Read, Write

任何 product 都可以派生,包括具名元组 —— 这对于无需声明类的临时投影很方便:

val r = Read.derived[(id: Long, name: String)]

Get/Read.mapPut/Write.contramap,用于适配 Chisel 不认识的类型:

enum Color:
case Red, Green, Blue
given Get[Color] = Get[String].map(Color.valueOf)
given Put[Color] = Put[String].contramap[Color](_.toString)

sql"…" 构建一个 Fragment —— SQL 文本加上绑定参数。每个被插入的参数都通过其 Put 进行编码并作为参数绑定,因此值永远不会变成 SQL 文本,注入是不可能的:

val minAge = 18
val frag = sql"select id, name, age from users where age >= $minAge"

Fragment 渲染为引擎的 $1$2、… 占位符形式:

frag.sql // "select id, name, age from users where age >= $1"
frag.params // Seq(NumberValue(18))

Option 和预先构建的 Value 也可以被插入 —— None 绑定 SQL NULL

片段以未渲染的形式存储,因此它们可以用 ++ 组合并自动重新编号占位符。Fragment.const 贡献原始文本(用于受信任的标识符,如表名),Fragment.param 贡献单个绑定值:

val table = "users"
val q = sql"select * from " ++ Fragment.const(table) ++ sql" where age >= $minAge"

.query[A] 附加行解码器,然后选择如何收集结果:

val users: Future[List[User]] =
sql"select * from users order by id".query[User].toList
val one: Future[Option[User]] =
sql"select * from users where id = ${1L}".query[User].option
val exactlyOne: Future[User] =
sql"select * from users where id = ${1L}".query[User].unique
方法结果说明
.toListFuture[List[A]]所有行
.toVectorFuture[Vector[A]]所有行
.optionFuture[Option[A]]第一行或 None
.uniqueFuture[A]恰好一行;否则失败

INSERT … RETURNING 同样通过 .query 解码:

val inserted: Future[User] =
sql"insert into users (name, age) values (${"alice"}, ${30}) returning *"
.query[User].unique

queryValue 直接读取第一行的第一列 —— 用于 count(*)existsmax 等:

val total: Future[Long] = sql"select count(*) from users".queryValue[Long]
val maybeMax: Future[Option[Int]] = sql"select max(age) from users".queryValueOption[Int]

对于非查询的 SQL,.update 返回受影响的行数,.run 返回原始引擎结果:

val changed: Future[Int] =
sql"update users set age = ${31} where id = ${1L}".update
val raw: Future[Seq[Result]] =
sql"create table users (id serial primary key, name text, age integer)".run

Repo[A, Id] 根据实体的 Read/Write 实例为单个表生成 CRUD。对于它未覆盖的任何内容,使用 sql"…"

case class User(id: Long, name: String, age: Int) derives Read, Write
val users = Repo[User, Long]("users") // idColumn = "id", generatedId = true

默认情况下,Repo 假定使用数据库生成的键(SERIAL),因此 insert/insertReturning 会省略 id 列,由数据库进行分配。构造实体时传入一个占位 id —— 它在插入时会被忽略:

val saved: Future[User] = users.insertReturning(User(0, "alice", 30))
// saved.id 是生成的键
users.findById(1L) // Future[Option[User]]
users.findAll // Future[List[User]]
users.count // Future[Long]
users.existsById(1L) // Future[Boolean]
users.insert(User(0, "bob", 25)) // Future[Int] — 插入的行数
users.insertReturning(User(0, "eve", 22)) // Future[User] — 取回生成的 id
users.update(saved.copy(age = 31)) // Future[Int] — 在 id 匹配处设置所有非 id 列
users.deleteById(1L) // Future[Int]
users.deleteAll // Future[Int]

对于主键由应用程序提供(而非生成)的表,设置 generatedId = false,如果 id 列不是 "id",则为其命名:

case class Widget(sku: String, label: String) derives Read, Write
val widgets = Repo[Widget, String]("widgets", idColumn = "sku", generatedId = false)
widgets.insert(Widget("w-1", "Sprocket"))
widgets.findById("w-1")

Chisel 从单一源代码为 JVM、Scala.js 和 Scala Native 发布。行解码使用 Scala 3 内联 Mirror 派生,因此在任何平台上都没有运行时反射。

summon[Session].close()