TIL : les contraintes Django

Bonjour,

J'ai récemment travaillé sur des contraintes et je voulais écrire quelque chose dessus, pour m'en souvenir. J'espère que ça vous sera utile aussi.

Les contraintes de base de données sont vraiment utiles pour s'assurer que chaque ligne est valide avant de l'enregistrer. Il existe deux types de contraintes que l'on peut utiliser avant d'enregistrer une nouvelle ligne dans une table avec Django : CheckConstraint et UniqueConstraint.

J'utilise PostgreSQL, qui dispose d'outils puissants : on peut définir des contraintes de base de données en fonction de ce qui est implémenté par Django.

Les explications sont toujours meilleures avec un exemple, alors voici une contrainte pour éviter le chevauchement entre la date de début et la date de fin :

class Event(models.Model):
   start_at = models.DateField()
   end_at = models.DateField()

   class Meta:
      constraints = [
         models.CheckConstraint(
               check=models.Q(start_at__lt=end_at),
               name='start_at_before_end_at'
         ),
         models.UniqueConstraint(
               fields=['start_date', 'end_date'],
               name='unique_event_dates'
         ),
      ]

La CheckConstraint garantit que start_at est antérieur à end_at. La UniqueConstraint garantit qu'il n'y a pas deux événements avec la même start_date et la même end_date.

J'ai découvert que lorsqu'on crée une UniqueConstraint dans Django, les valeurs NULL sont considérées comme distinctes, ce que je ne voulais pas dans mon cas.

Pour reprendre mon exemple précédent, voici 2 enregistrements :

id start_date end_date
1 NULL 2019-12-31
2 NULL 2019-12-31

Les deux ont la même end_date et une start_date à NULL.

Ces deux enregistrements seront considérés comme uniques, car en SQL un NULL n'est pas égal à un autre NULL : les deux enregistrements sont donc considérés comme distincts.

On peut utiliser WITH DISTINCT NULL VALUES avec PostgreSQL 15, mais dans mon cas j'utilisais une version plus ancienne, évidemment, donc j'ai dû utiliser une autre solution.

Si vous voulez vous assurer que les valeurs NULL ne sont pas considérées comme distinctes dans la UniqueConstraint, vous pouvez utiliser à la place une fonction de hachage avec l'attribut unique=True.

Voici un exemple de fonction de hachage avec une valeur unique, basé sur le code précédent avec la classe Event. Un champ sha256_hash qui combine dans un hash tous les champs que je veux :

import hashlib
import json

class Event(models.Model):
   start_at = models.DateField()
   end_at = models.DateField()
   fancy_field = models.CharField()
   sha256_hash = models.BinaryField(
      max_length=32,
      unique=True,
      null=True
   )

   def __save__(self, *args: Any, **kwargs: Any) -> None:
      super().__save__(*args, **kwargs)
      self.sha256_hash = self.compute_sha256_hash()
      self.save()

   def compute_sha256_hash(self) -> bytes:
      json_object = {
         field: getattr(self, field)
         for field in (
               "start_at",
               "end_at",
               "fancy_field"
         )
      }

      # Forces datetime to use timestamp due to inconsistency
      # in timezone format.
      json_object["start_at"] = self.start_at.timestamp()
      json_object["end_at"] = self.end_at.timestamp()

      json_repr = json.dumps(
         json_object,
         sort_keys=True,
         indent=4,
         cls=DjangoJSONEncoder,
      )
      return hashlib.sha256(json_repr.encode("utf-8")).digest()

Le sha256_hash est unique et ne devrait pas accepter de valeurs nulles, mais avec les attributs blank=True et null=True, il peut en avoir. Il faut alors remplir les valeurs existantes s'il y en a.

Et s'il y a déjà des données dans la table ?

Le sha256_hash est unique et ne devrait pas accepter de valeurs nulles, mais avec les attributs blank=True et null=True, il peut en avoir. Il faut alors remplir les valeurs existantes s'il y en a.

Pour cela, on modifie l'attribut pour autoriser les valeurs nulles :

class Event(models.Model):
   start_at = models.DateField()
   end_at = models.DateField()
   fancy_field = models.CharField()
   sha256_hash = models.BinaryField(
      max_length=32,
      unique=True,
      null=True,
      blank=True
   )
   …

Ensuite, on peut remplir la valeur avec une commande Django en filtrant la table Event existante sur les lignes où le champ sha256_hash est nul, et en appelant la méthode compute_sha256_hash de la classe pour créer la valeur de sha256_hash.

C'est tout pour moi !