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 product
❯ 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": 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:
True(has passed quality check)False(did not pass quality check)- 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_filterspackage'sDjangoFilterBackend
...
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
}
]