Quill 的核心思路并不复杂:它把 Scala 中的查询描述通过宏展开成 SQL 字符串,而不是在运行时解析字符串模板。这样做的好处是,所有对表字段的引用都变成对 case class 属性的引用,编译器会替你检查字段是否存在、类型是否匹配。在 PostgreSQL 项目中,列名拼错、过滤条件类型写反这类问题可以在编译阶段直接报错,不用等到执行 SQL 才暴露。

一、Quill 编译时查询的核心机制
Quill 提供了一套 quoted DSL,通过 Scala 的宏机制在编译期把查询表达式转换为 SQL。与其他 ORM 不同,它不依赖运行时反射去扫描实体,而是利用静态类型信息直接生成 SQL 片段。比如定义一个 User 实体后,在 quote 块里写的过滤条件会被宏解析成 AST,再由方言模块翻译成 PostgreSQL 可执行的 SQL。
这种机制的关键在于查询本身是普通 Scala 代码。当你写 u.age > 18 时,编译器先确认 age 字段确实存在于 User 类型上,并且类型支持 > 比较。如果字段名拼错,宏展开前就会报编译错误;如果拿字符串去和整型比较,也会被编译器拦下。对于长期维护的 PostgreSQL 项目,这一点能显著降低重构表结构时漏改查询的风险。
Quill 并不生成一套完全不可控的抽象层。它允许通过 infix 直接嵌入 SQL 片段,所以遇到 PostgreSQL 特有的函数或运算符时,仍能保留原生 SQL 的表达能力。编译时检查与手动 SQL 控制之间因此有了比较平衡的折中。
二、接入 PostgreSQL 并编写第一个编译时查询
在 sbt 项目中引入 Quill 的 JDBC 模块即可使用 PostgreSQL 方言。通常需要配置数据源,然后创建对应的上下文对象。下面的示例使用 SnakeCase 命名策略,这样 Scala 里的驼峰字段会自动映射到 PostgreSQL 的下划线列名。
import io.getquill._
case class User(id: Int, name: String, age: Int)
val ctx = new PostgresJdbcContext(SnakeCase, "db.default")
import ctx._
val adults = quote {
query[User].filter(u => u.age > 18)
}
val result: List[User] = ctx.run(adults)
执行这段代码时,Quill 会在编译期为 adults 生成类似 SELECT u.id, u.name, u.age FROM users u WHERE u.age > 18 的 SQL。实际发送到 PostgreSQL 的 SQL 可以通过 ctx.translate 查看,这对调试非常有用。注意代码里没有写任何字符串拼接,列名和表名都由映射策略自动生成。
PostgreSQL 常见的参数化查询也很自然。对于外部传入的值,可以使用 lift 将其安全地嵌入查询。比如按名称模糊搜索可以写成 query[User].filter(u => u.name like lift(s"%$keyword%"))。lift 会变成预编译参数,避免 SQL 注入。
三、处理 PostgreSQL 特有类型与动态查询
PostgreSQL 的 JSONB、数组和全文检索等能力在 Quill 中也有对应使用方式,但复杂度会上升。对于 JSONB 字段,Quill 内置了一部分操作符支持,但更灵活的做法是使用 infix 直接表达 JSONB 查询。例如要筛选 JSONB 列中是否包含某个键值,可以写:
case class Event(id: Int, payload: io.getquill.MappedEncoding[String, String])
val search = quote {
query[Event].filter(e =>
infix"${e.payload} @> ${lift("""{"level":"error"}""")}".as[Boolean]
)
}
这里 infix 允许将原生 SQL 运算符 @> 放入查询,同时 ${...} 插值仍然被 Quill 识别为安全参数或字段引用。需要注意的是,过度使用 infix 会削弱编译时检查,因为里面的 SQL 片段只有在运行时才会被 PostgreSQL 解析。所以建议把 infix 封装在小型函数中,而不是散落在业务查询里。
动态查询是另一个常见需求。Quill 支持将多个 quote 组合成一个查询,通过 dynamicQuery 在运行时根据条件拼接。比如分页和筛选可以写成:
val q = quote {
query[User].filter(u => u.name like lift(s"%$kw%"))
}
val finalQ = quote {
q.drop(lift(offset)).take(lift(limit))
}
val page: List[User] = ctx.run(finalQ)
这种组合方式仍然能保留大部分编译期检查,同时适应运行时变化的条件。与完全手写 SQL 字符串相比,字段引用和类型检查仍然由编译器负责,只有控制流部分在运行时确定。
四、编译时检查的边界与性能取舍
Quill 的编译时查询并不是银弹。它的宏展开发生在编译阶段,因此查询结构必须在编译期可见。如果查询需要依赖运行时反射生成字段列表,或者要执行非常复杂的递归 CTE,可能需要退回到底层 JDBC。另外,宏展开会增加编译时间,当查询数量很多时,Scala 编译速度会明显变慢。不过对大多数 CRUD 和报表类查询来说,这种开销通常可以接受。
性能方面,编译时生成的 SQL 与手写 SQL 基本一致,因为它没有引入额外的 ORM 缓存或实体状态管理。查询执行路径直接走 JDBC PreparedStatement,PostgreSQL 执行计划不会因为 Quill 而改变。唯一需要关注的是,宏生成 SQL 时可能会把某些表达式展开成冗余的别名,但现代 PostgreSQL 优化器会消除这些影响。
综合来看,在 PostgreSQL 项目中使用 Quill 的收益主要体现在类型安全和重构体验上。列名重命名时,编译器会指出所有受影响的查询;新增字段不会破坏旧的查询;大多数参数绑定自动完成。对于重视 SQL 可控性又希望减少低级错误的团队,Quill 是一个比较务实的选择。
落地时建议把查询定义集中放在独立的 Queries 对象中,避免在业务代码里散落 quote 块。同时,对 infix 的使用加一层封装,例如只暴露语义化的方法 containsKey,这样既能享受 PostgreSQL 特性,又不会让原生 SQL 片段蔓延到整个代码库。
PostgreSQLQuill编译时查询修改时间:2026-10-02 06:54:09