ORM、@property、そしてCeleryデバッグ:

「なぜ .only() では確かに存在する値がないと言われるのか?」



セキュリティが重要なシステムを設計するとき、API KeyやSecretをモデルに暗号化された形で保存し、アクセスするときは @property を通じて復号化された値だけを公開するパターンをよく使います。

例えば、こんな構造です。

  • _api_key → 実際のDBカラム(暗号化された値を保存)
  • api_key → 復号化して返す @property
  • api_key.setter → 値を保存するときに自動で暗号化

この構造はコードもきれいで、セキュリティ的にも自然なパターンです。 しかし、ORM(Django ORMを想定)と一緒に使うと、非常に簡単に落とし穴にハマります。

「確かにモデルでうまく使っていた api_key なのに、ORMクエリでだけ『フィールドがない』と言われる?」

この記事は、その落とし穴がどのように発生し、なぜデバッグが特に難しかったのか、そしてどうすれば避けられるかをまとめた記録です。


1. 問題状況:『確かに値があるのにORMがないと言う?』

暗号化フィールドを @property で包んだモデルを運用していたとき、ある日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 で作った api_key本当のフィールドではない

Django ORMが「フィールド」と呼ぶのは、モデルの models.Field サブクラスです。つまり、Model._meta.fields に入っているリスト全てです。

上記例でORMが知っている情報はこの程度です。

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

ここで:

  • モデルフィールド名(field name)_api_key
  • DBカラム名(column name)"api_key"
  • @property api_key → ORMは何も知らない

整理するとこうなります。

私たちが毎日使う名前(obj.api_key) ORMが知っている名前(Model._meta)
api_key(プロパティ) _api_key(フィールド名)

したがってORMで:

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

こう書くと、ORM側では「そのようなフィールドはない」と見なします。ORMはプロパティをフィールドとして扱わないためです。

ORMが理解するのは常に「フィールド名」に基づきます。

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

つまり、

Pythonレベルで使うプロパティ名とORMが理解するフィールド名を混同して生じた問題でした。


3. Celeryに入るとなぜさらに致命的になるのか?

ここまでは「ちょっと面倒だけどすぐに解決できる」エラーのように見えます。問題は、このコードがDjango viewではなく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 例外が発生
  • この例外が特定の例外カテゴリに含まれたり、上位でcatchされて
  • Celeryがこれを「一時的なエラー」と誤認し self.retry() で再試行

つまり、実際にはコードバグなのに、外側では「継続的に失敗して再試行されるネットワーク問題」のように見える状態です。

症状はこうです:

  • Celeryタスクは継続的にretry中
  • 外部APIサーバーにはリクエストがまったく送られない
  • ログを詳しく見ないと、「外部サーバーが応答しないのか?」と疑いが走りやすい

デバッグ難度が不当に爆発します。例外がすぐに上に上がって爆発すべきなのに、Celeryのretryメカニズムに隠れてしまうと「静かなバグ」になります。


4. 解決策:ORMには「常にフィールド名」だけ渡す

結論はシンプルです。

プロパティはインスタンスでのみ使い、ORMには常に「本当のフィールド名」を使用する。

つまり、次のように書くべきです。

# 誤った例(FieldDoesNotExist)
obj = Model.objects.only("api_key").get(...)
# 正しい例
obj = Model.objects.only("_api_key").get(...)
real_key = obj.api_key       # ここからはプロパティでアクセス

フィルタリングも同様です。

# 誤った例
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を呼び出さないようにチーム/プロジェクトレベルでルールを定めるのも手です。

例:

  • _で始まるフィールドはORMでのみ使用」
  • 「外部に公開するプロパティ名はORMクエリで絶対に使わない」
  • レビュー時に .values("..."), .only("..."), .filter(...) にプロパティ名が入っていないかチェック

同様に。


5. 追加ヒント:完全にカスタムフィールドで隠す方法もある

規模が大きいプロジェクトでは、@property の代わりにカスタムモデルフィールドを作る方法も検討できます。

例:EncryptedCharField を作って、

  • to_python() / get_prep_value() で暗号化/復号化を処理
  • フィールド自体はORMが認識する「本当のフィールド」として残し、
  • 値は常にプレーンテキストのように扱える

概念例は次の通りです。

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 + _api_key フィールドを使い、ルールを守る」 vs 「完全にカスタムフィールドで抽象化する」 のどちらかを選択できます。


6. 学んだこと:単純に見えて、簡単に見落としやすい

今回の経験から得た教訓は思ったより単純です。

しかし実際のコードでは本当に簡単に見落とされます。

  • 普段は obj.api_key だけを見て 「これが本当のフィールドではない」という感覚が徐々に薄れます。
  • 長く書いたORMクエリの一行に .only("api_key") などのミスが入り込むのに最適です。
  • 特にCeleryのような非同期環境では 例外がすぐに目に見える形で爆発せず、静かにretryに吸収されるため デバッグが何倍も難しくなります。

結局、しばらくはこう誤解します。

「外部APIが遅い? ネットワーク問題か?」 → 実は ORM が FieldDoesNotExist を投げていた…

この時間を減らすには、以下の4つだけを覚えておけば十分です。


7. まとめ

1) ORMは「モデルフィールド名」だけを理解する

  • Model._meta.fields に基づいて動作します。
  • @property は含まれません。

2) @property は ORM クエリで使えない

  • .only("api_key"), .filter(api_key=...) はすべて誤用。
  • ORM には _api_key のように実際のフィールド名だけを渡す。

3) 暗号化/復号化パターンを使うほど注意が必要

  • _api_key / api_key のように名前が似ているとさらに混乱しやすい。
  • レビュー段階でこの点を必ずチェック。

4) Celery 内部ではこのバグが特に危険

  • 例外が retry ロジックに混ざり、静かに隠れます。
  • ネットワーク障害のように偽装されることも。
  • ログ + 例外ハンドリング を一緒に確認することが重要。

セキュリティのためにフィールドを隠す設計は良い方向です。 しかしその構造が ORM、非同期ワーカー(Celery)と共に動くと どんな落とし穴を作るかを一度は確認しておけば、同様の問題で一日を丸ごと失うことは減らせます。 image