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.
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:
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:
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:
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.
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