Django DRF - Filtering on null values?

Recently I learnt that tri-state booleans (true, false, null) are a thing. And that batteries included DRF (Django REST framework) does not natively support them.

Imagine a property that tells us whether a certain product has passed the quality check or not.

My immediate implementation was to add a boolean field called qc_passed with default value False.

# models.py
class Product(models.Model):
	name = models.CharField(max_length=256)
	qc_passed = models.BooleanField(default=False, verbose_name="QC passed")

One eventually (if you're slow like me, ). This ends up meaning all products have failed quality check until

# Creating a new productcurl -X POST -H "Content-Type: application/json" -d '{"name": "Tiny Rick Potion"}' localhost:8000/api/products/ -s | jq
{
  "name": "Tiny Rick Potion",
  "qc_passed": false # the QC passed is false by default (which is not good!)
}

What if a product hasn't even gone through a quality check?

This point to 3 possible states for a product's qc_passed field:

  1. True (has passed quality check)
  2. False (did not pass quality check)
  3. Null (no data about quality checks yet)
# models.py
class Product(models.Model):
	name = models.CharField(max_length=256)
	qc_passed = models.BooleanField(null=True, default=None, verbose_name="QC passed")
curl -X POST -H "Content-Type: application/json" -d '{"name": "Tiny Rick Potion"}' localhost:8000/api/products/ -s | jq

{
  "name": "Tiny Rick Potion",
  "qc_passed": null
}

Voila!

Making the states look better with enum (mention that this is completely optional and only valuable if you're using forms or the admin panel) Let's use some readable labels that represent the 3 states: [null, False, True] -> ["Not set", "Needs improvement", "Passed"]. These values are arbitrary and you accommodate them according to your use case

class Product(models.Model):
	# define a map of human-readable labels for field values
    class QCPassedStates(models.Choices):
        NOT_SET = None
        NEEDS_IMPROVEMENT = False
        PASSED = True
	
    name = models.CharField(max_length=256)
    qc_passed = models.BooleanField(
        choices=QCPassedStates.choices, # pass choices to the field
        null=True,
        default=None,
        verbose_name="QC passed",
    )

    def __str__(self):
        return self.name

Love that

How about filtering them using DRF?

But how do filters work?

  • DRF's documentation suggests using django_filters package's DjangoFilterBackend
...
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, filters

class ProductFilter(FilterSet):
    qc_passed = filters.BooleanFilter()

    class Meta:
        model = Product
        fields = ["qc_passed"]



class ProductViewSet(viewsets.ModelViewSet):
    serializer_class = ProductSerializer
    queryset = Product.objects.all()
    filter_backends = [DjangoFilterBackend]
    filterset_class = ProductFilter 
...
curl localhost:8000/api/products/ -s | jq 'group_by(.qc_passed) | map({qc_passed: .[0].qc_passed, count: length})'
[
  {
    "qc_passed": null,
    "count": 2
  },
  {
    "qc_passed": false,
    "count": 2
  },
  {
    "qc_passed": true,
    "count": 3
  }
]

❯ curl localhost:8000/api/products/?qc_passed=False -s | jq
[
  {
    "name": "Tiny Rick Potion",
    "qc_passed": false
  },
  {
    "name": "Mega Seeds",
    "qc_passed": false
  }
]
curl localhost:8000/api/products/?qc_passed=True -s | jq
[
  {
    "name": "Portal Gun",
    "qc_passed": true
  },
  {
    "name": "Plumbus",
    "qc_passed": true
  },
  {
    "name": "Meeseek Box",
    "qc_passed": true
  }
]

However, for filtering for null values..

curl localhost:8000/api/products/?qc_passed=null -s | jq
{
  "qc_passed": [
    "Select a valid choice. null is not one of the available choices."
  ]
}


curl localhost:8000/api/products/?qc_passed=none -s | jq
{
  "qc_passed": [
    "Select a valid choice. none is not one of the available choices."
  ]
}
...
  • Django filters does not have framework level null filtering support https://github.com/carltongibson/django-filter/issues/1012
  • It can be solved with a choice filter.. but that's not a boolean filter..

It seems like even though null is a valid option for the QC passed field, the filter doesn't seem to translate "" (empty string), "None" or "null" as valid none type values.

Let's fix that:

First we need a way to override how the qc_passed field is filtered.

BooleanFilter: This filter matches a boolean, either True or False, used with BooleanField and NullBooleanField by default. https://django-filter.readthedocs.io/en/stable/ref/filters.html#booleanfilter


# ... 
FILTER_FOR_DBFIELD_DEFAULTS = {
	#..
    models.BooleanField: {"filter_class": BooleanFilter},
    #..
}
# ...

Each filter field is applied on the query using the filter method of django_filters.filters.Filter (and its children e.g BooleanField)

class Filter
	#...
    def filter(self, qs, value):
        if value in EMPTY_VALUES:
            return qs
        if self.distinct:
            qs = qs.distinct()
        lookup = "%s__%s" % (self.field_name, self.lookup_expr)
        qs = self.get_method(qs)(**{lookup: value})
        return qs
	#...


# Ref: https://github.com/carltongibson/django-filter/blob/635343ec55c9928bfa297314711df77fa83ff6c7/django_filters/filters.py#L161

We can define a custom implementation BooleanFilter which supports null values

class NullBooleanFilter(filters.BooleanFilter):

	# django_filters also has None as an empty value
	# which is not the case for this special BooleanFilter
    EMPTY_VALUES = (
        [],
        (),
        {},
        "",
    )

    NULL_VALUES = ("null", "none")

    def filter(self, qs, value):
        if value in self.EMPTY_VALUES:
            return qs
        if self.distinct:
            qs = qs.distinct()
        # pièce de résistance
        if str(value).lower() in self.NULL_VALUES:
            pass

        lookup = "%s__%s" % (self.field_name, self.lookup_expr)
        qs = self.get_method(qs)(**{lookup: value})
        return qs

Success? Success.

curl localhost:8000/api/products/?qc_passed=null -s | jq
[
  {
    "name": "Microvert Battery",
    "qc_passed": null
  },
  {
    "name": "Memory Wipe",
    "qc_passed": null
  }
]

curl localhost:8000/api/products/?qc_passed=none -s | jq
[
  {
    "name": "Microvert Battery",
    "qc_passed": null
  },
  {
    "name": "Memory Wipe",
    "qc_passed": null
  }
]