症狀:資料明明在,前端就是拿不到

一個 CMS 系統的文件列印預覽,每份文件 header 都固定顯示「上傳者:—」。DB 裡明明有資料,API 回 HTTP 200 OK,回應裡的其他欄位(日期、文件類型、附件)都對——唯獨 uploadedBynull

沒錯誤、沒警告,伺服器 log 也乾淨。前端碰到 uploadedBy?.realName || '-' 就乖乖畫 dash。使用者以為這份文件沒指派擁有者,作者本人以為畫面壞了。大家都錯,但系統看起來好好的。

這是最難纏的 bug——靜默失敗

先看 ORM 層在整個架構哪裡

要理解為什麼這類 bug 發生、也為什麼解法是「繞過 ORM」,先把架構分層看清楚:

Mermaid Diagram

為什麼 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 v5populatefilters[rel][id] + populate[rel] 讓關聯回 null
Prismainclude / selectselect 漏某欄位、或 includeselect 互用,關聯被剝掉
TypeORMrelationsQueryBuilder 的 where + relations 混用,某些關聯不載入
Mongoose.populate()後面接 .lean() 某些欄位就不被 populate
SQLAlchemyjoinedload多層 joinedload + filter 產生笛卡兒積或被自動 unload
Djangoprefetch_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,讓你跳到下一層:

  • Prismaprisma.$queryRaw 或 raw SQL tagged template
  • TypeORMgetManager().query(...)QueryRunner
  • MongooseModel.aggregate([...]) 手寫 pipeline
  • SQLAlchemysession.execute(text('SELECT ...'))

寫的時候多打幾行字,換來每一筆 JOIN 都在你掌控之中。 在關鍵路徑(影響業務正確性的地方),這是值得的交易。

反思:靜默降級是糟糕的預設

這個 bug 最讓人上火的不是它難修(實際修只要 20 行 knex),而是它應該在幾分鐘內被 server log 抓到,結果花了半天。

好的框架應該在「我不確定能不能做到」時明確拋錯,而不是偷偷回 null。靜默降級會造成兩個後遺症:

  1. 前端把「沒帶回來」當「沒設定」,業務邏輯跟著壞。
  2. 監控工具抓不到,只能靠使用者抱怨「欸怎麼變空白」才發現。

下次設計 API 或封裝 ORM 時,寧可拋一個 RelationLoadFailedError,也不要靜默回 null——讓錯誤有 trace,讓監控能叫。 這是框架設計者的責任,也是使用框架的人在看到 null 時要多想一秒的提醒。


想看這個 bug 在 Strapi v5 上的具體 reproduction steps 和自訂 controller 完整實作,請看英文版:

👉 Strapi v5’s Silent populate Failure with Relation Filters