症狀:資料明明在,前端就是拿不到
一個 CMS 系統的文件列印預覽,每份文件 header 都固定顯示「上傳者:—」。DB 裡明明有資料,API 回 HTTP 200 OK,回應裡的其他欄位(日期、文件類型、附件)都對——唯獨 uploadedBy 是 null。
沒錯誤、沒警告,伺服器 log 也乾淨。前端碰到 uploadedBy?.realName || '-' 就乖乖畫 dash。使用者以為這份文件沒指派擁有者,作者本人以為畫面壞了。大家都錯,但系統看起來好好的。
這是最難纏的 bug——靜默失敗。
先看 ORM 層在整個架構哪裡
要理解為什麼這類 bug 發生、也為什麼解法是「繞過 ORM」,先把架構分層看清楚:
為什麼 bug 只會發生在 ORM 層?
上面的業務邏輯只負責說「給我文件,順便帶上傳者」——它不知道要怎麼 JOIN。下面的 DB 只回應被問到的 SQL——你沒 SELECT 的欄位它不會主動給你。只有 ORM 層有權力決定「要翻譯成什麼 SQL」,也只有它遇到不會處理的組合時,會選擇「悄悄回你 null」而不是「拋錯給你看」。
這就是為什麼靜默失敗只會出現在這一層。
用 curl 逼它露餡
把 query 簡化到只剩 filter + populate 兩件事,一個一個比對:
# A: 用 id filter → 關聯欄位變 null ❌
curl "$URL?filters[owner][id][\$eq]=3807&populate[uploadedBy]=true"
# { "uploadedBy": null }
# B: 用 documentId filter → 關聯正常回來 ✅
curl "$URL?filters[owner][documentId][\$eq]=abc123&populate[uploadedBy]=true"
# { "uploadedBy": { "id": 85, "realName": "J. Chen" } }
同樣的 populate、同樣的文件集合,差別只在「怎麼辨認 owner」。這種事應該是 ORM 內部細節,對使用者不該有差。但就是有。
這不是 Strapi 獨有:跨 ORM 的通病
寫到一半你可能以為這是 Strapi v5 特例。事實上,「關聯 filter 和 eager loading 放在一起時靜默壞掉」是 ORM 世界的共通坑:
| ORM | 關鍵字 | 典型陷阱 |
|---|---|---|
| Strapi v5 | populate | filters[rel][id] + populate[rel] 讓關聯回 null |
| Prisma | include / select | select 漏某欄位、或 include 與 select 互用,關聯被剝掉 |
| TypeORM | relations | QueryBuilder 的 where + relations 混用,某些關聯不載入 |
| Mongoose | .populate() | 後面接 .lean() 某些欄位就不被 populate |
| SQLAlchemy | joinedload | 多層 joinedload + filter 產生笛卡兒積或被自動 unload |
| Django | prefetch_related | 配合 Prefetch(queryset=...) 寫錯 queryset 就 silent 失敗 |
共通 pattern:ORM 在把 API query 翻成 SQL 的那一刻,遇到它內部認為「不合理」或「效能危險」的組合時,往往選擇「不幫你 JOIN」而非「拋錯」。結果你拿到半套資料,卻以為拿到全套。
診斷手法:三招通用
不管哪家 ORM,靜默失敗卡住時都可以試這三招:
1. 打開 SQL log,看它實際做了什麼
Strapi 可設 DATABASE_DEBUG=true、Prisma 的 log: ['query']、TypeORM 的 logging: true。多半會發現「它根本沒 JOIN 你要的那張表」——答案就在這。
2. 對比差異 query
別一次改很多變數。固定 populate,只動 filter;或反之。差異出現那一刻就是線索。
3. 直接查 DB
繞過 ORM,用 psql / mongo shell 驗資料是不是真的存在。如果 DB 有、API 沒——問題 100% 在 ORM 層,不用再懷疑使用者是不是忘記設欄位。
通用解法:關鍵路徑「降一層」
當 ORM 變成黑盒子,最務實的做法是降到下一層。從上面那張架構圖看,就是從 ORM 層降到 Query Builder 層。
以 Strapi 為例,自訂 controller 用 knex(Strapi 內建)明確 JOIN link table:
// 不走 ORM populate,降一層用 Query Builder 自己 JOIN
const uploaders = await knex('reports_uploaded_by_lnk')
.leftJoin('up_users', 'up_users.id', 'reports_uploaded_by_lnk.user_id')
.whereIn('reports_uploaded_by_lnk.report_id', reportIds)
.select('up_users.real_name', 'up_users.title');
其他 ORM 都有類似 escape hatch,讓你跳到下一層:
- Prisma →
prisma.$queryRaw或 raw SQL tagged template - TypeORM →
getManager().query(...)或QueryRunner - Mongoose →
Model.aggregate([...])手寫 pipeline - SQLAlchemy →
session.execute(text('SELECT ...'))
寫的時候多打幾行字,換來每一筆 JOIN 都在你掌控之中。 在關鍵路徑(影響業務正確性的地方),這是值得的交易。
反思:靜默降級是糟糕的預設
這個 bug 最讓人上火的不是它難修(實際修只要 20 行 knex),而是它應該在幾分鐘內被 server log 抓到,結果花了半天。
好的框架應該在「我不確定能不能做到」時明確拋錯,而不是偷偷回 null。靜默降級會造成兩個後遺症:
- 前端把「沒帶回來」當「沒設定」,業務邏輯跟著壞。
- 監控工具抓不到,只能靠使用者抱怨「欸怎麼變空白」才發現。
下次設計 API 或封裝 ORM 時,寧可拋一個 RelationLoadFailedError,也不要靜默回 null——讓錯誤有 trace,讓監控能叫。 這是框架設計者的責任,也是使用框架的人在看到 null 時要多想一秒的提醒。
想看這個 bug 在 Strapi v5 上的具體 reproduction steps 和自訂 controller 完整實作,請看英文版:
