Custom Sequential Number Django Model Field

Sometimes we need certain functionality again, and again, and again,… Sometimes it just makes sense to encapsulate certain logic into a separate module so it is easier to test and maintan. For example, we might need Django model field that is capable of keeping seqential number of the items in the order they are stored in the database.

Requirements

  • If value for the field is assigned, do not change it

  • The sequence is defined through database key - sequence key

  • The sequence key could be composite

  • The sequence key could be global (empty)

  • If value is not assigned, get the highest used value from the sequence + increment

  • If value is not assigned and sequence is empty use initial value

Warning

Not concurrency-safe.

Custom Django model field

Following is the source for the sequence number custom Django model field.

sequential_number_field.py
 1from typing import Any
 2
 3from django.core.exceptions import ObjectDoesNotExist
 4from django.db import models
 5from django.db.models import Model
 6
 7
 8class SequentialNumberField(models.PositiveIntegerField):
 9    def __init__(self, key=None, start_at=1, increment=1, *args, **kwargs):
10        """
11        Initializes a SequentialNumberField.
12
13        Args:
14            key (list of str, optional): A list of field names to use as keys for sequential number grouping.
15                If specified, the sequential number will be unique within the group specified by the key.
16                Defaults to None.
17            start_at (int, optional): The initial value for the sequence. Defaults to 1.
18            increment (int, optional): The increment value for the sequence. Defaults to 1.
19        """
20        if isinstance(key, str):
21            self.key = [key]
22        else:
23            self.key = key
24        self.start_at = start_at
25        self.increment = increment
26        super().__init__(*args, **kwargs)
27
28    def pre_save(self, model_instance: Model, add: bool) -> Any:
29        """
30        Pre-save method to generate and assign the sequential number.
31
32        Args:
33            model_instance (Model): The model instance being saved.
34            add (bool): True if the model instance is being added, False if it's being updated.
35
36        Returns:
37            int: The generated sequential number.
38        """
39        if getattr(model_instance, self.attname) is None:
40            value = self._generate_value(model_instance)
41            setattr(model_instance, self.attname, value)
42            return value
43        return super().pre_save(model_instance, add)
44
45    def _generate_value(self, model_instance: Model) -> Any:
46        try:
47            query = self._get_filter_query(model_instance)
48            highest = self._find_highest_used_value(query)
49            value = highest + self.increment
50        except ObjectDoesNotExist:
51            value = self.start_at
52        return value
53
54    def _get_filter_query(self, model_instance: Model) -> dict:
55        if self.key:
56            query = {field: getattr(model_instance, field) for field in self.key}
57            return query
58        return {}
59
60    def _find_highest_used_value(self, query: dict):
61        qs = self._get_queryset().filter(**query)
62        last_item = qs.latest(self.attname)
63        return getattr(last_item, self.attname)
64
65    def _get_queryset(self):
66        return self.model.objects.all()

Object initializer stores the sequence key, the sequence initial value (start_at) at the sequence increment.

The pre_save method is called by Django before persisting the model instance into the database. We override this method so that if no value is assigned to the attribute, a new value is generated and assigned. If value is already assigned, the default Django implementation from the parent class is called.

For clean code considerations I split the pre_save int small self-contained methods. If we follow the all-in-one approach and inline all the methods, the pre_save could look like:

inlined version of the pre_save method
 1 def pre_save(self, model_instance: Model, add: bool) -> Any:
 2     if getattr(model_instance, self.attname) is None:
 3         try:
 4             qs = self._get_queryset()
 5             if self.key:
 6                 query = {field: getattr(model_instance, field) for field in self.key}
 7                 qs = qs.filter(**query)
 8             highest_item = qs.latest(self.attname)
 9             highest_value = getattr(highest_item, self.attname)
10             value = highest_value + self.increment
11         except ObjectDoesNotExist:
12             value = self.start_at
13         setattr(model_instance, self.attname, value)
14         return value
15     return super().pre_save(model_instance, add)

In this case the inlined version of the code is still readable so it might make sense using it. However from testability perspective, it is not that easy to unit test the method as for each unit test it would require quite some mocking and patching.

I would rather prefer something in-between:

structured version of the pre_save method
 1 def pre_save(self, model_instance: Model, add: bool) -> Any:
 2     if getattr(model_instance, self.attname) is None:
 3         try:
 4             qs = self._get_queryset_for(model_instance)
 5             highest_item = qs.latest(self.attname)
 6             highest_value = getattr(highest_item, self.attname)
 7             value = highest_value + self.increment
 8         except ObjectDoesNotExist:
 9             value = self.start_at
10         setattr(model_instance, self.attname, value)
11         return value
12     return super().pre_save(model_instance, add)
13
14 def _get_queryset_for(self, model_instance: Model) -> models.QuerySet:
15     qs = self.model.objects.all()
16     if self.key:
17         query = {field: getattr(model_instance, field) for field in self.key}
18         qs = qs.filter(**query)
19     return qs

Once you have good tests in place, you could experiment with implementation that suits your preferences. In our case I created tests which evaluate the behavior of the SequentialNumberField instances through the public interface. This allows me for experimenting with different implementations while still using the same tests. Since I am not testing methods in isolation, this is known as the testing trophy (as oppiosed to the testing pyramid).

For the sake of fun I asked ChatGPT to restructure my code to follow better the Single Responsibility Principle (SRP). Here is what it came with:

pre_save method with SRP in mind - ChatGPT version
 1class SequentialNumberField(models.PositiveIntegerField):
 2   # ... (constructor remains the same)
 3
 4   def pre_save(self, model_instance: Model, add: bool) -> Any:
 5      if getattr(model_instance, self.attname) is None:
 6            value = self._generate_sequential_number(model_instance)
 7            setattr(model_instance, self.attname, value)
 8            return value
 9      return super().pre_save(model_instance, add)
10
11   def _generate_sequential_number(self, model_instance: Model) -> int:
12      try:
13            highest_value = self._get_highest_value(model_instance)
14            return highest_value + self.increment
15      except ObjectDoesNotExist:
16            return self.start_at
17
18   def _get_highest_value(self, model_instance: Model) -> int:
19      qs = self._get_queryset(model_instance)
20      highest_item = qs.latest(self.attname)
21      return getattr(highest_item, self.attname)
22
23   def _get_queryset(self, model_instance: Model) -> models.QuerySet:
24      qs = model_instance.__class__.objects.all()
25      if self.key:
26            query = {field: getattr(model_instance, field) for field in self.key}
27            qs = qs.filter(**query)
28      return qs

Pytest tests for our SequentialNumberField custom Django model field

Here is a sample implementation for pytest tests that verify the implementation matches the requirements.

test_sequential_number_field.py
 1import pytest
 2from tests.fake_app import models
 3
 4from iris.fields import SequentialNumberField
 5
 6
 7class TestSequentialNumberField:
 8    def test_can_create_instance(self):
 9        f = SequentialNumberField()
10
11    def test_can_specify_field_name_as_key(self):
12        f = SequentialNumberField(key="field")
13        assert f.key == ["field"]
14
15    def test_can_specify_no_key(self):
16        f = SequentialNumberField()
17        assert f.key is None
18
19    def test_can_specify_composite_key(self):
20        f = SequentialNumberField(["field1", "field2"])
21        assert f.key == ["field1", "field2"]
22
23    @pytest.mark.django_db
24    def test_should_use_assigned_value(self, order):
25        item = models.OrderItem.objects.create(
26            order=order,
27            sequential_number=101,
28        )
29        item.refresh_from_db
30        assert item.sequential_number == 101
31
32    @pytest.mark.django_db
33    def test_should_use_start_at_as_first_sequence_number(self, order):
34        item = models.OrderItem.objects.create(
35            order=order,
36        )
37        item.refresh_from_db
38        assert item.sequential_number == 11
39
40    @pytest.mark.django_db
41    def test_should_use_max_existing_sequenc_number_incremented_with_increment(self, order, order_item):
42        item = models.OrderItem.objects.create(
43            order=order,
44        )
45        item.refresh_from_db
46        expected_sequential_number = (
47            order_item.sequential_number + models.OrderItem._meta.get_field("sequential_number").increment
48        )
49        assert item.sequential_number == expected_sequential_number
50
51
52@pytest.fixture(name="order")
53def given_order():
54    order = models.Order.objects.create()
55    yield order
56
57
58@pytest.fixture(name="order_item")
59def given_order_item(order):
60    item = models.OrderItem.objects.create(
61        order=order,
62        sequential_number=5,
63    )
64    return item