From 2cb9cd56741c352005935e5e13dbe3cc3ff76104 Mon Sep 17 00:00:00 2001 From: Peter Bex Date: Thu, 21 Mar 2019 11:27:16 +0100 Subject: [PATCH] Do not abort early when standard uniqueness validations fail Just like Django will do in the standard behaviour, we want to ensure all errors are included. Therefore, we catch validation errors raised by Django and merge these with our own. --- partial_index/mixins.py | 17 +++++++++++-- tests/test_models.py | 55 ++++++++++++++++++++++++++++++++++++++++- tests/testapp/models.py | 17 +++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/partial_index/mixins.py b/partial_index/mixins.py index fb0e344..79a4a09 100644 --- a/partial_index/mixins.py +++ b/partial_index/mixins.py @@ -34,9 +34,22 @@ class MyModel(ValidatePartialUniqueMixin, models.Model): """ def validate_unique(self, exclude=None): + errors = {} + # Standard unique validation first. - super(ValidatePartialUniqueMixin, self).validate_unique(exclude=exclude) - self.validate_partial_unique() + try: + super(ValidatePartialUniqueMixin, self).validate_unique(exclude=exclude) + except ValidationError as e: + errors.update(e.error_dict) + + # Merge ours into the existing errors (if any) + try: + self.validate_partial_unique() + except ValidationError as e: + errors.update(e.error_dict) + + if errors: + raise PartialUniqueValidationError(errors) def validate_partial_unique(self): """Check partial unique constraints on the model and raise ValidationError if any failed. diff --git a/tests/test_models.py b/tests/test_models.py index 8f726f2..3f20796 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,7 +6,7 @@ from django.utils import timezone from django.core.exceptions import NON_FIELD_ERRORS, ValidationError -from testapp.models import User, Room, RoomBookingText, JobText, ComparisonText, RoomBookingQ, JobQ, ComparisonQ +from testapp.models import User, Room, RoomBookingText, JobText, ComparisonText, RoomBookingQ, JobQ, ComparisonQ, Label class PartialIndexRoomBookingTest(TransactionTestCase): @@ -154,3 +154,56 @@ def test_comparison_text_duplicate_different_numbers(self): def test_comparison_q_duplicate_different_numbers(self): ComparisonQ.objects.create(a=1, b=2) ComparisonQ.objects.create(a=1, b=2) + + +class PartialIndexLabelValidationTest(TransactionTestCase): + """Test that partial unique validations are all executed.""" + + def setUp(self): + self.room1 = Room.objects.create(name='room 1') + self.room2 = Room.objects.create(name='room 2') + self.user1 = User.objects.create(name='user 1') + self.user2 = User.objects.create(name='user 2') + + def test_single_unique_constraints_are_still_evaluated(self): + Label.objects.create(label='a', user=self.user1, room=self.room1, uuid='11111111-0000-0000-0000-000000000000', created_at='2019-01-01T00:00:00') + + label = Label(label='b', user=self.user2, room=self.room2, uuid='22222222-0000-0000-0000-000000000000', created_at='2019-01-01T00:00:00') + with self.assertRaises(ValidationError) as cm: + label.full_clean() + + self.assertSetEqual({'created_at'}, set(cm.exception.message_dict.keys())) + self.assertEqual('unique', cm.exception.error_dict['created_at'][0].code) + + with self.assertRaises(IntegrityError): + label.save() + + def test_standard_single_field_unique_constraints_do_not_block_evaluation_of_partial_index_constraints(self): + Label.objects.create(label='a', user=self.user1, room=self.room1, uuid='11111111-0000-0000-0000-000000000000', created_at='2019-01-01T00:00:00') + + label = Label(label='b', user=self.user2, room=self.room2, uuid='11111111-0000-0000-0000-000000000000', created_at='2019-01-01T00:00:00') + with self.assertRaises(ValidationError) as cm: + label.full_clean() + + self.assertSetEqual({'created_at', 'uuid'}, set(cm.exception.message_dict.keys())) + self.assertEqual('unique', cm.exception.error_dict['created_at'][0].code) + self.assertEqual('unique', cm.exception.error_dict['uuid'][0].code) + + with self.assertRaises(IntegrityError): + label.save() + + def test_standard_unique_together_constraints_do_not_block_evaluation_of_partial_index_constraints(self): + Label.objects.create(label='a', user=self.user1, room=self.room1, uuid='11111111-0000-0000-0000-000000000000', created_at='2019-01-01T11:11:11') + + label = Label(label='b', user=self.user1, room=self.room1, uuid='11111111-0000-0000-0000-000000000000', created_at='2019-01-02T22:22:22') + with self.assertRaises(ValidationError) as cm: + label.full_clean() + + self.assertSetEqual({NON_FIELD_ERRORS, 'uuid'}, set(cm.exception.message_dict.keys())) + self.assertEqual(1, len(cm.exception.error_dict['uuid'])) + self.assertEqual(1, len(cm.exception.error_dict[NON_FIELD_ERRORS])) + self.assertEqual('unique', cm.exception.error_dict['uuid'][0].code) + self.assertEqual('unique_together', cm.exception.error_dict[NON_FIELD_ERRORS][0].code) + + with self.assertRaises(IntegrityError): + label.save() diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 86a8e8c..5be926a 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -97,3 +97,20 @@ class Meta: indexes = [ PartialIndex(fields=['a', 'b'], unique=True, where=PQ(a=PF('b'))), ] + + +class Label(ValidatePartialUniqueMixin, models.Model): + room = models.ForeignKey(Room, on_delete=models.CASCADE, null=True, blank=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) + label = models.TextField() + uuid = models.UUIDField() + created_at = models.DateTimeField(unique=True) + deleted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + indexes = [ + PartialIndex(fields=['room', 'label'], unique=True, where=PQ(deleted_at__isnull=True)), + PartialIndex(fields=['user', 'label'], unique=True, where=PQ(deleted_at__isnull=True)), + PartialIndex(fields=['uuid'], unique=True, where=PQ(deleted_at__isnull=True)), + ] + unique_together = [['room', 'user']] # Regardless of deletion status