├── VERSION ├── testapp ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_developer_employee_manager.py │ └── 0001_initial.py └── models.py ├── typedmodels ├── py.typed ├── __init__.py ├── admin.py ├── tests.py └── models.py ├── pytest.ini ├── MANIFEST.in ├── .gitignore ├── .coveragerc ├── .pre-commit-config.yaml ├── test_settings.py ├── mypy.ini ├── tox.ini ├── LICENSE.txt ├── .github └── workflows │ └── tests.yml ├── pyproject.toml ├── CHANGES.md ├── CLAUDE.md └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 0.14.0 2 | -------------------------------------------------------------------------------- /testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typedmodels/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testapp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = . 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE.txt VERSION 2 | -------------------------------------------------------------------------------- /typedmodels/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.15.1" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .pytest_cache 3 | .cache 4 | /.tox 5 | *.pyc 6 | *.pyo 7 | dist 8 | MANIFEST 9 | .coverage 10 | pip-log.txt 11 | *.egg-info 12 | *~ 13 | .vscode 14 | .claude/ 15 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # Note that to receive proper coverage results, you should be appending the results from different environments. 2 | 3 | [run] 4 | source=typedmodels 5 | 6 | [report] 7 | omit=*test* 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.4 4 | hooks: 5 | - id: ruff 6 | args: [--fix] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | INSTALLED_APPS = ( 2 | "typedmodels", 3 | "django.contrib.contenttypes", 4 | "testapp", 5 | ) 6 | MIDDLEWARE_CLASSES = () 7 | DATABASES = {"default": {"NAME": ":memory:", "ENGINE": "django.db.backends.sqlite3"}} 8 | SECRET_KEY = "abc123" 9 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 10 | # avoid RemovedInDjango50Warning when running tests: 11 | USE_TZ = True 12 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = mypy_django_plugin.main 3 | 4 | # when checking specific files (pre-commit), it's annoying when mypy reports a ton of errors in other files... 5 | # NOTE: this seems to be the default when run on Linux but not on MacOS? (Undocumented though) 6 | follow_imports = silent 7 | 8 | [mypy.plugins.django-stubs] 9 | django_settings_module = "test_settings" 10 | 11 | [mypy-setuptools.*] 12 | ignore_missing_imports = true 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = {py310}-{dj42} 4 | {py310,py311,py312,py313}-{dj51} 5 | {py310,py311,py312,py313}-{dj52} 6 | {py312,py313}-{dj60} 7 | mypy 8 | 9 | [testenv] 10 | changedir = {toxinidir} 11 | commands = 12 | coverage run {envbindir}/pytest --ds=test_settings typedmodels/tests.py {posargs} 13 | coverage report --omit=typedmodels/test* 14 | 15 | setenv = 16 | PYTHONBREAKPOINT=ipdb.set_trace 17 | deps = 18 | pyyaml 19 | coveralls 20 | ipdb 21 | pytest 22 | pytest-django 23 | pytest-sugar 24 | django-stubs 25 | django-stubs-ext 26 | dj42: Django~=4.2.0 27 | dj51: Django~=5.1.3 28 | dj52: Django~=5.2.0 29 | dj60: Django~=6.0.0 30 | mypy: mypy 31 | 32 | [testenv:mypy] 33 | commands = 34 | python -m mypy . 35 | basepython = python3.12 -------------------------------------------------------------------------------- /typedmodels/admin.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Generic 2 | 3 | from django.contrib.admin import ModelAdmin 4 | 5 | from .models import TypedModel, TypedModelT 6 | 7 | if TYPE_CHECKING: 8 | from django.forms.forms import BaseForm 9 | from django.http import HttpRequest 10 | 11 | 12 | class TypedModelAdmin(ModelAdmin, Generic[TypedModelT]): 13 | model: "type[TypedModelT]" 14 | 15 | def get_fields( 16 | self, 17 | request: "HttpRequest", 18 | obj: "TypedModelT | None" = None, 19 | ) -> list[str | list[str] | tuple[str, ...]]: 20 | fields = list(super().get_fields(request, obj)) 21 | # we remove the type field from the admin of subclasses. 22 | if TypedModel not in self.model.__bases__: 23 | fields.remove(self.model._meta.get_field("type").name) 24 | return fields 25 | 26 | def save_model( 27 | self, 28 | request: "HttpRequest", 29 | obj: "TypedModelT", 30 | form: "BaseForm", 31 | change, 32 | ) -> None: 33 | if getattr(obj, "_typedmodels_type", None) is None: 34 | # new instances don't have the type attribute 35 | obj._typedmodels_type = form.cleaned_data["type"] # type: ignore[misc] 36 | obj.save() 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2025 Craig de Stigter 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /testapp/migrations/0002_developer_employee_manager.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-19 17:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('testapp', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Employee', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('type', models.CharField(choices=[('testapp.developer', 'developer'), ('testapp.manager', 'manager')], db_index=True, max_length=255)), 18 | ('name', models.CharField(max_length=255, null=True)), 19 | ], 20 | options={ 21 | 'abstract': False, 22 | }, 23 | ), 24 | migrations.CreateModel( 25 | name='Developer', 26 | fields=[ 27 | ], 28 | options={ 29 | 'proxy': True, 30 | 'indexes': [], 31 | 'constraints': [], 32 | }, 33 | bases=('testapp.employee',), 34 | ), 35 | migrations.CreateModel( 36 | name='Manager', 37 | fields=[ 38 | ], 39 | options={ 40 | 'proxy': True, 41 | 'indexes': [], 42 | 'constraints': [], 43 | }, 44 | bases=('testapp.employee',), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: django-typed-models tests 2 | on: [pull_request, push] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | 9 | - name: Set up Python 10 | uses: actions/setup-python@v5 11 | with: 12 | python-version: "3.12" 13 | 14 | - name: Install uv 15 | uses: astral-sh/setup-uv@v5 16 | 17 | - name: Install pre-commit 18 | run: uv pip install --system pre-commit 19 | 20 | - name: Run pre-commit 21 | run: pre-commit run --all-files 22 | 23 | mypy: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.12" 32 | 33 | - name: Install uv 34 | uses: astral-sh/setup-uv@v5 35 | 36 | - name: Install tox 37 | run: uv pip install --system tox 38 | 39 | - name: Run mypy 40 | run: tox -e mypy 41 | 42 | test: 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | python-version: 47 | - "3.10" 48 | - "3.12" 49 | - "3.13" 50 | 51 | steps: 52 | - uses: actions/checkout@v4 53 | 54 | - name: Set up Python ${{ matrix.python-version }} 55 | uses: actions/setup-python@v5 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | 59 | - name: Install uv 60 | uses: astral-sh/setup-uv@v5 61 | 62 | - name: Install tox 63 | run: uv pip install --system tox 64 | 65 | - name: Test with pytest 66 | run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-typed-models" 7 | description = "Sane single table model inheritance for Django" 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | authors = [{ name = "Craig de Stigter", email = "craig@destigter.nz" }] 11 | license = "BSD-3-Clause" 12 | license-files = ["LICENSE.txt"] 13 | classifiers = [ 14 | "Environment :: Web Environment", 15 | "Framework :: Django", 16 | "Framework :: Django :: 4.2", 17 | "Framework :: Django :: 5.1", 18 | "Framework :: Django :: 5.2", 19 | "Framework :: Django :: 6.0", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: BSD License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Topic :: Utilities", 31 | ] 32 | dependencies = ["django_stubs_ext", "typing-extensions"] 33 | requires-python = ">=3.10" 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/craigds/django-typed-models" 37 | 38 | [tool.hatch.version] 39 | path = "typedmodels/__init__.py" 40 | 41 | [tool.hatch.build.targets.wheel] 42 | packages = ["typedmodels"] 43 | 44 | [tool.ruff] 45 | target-version = "py310" 46 | line-length = 100 47 | exclude = ["testapp/migrations"] 48 | 49 | [tool.ruff.lint] 50 | select = ["E", "F", "I", "UP", "B"] 51 | ignore = [ 52 | "E501", # line too long - let formatter handle it where possible 53 | ] 54 | 55 | [tool.ruff.lint.per-file-ignores] 56 | "typedmodels/tests.py" = ["E721"] # type() == Class comparisons are intentional in tests 57 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Upgrade notes 2 | 3 | Backward-incompatible changes for released versions are listed here (for 0.5 onwards.) 4 | 5 | ## 0.14 6 | 7 | * Added support for Django 4.2 and 5.0. 8 | * Added support for Python 3.12. 9 | * Dropped the `VERSION` and `__version__` attributes. To check the version of the package, use `importlib.metadata.version("django-typed-models")` ([docs](https://docs.python.org/3/library/importlib.metadata.html#distribution-versions) / 10 | [backport](https://pypi.org/project/importlib-metadata/)). 11 | 12 | ## 0.13 13 | 14 | * Dropped support for Django 3.1. 15 | * Some apps using typedmodels may generate new migrations, due to [#68](https://github.com/craigds/django-typed-models/pull/68) - these are harmless and don't actually change anything in your database. 16 | 17 | ## 0.12 18 | 19 | No backward-incompatible changes. Added support for Django 4.x 20 | 21 | ## 0.11 22 | 23 | * Dropped support for djangoes older than 3.1 24 | * Fields on concrete typed models must now have `null=True`. Previously the null=True was added automatically ([#39](https://github.com/craigds/django-typed-models/issues/39)) 25 | * If you defer the `type` field (via `queryset.only()` or `queryset.defer()`), typedmodels will no longer automatically cast the model instances from that queryset. 26 | 27 | ## 0.10 28 | 29 | * Dropped Python 2 support 30 | * Added support for django 2.2 and 3.0, and dropped support for <2.2. 31 | 32 | ## 0.9 33 | 34 | Removed shims for unsupported django versions (now supports 1.11+) 35 | 36 | ## 0.8 37 | 38 | Fields defined in typed subclasses no longer get `null=True` added silently. 39 | 40 | * If the field has a default value, we will use the default value instead of `None` for other types. You may need to either add `null=True` yourself, or create a migration for your app. 41 | * If the field doesn't have a default value, a warning is logged and the `null=True` is added implicitly. This will be removed in typedmodels 0.9. 42 | 43 | ## 0.7 44 | 45 | This release removes some magic around manager setup. Managers are now inherited using the normal Django mechanism. 46 | 47 | If you are using a custom default manager on a TypedModel subclass, you need to make sure it is a subclass 48 | of TypedModelManager. Otherwise type filtering will not work as expected. 49 | 50 | ## 0.5 51 | 52 | This import path no longer works, as recent Django changes make it impossible: 53 | 54 | ``` 55 | from typedmodels import TypedModel 56 | ``` 57 | 58 | Instead, use: 59 | 60 | ``` 61 | from typedmodels.models import TypedModel 62 | ``` 63 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | django-typed-models is a Django package that provides single-table inheritance with automatic type-based downcasting. It allows storing different model subclasses in the same database table while automatically casting objects to their correct subclass when retrieved. 8 | 9 | ## Architecture 10 | 11 | ### Core Components 12 | 13 | - **TypedModel**: Base abstract model that other models inherit from 14 | - **TypedModelMetaclass**: Metaclass that handles proxy model creation and type registration 15 | - **TypedModelManager**: Custom manager that filters querysets by model type 16 | - **TypedModelAdmin**: Django admin class for managing typed models 17 | 18 | ### Key Concepts 19 | 20 | - **Base Class**: The concrete model that defines the database table (inherits from TypedModel) 21 | - **Proxy Subclasses**: Type-specific models that are automatically created as Django proxy models 22 | - **Type Registry**: Dict mapping type strings (app_label.model_name) to model classes 23 | - **Auto-recasting**: Objects are automatically cast to correct subclass on retrieval 24 | 25 | ## Development Commands 26 | 27 | ### Testing 28 | ```bash 29 | # Run tests using pytest 30 | pytest --ds=test_settings typedmodels/tests.py 31 | 32 | # Run tests with coverage 33 | coverage run $(which pytest) --ds=test_settings typedmodels/tests.py 34 | coverage report --omit=typedmodels/test* 35 | 36 | # Run full test matrix (all Python/Django versions) 37 | tox 38 | 39 | # Run specific tox environment 40 | tox -e py312-dj51 41 | ``` 42 | 43 | ### Type Checking 44 | ```bash 45 | # Run MyPy type checking 46 | python -m mypy . 47 | 48 | # Or via tox 49 | tox -e mypy 50 | ``` 51 | 52 | ### Linting 53 | ```bash 54 | # Run flake8 (used in CI) 55 | flake8 --select=E9,F63,F7,F82 . 56 | ``` 57 | 58 | ## Important Implementation Details 59 | 60 | ### Field Constraints for Subclasses 61 | All fields defined on TypedModel subclasses must be: 62 | - Nullable (`null=True`) 63 | - Have a default value 64 | - Be a ManyToManyField 65 | 66 | This is enforced by the metaclass and will raise FieldError if violated. 67 | 68 | ### Type Field Management 69 | - Each subclass gets a unique type string: `{app_label}.{model_name}` 70 | - Type choices are automatically populated on the base class 71 | - The `type` field is indexed and required 72 | 73 | ### Proxy Model Behavior 74 | - Subclasses are automatically converted to proxy models 75 | - Fields are contributed to the base class, not the proxy 76 | - Querysets are filtered by type in the custom manager 77 | 78 | ### Serialization Patches 79 | The code monkey-patches Django's Python and XML serializers to use the base class model name for TypedModel instances. 80 | 81 | ## Workflow Tips 82 | 83 | - Use tox to run the tests in this project 84 | - Don't run pytest directly, use tox to run the tests -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-typed-models 2 | 3 | ![example workflow](https://github.com/craigds/django-typed-models/actions/workflows/tests.yml/badge.svg) 4 | 5 | ## Intro 6 | 7 | `django-typed-models` provides an extra type of model inheritance for Django. It is similar to single-table inheritance in Ruby on Rails. 8 | 9 | The actual type of each object is stored in the database, and when the object is retrieved it is automatically cast to the correct model class. 10 | 11 | Licensed under the New BSD License. 12 | 13 | 14 | ## Features 15 | 16 | * Models in querysets have the right class automatically 17 | * All models subclassing a common base are stored in the same table 18 | * Object types are stored in a 'type' field in the database 19 | * No extra queries or joins to retrieve multiple types 20 | 21 | 22 | ## Usage: 23 | 24 | An example says a bunch of words: 25 | 26 | ```python 27 | 28 | # myapp/models.py 29 | 30 | from django.db import models 31 | from typedmodels.models import TypedModel 32 | 33 | class Animal(TypedModel): 34 | """ 35 | Abstract model 36 | """ 37 | name = models.CharField(max_length=255) 38 | 39 | def say_something(self): 40 | raise NotImplemented 41 | 42 | def __repr__(self): 43 | return u'<%s: %s>' % (self.__class__.__name__, self.name) 44 | 45 | class Canine(Animal): 46 | def say_something(self): 47 | return "woof" 48 | 49 | class Feline(Animal): 50 | mice_eaten = models.IntegerField( 51 | default = 0 52 | ) 53 | 54 | def say_something(self): 55 | return "meoww" 56 | ``` 57 | 58 | Later: 59 | 60 | ```python 61 | >>> from myapp.models import Animal, Canine, Feline 62 | >>> Feline.objects.create(name="kitteh") 63 | >>> Feline.objects.create(name="cheetah") 64 | >>> Canine.objects.create(name="fido") 65 | >>> print Animal.objects.all() 66 | [, , ] 67 | 68 | >>> print Canine.objects.all() 69 | [] 70 | 71 | >>> print Feline.objects.all() 72 | [, ] 73 | ``` 74 | 75 | You can actually change the types of objects. Simply run an update query: 76 | 77 | ```python 78 | Feline.objects.update(type='myapp.bigcat') 79 | ``` 80 | 81 | If you want to change the type of an object without refreshing it from the database, you can call ``recast``: 82 | 83 | ```python 84 | kitty.recast(BigCat) 85 | # or kitty.recast('myapp.bigcat') 86 | kitty.save() 87 | ``` 88 | 89 | 90 | ## Listing subclasses 91 | 92 | Occasionally you might need to list the various subclasses of your abstract type. 93 | 94 | One current use for this is connecting signals, since currently they don't fire on the base class (see [#1](https://github.com/craigds/django-typed-models/issues/1)) 95 | 96 | ```python 97 | for sender in Animal.get_type_classes(): 98 | post_save.connect(on_animal_saved, sender=sender) 99 | ``` 100 | 101 | 102 | ## Django admin 103 | 104 | If you plan to use typed models with Django admin, consider inheriting from typedmodels.admin.TypedModelAdmin. 105 | This will hide the type field from subclasses admin by default, and allow to create new instances from the base class admin. 106 | 107 | ```python 108 | from django.contrib import admin 109 | from typedmodels.admin import TypedModelAdmin 110 | from .models import Animal, Canine, Feline 111 | 112 | @admin.register(Animal) 113 | class AnimalAdmin(TypedModelAdmin): 114 | pass 115 | 116 | @admin.register(Canine) 117 | class CanineAdmin(TypedModelAdmin): 118 | pass 119 | 120 | @admin.register(Feline) 121 | class FelineAdmin(TypedModelAdmin): 122 | pass 123 | ``` 124 | 125 | ## Limitations 126 | 127 | * Since all objects are stored in the same table, all fields defined in subclasses are nullable. 128 | * Fields defined on subclasses can only be defined on *one* subclass, unless the duplicate fields are exactly identical. 129 | 130 | 131 | ## Requirements 132 | 133 | * a non-EOL combination of Django and Python 134 | -------------------------------------------------------------------------------- /testapp/models.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.db.models import CharField, ForeignKey, PositiveIntegerField 7 | 8 | from typedmodels.models import TypedModel, TypedModelManager 9 | 10 | 11 | class UniqueIdentifier(models.Model): 12 | referent = GenericForeignKey() 13 | content_type = ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) 14 | object_id = PositiveIntegerField(null=True, blank=True) 15 | created = models.DateTimeField(db_index=True, auto_now_add=True) 16 | name = CharField(max_length=255) 17 | 18 | 19 | class UniqueIdentifierMixin(models.Model): 20 | unique_identifiers = GenericRelation(UniqueIdentifier, related_query_name="referents") 21 | 22 | class Meta: 23 | abstract = True 24 | 25 | 26 | class Animal(TypedModel, UniqueIdentifierMixin): 27 | """ 28 | Abstract model 29 | """ 30 | 31 | name = models.CharField(max_length=255) 32 | 33 | def say_something(self): 34 | raise NotImplementedError 35 | 36 | # def __repr__(self): 37 | # return u'<%s: %s>' % (self.__class__.__name__, self.name) 38 | 39 | def __str__(self): 40 | return str(self.name) 41 | 42 | 43 | class Canine(Animal): 44 | def say_something(self): 45 | return "woof" 46 | 47 | 48 | class Feline(Animal): 49 | def say_something(self): 50 | return "meoww" 51 | 52 | mice_eaten = models.IntegerField(default=0) 53 | 54 | 55 | class BigCat(Feline): 56 | """ 57 | This model tests doubly-proxied models. 58 | """ 59 | 60 | def say_something(self): 61 | return "roar" 62 | 63 | 64 | class AngryBigCat(BigCat): 65 | """ 66 | This model tests triple-proxied models. Because we can 67 | """ 68 | 69 | canines_eaten = models.ManyToManyField(Canine) 70 | 71 | def say_something(self): 72 | return "raawr" 73 | 74 | 75 | class Parrot(Animal): 76 | known_words = models.IntegerField(null=True) 77 | 78 | def say_something(self): 79 | return "hello" 80 | 81 | 82 | class AbstractVegetable(TypedModel): 83 | """ 84 | This is an entirely different typed model. 85 | """ 86 | 87 | name = models.CharField(max_length=255) 88 | color = models.CharField(max_length=255) 89 | yumness = models.FloatField(null=False) 90 | 91 | mymanager = models.Manager() 92 | 93 | 94 | class Fruit(AbstractVegetable): 95 | pass 96 | 97 | 98 | class Vegetable(AbstractVegetable): 99 | pass 100 | 101 | 102 | class SurpriseAbstractModel(TypedModel): 103 | """ 104 | This class *isn't* the typed base, it's a random abstract model. 105 | The presence of this model tests 106 | https://github.com/craigds/django-typed-models/issues/61 107 | """ 108 | 109 | class Meta: 110 | abstract = True 111 | 112 | 113 | class Parent(SurpriseAbstractModel): 114 | a = models.CharField(max_length=1) 115 | 116 | 117 | class Child1(Parent): 118 | b = models.OneToOneField("self", null=True, on_delete=models.CASCADE) 119 | 120 | 121 | class Child2(Parent): 122 | pass 123 | 124 | 125 | class EmployeeManager(TypedModelManager): 126 | pass 127 | 128 | 129 | class Employee(TypedModel): 130 | objects: ClassVar[EmployeeManager] = EmployeeManager() 131 | 132 | 133 | class Developer(Employee): 134 | name = models.CharField(max_length=255, null=True) 135 | 136 | 137 | class Manager(Employee): 138 | # Adds the _exact_ same field as Developer. Shouldn't error. 139 | name = models.CharField(max_length=255, null=True) 140 | 141 | 142 | def typed_queryset() -> None: 143 | # This isn't actually called, but it's here for the mypy check to ensure that type hinting works correctly. 144 | queryset = Animal.objects.filter(pk=1) 145 | queryset.filter(name="lynx") # works, because Animal has this field 146 | 147 | 148 | def do_get_type_classes() -> None: 149 | for x in Animal.get_type_classes(): 150 | print(x) 151 | -------------------------------------------------------------------------------- /testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.14 on 2020-07-26 18:18 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.db.models.manager 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('contenttypes', '0002_remove_content_type_name'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='AbstractVegetable', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('type', models.CharField(choices=[('testapp.fruit', 'fruit'), ('testapp.vegetable', 'vegetable')], db_index=True, max_length=255)), 22 | ('name', models.CharField(max_length=255)), 23 | ('color', models.CharField(max_length=255)), 24 | ('yumness', models.FloatField()), 25 | ], 26 | options={ 27 | 'abstract': False, 28 | }, 29 | managers=[ 30 | ('mymanager', django.db.models.manager.Manager()), 31 | ], 32 | ), 33 | migrations.CreateModel( 34 | name='Animal', 35 | fields=[ 36 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 37 | ('type', models.CharField(choices=[('testapp.angrybigcat', 'angry big cat'), ('testapp.bigcat', 'big cat'), ('testapp.canine', 'canine'), ('testapp.feline', 'feline'), ('testapp.parrot', 'parrot')], db_index=True, max_length=255)), 38 | ('name', models.CharField(max_length=255)), 39 | ('mice_eaten', models.IntegerField(default=0)), 40 | ('known_words', models.IntegerField(null=True)), 41 | ], 42 | options={ 43 | 'abstract': False, 44 | }, 45 | ), 46 | migrations.CreateModel( 47 | name='UniqueIdentifier', 48 | fields=[ 49 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 50 | ('object_id', models.PositiveIntegerField(blank=True, null=True)), 51 | ('created', models.DateTimeField(auto_now_add=True, db_index=True)), 52 | ('name', models.CharField(max_length=255)), 53 | ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='Parent', 58 | fields=[ 59 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('type', models.CharField(choices=[('testapp.child1', 'child1'), ('testapp.child2', 'child2')], db_index=True, max_length=255)), 61 | ('a', models.CharField(max_length=1)), 62 | ('b', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.Parent')), 63 | ], 64 | options={ 65 | 'abstract': False, 66 | }, 67 | ), 68 | migrations.CreateModel( 69 | name='Canine', 70 | fields=[ 71 | ], 72 | options={ 73 | 'proxy': True, 74 | 'indexes': [], 75 | 'constraints': [], 76 | }, 77 | bases=('testapp.animal',), 78 | ), 79 | migrations.CreateModel( 80 | name='Child1', 81 | fields=[ 82 | ], 83 | options={ 84 | 'proxy': True, 85 | 'indexes': [], 86 | 'constraints': [], 87 | }, 88 | bases=('testapp.parent',), 89 | ), 90 | migrations.CreateModel( 91 | name='Child2', 92 | fields=[ 93 | ], 94 | options={ 95 | 'proxy': True, 96 | 'indexes': [], 97 | 'constraints': [], 98 | }, 99 | bases=('testapp.parent',), 100 | ), 101 | migrations.CreateModel( 102 | name='Feline', 103 | fields=[ 104 | ], 105 | options={ 106 | 'proxy': True, 107 | 'indexes': [], 108 | 'constraints': [], 109 | }, 110 | bases=('testapp.animal',), 111 | ), 112 | migrations.CreateModel( 113 | name='Fruit', 114 | fields=[ 115 | ], 116 | options={ 117 | 'proxy': True, 118 | 'indexes': [], 119 | 'constraints': [], 120 | }, 121 | bases=('testapp.abstractvegetable',), 122 | managers=[ 123 | ('mymanager', django.db.models.manager.Manager()), 124 | ], 125 | ), 126 | migrations.CreateModel( 127 | name='Parrot', 128 | fields=[ 129 | ], 130 | options={ 131 | 'proxy': True, 132 | 'indexes': [], 133 | 'constraints': [], 134 | }, 135 | bases=('testapp.animal',), 136 | ), 137 | migrations.CreateModel( 138 | name='Vegetable', 139 | fields=[ 140 | ], 141 | options={ 142 | 'proxy': True, 143 | 'indexes': [], 144 | 'constraints': [], 145 | }, 146 | bases=('testapp.abstractvegetable',), 147 | managers=[ 148 | ('mymanager', django.db.models.manager.Manager()), 149 | ], 150 | ), 151 | migrations.AddField( 152 | model_name='animal', 153 | name='canines_eaten', 154 | field=models.ManyToManyField(to='testapp.Canine'), 155 | ), 156 | migrations.CreateModel( 157 | name='BigCat', 158 | fields=[ 159 | ], 160 | options={ 161 | 'proxy': True, 162 | 'indexes': [], 163 | 'constraints': [], 164 | }, 165 | bases=('testapp.feline',), 166 | ), 167 | migrations.CreateModel( 168 | name='AngryBigCat', 169 | fields=[ 170 | ], 171 | options={ 172 | 'proxy': True, 173 | 'indexes': [], 174 | 'constraints': [], 175 | }, 176 | bases=('testapp.bigcat',), 177 | ), 178 | ] 179 | -------------------------------------------------------------------------------- /typedmodels/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | 5 | try: 6 | import yaml 7 | 8 | PYYAML_AVAILABLE = True 9 | del yaml 10 | except ImportError: 11 | PYYAML_AVAILABLE = False 12 | 13 | from django.core import serializers 14 | from django.core.exceptions import FieldError 15 | 16 | from testapp.models import ( 17 | AbstractVegetable, 18 | AngryBigCat, 19 | Animal, 20 | BigCat, 21 | Canine, 22 | Child2, 23 | Employee, 24 | Feline, 25 | Fruit, 26 | Parrot, 27 | UniqueIdentifier, 28 | Vegetable, 29 | ) 30 | 31 | from .models import TypedModelManager 32 | 33 | 34 | @pytest.fixture 35 | def animals(db): 36 | kitteh = Feline.objects.create(name="kitteh") 37 | UniqueIdentifier.objects.create( 38 | name="kitteh", 39 | object_id=kitteh.pk, 40 | content_type=ContentType.objects.get_for_model(kitteh), 41 | ) 42 | cheetah = Feline.objects.create(name="cheetah") 43 | UniqueIdentifier.objects.create( 44 | name="cheetah", 45 | object_id=cheetah.pk, 46 | content_type=ContentType.objects.get_for_model(cheetah), 47 | ) 48 | fido = Canine.objects.create(name="fido") 49 | UniqueIdentifier.objects.create( 50 | name="fido", 51 | object_id=fido.pk, 52 | content_type=ContentType.objects.get_for_model(fido), 53 | ) 54 | simba = BigCat.objects.create(name="simba") 55 | UniqueIdentifier.objects.create( 56 | name="simba", 57 | object_id=simba.pk, 58 | content_type=ContentType.objects.get_for_model(simba), 59 | ) 60 | mufasa = AngryBigCat.objects.create(name="mufasa") 61 | UniqueIdentifier.objects.create( 62 | name="mufasa", 63 | object_id=mufasa.pk, 64 | content_type=ContentType.objects.get_for_model(mufasa), 65 | ) 66 | kajtek = Parrot.objects.create(name="Kajtek") 67 | UniqueIdentifier.objects.create( 68 | name="kajtek", 69 | object_id=kajtek.pk, 70 | content_type=ContentType.objects.get_for_model(kajtek), 71 | ) 72 | 73 | 74 | def test_can_instantiate_base_model(db): 75 | # direct instantiation works fine without a type, as long as you don't save 76 | animal = Animal() 77 | assert not animal.type 78 | assert type(animal) is Animal 79 | 80 | 81 | def test_cant_save_untyped_base_model(db): 82 | # direct instantiation shouldn't work 83 | with pytest.raises(RuntimeError): 84 | Animal.objects.create(name="uhoh") 85 | 86 | # ... unless a type is specified 87 | Animal.objects.create(name="dingo", type="testapp.canine") 88 | 89 | # ... unless that type is stupid 90 | with pytest.raises(ValueError): 91 | Animal.objects.create(name="dingo", type="macaroni.buffaloes") 92 | 93 | 94 | def test_get_types(): 95 | assert set(Animal.get_types()) == { 96 | "testapp.canine", 97 | "testapp.bigcat", 98 | "testapp.parrot", 99 | "testapp.angrybigcat", 100 | "testapp.feline", 101 | } 102 | assert set(Canine.get_types()) == {"testapp.canine"} 103 | assert set(Feline.get_types()) == { 104 | "testapp.bigcat", 105 | "testapp.angrybigcat", 106 | "testapp.feline", 107 | } 108 | 109 | 110 | def test_get_type_classes(): 111 | assert set(Animal.get_type_classes()) == { 112 | Canine, 113 | BigCat, 114 | Parrot, 115 | AngryBigCat, 116 | Feline, 117 | } 118 | assert set(Canine.get_type_classes()) == {Canine} 119 | assert set(Feline.get_type_classes()) == {BigCat, AngryBigCat, Feline} 120 | 121 | 122 | def test_type_choices(): 123 | type_choices = Animal._meta.get_field("type").choices 124 | assert type_choices == [ 125 | ("testapp.angrybigcat", "angry big cat"), 126 | ("testapp.bigcat", "big cat"), 127 | ("testapp.canine", "canine"), 128 | ("testapp.feline", "feline"), 129 | ("testapp.parrot", "parrot"), 130 | ] 131 | assert {cls for cls, _ in type_choices} == set(Animal.get_types()) 132 | 133 | 134 | def test_base_model_queryset(animals): 135 | # all objects returned 136 | qs = Animal.objects.all().order_by("type") 137 | assert [obj.type for obj in qs] == [ 138 | "testapp.angrybigcat", 139 | "testapp.bigcat", 140 | "testapp.canine", 141 | "testapp.feline", 142 | "testapp.feline", 143 | "testapp.parrot", 144 | ] 145 | assert [type(obj) for obj in qs] == [ 146 | AngryBigCat, 147 | BigCat, 148 | Canine, 149 | Feline, 150 | Feline, 151 | Parrot, 152 | ] 153 | 154 | 155 | def test_proxy_model_queryset(animals): 156 | qs = Canine.objects.all().order_by("type") 157 | assert qs.count() == 1 158 | assert len(qs) == 1 159 | assert [obj.type for obj in qs] == ["testapp.canine"] 160 | assert [type(obj) for obj in qs] == [Canine] 161 | 162 | qs = Feline.objects.all().order_by("type") 163 | assert qs.count() == 4 164 | assert len(qs) == 4 165 | assert [obj.type for obj in qs] == [ 166 | "testapp.angrybigcat", 167 | "testapp.bigcat", 168 | "testapp.feline", 169 | "testapp.feline", 170 | ] 171 | assert [type(obj) for obj in qs] == [AngryBigCat, BigCat, Feline, Feline] 172 | 173 | 174 | def test_doubly_proxied_model_queryset(animals): 175 | qs = BigCat.objects.all().order_by("type") 176 | assert qs.count() == 2 177 | assert len(qs) == 2 178 | assert [obj.type for obj in qs] == ["testapp.angrybigcat", "testapp.bigcat"] 179 | assert [type(obj) for obj in qs] == [AngryBigCat, BigCat] 180 | 181 | 182 | def test_triply_proxied_model_queryset(animals): 183 | qs = AngryBigCat.objects.all().order_by("type") 184 | assert qs.count() == 1 185 | assert len(qs) == 1 186 | assert [obj.type for obj in qs] == ["testapp.angrybigcat"] 187 | assert [type(obj) for obj in qs] == [AngryBigCat] 188 | 189 | 190 | def test_recast_auto(animals): 191 | cat = Feline.objects.get(name="kitteh") 192 | cat.type = "testapp.bigcat" 193 | cat.recast() 194 | assert cat.type == "testapp.bigcat" 195 | assert type(cat) == BigCat 196 | 197 | 198 | def test_recast_to_subclass_string(animals): 199 | cat = Feline.objects.get(name="kitteh") 200 | cat.recast("testapp.bigcat") 201 | assert cat.type == "testapp.bigcat" 202 | assert type(cat) == BigCat 203 | 204 | 205 | def test_recast_to_subclass_modelclass(animals): 206 | cat = Feline.objects.get(name="kitteh") 207 | cat.recast(BigCat) 208 | assert cat.type == "testapp.bigcat" 209 | assert type(cat) == BigCat 210 | 211 | 212 | def test_recast_string(animals): 213 | cat = Feline.objects.get(name="kitteh") 214 | cat.recast("testapp.canine") 215 | assert cat.type == "testapp.canine" 216 | assert type(cat) == Canine 217 | 218 | 219 | def test_recast_modelclass(animals): 220 | cat = Feline.objects.get(name="kitteh") 221 | cat.recast(Canine) 222 | assert cat.type == "testapp.canine" 223 | assert type(cat) == Canine 224 | 225 | 226 | def test_recast_fail(animals): 227 | cat = Feline.objects.get(name="kitteh") 228 | with pytest.raises(ValueError): 229 | cat.recast(AbstractVegetable) 230 | with pytest.raises(ValueError): 231 | cat.recast("typedmodels.abstractvegetable") 232 | with pytest.raises(ValueError): 233 | cat.recast(Vegetable) 234 | with pytest.raises(ValueError): 235 | cat.recast("typedmodels.vegetable") 236 | 237 | 238 | def test_fields_in_subclasses(animals): 239 | canine = Canine.objects.all()[0] 240 | angry = AngryBigCat.objects.all()[0] 241 | 242 | angry.mice_eaten = 5 243 | angry.save() 244 | assert AngryBigCat.objects.get(pk=angry.pk).mice_eaten == 5 245 | 246 | angry.canines_eaten.add(canine) 247 | assert list(angry.canines_eaten.all()) == [canine] 248 | 249 | # Feline class was created before Parrot and has mice_eaten field which is non-m2m, so it may break accessing 250 | # known_words field in Parrot instances (since Django 1.5). 251 | parrot = Parrot.objects.all()[0] 252 | parrot.known_words = 500 253 | parrot.save() 254 | assert Parrot.objects.get(pk=parrot.pk).known_words == 500 255 | 256 | 257 | def test_fields_cache(): 258 | mice_eaten = Feline._meta.get_field("mice_eaten") 259 | known_words = Parrot._meta.get_field("known_words") 260 | assert mice_eaten in AngryBigCat._meta.fields 261 | assert mice_eaten in Feline._meta.fields 262 | assert mice_eaten not in Parrot._meta.fields 263 | assert known_words in Parrot._meta.fields 264 | assert known_words not in AngryBigCat._meta.fields 265 | assert known_words not in Feline._meta.fields 266 | 267 | 268 | def test_m2m_cache(): 269 | canines_eaten = AngryBigCat._meta.get_field("canines_eaten") 270 | assert canines_eaten in AngryBigCat._meta.many_to_many 271 | assert canines_eaten not in Feline._meta.many_to_many 272 | assert canines_eaten not in Parrot._meta.many_to_many 273 | 274 | 275 | def test_related_names(animals): 276 | """Ensure that accessor names for reverse relations are generated properly.""" 277 | 278 | canine = Canine.objects.all()[0] 279 | assert hasattr(canine, "angrybigcat_set") 280 | 281 | 282 | def test_queryset_defer(db): 283 | """ 284 | Ensure that qs.defer() works correctly 285 | """ 286 | Vegetable.objects.create(name="cauliflower", color="white", yumness=1) 287 | Vegetable.objects.create(name="spinach", color="green", yumness=5) 288 | Vegetable.objects.create(name="sweetcorn", color="yellow", yumness=10) 289 | Fruit.objects.create(name="Apple", color="red", yumness=7) 290 | 291 | qs = AbstractVegetable.objects.defer("yumness") 292 | 293 | objs = set(qs) 294 | for o in objs: 295 | assert isinstance(o, AbstractVegetable) 296 | assert set(o.get_deferred_fields()) == {"yumness"} 297 | # does a query, since this field was deferred 298 | assert isinstance(o.yumness, float) 299 | 300 | 301 | def test_queryset_defer_type(db): 302 | Vegetable.objects.create(name="cauliflower", color="white", yumness=1) 303 | Fruit.objects.create(name="Apple", color="red", yumness=7) 304 | 305 | qs = AbstractVegetable.objects.only("id") 306 | assert len(qs) == 2 307 | assert type(qs[0]) is AbstractVegetable 308 | assert type(qs[1]) is AbstractVegetable 309 | 310 | qs = Vegetable.objects.only("id") 311 | assert len(qs) == 1 312 | assert type(qs[0]) is Vegetable 313 | 314 | 315 | def test_queryset_defer_type_with_subclass_fields(db, animals): 316 | # .delete() tends to do this .only('id') thing while collating referenced models. 317 | # So it needs to work even if you think it's a weird thing to do 318 | # In this case we *don't* auto-cast to anything; all returned models are Animal instances 319 | # this avoids the following error: 320 | # django.core.exceptions.FieldDoesNotExist: AngryBigCat has no field named 'known_words' 321 | qs = list(Animal.objects.only("id")) 322 | assert len(qs) == 6 323 | assert all(type(x) is Animal for x in qs) 324 | 325 | 326 | @pytest.mark.parametrize( 327 | "fmt", 328 | [ 329 | "xml", 330 | "json", 331 | pytest.param( 332 | "yaml", 333 | marks=[pytest.mark.skipif(not PYYAML_AVAILABLE, reason="PyYAML is not available")], 334 | ), 335 | ], 336 | ) 337 | def test_serialization(fmt, animals): 338 | """Helper function used to check serialization and deserialization for concrete format.""" 339 | animals = Animal.objects.order_by("pk") 340 | serialized_animals = serializers.serialize(fmt, animals) 341 | deserialized_animals = [ 342 | wrapper.object for wrapper in serializers.deserialize(fmt, serialized_animals) 343 | ] 344 | assert set(deserialized_animals) == set(animals) 345 | 346 | 347 | def test_generic_relation(animals): 348 | for animal in Animal.objects.all(): 349 | assert hasattr(animal, "unique_identifiers") 350 | assert animal.unique_identifiers.all() 351 | 352 | Feline._meta.get_field("unique_identifiers") 353 | for feline in Feline.objects.all(): 354 | assert hasattr(feline, "unique_identifiers") 355 | assert feline.unique_identifiers.all() 356 | 357 | for uid in UniqueIdentifier.objects.all(): 358 | cls = uid.referent.__class__ 359 | animal = cls.objects.filter(unique_identifiers=uid) 360 | assert isinstance(animal.first(), Animal) 361 | 362 | for uid in UniqueIdentifier.objects.all(): 363 | cls = uid.referent.__class__ 364 | animal = cls.objects.filter(unique_identifiers__name=uid.name) 365 | assert isinstance(animal.first(), Animal) 366 | 367 | 368 | def test_manager_classes(): 369 | assert isinstance(Animal.objects, TypedModelManager) 370 | assert isinstance(Feline.objects, TypedModelManager) 371 | assert isinstance(BigCat.objects, TypedModelManager) 372 | 373 | # This one has a custom manager defined, but that shouldn't prevent objects from working 374 | assert isinstance(AbstractVegetable.mymanager, models.Manager) 375 | assert isinstance(AbstractVegetable.objects, TypedModelManager) 376 | 377 | # subclasses work the same way 378 | assert isinstance(Vegetable.mymanager, models.Manager) 379 | assert isinstance(Vegetable.objects, TypedModelManager) 380 | 381 | 382 | def test_uniqueness_check_on_child(db): 383 | child2 = Child2.objects.create(a="a") 384 | 385 | # Regression test for https://github.com/craigds/django-typed-models/issues/42 386 | # FieldDoesNotExist: Child2 has no field named 'b' 387 | child2.validate_unique() 388 | 389 | 390 | def test_uniqueness_check_include_meta_constraints(db): 391 | """Django 4.1 introduces a new required kwarg 392 | django/forms/models.py in validate_unique at line 809 393 | for form in valid_forms: 394 | exclude = form._get_validation_exclusions() 395 | unique_checks, date_checks = form.instance._get_unique_checks( 396 | exclude=exclude, 397 | include_meta_constraints=True, 398 | ) 399 | """ 400 | child2 = Child2.objects.create(a="a") 401 | child2._get_unique_checks() 402 | 403 | 404 | def test_non_nullable_subclass_field_error(): 405 | with pytest.raises(FieldError): 406 | 407 | class Bug(Animal): 408 | # should have null=True 409 | num_legs = models.PositiveIntegerField() 410 | 411 | 412 | def test_explicit_recast_with_class_on_untyped_instance(): 413 | animal = Animal() 414 | assert not animal.type 415 | animal.recast(Feline) 416 | assert animal.type == "testapp.feline" 417 | assert type(animal) is Feline 418 | 419 | 420 | def test_explicit_recast_with_string_on_untyped_instance(): 421 | animal = Animal() 422 | assert not animal.type 423 | animal.recast("testapp.feline") 424 | assert animal.type == "testapp.feline" 425 | assert type(animal) is Feline 426 | 427 | 428 | def test_same_field_name_in_two_subclasses(): 429 | with pytest.raises(ValueError): 430 | 431 | class Tester1(Employee): 432 | name = models.CharField(max_length=255, blank=True, null=True) 433 | 434 | with pytest.raises(ValueError): 435 | 436 | class Tester2(Employee): 437 | name = models.CharField(max_length=254, null=True) 438 | 439 | with pytest.raises(ValueError): 440 | 441 | class Tester3(Employee): 442 | name = models.IntegerField(null=True) 443 | -------------------------------------------------------------------------------- /typedmodels/models.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import types 3 | import typing 4 | from functools import partial 5 | from typing import ClassVar, TypeVar, cast 6 | 7 | import django 8 | from django.core.exceptions import FieldDoesNotExist, FieldError 9 | from django.core.serializers.python import Serializer as _PythonSerializer 10 | from django.core.serializers.xml_serializer import Serializer as _XmlSerializer 11 | from django.db import models 12 | from django.db.models.base import DEFERRED, ModelBase # type: ignore 13 | from django.db.models.fields import Field 14 | from django.db.models.options import Options, make_immutable_fields_list 15 | from django.utils.encoding import smart_str 16 | from typing_extensions import Self 17 | 18 | if typing.TYPE_CHECKING: 19 | from django.db.models import Model, QuerySet 20 | else: 21 | from django_stubs_ext import QuerySetAny as QuerySet 22 | 23 | reveal_type = print 24 | 25 | 26 | T = TypeVar("T", bound="TypedModel", covariant=True) 27 | TypedModelT = TypeVar("TypedModelT", bound="TypedModel") 28 | 29 | 30 | class TypedModelManager(models.Manager[T]): 31 | def get_queryset(self) -> QuerySet[T]: 32 | qs = super().get_queryset() 33 | return self._filter_by_type(qs) 34 | 35 | def _filter_by_type(self, qs: QuerySet[T]) -> QuerySet[T]: 36 | if hasattr(self.model, "_typedmodels_type"): 37 | if self.model._typedmodels_subtypes and len(self.model._typedmodels_subtypes) > 1: 38 | qs = qs.filter(type__in=self.model._typedmodels_subtypes) 39 | else: 40 | qs = qs.filter(type=self.model._typedmodels_type) 41 | return qs 42 | 43 | 44 | class TypedModelMetaclass(ModelBase): 45 | """ 46 | This metaclass enables a model for auto-downcasting using a ``type`` attribute. 47 | """ 48 | 49 | def __new__(meta, classname, bases, classdict) -> type["TypedModel"]: 50 | try: 51 | TypedModel # noqa: B018 52 | except NameError: 53 | # don't do anything for TypedModel class itself 54 | # 55 | # ...except updating Meta class to instantiate fields_from_subclasses attribute 56 | typed_model = cast( 57 | type["TypedModel"], 58 | super().__new__(meta, classname, bases, classdict), 59 | ) 60 | # We have to set this attribute after _meta has been created, otherwise an 61 | # exception would be thrown by Options class constructor. 62 | typed_model._meta.fields_from_subclasses = {} 63 | return typed_model 64 | 65 | # look for a non-proxy base class that is a subclass of TypedModel 66 | mro: list[type] = list(bases) 67 | base_class: type[TypedModel] | None 68 | while mro: 69 | base_class = mro.pop(-1) 70 | if issubclass(base_class, TypedModel) and base_class is not TypedModel: 71 | if base_class._meta.proxy or base_class._meta.abstract: 72 | # continue up the mro looking for non-proxy base classes 73 | mro.extend(base_class.__bases__) 74 | else: 75 | break 76 | else: 77 | base_class = None 78 | 79 | if base_class: 80 | # Enforce that subclasses are proxy models. 81 | # Update an existing metaclass, or define an empty one 82 | # then set proxy=True 83 | class Meta: 84 | proxy: bool 85 | app_label: str 86 | 87 | Meta = classdict.get("Meta", Meta) # type: ignore 88 | if getattr(Meta, "proxy", False): 89 | # If user has specified proxy=True explicitly, we assume that they want it to be treated like ordinary 90 | # proxy class, without TypedModel logic. 91 | return cast( 92 | type["TypedModel"], 93 | super().__new__(meta, classname, bases, classdict), 94 | ) 95 | Meta.proxy = True 96 | 97 | declared_fields = dict( 98 | (name, element) 99 | for name, element in list(classdict.items()) 100 | if isinstance(element, Field) 101 | ) 102 | 103 | for field_name, field in list(declared_fields.items()): 104 | # We need fields defined on subclasses to either: 105 | # * be a ManyToManyField 106 | # * have a default 107 | # * be nullable 108 | if not (field.many_to_many or field.null or field.has_default()): 109 | raise FieldError( 110 | "All fields defined on typedmodels subclasses must be nullable, " 111 | "or have a default set. " 112 | f"Add null=True to the {classname}.{field_name} field definition." 113 | ) 114 | 115 | if isinstance(field, models.fields.related.RelatedField): 116 | # Monkey patching field instance to make do_related_class use created class instead of base_class. 117 | # Actually that class doesn't exist yet, so we just monkey patch base_class for a while, 118 | # changing _meta.model_name, so accessor names are generated properly. 119 | # We'll do more stuff when the class is created. 120 | old_do_related_class = field.do_related_class 121 | 122 | def do_related_class(self, other, cls): 123 | base_class_name = base_class.__name__ 124 | cls._meta.model_name = classname.lower() 125 | old_do_related_class(other, cls) # noqa: B023 126 | cls._meta.model_name = base_class_name.lower() 127 | 128 | field.do_related_class = types.MethodType(do_related_class, field) # type: ignore 129 | if isinstance(field, models.fields.related.RelatedField): 130 | remote_field = field.remote_field 131 | if isinstance(remote_field.model, TypedModel) and remote_field.model.base_class: 132 | remote_field.limit_choices_to["type__in"] = ( 133 | remote_field.model._typedmodels_subtypes 134 | ) 135 | 136 | # Check if a field with this name has already been added to class 137 | try: 138 | duplicate_field = base_class._meta.get_field(field_name) 139 | except FieldDoesNotExist: 140 | field.contribute_to_class(base_class, field_name) 141 | else: 142 | # Check if the field being added is _exactly_ the same as the field 143 | # that already exists. 144 | assert isinstance(duplicate_field, Field) 145 | if duplicate_field.deconstruct()[1:] != field.deconstruct()[1:]: 146 | raise ValueError( 147 | "Can't add field '%s' from '%s' to '%s', field already exists.", 148 | field_name, 149 | classname, 150 | base_class.__name__, 151 | ) 152 | 153 | classdict.pop(field_name) 154 | base_class._meta.fields_from_subclasses.update(declared_fields) 155 | 156 | # set app_label to the same as the base class, unless explicitly defined otherwise 157 | if not hasattr(Meta, "app_label"): 158 | if hasattr(getattr(base_class, "_meta", None), "app_label"): 159 | Meta.app_label = base_class._meta.app_label 160 | 161 | classdict.update( 162 | { 163 | "Meta": Meta, 164 | } 165 | ) 166 | 167 | classdict["base_class"] = base_class 168 | 169 | cls = cast( 170 | type[TypedModel], 171 | super().__new__(meta, classname, bases, classdict), 172 | ) 173 | 174 | cls._meta.fields_from_subclasses = {} 175 | 176 | if base_class: 177 | opts = cls._meta 178 | 179 | model_name = opts.model_name 180 | typ = f"{opts.app_label}.{model_name}" 181 | cls._typedmodels_type = typ 182 | cls._typedmodels_subtypes = [typ] 183 | if typ in base_class._typedmodels_registry: 184 | raise ValueError( 185 | f"Can't register type {typ!r} to {classname!r} (already registered to {base_class._typedmodels_registry[typ].__name__!r})" 186 | ) 187 | base_class._typedmodels_registry[typ] = cls 188 | 189 | type_name = getattr(cls._meta, "verbose_name", cls.__name__) 190 | type_field = cast(models.CharField, base_class._meta.get_field("type")) 191 | choices = (*(type_field.choices or ()), (typ, type_name)) 192 | type_field.choices = sorted(choices) 193 | 194 | cls._meta.declared_fields = declared_fields 195 | 196 | # look for any other proxy superclasses, they'll need to know 197 | # about this subclass 198 | for superclass in cls.mro(): 199 | if ( 200 | issubclass(superclass, base_class) 201 | and superclass not in (cls, base_class) 202 | and hasattr(superclass, "_typedmodels_type") 203 | ): 204 | if superclass._typedmodels_subtypes is not None: 205 | superclass._typedmodels_subtypes.append(typ) 206 | 207 | meta._patch_fields_cache(cls, base_class) 208 | elif not cls._meta.abstract: 209 | # this is the base class 210 | cls._typedmodels_registry = {} 211 | 212 | # Since fields may be added by subclasses, save original fields. 213 | cls._meta._typedmodels_original_fields = {f.name for f in cls._meta.fields} 214 | cls._meta._typedmodels_original_many_to_many = {f.name for f in cls._meta.many_to_many} 215 | 216 | # add a get_type_classes classmethod to allow fetching of all the subclasses (useful for admin) 217 | 218 | def _get_type_classes(subcls) -> list[type["TypedModel"]]: 219 | """ 220 | Returns a list of the classes which are proxy subtypes of this concrete typed model. 221 | """ 222 | if subcls is cls: 223 | return list(cls._typedmodels_registry.values()) 224 | else: 225 | return [cls._typedmodels_registry[k] for k in subcls._typedmodels_subtypes] 226 | 227 | cls._get_type_classes = classmethod(_get_type_classes) # type: ignore 228 | 229 | def _get_types(subcls) -> list[str]: 230 | """ 231 | Returns a list of the possible string values (for the `type` attribute) for classes 232 | which are proxy subtypes of this concrete typed model. 233 | """ 234 | if subcls is cls: 235 | return list(cls._typedmodels_registry.keys()) 236 | else: 237 | return subcls._typedmodels_subtypes[:] 238 | 239 | cls.get_types = classmethod(_get_types) # type: ignore 240 | 241 | return cls 242 | 243 | @staticmethod 244 | def _model_has_field(cls, base_class: type[TypedModelT], field_name: str): 245 | if field_name in base_class._meta._typedmodels_original_many_to_many: 246 | return True 247 | if field_name in base_class._meta._typedmodels_original_fields: 248 | return True 249 | if any(f.name == field_name for f in base_class._meta.private_fields): 250 | return True 251 | for ancestor in cls.mro(): 252 | if issubclass(ancestor, base_class) and ancestor != base_class: 253 | if field_name in ancestor._meta.declared_fields.keys(): 254 | return True 255 | 256 | if field_name in cls._meta.fields_map: 257 | # Crazy case where a reverse M2M from another typedmodels proxy points to this proxy 258 | # (this is an m2m reverse field) 259 | return True 260 | return False 261 | 262 | @staticmethod 263 | def _patch_fields_cache(cls, base_class: type[TypedModelT]): 264 | orig_get_fields = cls._meta._get_fields 265 | 266 | if django.VERSION >= (5, 0): 267 | 268 | def _get_fields( 269 | self, 270 | forward=True, 271 | reverse=True, 272 | include_parents=True, 273 | include_hidden=False, 274 | topmost_call=True, 275 | ): 276 | cache_key = ( 277 | forward, 278 | reverse, 279 | include_parents, 280 | include_hidden, 281 | topmost_call, 282 | ) 283 | 284 | was_cached = cache_key in self._get_fields_cache 285 | fields = orig_get_fields( 286 | forward=forward, 287 | reverse=reverse, 288 | include_parents=include_parents, 289 | include_hidden=include_hidden, 290 | topmost_call=topmost_call, 291 | ) 292 | # If it was cached already, it's because we've already filtered this, skip it 293 | if not was_cached: 294 | fields = [ 295 | f 296 | for f in fields 297 | if TypedModelMetaclass._model_has_field(cls, base_class, f.name) 298 | ] 299 | fields = make_immutable_fields_list("get_fields()", fields) 300 | self._get_fields_cache[cache_key] = fields 301 | return fields 302 | 303 | else: 304 | 305 | def _get_fields( 306 | self, 307 | forward=True, 308 | reverse=True, 309 | include_parents=True, 310 | include_hidden=False, 311 | seen_models=None, 312 | ): 313 | cache_key = ( 314 | forward, 315 | reverse, 316 | include_parents, 317 | include_hidden, 318 | seen_models is None, 319 | ) 320 | 321 | was_cached = cache_key in self._get_fields_cache 322 | fields = orig_get_fields( 323 | forward=forward, 324 | reverse=reverse, 325 | include_parents=include_parents, 326 | include_hidden=include_hidden, 327 | seen_models=seen_models, 328 | ) 329 | # If it was cached already, it's because we've already filtered this, skip it 330 | if not was_cached: 331 | fields = [ 332 | f 333 | for f in fields 334 | if TypedModelMetaclass._model_has_field(cls, base_class, f.name) 335 | ] 336 | fields = make_immutable_fields_list("get_fields()", fields) 337 | self._get_fields_cache[cache_key] = fields 338 | return fields 339 | 340 | cls._meta._get_fields = partial(_get_fields, cls._meta) 341 | 342 | # If fields are already cached, expire the cache. 343 | cls._meta._expire_cache() 344 | 345 | 346 | class TypedModelOptions(Options): 347 | _typedmodels_original_fields: set[str] 348 | _typedmodels_original_many_to_many: set[str] 349 | fields_from_subclasses: dict[str, Field] 350 | declared_fields: dict[str, Field] 351 | 352 | 353 | class TypedModel(models.Model, metaclass=TypedModelMetaclass): 354 | ''' 355 | This class contains the functionality required to auto-downcast a model based 356 | on its ``type`` attribute. 357 | 358 | To use, simply subclass TypedModel for your base type, and then subclass 359 | that for your concrete types. 360 | 361 | Example usage:: 362 | 363 | from django.db import models 364 | from typedmodels.models import TypedModel 365 | 366 | class Animal(TypedModel): 367 | """ 368 | Abstract model 369 | """ 370 | name = models.CharField(max_length=255) 371 | 372 | def say_something(self): 373 | raise NotImplemented 374 | 375 | def __repr__(self): 376 | return u'<%s: %s>' % (self.__class__.__name__, self.name) 377 | 378 | class Canine(Animal): 379 | def say_something(self): 380 | return "woof" 381 | 382 | class Feline(Animal): 383 | def say_something(self): 384 | return "meoww" 385 | ''' 386 | 387 | _typedmodels_type: ClassVar[str] 388 | _typedmodels_subtypes: ClassVar[list[str]] 389 | _typedmodels_registry: ClassVar[dict[str, type["TypedModel"]]] 390 | _meta: ClassVar[TypedModelOptions] 391 | 392 | objects: ClassVar[TypedModelManager[Self]] = TypedModelManager() 393 | 394 | type = models.CharField(choices=(), max_length=255, null=False, blank=False, db_index=True) 395 | 396 | # Class variable indicating if model should be automatically recasted after initialization 397 | _auto_recast = True 398 | 399 | class Meta: 400 | abstract = True 401 | 402 | @classmethod 403 | def from_db(cls, db, field_names, values): 404 | # Called when django instantiates a model class from a queryset. 405 | _typedmodels_do_recast = True 406 | if "type" not in field_names: 407 | # LIMITATION: 408 | # `type` was deferred in the queryset. 409 | # So we can't cast this model without generating another query. 410 | # That'd be *really* bad for performance. 411 | # Most likely, this would have happened in `obj.fieldname`, where `fieldname` is deferred. 412 | # This will populate a queryset with .only('fieldname'), 413 | # leading to this situation where 'type' is deferred. 414 | # Django will then copy the fieldname from those model instances onto the original obj. 415 | # In this case we don't really need a typed subclass, so we choose to just not recast in 416 | # this situation. 417 | # 418 | # Unfortunately we can't tell the difference between this situation and 419 | # MyModel.objects.only('myfield'). If you do that, we will also not recast. 420 | # 421 | # If you want you can recast manually (call obj.recast() on each object in your 422 | # queryset) - but by far a better solution would be to not defer the `type` field 423 | # to start with. 424 | _typedmodels_do_recast = False 425 | 426 | if len(values) != len(cls._meta.concrete_fields): 427 | values_iter = iter(values) 428 | values = [ 429 | next(values_iter) if f.attname in field_names else DEFERRED 430 | for f in cls._meta.concrete_fields 431 | ] 432 | new = cls(*values, _typedmodels_do_recast=_typedmodels_do_recast) 433 | new._state.adding = False 434 | new._state.db = db 435 | return new 436 | 437 | @classmethod 438 | def get_type_classes(cls) -> list["__builtins__.type[Self]"]: 439 | """ 440 | Returns a list of the classes which are proxy subtypes of this concrete typed model. 441 | """ 442 | return cls._get_type_classes() # type: ignore 443 | 444 | @classmethod 445 | def get_types(cls) -> list[str]: 446 | """ 447 | Returns a list of the possible string values (for the `type` attribute) for classes 448 | which are proxy subtypes of this concrete typed model. 449 | """ 450 | return cls._get_types() # type: ignore 451 | 452 | def __init__(self, *args, _typedmodels_do_recast=None, **kwargs): 453 | # Calling __init__ on base class because some functions (e.g. save()) need access to field values from base 454 | # class. 455 | 456 | # Move args to kwargs since base_class may have more fields defined with different ordering 457 | args = list(args) 458 | if len(args) > len(self._meta.fields): 459 | # Daft, but matches old exception sans the err msg. 460 | raise IndexError("Number of args exceeds number of fields") 461 | for field_value, field in zip(args, self._meta.fields, strict=False): 462 | kwargs[field.attname] = field_value 463 | args = [] # args were all converted to kwargs 464 | 465 | if self.base_class: 466 | before_class = self.__class__ 467 | self.__class__ = self.base_class 468 | else: 469 | before_class = None 470 | super().__init__(*args, **kwargs) 471 | if before_class: 472 | self.__class__ = before_class 473 | 474 | if _typedmodels_do_recast is None: 475 | _typedmodels_do_recast = self._auto_recast 476 | if _typedmodels_do_recast: 477 | self.recast() 478 | 479 | def recast(self, typ: builtins.type["TypedModel"] | None = None) -> None: 480 | for base in reversed(self.__class__.mro()): 481 | if issubclass(base, TypedModel) and hasattr(base, "_typedmodels_registry"): 482 | break 483 | else: 484 | raise ValueError("No suitable base class found to recast!") 485 | 486 | if not self.type: 487 | if not hasattr(self, "_typedmodels_type"): 488 | # This is an instance of an untyped model 489 | if typ is None: 490 | # recast() is probably being called by __init__() here. 491 | # Ideally we'd raise an error here, but the django admin likes to call 492 | # model() and doesn't expect an error. 493 | # Instead, we raise an error when the object is saved. 494 | return 495 | else: 496 | self.type = self._typedmodels_type 497 | 498 | if typ is None: 499 | typ_str = self.type 500 | else: 501 | if isinstance(typ, type) and issubclass(typ, base): 502 | model_name = typ._meta.model_name 503 | typ_str = f"{typ._meta.app_label}.{model_name}" 504 | else: 505 | typ_str = str(typ) 506 | 507 | try: 508 | correct_cls = base._typedmodels_registry[typ_str] 509 | except KeyError: 510 | raise ValueError(f"Invalid {base.__name__} identifier: {typ!r}") from None 511 | 512 | self.type = typ_str 513 | 514 | current_cls = self.__class__ 515 | 516 | if current_cls != correct_cls: 517 | self.__class__ = correct_cls 518 | 519 | def save(self, *args, **kwargs) -> None: 520 | self.presave(*args, **kwargs) 521 | return super().save(*args, **kwargs) 522 | 523 | def presave(self, *args, **kwargs) -> None: 524 | """Perform checks before saving the model.""" 525 | if not getattr(self, "_typedmodels_type", None): 526 | raise RuntimeError(f"Untyped {self.__class__.__name__} cannot be saved.") 527 | 528 | def _get_unique_checks(self, exclude=None, **kwargs): 529 | unique_checks, date_checks = super()._get_unique_checks(exclude=exclude, **kwargs) 530 | 531 | for i, (_model_class, field_names) in reversed(list(enumerate(unique_checks))): 532 | for fn in field_names: 533 | try: 534 | self._meta.get_field(fn) 535 | except FieldDoesNotExist: 536 | # Some field in this unique check isn't actually on this proxy model. 537 | unique_checks.pop(i) 538 | break 539 | return unique_checks, date_checks 540 | 541 | 542 | # Monkey patching Python and XML serializers in Django to use model name from base class. 543 | # This should be preferably done by changing __unicode__ method for ._meta attribute in each model, 544 | # but it doesn’t work. 545 | _python_serializer_get_dump_object = _PythonSerializer.get_dump_object 546 | 547 | 548 | def _get_dump_object(self, obj: "Model") -> dict: 549 | if isinstance(obj, TypedModel): 550 | return { 551 | "pk": smart_str(obj._get_pk_val(), strings_only=True), 552 | "model": smart_str(getattr(obj, "base_class", obj)._meta), 553 | "fields": self._current, 554 | } 555 | else: 556 | return _python_serializer_get_dump_object(self, obj) 557 | 558 | 559 | _PythonSerializer.get_dump_object = _get_dump_object # type: ignore 560 | 561 | _xml_serializer_start_object = _XmlSerializer.start_object 562 | 563 | 564 | def _start_object(self, obj: "Model") -> None: 565 | if isinstance(obj, TypedModel): 566 | self.indent(1) 567 | obj_pk = obj._get_pk_val() 568 | modelname = smart_str(getattr(obj, "base_class", obj)._meta) 569 | if obj_pk is None: 570 | attrs = { 571 | "model": modelname, 572 | } 573 | else: 574 | attrs = { 575 | "pk": smart_str(obj._get_pk_val()), 576 | "model": modelname, 577 | } 578 | 579 | self.xml.startElement("object", attrs) 580 | else: 581 | return _xml_serializer_start_object(self, obj) 582 | 583 | 584 | _XmlSerializer.start_object = _start_object # type: ignore 585 | --------------------------------------------------------------------------------