migration 跟測試走的是兩條不同的路,所以「tests 全綠」不能證明 migration 是安全的。
昨天講的是架構規則怎麼被檢查,今天要講 Migration 比較麻煩。它是整個專案裡幾乎沒有任何既有驗證流程會直接檢查的產出:
| 誰會看 | 會看 migration 嗎 |
|---|---|
| 測試 | ❌ 測試走 create_all 從 metadata 建表,永遠不經過 alembic |
| typecheck | ❌ 它是 SQL,不是型別 |
| code review | ⚠️ 它長得像機器產的,通常被捲過去 |
| 使用者 | ❌ 錯了要到部署那天才知道 |
而它偏偏又是唯一一個會直接改動資料庫 schema 的產出,所以今天我決定故意拿一支 migration 做實驗。
這個專案目前的 schema 和程式碼理論上是同步的。
所以我直接對目前的狀態跑一次,照理說應該產出一支空 migration。
$ uv run alembic revision --autogenerate -m "autogenerate probe"
INFO [alembic.autogenerate.compare.check_constraints]
Detected removed check constraint 'version_status' on table 'note_versions'
INFO [alembic.autogenerate.compare.check_constraints]
Detected removed check constraint 'decision_action' on table 'version_decisions'
結果不是空的。
Alembic 偵測到兩個 CHECK constraint 被移除,產出的 migration 是:
def upgrade() -> None:
op.drop_constraint(op.f('version_status'), 'note_versions', type_='check')
op.drop_constraint(op.f('decision_action'), 'version_decisions', type_='check')
先確認這兩個 constraint 是不是真的漏掉了:
① 資料庫裡真的有嗎: ['version_status', 'decision_action']
② metadata 裡有嗎: ['note_versions.version_status', 'version_decisions.decision_action']
這是假陽性。
原因最後追到 native_enum=False 產生的 CHECK constraint 在比對時無法正確對應。但這裡真正值得記住的不是原因,而是:
autogenerate 不只是「可能漏掉東西」,也可能把它看到的東西判斷錯。
接著我把這支 migration 放到乾淨資料庫實際跑一次。
結果:
原本的 schema
→ status 非法值:擋住
套用 autogenerate migration
→ status 非法值:可以寫進去
也就是那個 CHECK constraint 真的被拿掉了,但同一時間,原本的 45 條測試仍然全部通過。
測試
→ create_all()
→ Base.metadata
→ 建立 schema
正式部署
→ alembic upgrade head
→ 執行 migration
→ 建立 schema
兩條路根本不一樣。
一支 migration 可以拆掉一條業務規則,而整套測試完全不知道。
autogenerate比對的是Base.metadata與資料庫,不是在理解整個資料庫設計。
所以有些東西它根本看不到,有些東西則可能比對錯。
目前採到的坑:
| 盲區 | 情況 |
|---|---|
| trigger / function | 完全看不見——不會產生,也不會發現它不見了 |
帶 WHERE 的 partial index |
偵測不可靠,plan.md 早就記著「產完必須人工核對」 |
沒 import 進 models/__init__.py 的 model |
看不見(env.py 用的是 Base.metadata) |
native_enum=False 產生的 CHECK |
比對不出來,會誤判為「被移除」 |
| 資料(seed) | 它只看 schema |
這是我調整最多的地方。原本我看 migration 的方式是讀那段 SQL、確認它符合我要的改動——那是在檢查「它產得對不對」。
但這個實驗裡的那支 migration,SQL 完全正確:它確實會 drop 掉那兩個約束,語法沒問題,downgrade 也寫好了。它產得很對,只是它想做的事是錯的。
所以問題要換成:它有沒有看見? 產完之後,逐項對一次盲區清單——這份 migration 該碰的東西裡,有沒有哪一類是它看不見或會猜錯的?
| 檢查 | 這個專案抓到什麼 | |
|---|---|---|
| 1 | 它有沒有看見? 對照盲區清單逐項確認 | 它想 drop 掉兩個正在運作的約束 |
| 2 | downgrade 真的跑得動嗎? |
— |
| 3 | 在一個空資料庫上,從頭跑到 head 會成功嗎? | VARCHAR(32) 那個 |
第 3 個尤其重要。
因為開發資料庫通常都是一路升上來的,很少真的從:
base
↓
001
↓
002
↓
003
↓
head
完整跑一次。
但正式環境第一次部署,走的正是這條路。
目前這三件事還是手動的。
下一步才是把它放進 CI,讓 CI 自動建立空資料庫,執行 upgrade head,再驗證整套測試。
明天:資料庫的債清完了,明天要來講的是認證與權限:JWT + RBAC。