ORM, @property et débogage Celery

“Pourquoi .only() dit qu’un champ qui existe n’existe pas ?”



Lors de la conception d’un système où la sécurité est primordiale, on stocke souvent les clés API ou secrets chiffrés dans le modèle, puis on expose uniquement la valeur déchiffrée via un @property.

Par exemple, la structure est généralement la suivante :

  • _api_key → colonne réelle de la base de données (valeur chiffrée)
  • api_key@property qui déchiffre et renvoie la valeur
  • api_key.setter → chiffre automatiquement la valeur lors de la sauvegarde

Cette approche rend le code propre et la sécurité naturelle. Mais lorsqu’on l’utilise avec l’ORM (Django ORM), un piège simple se glisse.

“Le champ api_key que j’utilise dans le modèle n’existe pas dans l’ORM ?”

Cet article explique comment ce piège se produit, pourquoi le débogage devient si difficile, et comment l’éviter.


1. Situation problématique : le champ existe, mais l’ORM dit qu’il n’existe pas

En exploitant un modèle avec un champ chiffré encapsulé dans une @property, j’ai exécuté le code suivant :

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

ou encore :

Model.objects.values("api_key")

ou encore :

Model.objects.filter(api_key__isnull=False)

Le résultat était toujours le même :

FieldDoesNotExist: Model has no field named 'api_key'

En tant que développeur, je pensais :

“Il existe. Je l’utilise quotidiennement avec obj.api_key.”

Le modèle était en fait :

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)
  • L’instance fonctionne avec obj.api_key
  • La sauvegarde fonctionne avec obj.api_key = "plain-text"

Mais l’ORM refuse de reconnaître le champ au niveau de la requête.


2. Pourquoi l’ORM ne voit pas la @property



La raison est simple :

La @property n’est pas un vrai champ.

Dans Django, un champ est une sous-classe de models.Field. L’ORM ne connaît que les éléments de Model._meta.fields.

Dans l’exemple ci‑dessus, l’ORM ne sait que :

_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
  • Nom du champ : _api_key
  • Nom de la colonne : "api_key"
  • @property api_key : l’ORM ne le connaît pas.

En résumé :

Nom que j’utilise (obj.api_key) Nom que l’ORM connaît (Model._meta)
api_key (propriété) _api_key (champ)

Donc, lorsqu’on écrit :

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

l’ORM considère qu’il n’existe pas de champ api_key.

L’ORM ne traite que les noms de champs.

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

En bref : c’est un mélange entre le nom de la propriété Python et le nom du champ réel.


3. Pourquoi c’est encore pire dans Celery

Ce problème devient critique lorsqu’il se produit dans une tâche Celery.

Prenons un exemple typique :

@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)

Le vrai problème :

  • only("api_key") lève FieldDoesNotExist
  • Cette exception est capturée par un bloc except générique ou par la logique de retry de Celery
  • Celery interprète l’erreur comme un problème temporaire et la relance

Résultat : la tâche se retrouve en boucle de retry, aucune requête vers l’API externe n’est envoyée, et le problème reste caché derrière le mécanisme de retry.

Le débogage devient alors beaucoup plus difficile, car l’erreur ne se produit pas immédiatement mais est absorbée par Celery.


4. Solution : toujours passer le nom du champ réel à l’ORM

La règle est simple :

Utilisez uniquement le nom du champ réel dans les requêtes ORM.

# Mauvais : FieldDoesNotExist
obj = Model.objects.only("api_key").get(...)
# Correct :
obj = Model.objects.only("_api_key").get(...)
real_key = obj.api_key  # accès via la propriété

Même pour les filtres :

# Mauvais :
Model.objects.filter(api_key__isnull=False)
Model.objects.filter(api_key="plain-text")
# Correct :
Model.objects.filter(_api_key__isnull=False)
Model.objects.filter(_api_key=encrypt("plain-text"))

Pour éviter ce piège, il est judicieux d’instaurer une règle d’équipe :

  • Les champs commençant par _ sont réservés à l’ORM.
  • Les propriétés exposées publiquement ne doivent jamais être utilisées dans les requêtes ORM.
  • Revues de code : vérifier que .values("..."), .only("..."), .filter(...) n’utilisent pas de noms de propriétés.

5. Astuce supplémentaire : créer un champ personnalisé

Dans les projets de grande envergure, on peut préférer créer un champ personnalisé au lieu d’une @property.

Par exemple :

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)

Avec ce champ :

  • L’ORM reconnaît api_key comme un vrai champ.
  • Vous pouvez utiliser only("api_key"), values("api_key"), filter(api_key__isnull=False) sans problème.

Inconvénients :

  • Implémentation et tests supplémentaires.
  • Gestion des migrations et compatibilité.

Choisissez la solution qui convient le mieux à votre équipe.


6. Leçon apprise : un détail qui peut coûter cher

Cette expérience montre que même un détail apparemment mineur peut devenir un gouffre de bugs, surtout dans un environnement asynchrone comme Celery.

Points clés :

  • Ne confondez pas le nom de la propriété Python avec le nom du champ réel.
  • Vérifiez toujours vos requêtes ORM pour vous assurer qu’elles utilisent le bon nom.
  • En cas d’erreur FieldDoesNotExist, pensez à la différence entre le champ et la propriété.
  • Dans Celery, les exceptions peuvent être masquées par le mécanisme de retry, rendant le débogage plus ardu.

7. Résumé

1) L’ORM ne comprend que les noms de champs réels.

  • Model._meta.fields définit ce que l’ORM connaît.
  • Les @property ne sont pas incluses.

2) Ne jamais utiliser une propriété dans une requête ORM.

  • Utilisez toujours le nom du champ réel (_api_key).

3) Soyez vigilant avec les champs chiffrés.

  • Les noms proches peuvent prêter à confusion.
  • Revues de code sont essentielles.

4) En Celery, les bugs peuvent se masquer derrière le retry.

  • Vérifiez les logs et la gestion des exceptions.

En adoptant ces bonnes pratiques, vous éviterez de perdre des journées à traquer des bugs qui se cachent derrière des erreurs de champ.

image