├── tests ├── __init__.py ├── example_app │ ├── __init__.py │ ├── settings.py │ └── models.py ├── django_integrity │ ├── __init__.py │ ├── test_package.py │ ├── test_constraints.py │ └── test_conversion.py └── conftest.py ├── mypy-ratchet.json ├── src └── django_integrity │ ├── py.typed │ ├── __init__.py │ ├── conversion.py │ └── constraints.py ├── requirements ├── prerequisites.txt ├── release.txt ├── tox.txt ├── pytest-in-tox.txt └── development.txt ├── .github ├── CODEOWNERS └── workflows │ ├── checks.yaml │ ├── release-to-pypi.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── tox.ini ├── LICENSE ├── CHANGELOG.md ├── makefile ├── scripts ├── verify-version-tag.py └── type-ratchet.py ├── README.md ├── CONTRIBUTING.md └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mypy-ratchet.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/django_integrity/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/example_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/django_integrity/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/prerequisites.txt: -------------------------------------------------------------------------------- 1 | pip==24.0.0 2 | uv==0.1.40 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @kraken-tech/django-integrity-maintainers 2 | -------------------------------------------------------------------------------- /src/django_integrity/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Integrity. 3 | 4 | This package contains tools for controlling deferred constraints 5 | and handling IntegrityErrors in Django projects which use PostgreSQL. 6 | """ 7 | -------------------------------------------------------------------------------- /tests/example_app/settings.py: -------------------------------------------------------------------------------- 1 | from environs import Env 2 | 3 | 4 | env = Env() 5 | 6 | 7 | DATABASES = { 8 | "default": env.dj_db_url( 9 | "DATABASE_URL", default="postgres://localhost/django_integrity" 10 | ), 11 | } 12 | SECRET_KEY = "test-secret-key" 13 | INSTALLED_APPS = [ 14 | "tests.example_app", 15 | ] 16 | USE_TZ = True 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python byte-code 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | *.egg-info/ 7 | build/ 8 | 9 | # Linting 10 | .mypy_cache/ 11 | .import_linter_cache/ 12 | 13 | # Testing 14 | .pytest_cache/ 15 | .tox/ 16 | 17 | # Environments 18 | .venv/ 19 | venv/ 20 | .direnv/ 21 | .envrc 22 | 23 | # Environment variables 24 | .env 25 | 26 | # Pyenv 27 | .python-version 28 | -------------------------------------------------------------------------------- /tests/django_integrity/test_package.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django import db 3 | 4 | import django_integrity as package 5 | 6 | 7 | def test_has_docstring() -> None: 8 | assert package.__doc__ is not None 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_has_database() -> None: 13 | """Test that the database is available.""" 14 | with db.connection.cursor() as cursor: 15 | cursor.execute("SELECT 1") 16 | assert cursor.fetchone() == (1,) 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [pre-commit] 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.4.3 5 | hooks: 6 | - id: ruff 7 | args: [--fix, --show-fixes] 8 | - id: ruff-format 9 | - repo: local 10 | hooks: 11 | - id: mypy 12 | name: mypy 13 | types_or: [python, pyi] 14 | pass_filenames: false 15 | language: system 16 | entry: ./scripts/type-ratchet.py update 17 | -------------------------------------------------------------------------------- /requirements/release.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml --resolver=backtracking --strip-extras --extra=release --output-file=requirements/release.txt 3 | click==8.1.7 4 | # via typer 5 | markdown-it-py==3.0.0 6 | # via rich 7 | mdurl==0.1.2 8 | # via markdown-it-py 9 | packaging==24.0 10 | pygments==2.18.0 11 | # via rich 12 | rich==13.7.1 13 | # via typer 14 | shellingham==1.5.4 15 | # via typer 16 | tomli==2.0.1 17 | typer==0.12.3 18 | typing-extensions==4.11.0 19 | # via typer 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4.0 3 | env_list = 4 | py3{10,11}-django41-psycopg2 5 | py3{10,11,12,13,14}-django{42,50}-psycopg{2,3} 6 | 7 | [testenv] 8 | # Install wheels instead of source distributions for faster execution. 9 | package = wheel 10 | 11 | # Share the build environment between tox environments. 12 | wheel_build_env = .pkg 13 | 14 | pass_env = 15 | DATABASE_URL 16 | deps = 17 | -r requirements/pytest-in-tox.txt 18 | django41: django>=4.1,<4.2 19 | django42: django>=4.2,<5.0 20 | django50: django>=5.0,<5.1 21 | psycopg2: psycopg2-binary 22 | psycopg3: psycopg[binary] 23 | 24 | commands = 25 | python -m pytest {posargs} 26 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from pytest_django import fixtures, lazy_django 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def django_db_modify_db_settings_tox_suffix() -> None: 9 | """ 10 | Suffixed test databases names for tox workers to avoid clashing databases. 11 | 12 | This works around a bug in pytest-django, where the wrong environment 13 | variable is used. 14 | 15 | Ref: https://github.com/pytest-dev/pytest-django/pull/1112 16 | """ 17 | lazy_django.skip_if_no_django() 18 | 19 | tox_environment = os.getenv("TOX_ENV_NAME") 20 | if tox_environment: 21 | fixtures._set_suffix_to_test_databases(suffix=tox_environment) 22 | -------------------------------------------------------------------------------- /requirements/tox.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml --resolver=backtracking --strip-extras --extra=tox --output-file=requirements/tox.txt 3 | cachetools==5.3.3 4 | # via tox 5 | chardet==5.2.0 6 | # via tox 7 | colorama==0.4.6 8 | # via tox 9 | distlib==0.3.8 10 | # via virtualenv 11 | filelock==3.14.0 12 | # via 13 | # tox 14 | # virtualenv 15 | packaging==24.0 16 | # via 17 | # pyproject-api 18 | # tox 19 | # tox-uv 20 | platformdirs==4.2.1 21 | # via 22 | # tox 23 | # virtualenv 24 | pluggy==1.5.0 25 | # via tox 26 | pyproject-api==1.6.1 27 | # via tox 28 | tomli==2.0.1 29 | # via 30 | # pyproject-api 31 | # tox 32 | tox==4.15.0 33 | # via tox-uv 34 | tox-uv==1.8.2 35 | uv==0.1.42 36 | # via tox-uv 37 | virtualenv==20.26.1 38 | # via tox 39 | -------------------------------------------------------------------------------- /.github/workflows/checks.yaml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-22.04 12 | timeout-minutes: 3 13 | 14 | steps: 15 | - name: Clone the code 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Python 3.10 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.10" 22 | cache: 'pip' 23 | cache-dependency-path: | 24 | pyproject.toml 25 | requirements/*.txt 26 | tox.ini 27 | 28 | - name: Make a virtualenv 29 | run: python3 -m venv .venv 30 | 31 | - name: Install requirements 32 | run: | 33 | source .venv/bin/activate 34 | make install 35 | 36 | - name: Run linters 37 | run: | 38 | source .venv/bin/activate 39 | make lint 40 | -------------------------------------------------------------------------------- /requirements/pytest-in-tox.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml --resolver=backtracking --strip-extras --extra=pytest-in-tox --output-file=requirements/pytest-in-tox.txt --unsafe-package django 3 | asgiref==3.8.1 4 | # via 5 | # django 6 | # django-stubs 7 | dj-database-url==2.1.0 8 | django-stubs==5.0.0 9 | django-stubs-ext==5.0.0 10 | # via django-stubs 11 | environs==11.0.0 12 | exceptiongroup==1.2.1 13 | # via pytest 14 | iniconfig==2.0.0 15 | # via pytest 16 | marshmallow==3.21.2 17 | # via environs 18 | packaging==24.0 19 | # via 20 | # marshmallow 21 | # pytest 22 | pluggy==1.5.0 23 | # via pytest 24 | pytest==8.2.0 25 | # via pytest-django 26 | pytest-django==4.8.0 27 | python-dotenv==1.0.1 28 | # via environs 29 | sqlparse==0.5.0 30 | # via django 31 | tomli==2.0.1 32 | # via 33 | # django-stubs 34 | # pytest 35 | types-pyyaml==6.0.12.20240311 36 | # via django-stubs 37 | typing-extensions==4.11.0 38 | # via 39 | # asgiref 40 | # dj-database-url 41 | # django-stubs 42 | # django-stubs-ext 43 | 44 | # The following packages were excluded from the output: 45 | # django 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Kraken Technologies Limited 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## Unreleased changes 6 | 7 | ### Changed 8 | 9 | - The package is now tested against Python3.14 10 | and verified to support that version. 11 | 12 | ## v0.2.0 - 2024-05-13 13 | 14 | ### Changed 15 | 16 | - Change type signature of `django_integrity.conversion.refine_integrity_error`. 17 | Instead of accepting `Mapping[_Rule, Exception]`, it now accepts `Sequence[tuple[_Rule, Exception | type[Exception]]`. 18 | This prevents issues with typing, and removes the need for `_Rule` to be hashable. 19 | - Install CI and development requirements with `uv` instead of `pip-tools`. 20 | 21 | ### Fixed 22 | 23 | - Fix some more incorrect type signatures: 24 | - `django_integrity.conversion.Unique.fields` was erroneously `tuple[str]` instead of `tuple[str, ...]`. 25 | - Protect against previously-unhandled potential `None` in errors from Psycopg. 26 | 27 | ## v0.1.1 - 2024-05-09 28 | 29 | - Fix some incorrect type signatures. 30 | We were mistakenly asking for `django.db.models.Model` instead of `type[django.db.models.Model]` in: 31 | - `django_integrity.constraints.foreign_key_constraint_name` 32 | - `django_integrity.conversion.Unique` 33 | - `django_integrity.conversion.PrimaryKey` 34 | - `django_integrity.conversion.NotNull` 35 | - `django_integrity.conversion.ForeignKey` 36 | 37 | ## v0.1.0 - 2024-05-07 38 | 39 | - Initial release! WOO! 40 | - Tested against sensible combinations of: 41 | - Python 3.10, 3.11, and 3.12. 42 | - Django 4.1, 4.2, and 5.0. 43 | - PostgreSQL 12 to 16. 44 | - psycopg2 and psycopg3. 45 | -------------------------------------------------------------------------------- /.github/workflows/release-to-pypi.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release to PyPI 3 | 4 | on: 5 | pull_request: 6 | push: 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | build: 12 | name: Build dist package 13 | runs-on: ubuntu-22.04 14 | timeout-minutes: 5 15 | 16 | steps: 17 | - name: Clone the code 18 | uses: actions/checkout@v4 19 | 20 | - uses: hynek/build-and-inspect-python-package@v2 21 | 22 | verify: 23 | name: Verify versions 24 | runs-on: ubuntu-22.04 25 | timeout-minutes: 5 26 | if: github.repository_owner == 'kraken-tech' && github.ref_type == 'tag' 27 | 28 | steps: 29 | - name: Clone the code 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: "3.12" 36 | cache: 'pip' 37 | cache-dependency-path: | 38 | pyproject.toml 39 | requirements/*.txt 40 | tox.ini 41 | 42 | - name: Install requirements 43 | run: pip install --requirement requirements/release.txt 44 | 45 | - name: Verify version 46 | run: ./scripts/verify-version-tag.py 47 | 48 | release: 49 | name: Publish to pypi.org 50 | environment: release 51 | if: github.repository_owner == 'kraken-tech' && github.ref_type == 'tag' 52 | needs: [build, verify] 53 | runs-on: ubuntu-22.04 54 | timeout-minutes: 5 55 | 56 | permissions: 57 | id-token: write 58 | 59 | steps: 60 | - name: Download packages 61 | uses: actions/download-artifact@v4 62 | with: 63 | name: Packages 64 | path: dist 65 | 66 | - name: Upload package to PyPI 67 | uses: pypa/gh-action-pypi-publish@release/v1 68 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | timeout-minutes: 5 13 | 14 | strategy: 15 | matrix: 16 | postgres-image: 17 | - "postgres:12" 18 | - "postgres:13" 19 | - "postgres:14" 20 | - "postgres:15" 21 | - "postgres:16" 22 | 23 | services: 24 | postgres: 25 | image: ${{ matrix.postgres-image }} 26 | env: 27 | POSTGRES_PASSWORD: postgres 28 | options: >- 29 | --health-cmd pg_isready 30 | --health-interval 10s 31 | --health-timeout 5s 32 | --health-retries 5 33 | ports: 34 | - 5432:5432 35 | 36 | steps: 37 | - name: Clone the code 38 | uses: actions/checkout@v4 39 | 40 | - name: Set up Python versions 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: | 44 | 3.10 45 | 3.11 46 | 3.12 47 | 3.13 48 | 3.14 49 | allow-prereleases: true 50 | cache: 'pip' 51 | cache-dependency-path: | 52 | pyproject.toml 53 | requirements/*.txt 54 | tox.ini 55 | 56 | - name: Make a virtualenv 57 | run: python3 -m venv .venv 58 | 59 | - name: Install requirements 60 | run: | 61 | source .venv/bin/activate 62 | pip install --requirement requirements/prerequisites.txt 63 | uv pip install --requirement requirements/tox.txt 64 | 65 | - name: Run the tests 66 | run: | 67 | source .venv/bin/activate 68 | tox --parallel --parallel-no-spinner --skip-missing-interpreters=false 69 | env: 70 | DATABASE_URL: postgres://postgres:postgres@localhost/django_integrity 71 | -------------------------------------------------------------------------------- /tests/example_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class PrimaryKeyModel(models.Model): 5 | # Serialize must not be on because our tests try to create instances with clashing IDs. 6 | id = models.BigAutoField(primary_key=True, serialize=False) 7 | 8 | 9 | class AlternativePrimaryKeyModel(models.Model): 10 | # Serialize must not be on because our tests try to create instances with clashing IDs. 11 | identity = models.BigAutoField(primary_key=True, serialize=False) 12 | 13 | 14 | class ForeignKeyModel(models.Model): 15 | related = models.ForeignKey(PrimaryKeyModel, on_delete=models.CASCADE) 16 | 17 | 18 | class ForeignKeyModel2(models.Model): 19 | related = models.ForeignKey(PrimaryKeyModel, on_delete=models.CASCADE) 20 | 21 | 22 | class ForeignKeyModel3(models.Model): 23 | related_1 = models.ForeignKey( 24 | AlternativePrimaryKeyModel, on_delete=models.CASCADE, related_name="+" 25 | ) 26 | related_2 = models.ForeignKey( 27 | AlternativePrimaryKeyModel, 28 | on_delete=models.CASCADE, 29 | related_name="+", 30 | null=True, 31 | ) 32 | 33 | 34 | class UniqueModel(models.Model): 35 | unique_field = models.IntegerField() 36 | 37 | class Meta: 38 | constraints = ( 39 | models.UniqueConstraint( 40 | fields=["unique_field"], 41 | name="unique_model_unique_field_key", 42 | deferrable=models.Deferrable.IMMEDIATE, 43 | ), 44 | ) 45 | 46 | 47 | class AlternativeUniqueModel(models.Model): 48 | unique_field = models.IntegerField(unique=True) 49 | unique_field_2 = models.IntegerField(unique=True) 50 | 51 | 52 | class UniqueTogetherModel(models.Model): 53 | field_1 = models.IntegerField() 54 | field_2 = models.IntegerField() 55 | 56 | class Meta: 57 | constraints = ( 58 | models.UniqueConstraint( 59 | fields=["field_1", "field_2"], 60 | name="unique_together_model_field_1_field_2_key", 61 | ), 62 | ) 63 | 64 | 65 | class AbstractModel(models.Model): 66 | class Meta: 67 | abstract = True 68 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 3 | .PHONY:help 4 | help: 5 | @echo "Available targets:" 6 | @echo " help: Show this help message" 7 | @echo " install: Install dev dependencies" 8 | @echo " update: Update dev dependencies" 9 | @echo " test: Run Python tests" 10 | @echo " lint: Run formatters and static analysis checks" 11 | 12 | 13 | # Standard entry points 14 | # ===================== 15 | 16 | .PHONY:install 17 | install: install_python_packages install_pre_commit 18 | 19 | .PHONY:test 20 | test: 21 | tox --parallel 22 | ./scripts/type-ratchet.py check 23 | 24 | .PHONY:lint 25 | lint: 26 | pre-commit run --all-files 27 | 28 | .PHONY:update 29 | update: 30 | uv pip compile pyproject.toml \ 31 | --quiet --upgrade --resolver=backtracking --strip-extras \ 32 | --extra=dev \ 33 | --output-file=requirements/development.txt 34 | uv pip compile pyproject.toml \ 35 | --quiet --upgrade --resolver=backtracking --strip-extras \ 36 | --extra=pytest-in-tox \ 37 | --output-file=requirements/pytest-in-tox.txt \ 38 | --unsafe-package django 39 | uv pip compile pyproject.toml \ 40 | --quiet --upgrade --resolver=backtracking --strip-extras \ 41 | --extra=release \ 42 | --output-file=requirements/release.txt 43 | uv pip compile pyproject.toml \ 44 | --quiet --upgrade --resolver=backtracking --strip-extras \ 45 | --extra=tox \ 46 | --output-file=requirements/tox.txt 47 | 48 | 49 | # Implementation details 50 | # ====================== 51 | 52 | # Pip install all required Python packages 53 | .PHONY:install_python_packages 54 | install_python_packages: install_prerequisites requirements/development.txt 55 | uv pip sync requirements/development.txt 56 | 57 | .PHONY:install_prerequisites 58 | install_prerequisites: requirements/prerequisites.txt 59 | pip install --quiet --requirement requirements/prerequisites.txt 60 | 61 | .PHONY:install_pre_commit 62 | install_pre_commit: 63 | pre-commit install 64 | 65 | # Add new dependencies to requirements/development.txt whenever pyproject.toml changes 66 | requirements/development.txt: pyproject.toml 67 | uv pip compile pyproject.toml \ 68 | --quiet --resolver=backtracking --strip-extras \ 69 | --extra=dev \ 70 | --output-file=requirements/development.txt 71 | -------------------------------------------------------------------------------- /requirements/development.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml --resolver=backtracking --strip-extras --extra=dev --output-file=requirements/development.txt 3 | asgiref==3.8.1 4 | # via 5 | # django 6 | # django-stubs 7 | cachetools==5.3.3 8 | # via tox 9 | cfgv==3.4.0 10 | # via pre-commit 11 | chardet==5.2.0 12 | # via tox 13 | click==8.1.7 14 | # via typer 15 | colorama==0.4.6 16 | # via tox 17 | distlib==0.3.8 18 | # via virtualenv 19 | dj-database-url==2.1.0 20 | django==5.0.6 21 | # via 22 | # dj-database-url 23 | # django-stubs 24 | # django-stubs-ext 25 | django-stubs==5.0.0 26 | django-stubs-ext==5.0.0 27 | # via django-stubs 28 | environs==11.0.0 29 | exceptiongroup==1.2.1 30 | # via pytest 31 | filelock==3.14.0 32 | # via 33 | # tox 34 | # virtualenv 35 | identify==2.5.36 36 | # via pre-commit 37 | iniconfig==2.0.0 38 | # via pytest 39 | markdown-it-py==3.0.0 40 | # via rich 41 | marshmallow==3.21.2 42 | # via environs 43 | mdurl==0.1.2 44 | # via markdown-it-py 45 | mypy==1.10.0 46 | mypy-extensions==1.0.0 47 | # via mypy 48 | mypy-json-report==1.2.0 49 | nodeenv==1.8.0 50 | # via pre-commit 51 | packaging==24.0 52 | # via 53 | # marshmallow 54 | # pyproject-api 55 | # pytest 56 | # tox 57 | platformdirs==4.2.1 58 | # via 59 | # tox 60 | # virtualenv 61 | pluggy==1.5.0 62 | # via 63 | # pytest 64 | # tox 65 | pre-commit==3.7.0 66 | psycopg==3.1.18 67 | psycopg-binary==3.1.18 68 | # via psycopg 69 | psycopg2-binary==2.9.9 70 | pygments==2.18.0 71 | # via rich 72 | pyproject-api==1.6.1 73 | # via tox 74 | pytest==8.2.0 75 | # via pytest-django 76 | pytest-django==4.8.0 77 | python-dotenv==1.0.1 78 | # via environs 79 | pyyaml==6.0.1 80 | # via pre-commit 81 | rich==13.7.1 82 | # via typer 83 | setuptools==69.5.1 84 | # via nodeenv 85 | shellingham==1.5.4 86 | # via typer 87 | sqlparse==0.5.0 88 | # via django 89 | tomli==2.0.1 90 | # via 91 | # django-stubs 92 | # mypy 93 | # pyproject-api 94 | # pytest 95 | # tox 96 | tox==4.15.0 97 | typer==0.12.3 98 | types-psycopg2==2.9.21.20240417 99 | types-pyyaml==6.0.12.20240311 100 | # via django-stubs 101 | typing-extensions==4.11.0 102 | # via 103 | # asgiref 104 | # dj-database-url 105 | # django-stubs 106 | # django-stubs-ext 107 | # mypy 108 | # psycopg 109 | # typer 110 | virtualenv==20.26.1 111 | # via 112 | # pre-commit 113 | # tox 114 | -------------------------------------------------------------------------------- /scripts/verify-version-tag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | A script to help with making a new release. 4 | 5 | This script verifies that the current commit has a tag, 6 | that the tag matches the version in the `pyproject.toml` file, 7 | and that the tag is in the CHANGELOG. 8 | """ 9 | 10 | import pathlib 11 | import subprocess 12 | import sys 13 | 14 | import rich 15 | import typer 16 | from packaging.version import InvalidVersion, Version 17 | 18 | 19 | if sys.version_info >= (3, 11): 20 | import tomllib 21 | else: 22 | # We can remove this when we drop support for Python 3.10. 23 | import tomli as tomllib 24 | 25 | 26 | PYPROJECT_FILE = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml" 27 | CHANGELOG_FILE = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md" 28 | app = typer.Typer() 29 | 30 | 31 | @app.command() 32 | def main() -> None: 33 | # Get the tags on the current commit. 34 | tags = ( 35 | subprocess.check_output(["git", "tag", "--points-at", "HEAD"]).decode().split() 36 | ) 37 | 38 | # Find a tag that looks like a version. 39 | versions: list[Version] = [] 40 | for tag in tags: 41 | try: 42 | version = Version(tag) 43 | except InvalidVersion: 44 | rich.print(f"[yellow]Skipping non-version tag:[/] {tag}") 45 | else: 46 | versions.append(version) 47 | 48 | if not versions: 49 | rich.print("[red]No version tags found.") 50 | raise typer.Abort() 51 | elif len(versions) > 1: 52 | rich.print(f"[red]Multiple version tags found:[/] {versions}.") 53 | raise typer.Abort() 54 | 55 | (tag_version,) = versions 56 | 57 | # Get the version from the pyproject.toml file. 58 | pyproject_content = PYPROJECT_FILE.read_text() 59 | project_config = tomllib.loads(pyproject_content) 60 | project_version_str = project_config["project"]["version"] 61 | project_version = Version(project_version_str) 62 | 63 | # Check that the tag matches the version. 64 | if tag_version == project_version: 65 | rich.print("[green]Tag matches version in pyproject.toml.") 66 | else: 67 | rich.print("[red]Versions do not match:") 68 | rich.print(f" Git tag: {tag_version}") 69 | rich.print(f" Package: {project_version}") 70 | raise typer.Abort() 71 | 72 | # Check that the tag is in the CHANGELOG. 73 | return_code = subprocess.run( 74 | ["git", "grep", rf"\bv{tag_version}\b", "HEAD", "--", str(CHANGELOG_FILE)], 75 | ).returncode 76 | if return_code == 0: 77 | rich.print("[green]Tag found in CHANGELOG.") 78 | else: 79 | rich.print(f"[red]Tag not committed to CHANGELOG:[/] {tag_version}.") 80 | raise typer.Abort() 81 | 82 | 83 | if __name__ == "__main__": 84 | app() 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Integrity 2 | 3 | Django Integrity contains tools for controlling deferred constraints 4 | and handling `IntegrityError`s in Django projects which use PostgreSQL. 5 | 6 | ## Deferrable constraints 7 | 8 | Some PostgreSQL constraints can be defined as `DEFERRABLE`. 9 | A constraint that is not deferred will be checked immediately after every command. 10 | A deferred constraint check will be postponed until the end of the transaction. 11 | A deferrable constraint will default to either `DEFERRED` or `IMMEDIATE`. 12 | 13 | The utilities in `django_integrity.constraints` can 14 | ensure a deferred constraint is checked immediately, 15 | or defer an immediate constraint. 16 | 17 | These alter the state of constraints until the end of the current transaction: 18 | 19 | - `set_all_immediate(using=...)` 20 | - `set_immedate(names=(...), using=...)` 21 | - `set_deferred(names=(...), using=...)` 22 | 23 | To enforce a constraint immediately within some limited part of a transaction, 24 | use the `immediate(names=(...), using=...)` context manager. 25 | 26 | ### Why do we need this? 27 | 28 | This is most likely to be useful when you want to catch a foreign-key violation 29 | (i.e.: you have inserted a row which references different row which doesn't exist). 30 | 31 | Django's foreign key constraints are deferred by default, 32 | so they would normally raise an error only at the end of a transaction. 33 | Using `try` to catch an `IntegrityError` from a foreign-key violation wouldn't work, 34 | and you'd need to wrap the `COMMIT` instead, which is trickier. 35 | 36 | By making the constraint `IMMEDIATE`, 37 | the constraint would be checked on `INSERT`, 38 | and it would be much easier to catch. 39 | 40 | More generally, 41 | if you have a custom deferrable constraint, 42 | it may be useful to change the default behaviour with these tools. 43 | 44 | ## Refining `IntegrityError` 45 | 46 | The `refine_integrity_error` context manager in `django_integrity.conversion` 47 | will convert an `IntegrityError` into a more specific exception 48 | based on a mapping of rules to your custom exceptions, 49 | and will raise the `IntegrityError` if it doesn't match. 50 | 51 | ### Why do we need this? 52 | 53 | When a database constraint is violated, 54 | we usually expect to see an `IntegrityError`. 55 | 56 | Sometimes we need more information about the error: 57 | was it a unique constraint violation, or a check-constraint, or a not-null constraint? 58 | Perhaps we ran out of 32-bit integers for our ID column? 59 | Failing to be specific on these points could lead to bugs 60 | where we catch an exception without realising it was not the one we expected. 61 | 62 | ### Example 63 | 64 | ```python 65 | from django_integrity import conversion 66 | from users.models import User 67 | 68 | 69 | class UserAlreadyExists(Exception): ... 70 | class EmailCannotBeNull(Exception): ... 71 | class EmailMustBeLowerCase(Exception): ... 72 | 73 | 74 | def create_user(email: str) -> User: 75 | """ 76 | Creates a user with the provided email address. 77 | 78 | Raises: 79 | UserAlreadyExists: If the email was not unique. 80 | EmailCannotBeNull: If the email was None. 81 | EmailMustBeLowerCase: If the email had a non-lowercase character. 82 | """ 83 | rules = [ 84 | (conversion.Unique(model=User, fields=("email",)), UserAlreadyExists), 85 | (conversion.NotNull(model=User, field="email"), EmailCannotBeNull), 86 | (conversion.Named(name="constraint_islowercase"), EmailMustBeLowerCase), 87 | ] 88 | with conversion.refine_integrity_error(rules): 89 | User.objects.create(email=email) 90 | ``` 91 | 92 | ## Supported dependencies 93 | 94 | This package is tested against: 95 | 96 | - Python 3.10, 3.11, 3.12, 3.13, and 3.14. 97 | - Django 4.1, 4.2, or 5.0. 98 | - PostgreSQL 12 to 16. 99 | - psycopg2 and psycopg3. 100 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Local development 4 | 5 | ### Creating a virtual environment 6 | 7 | Ensure one of the supported Pythons (see README) is installed and used by the `python` executable: 8 | 9 | ```sh 10 | python3 --version 11 | ``` 12 | 13 | Then create and activate a virtual environment. If you don't have any other way of managing virtual 14 | environments this can be done by running: 15 | 16 | ```sh 17 | python3 -m venv .venv 18 | source .venv/bin/activate 19 | ``` 20 | 21 | You could also use [virtualenvwrapper], [direnv] or any similar tool to help manage your virtual 22 | environments. 23 | 24 | ### Install PostgreSQL 25 | 26 | Ensure that a supported version of PostgreSQL (see README) is installed and running on your local machine. 27 | 28 | ### Installing Python dependencies 29 | 30 | To install all the development dependencies in your virtual environment, run: 31 | 32 | ```sh 33 | make install 34 | ``` 35 | 36 | [direnv]: https://direnv.net 37 | [virtualenvwrapper]: https://virtualenvwrapper.readthedocs.io/ 38 | 39 | ### Testing 40 | 41 | To start the tests with [tox], run: 42 | 43 | ```sh 44 | make test 45 | ``` 46 | 47 | Alternatively, if you want to run the tests directly in your virtual environment, 48 | you many run the tests with: 49 | 50 | ```sh 51 | PYTHONPATH=src python3 -m pytest 52 | ``` 53 | 54 | ### Static analysis 55 | 56 | Run all static analysis tools with: 57 | 58 | ```sh 59 | make lint 60 | ``` 61 | 62 | ### Managing dependencies 63 | 64 | Package dependencies are declared in `pyproject.toml`. 65 | 66 | - _package_ dependencies in the `dependencies` array in the `[project]` section. 67 | - _development_ dependencies in the `dev` array in the `[project.optional-dependencies]` section. 68 | 69 | For local development, the dependencies declared in `pyproject.toml` are pinned to specific 70 | versions using the `requirements/development.txt` lock file. 71 | You should not manually edit the `requirements/development.txt` lock file. 72 | 73 | Prerequisites for installing those dependencies are tracked in the `requirements/prerequisites.txt`. 74 | 75 | 76 | #### Adding a new dependency 77 | 78 | To install a new Python dependency add it to the appropriate section in `pyproject.toml` and then 79 | run: 80 | 81 | ```sh 82 | make install 83 | ``` 84 | 85 | This will: 86 | 87 | 1. Build a new version of the `requirements/development.txt` lock file containing the newly added 88 | package. 89 | 2. Sync your installed packages with those pinned in `requirements/development.txt`. 90 | 91 | This will not change the pinned versions of any packages already in any requirements file unless 92 | needed by the new packages, even if there are updated versions of those packages available. 93 | 94 | Remember to commit your changed `requirements/development.txt` files alongside the changed 95 | `pyproject.toml`. 96 | 97 | #### Removing a dependency 98 | 99 | Removing Python dependencies works exactly the same way: edit `pyproject.toml` and then run 100 | `make install`. 101 | 102 | #### Updating all Python packages 103 | 104 | To update the pinned versions of all packages run: 105 | 106 | ```sh 107 | make update 108 | ``` 109 | 110 | This will update the pinned versions of every package in the `requirements/development.txt` lock 111 | file to the latest version which is compatible with the constraints in `pyproject.toml`. 112 | 113 | You can then run: 114 | 115 | ```sh 116 | make install 117 | ``` 118 | 119 | to sync your installed packages with the updated versions pinned in `requirements/development.txt`. 120 | 121 | #### Updating individual Python packages 122 | 123 | Upgrade a single development dependency with: 124 | 125 | ```sh 126 | pip-compile -P $PACKAGE==$VERSION pyproject.toml --resolver=backtracking --extra=dev --output-file=requirements/development.txt 127 | ``` 128 | 129 | You can then run: 130 | 131 | ```sh 132 | make install 133 | ``` 134 | 135 | to sync your installed packages with the updated versions pinned in `requirements/development.txt`. 136 | 137 | [tox]: https://tox.wiki 138 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Packaging 2 | # --------- 3 | 4 | [build-system] 5 | requires = ["setuptools>=67.0"] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.setuptools] 9 | # This is the default but we include it to be explicit. 10 | include-package-data = true 11 | 12 | [tool.setuptools.packages.find] 13 | where = ["src"] 14 | 15 | # Project 16 | # ------- 17 | 18 | [project] 19 | name = "django_integrity" 20 | version = "0.2.0" 21 | description = "Tools for refining Django's IntegrityError, and working with deferred database constraints." 22 | license.file = "LICENSE" 23 | readme = "README.md" 24 | requires-python = ">=3.10" 25 | dependencies = [ 26 | # We cannot decide for users if they want to use psycopg2, psycopg2-binary, or 27 | # psycopg (i.e. psycopg3) with or without the [binary] extra. It should be part of 28 | # their own project dependencies anyway. 29 | # See https://www.psycopg.org/docs/install.html#psycopg-vs-psycopg-binary 30 | # See https://www.psycopg.org/psycopg3/docs/basic/install.html#binary-installation 31 | ] 32 | classifiers = [ 33 | "Development Status :: 3 - Alpha", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: BSD License", 36 | "Operating System :: OS Independent", 37 | "Programming Language :: Python :: 3", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Programming Language :: Python :: 3.13", 42 | "Programming Language :: Python :: 3.14", 43 | "Programming Language :: Python :: 3 :: Only", 44 | "Typing :: Typed", 45 | ] 46 | 47 | 48 | [project.urls] 49 | # See https://daniel.feldroy.com/posts/2023-08-pypi-project-urls-cheatsheet for 50 | # additional URLs that can be included here. 51 | repository = "https://github.com/kraken-tech/django-integrity" 52 | changelog = "https://github.com/kraken-tech/django-integrity/blob/main/CHANGELOG.md" 53 | 54 | [project.optional-dependencies] 55 | # None of these extras are recommended for normal installation. 56 | # We use combinations of these groups in development and testing environments. 57 | pytest-in-tox = [ 58 | # We deliberately exclude Django and Psycopg from this list because 59 | # this groups is used for running the pytest tests with tox, 60 | # and those packages are a part of tox's test matrix. 61 | "dj-database-url>=2.1.0", 62 | "django-stubs>=5.0.0", 63 | "environs>=11.0.0", 64 | "pytest-django>=4.8.0", 65 | "pytest>=8.2.0", 66 | ] 67 | release = [ 68 | "packaging>=24.0", 69 | "rich>=13.7.1", 70 | "tomli >= 1.1.0 ; python_version < '3.11'", 71 | "typer>=0.12.3", 72 | ] 73 | tox = [ 74 | "tox-uv>=1.8.2", 75 | "tox>=4.15.0", 76 | ] 77 | dev = [ 78 | # Testing 79 | "dj-database-url>=2.1.0", 80 | "django>=4.2.0", 81 | "django-stubs>=5.0.0", 82 | "environs>=11.0.0", 83 | "psycopg2-binary>=2.9.9", 84 | "psycopg[binary]>=3.1.18", 85 | "pytest-django>=4.8.0", 86 | "pytest>=8.2.0", 87 | "tox>=4.15.0", 88 | "types-psycopg2>=2.9.21.20240417", 89 | 90 | # Linting 91 | "mypy>=1.10.0", 92 | "mypy-json-report>=1.2.0", 93 | "pre-commit>=3.7.0", 94 | 95 | # CLI utils 96 | "packaging>=24.0", 97 | "rich>=13.7.1", 98 | "tomli >= 1.1.0 ; python_version < '3.11'", 99 | "typer>=0.12.3", 100 | ] 101 | 102 | # Ruff 103 | # ---- 104 | 105 | [tool.ruff] 106 | lint.select = [ 107 | "E", # pycodestyle errors 108 | "W", # pycodestyle warnings 109 | "F", # pyflakes 110 | "I", # isort 111 | ] 112 | lint.ignore = [ 113 | "E501", # line too long - the formatter takes care of this for us 114 | ] 115 | 116 | [tool.ruff.lint.isort] 117 | lines-after-imports = 2 118 | section-order = [ 119 | "future", 120 | "standard-library", 121 | "third-party", 122 | "first-party", 123 | "project", 124 | "local-folder", 125 | ] 126 | 127 | [tool.ruff.lint.isort.sections] 128 | "project" = [ 129 | "django_integrity", 130 | "tests", 131 | ] 132 | 133 | # Mypy 134 | # ---- 135 | 136 | [tool.mypy] 137 | files = "." 138 | exclude = [ 139 | "build", 140 | "dist", 141 | "env", 142 | "venv", 143 | ] 144 | 145 | plugins = [ 146 | "mypy_django_plugin.main", 147 | ] 148 | 149 | # Use strict defaults 150 | strict = true 151 | warn_unreachable = true 152 | warn_no_return = true 153 | 154 | 155 | [tool.django-stubs] 156 | django_settings_module = "tests.example_app.settings" 157 | 158 | # Pytest 159 | # ------ 160 | 161 | [tool.pytest.ini_options] 162 | # Ensure error warnings are converted into test errors. 163 | filterwarnings = "error" 164 | # Ensure that tests fail if an xfail test unexpectedly passes. 165 | xfail_strict = true 166 | 167 | DJANGO_SETTINGS_MODULE = "tests.example_app.settings" 168 | -------------------------------------------------------------------------------- /scripts/type-ratchet.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | A script to help with running mypy and managing the type ratchet file. 4 | 5 | There are three commands: 6 | - update: Runs mypy and updates the type ratchet file with any changes. 7 | Prints an error if there are any changes to the ratchet file. 8 | 9 | - force_update: Runs mypy and updates the type ratchet file without checking for changes. 10 | 11 | - check: Runs mypy and checks for changes in the ratchet file without updating it. 12 | """ 13 | 14 | import pathlib 15 | import re 16 | import subprocess 17 | import sys 18 | from collections.abc import Sequence 19 | 20 | import rich 21 | import typer 22 | 23 | 24 | RATCHET_FILE = "mypy-ratchet.json" 25 | PARENT_DIR = str(pathlib.Path(__file__).resolve().parent.parent) 26 | 27 | 28 | app = typer.Typer() 29 | 30 | 31 | @app.command() 32 | def update() -> None: 33 | """ 34 | Run mypy and update the type ratchet file with the changes, print errors if any. 35 | """ 36 | rich.print(f"[bold blue]Running mypy on [white]{PARENT_DIR}[/white].") 37 | mypy_output = get_mypy_output(PARENT_DIR) 38 | 39 | rich.print(f"[bold blue]Updating type ratchet file: [white]{RATCHET_FILE}[/white].") 40 | flags = [ 41 | "--color", 42 | "--diff-old-report", 43 | RATCHET_FILE, 44 | "--output-file", 45 | RATCHET_FILE, 46 | ] 47 | result = run_type_ratchet(mypy_output, PARENT_DIR, flags) 48 | 49 | # A return code of 0 means that the report was unchanged 50 | if result.returncode == 0: 51 | rich.print("[bold green]Type ratchet file unchanged.") 52 | # A return code of 3 means that the report was updated. 53 | elif result.returncode == 3: 54 | rich.print("[bold yellow]Type ratchet updated. STDOUT was:") 55 | print(result.stdout) 56 | # Any other return code is an error. 57 | else: 58 | rich.print("[bold red]mypy-json-report failed with the following output:") 59 | print(result.stderr) 60 | 61 | sys.exit(result.returncode) 62 | 63 | 64 | @app.command(name="force_update") 65 | def force_update() -> None: 66 | """ 67 | Run mypy and update the type ratchet file without checking for changes. 68 | 69 | This is useful when there are merge conflicts in the ratchet file. 70 | """ 71 | rich.print(f"[bold blue]Running mypy on [white]{PARENT_DIR}[/white].") 72 | mypy_output = get_mypy_output(PARENT_DIR) 73 | 74 | rich.print( 75 | f"[bold blue]Force-updating ratchet file: [white]{RATCHET_FILE}[/white]." 76 | ) 77 | flags = ["--output-file", RATCHET_FILE] 78 | result = run_type_ratchet(mypy_output, PARENT_DIR, flags) 79 | 80 | # The return code of 0 means that the report was updated without error. 81 | if result.returncode == 0: 82 | rich.print("[bold green]Type ratchet updated.") 83 | else: 84 | rich.print("[bold red]mypy-json-report failed with the following output:") 85 | print(result.stderr) 86 | 87 | sys.exit(result.returncode) 88 | 89 | 90 | @app.command() 91 | def check() -> None: 92 | """ 93 | Check for changing in Mypy's output without updating the ratchet file. 94 | 95 | This is useful for when you're running tests, 96 | without the intention of committing changes. 97 | """ 98 | rich.print(f"[bold blue]Running mypy on [white]{PARENT_DIR}[/white].") 99 | mypy_output = get_mypy_output(PARENT_DIR) 100 | 101 | rich.print( 102 | f"[bold blue]Comparing against type ratchet file: [white]{RATCHET_FILE}[/white]." 103 | ) 104 | flags = ["--color", "--diff-old-report", RATCHET_FILE, "--output-file", "/dev/null"] 105 | result = run_type_ratchet(mypy_output, PARENT_DIR, flags) 106 | 107 | # A return code of 0 means that there were no changes. 108 | if result.returncode == 0: 109 | rich.print("[bold green]No changes detected.") 110 | # A return code of 3 means that the report was updated. 111 | elif result.returncode == 3: 112 | rich.print("[bold yellow]Changes detected. STDOUT was:") 113 | print(result.stdout) 114 | else: 115 | rich.print("[bold red]mypy-json-report failed with the following output:") 116 | print(result.stderr) 117 | 118 | sys.exit(result.returncode) 119 | 120 | 121 | def get_mypy_output(cwd: str) -> str: 122 | """Runs mypy and returns the output.""" 123 | result = subprocess.run( 124 | ["mypy", "."], 125 | capture_output=True, 126 | text=True, 127 | cwd=cwd, 128 | ) 129 | 130 | # Mypy returns 0 if no errors are found, 1 if errors are found, and 2 if it crashes. 131 | # Unfortunately, crashes in plugins can also cause mypy to return 1, 132 | # so we need to check the last line of the output for a specific message. 133 | 134 | if result.returncode == 0: 135 | return result.stdout 136 | last_line = result.stdout.strip().splitlines()[-1] 137 | if result.returncode == 1 and re.match(r"Found \d+ errors?", last_line): 138 | return result.stdout 139 | 140 | # If we get here, it means that mypy crashed. 141 | rich.print("[bold red]Mypy crashed.") 142 | rich.print("[bold red]STDERR:") 143 | print(result.stderr) 144 | rich.print("[bold red]STDOUT:") 145 | print(result.stdout) 146 | sys.exit(result.returncode) 147 | 148 | 149 | def run_type_ratchet( 150 | mypy_output: str, cwd: str, flags: Sequence[str] 151 | ) -> subprocess.CompletedProcess[str]: 152 | """Calls mypy-json-report with the given flags and mypy output.""" 153 | return subprocess.run( 154 | ["mypy-json-report", "parse", *flags], 155 | input=mypy_output, 156 | cwd=cwd, 157 | capture_output=True, 158 | text=True, 159 | ) 160 | 161 | 162 | if __name__ == "__main__": 163 | app() 164 | -------------------------------------------------------------------------------- /src/django_integrity/conversion.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import contextlib 5 | import dataclasses 6 | import re 7 | from collections.abc import Iterator, Sequence 8 | 9 | from django import db as django_db 10 | 11 | 12 | try: 13 | import psycopg 14 | except ImportError: 15 | import psycopg2 as psycopg # type: ignore[no-redef] 16 | 17 | 18 | @contextlib.contextmanager 19 | def refine_integrity_error( 20 | rules: Sequence[tuple[_Rule, Exception | type[Exception]]], 21 | ) -> Iterator[None]: 22 | """ 23 | Convert a generic IntegrityError into a more specific exception. 24 | 25 | The conversion is based on (rule, exception) pairs. 26 | 27 | Args: 28 | rules: A sequence of rule, exception pairs. 29 | If the rule matches the IntegrityError, the exception is raised. 30 | 31 | Raises: 32 | The exception paired with the first matching rule. 33 | Otherwise, the original IntegrityError. 34 | """ 35 | try: 36 | yield 37 | except django_db.IntegrityError as e: 38 | for rule, refined_error in rules: 39 | if rule.is_match(e): 40 | raise refined_error from e 41 | raise 42 | 43 | 44 | class _Rule(abc.ABC): 45 | @abc.abstractmethod 46 | def is_match(self, error: django_db.IntegrityError) -> bool: 47 | """ 48 | Determine if the rule matches the given IntegrityError. 49 | 50 | Args: 51 | error: The IntegrityError to check. 52 | 53 | Returns: 54 | True if the rule matches the error, False otherwise. 55 | """ 56 | ... 57 | 58 | 59 | @dataclasses.dataclass(frozen=True) 60 | class Named(_Rule): 61 | """ 62 | A constraint identified by its name. 63 | """ 64 | 65 | name: str 66 | 67 | def is_match(self, error: django_db.IntegrityError) -> bool: 68 | if not isinstance(error.__cause__, psycopg.errors.IntegrityError): 69 | return False 70 | 71 | return error.__cause__.diag.constraint_name == self.name 72 | 73 | 74 | @dataclasses.dataclass(frozen=True) 75 | class Unique(_Rule): 76 | """ 77 | A unique constraint defined by a model and a set of fields. 78 | """ 79 | 80 | model: type[django_db.models.Model] 81 | fields: tuple[str, ...] 82 | 83 | _pattern = re.compile(r"Key \((?P.+)\)=\(.*\) already exists.") 84 | 85 | def is_match(self, error: django_db.IntegrityError) -> bool: 86 | if not isinstance(error.__cause__, psycopg.errors.UniqueViolation): 87 | return False 88 | 89 | match = self._pattern.match(error.__cause__.diag.message_detail or "") 90 | if match is None: 91 | return False 92 | 93 | return ( 94 | tuple(match.group("fields").split(", ")) == self.fields 95 | and error.__cause__.diag.table_name == self.model._meta.db_table 96 | ) 97 | 98 | 99 | @dataclasses.dataclass(frozen=True) 100 | class PrimaryKey(_Rule): 101 | """ 102 | A unique constraint on the primary key of a model. 103 | 104 | If the model has no primary key, a PrimaryKeyDoesNotExist error is raised when 105 | trying to create a PrimaryKey rule. 106 | """ 107 | 108 | model: type[django_db.models.Model] 109 | 110 | _pattern = re.compile(r"Key \((?P.+)\)=\(.*\) already exists.") 111 | 112 | def __post_init__(self) -> None: 113 | """ 114 | Ensure the model has a primary key. 115 | 116 | There's no sense in creating a rule to match a primary key constraint 117 | if the model has no primary key. 118 | 119 | This helps us to justify an assert statement in is_match. 120 | """ 121 | if self.model._meta.pk is None: 122 | raise ModelHasNoPrimaryKey 123 | 124 | def is_match(self, error: django_db.IntegrityError) -> bool: 125 | if not isinstance(error.__cause__, psycopg.errors.UniqueViolation): 126 | return False 127 | 128 | match = self._pattern.match(error.__cause__.diag.message_detail or "") 129 | if match is None: 130 | return False 131 | 132 | # The assert below informs Mypy that self.model._meta.pk is not None. 133 | # This has been enforced in __post_init__, 134 | # so this should never raise an error in practice. 135 | assert self.model._meta.pk is not None 136 | 137 | return ( 138 | tuple(match.group("fields").split(", ")) == (self.model._meta.pk.name,) 139 | and error.__cause__.diag.table_name == self.model._meta.db_table 140 | ) 141 | 142 | 143 | class ModelHasNoPrimaryKey(Exception): 144 | """ 145 | Raised when trying to make a PrimaryKey rule for a model without a primary key. 146 | """ 147 | 148 | 149 | @dataclasses.dataclass(frozen=True) 150 | class NotNull(_Rule): 151 | """ 152 | A not-null constraint on a Model's field. 153 | """ 154 | 155 | model: type[django_db.models.Model] 156 | field: str 157 | 158 | def is_match(self, error: django_db.IntegrityError) -> bool: 159 | if not isinstance(error.__cause__, psycopg.errors.NotNullViolation): 160 | return False 161 | 162 | return ( 163 | error.__cause__.diag.column_name == self.field 164 | and error.__cause__.diag.table_name == self.model._meta.db_table 165 | ) 166 | 167 | 168 | @dataclasses.dataclass(frozen=True) 169 | class ForeignKey(_Rule): 170 | """ 171 | A foreign key constraint on a Model's field. 172 | """ 173 | 174 | model: type[django_db.models.Model] 175 | field: str 176 | 177 | _detail_pattern = re.compile( 178 | r"Key \((?P.+)\)=\((?P.+)\) is not present in table" 179 | ) 180 | 181 | def is_match(self, error: django_db.IntegrityError) -> bool: 182 | if not isinstance(error.__cause__, psycopg.errors.ForeignKeyViolation): 183 | return False 184 | 185 | detail_match = self._detail_pattern.match( 186 | error.__cause__.diag.message_detail or "" 187 | ) 188 | if detail_match is None: 189 | return False 190 | 191 | return ( 192 | detail_match.group("field") == self.field 193 | and error.__cause__.diag.table_name == self.model._meta.db_table 194 | ) 195 | -------------------------------------------------------------------------------- /src/django_integrity/constraints.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from collections.abc import Iterator, Sequence 3 | 4 | from django.db import connections, models, transaction 5 | 6 | 7 | try: 8 | from psycopg import sql 9 | except ImportError: 10 | from psycopg2 import sql # type: ignore[no-redef] 11 | 12 | 13 | # Note [Deferrable constraints] 14 | # ----------------------------- 15 | # Only some types of PostgreSQL constraint can be DEFERRED, and 16 | # they may be deferred if they are created with the DEFERRABLE option. 17 | # 18 | # These types of constraints can be DEFERRABLE: 19 | # - UNIQUE 20 | # - PRIMARY KEY 21 | # - REFERENCES (foreign key) 22 | # - EXCLUDE 23 | # 24 | # These types of constraints can never be DEFERRABLE: 25 | # - CHECK 26 | # - NOT NULL 27 | # 28 | # By default, Django makes foreign key constraints DEFERRABLE INITIALLY DEFERRED, 29 | # so they are checked at the end of the transaction, 30 | # rather than when the statement is executed. 31 | # 32 | # All other constraints are IMMEDIATE (and not DEFERRABLE) by default. 33 | # This can be changed by passing the `deferrable` argument to the constraint. 34 | # 35 | # Further reading: 36 | # - https://www.postgresql.org/docs/current/sql-set-constraints.html 37 | # - https://www.postgresql.org/docs/current/sql-createtable.html 38 | # - https://docs.djangoproject.com/en/5.0/ref/models/constraints/#deferrable 39 | 40 | 41 | @contextlib.contextmanager 42 | def immediate(names: Sequence[str], *, using: str) -> Iterator[None]: 43 | """ 44 | Temporarily set named DEFERRABLE constraints to IMMEDIATE. 45 | 46 | This is useful for catching constraint violations as soon as they occur, 47 | rather than at the end of the transaction. 48 | 49 | This is especially useful for foreign key constraints in Django, 50 | which are DEFERRED by default. 51 | 52 | We presume that any provided constraints were previously DEFERRED, 53 | and we restore them to that state after the context manager exits. 54 | 55 | To be sure that the constraints are restored to DEFERRED 56 | even if an exception is raised, we use a savepoint. 57 | 58 | This could be expensive if used in a loop because on every iteration we would 59 | create and close (or roll back) a savepoint, and set and unset the constraint state. 60 | 61 | # See Note [Deferrable constraints] 62 | 63 | Args: 64 | names: The names of the constraints to change. 65 | using: The name of the database connection to use. 66 | 67 | Raises: 68 | NotInTransaction: When we try to change constraints outside of a transaction. 69 | """ 70 | set_immediate(names, using=using) 71 | try: 72 | with transaction.atomic(using=using): 73 | yield 74 | finally: 75 | set_deferred(names, using=using) 76 | 77 | 78 | def set_all_immediate(*, using: str) -> None: 79 | """ 80 | Set all constraints to IMMEDIATE for the remainder of the transaction. 81 | 82 | # See Note [Deferrable constraints] 83 | 84 | Args: 85 | using: The name of the database connection to use. 86 | 87 | Raises: 88 | NotInTransaction: When we try to change constraints outside of a transaction. 89 | """ 90 | if transaction.get_autocommit(using): 91 | raise NotInTransaction 92 | 93 | with connections[using].cursor() as cursor: 94 | cursor.execute("SET CONSTRAINTS ALL IMMEDIATE") 95 | 96 | 97 | def set_immediate(names: Sequence[str], *, using: str) -> None: 98 | """ 99 | Set particular constraints to IMMEDIATE for the remainder of the transaction. 100 | 101 | # See Note [Deferrable constraints] 102 | 103 | Args: 104 | names: The names of the constraints to set to IMMEDIATE. 105 | using: The name of the database connection to use. 106 | 107 | Raises: 108 | NotInTransaction: When we try to change constraints outside of a transaction. 109 | """ 110 | if transaction.get_autocommit(using): 111 | raise NotInTransaction 112 | 113 | if not names: 114 | return 115 | 116 | query = sql.SQL("SET CONSTRAINTS {names} IMMEDIATE").format( 117 | names=sql.SQL(", ").join(sql.Identifier(name) for name in names) 118 | ) 119 | 120 | with connections[using].cursor() as cursor: 121 | cursor.execute(query) 122 | 123 | 124 | def set_deferred(names: Sequence[str], *, using: str) -> None: 125 | """ 126 | Set particular constraints to DEFERRED for the remainder of the transaction. 127 | 128 | # See Note [Deferrable constraints] 129 | 130 | Args: 131 | names: The names of the constraints to set to DEFERRED. 132 | using: The name of the database connection to use. 133 | 134 | Raises: 135 | NotInTransaction: When we try to change constraints outside of a transaction. 136 | """ 137 | if transaction.get_autocommit(using): 138 | raise NotInTransaction 139 | 140 | if not names: 141 | return 142 | 143 | query = sql.SQL("SET CONSTRAINTS {names} DEFERRED").format( 144 | names=sql.SQL(", ").join(sql.Identifier(name) for name in names) 145 | ) 146 | 147 | with connections[using].cursor() as cursor: 148 | cursor.execute(query) 149 | 150 | 151 | class NotInTransaction(Exception): 152 | """ 153 | Raised when we try to change the state of constraints outside of a transaction. 154 | 155 | It doesn't make sense to change the state of constraints outside of a transaction, 156 | because the change of state would only last for the remainder of the transaction. 157 | 158 | See https://www.postgresql.org/docs/current/sql-set-constraints.html 159 | """ 160 | 161 | 162 | def foreign_key_constraint_name( 163 | model: type[models.Model], field_name: str, *, using: str 164 | ) -> str: 165 | """ 166 | Calculate FK constraint name for a model's field. 167 | 168 | Django's constraint names are based on the names of the tables and columns involved. 169 | 170 | Because there is a 63-character limit on constraint names in PostgreSQL, 171 | Django uses a hash to shorten the names of long columns. 172 | This means that the constraint name is not deterministic based on the model and field alone. 173 | 174 | Django surely ought to have a public method for this, but it doesn't! 175 | 176 | Args: 177 | model: The model that contains the field. 178 | field_name: The name of the field. 179 | using: The name of the database connection to use. 180 | 181 | Raises: 182 | django.core.exceptions.FieldDoesNotExist: When the field is not on the model. 183 | NotAForeignKey: When the field is not a foreign key. 184 | 185 | Returns: 186 | The name of the foreign key constraint. 187 | """ 188 | field = model._meta.get_field(field_name) 189 | 190 | remote_field = field.remote_field 191 | if remote_field is None: 192 | raise NotAForeignKey 193 | 194 | to_table = remote_field.model._meta.db_table 195 | to_field = remote_field.name 196 | suffix = f"_fk_{to_table}_{to_field}" 197 | 198 | connection = connections[using] 199 | with connection.schema_editor() as editor: 200 | # The _fk_constraint_name method is not part of the public API, 201 | # and only exists on the PostgreSQL schema editor. 202 | # Django-stubs does not know about this method, so we have to use a type ignore. 203 | constraint_name = editor._fk_constraint_name(model, field, suffix) # type: ignore[attr-defined] 204 | 205 | return str(constraint_name).removeprefix('"').removesuffix('"') 206 | 207 | 208 | class NotAForeignKey(Exception): 209 | """ 210 | Raised when we ask for the FK constraint name of a field that is not a foreign key. 211 | """ 212 | -------------------------------------------------------------------------------- /tests/django_integrity/test_constraints.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django import db as django_db 3 | from django.core import exceptions 4 | from django.db import transaction 5 | 6 | from django_integrity import constraints 7 | from tests.example_app import models as test_models 8 | 9 | 10 | class TestForeignKeyConstraintName: 11 | @pytest.mark.django_db 12 | def test_generated_name(self) -> None: 13 | # The ForeignKey constraint name is generated from the model and field. 14 | constraint_name = constraints.foreign_key_constraint_name( 15 | model=test_models.ForeignKeyModel, 16 | field_name="related", 17 | using="default", 18 | ) 19 | 20 | assert ( 21 | constraint_name == "example_app_foreignk_related_id_7403e50b_fk_example_a" 22 | ) 23 | 24 | def test_not_a_foreign_key(self) -> None: 25 | # The field is not a ForeignKey. 26 | with pytest.raises(constraints.NotAForeignKey): 27 | constraints.foreign_key_constraint_name( 28 | model=test_models.PrimaryKeyModel, 29 | field_name="id", 30 | using="default", 31 | ) 32 | 33 | def test_wrong_field_name(self) -> None: 34 | # The field name doesn't exist on the model. 35 | with pytest.raises(exceptions.FieldDoesNotExist): 36 | constraints.foreign_key_constraint_name( 37 | model=test_models.ForeignKeyModel, 38 | field_name="does_not_exist", 39 | using="default", 40 | ) 41 | 42 | 43 | class TestSetAllImmediate: 44 | @pytest.mark.django_db 45 | def test_all_constraints_set(self) -> None: 46 | constraints.set_all_immediate(using="default") 47 | 48 | with pytest.raises(django_db.IntegrityError): 49 | # The ForeignKey constraint should be enforced immediately. 50 | test_models.ForeignKeyModel.objects.create(related_id=42) 51 | 52 | @pytest.mark.django_db(transaction=True) 53 | def test_constraint_not_set(self) -> None: 54 | # We handle transaction open/close manually in this test 55 | # so that we can catch exceptions from the COMMIT. 56 | transaction.set_autocommit(False) 57 | 58 | # The related object does not exist. 59 | # This would raise an IntegrityError if the FK constraint wasn't deferred. 60 | test_models.ForeignKeyModel.objects.create(related_id=42) 61 | 62 | with pytest.raises(django_db.IntegrityError): 63 | transaction.commit() 64 | 65 | @pytest.mark.django_db(transaction=True) 66 | def test_not_in_transaction(self) -> None: 67 | # Fail if we're not in a transaction. 68 | with pytest.raises(constraints.NotInTransaction): 69 | constraints.set_all_immediate(using="default") 70 | 71 | 72 | class TestSetImmediate: 73 | @pytest.mark.django_db 74 | def test_set(self) -> None: 75 | constraint_name = constraints.foreign_key_constraint_name( 76 | model=test_models.ForeignKeyModel, 77 | field_name="related_id", 78 | using="default", 79 | ) 80 | 81 | constraints.set_immediate(names=(constraint_name,), using="default") 82 | 83 | # An error should be raised immediately. 84 | with pytest.raises(django_db.IntegrityError): 85 | test_models.ForeignKeyModel.objects.create(related_id=42) 86 | 87 | @pytest.mark.django_db 88 | def test_not_set(self) -> None: 89 | # No constraint name is passed, so no constraints should be set to immediate. 90 | constraints.set_immediate(names=(), using="default") 91 | 92 | # No error should be raised. 93 | test_models.ForeignKeyModel.objects.create(related_id=42) 94 | 95 | # We catch the error here to prevent the test from failing in shutdown. 96 | with pytest.raises(django_db.IntegrityError): 97 | constraints.set_all_immediate(using="default") 98 | 99 | @pytest.mark.django_db(transaction=True) 100 | def test_not_in_transaction(self) -> None: 101 | # Fail if we're not in a transaction. 102 | with pytest.raises(constraints.NotInTransaction): 103 | constraints.set_immediate(names=(), using="default") 104 | 105 | 106 | class TestSetDeferred: 107 | @pytest.mark.django_db 108 | def test_not_set(self) -> None: 109 | test_models.UniqueModel.objects.create(unique_field=42) 110 | 111 | # We pass no names, so no constraints should be set to deferred. 112 | constraints.set_deferred(names=(), using="default") 113 | 114 | # This constraint defaults to IMMEDIATE, 115 | # so an error should be raised immediately. 116 | with pytest.raises(django_db.IntegrityError): 117 | test_models.UniqueModel.objects.create(unique_field=42) 118 | 119 | @pytest.mark.django_db 120 | def test_set(self) -> None: 121 | test_models.UniqueModel.objects.create(unique_field=42) 122 | 123 | # We defer the constraint... 124 | constraint_name = "unique_model_unique_field_key" 125 | constraints.set_deferred(names=(constraint_name,), using="default") 126 | 127 | # ... so no error should be raised. 128 | test_models.UniqueModel.objects.create(unique_field=42) 129 | 130 | # We catch the error here to prevent the test from failing in shutdown. 131 | with pytest.raises(django_db.IntegrityError): 132 | constraints.set_all_immediate(using="default") 133 | 134 | @pytest.mark.django_db(transaction=True) 135 | def test_not_in_transaction(self) -> None: 136 | # Fail if we're not in a transaction. 137 | with pytest.raises(constraints.NotInTransaction): 138 | constraints.set_deferred(names=(), using="default") 139 | 140 | 141 | class TestImmediate: 142 | @pytest.mark.django_db 143 | def test_constraint_not_enforced(self) -> None: 144 | """Constraints are not changed when not explicitly enforced.""" 145 | # Call the context manager without any constraint names. 146 | with constraints.immediate((), using="default"): 147 | # Create an instance that violates a deferred constraint. 148 | # No error should be raised. 149 | test_models.ForeignKeyModel.objects.create(related_id=42) 150 | 151 | # We catch the error here to prevent the test from failing in shutdown. 152 | with pytest.raises(django_db.IntegrityError): 153 | constraints.set_all_immediate(using="default") 154 | 155 | @pytest.mark.django_db 156 | def test_constraint_enforced(self) -> None: 157 | """Constraints are enforced when explicitly enforced.""" 158 | constraint_name = constraints.foreign_key_constraint_name( 159 | model=test_models.ForeignKeyModel, 160 | field_name="related_id", 161 | using="default", 162 | ) 163 | 164 | context_manager_successfully_entered = False 165 | 166 | # An error should be raised immediately. 167 | with pytest.raises(django_db.IntegrityError): 168 | with constraints.immediate((constraint_name,), using="default"): 169 | context_manager_successfully_entered = True 170 | 171 | # Create an instance that violates a deferred constraint. 172 | test_models.ForeignKeyModel.objects.create(related_id=42) 173 | 174 | # Just to be sure the context manager was entered, 175 | # and the error didn't come a mistake in the context manager. 176 | assert context_manager_successfully_entered is True 177 | 178 | @pytest.mark.django_db 179 | def test_deferral_restored(self) -> None: 180 | """Constraints are restored to DEFERRED after the context manager.""" 181 | constraint_name = constraints.foreign_key_constraint_name( 182 | model=test_models.ForeignKeyModel, 183 | field_name="related_id", 184 | using="default", 185 | ) 186 | 187 | with constraints.immediate((constraint_name,), using="default"): 188 | pass 189 | 190 | # Create an instance that violates a deferred constraint. 191 | # No error should be raised, because the constraint should be deferred again. 192 | test_models.ForeignKeyModel.objects.create(related_id=42) 193 | 194 | # We catch the error here to prevent the test from failing in shutdown. 195 | with pytest.raises(django_db.IntegrityError): 196 | constraints.set_all_immediate(using="default") 197 | 198 | @pytest.mark.django_db(transaction=True) 199 | def test_not_in_transaction(self) -> None: 200 | # Fail if we're not in a transaction. 201 | with pytest.raises(constraints.NotInTransaction): 202 | with constraints.immediate((), using="default"): 203 | pass 204 | -------------------------------------------------------------------------------- /tests/django_integrity/test_conversion.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django import db as django_db 3 | 4 | from django_integrity import constraints, conversion 5 | from tests.example_app import models as test_models 6 | 7 | 8 | class SimpleError(Exception): 9 | pass 10 | 11 | 12 | class TestRefineIntegrityError: 13 | def test_no_rules(self) -> None: 14 | # It is legal to call the context manager without any rules. 15 | with conversion.refine_integrity_error(rules=()): 16 | pass 17 | 18 | 19 | @pytest.mark.django_db 20 | class TestNamedConstraint: 21 | def test_error_refined(self) -> None: 22 | # Create a unique instance so that we can violate the constraint later. 23 | test_models.UniqueModel.objects.create(unique_field=42) 24 | 25 | rules = ((conversion.Named(name="unique_model_unique_field_key"), SimpleError),) 26 | 27 | # The original error should be transformed into our expected error. 28 | with pytest.raises(SimpleError): 29 | with conversion.refine_integrity_error(rules): 30 | test_models.UniqueModel.objects.create(unique_field=42) 31 | 32 | def test_rules_mismatch(self) -> None: 33 | # Create a unique instance so that we can violate the constraint later. 34 | test_models.UniqueModel.objects.create(unique_field=42) 35 | 36 | # No constraints match the error: 37 | rules = ((conversion.Named(name="nonexistent_constraint"), SimpleError),) 38 | 39 | # The original error should be raised. 40 | with pytest.raises(django_db.IntegrityError): 41 | with conversion.refine_integrity_error(rules): 42 | test_models.UniqueModel.objects.create(unique_field=42) 43 | 44 | 45 | @pytest.mark.django_db 46 | class TestUnique: 47 | def test_error_refined(self) -> None: 48 | # Create a unique instance so that we can violate the constraint later. 49 | test_models.UniqueModel.objects.create(unique_field=42) 50 | 51 | rules = ( 52 | ( 53 | conversion.Unique( 54 | model=test_models.UniqueModel, fields=("unique_field",) 55 | ), 56 | SimpleError, 57 | ), 58 | ) 59 | 60 | # The original error should be transformed into our expected error. 61 | with pytest.raises(SimpleError): 62 | with conversion.refine_integrity_error(rules): 63 | test_models.UniqueModel.objects.create(unique_field=42) 64 | 65 | def test_multiple_fields(self) -> None: 66 | # Create a unique instance so that we can violate the constraint later. 67 | test_models.UniqueTogetherModel.objects.create(field_1=1, field_2=2) 68 | 69 | rules = ( 70 | ( 71 | conversion.Unique( 72 | model=test_models.UniqueTogetherModel, fields=("field_1", "field_2") 73 | ), 74 | SimpleError, 75 | ), 76 | ) 77 | 78 | # The original error should be transformed into our expected error. 79 | with pytest.raises(SimpleError): 80 | with conversion.refine_integrity_error(rules): 81 | test_models.UniqueTogetherModel.objects.create(field_1=1, field_2=2) 82 | 83 | @pytest.mark.parametrize( 84 | "Model, field", 85 | ( 86 | # Wrong model, despite matching field name. 87 | ( 88 | test_models.AlternativeUniqueModel, 89 | "unique_field", 90 | ), 91 | # Wrong field, despite matching model. 92 | ( 93 | test_models.UniqueModel, 94 | "id", 95 | ), 96 | ), 97 | ids=("wrong_model", "wrong_field"), 98 | ) 99 | def test_rules_mismatch( 100 | self, 101 | Model: type[test_models.AlternativeUniqueModel | test_models.UniqueModel], 102 | field: str, 103 | ) -> None: 104 | # A rule that matches a similar looking, but different, unique constraint. 105 | # Create a unique instance so that we can violate the constraint later. 106 | test_models.UniqueModel.objects.create(unique_field=42) 107 | 108 | rules = ((conversion.Unique(model=Model, fields=(field,)), SimpleError),) 109 | 110 | # We shouldn't transform the error, because it didn't match the rule. 111 | with pytest.raises(django_db.IntegrityError): 112 | with conversion.refine_integrity_error(rules): 113 | test_models.UniqueModel.objects.create(unique_field=42) 114 | 115 | 116 | @pytest.mark.django_db 117 | class TestPrimaryKey: 118 | @pytest.mark.parametrize( 119 | "ModelClass", 120 | ( 121 | test_models.PrimaryKeyModel, 122 | test_models.AlternativePrimaryKeyModel, 123 | ), 124 | ) 125 | def test_error_refined( 126 | self, 127 | ModelClass: type[test_models.PrimaryKeyModel] 128 | | type[test_models.AlternativePrimaryKeyModel], 129 | ) -> None: 130 | """ 131 | The primary key of a model is extracted from the model. 132 | 133 | This test internally refers to the models primary key using "pk". 134 | "pk" is Django magic that refers to the primary key of the model. 135 | On PrimaryKeyModel, the primary key is "id". 136 | On AlternativePrimaryKeyModel, the primary key is "identity". 137 | """ 138 | # Create a unique instance so that we can violate the constraint later. 139 | existing_primary_key = ModelClass.objects.create().pk 140 | 141 | rules = ((conversion.PrimaryKey(model=ModelClass), SimpleError),) 142 | 143 | # The original error should be transformed into our expected error. 144 | with pytest.raises(SimpleError): 145 | with conversion.refine_integrity_error(rules): 146 | ModelClass.objects.create(pk=existing_primary_key) 147 | 148 | def test_rules_mismatch(self) -> None: 149 | # Create a unique instance so that we can violate the constraint later. 150 | existing_primary_key = test_models.PrimaryKeyModel.objects.create().pk 151 | 152 | # A similar rule, but for a different model with the same field name.. 153 | rules = ((conversion.PrimaryKey(model=test_models.UniqueModel), SimpleError),) 154 | 155 | # The original error should be raised. 156 | with pytest.raises(django_db.IntegrityError): 157 | with conversion.refine_integrity_error(rules): 158 | test_models.PrimaryKeyModel.objects.create(pk=existing_primary_key) 159 | 160 | def test_model_without_primary_key(self) -> None: 161 | """ 162 | We cannot create a PrimaryKey rule for a model without a primary key. 163 | """ 164 | with pytest.raises(conversion.ModelHasNoPrimaryKey): 165 | conversion.PrimaryKey( 166 | # An abstract model without a primary key. 167 | model=test_models.AbstractModel 168 | ) 169 | 170 | 171 | @pytest.mark.django_db 172 | class TestNotNull: 173 | def test_error_refined(self) -> None: 174 | rules = ( 175 | ( 176 | conversion.NotNull(model=test_models.UniqueModel, field="unique_field"), 177 | SimpleError, 178 | ), 179 | ) 180 | 181 | # The original error should be transformed into our expected error. 182 | with pytest.raises(SimpleError): 183 | with conversion.refine_integrity_error(rules): 184 | # We ignore the type error because it's picking up on the error we're testing. 185 | test_models.UniqueModel.objects.create(unique_field=None) # type: ignore[misc] 186 | 187 | def test_model_mismatch(self) -> None: 188 | # Same field, but different model. 189 | rules = ( 190 | ( 191 | conversion.NotNull( 192 | model=test_models.AlternativeUniqueModel, field="unique_field" 193 | ), 194 | SimpleError, 195 | ), 196 | ) 197 | 198 | with pytest.raises(django_db.IntegrityError): 199 | with conversion.refine_integrity_error(rules): 200 | # We ignore the type error because it's picking up on the error we're testing. 201 | test_models.UniqueModel.objects.create(unique_field=None) # type: ignore[misc] 202 | 203 | def test_field_mismatch(self) -> None: 204 | # Same model, but different field. 205 | rules = ( 206 | ( 207 | conversion.NotNull( 208 | model=test_models.AlternativeUniqueModel, field="unique_field_2" 209 | ), 210 | SimpleError, 211 | ), 212 | ) 213 | 214 | # The original error should be raised. 215 | with pytest.raises(django_db.IntegrityError): 216 | with conversion.refine_integrity_error(rules): 217 | test_models.AlternativeUniqueModel.objects.create( 218 | # We ignore the type error because it's picking up on the error we're testing. 219 | unique_field=None, # type: ignore[misc] 220 | unique_field_2=42, 221 | ) 222 | 223 | 224 | @pytest.mark.django_db 225 | class TestForeignKey: 226 | def test_error_refined(self) -> None: 227 | rules = ( 228 | ( 229 | conversion.ForeignKey( 230 | model=test_models.ForeignKeyModel, field="related_id" 231 | ), 232 | SimpleError, 233 | ), 234 | ) 235 | constraints.set_all_immediate(using="default") 236 | 237 | # The original error should be transformed into our expected error. 238 | with pytest.raises(SimpleError): 239 | with conversion.refine_integrity_error(rules): 240 | # Create a ForeignKeyModel with a related_id that doesn't exist. 241 | test_models.ForeignKeyModel.objects.create(related_id=42) 242 | 243 | def test_source_mismatch(self) -> None: 244 | # The field name matches, but the source model is different. 245 | rules = ( 246 | ( 247 | conversion.ForeignKey( 248 | model=test_models.ForeignKeyModel2, field="related_id" 249 | ), 250 | SimpleError, 251 | ), 252 | ) 253 | constraints.set_all_immediate(using="default") 254 | 255 | with pytest.raises(django_db.IntegrityError): 256 | with conversion.refine_integrity_error(rules): 257 | test_models.ForeignKeyModel.objects.create(related_id=42) 258 | 259 | def test_field_mismatch(self) -> None: 260 | # The source model matches, but the field name is different. 261 | rules = ( 262 | ( 263 | conversion.ForeignKey( 264 | model=test_models.ForeignKeyModel3, field="related_2_id" 265 | ), 266 | SimpleError, 267 | ), 268 | ) 269 | constraints.set_all_immediate(using="default") 270 | 271 | with pytest.raises(django_db.IntegrityError): 272 | with conversion.refine_integrity_error(rules): 273 | test_models.ForeignKeyModel3.objects.create(related_1_id=42) 274 | --------------------------------------------------------------------------------