Django-filter: NullBooleanField et aucune valeur

Créé le 14 oct. 2016  ·  3Commentaires  ·  Source: carltongibson/django-filter

Bonjour, j'ai rencontré quelque chose de très intéressant aujourd'hui concernant NullBooleanField et la valeur vide ('', 'None') pour filtrer les valeurs nulles dans db.
Pour mémoire, j'utilise django rest framework.

Voici mon expérience :

Je crée un modèle Django :

class MyModel(models.Model):
    is_solved = models.NullBooleanField()

Et dans django rest framework, je déclare ma View :

class MyModelFilter(filters.FilterSet):
    class Meta:
        model = MyModel
        fields = ('is_solved',)


class MyModelViewSet(ModelViewSet):
    queryset = MyModel.objects.all()
    serializer_class = MyModelSerializer
    filter_backends = (filters.DjangoFilterBackend,)
    filter_class = MyModelFilter

Avec cela, j'ai un filtre sur le champ is_solved avec django rest framework.
Le filtre utilise un widget de sélection avec étiquette/valeurs :

choices = (('', _('Unknown')),
           ('true', _('Yes')),
           ('false', _('No')))

Comme décrit ici : https://github.com/carltongibson/django-filter/blob/develop/django_filters/widgets.py#L103

Lorsque j'utilise ce filtre, si je sélectionne Unknown , tout est renvoyé... donc rien n'est filtré...
Ce que je voulais, c'est n'avoir que des objets avec is_solved définis sur null dans db.

De plus, je voulais changer la façon dont is_solved est affiché, j'ai donc fait une petite modification sur mon modèle.
Nouveau modèle:

CHOICES = (
    (None, "OFF"),
    (True, "YES"),
    (False, "NO")
)
class MyModel(models.Model):
    is_solved = models.NullBooleanField(choices=CHOICES)

Avec cette modification, le filtre dans le framework django rest me donne le bon widget Select, mais il utilise maintenant le ChoiceField, avec son propre widget.
Cela a également plus de sens, car j'ai maintenant un selectBox lors de la création d'un modèle au lieu d'une entrée de texte.
Mais lorsque je sélectionne OFF pour le filtrage, l'url définit un query_parameter comme ?is_solved= :/
Cela fonctionne bien avec vrai et faux de toute façon.

Donc, pour le faire fonctionner avec le framework django rest, j'ai changé mes choix en:

CHOICES = (
    ('None', "OFF"),
    (True, "YES"),
    (False, "NO")
)

Notez 'None' au lieu de None . Et c'est bon, j'ai maintenant un paramètre de requête ?is_solved=None
mais maintenant, rien n'est retourné avec ce filtre... La liste est vide...

M'a fait mettre les mains dans le code django_filters :)

La clé est dans cette partie du code :
https://github.com/carltongibson/django-filter/blob/develop/django_filters/filters.py#L172

    def filter(self, qs, value):
        if isinstance(value, Lookup):
            lookup = six.text_type(value.lookup_type)
            value = value.value
        else:
            lookup = self.lookup_expr
        if value in EMPTY_VALUES:
            return qs
        if self.distinct:
            qs = qs.distinct()
        qs = self.get_method(qs)(**{'%s__%s' % (self.name, lookup): value})
        return qs

Ce petit morceau de code fait le filtrage.
Et j'ai trouvé quelque chose de très très intéressant avec le comportement de dkango QuerySet.

Une partie des lignes importantes sont :

if value in EMPTY_VALUES:
    return qs

La requête est retournée non filtrée si nous avons une valeur vide, qui est [], (), u'', {}, None
Mais notre valeur est 'None' donc ça va, tout le code précédent n'a pas transformé la 'None' en None . Mais utiliser le NullBooleanField sans choices fait quelque chose de totalement différent car le champ n'est pas considéré comme un ChoiceField :
code du noyau de django :

class NullBooleanField(BooleanField):
    """
    A field whose valid values are None, True and False. Invalid values are
    cleaned to None.
    """
    widget = NullBooleanSelect

    def to_python(self, value):
        """
        Explicitly checks for the string 'True' and 'False', which is what a
        hidden field will submit for True and False, for 'true' and 'false',
        which are likely to be returned by JavaScript serializations of forms,
        and for '1' and '0', which is what a RadioField will submit. Unlike
        the Booleanfield we need to explicitly check for True, because we are
        not using the bool() function
        """
        if value in (True, 'True', 'true', '1'):
            return True
        elif value in (False, 'False', 'false', '0'):
            return False
        else:
            return None

    def validate(self, value):
        pass

Maintenant, les choses deviennent vraiment très intéressantes...
Jetez un oeil à cette ligne :

qs = self.get_method(qs)(**{'%s__%s' % (self.name, lookup): value})

Il effectue le filtrage pour passer à django core db Queryset.
Nous pouvons le traduire en :

qs = qs.filter(is_solved__exact='None')

Cette opération renverra un ensemble de requêtes vide...
Il produira une requête sql avec is_solved = None

Mais si on teste :

qs = qs.filter(is_solved__exact=None)

Il renvoie correctement uniquement les instances avec des valeurs null dans db.
Il produira une requête sql avec is_solved IS NULL
C'est exactement la même chose à tester :

qs = qs.filter(is_solved__isnull=True)

Cependant, les valeurs 'True' et 'False' n'ont pas ce problème. La transcription est correcte par Django.

Nous pourrions penser à remplacer le lookup_epxr de notre filtre par isnull mais notre filtre ne fonctionnera pas avec les valeurs True et False ...

À ce stade, je ne sais vraiment pas comment gérer ce comportement, bug django ? les filtres django manquent quelque chose ?

Quoi qu'il en soit, j'ai réussi à le faire fonctionner en réécrivant la fonction filter des filtres Django :

    def filter(self, qs, value):
        from django.db.models import NullBooleanField

        if isinstance(value, Lookup):
            lookup = six.text_type(value.lookup_type)
            value = value.value
        else:
            lookup = self.lookup_expr
        if value in EMPTY_VALUES:
            return qs
        if self.distinct:
            qs = qs.distinct()

        # if original field is a NullBooleanField, we need to transform 'None' value as None
        if isinstance(self.model._meta.get_field(self.name), NullBooleanField) and value in ('None',):
            value = None

        qs = self.get_method(qs)(**{'%s__%s' % (self.name, lookup): value})
        return qs

Cela ressemble à un hack et ça ne me plaît pas pour l'instant...
Si quelqu'un est intéressé à réfléchir à cette question, je serai heureux de l'entendre! :RÉ

Pour l'instant, des avis sur cette modification ?
En ce moment, je pense que je vais utiliser un IntegerField avec une option de choix pour ne pas avoir à utiliser une version fourchue des filtres Django... car je pense que ce n'est pas un problème de filtres Django au début :)

Merci,

Commentaire le plus utile

Merci @carltongibson et merci aussi @rpkilby
Je viens de tirer la branche develop.

Le NullBooleanField fonctionne maintenant avec le framework django rest avec cette configuration :
Model :

CHOICES = (
    (None, "OFF"),
    (True, "YES"),
    (False, "NO")
)
class MyModel(models.Model):
    is_solved = models.NullBooleanField(choices=CHOICES)

View :

choices = (
    (True, "YES"),
    (False, "NO")
)


class MyModelFilter(filters.FilterSet):

    is_solved = ChoiceFilter(null_label='OFF', choices=choices)

    class Meta:
        model = MyModel
        fields = ('is_solved',)


class MyModelViewSet(ModelViewSet):
    queryset = MyModel.objects.all()
    serializer_class = MyModelSerializer
    filter_backends = (filters.DjangoFilterBackend,)
    filter_class = MyModelFilter

Notez la différence entre les tuples de choix.

Tous les 3 commentaires

Salut @GuillaumeCisco - il semble qu'il y ait trois problèmes ici :

  1. BooleanWidget ne fonctionne pas avec NullBooleanField s, car il ne valide pas les valeurs nulles.

Il devrait probablement y avoir un NullBooleanWidget qui accueille l'option supplémentaire.

  1. Vous souhaitez remplacer les étiquettes de texte pour les choix (on/off au lieu de oui/non).

Ceci est probablement mieux géré en sous-classant la classe widget et en définissant manuellement la propriété choices . BooleanWidget ne peut pas vraiment accepter un argument de choix, car il attend des valeurs spécifiques ( 'true' / 'false' au lieu de True / False ) . La même chose s'appliquerait à un potentiel NullBooleanWidget .

  1. Il y a des problèmes de filtrage par None , car il ne passe pas la vérification des valeurs vides.

Une valeur magique telle que 'None' ou 'null' va être nécessaire ici. La méthode filter() devra en tenir compte.

Je pense que la majeure partie de cela a été couverte par # 519.

@GuillaumeCisco , veuillez revoir cela. S'il y a un cas de test spécifique qui, selon vous, devrait être couvert, pourriez-vous ouvrir un PR en ajoutant cela et nous pourrons l'examiner.

Merci.

Merci @carltongibson et merci aussi @rpkilby
Je viens de tirer la branche develop.

Le NullBooleanField fonctionne maintenant avec le framework django rest avec cette configuration :
Model :

CHOICES = (
    (None, "OFF"),
    (True, "YES"),
    (False, "NO")
)
class MyModel(models.Model):
    is_solved = models.NullBooleanField(choices=CHOICES)

View :

choices = (
    (True, "YES"),
    (False, "NO")
)


class MyModelFilter(filters.FilterSet):

    is_solved = ChoiceFilter(null_label='OFF', choices=choices)

    class Meta:
        model = MyModel
        fields = ('is_solved',)


class MyModelViewSet(ModelViewSet):
    queryset = MyModel.objects.all()
    serializer_class = MyModelSerializer
    filter_backends = (filters.DjangoFilterBackend,)
    filter_class = MyModelFilter

Notez la différence entre les tuples de choix.

Cette page vous a été utile?
0 / 5 - 0 notes