昨天解決了 Compose Desktop 的除錯問題,今天要來處理資料層。
當你在做 KMP 專案時,資料庫選擇是個大問題。
於是我請 AI 研究 KMP 的資料庫方案:
我:KMP 專案要用什麼資料庫?需要支援 Desktop 和未來的 iOS
AI:推薦 SQLDelight,它是 Square(Cash App 開發商)開發的...
我:直接寫 SQL?這不是倒退嗎?
AI:讓我展示 SQLDelight 的魔法...
在 AI 的引導下,我才發現 SQLDelight 是 SQL 與類型系統的完美結合。
// Room/JPA 風格
@Query("SELECT * FROM project WHERE status = :status")
fun getProjectsByStatus(status: String): List<Project>
// 問題:
// 1. status 可能拼錯("active" vs "activ")
// 2. 執行時才發現 SQL 錯誤
// 3. 欄位改名要手動更新所有查詢
-- Project.sq
selectByStatus:
SELECT * FROM Project
WHERE status = ?;
編譯後自動生成:
// 自動生成的 Kotlin 程式碼
fun selectByStatus(status: String): Query<Project> {
// 類型安全、編譯時檢查
}
關鍵差異在於 SQL 在編譯時驗證,不是執行時。
-- Project.sq
CREATE TABLE Project (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
tags TEXT, -- JSON array
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
);
-- 建立索引優化查詢
CREATE INDEX idx_project_status ON Project(status);
CREATE INDEX idx_project_updated ON Project(updatedAt DESC);
-- 基本 CRUD
selectAll:
SELECT * FROM Project
ORDER BY updatedAt DESC;
selectById:
SELECT * FROM Project
WHERE id = ?;
insert:
INSERT OR REPLACE INTO Project
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
update:
UPDATE Project
SET name = ?, description = ?, path = ?,
status = ?, tags = ?, updatedAt = ?
WHERE id = ?;
delete:
DELETE FROM Project
WHERE id = ?;
-- 進階查詢
selectActive:
SELECT * FROM Project
WHERE status = 'active'
ORDER BY updatedAt DESC;
selectByTags:
SELECT * FROM Project
WHERE tags LIKE '%' || ? || '%';
countByStatus:
SELECT status, COUNT(*) as count
FROM Project
GROUP BY status;
-- 複雜查詢:最近修改的專案
selectRecent:
SELECT * FROM Project
WHERE updatedAt > ?
AND status != 'archived'
ORDER BY updatedAt DESC
LIMIT ?;
class ProjectRepositoryImpl(
private val database: GrimoDatabase
) : ProjectRepository {
private val queries = database.projectQueries
// 基本 CRUD
override suspend fun getAllProjects(): List<Project> =
withContext(Dispatchers.IO) {
queries.selectAll()
.executeAsList()
.map { it.toDomain() }
}
override suspend fun getProjectById(id: String): Project? =
withContext(Dispatchers.IO) {
queries.selectById(id)
.executeAsOneOrNull()
?.toDomain()
}
override suspend fun createProject(project: Project): Result<Project> =
withContext(Dispatchers.IO) {
try {
queries.insert(
id = project.id,
name = project.name,
description = project.description,
path = project.path,
status = project.status.name,
tags = project.tags.toJson(),
createdAt = project.createdAt.toEpochMilliseconds(),
updatedAt = project.updatedAt.toEpochMilliseconds()
)
Result.success(project)
} catch (e: Exception) {
Result.failure(e)
}
}
// Flow 支援 - 即時監聽變化
override fun observeAllProjects(): Flow<List<Project>> =
queries.selectAll()
.asFlow()
.mapToList(Dispatchers.IO)
.map { list ->
list.map { it.toDomain() }
}
.flowOn(Dispatchers.IO)
// 進階查詢
override suspend fun getRecentProjects(
since: Instant,
limit: Int
): List<Project> =
withContext(Dispatchers.IO) {
queries.selectRecent(
since.toEpochMilliseconds(),
limit.toLong()
)
.executeAsList()
.map { it.toDomain() }
}
override suspend fun getProjectStats(): Map<ProjectStatus, Int> =
withContext(Dispatchers.IO) {
queries.countByStatus()
.executeAsList()
.associate {
ProjectStatus.valueOf(it.status) to it.count.toInt()
}
}
// 批次操作
override suspend fun archiveProjects(ids: List<String>): Result<Int> =
withContext(Dispatchers.IO) {
try {
var count = 0
queries.transaction {
ids.forEach { id ->
queries.update(
id = id,
status = ProjectStatus.ARCHIVED.name,
updatedAt = Clock.System.now().toEpochMilliseconds(),
// 保持其他欄位不變
name = queries.selectById(id).executeAsOne().name,
description = queries.selectById(id).executeAsOne().description,
path = queries.selectById(id).executeAsOne().path,
tags = queries.selectById(id).executeAsOne().tags
)
count++
}
}
Result.success(count)
} catch (e: Exception) {
Result.failure(e)
}
}
}
// 擴展函數:DB Model 轉 Domain Model
private fun DbProject.toDomain(): Project =
Project(
id = id,
name = name,
description = description,
path = path,
status = ProjectStatus.valueOf(status),
tags = tags?.fromJson() ?: emptyList(),
createdAt = Instant.fromEpochMilliseconds(createdAt),
updatedAt = Instant.fromEpochMilliseconds(updatedAt)
)
// 這樣寫會編譯錯誤
queries.selectById(123) // 期待 String,給了 Int
// 這樣也會編譯錯誤
queries.insert(
id = project.id,
name = project.name
// 缺少必要參數,編譯器會提醒
)
// 編譯器強制正確性
queries.insert(
id = project.id,
name = project.name,
description = project.description,
path = project.path,
status = project.status.name,
tags = project.tags.toJson(),
createdAt = project.createdAt.toEpochMilliseconds(),
updatedAt = project.updatedAt.toEpochMilliseconds()
)
SQLDelight 自動生成對應的 data class:
// 自動生成(你不用寫)
data class DbProject(
val id: String,
val name: String,
val description: String?,
val path: String,
val status: String,
val tags: String?,
val createdAt: Long,
val updatedAt: Long
)
// UI 層
@Composable
fun ProjectListScreen(repository: ProjectRepository) {
// 自動響應資料庫變化
val projects by repository.observeAllProjects()
.collectAsState(initial = emptyList())
LazyColumn {
items(projects) { project ->
ProjectCard(project)
}
}
}
// 當任何地方更新資料
repository.createProject(newProject)
// UI 自動更新!不需要手動 refresh
// 複雜操作的原子性保證
suspend fun moveProject(
projectId: String,
newPath: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
queries.transaction {
// 1. 更新專案路徑
val project = queries.selectById(projectId).executeAsOne()
queries.update(
id = projectId,
path = newPath,
updatedAt = Clock.System.now().toEpochMilliseconds(),
// ... 其他欄位
)
// 2. 記錄移動歷史
queries.insertHistory(
projectId = projectId,
action = "MOVED",
details = "From ${project.path} to $newPath",
timestamp = Clock.System.now().toEpochMilliseconds()
)
// 3. 更新快取
updateCache(projectId, newPath)
// 全部成功或全部失敗
}
Result.success(Unit)
} catch (e: Exception) {
// 自動 rollback
Result.failure(e)
}
}
// 在 build.gradle.kts 設定
sqldelight {
databases {
create("GrimoDatabase") {
packageName.set("dev.grimo.db")
}
}
}
// 自訂 Adapter
class InstantAdapter : ColumnAdapter<Instant, Long> {
override fun decode(databaseValue: Long): Instant =
Instant.fromEpochMilliseconds(databaseValue)
override fun encode(value: Instant): Long =
value.toEpochMilliseconds()
}
// 使用 Adapter
val database = GrimoDatabase(
driver = driver,
ProjectAdapter = Project.Adapter(
createdAtAdapter = InstantAdapter(),
updatedAtAdapter = InstantAdapter()
)
)
-- 好的命名
selectAll: -- 取得全部
selectById: -- 依 ID 查詢
selectByStatus: -- 依狀態查詢
countByStatus: -- 統計
insertOrUpdate: -- 插入或更新
deleteById: -- 刪除
-- 不好的命名
getProjects: -- 不明確
query1: -- 無意義
findStuff: -- 太模糊
// 每次都執行查詢(不好)
fun getProjects(): List<Project> {
return queries.selectAll().executeAsList()
}
// 返回 Query,讓呼叫者決定(好)
fun getProjectsQuery(): Query<Project> {
return queries.selectAll()
}
// 呼叫者可以:
val list = repo.getProjectsQuery().executeAsList()
val flow = repo.getProjectsQuery().asFlow()
val single = repo.getProjectsQuery().executeAsOne()
// SQLDelight 完美處理 Kotlin 的 nullable
data class Project(
val id: String,
val name: String,
val description: String?, // 可為 null
val tags: List<String>? // 可為 null
)
// 查詢時自動處理
val project = queries.selectById(id).executeAsOneOrNull()
project?.description?.let {
// 安全處理
}
// 儲存複雜資料結構
@Serializable
data class ProjectTags(val tags: List<String>)
// 轉換
fun List<String>.toJson(): String =
Json.encodeToString(ProjectTags(this))
fun String.fromJson(): List<String> =
Json.decodeFromString<ProjectTags>(this).tags
// 使用
queries.insert(
// ...
tags = project.tags.toJson()
)
-- 經常查詢的欄位要建索引
CREATE INDEX idx_project_status ON Project(status);
CREATE INDEX idx_project_updated ON Project(updatedAt DESC);
-- 複合索引
CREATE INDEX idx_project_status_updated
ON Project(status, updatedAt DESC);
selectPaged:
SELECT * FROM Project
ORDER BY updatedAt DESC
LIMIT :limit OFFSET :offset;
suspend fun getProjectsPaged(
page: Int,
pageSize: Int
): List<Project> = withContext(Dispatchers.IO) {
queries.selectPaged(
limit = pageSize.toLong(),
offset = ((page - 1) * pageSize).toLong()
).executeAsList().map { it.toDomain() }
}
suspend fun insertProjects(projects: List<Project>) {
queries.transaction {
projects.forEach { project ->
queries.insert(
// ... 參數
)
}
}
// 一次 transaction,效能更好
}
由於我只有 Desktop 開發經驗,跨平台的實作是請 AI 協助設計的:
我:SQLDelight 如何支援不同平台?
AI:使用 expect/actual 機制,讓我展示各平台的實作...
// desktopMain
actual fun createDriver(): SqlDriver {
return JdbcSqliteDriver("jdbc:sqlite:grimo.db")
}
// iosMain
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(
GrimoDatabase.Schema,
"grimo.db"
)
}
// androidMain
actual fun createDriver(context: Context): SqlDriver {
return AndroidSqliteDriver(
GrimoDatabase.Schema,
context,
"grimo.db"
)
}
AI 的解釋很清楚。雖然我現在只做 Desktop,但這樣設計後,未來要支援 iOS/Android 只需要實作對應的 driver,其他程式碼完全不用改。
這就是 KMP 的魅力,寫一次,到處用。
一開始我很抗拒直接寫 SQL。都 2025 年了,還要寫 SQL?
但 AI 的一句話點醒我。SQL 本身就是最好的 DSL(領域特定語言),為什麼要用另一個 DSL 去包裝它?
使用後才發現幾個重點。
SQL 本身就是 DSL,為什麼要用別的 DSL 去包裝 SQL?編譯時驗證比 ORM 更安全。效能透明,你寫什麼就執行什麼。跨平台完美,一套 SQL,多平台使用。
Room 只支援 Android,SQLDelight 支援 KMP 全平台。Room 是執行時驗證,SQLDelight 是編譯時驗證。Room 用 Annotation 生成程式碼,SQLDelight 用 SQL 檔案。Room 的學習曲線較低但受限於 API,SQLDelight 需要懂 SQL 但有完全控制權。效能方面,SQLDelight 更好一些。
類型安全不是限制,而是解放。
當編譯器幫你檢查 SQL 語法、參數類型、返回值類型、NULL 安全,你可以專注在業務邏輯,而不是 debug 執行時錯誤。
SQLDelight 讓我重新認識了資料存取。
SQL 就是最好的查詢語言,不需要額外的抽象。類型系統是最好的保護,編譯時就抓出錯誤。簡單就是可維護,看到 SQL 就知道在做什麼。
別怕 SQL,它比你想的簡單。相信編譯器,類型安全是你的朋友。保持簡單,不要過度抽象。
下次當你需要資料庫時,試試 SQLDelight。
你會發現,原來資料存取可以這麼優雅。
「最好的 ORM 就是沒有 ORM。」
關於作者:Sam,一人公司創辦人。正在打造 Grimo,一個智能任務管理和分配平台。
專案連結:GitHub - grimostudio