Using Django Check Constraints to Limit A Model to a Single Instance

2021-02-04 A most singular unicorn.

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.

Fin

May your single instance models work well,

—Adam


Want better tests? Check out my book Speed Up Your Django Tests which teaches you to write faster, more accurate tests.


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: django