ORM, @property и отладка Celery:

«Почему в .only() явно существующее поле отсутствует?»



При проектировании систем, где безопасность критична, часто сохраняют значения API‑ключей или секретов в модели в зашифрованном виде, а при доступе через @property раскрывают только дешифрованные значения.

Например, типичная структура выглядит так.

  • _api_key → реальный столбец БД (зашифрованное значение)
  • 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 не является настоящим полем.

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 ничего не знает

Итого:

Что мы используем в коде Что знает ORM
api_key (property) _api_key

Поэтому запросы вида:

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

вызывают ошибку, потому что ORM ищет поле api_key, которого нет.

Правильные варианты:

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

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)

Здесь FieldDoesNotExist выбрасывается, но Celery перехватывает исключение как «временную ошибку» и автоматически повторяет задачу. В итоге:

  • Задача постоянно переходит в режим retry
  • Внешний API никогда не получает запросов
  • Логи могут показать «сеть не отвечает», хотя проблема в коде

Отладка становится сложной, потому что исключение «прячется» внутри механизма повторных попыток.


4. Решение: всегда использовать реальные имена полей

Ключевой принцип: свойства (@property) используют только в коде, а в запросах ORM всегда передаём реальные имена полей.

# Неправильно
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__isnull=False)

Для большей безопасности можно в проекте закрепить правило:

  • Поля, начинающиеся с _, используются только в ORM
  • Свойства, открытые для внешнего доступа, никогда не упоминаются в запросах
  • При ревью проверять, что в .values(), .only(), .filter() нет имён свойств

5. Дополнительный совет: использовать кастомное поле

В больших проектах вместо @property можно создать собственное поле модели, которое автоматически шифрует/дешифрует данные.

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)

Теперь api_key является настоящим полем ORM, но в коде выглядит как обычное строковое поле.

Преимущества:

  • ORM понимает api_key
  • Можно использовать only("api_key"), values("api_key"), filter(api_key__isnull=False) без ошибок

Недостатки:

  • Нужно реализовать и протестировать кастомное поле
  • Миграции и совместимость

Выбор зависит от масштаба и команды.


6. Выводы

  • ORM понимает только имена полей, а не свойства
  • В Celery ошибки FieldDoesNotExist могут маскироваться как сетевые проблемы
  • Всегда используйте реальные имена полей в запросах
  • Рассмотрите кастомные поля для упрощения работы

Эти простые правила помогут избежать потери времени на отладку и сделают ваш код более надёжным.

image