ORM、@property 與 Celery 調試:

“為什麼 .only() 報告欄位不存在?”



在設計安全性重要的系統時,常會將 API Key 或 Secret 以加密形式存於模型,並透過 @property 只暴露解密後的值

例如,結構大致如下:

  • _api_key → 真實資料庫欄位(存儲加密值)
  • api_key → 透過 @property 進行解密後回傳
  • api_key.setter → 在儲存時自動加密

這種模式既乾淨又安全,但在與 ORM(以 Django ORM 為例)結合時,會不小心落入一個陷阱。

“我在模型裡一直用 api_key,為什麼 ORM 只說欄位不存在?”

本文整理了這個陷阱的成因、為何調試特別困難,以及如何避免。


1. 問題情境:欄位存在卻被報告不存在

在使用加密欄位的模型時,某天執行以下 ORM 查詢:

Model.objects.only("api_key").first()

或:

Model.objects.values("api_key")

或:

Model.objects.filter(api_key__isnull=False)

結果總是相同:

FieldDoesNotExist: Model has no field named 'api_key'

開發者自然會想:

“欄位確實存在,我每天都用 obj.api_key。”

實際模型大致如下(簡化示例):

class Model(models.Model):
    _api_key = models.CharField(db_column="api_key", max_length=255, null=True)

    @property
    def api_key(self):
        if not self._api_key:
            return None
        return decrypt(self._api_key)

    @api_key.setter
    def api_key(self, value):
        self._api_key = encrypt(value)
  • 實例層:obj.api_key 正常工作
  • 儲存層:obj.api_key = "plain-text" 也正常

但在 ORM 層卻報告欄位不存在。這看似奇怪,實際上從 ORM 的角度來說並不奇怪。


2. 原因:ORM 只認識「欄位名稱」,不會把 @property 當作欄位



核心很簡單:

@property 製作的 api_key 不是實際欄位

Django ORM 所謂「欄位」是指 models.Field 的子類別,亦即 Model._meta.fields 裡的項目。

在上述例子中,ORM 只知道:

_api_key = models.CharField(db_column="api_key", max_length=255, null=True)

這裡:

  • 模型欄位名稱_api_key
  • 資料庫欄位名稱"api_key"
  • @property api_key → ORM 完全不知情

總結如下:

我們日常使用的名稱 (obj.api_key) ORM 了解的名稱 (Model._meta)
api_key (property) _api_key (field name)

因此,當寫:

Model.objects.only("api_key")
Model.objects.values("api_key")
Model.objects.filter(api_key__isnull=False)

ORM 會認為「沒有這個欄位」並拋出 FieldDoesNotExist

ORM 只會以「欄位名稱」為基準:

  • only("_api_key")
  • values("_api_key")
  • filter(_api_key__isnull=False)
  • only("api_key")
  • filter(api_key=...)

簡而言之:

Python 層使用的 property 名稱與 ORM 了解的欄位名稱混淆


3. 為什麼在 Celery 裡更危險?

這看似「稍微麻煩」的錯誤,實際上在 Celery 任務內部會變得更棘手。

假設程式碼如下:

@shared_task(bind=True, max_retries=3)
def send_something(self, model_id):
    try:
        obj = Model.objects.only("api_key").get(id=model_id)
        call_external_api(obj.api_key)
    except SomeRecoverableError as exc:
        raise self.retry(exc=exc)

真正的問題是:

  • only("api_key") 觸發 FieldDoesNotExist
  • 這個例外被某個上層捕捉,或被 Celery 的 retry 機制誤判為「暫時性錯誤」
  • Celery 會將任務重新排程,導致「看似網路問題」的錯誤持續重試

結果:

  • Celery 任務不斷重試
  • 外部 API 甚至根本沒有收到請求
  • 若不仔細查看日誌,容易誤以為是「外部伺服器無回應」

這種「靜默」的 bug 讓調試難度大幅提升。


4. 解決方案:在 ORM 中只使用「真實欄位名稱」

結論很簡單:

property 只在實例層使用,ORM 查詢時必須使用真實欄位名稱

正確寫法:

# 錯誤示例(FieldDoesNotExist)
obj = Model.objects.only("api_key").get(...)
# 正確示例
obj = Model.objects.only("_api_key").get(...)
real_key = obj.api_key  # 之後再用 property 取值

同理,過濾也要改成:

# 錯誤示例
Model.objects.filter(api_key__isnull=False)
Model.objects.filter(api_key="plain-text")
# 正確示例
Model.objects.filter(_api_key__isnull=False)
Model.objects.filter(_api_key=encrypt("plain-text"))

若想更安全,團隊可制定規則:

  • 「以 _ 開頭的欄位僅在 ORM 內使用」
  • 「對外暴露的 property 名稱不應在 ORM 查詢中使用」
  • 審查時檢查 .values("...").only("...").filter(...) 是否包含 property 名稱

5. 進階技巧:使用自訂欄位隱藏加密邏輯

在大型專案中,除了 @property,還可以考慮自訂模型欄位,例如 EncryptedCharField

class EncryptedCharField(models.CharField):
    def get_prep_value(self, value):
        if value is None:
            return None
        return encrypt(value)

    def from_db_value(self, value, expression, connection):
        if value is None:
            return None
        return decrypt(value)

class Model(models.Model):
    api_key = EncryptedCharField(max_length=255, db_column="api_key", null=True)

這樣:

  • ORM 仍認為 api_key 是真實欄位
  • 代碼中可直接使用 obj.api_key,看似平文
  • .only("api_key").values("api_key").filter(api_key__isnull=False) 都能正常工作

缺點:

  • 需要實作並測試自訂欄位
  • 可能影響遷移或相容性

根據專案規模與團隊情況,選擇「簡單的 @property + 規則」或「自訂欄位」即可。


6. 總結:易忽略但關鍵的注意點

  • ORM 只理解「欄位名稱」;@property 不算欄位。
  • 在 Celery 等非同步環境,錯誤可能被 retry 機制吞掉,變成「靜默」bug。
  • 只在 ORM 查詢中使用真實欄位名稱,property 只在實例層使用。
  • 若想更安全,可考慮自訂欄位或制定團隊規則。

這些注意點能幫你避免因加密欄位而導致的重複失敗與調試困難。

image