SQLDelight 的类型安全 SQL,在 Android 项目中的使用
SQLDelight 的类型安全 SQL,在 Android 项目中的使用
从一个 Room 的字符串 SQL 隐患切入
如果你用 Room 写过足够复杂的查询,大概率踩过这样的坑:某个需求迭代里,你把数据库表里的列名从 user_name 改成了 display_name,Kotlin data class 里的字段跟着改了,@Entity 里的 ColumnInfo 也同步了,甚至编译一次顺利通过。结果发版后线上崩溃,堆栈信息指向某行你手写在 @Query("SELECT * FROM user WHERE user_name = :name") 里的 SQL。Room 在编译期只会检查这条 SQL 的语法合法性,它并不会去验证 user_name 这个字符串在当前 schema 里是否存在。字符串就是字符串,R8 混淆之后问题可能更隐蔽,直到运行时 SQLite 抛出一个 no such column: user_name 才真正暴露。
这类问题在包含大量手写 JOIN、子查询或者窗口函数的项目里尤其致命。Room 的设计哲学是“注解驱动”,你用 Kotlin 接口和注解描述意图,Room 帮你生成实现。这个模式对简单 CRUD 非常友好,但一旦查询复杂起来,SQL 字符串散落在各个 @Query 里,重构时的脆弱性就成了技术债。SQLDelight 走的则是完全相反的路子:SQL 优先。你把真实的 SQL 语句写进 .sq 文件,SQLDelight 的 Gradle 插件在编译期解析这些语句,生成类型安全的 Kotlin API。如果列名写错了,编译直接失败,而不是等到运行时。
我最初接触 SQLDelight 是在一个需要重度使用 CTE(Common Table Expression)和递归查询的项目里。Room 对这类 SQL 的支持基本为零,而 SQLDelight 因为直接拥抱原生 SQL,语法支持度几乎等同于底层 SQLite 的版本。这个项目最终从 Room 迁移到 SQLDelight 的过程不算轻松,但迁移完之后,数据库层的编译期安全感确实提升了一个量级。
SQLDelight 的编译期 SQL 验证与代码生成逻辑
SQLDelight 的核心机制并不神秘:你在 src/main/sqldelight 目录下创建 .sq 文件,文件里写标准的 SQL 语句,插件在 Gradle 的编译前阶段调用 Antlr 解析这些文件,然后生成对应的 Kotlin 接口。生成的代码里,每个 .sq 文件对应一个 Queries 类,每个 SQL 语句对应一个类型安全的函数。
举个例子,如果你创建了 User.sq,里面写:
CREATE TABLE user (
id INTEGER PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT
);
selectByEmail:
SELECT * FROM user WHERE email = ?;SQLDelight 生成的 Kotlin 代码里会有一个 UserQueries 类,包含一个 selectByEmail(email: String): Query<User> 方法。这里的参数类型和返回类型都不是你手写注解猜出来的,而是插件根据 SQL 语句里的列定义严格推导的。display_name 标记了 NOT NULL,所以生成的 User data class 里 display_name 是 String;email 允许 NULL,对应类型就是 String?。这种推导在表结构变更时会自动传播,不存在 Room 里那种“字符串 SQL 和数据类脱节”的风险。
需要特别注意版本迁移。SQLDelight 目前主版本是 2.x,Gradle 插件的坐标从 1.x 时代的 com.squareup.sqldelight 迁移到了 app.cash.sqldelight(GitHub 仓库地址:github.com/cashapp/sqldelight)。如果你在旧项目里看到 com.squareup.sqldelight:gradle-plugin,那就是还没升级到 2.0.0 之前的遗产配置。升级时不仅要改 Gradle 坐标,生成的包名和 artifact 路径也有变化,比如 com.squareup.sqldelight:runtime 变成了 app.cash.sqldelight:runtime,这个细节在官方迁移指南里有说明,但很多人第一次升级时会被 Gradle 的依赖解析报错卡住。
生成的代码结构里,除了各表的 Queries,还会生成一个 Database 接口和一个 Transacter 实现。你不需要手动实现事务逻辑,直接用生成的 transaction { ... } 块就行。这和 Room 的 @Transaction 注解在效果上等价,但实现方式不同:Room 是在生成的实现类里包裹 beginTransaction/endTransaction,而 SQLDelight 把事务控制直接暴露给调用方,通过 SqlDriver 来执行。Android 项目上,这个 SqlDriver 的具体实现是 AndroidSqliteDriver,它封装了 SupportSQLiteDatabase,所以你依然可以在底层复用 Room 的 SQLite 支持库,或者直接用 Android 原生的 SQLiteDatabase。
.sq 文件里的 Kotlin 类型映射与 ColumnAdapter
SQLDelight 默认支持的类型映射比较基础:INTEGER 对应 Long,REAL 对应 Double,TEXT 对应 String,BLOB 对应 ByteArray。实际项目里这几个原生类型往往不够用。比如后端返回的时间戳是 ISO-8601 字符串,你想在 Kotlin 层用 kotlinx.datetime.Instant;或者某个字段存的是 JSON,你想直接映射成自定义的 data class。
这时候就需要 ColumnAdapter。它的接口定义很简单,只有两个方法:encode 把 Kotlin 类型转成 SQL 能存的类型,decode 做反向转换。比如在 User.sq 里,你可以声明一个 profile_data TEXT AS Profile 的列,然后在创建 AndroidSqliteDriver 的时候注册适配器:
val driver: SqlDriver = AndroidSqliteDriver(schema, context, "app.db")
val database = Database(
driver = driver,
userAdapter = User.Adapter(
profile_dataAdapter = object : ColumnAdapter<Profile, String> {
override fun decode(databaseValue: String): Profile {
return Json.decodeFromString(databaseValue)
}
override fun encode(value: Profile): String {
return Json.encodeToString(value)
}
}
)
)这个机制非常灵活,但也有坑。最大的坑在于 nullable 的处理。如果你的 SQL 里写的是 profile_data TEXT AS Profile,没有 NOT NULL,SQLDelight 会生成 Profile? 类型。可一旦你的 ColumnAdapter 在 decode 里假设了输入非空,运行时遇到 NULL 就会直接崩溃。更隐蔽的情况是,你在 SQL 里把列标记为 NOT NULL,但数据库里已经存在的旧数据其实有 NULL(比如迁移脚本没处理好历史数据),这时 SQLDelight 会按非空类型生成 Kotlin 代码,读取旧数据时底层 SQLite 返回 NULL,结果就是在 Cursor 读取阶段直接抛异常。
另一个常见陷阱是枚举类型。很多人喜欢用 TEXT AS EnumClass 配合 ColumnAdapter 把数据库的字符串映射成 Kotlin enum。这本身没问题,但一旦后端新增了一个枚举值,而 App 还没更新版本,数据库里就会出现一个 adapter 无法识别的字符串。ColumnAdapter 的 decode 在这种情况下要么抛异常,要么你得在实现里兜底成一个默认枚举值。这和 Room 的 TypeConverter 面临的挑战一样,但 SQLDelight 因为类型推导更严格,出错信息往往更直接——通常是一个 IllegalStateException 带着无法反序列化的值,而不是 Room 那种类型转换后的 ClassCastException。
迁移脚本 .sqm 与版本控制的严格性
Room 的迁移策略比较宽松:你可以直接改 @Entity 里的定义,只要不增加 version,Room 在 debug 模式下甚至会自动重建数据库。SQLDelight 对迁移的态度近乎严苛。每次修改 .sq 文件里的 CREATE TABLE 定义,你必须同步更新 version 号,并且在同级目录下提供对应的 .sqm 迁移脚本。假设你的 User.sq 初始版本是 1,后来加了 age 列,你需要做三件事:第一,在 User.sq 里把版本声明改成 2;第二,创建 1.sqm 文件,里面写 ALTER TABLE user ADD COLUMN age INTEGER;;第三,确保这个迁移脚本在语法上能正确把旧数据库带到新 schema。
更严格的是,SQLDelight 默认会开启迁移验证。在 Gradle 配置里,如果你打开了 verifyMigration = true,插件会在编译期执行所有迁移脚本,并把最终生成的 schema 与 .sq 文件里的定义做 diff。如果 1.sqm 执行完后,表结构和 User.sq 里写的 CREATE TABLE 不一致,编译会直接失败,报错信息通常是 Migration validation failed 并指出具体差异。这对团队协作是双刃剑:好处是再也不会有人忘记写迁移脚本;坏处是如果某个开发者在分支 A 里改了 schema,分支 B 里也改了 schema,合并之后两个分支的 migration 序号冲突,解决起来比 Room 的 RoomDatabase.Migration 麻烦得多。
我遇到过的一个具体坑是:项目里有一个 1.sqm 做了 ALTER TABLE,但我在修改 CREATE TABLE 语句时顺手把列顺序调整了。SQLite 的 ALTER TABLE 并不支持重排列,最终验证时发现 migration 后的列顺序和 .sq 文件里定义的顺序不同,编译报错。解决方式要么是在 1.sqm 里用临时表做完整的数据迁移(SQLite 标准做法),要么是接受 SQLDelight 生成代码时并不依赖列顺序这一事实——实际上 SQLDelight 按列名匹配,顺序不同不影响运行,但验证器会把它当成 schema diff 拦下来。这种时候你不得不去 Gradle 配置里调整验证策略,或者老老实实重写迁移脚本。
Gradle 配置与版本锁定
SQLDelight 的 Gradle 配置比 Room 要多几步。在项目的 build.gradle.kts 里,你需要应用插件 app.cash.sqldelight,并在 sqldelight 块里显式声明数据库:
plugins {
id("app.cash.sqldelight") version "2.0.1"
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.example.data.db")
verifyMigration.set(true)
schemaOutputDirectory.set(file("src/main/sqldelight/databases"))
}
}
}schemaOutputDirectory 这个配置很多人会忽略。指定之后,SQLDelight 会在每次编译时把当前 schema 的完整 DDL 输出到这个目录,通常是 1.db 这样的文件。这些文件应该提交到版本控制里,因为 CI 环境进行迁移验证时需要它们作为基准。