Using Django Check Constraints to Limit A Model to a Single Instance
Yet another use case for creating a database constraint with Django’s CheckConstraint
class.
Sometimes it’s useful to have a model with only have one instance in the database, sometimes known as a singleton. This is useful for storing a small amount of structured data that we want to share between all our project’s processes.
For example, imagine a remote API that we authenticate with using a temporary access token. We have the username and password for the API in our Django settings, and use those to get a temporary access token. We then need to store that temporary access token for all operations with that API, and refresh it when it nears its expiry time. And for security reasons, we are only allowed to store the current access token.
We can store the token and its expiry in a model like this:
from django.db import models
class RemoteAPIAccount(models.Model):
access_token = models.CharField(max_length=120)
access_token_expires = models.DateTimeField()
This model has the right fields for holding the token, but it can also have many instances of it in our database.
We can write code that always uses a single instance by always the model through get_or_create()
or update_or_create()
, and passing all field values through defaults
. For example:
In [1]: import datetime as dt
In [2]: from django.utils import timezone
In [3]: from example.core.models import *
In [4]: RemoteAPIAccount.objects.update_or_create(defaults={"access_token": "some-token", "access_token_expires": timezone.now() + dt.timedelta(hours=12)})
Out[4]: (<RemoteAPIAccount: RemoteAPIAccount object (1)>, True)
In [5]: RemoteAPIAccount.objects.update_or_create(defaults={"access_token": "some-new-token", "access_token_expires": timezone.now() + dt.timedelta(hours=12)})
Out[5]: (<RemoteAPIAccount: RemoteAPIAccount object (1)>, False)
But if any process ever creates a second instance, such as an accidental creation on the admin, that code will raise a MultipleObjectsReturned
exception:
In [13]: RemoteAPIAccount.objects.update_or_create(defaults={"access_token": "some-even-newer-token", "access_token_expires": timezone.now() + dt.timedelta(hours=12)})
---------------------------------------------------------------------------
MultipleObjectsReturned Traceback (most recent call last)
<ipython-input-13-34853e05e383> in <module>
----> 1 RemoteAPIAccount.objects.update_or_create(defaults={"access_token": "some-even-newer-token", "access_token_expires": timezone.now() + dt.timedelta(hours=12)})
...
MultipleObjectsReturned: get() returned more than one RemoteAPIAccount -- it returned 3!
How ever careful we are in our code, it’s better if we disallow this from ever happening. We can do that by adding a constraint that limits the model to exactly one instance.
At first it might sound like a UniqueConstraint
would work, as we want to have a unique instance. Unfortunately this is not possible since unique constraints need at least one field to enforce uniqueness on, but we’d want to specify no fields. Instead, we can use a CheckConstraint
that constrains the id
field that Django adds to only ever be 1
.
First, we define the constraint in Meta.constraints
:
from django.db import models
class RemoteAPIAccount(models.Model):
access_token = models.CharField(max_length=120)
access_token_expires = models.DateTimeField()
class Meta:
constraints = [
models.CheckConstraint(
name="%(app_label)s_%(class)s_single_instance",
check=models.Q(id=1),
),
]
Second, we run makemigrations
to generate a new migration:
$ ./manage.py makemigrations core
Migrations for 'core':
example/core/migrations/0002_remoteapiaccount_core_remoteapiaccount_single_instance.py
- Create constraint core_remoteapiaccount_single_instance on model remoteapiaccount
We check the migration and indeed it spells out adding the constraint:
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.AddConstraint(
model_name="remoteapiaccount",
constraint=models.CheckConstraint(
check=models.Q(id=1),
name="core_remoteapiaccount_single_instance",
),
),
]
Looks good.
Third, we write a test to ensure the constraint works:
import datetime as dt
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from example.core.models import RemoteAPIAccount
class RemoteAPIAccountTests(TestCase):
def test_single_instance(self):
constraint_name = "core_remoteapiaccount_single_instance"
with self.assertRaisesMessage(IntegrityError, constraint_name):
RemoteAPIAccount.objects.create(
id=2,
access_token="some-token",
access_token_expires=timezone.now() + dt.timedelta(hours=1),
)
The test tries to create an instance with id=2
and ensures this query fails with a database error listing the name of our constraint.
Fourth, we change all code that handles the token to specify id=1
. For example, our token refreshing function might look like this:
from example.core.models import RemoteAPIAccount
def refresh_token():
# Request token from remote API
new_token = ...
new_token_expires = ...
RemoteAPIAccount.objects.update_or_create(
id=1,
defaults={"access_token": new_token, "access_token_expires": new_token_expires},
)
Fifth, if our code has already been running without the constraint, we’d want to check our production data is valid. Otherwise, when we try to migrate the constraint addition will fail. We can do this by checking there is one instance with id 1
, and there are no others:
In[2]: RemoteAPIAccount.objects.filter(id=1).count()
Out[2]: 1
In[3]: RemoteAPIAccount.objects.exclude(id=1).count()
Out[3]: 0
If there were bad instances, we could add a migration step to update or delete them, or do that manually.
Six for any user-facing forms or API endpoints for our model will need updating to ensure they are compatible. For example on the admin we would want to disable the “add” button if the instance already exists, to prevent a crash when trying to add a second instance.
Newly updated: my book Boost Your Django DX now covers Django 5.0 and Python 3.12.
One summary email a week, no spam, I pinky promise.
Related posts:
- Using Django Check Constraints to Prevent the Storage of The Empty String
- Django’s Field Choices Don’t Constrain Your Data
- Using Django Check Constraints to Ensure Only One Field Is Set
Tags: django