iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
生成式 AI

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

Day 26: 架構師的 DB 管理術:SQLDelight Migration 的優雅解法

  • 分享至 

  • xImage
  •  

前言:Migration 的困惑

準備加新功能時,我發現一個問題。

SQLDelight 的文件提到 migration,但語焉不詳。我的專案要怎麼處理資料庫版本更新?

看了看現有程式碼:

// 直接 create table,沒有版本控制
GrimoDatabase.Schema.create(driver)

這樣不行啊。用戶更新應用程式時,資料庫結構變了怎麼辦?

搜尋解決方案

請 ARS 研究

「請以 ARS 角色,研究 SQLDelight 2.0 最新的 migration 最佳實踐。」

ARS 的研究報告:

## 研究報告:SQLDelight Migration 方案

### 執行摘要
- SQLDelight 2.0 新增 deriveSchemaFromMigrations
- 使用 PRAGMA user_version 管理版本
- .sqm 檔案專門處理 migration

### 關鍵發現
1. **deriveSchemaFromMigrations** - Schema 從 .sqm 產生
2. **PRAGMA user_version** - SQLite 內建版本控制
3. **自動 transaction** - migration 自動包裝

### 業界實踐
- Cash App:100+ migrations,只用 30 行 helper
- Signal Desktop:用 PRAGMA user_version

「什麼是 .sqm?跟 .sq 有什麼差別?」我問。

請教 Claude Code

「Claude Code,根據 ARS 的研究,幫我理解 deriveSchemaFromMigrations 怎麼運作。」

Claude Code 解釋:「這是 SQLDelight 2.0 的新功能。設定後,資料庫 schema 完全從 migration 檔案產生,而不是從 .sq 檔案。」

「那版本控制呢?」我問。

「SQLite 有內建的 PRAGMA user_version,」Claude Code 說,「不需要額外的版本表。」

PRAGMA user_version?我從來沒用過。

實驗 PRAGMA user_version

測試版本控制

「怎麼用 PRAGMA user_version?」我自己試試看:

-- 查詢目前版本
PRAGMA user_version;
-- 結果:0

-- 設定版本
PRAGMA user_version = 1;

-- 再次查詢
PRAGMA user_version;
-- 結果:1

原來這麼簡單!SQLite 內建的功能。

設計 MigrationHelper

參考文件和測試後,我寫了簡單的 MigrationHelper:

object MigrationHelper {
    suspend fun migrateIfNeeded(driver: SqlDriver) {
        val currentVersion = getCurrentVersion(driver)
        val targetVersion = GrimoDatabase.Schema.version
        
        when {
            currentVersion == 0L -> {
                // 新資料庫,直接建立
                GrimoDatabase.Schema.create(driver).await()
            }
            currentVersion < targetVersion -> {
                // 需要升級
                GrimoDatabase.Schema.migrate(
                    driver, currentVersion, targetVersion
                ).await()
            }
        }
        
        setVersion(driver, targetVersion)
    }
    
    private fun getCurrentVersion(driver: SqlDriver): Long =
        driver.executeQuery(
            null, "PRAGMA user_version", 
            { cursor -> QueryResult.Value(cursor.getLong(0) ?: 0L) }, 
            0
        ).value
        
    private fun setVersion(driver: SqlDriver, version: Long) {
        driver.execute(null, "PRAGMA user_version = $version", 0)
    }
}

30 行左右,簡潔明瞭。

配置 build.gradle.kts

研究關鍵設定

看了文件後,我修改 build.gradle.kts:

sqldelight {
    databases {
        create("GrimoDatabase") {
            packageName.set("io.github.grimostudio.grimo.db")
            
            // 從 migration 檔案產生 schema
            deriveSchemaFromMigrations.set(true)
            
            // 其他設定
            generateAsync.set(true)        // 支援協程
            verifyMigrations.set(true)     // 編譯時驗證
        }
    }
}

「deriveSchemaFromMigrations 是關鍵,」我理解了,「設定後,不用在 .sq 檔案寫 CREATE TABLE。」

建立 Migration 檔案

專案結構變成:

sqldelight/
├── migrations/
│   ├── 1.sqm    # 初始 schema
│   └── 2.sqm    # 新增功能
└── io/.../grimo/
    └── Project.sq   # 只有查詢

第一個 migration (1.sqm):

-- 初始資料庫結構
CREATE TABLE Project (
    id TEXT NOT NULL PRIMARY KEY,
    name TEXT NOT NULL,
    createdAt INTEGER NOT NULL,
    updatedAt INTEGER NOT NULL
);

第二個 migration (2.sqm) 加入 EventStore:

-- 支援 CloudEvents 和 Event Sourcing
CREATE TABLE IF NOT EXISTS event_store (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    stream_id TEXT NOT NULL,
    cloud_event_id TEXT UNIQUE NOT NULL,
    -- ... 更多欄位
);

測試 Migration 流程

第一次執行

清空資料庫,重新執行:

資料庫版本: 0
執行 Schema.create()
設定版本為 2

成功!

模擬升級

把 user_version 改成 1,再執行:

資料庫版本: 1
執行 Schema.migrate(1, 2)
設定版本為 2

也成功了!

遇到的問題

Migration 檔案的坑

一開始我把 migration 檔案命名成 001.sqm、002.sqm。

結果編譯錯誤:

Expected migration file to be named <version>.sqm

「噢,要用數字當檔名,」我改成 1.sqm、2.sqm,問題解決。

檔案順序問題

「Claude Code,migration 檔案是依序執行嗎?」

「對,」Claude Code 回答,「從當前版本執行到目標版本。比如從版本 1 升級到版本 3,會依序執行 2.sqm 和 3.sqm。」

「那如果中間有錯誤呢?」

「SQLDelight 會自動包在 transaction 裡,失敗就回滾。」

原來如此,不用自己處理 transaction。

優化建議

Claude Code 的提醒

「有幾個最佳實踐,」Claude Code 建議:

  1. 每個 migration 做一件事 - 方便追蹤和除錯
  2. 向前相容 - 新增欄位要給預設值
  3. 不要修改舊的 migration - 已經執行過的不能改

我記下這些原則。

實際專案的 Migration

我的專案第二個 migration 比較複雜,加入 EventStore 支援 CloudEvents:

-- Migration 2: EventStore for CloudEvents and Event Sourcing
CREATE TABLE IF NOT EXISTS event_store (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    stream_id TEXT NOT NULL,
    stream_version INTEGER NOT NULL,
    cloud_event_id TEXT UNIQUE NOT NULL,
    cloud_event_source TEXT NOT NULL,
    cloud_event_type TEXT NOT NULL,
    -- ... 更多欄位
    
    UNIQUE(stream_id, stream_version),
    CHECK(stream_version > 0)
);

-- 建立索引提升查詢效能
CREATE INDEX IF NOT EXISTS idx_event_store_stream 
ON event_store(stream_id, stream_version);

這個 migration 有 87 行,但很清楚:建立事件存儲系統。

實戰心得

從複雜到簡單

原本想寫一個「完美」的 migration 系統,支援:

  • 版本追蹤
  • 執行歷史
  • Checksum 驗證
  • 回滾機制

結果發現 SQLDelight + PRAGMA user_version 就夠了。

學習曲線

剛開始不懂 deriveSchemaFromMigrations,查了文件、看了範例、問了 Claude Code,慢慢理解。

現在回頭看,其實概念很簡單:

  • .sqm 檔案定義資料庫變更
  • PRAGMA user_version 追蹤版本
  • SQLDelight 處理其他細節

框架的威力

SQLDelight 團隊已經想過這些問題,提供了優雅的解決方案。

我不需要重新發明輪子,專注在業務邏輯就好。

總結

今天研究了 SQLDelight 的 migration 機制,從一開始的困惑到最後理解。

關鍵發現:

  • PRAGMA user_version 是 SQLite 內建的版本控制
  • deriveSchemaFromMigrations 讓 schema 管理變簡單
  • 不需要自己寫複雜的 migration 管理器

Claude Code 在這個過程中扮演研究助手的角色,幫我理解概念、分析文件、提供最佳實踐建議。

最大的收穫是學會相信框架。SQLDelight 提供的功能已經很完善,不需要過度設計。

明天繼續探索其他架構優化的可能性。

今日金句

「最好的架構是看不見的架構。」

參考資源

關於作者:Sam,一人公司創辦人。正在打造 Grimo,智能任務管理平台。

專案連結GitHub - grimostudio


上一篇
Day 25: 架構師的相容性建議:KMP AppResult 跨平台錯誤處理實踐
下一篇
Day 27: 策略長的商業思考:開源與獲利模式的平衡
系列文
30 天一人公司的 AI 開發實戰27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言