ORM、@property、そしてCeleryデバッグ:
「なぜ .only() では確かに存在する値がないと言われるのか?」
セキュリティが重要なシステムを設計するとき、API KeyやSecretをモデルに暗号化された形で保存し、アクセスするときは @property を通じて復号化された値だけを公開するパターンをよく使います。
例えば、こんな構造です。
_api_key→ 実際のDBカラム(暗号化された値を保存)api_key→ 復号化して返す@propertyapi_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)と共に動くと
どんな落とし穴を作るかを一度は確認しておけば、同様の問題で一日を丸ごと失うことは減らせます。

コメントはありません。