iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
生成式 AI

30 天一人公司的 AI 開發實戰系列 第 13

Day 13: 開發者的資料庫魔法:類型安全的 SQLDelight 存取之道

  • 分享至 

  • xImage
  •  

昨天解決了 Compose Desktop 的除錯問題,今天要來處理資料層。

為什麼選擇 SQLDelight?

當你在做 KMP 專案時,資料庫選擇是個大問題。

於是我請 AI 研究 KMP 的資料庫方案:

我:KMP 專案要用什麼資料庫?需要支援 Desktop 和未來的 iOS
AI:推薦 SQLDelight,它是 Square(Cash App 開發商)開發的...
我:直接寫 SQL?這不是倒退嗎?
AI:讓我展示 SQLDelight 的魔法...

在 AI 的引導下,我才發現 SQLDelight 是 SQL 與類型系統的完美結合。

SQLDelight 的魔法

傳統 ORM vs SQLDelight

傳統 ORM 的問題

// Room/JPA 風格
@Query("SELECT * FROM project WHERE status = :status")
fun getProjectsByStatus(status: String): List<Project>

// 問題:
// 1. status 可能拼錯("active" vs "activ")
// 2. 執行時才發現 SQL 錯誤
// 3. 欄位改名要手動更新所有查詢

SQLDelight 的解法

-- Project.sq
selectByStatus:
SELECT * FROM Project
WHERE status = ?;

編譯後自動生成:

// 自動生成的 Kotlin 程式碼
fun selectByStatus(status: String): Query<Project> {
    // 類型安全、編譯時檢查
}

關鍵差異在於 SQL 在編譯時驗證,不是執行時。

實戰建立專案管理系統

定義 Schema

-- 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 ?;

Repository 實作

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)
    )

SQLDelight 的殺手級特性

類型安全的魔法

// 這樣寫會編譯錯誤
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()
)

自動生成的 Data Class

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
)

Flow 支援響應式資料

// 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:           -- 太模糊

使用 Query 而非 Execute

// 每次都執行查詢(不好)
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()

處理 NULL 值

// 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 { 
    // 安全處理
}

JSON 支援

// 儲存複雜資料結構
@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,效能更好
}

AI 協助的跨平台設計

由於我只有 Desktop 開發經驗,跨平台的實作是請 AI 協助設計的:

我:SQLDelight 如何支援不同平台?
AI:使用 expect/actual 機制,讓我展示各平台的實作...

Desktop 特定實作(我實際使用的)

// desktopMain
actual fun createDriver(): SqlDriver {
    return JdbcSqliteDriver("jdbc:sqlite:grimo.db")
}

iOS 特定實作(AI 設計,未來使用)

// iosMain
actual fun createDriver(): SqlDriver {
    return NativeSqliteDriver(
        GrimoDatabase.Schema,
        "grimo.db"
    )
}

Android 特定實作(AI 設計,預留擴充)

// androidMain
actual fun createDriver(context: Context): SqlDriver {
    return AndroidSqliteDriver(
        GrimoDatabase.Schema,
        context,
        "grimo.db"
    )
}

AI 的解釋很清楚。雖然我現在只做 Desktop,但這樣設計後,未來要支援 iOS/Android 只需要實作對應的 driver,其他程式碼完全不用改。

這就是 KMP 的魅力,寫一次,到處用。

實戰心得:SQL 的文藝復興

從抗拒到愛上

一開始我很抗拒直接寫 SQL。都 2025 年了,還要寫 SQL?

但 AI 的一句話點醒我。SQL 本身就是最好的 DSL(領域特定語言),為什麼要用另一個 DSL 去包裝它?

使用後才發現幾個重點。

SQL 本身就是 DSL,為什麼要用別的 DSL 去包裝 SQL?編譯時驗證比 ORM 更安全。效能透明,你寫什麼就執行什麼。跨平台完美,一套 SQL,多平台使用。

與 Room 的比較

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


上一篇
Day 12: 開發者的困擾:Compose Desktop 不好除錯
下一篇
Day 14: 創辦人週報:重新學習一套開發生態框架讓開發如此順利
系列文
30 天一人公司的 AI 開發實戰14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言