├── tests ├── test_app │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── urls.py │ ├── views.py │ └── models.py ├── urls.py ├── settings.py ├── __init__.py ├── test_naturalkey.py └── test_rest.py ├── .gitignore ├── natural_keys ├── __init__.py ├── serializers.py └── models.py ├── LICENSE ├── pyproject.toml ├── .github └── workflows │ └── test.yml └── README.md /tests/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | *.egg 4 | *.sw? 5 | build 6 | dist 7 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | 3 | 4 | urlpatterns = [ 5 | path("", include("tests.test_app.urls")), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "1234" 2 | DATABASES = { 3 | "default": { 4 | "ENGINE": "django.db.backends.sqlite3", 5 | "NAME": ":memory:", 6 | } 7 | } 8 | ROOT_URLCONF = "tests.urls" 9 | REST_FRAMEWORK = { 10 | "UNAUTHENTICATED_USER": None, 11 | } 12 | 13 | INSTALLED_APPS = [ 14 | "tests.test_app", 15 | ] 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.test.utils import setup_test_environment 3 | import django 4 | from django.core.management import call_command 5 | 6 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 7 | setup_test_environment() 8 | django.setup() 9 | call_command("makemigrations", interactive=False) 10 | call_command("migrate", interactive=False) 11 | -------------------------------------------------------------------------------- /natural_keys/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import NaturalKeyModel, NaturalKeyModelManager, NaturalKeyQuerySet 2 | 3 | try: 4 | from .serializers import NaturalKeySerializer, NaturalKeyModelSerializer 5 | except ImportError: 6 | NaturalKeySerializer = None 7 | NaturalKeyModelSerializer = None 8 | 9 | 10 | __all__ = [ 11 | "NaturalKeyModel", 12 | "NaturalKeyModelManager", 13 | "NaturalKeySerializer", 14 | "NaturalKeyModelSerializer", 15 | "NaturalKeyQuerySet", 16 | ] 17 | -------------------------------------------------------------------------------- /tests/test_app/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from rest_framework import routers 3 | except ImportError: 4 | urlpatterns = [] 5 | else: 6 | from .views import ( 7 | NaturalKeyChildViewSet, 8 | ModelWithNaturalKeyViewSet, 9 | ModelWithSingleUniqueFieldViewSet, 10 | NaturalKeyLookupViewSet, 11 | ) 12 | 13 | router = routers.DefaultRouter() 14 | router.register(r"naturalkeychilds", NaturalKeyChildViewSet) 15 | router.register(r"modelwithnaturalkeys", ModelWithNaturalKeyViewSet) 16 | router.register( 17 | r"modelwithsingleuniquefield", ModelWithSingleUniqueFieldViewSet 18 | ) 19 | router.register( 20 | r"naturalkeylookup", NaturalKeyLookupViewSet, basename="lookup" 21 | ) 22 | 23 | urlpatterns = router.urls 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2024 S. Andrew Sheppard, https://wq.io/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | 4 | [project] 5 | name = "natural-keys" 6 | dynamic = ["version"] 7 | authors = [ 8 | {name = "S. Andrew Sheppard", email = "andrew@wq.io"}, 9 | ] 10 | description = "Enhanced support for natural keys in Django and Django REST Framework." 11 | readme = "README.md" 12 | requires-python = ">=3.8" 13 | license = {text = "MIT" } 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Web Environment", 17 | "License :: OSI Approved :: MIT License", 18 | "Natural Language :: English", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Framework :: Django", 26 | "Framework :: Django :: 4.0", 27 | "Framework :: Django :: 4.1", 28 | "Framework :: Django :: 4.2", 29 | "Framework :: Django :: 5.0", 30 | "Topic :: Database", 31 | ] 32 | dependencies = [ 33 | "html-json-forms>=1.0.0" 34 | ] 35 | 36 | [project.urls] 37 | Homepage = "https://github.com/wq/django-natural-keys" 38 | Documentation = "https://wq.io/" 39 | Source = "https://github.com/wq/django-natural-keys" 40 | "Release Notes" = "https://github.com/wq/django-natural-keys/releases" 41 | Issues = "https://github.com/wq/django-natural-keys/issues" 42 | Tests = "https://github.com/wq/django-natural-keys/actions/workflows/test.yml" 43 | 44 | [tool.setuptools_scm] 45 | -------------------------------------------------------------------------------- /tests/test_app/views.py: -------------------------------------------------------------------------------- 1 | try: 2 | from rest_framework import viewsets 3 | from rest_framework import serializers 4 | except ImportError: 5 | pass 6 | else: 7 | from .models import ( 8 | NaturalKeyChild, 9 | ModelWithNaturalKey, 10 | ModelWithSingleUniqueField, 11 | ) 12 | from natural_keys import NaturalKeySerializer, NaturalKeyModelSerializer 13 | 14 | class NaturalKeyChildViewSet(viewsets.ModelViewSet): 15 | queryset = NaturalKeyChild.objects.all() 16 | serializer_class = NaturalKeySerializer.for_model(NaturalKeyChild) 17 | 18 | class ModelWithNaturalKeyViewSet(viewsets.ModelViewSet): 19 | queryset = ModelWithNaturalKey.objects.all() 20 | serializer_class = NaturalKeyModelSerializer.for_model( 21 | ModelWithNaturalKey, 22 | include_fields="__all__", 23 | ) 24 | 25 | class ModelWithSingleUniqueFieldViewSet(viewsets.ModelViewSet): 26 | queryset = ModelWithSingleUniqueField.objects.all() 27 | serializer_class = NaturalKeyModelSerializer.for_model( 28 | ModelWithSingleUniqueField, 29 | include_fields="__all__", 30 | ) 31 | 32 | class LookupSerializer(NaturalKeyModelSerializer): 33 | id = serializers.ReadOnlyField(source="natural_key_slug") 34 | 35 | class Meta: 36 | model = NaturalKeyChild 37 | fields = "__all__" 38 | 39 | class NaturalKeyLookupViewSet(viewsets.ModelViewSet): 40 | queryset = NaturalKeyChild.objects.all() 41 | serializer_class = LookupSerializer 42 | lookup_field = "natural_key_slug" 43 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from natural_keys import NaturalKeyModel 3 | 4 | 5 | class NaturalKeyParent(NaturalKeyModel): 6 | code = models.CharField(max_length=10) 7 | group = models.CharField(max_length=10) 8 | 9 | class Meta: 10 | unique_together = ["code", "group"] 11 | 12 | 13 | class NaturalKeyChild(NaturalKeyModel): 14 | parent = models.ForeignKey( 15 | NaturalKeyParent, 16 | on_delete=models.CASCADE, 17 | null=True, 18 | blank=True, 19 | ) 20 | mode = models.CharField(max_length=10) 21 | 22 | class Meta: 23 | unique_together = ["parent", "mode"] 24 | 25 | 26 | class ModelWithNaturalKey(models.Model): 27 | key = models.ForeignKey(NaturalKeyChild, on_delete=models.CASCADE) 28 | value = models.CharField(max_length=10) 29 | 30 | 31 | class ModelWithSingleUniqueField(NaturalKeyModel): 32 | code = models.CharField(max_length=10, unique=True) 33 | 34 | 35 | class ModelWithExtraField(NaturalKeyModel): 36 | code = models.CharField(max_length=10) 37 | date = models.DateField(max_length=10) 38 | extra = models.TextField() 39 | 40 | class Meta: 41 | unique_together = ["code", "date"] 42 | 43 | 44 | class ModelWithConstraint(NaturalKeyModel): 45 | code = models.CharField(max_length=10) 46 | date = models.DateField(max_length=10) 47 | 48 | def __str__(self): 49 | return f"{self.code} {self.date}" 50 | 51 | class Meta: 52 | constraints = [ 53 | models.UniqueConstraint( 54 | name="natural key", 55 | fields=["code", "date"], 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: py=${{ matrix.python-version }} dj=${{ matrix.django-version }} drf=${{ matrix.drf-version }} 8 | runs-on: ubuntu-22.04 9 | strategy: 10 | matrix: 11 | python-version: 12 | - "3.10" 13 | - "3.11" 14 | - "3.12" 15 | django-version: [5.0.3] 16 | drf-version: [3.15.0] 17 | include: 18 | - python-version: "3.12" 19 | django-version: "5.0.3" 20 | drf-version: none 21 | - python-version: "3.9" 22 | django-version: 4.2.11 23 | drf-version: 3.14.0 24 | - python-version: "3.8" 25 | django-version: 4.2.11 26 | drf-version: 3.14.0 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | python -m pip install build 37 | python -m pip install flake8 pytest wheel 38 | python -m pip install html-json-forms 39 | python -m pip install django==${{ matrix.django-version }} 40 | - name: Install Django REST Framework 41 | if: ${{ matrix.drf-version != 'none' }} 42 | run: | 43 | python -m pip install djangorestframework==${{ matrix.drf-version }} 44 | - name: Lint with flake8 45 | run: | 46 | # stop the build if there are Python syntax errors or undefined names 47 | flake8 natural_keys --count --select=E9,F63,F7,F82 --show-source --statistics 48 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 49 | flake8 natural_keys --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 50 | - name: Test with unittest 51 | run: python -m unittest discover -s tests -t . -v 52 | - name: Test build 53 | run: python -m build 54 | -------------------------------------------------------------------------------- /tests/test_naturalkey.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from tests.test_app.models import ( 3 | NaturalKeyParent, 4 | NaturalKeyChild, 5 | ModelWithSingleUniqueField, 6 | ModelWithExtraField, 7 | ModelWithConstraint, 8 | ) 9 | from django.db.utils import IntegrityError 10 | 11 | # Tests for natural key models 12 | 13 | 14 | class NaturalKeyTestCase(TestCase): 15 | def test_naturalkey_fields(self): 16 | # Model APIs 17 | self.assertEqual( 18 | NaturalKeyParent.get_natural_key_fields(), 19 | ("code", "group"), 20 | ) 21 | self.assertEqual( 22 | NaturalKeyParent(code="code0", group="group0").natural_key(), 23 | ("code0", "group0"), 24 | ) 25 | self.assertEqual( 26 | NaturalKeyChild.get_natural_key_fields(), 27 | ("parent__code", "parent__group", "mode"), 28 | ) 29 | self.assertEqual( 30 | ModelWithSingleUniqueField.get_natural_key_fields(), 31 | ("code",), 32 | ) 33 | 34 | def test_naturalkey_create(self): 35 | # Manager create 36 | p1 = NaturalKeyParent.objects.create_by_natural_key("code1", "group1") 37 | self.assertEqual(p1.code, "code1") 38 | self.assertEqual(p1.group, "group1") 39 | 40 | # get_or_create with same key retrieve existing item 41 | p2, is_new = NaturalKeyParent.objects.get_or_create_by_natural_key( 42 | "code1", "group1" 43 | ) 44 | self.assertFalse(is_new) 45 | self.assertEqual(p1.pk, p2.pk) 46 | 47 | # Shortcut version 48 | p3 = NaturalKeyParent.objects.find("code1", "group1") 49 | self.assertEqual(p1.pk, p3.pk) 50 | 51 | p4 = ModelWithSingleUniqueField.objects.create_by_natural_key("code4") 52 | self.assertEqual(p4.code, "code4") 53 | 54 | def test_naturalkey_nested_create(self): 55 | # Manager create, with nested natural key 56 | c1 = NaturalKeyChild.objects.create_by_natural_key( 57 | "code2", "group2", "mode1" 58 | ) 59 | self.assertEqual(c1.parent.code, "code2") 60 | self.assertEqual(c1.parent.group, "group2") 61 | self.assertEqual(c1.mode, "mode1") 62 | 63 | # create with same nested key should not create a new parent 64 | c2 = NaturalKeyChild.objects.create_by_natural_key( 65 | "code2", "group2", "mode2" 66 | ) 67 | self.assertEqual(c1.parent.pk, c2.parent.pk) 68 | self.assertEqual(c2.mode, "mode2") 69 | 70 | def test_naturalkey_duplicate(self): 71 | # Manager create, with duplicate 72 | NaturalKeyParent.objects.create_by_natural_key("code1", "group1") 73 | # create with same key should fail 74 | with self.assertRaises(IntegrityError): 75 | NaturalKeyParent.objects.create_by_natural_key("code1", "group1") 76 | 77 | def test_filter_with_Q(self): 78 | from django.db.models import Q 79 | 80 | query = Q(code="bizarre") 81 | self.assertEqual( 82 | ModelWithSingleUniqueField.objects.filter(query).count(), 0 83 | ) 84 | 85 | def test_find_with_defaults(self): 86 | obj = ModelWithExtraField.objects.find( 87 | "extra1", 88 | "2019-07-26", 89 | defaults={"extra": "Test 123"}, 90 | ) 91 | self.assertEqual(obj.extra, "Test 123") 92 | 93 | def test_find_with_kwargs(self): 94 | with self.assertRaises(TypeError) as e: 95 | ModelWithExtraField.objects.find( 96 | "extra1", 97 | date="2019-07-26", 98 | ) 99 | msg = str(e.exception).replace("NaturalKeyModelManager.", "") 100 | self.assertEqual( 101 | msg, "find() got an unexpected keyword argument 'date'" 102 | ) 103 | 104 | def test_find_with_constraint(self): 105 | obj = ModelWithConstraint.objects.find( 106 | "constraint1", 107 | "2021-08-23", 108 | ) 109 | self.assertEqual(str(obj), "constraint1 2021-08-23") 110 | 111 | def test_null_foreign_key(self): 112 | obj = NaturalKeyChild.objects.create(mode="mode0") 113 | self.assertEqual( 114 | obj.natural_key(), 115 | ( 116 | None, 117 | None, 118 | "mode0", 119 | ), 120 | ) 121 | self.assertEqual( 122 | NaturalKeyChild.objects.get_by_natural_key( 123 | None, 124 | None, 125 | "mode0", 126 | ), 127 | obj, 128 | ) 129 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-06-15 12:37 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="ModelWithConstraint", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("code", models.CharField(max_length=10)), 27 | ("date", models.DateField(max_length=10)), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name="ModelWithSingleUniqueField", 32 | fields=[ 33 | ( 34 | "id", 35 | models.AutoField( 36 | auto_created=True, 37 | primary_key=True, 38 | serialize=False, 39 | verbose_name="ID", 40 | ), 41 | ), 42 | ("code", models.CharField(max_length=10, unique=True)), 43 | ], 44 | options={ 45 | "abstract": False, 46 | }, 47 | ), 48 | migrations.CreateModel( 49 | name="NaturalKeyParent", 50 | fields=[ 51 | ( 52 | "id", 53 | models.AutoField( 54 | auto_created=True, 55 | primary_key=True, 56 | serialize=False, 57 | verbose_name="ID", 58 | ), 59 | ), 60 | ("code", models.CharField(max_length=10)), 61 | ("group", models.CharField(max_length=10)), 62 | ], 63 | options={ 64 | "unique_together": {("code", "group")}, 65 | }, 66 | ), 67 | migrations.CreateModel( 68 | name="NaturalKeyChild", 69 | fields=[ 70 | ( 71 | "id", 72 | models.AutoField( 73 | auto_created=True, 74 | primary_key=True, 75 | serialize=False, 76 | verbose_name="ID", 77 | ), 78 | ), 79 | ("mode", models.CharField(max_length=10)), 80 | ( 81 | "parent", 82 | models.ForeignKey( 83 | blank=True, 84 | null=True, 85 | on_delete=django.db.models.deletion.CASCADE, 86 | to="test_app.naturalkeyparent", 87 | ), 88 | ), 89 | ], 90 | ), 91 | migrations.CreateModel( 92 | name="ModelWithNaturalKey", 93 | fields=[ 94 | ( 95 | "id", 96 | models.AutoField( 97 | auto_created=True, 98 | primary_key=True, 99 | serialize=False, 100 | verbose_name="ID", 101 | ), 102 | ), 103 | ("value", models.CharField(max_length=10)), 104 | ( 105 | "key", 106 | models.ForeignKey( 107 | on_delete=django.db.models.deletion.CASCADE, 108 | to="test_app.naturalkeychild", 109 | ), 110 | ), 111 | ], 112 | ), 113 | migrations.CreateModel( 114 | name="ModelWithExtraField", 115 | fields=[ 116 | ( 117 | "id", 118 | models.AutoField( 119 | auto_created=True, 120 | primary_key=True, 121 | serialize=False, 122 | verbose_name="ID", 123 | ), 124 | ), 125 | ("code", models.CharField(max_length=10)), 126 | ("date", models.DateField(max_length=10)), 127 | ("extra", models.TextField()), 128 | ], 129 | options={ 130 | "unique_together": {("code", "date")}, 131 | }, 132 | ), 133 | migrations.AddConstraint( 134 | model_name="modelwithconstraint", 135 | constraint=models.UniqueConstraint( 136 | fields=("code", "date"), name="natural key" 137 | ), 138 | ), 139 | migrations.AlterUniqueTogether( 140 | name="naturalkeychild", 141 | unique_together={("parent", "mode")}, 142 | ), 143 | ] 144 | -------------------------------------------------------------------------------- /natural_keys/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.utils import model_meta 3 | from rest_framework.validators import UniqueValidator 4 | from html_json_forms.serializers import JSONFormModelSerializer 5 | from .models import NaturalKeyModel 6 | from collections import OrderedDict 7 | 8 | 9 | class NaturalKeyValidator(serializers.UniqueTogetherValidator): 10 | def set_context(self, serializer): 11 | if getattr(self, "requires_context", None): 12 | # DRF 3.11+ 13 | pass 14 | else: 15 | # DRF 3.10 and older 16 | self.serializer = serializer 17 | super(NaturalKeyValidator, self).set_context(serializer) 18 | 19 | def filter_queryset(self, attrs, queryset, serializer=None): 20 | if not serializer: 21 | # DRF 3.10 and older 22 | serializer = self.serializer 23 | 24 | nested_fields = { 25 | name: serializer.fields[name] 26 | for name in self.fields 27 | if isinstance(serializer.fields[name], NaturalKeySerializer) 28 | } 29 | 30 | attrs = attrs.copy() 31 | for field in attrs: 32 | if field in nested_fields: 33 | assert isinstance(attrs[field], dict) 34 | cls = nested_fields[field].Meta.model 35 | result = cls._default_manager.filter(**attrs[field]) 36 | if result.count() == 0: 37 | # No existing nested object for these values 38 | return queryset.none() 39 | else: 40 | # Existing nested object, use it to validate 41 | attrs[field] = result[0].pk 42 | 43 | if getattr(self, "requires_context", None): 44 | # DRF 3.11+ 45 | return super(NaturalKeyValidator, self).filter_queryset( 46 | attrs, queryset, serializer 47 | ) 48 | else: 49 | # DRF 3.10 and older 50 | return super(NaturalKeyValidator, self).filter_queryset( 51 | attrs, queryset 52 | ) 53 | 54 | 55 | class NaturalKeySerializer(JSONFormModelSerializer): 56 | """ 57 | Self-nesting Serializer for NaturalKeyModels 58 | """ 59 | 60 | def build_standard_field(self, field_name, model_field): 61 | field_class, field_kwargs = super( 62 | NaturalKeySerializer, self 63 | ).build_standard_field(field_name, model_field) 64 | 65 | if "validators" in field_kwargs: 66 | field_kwargs["validators"] = [ 67 | validator 68 | for validator in field_kwargs.get("validators", []) 69 | if not isinstance(validator, UniqueValidator) 70 | ] 71 | return field_class, field_kwargs 72 | 73 | def build_nested_field(self, field_name, relation_info, nested_depth): 74 | field_class = NaturalKeySerializer.for_model( 75 | relation_info.related_model, 76 | validate_key=False, 77 | ) 78 | field_kwargs = {} 79 | return field_class, field_kwargs 80 | 81 | def create(self, validated_data): 82 | model_class = self.Meta.model 83 | natural_key_fields = model_class.get_natural_key_fields() 84 | natural_key = [] 85 | for field in natural_key_fields: 86 | val = validated_data 87 | for key in field.split("__"): 88 | val = val[key] 89 | natural_key.append(val) 90 | return model_class.objects.find(*natural_key) 91 | 92 | def update(self, instance, validated_data): 93 | raise NotImplementedError( 94 | "Updating an existing natural key is not supported." 95 | ) 96 | 97 | @classmethod 98 | def for_model(cls, model_class, validate_key=True, include_fields=None): 99 | unique_together = model_class.get_natural_key_def() 100 | if include_fields and list(include_fields) != list(unique_together): 101 | raise NotImplementedError( 102 | "NaturalKeySerializer for '%s' has unique_together = [%s], " 103 | "but provided include_fields = [%s]" 104 | % ( 105 | model_class._meta.model_name, 106 | ", ".join(unique_together), 107 | ", ".join(include_fields), 108 | ) 109 | ) 110 | 111 | class Serializer(cls): 112 | class Meta(cls.Meta): 113 | model = model_class 114 | fields = unique_together 115 | if validate_key: 116 | validators = [ 117 | NaturalKeyValidator( 118 | queryset=model_class._default_manager, 119 | fields=unique_together, 120 | ) 121 | ] 122 | else: 123 | validators = [] 124 | 125 | return Serializer 126 | 127 | @classmethod 128 | def for_depth(cls, model_class): 129 | return cls 130 | 131 | class Meta: 132 | depth = 1 133 | 134 | 135 | class NaturalKeyModelSerializer(JSONFormModelSerializer): 136 | """ 137 | Serializer for models with one or more foreign keys to a NaturalKeyModel 138 | """ 139 | 140 | def is_natural_key_model(self, related_model): 141 | return issubclass(related_model, NaturalKeyModel) 142 | 143 | def build_nested_field(self, field_name, relation_info, nested_depth): 144 | if self.is_natural_key_model(relation_info.related_model): 145 | field_class = NaturalKeySerializer.for_model( 146 | relation_info.related_model, 147 | validate_key=False, 148 | ) 149 | field_kwargs = {} 150 | if relation_info.model_field.null: 151 | field_kwargs["required"] = False 152 | return field_class, field_kwargs 153 | 154 | return super(NaturalKeyModelSerializer, self).build_nested_field( 155 | field_name, relation_info, nested_depth 156 | ) 157 | 158 | def build_relational_field(self, field_name, relation_info): 159 | field_class, field_kwargs = super( 160 | NaturalKeyModelSerializer, self 161 | ).build_relational_field(field_name, relation_info) 162 | if self.is_natural_key_model(relation_info.related_model): 163 | field_kwargs.pop("queryset") 164 | field_kwargs["read_only"] = True 165 | return field_class, field_kwargs 166 | 167 | def get_fields(self): 168 | fields = super(NaturalKeyModelSerializer, self).get_fields() 169 | fields.update(self.build_natural_key_fields()) 170 | return fields 171 | 172 | def build_natural_key_fields(self): 173 | info = model_meta.get_field_info(self.Meta.model) 174 | fields = OrderedDict() 175 | for field, relation_info in info.relations.items(): 176 | if relation_info.reverse or relation_info.to_many: 177 | continue 178 | if not self.is_natural_key_model(relation_info.related_model): 179 | continue 180 | field_class, field_kwargs = self.build_nested_field( 181 | field, relation_info, 1 182 | ) 183 | fields[field] = field_class(**field_kwargs) 184 | return fields 185 | 186 | def create(self, validated_data): 187 | self.convert_natural_keys(validated_data) 188 | return super(NaturalKeyModelSerializer, self).create(validated_data) 189 | 190 | def update(self, instance, validated_data): 191 | self.convert_natural_keys(validated_data) 192 | return super(NaturalKeyModelSerializer, self).update( 193 | instance, validated_data 194 | ) 195 | 196 | def convert_natural_keys(self, validated_data): 197 | fields = self.get_fields() 198 | for name, field in fields.items(): 199 | if name not in validated_data: 200 | continue 201 | if isinstance(field, NaturalKeySerializer): 202 | validated_data[name] = fields[name].create( 203 | validated_data[name] 204 | ) 205 | 206 | @classmethod 207 | def for_model(cls, model_class, include_fields=None): 208 | # c.f. wq.db.rest.serializers.ModelSerializer 209 | class Serializer(cls): 210 | class Meta(cls.Meta): 211 | model = model_class 212 | if include_fields: 213 | fields = include_fields 214 | 215 | return Serializer 216 | 217 | class Meta: 218 | pass 219 | -------------------------------------------------------------------------------- /tests/test_rest.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | try: 4 | from rest_framework.test import APITestCase 5 | except ImportError: 6 | raise unittest.SkipTest("Skipping DRF tests as DRF is not installed.") 7 | 8 | from rest_framework import status 9 | from tests.test_app.models import ( 10 | NaturalKeyChild, 11 | ModelWithNaturalKey, 12 | ModelWithSingleUniqueField, 13 | ) 14 | from natural_keys import NaturalKeySerializer 15 | 16 | # Tests for natural key DRF integration 17 | 18 | 19 | class NaturalKeyRestTestCase(APITestCase): 20 | def test_naturalkey_rest_serializer(self): 21 | # Serializer should include validator 22 | serializer = NaturalKeySerializer.for_model(NaturalKeyChild)() 23 | expect = """ 24 | Serializer(): 25 | parent = Serializer(): 26 | code = CharField(max_length=10) 27 | group = CharField(max_length=10) 28 | mode = CharField(max_length=10) 29 | class Meta: 30 | validators = []""".replace( 31 | " ", "" 32 | )[ 33 | 1: 34 | ] # noqa 35 | self.assertEqual(expect, str(serializer)) 36 | 37 | fields = serializer.get_fields() 38 | self.assertTrue(fields["parent"].required) 39 | self.assertTrue(fields["mode"].required) 40 | self.assertTrue(fields["parent"].get_fields()["code"].required) 41 | 42 | def test_naturalkey_rest_singleunique(self): 43 | # Serializer should only have single top-level validator 44 | serializer = NaturalKeySerializer.for_model( 45 | ModelWithSingleUniqueField 46 | )() 47 | expect = """ 48 | Serializer(): 49 | code = CharField(max_length=10, validators=[]) 50 | class Meta: 51 | validators = []""".replace( 52 | " ", "" 53 | )[ 54 | 1: 55 | ] # noqa 56 | self.assertEqual(expect, str(serializer)) 57 | 58 | fields = serializer.get_fields() 59 | self.assertTrue(fields["code"].required) 60 | 61 | def test_naturalkey_rest_post(self): 62 | # Posting a compound natural key should work 63 | form = { 64 | "mode": "mode3a", 65 | "parent[code]": "code3", 66 | "parent[group]": "group3", 67 | } 68 | response = self.client.post("/naturalkeychilds.json", form) 69 | self.assertEqual( 70 | response.status_code, status.HTTP_201_CREATED, response.data 71 | ) 72 | self.assertEqual(response.data["mode"], "mode3a") 73 | self.assertEqual(response.data["parent"]["code"], "code3") 74 | self.assertEqual(response.data["parent"]["group"], "group3") 75 | 76 | # Posting a simple natural key should work 77 | form = { 78 | "code": "code9", 79 | } 80 | response = self.client.post("/modelwithsingleuniquefield.json", form) 81 | self.assertEqual( 82 | response.status_code, status.HTTP_201_CREATED, response.data 83 | ) 84 | self.assertEqual(response.data["code"], "code9") 85 | 86 | # Posting same nested natural key should reuse nested object 87 | form = { 88 | "mode": "mode3b", 89 | "parent[code]": "code3", 90 | "parent[group]": "group3", 91 | } 92 | response = self.client.post("/naturalkeychilds.json", form) 93 | self.assertEqual( 94 | response.status_code, status.HTTP_201_CREATED, response.data 95 | ) 96 | self.assertEqual( 97 | NaturalKeyChild.objects.get(mode="mode3a").parent.pk, 98 | NaturalKeyChild.objects.get(mode="mode3b").parent.pk, 99 | ) 100 | 101 | def test_naturalkey_rest_duplicate(self): 102 | # Posting identical compound natural key should fail 103 | form = { 104 | "mode": "mode3c", 105 | "parent[code]": "code3", 106 | "parent[group]": "group3", 107 | } 108 | response = self.client.post("/naturalkeychilds.json", form) 109 | self.assertEqual( 110 | response.status_code, status.HTTP_201_CREATED, response.data 111 | ) 112 | form = { 113 | "mode": "mode3c", 114 | "parent[code]": "code3", 115 | "parent[group]": "group3", 116 | } 117 | response = self.client.post("/naturalkeychilds.json", form) 118 | self.assertEqual( 119 | response.status_code, status.HTTP_400_BAD_REQUEST, response.data 120 | ) 121 | self.assertEqual( 122 | response.data, 123 | { 124 | "non_field_errors": [ 125 | "The fields parent, mode must make a unique set." 126 | ] 127 | }, 128 | ) 129 | 130 | # Posting identical simple natural key should fail 131 | form = { 132 | "code": "code8", 133 | } 134 | response = self.client.post("/modelwithsingleuniquefield.json", form) 135 | self.assertEqual( 136 | response.status_code, status.HTTP_201_CREATED, response.data 137 | ) 138 | form = { 139 | "code": "code8", 140 | } 141 | response = self.client.post("/modelwithsingleuniquefield.json", form) 142 | self.assertEqual( 143 | response.status_code, status.HTTP_400_BAD_REQUEST, response.data 144 | ) 145 | self.assertEqual( 146 | response.data, 147 | { 148 | "code": [ 149 | "model with single unique field " 150 | "with this code already exists." 151 | ] 152 | }, 153 | ) 154 | 155 | def test_naturalkey_rest_nested_post(self): 156 | # Posting a regular model with a ref to natural key 157 | form = { 158 | "key[mode]": "mode4", 159 | "key[parent][code]": "code4", 160 | "key[parent][group]": "group4", 161 | "value": 5, 162 | } 163 | response = self.client.post("/modelwithnaturalkeys.json", form) 164 | self.assertEqual( 165 | response.status_code, status.HTTP_201_CREATED, response.data 166 | ) 167 | self.assertEqual(response.data["key"]["mode"], "mode4") 168 | self.assertEqual(response.data["key"]["parent"]["code"], "code4") 169 | self.assertEqual(response.data["key"]["parent"]["group"], "group4") 170 | 171 | def test_naturalkey_rest_nested_put(self): 172 | # Updating a regular model with a ref to natural key 173 | instance = ModelWithNaturalKey.objects.create( 174 | key=NaturalKeyChild.objects.find("code5", "group5", "mode5"), 175 | value=7, 176 | ) 177 | self.assertEqual(instance.key.parent.code, "code5") 178 | 179 | # Updating with same natural key should reuse it 180 | form = { 181 | "key[mode]": "mode5", 182 | "key[parent][code]": "code5", 183 | "key[parent][group]": "group5", 184 | "value": 8, 185 | } 186 | self.assertEqual(NaturalKeyChild.objects.count(), 1) 187 | 188 | # Updating with new natural key should create it 189 | response = self.client.put( 190 | "/modelwithnaturalkeys/%s.json" % instance.pk, form 191 | ) 192 | form = { 193 | "key[mode]": "mode6", 194 | "key[parent][code]": "code6", 195 | "key[parent][group]": "group6", 196 | "value": 9, 197 | } 198 | response = self.client.put( 199 | "/modelwithnaturalkeys/%s.json" % instance.pk, form 200 | ) 201 | self.assertEqual( 202 | response.status_code, status.HTTP_200_OK, response.data 203 | ) 204 | self.assertEqual(response.data["key"]["mode"], "mode6") 205 | self.assertEqual(response.data["key"]["parent"]["code"], "code6") 206 | self.assertEqual(response.data["key"]["parent"]["group"], "group6") 207 | self.assertEqual(NaturalKeyChild.objects.count(), 2) 208 | 209 | def test_naturalkey_lookup(self): 210 | # Support natural_key_slug as lookup_field setting 211 | NaturalKeyChild.objects.find("code7", "group7", "mode7") 212 | response = self.client.get( 213 | "/naturalkeylookup/code7-group7-mode7.json", 214 | ) 215 | self.assertEqual( 216 | response.status_code, status.HTTP_200_OK, response.data 217 | ) 218 | self.assertEqual(response.data["id"], "code7-group7-mode7") 219 | 220 | def test_naturalkey_lookup_slug(self): 221 | # Support separator in slug (but only for last part of key) 222 | NaturalKeyChild.objects.find("code7", "group7", "mode7-alt") 223 | response = self.client.get( 224 | "/naturalkeylookup/code7-group7-mode7-alt.json", 225 | ) 226 | self.assertEqual( 227 | response.status_code, status.HTTP_200_OK, response.data 228 | ) 229 | self.assertEqual(response.data["id"], "code7-group7-mode7-alt") 230 | 231 | def test_invalid_slug_404(self): 232 | response = self.client.get( 233 | "/naturalkeylookup/not-valid.json", 234 | ) 235 | self.assertEqual( 236 | status.HTTP_404_NOT_FOUND, 237 | response.status_code, 238 | ) 239 | -------------------------------------------------------------------------------- /natural_keys/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from functools import reduce 3 | 4 | 5 | class NaturalKeyQuerySet(models.QuerySet): 6 | def filter(self, *args, **kwargs): 7 | natural_key_slug = kwargs.pop("natural_key_slug", None) 8 | if natural_key_slug and type(natural_key_slug) is str: 9 | slugs = natural_key_slug.split(self.model.natural_key_separator) 10 | fields = self.model.get_natural_key_fields() 11 | if len(slugs) > len(fields): 12 | slugs[len(fields) - 1 :] = [ 13 | self.model.natural_key_separator.join( 14 | slugs[len(fields) - 1 :] 15 | ) 16 | ] 17 | elif len(slugs) < len(fields): 18 | return self.none() 19 | kwargs.update(self.natural_key_kwargs(*slugs)) 20 | 21 | return super(NaturalKeyQuerySet, self).filter(*args, **kwargs) 22 | 23 | def natural_key_kwargs(self, *args): 24 | natural_key = self.model.get_natural_key_fields() 25 | if len(args) != len(natural_key): 26 | raise TypeError( 27 | "Wrong number of values, expected %s" % len(natural_key) 28 | ) 29 | return dict(zip(natural_key, args)) 30 | 31 | 32 | class NaturalKeyModelManager(models.Manager): 33 | """ 34 | Manager for use with subclasses of NaturalKeyModel. 35 | """ 36 | 37 | def get_queryset(self): 38 | return NaturalKeyQuerySet(self.model, using=self._db) 39 | 40 | def get_by_natural_key(self, *args): 41 | """ 42 | Return the object corresponding to the provided natural key. 43 | 44 | (This is a generic implementation of the standard Django function) 45 | """ 46 | 47 | kwargs = self.natural_key_kwargs(*args) 48 | 49 | # Since kwargs already has __ lookups in it, we could just do this: 50 | # return self.get(**kwargs) 51 | 52 | # But, we should call each related model's get_by_natural_key in case 53 | # it's been overridden 54 | for name, rel_to in self.model.get_natural_key_info(): 55 | if not rel_to: 56 | continue 57 | 58 | # Extract natural key for related object 59 | nested_key = extract_nested_key(kwargs, rel_to, name) 60 | if nested_key: 61 | # Update kwargs with related object 62 | try: 63 | kwargs[name] = rel_to.objects.get_by_natural_key( 64 | *nested_key 65 | ) 66 | except rel_to.DoesNotExist: 67 | # If related object doesn't exist, assume this one doesn't 68 | raise self.model.DoesNotExist() 69 | else: 70 | kwargs[name] = None 71 | 72 | return self.get(**kwargs) 73 | 74 | def create_by_natural_key(self, *args, defaults=None): 75 | """ 76 | Create a new object from the provided natural key values. If the 77 | natural key contains related objects, recursively get or create them by 78 | their natural keys. 79 | """ 80 | 81 | kwargs = self.natural_key_kwargs(*args) 82 | for name, rel_to in self.model.get_natural_key_info(): 83 | if not rel_to: 84 | continue 85 | nested_key = extract_nested_key(kwargs, rel_to, name) 86 | # Automatically create any related objects as needed 87 | if nested_key: 88 | ( 89 | kwargs[name], 90 | is_new, 91 | ) = rel_to.objects.get_or_create_by_natural_key(*nested_key) 92 | else: 93 | kwargs[name] = None 94 | if defaults: 95 | attrs = defaults 96 | attrs.update(kwargs) 97 | else: 98 | attrs = kwargs 99 | return self.create(**attrs) 100 | 101 | def get_or_create_by_natural_key(self, *args, defaults=None): 102 | """ 103 | get_or_create + get_by_natural_key 104 | """ 105 | try: 106 | return ( 107 | self.get_by_natural_key(*args), 108 | False, 109 | ) 110 | except self.model.DoesNotExist: 111 | return ( 112 | self.create_by_natural_key( 113 | *args, 114 | defaults=defaults, 115 | ), 116 | True, 117 | ) 118 | 119 | # Shortcut for common use case 120 | def find(self, *args, defaults=None): 121 | """ 122 | Shortcut for get_or_create_by_natural_key that discards the "created" 123 | boolean. 124 | """ 125 | obj, is_new = self.get_or_create_by_natural_key( 126 | *args, 127 | defaults=defaults, 128 | ) 129 | return obj 130 | 131 | def natural_key_kwargs(self, *args): 132 | """ 133 | Convert args into kwargs by merging with model's natural key fieldnames 134 | """ 135 | return self.get_queryset().natural_key_kwargs(*args) 136 | 137 | def resolve_keys(self, keys, auto_create=False): 138 | """ 139 | Resolve the list of given keys into objects, if possible. 140 | Returns a mapping and a success indicator. 141 | """ 142 | resolved = {} 143 | success = True 144 | for key in keys: 145 | if auto_create: 146 | resolved[key] = self.find(*key) 147 | else: 148 | try: 149 | resolved[key] = self.get_by_natural_key(*key) 150 | except self.model.DoesNotExist: 151 | success = False 152 | resolved[key] = None 153 | return resolved, success 154 | 155 | 156 | class NaturalKeyModel(models.Model): 157 | """ 158 | Abstract class with a generic implementation of natural_key. 159 | """ 160 | 161 | objects = NaturalKeyModelManager() 162 | 163 | @classmethod 164 | def get_natural_key_info(cls): 165 | """ 166 | Derive natural key from first unique_together definition, noting which 167 | fields are related objects vs. regular fields. 168 | """ 169 | fields = cls.get_natural_key_def() 170 | info = [] 171 | for name in fields: 172 | field = cls._meta.get_field(name) 173 | rel_to = None 174 | if hasattr(field, "rel"): 175 | rel_to = field.rel.to if field.rel else None 176 | elif hasattr(field, "remote_field"): 177 | if field.remote_field: 178 | rel_to = field.remote_field.model 179 | else: 180 | rel_to = None 181 | info.append((name, rel_to)) 182 | return info 183 | 184 | @classmethod 185 | def get_natural_key_def(cls): 186 | if hasattr(cls, "_natural_key"): 187 | return cls._natural_key 188 | 189 | for constraint in cls._meta.constraints: 190 | if isinstance(constraint, models.UniqueConstraint): 191 | return constraint.fields 192 | 193 | if cls._meta.unique_together: 194 | return cls._meta.unique_together[0] 195 | 196 | unique = [ 197 | f 198 | for f in cls._meta.fields 199 | if f.unique 200 | and f.__class__.__name__ not in ["AutoField", "BigAutoField"] 201 | ] 202 | if unique: 203 | return (unique[0].name,) 204 | 205 | raise Exception("Add a UniqueConstraint to use natural-keys") 206 | 207 | @classmethod 208 | def get_natural_key_fields(cls): 209 | """ 210 | Determine actual natural key field list, incorporating the natural keys 211 | of related objects as needed. 212 | """ 213 | natural_key = [] 214 | for name, rel_to in cls.get_natural_key_info(): 215 | if not rel_to: 216 | natural_key.append(name) 217 | else: 218 | nested_key = rel_to.get_natural_key_fields() 219 | natural_key.extend( 220 | [name + "__" + nname for nname in nested_key] 221 | ) 222 | return tuple(natural_key) 223 | 224 | def natural_key(self): 225 | """ 226 | Return the natural key for this object. 227 | 228 | (This is a generic implementation of the standard Django function) 229 | """ 230 | return tuple( 231 | self.get_natural_key_value(field) 232 | for field in self.get_natural_key_fields() 233 | ) 234 | 235 | def get_natural_key_value(self, field): 236 | # Recursively extract properties from related objects if needed 237 | obj = self 238 | for part in field.split("__"): 239 | obj = getattr(obj, part) 240 | if obj is None: 241 | return None 242 | return obj 243 | 244 | natural_key_separator = "-" 245 | 246 | @property 247 | def natural_key_slug(self): 248 | return self.natural_key_separator.join( 249 | str(slug) for slug in self.natural_key() 250 | ) 251 | 252 | class Meta: 253 | abstract = True 254 | 255 | 256 | def extract_nested_key(key, cls, prefix=""): 257 | nested_key = cls.get_natural_key_fields() 258 | local_fields = {field.name: field for field in cls._meta.local_fields} 259 | values = [] 260 | has_val = False 261 | if prefix: 262 | prefix += "__" 263 | for nname in nested_key: 264 | val = key.pop(prefix + nname, None) 265 | if val is None and nname in local_fields: 266 | if type(local_fields[nname]).__name__ == "DateTimeField": 267 | date = key.pop(nname + "_date", None) 268 | time = key.pop(nname + "_time", None) 269 | if date and time: 270 | val = "%s %s" % (date, time) 271 | 272 | if val is not None: 273 | has_val = True 274 | values.append(val) 275 | if has_val: 276 | return values 277 | else: 278 | return None 279 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Natural Keys 2 | 3 | Enhanced support for [natural keys] in Django and [Django REST Framework]. Extracted from [wq.db] for general use. 4 | 5 | *Django Natural Keys* provides a number of useful model methods (e.g. `get_or_create_by_natural_key()`) that speed up working with natural keys in Django. The module also provides a couple of serializer classes that streamline creating REST API support for models with natural keys. 6 | 7 | [![Latest PyPI Release](https://img.shields.io/pypi/v/natural-keys.svg)](https://pypi.org/project/natural-keys/) 8 | [![Release Notes](https://img.shields.io/github/release/wq/django-natural-keys.svg)](https://github.com/wq/django-natural-keys/releases) 9 | [![License](https://img.shields.io/pypi/l/natural-keys.svg)](https://github.com/wq/django-natural-keys/blob/main/LICENSE) 10 | [![GitHub Stars](https://img.shields.io/github/stars/wq/django-natural-keys.svg)](https://github.com/wq/django-natural-keys/stargazers) 11 | [![GitHub Forks](https://img.shields.io/github/forks/wq/django-natural-keys.svg)](https://github.com/wq/django-natural-keys/network) 12 | [![GitHub Issues](https://img.shields.io/github/issues/wq/django-natural-keys.svg)](https://github.com/wq/django-natural-keys/issues) 13 | 14 | [![Tests](https://github.com/wq/django-natural-keys/actions/workflows/test.yml/badge.svg)](https://github.com/wq/django-natural-keys/actions/workflows/test.yml) 15 | [![Python Support](https://img.shields.io/pypi/pyversions/natural-keys.svg)](https://pypi.org/project/natural-keys/) 16 | [![Django Support](https://img.shields.io/pypi/djversions/natural-keys.svg)](https://pypi.org/project/natural-keys/) 17 | 18 | 19 | ## Usage 20 | 21 | *Django Natural Keys* is available via PyPI: 22 | 23 | ```bash 24 | # Recommended: create virtual environment 25 | # python3 -m venv venv 26 | # . venv/bin/activate 27 | pip install natural-keys 28 | ``` 29 | 30 | ### Model API 31 | 32 | To use [natural keys] in vanilla Django, you need to define a `natural_key()` method on your Model class and a `get_natural_key()` method on the Manager class. With *Django Natural Keys*, you can instead extend `NaturalKeyModel` and define one of the following: 33 | 34 | * A [`UniqueConstraint`][UniqueConstraint] in `Meta.constraints` (recommended), 35 | * A tuple in [`Meta.unique_together`][unique_together], or 36 | * A [model field][unique] (other than `AutoField`) with `unique=True` 37 | 38 | The first unique constraint found will be treated as the natural key for the model, and all of the necessary functions for working with natural keys will automatically work. 39 | 40 | ```python 41 | from natural_keys import NaturalKeyModel 42 | 43 | class Event(NaturalKeyModel): 44 | name = models.CharField(max_length=255) 45 | date = models.DateField() 46 | class Meta: 47 | constraints = [ 48 | models.UniqueConstraint( 49 | fields=('name', 'date'), 50 | name='event_natural_key', 51 | ) 52 | ] 53 | 54 | class Note(models.Model): 55 | event = models.ForeignKey(Event) 56 | note = models.TextField() 57 | ``` 58 | or 59 | ```python 60 | from natural_keys import NaturalKeyModel 61 | 62 | class Event(NaturalKeyModel): 63 | name = models.CharField(unique=True) 64 | ``` 65 | 66 | The following methods will then be available on your Model and its Manager: 67 | 68 | ```python 69 | # Default Django methods 70 | instance = Event.objects.get_by_natural_key('ABC123', date(2016, 1, 1)) 71 | instance.natural_key == ('ABC123', date(2016, 1, 1)) 72 | 73 | # get_or_create + natural keys 74 | instance, is_new = Event.objects.get_or_create_by_natural_key('ABC123', date(2016, 1, 1)) 75 | 76 | # Like get_or_create_by_natural_key, but discards is_new 77 | # Useful for quick lookup/creation when you don't care whether the object exists already 78 | instance = Event.objects.find('ABC123', date(2016, 1, 1)) 79 | note = Note.objects.create( 80 | event=Event.objects.find('ABC123', date(2016, 1, 1)), 81 | note="This is a note" 82 | ) 83 | instance == note.event 84 | 85 | # Inspect natural key fields on a model without instantiating it 86 | Event.get_natural_key_fields() == ('name', 'date') 87 | ``` 88 | 89 | #### Nested Natural Keys 90 | One key feature of *Django Natural Keys* is that it will automatically traverse `ForeignKey`s to related models (which should also be `NaturalKeyModel` classes). This makes it possible to define complex, arbitrarily nested natural keys with minimal effort. 91 | 92 | ```python 93 | class Place(NaturalKeyModel): 94 | name = models.CharField(max_length=255, unique=True) 95 | 96 | class Event(NaturalKeyModel): 97 | place = models.ForeignKey(Place) 98 | date = models.DateField() 99 | class Meta: 100 | constraints = [ 101 | models.UniqueConstraint( 102 | fields=('place', 'date'), 103 | name='event_natural_key', 104 | ) 105 | ] 106 | ``` 107 | 108 | ```python 109 | Event.get_natural_key_fields() == ('place__name', 'date') 110 | instance = Event.find('ABC123', date(2016, 1, 1)) 111 | instance.place.name == 'ABC123' 112 | ``` 113 | 114 | ### REST Framework Support 115 | *Django Natural Keys* provides several integrations with [Django REST Framework], primarily through custom Serializer classes. In most cases, you will want to use either: 116 | * `NaturalKeyModelSerializer`, or 117 | * The `natural_key_slug` pseudo-field (see below) 118 | 119 | If you have only a single model with a single char field for its natural key, you probably do not need to use either of these integrations. In your view, you can just use Django REST Framework's built in `lookup_field` to point directly to your natural key. 120 | 121 | #### `NaturalKeyModelSerializer` 122 | `NaturalKeyModelSerializer` facilitates handling complex natural keys in your rest API. It can be used with a `NaturalKeyModel`, or (more commonly) a model that has a foreign key to a `NaturalKeyModel` but is not a `NaturalKeyModel` itself. (One concrete example of this is the [vera.Report] model, which has a ForeignKey to [vera.Event], which is a `NaturalKeyModel`). 123 | 124 | `NaturalKeyModelSerializer` extends DRF's [ModelSerializer], but uses `NaturalKeySerializer` for each foreign key that points to a `NaturalKeyModel`. When `update()` or `create()`ing the primary model, the nested `NaturalKeySerializer`s will automatically create instances of referenced models if they do not exist already (via the `find()` method described above). Note that `NaturalKeyModelSerializer` does not override DRF's default behavior for other fields, whether or not they form part of the primary model's natural key. 125 | 126 | `NaturalKeySerializer` can technically be used as a top level serializer, though this is not recommended. `NaturalKeySerializer` is designed for dealing with nested natural keys and does not support updates or non-natural key fields. Even when used together with `NaturalKeyModelSerializer`, `NaturalKeySerializer` never updates an existing related model instance. Instead, it will repoint the foreign key to another (potentially new) instance of the related model. It may help to think of `NaturalKeySerializer` as a special [RelatedField] class rather than as a `Serializer` per se. 127 | 128 | 129 | You can use `NaturalKeyModelSerializer` with [Django REST Framework] and/or [wq.db] just like any other serializer: 130 | ```python 131 | # Django REST Framework usage example 132 | from rest_framework import viewsets 133 | from rest_framework import routers 134 | from natural_keys import NaturalKeyModelSerializer 135 | from .models import Event, Note 136 | 137 | class EventSerializer(NaturalKeyModelSerializer): 138 | class Meta: 139 | model = Event 140 | 141 | class NoteSerializer(NaturalKeyModelSerializer): 142 | class Meta: 143 | model = Note 144 | 145 | class EventViewSet(viewsets.ModelViewSet): 146 | queryset = Event.objects.all() 147 | serializer_class = EventSerializer 148 | 149 | class NoteViewSet(viewsets.ModelViewSet): 150 | queryset = Note.objects.all() 151 | serializer_class = NoteSerializer 152 | 153 | router = routers.DefaultRouter() 154 | router.register(r'events', EventViewSet) 155 | router.register(r'notes', NoteViewSet) 156 | 157 | # wq.db usage example 158 | from wq.db import rest 159 | from natural_keys import NaturalKeyModelSerializer 160 | from .models import Event, Note 161 | 162 | rest.router.register_model(Note, serializer=NaturalKeyModelSerializer) 163 | rest.router.register_model(Event, serializer=NaturalKeyModelSerializer) 164 | ``` 165 | 166 | Once this is set up, you can use your REST API to create and view your `NaturalKeyModel` instances and related data. To facilitate integration with regular HTML Forms, *Django Natural Keys* is integrated with the [HTML JSON Forms] package, which supports nested keys via an array naming convention, as the examples below demonstrate. 167 | 168 | ```html 169 |
170 | 171 | 172 |
173 | ``` 174 | ```js 175 | // /events.json 176 | [ 177 | { 178 | "id": 123, 179 | "place": {"name": "ABC123"}, 180 | "date": "2016-01-01" 181 | } 182 | ] 183 | ``` 184 | ```html 185 |
186 | 187 | 188 | 189 |
190 | ``` 191 | ```js 192 | // /notes.json 193 | [ 194 | { 195 | "id": 12345, 196 | "event": { 197 | "place": {"name": "ABC123"}, 198 | "date": "2016-01-01" 199 | }, 200 | "note": "This is a note" 201 | } 202 | ] 203 | ``` 204 | 205 | ### Natural Key Slugs 206 | As an alternative to using `NaturalKeyModelSerializer` / `NaturalKeySerializer`, you can also use a single slug-like field for lookup and serialization. `NaturalKeyModel` (and its associated queryset) defines a pseudo-field, `natural_key_slug`, for this purpose. 207 | 208 | ```python 209 | class Place(NaturalKeyModel): 210 | name = models.CharField(max_length=255, unique=True) 211 | 212 | class Room(NaturalKeyModel) 213 | place = models.ForeignKey(Place, models.ON_DELETE) 214 | name = models.CharField(max_length=255) 215 | 216 | class Meta: 217 | unique_together = (('place', 'name'),) 218 | ``` 219 | ```python 220 | room = Room.objects.find("ABC123", "MainHall") 221 | assert(room.natural_key_slug == "ABC123-MainHall") 222 | assert(room == Room.objects.get(natural_key_slug="ABC123-MainHall")) 223 | ``` 224 | 225 | You can expose this functionality in your REST API to expose natural keys instead of database-generated ids. To do this, you will likely want to do the following: 226 | 227 | 1. Create a regular serializer with `id = serializers.ReadOnlyField(source='natural_key_slug')` 228 | 2. Set `lookup_field = 'natural_key_slug'` on your `ModelViewSet` (or similar generic class) and update the URL registration accordingly 229 | 3. Ensure foreign keys on any related models are serialized with `serializers.SlugRelatedField(slug_field='natural_key_slug')` 230 | 231 | In [wq.db], all three of the above can be achieved by setting the `"lookup"` attribute when registering with the [router]: 232 | 233 | ```python 234 | # myapp/rest.py 235 | from wq.db import rest 236 | from .models import Room 237 | 238 | rest.router.register_model( 239 | Room, 240 | fields='__all__', 241 | lookup='natural_key_slug', 242 | ) 243 | ``` 244 | 245 | Note that the `natural_key_slug` may not behave as expected if any of the component values contain the delimiter character (`-` by default). To mitigate this, you can set `natural_key_separator` on the model class to another character. 246 | 247 | [natural keys]: https://docs.djangoproject.com/en/4.2/topics/serialization/#natural-keys 248 | [UniqueConstraint]: https://docs.djangoproject.com/en/4.2/ref/models/constraints/#uniqueconstraint 249 | [unique_together]: https://docs.djangoproject.com/en/4.2/ref/models/options/#unique-together 250 | [unique]: https://docs.djangoproject.com/en/4.2/ref/models/fields/#unique 251 | 252 | [wq.db]: https://wq.io/wq.db/ 253 | [Django REST Framework]: http://www.django-rest-framework.org/ 254 | [vera.Report]:https://github.com/powered-by-wq/vera#report 255 | [vera.Event]: https://github.com/powered-by-wq/vera#event 256 | [ModelSerializer]: https://www.django-rest-framework.org/api-guide/serializers/#modelserializer 257 | [RelatedField]: https://www.django-rest-framework.org/api-guide/relations/ 258 | [HTML JSON Forms]: https://github.com/wq/html-json-forms 259 | [router]: https://wq.io/wq.db/router 260 | --------------------------------------------------------------------------------