diff options
| author | Mark Gensler <mark.gensler@protonmail.com> | 2024-07-18 08:38:06 +0100 |
|---|---|---|
| committer | Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> | 2024-08-12 13:45:57 +0200 |
| commit | 228128618bd895ecad235d2215f4ad4e3232595d (patch) | |
| tree | e0e73d595dce097d69909bee06c86019c61dc20b /tests | |
| parent | f883bef05457a5a49eb31109429fc01737f82532 (diff) | |
Fixed #35575 -- Added support for constraint validation on GeneratedFields.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/constraints/models.py | 41 | ||||
| -rw-r--r-- | tests/constraints/tests.py | 111 | ||||
| -rw-r--r-- | tests/postgres_tests/test_constraints.py | 34 |
3 files changed, 185 insertions, 1 deletions
diff --git a/tests/constraints/models.py b/tests/constraints/models.py index 983d550502..829f671cdd 100644 --- a/tests/constraints/models.py +++ b/tests/constraints/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models.functions import Coalesce, Lower class Product(models.Model): @@ -28,6 +29,46 @@ class Product(models.Model): ] +class GeneratedFieldStoredProduct(models.Model): + name = models.CharField(max_length=255, null=True) + price = models.IntegerField(null=True) + discounted_price = models.IntegerField(null=True) + rebate = models.GeneratedField( + expression=Coalesce("price", 0) + - Coalesce("discounted_price", Coalesce("price", 0)), + output_field=models.IntegerField(), + db_persist=True, + ) + lower_name = models.GeneratedField( + expression=Lower(models.F("name")), + output_field=models.CharField(max_length=255, null=True), + db_persist=True, + ) + + class Meta: + required_db_features = {"supports_stored_generated_columns"} + + +class GeneratedFieldVirtualProduct(models.Model): + name = models.CharField(max_length=255, null=True) + price = models.IntegerField(null=True) + discounted_price = models.IntegerField(null=True) + rebate = models.GeneratedField( + expression=Coalesce("price", 0) + - Coalesce("discounted_price", Coalesce("price", 0)), + output_field=models.IntegerField(), + db_persist=False, + ) + lower_name = models.GeneratedField( + expression=Lower(models.F("name")), + output_field=models.CharField(max_length=255, null=True), + db_persist=False, + ) + + class Meta: + required_db_features = {"supports_virtual_generated_columns"} + + class UniqueConstraintProduct(models.Model): name = models.CharField(max_length=255) color = models.CharField(max_length=32, null=True) diff --git a/tests/constraints/tests.py b/tests/constraints/tests.py index 350f05f2b8..9ca889ca6d 100644 --- a/tests/constraints/tests.py +++ b/tests/constraints/tests.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError, connection, models from django.db.models import F from django.db.models.constraints import BaseConstraint, UniqueConstraint -from django.db.models.functions import Abs, Lower, Upper +from django.db.models.functions import Abs, Lower, Sqrt, Upper from django.db.transaction import atomic from django.test import SimpleTestCase, TestCase, skipIfDBFeature, skipUnlessDBFeature from django.test.utils import ignore_warnings @@ -13,6 +13,8 @@ from django.utils.deprecation import RemovedInDjango60Warning from .models import ( ChildModel, ChildUniqueConstraintProduct, + GeneratedFieldStoredProduct, + GeneratedFieldVirtualProduct, JSONFieldModel, ModelWithDatabaseDefault, Product, @@ -384,6 +386,29 @@ class CheckConstraintTests(TestCase): with self.assertRaisesMessage(ValidationError, msg): json_exact_constraint.validate(JSONFieldModel, JSONFieldModel(data=data)) + @skipUnlessDBFeature("supports_stored_generated_columns") + def test_validate_generated_field_stored(self): + self.assertGeneratedFieldIsValidated(model=GeneratedFieldStoredProduct) + + @skipUnlessDBFeature("supports_virtual_generated_columns") + def test_validate_generated_field_virtual(self): + self.assertGeneratedFieldIsValidated(model=GeneratedFieldVirtualProduct) + + def assertGeneratedFieldIsValidated(self, model): + constraint = models.CheckConstraint( + condition=models.Q(rebate__range=(0, 100)), name="bounded_rebate" + ) + constraint.validate(model, model(price=50, discounted_price=20)) + + invalid_product = model(price=1200, discounted_price=500) + msg = f"Constraint “{constraint.name}” is violated." + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate(model, invalid_product) + + # Excluding referenced or generated fields should skip validation. + constraint.validate(model, invalid_product, exclude={"price"}) + constraint.validate(model, invalid_product, exclude={"rebate"}) + def test_check_deprecation(self): msg = "CheckConstraint.check is deprecated in favor of `.condition`." condition = models.Q(foo="bar") @@ -1062,6 +1087,90 @@ class UniqueConstraintTests(TestCase): exclude={"name"}, ) + @skipUnlessDBFeature("supports_stored_generated_columns") + def test_validate_expression_generated_field_stored(self): + self.assertGeneratedFieldWithExpressionIsValidated( + model=GeneratedFieldStoredProduct + ) + + @skipUnlessDBFeature("supports_virtual_generated_columns") + def test_validate_expression_generated_field_virtual(self): + self.assertGeneratedFieldWithExpressionIsValidated( + model=GeneratedFieldVirtualProduct + ) + + def assertGeneratedFieldWithExpressionIsValidated(self, model): + constraint = UniqueConstraint(Sqrt("rebate"), name="unique_rebate_sqrt") + model.objects.create(price=100, discounted_price=84) + + valid_product = model(price=100, discounted_price=75) + constraint.validate(model, valid_product) + + invalid_product = model(price=20, discounted_price=4) + with self.assertRaisesMessage( + ValidationError, f"Constraint “{constraint.name}” is violated." + ): + constraint.validate(model, invalid_product) + + # Excluding referenced or generated fields should skip validation. + constraint.validate(model, invalid_product, exclude={"rebate"}) + constraint.validate(model, invalid_product, exclude={"price"}) + + @skipUnlessDBFeature("supports_stored_generated_columns") + def test_validate_fields_generated_field_stored(self): + self.assertGeneratedFieldWithFieldsIsValidated( + model=GeneratedFieldStoredProduct + ) + + @skipUnlessDBFeature("supports_virtual_generated_columns") + def test_validate_fields_generated_field_virtual(self): + self.assertGeneratedFieldWithFieldsIsValidated( + model=GeneratedFieldVirtualProduct + ) + + def assertGeneratedFieldWithFieldsIsValidated(self, model): + constraint = models.UniqueConstraint( + fields=["lower_name"], name="lower_name_unique" + ) + model.objects.create(name="Box") + constraint.validate(model, model(name="Case")) + + invalid_product = model(name="BOX") + msg = str(invalid_product.unique_error_message(model, ["lower_name"])) + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate(model, invalid_product) + + # Excluding referenced or generated fields should skip validation. + constraint.validate(model, invalid_product, exclude={"lower_name"}) + constraint.validate(model, invalid_product, exclude={"name"}) + + @skipUnlessDBFeature("supports_stored_generated_columns") + def test_validate_fields_generated_field_stored_nulls_distinct(self): + self.assertGeneratedFieldNullsDistinctIsValidated( + model=GeneratedFieldStoredProduct + ) + + @skipUnlessDBFeature("supports_virtual_generated_columns") + def test_validate_fields_generated_field_virtual_nulls_distinct(self): + self.assertGeneratedFieldNullsDistinctIsValidated( + model=GeneratedFieldVirtualProduct + ) + + def assertGeneratedFieldNullsDistinctIsValidated(self, model): + constraint = models.UniqueConstraint( + fields=["lower_name"], + name="lower_name_unique_nulls_distinct", + nulls_distinct=False, + ) + model.objects.create(name=None) + valid_product = model(name="Box") + constraint.validate(model, valid_product) + + invalid_product = model(name=None) + msg = str(invalid_product.unique_error_message(model, ["lower_name"])) + with self.assertRaisesMessage(ValidationError, msg): + constraint.validate(model, invalid_product) + @skipUnlessDBFeature("supports_table_check_constraints") def test_validate_nullable_textfield_with_isnull_true(self): is_null_constraint = models.UniqueConstraint( diff --git a/tests/postgres_tests/test_constraints.py b/tests/postgres_tests/test_constraints.py index f571a96f35..ab5bf2bab1 100644 --- a/tests/postgres_tests/test_constraints.py +++ b/tests/postgres_tests/test_constraints.py @@ -14,6 +14,7 @@ from django.db.models import ( F, ForeignKey, Func, + GeneratedField, IntegerField, Model, Q, @@ -32,6 +33,7 @@ try: from django.contrib.postgres.constraints import ExclusionConstraint from django.contrib.postgres.fields import ( DateTimeRangeField, + IntegerRangeField, RangeBoundary, RangeOperators, ) @@ -866,6 +868,38 @@ class ExclusionConstraintTests(PostgreSQLTestCase): constraint.validate(RangesModel, RangesModel(ints=(51, 60))) constraint.validate(RangesModel, RangesModel(ints=(10, 20)), exclude={"ints"}) + @skipUnlessDBFeature("supports_stored_generated_columns") + @isolate_apps("postgres_tests") + def test_validate_generated_field_range_adjacent(self): + class RangesModelGeneratedField(Model): + ints = IntegerRangeField(blank=True, null=True) + ints_generated = GeneratedField( + expression=F("ints"), + output_field=IntegerRangeField(null=True), + db_persist=True, + ) + + with connection.schema_editor() as editor: + editor.create_model(RangesModelGeneratedField) + + constraint = ExclusionConstraint( + name="ints_adjacent", + expressions=[("ints_generated", RangeOperators.ADJACENT_TO)], + violation_error_code="custom_code", + violation_error_message="Custom error message.", + ) + RangesModelGeneratedField.objects.create(ints=(20, 50)) + + range_obj = RangesModelGeneratedField(ints=(3, 20)) + with self.assertRaisesMessage(ValidationError, "Custom error message."): + constraint.validate(RangesModelGeneratedField, range_obj) + + # Excluding referenced or generated field should skip validation. + constraint.validate(RangesModelGeneratedField, range_obj, exclude={"ints"}) + constraint.validate( + RangesModelGeneratedField, range_obj, exclude={"ints_generated"} + ) + def test_validate_with_custom_code_and_condition(self): constraint = ExclusionConstraint( name="ints_adjacent", |
