Champs uniques qui autorisent les nulls dans Django


135

J'ai le modèle Foo qui a une barre de champ. Le champ de la barre doit être unique, mais autoriser les valeurs nulles, ce qui signifie que je souhaite autoriser plus d'un enregistrement si le champ de la barre l'est null, mais si ce n'est pas nullle cas, les valeurs doivent être uniques.

Voici mon modèle:

class Foo(models.Model):
    name = models.CharField(max_length=40)
    bar = models.CharField(max_length=40, unique=True, blank=True, null=True, default=None)

Et voici le SQL correspondant pour la table:

CREATE TABLE appl_foo
(
    id serial NOT NULL,
     "name" character varying(40) NOT NULL,
    bar character varying(40),
    CONSTRAINT appl_foo_pkey PRIMARY KEY (id),
    CONSTRAINT appl_foo_bar_key UNIQUE (bar)
)   

Lorsque vous utilisez l'interface d'administration pour créer plus de 1 objets foo où bar est nul, cela me donne une erreur: "Foo avec cette barre existe déjà."

Cependant, lorsque j'insère dans la base de données (PostgreSQL):

insert into appl_foo ("name", bar) values ('test1', null)
insert into appl_foo ("name", bar) values ('test2', null)

Cela fonctionne, très bien, cela me permet d'insérer plus d'un enregistrement avec une barre nulle, donc la base de données me permet de faire ce que je veux, c'est juste quelque chose qui ne va pas avec le modèle Django. Des idées?

ÉDITER

La portabilité de la solution dans la mesure où DB n'est pas un problème, nous sommes satisfaits de Postgres. J'ai essayé de définir un paramètre unique pour un appelable, qui était ma fonction retournant Vrai / Faux pour des valeurs spécifiques de barre , cela ne donnait aucune erreur, même si cela n'avait aucun effet.

Jusqu'à présent, j'ai supprimé le spécificateur unique de la propriété bar et géré l' unicité de la barre dans l'application, tout en recherchant une solution plus élégante. Des recommandations?


Je ne peux pas encore commenter donc voici un petit ajout à mightyhal: Depuis Django 1.4, vous auriez besoin def get_db_prep_value(self, value, connection, prepared=False)d'un appel de méthode. Consultez groups.google.com/d/msg/django-users/Z_AXgg2GCqs/zKEsfu33OZMJ pour plus d'informations. La méthode suivante fonctionne aussi pour moi: def get_prep_value (self, value): if value == "": #if Django essaie de sauvegarder la chaîne '', envoie la base de données None (NULL) return None else: renvoie la valeur #otherwise, juste passer la valeur
Jens

J'ai ouvert un ticket Django pour cela. Ajoutez votre soutien. code.djangoproject.com/ticket/30210#ticket
Carl Brubaker

Réponses:


154

Django n'a pas considéré NULL comme étant égal à NULL aux fins des vérifications d'unicité depuis que le ticket # 9039 a été corrigé, voir:

http://code.djangoproject.com/ticket/9039

Le problème ici est que la valeur «vide» normalisée pour un formulaire CharField est une chaîne vide, pas None. Donc, si vous laissez le champ vide, vous obtenez une chaîne vide, et non NULL, stockée dans la base de données. Les chaînes vides sont égales aux chaînes vides pour les vérifications d'unicité, sous les règles de Django et de base de données.

Vous pouvez forcer l'interface d'administration à stocker NULL pour une chaîne vide en fournissant votre propre formulaire de modèle personnalisé pour Foo avec une méthode clean_bar qui transforme la chaîne vide en None:

class FooForm(forms.ModelForm):
    class Meta:
        model = Foo
    def clean_bar(self):
        return self.cleaned_data['bar'] or None

class FooAdmin(admin.ModelAdmin):
    form = FooForm

2
Si la barre est vide, remplacez-la par None dans la méthode pre_save. Le code sera plus SEC, je suppose.
Ashish Gupta

6
Cette réponse n'aide que pour la saisie de données basée sur un formulaire, mais ne fait rien pour protéger l'intégrité des données. Les données peuvent être saisies via des scripts d'importation, depuis le shell, via une API ou tout autre moyen. Il est préférable de remplacer la méthode save () que de créer des cas personnalisés pour chaque formulaire susceptible de toucher les données.
shacker

Django 1.9+ nécessite un attribut fieldsou excludedans les ModelForminstances. Vous pouvez contourner ce Metaproblème en omettant la classe interne du ModelForm pour une utilisation dans admin. Référence: docs.djangoproject.com/en/1.10/ref/contrib/admin/…
user85461

62

** edit 30/11/2015 : En python 3, la __metaclass__variable module-global n'est plus prise en charge . En outre, à partir de Django 1.10la SubfieldBaseclasse était obsolète :

à partir de la documentation :

django.db.models.fields.subclassing.SubfieldBaseest obsolète et sera supprimé dans Django 1.10. Historiquement, il était utilisé pour gérer les champs où la conversion de type était nécessaire lors du chargement à partir de la base de données, mais il n'était pas utilisé dans les .values()appels ou dans les agrégats. Il a été remplacé par from_db_value(). Notez que la nouvelle approche n'appelle pas la to_python()méthode lors de l'affectation comme c'était le cas avec SubfieldBase.

Par conséquent, comme suggéré par la from_db_value() documentation et cet exemple , cette solution doit être remplacée par:

class CharNullField(models.CharField):

    """
    Subclass of the CharField that allows empty strings to be stored as NULL.
    """

    description = "CharField that stores NULL but returns ''."

    def from_db_value(self, value, expression, connection, contex):
        """
        Gets value right out of the db and changes it if its ``None``.
        """
        if value is None:
            return ''
        else:
            return value


    def to_python(self, value):
        """
        Gets value right out of the db or an instance, and changes it if its ``None``.
        """
        if isinstance(value, models.CharField):
            # If an instance, just return the instance.
            return value
        if value is None:
            # If db has NULL, convert it to ''.
            return ''

        # Otherwise, just return the value.
        return value

    def get_prep_value(self, value):
        """
        Catches value right before sending to db.
        """
        if value == '':
            # If Django tries to save an empty string, send the db None (NULL).
            return None
        else:
            # Otherwise, just pass the value.
            return value

Je pense qu'un meilleur moyen que de remplacer les données nettoyées dans l'administrateur serait de sous-classer le champ de caractères - de cette façon, quel que soit le formulaire accédant au champ, cela "fonctionnera simplement". Vous pouvez attraper le ''juste avant qu'il ne soit envoyé à la base de données, et attraper le NULL juste après qu'il sort de la base de données, et le reste de Django ne le saura pas. Un exemple rapide et sale:

from django.db import models


class CharNullField(models.CharField):  # subclass the CharField
    description = "CharField that stores NULL but returns ''"
    __metaclass__ = models.SubfieldBase  # this ensures to_python will be called

    def to_python(self, value):
        # this is the value right out of the db, or an instance
        # if an instance, just return the instance
        if isinstance(value, models.CharField):
            return value 
        if value is None:  # if the db has a NULL (None in Python)
            return ''      # convert it into an empty string
        else:
            return value   # otherwise, just return the value

    def get_prep_value(self, value):  # catches value right before sending to db
        if value == '':   
            # if Django tries to save an empty string, send the db None (NULL)
            return None
        else:
            # otherwise, just pass the value
            return value  

Pour mon projet, je l'ai vidé dans un extras.pyfichier qui vit à la racine de mon site, puis je peux juste from mysite.extras import CharNullFielddans le models.pyfichier de mon application . Le champ agit comme un CharField - n'oubliez pas de définir blank=True, null=Truelors de la déclaration du champ, sinon Django lèvera une erreur de validation (champ obligatoire) ou créera une colonne db qui n'accepte pas NULL.


3
dans get_prep_value, vous devez supprimer la valeur, au cas où la valeur aurait plusieurs espaces.
ax003d

1
La réponse mise à jour ici fonctionne bien en 2016 avec Django 1.10 et en utilisant EmailField.
k0nG

4
Si vous mettez à jour un CharFieldpour devenir un CharNullField, vous devrez le faire en trois étapes. Tout d'abord, ajoutez null=Trueau champ et migrez-le. Ensuite, effectuez une migration de données pour mettre à jour les valeurs vides afin qu'elles soient nulles. Enfin, convertissez le champ en CharNullField. Si vous convertissez le champ avant d'effectuer la migration de données, votre migration de données ne fera rien.
mlissner le

3
Notez que dans la solution mise à jour, from_db_value()ne devrait pas avoir ce contexparamètre supplémentaire . Ça devrait êtredef from_db_value(self, value, expression, connection):
Phil Gyford

1
Le commentaire de @PhilGyford s'applique à partir de 2.0.
Shaheed Haque

16

Parce que je suis nouveau dans stackoverflow, je ne suis pas encore autorisé à répondre aux réponses, mais je tiens à souligner que d'un point de vue philosophique, je ne peux pas être d'accord avec la réponse la plus populaire à cette question. (par Karen Tracey)

L'OP exige que son champ de barre soit unique s'il a une valeur, et nul dans le cas contraire. Ensuite, il faut que le modèle lui-même s'assure que ce soit le cas. Il ne peut pas être laissé au code externe de vérifier cela, car cela signifierait qu'il peut être contourné. (Ou vous pouvez oublier de le vérifier si vous écrivez une nouvelle vue à l'avenir)

Par conséquent, pour garder votre code véritablement POO, vous devez utiliser une méthode interne de votre modèle Foo. Modifier la méthode save () ou le champ sont de bonnes options, mais l'utilisation d'un formulaire pour le faire ne l'est certainement pas.

Personnellement, je préfère utiliser le CharNullField suggéré, pour la portabilité vers des modèles que je pourrais définir dans le futur.


13

La solution rapide est de faire:

def save(self, *args, **kwargs):

    if not self.bar:
        self.bar = None

    super(Foo, self).save(*args, **kwargs)

2
sachez que l'utilisation MyModel.objects.bulk_create()contournerait cette méthode.
BenjaminGolder le

Cette méthode est-elle appelée lorsque nous enregistrons à partir du panneau d'administration? J'ai essayé mais ce n'est pas le cas.
Kishan Mehta

1
Le panneau @Kishan django-admin ignorera ces crochets malheureusement
Vincent Buscarello

@ e-satis votre logique est saine donc j'ai implémenté ceci, mais l'erreur est toujours un problème. On me dit que nul est un double.
Vincent Buscarello

6

Une autre solution possible

class Foo(models.Model):
    value = models.CharField(max_length=255, unique=True)

class Bar(models.Model):
    foo = models.OneToOneField(Foo, null=True)

Ce n'est pas une bonne solution car vous créez une relation inutile.
Burak Özdemir


1

J'ai récemment eu la même exigence. Au lieu de sous-classer différents champs, j'ai choisi de remplacer le metod save () sur mon modèle (nommé `` MyModel '' ci-dessous) comme suit:

def save(self):
        """overriding save method so that we can save Null to database, instead of empty string (project requirement)"""
        # get a list of all model fields (i.e. self._meta.fields)...
        emptystringfields = [ field for field in self._meta.fields \
                # ...that are of type CharField or Textfield...
                if ((type(field) == django.db.models.fields.CharField) or (type(field) == django.db.models.fields.TextField)) \
                # ...and that contain the empty string
                and (getattr(self, field.name) == "") ]
        # set each of these fields to None (which tells Django to save Null)
        for field in emptystringfields:
            setattr(self, field.name, None)
        # call the super.save() method
        super(MyModel, self).save()    

1

Si vous avez un modèle MyModel et que vous souhaitez que my_field soit Null ou unique, vous pouvez remplacer la méthode de sauvegarde du modèle:

class MyModel(models.Model):
    my_field = models.TextField(unique=True, default=None, null=True, blank=True) 

    def save(self, **kwargs):
        self.my_field = self.my_field or None
        super().save(**kwargs)

De cette façon, le champ ne peut pas être vide sera seulement non vide ou nul. les valeurs nulles ne contredisent pas l'unicité


1

Vous pouvez ajouter UniqueConstraintavec condition de nullable_field=nullet ne pas inclure ce champ dans la fieldsliste. Si vous avez également besoin d'une contrainte nullable_fielddont la valeur n'est pas null, vous pouvez en ajouter une supplémentaire.

Remarque: UniqueConstraint a été ajouté depuis django 2.2

class Foo(models.Model):
    name = models.CharField(max_length=40)
    bar = models.CharField(max_length=40, unique=True, blank=True, null=True, default=None)
    
    class Meta:
        constraints = [
            # For bar == null only
            models.UniqueConstraint(fields=['name'], name='unique__name__when__bar__null',
                                    condition=Q(bar__isnull=True)),
            # For bar != null only
            models.UniqueConstraint(fields=['name', 'bar'], name='unique__name__when__bar__not_null')
        ]

Ça marche! mais j'obtiens une exception IntegrityError au lieu de l'erreur de validation de formulaire. Comment vas-tu t'occuper de ça? Attrapez-le et augmentez ValidationError dans les vues de création + mise à jour?
gek il y a

0

Pour le meilleur ou pour le pire, Django considère NULLcomme équivalent à NULLdes fins de vérification d'unicité. Il n'y a vraiment aucun moyen de contourner cela sans écrire votre propre implémentation du contrôle d'unicité qui considère NULLcomme unique, quel que soit le nombre de fois qu'il se produit dans une table.

(et gardez à l'esprit que certaines solutions de base de données adoptent le même point de vue NULL, donc le code reposant sur les idées d'une base de données NULLpeut ne pas être portable pour d'autres)


6
Ce n'est pas la bonne réponse. Voir cette réponse pour une explication .
Carl G

2
D'accord, ce n'est pas correct. Je viens de tester IntegerField (blank = True, null = True, unique = True) dans Django 1.4 et il autorise plusieurs lignes avec des valeurs nulles.
slacy
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.