diff --git a/CHANGES.md b/CHANGES.md index 51f008c8..c85566c0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,8 @@ # Changelog +**12.13.0** (2026-08-27) +* Added `ValidateConstraintsOnSaveMixin`, which validates the models `Meta.constraints` on save and raises a `ValidationError` instead of an `IntegrityError` + **12.12.0** (2026-08-27) * Added support for Django 6.1 * **Breaking change:** Dropped support for Django 4.2, whose extended support ended in April 2026 diff --git a/ambient_toolbox/__init__.py b/ambient_toolbox/__init__.py index a560c862..873dc02a 100644 --- a/ambient_toolbox/__init__.py +++ b/ambient_toolbox/__init__.py @@ -1,3 +1,3 @@ """Python toolbox of Beyonder Deutschland containing an abundance of useful tools and gadgets.""" -__version__ = "12.12.0" +__version__ = "12.13.0" diff --git a/ambient_toolbox/mixins/validation.py b/ambient_toolbox/mixins/validation.py index 11fe9450..78607e1b 100644 --- a/ambient_toolbox/mixins/validation.py +++ b/ambient_toolbox/mixins/validation.py @@ -4,5 +4,27 @@ class CleanOnSaveMixin: """ def save(self, *args, **kwargs): - self.clean() + self._validate_before_save() super().save(*args, **kwargs) + + def _validate_before_save(self): + self.clean() + + +class ValidateConstraintsOnSaveMixin(CleanOnSaveMixin): + """ + Mixin which ensures model-level validation ("clean()") and constraint validation + ("validate_constraints()") are called on saving the current instance. + + Constraints are checked the way a ModelForm checks them, so a violation surfaces as a + ``ValidationError`` carrying the constraint's ``violation_error_message`` instead of an + ``IntegrityError``. The constraint in the database stays the authority for concurrent writes - + this only turns the common case into a catchable error. + + Costs one query per constraint per save. ``bulk_create()`` and ``QuerySet.update()`` bypass + ``save()`` entirely; there the database constraint still holds, but raises an ``IntegrityError``. + """ + + def _validate_before_save(self): + super()._validate_before_save() + self.validate_constraints() diff --git a/docs/features/mixins.md b/docs/features/mixins.md index a0bea875..eb8eda71 100644 --- a/docs/features/mixins.md +++ b/docs/features/mixins.md @@ -188,3 +188,42 @@ class ModelWithCleanMixin(CleanOnSaveMixin, models.Model): # to your magic here pass ```` + +### ValidateConstraintsOnSaveMixin + +Django validates the constraints declared in your models `Meta.constraints` when a `ModelForm` is validated - but not +on a regular model save. There, a violation reaches the database and surfaces as an `IntegrityError`. + +If you want to handle such a violation like any other validation problem, derive your model from the +`ValidateConstraintsOnSaveMixin`. It calls `clean()` and afterwards `validate_constraints()` on every save, so a +violation is raised as a `ValidationError` containing the constraints `violation_error_message`. + +````python +from django.db import models +from ambient_toolbox.mixins.validation import ValidateConstraintsOnSaveMixin + +class Booking(ValidateConstraintsOnSaveMixin, models.Model): + guest_count = models.PositiveIntegerField() + + class Meta: + constraints = [ + models.CheckConstraint( + condition=models.Q(guest_count__lte=10), + name="booking_guest_count_lte_10", + violation_error_message="A booking can hold ten guests at most.", + ) + ] +```` + +````python +from django.core.exceptions import ValidationError + +try: + Booking(guest_count=11).save() +except ValidationError as e: + print(e.messages) # ['A booking can hold ten guests at most.'] +```` + +The constraint in the database stays the authority for concurrent writes - the mixin only turns the common case into a +catchable error. Keep in mind that validating constraints costs one query per constraint per save, and that bulk +operations like `bulk_create()` or `QuerySet.update()` don't call `save()`, hence still raise an `IntegrityError`. diff --git a/testapp/migrations/0005_modelwithvalidateconstraintsmixin.py b/testapp/migrations/0005_modelwithvalidateconstraintsmixin.py new file mode 100644 index 00000000..2d800493 --- /dev/null +++ b/testapp/migrations/0005_modelwithvalidateconstraintsmixin.py @@ -0,0 +1,30 @@ +# Generated by Django 6.1 on 2026-08-27 11:23 + +import ambient_toolbox.mixins.validation +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("testapp", "0004_modelwithm2mtouser"), + ] + + operations = [ + migrations.CreateModel( + name="ModelWithValidateConstraintsMixin", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("value", models.PositiveIntegerField(default=0)), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q(("value__lte", 10)), + name="testapp_model_with_validate_constraints_mixin_value_lte_10", + violation_error_message="Value must not exceed 10.", + ) + ], + }, + bases=(ambient_toolbox.mixins.validation.ValidateConstraintsOnSaveMixin, models.Model), + ), + ] diff --git a/testapp/models.py b/testapp/models.py index 05c5a003..0c547815 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives from django.db import models from django.db.models.signals import post_save, pre_save @@ -6,7 +7,7 @@ from ambient_toolbox.managers import GloballyVisibleQuerySet from ambient_toolbox.mixins.bleacher import BleacherMixin from ambient_toolbox.mixins.models import PermissionModelMixin, SaveWithoutSignalsMixin -from ambient_toolbox.mixins.validation import CleanOnSaveMixin +from ambient_toolbox.mixins.validation import CleanOnSaveMixin, ValidateConstraintsOnSaveMixin from ambient_toolbox.models import CommonInfo from testapp.managers import ModelWithGetOrNoneManager, ModelWithSelectorQuerySet from testapp.selectors import ModelWithSelectorGloballyVisibleSelector @@ -97,6 +98,28 @@ def clean(self): return True +class ModelWithValidateConstraintsMixin(ValidateConstraintsOnSaveMixin, models.Model): + INVALID_VALUE = 7 + + value = models.PositiveIntegerField(default=0) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=models.Q(value__lte=10), + name="testapp_model_with_validate_constraints_mixin_value_lte_10", + violation_error_message="Value must not exceed 10.", + ) + ] + + def __str__(self): + return str(self.value) + + def clean(self): + if self.value == self.INVALID_VALUE: + raise ValidationError("Seven is not a valid value.") + + class MyPermissionModelMixin(PermissionModelMixin, models.Model): def __str__(self): return str(self.id) diff --git a/tests/mixins/test_validation.py b/tests/mixins/test_validation.py index 212296ca..08942e2b 100644 --- a/tests/mixins/test_validation.py +++ b/tests/mixins/test_validation.py @@ -1,8 +1,10 @@ from unittest import mock +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError from django.test import TestCase -from testapp.models import ModelWithCleanMixin +from testapp.models import ModelWithCleanMixin, ModelWithValidateConstraintsMixin class CleanOnSaveMixinTest(TestCase): @@ -16,3 +18,53 @@ def test_clean_is_called(self): obj.save() mocked_method.assert_called_once() + + +class ValidateConstraintsOnSaveMixinTest(TestCase): + def test_valid_object_is_saved(self): + obj = ModelWithValidateConstraintsMixin(value=1) + + self.assertIsNone(obj.save()) + self.assertEqual(ModelWithValidateConstraintsMixin.objects.get(pk=obj.pk).value, 1) + + def test_clean_and_constraint_validation_are_called(self): + obj = ModelWithValidateConstraintsMixin(value=1) + with ( + mock.patch.object(obj, "clean") as mocked_clean, + mock.patch.object(obj, "validate_constraints") as mocked_validate_constraints, + ): + obj.save() + + mocked_clean.assert_called_once() + mocked_validate_constraints.assert_called_once() + + def test_clean_is_called_before_constraint_validation(self): + obj = ModelWithValidateConstraintsMixin(value=1) + call_order = [] + with ( + mock.patch.object(obj, "clean", side_effect=lambda: call_order.append("clean")), + mock.patch.object( + obj, "validate_constraints", side_effect=lambda: call_order.append("validate_constraints") + ), + ): + obj.save() + + self.assertEqual(call_order, ["clean", "validate_constraints"]) + + def test_constraint_violation_raises_validation_error(self): + obj = ModelWithValidateConstraintsMixin(value=11) + + with self.assertRaisesMessage(ValidationError, "Value must not exceed 10."): + obj.save() + + self.assertFalse(ModelWithValidateConstraintsMixin.objects.exists()) + + def test_clean_violation_raises_validation_error(self): + obj = ModelWithValidateConstraintsMixin(value=7) + + with self.assertRaisesMessage(ValidationError, "Seven is not a valid value."): + obj.save() + + def test_bulk_create_bypasses_constraint_validation(self): + with self.assertRaises(IntegrityError): + ModelWithValidateConstraintsMixin.objects.bulk_create([ModelWithValidateConstraintsMixin(value=11)])