diff --git a/eav/migrations/0012_add_value_uniqueness_checks.py b/eav/migrations/0012_add_value_uniqueness_checks.py new file mode 100644 index 00000000..d35b9d81 --- /dev/null +++ b/eav/migrations/0012_add_value_uniqueness_checks.py @@ -0,0 +1,54 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Add uniqueness and integrity constraints to the Value model. + + This migration adds database-level constraints to ensure: + 1. Each entity (identified by UUID) can have only one value per attribute + 2. Each entity (identified by integer ID) can have only one value per attribute + 3. Each value must use either entity_id OR entity_uuid, never both or neither + + These constraints ensure data integrity by preventing duplicate attribute values + for the same entity and enforcing the XOR relationship between the two types of + entity identification (integer ID vs UUID). + """ + + dependencies = [ + ("eav", "0011_update_defaults_and_meta"), + ] + + operations = [ + migrations.AddConstraint( + model_name="value", + constraint=models.UniqueConstraint( + fields=("entity_ct", "attribute", "entity_uuid"), + name="unique_entity_uuid_per_attribute", + ), + ), + migrations.AddConstraint( + model_name="value", + constraint=models.UniqueConstraint( + fields=("entity_ct", "attribute", "entity_id"), + name="unique_entity_id_per_attribute", + ), + ), + migrations.AddConstraint( + model_name="value", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("entity_id__isnull", False), + ("entity_uuid__isnull", True), + ), + models.Q( + ("entity_id__isnull", True), + ("entity_uuid__isnull", False), + ), + _connector="OR", + ), + name="ensure_entity_id_xor_entity_uuid", + ), + ), + ] diff --git a/eav/models/value.py b/eav/models/value.py index 2b5cf65a..5d8c0fdf 100644 --- a/eav/models/value.py +++ b/eav/models/value.py @@ -1,7 +1,7 @@ # ruff: noqa: UP007 from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, ClassVar, Optional from django.contrib.contenttypes import fields as generic from django.contrib.contenttypes.models import ContentType @@ -173,6 +173,24 @@ class Meta: verbose_name = _("Value") verbose_name_plural = _("Values") + constraints: ClassVar[list[models.Constraint]] = [ + models.UniqueConstraint( + fields=["entity_ct", "attribute", "entity_uuid"], + name="unique_entity_uuid_per_attribute", + ), + models.UniqueConstraint( + fields=["entity_ct", "attribute", "entity_id"], + name="unique_entity_id_per_attribute", + ), + models.CheckConstraint( + check=( + models.Q(entity_id__isnull=False, entity_uuid__isnull=True) + | models.Q(entity_id__isnull=True, entity_uuid__isnull=False) + ), + name="ensure_entity_id_xor_entity_uuid", + ), + ] + def __str__(self) -> str: """String representation of a Value.""" entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 00000000..0741fb28 --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,319 @@ +import pytest +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from eav.models import Attribute, Value +from test_project.models import Doctor, Patient + + +@pytest.fixture +def patient_ct() -> ContentType: + """Return the content type for the Patient model.""" + return ContentType.objects.get_for_model(Patient) + + +@pytest.fixture +def doctor_ct() -> ContentType: + """Return the content type for the Doctor model.""" + # We use Doctor model for UUID tests since it already uses UUID as primary key + return ContentType.objects.get_for_model(Doctor) + + +@pytest.fixture +def attribute() -> Attribute: + """Create and return a test attribute.""" + return Attribute.objects.create( + name="test_attribute", + datatype="text", + ) + + +@pytest.fixture +def patient() -> Patient: + """Create and return a patient with integer PK.""" + # Patient model uses auto-incrementing integer primary keys + return Patient.objects.create(name="Patient with Int PK") + + +@pytest.fixture +def doctor() -> Doctor: + """Create and return a doctor with UUID PK.""" + # Doctor model uses UUID primary keys, ideal for testing entity_uuid constraints + return Doctor.objects.create(name="Doctor with UUID PK") + + +class TestValueModelValidation: + """Test Value model Python-level validation (via full_clean in save).""" + + @pytest.mark.django_db + def test_unique_entity_id_validation( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + ) -> None: + """ + Test that model validation prevents duplicate entity_id values. + + The model's save() method calls full_clean() which should detect the + duplicate before it hits the database constraint. + """ + # Create first value - this should succeed + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="First value", + ) + + # Try to create a second value with the same entity_ct, attribute, and entity_id + # This should fail with ValidationError from full_clean() + with pytest.raises(ValidationError) as excinfo: + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="Second value", + ) + + # Verify the error message indicates uniqueness violation + assert "already exists" in str(excinfo.value) + + @pytest.mark.django_db + def test_unique_entity_uuid_validation( + self, + doctor_ct: ContentType, + attribute: Attribute, + doctor: Doctor, + ) -> None: + """ + Test that model validation prevents duplicate entity_uuid values. + + The model's full_clean() should detect the duplicate before it hits + the database constraint. + """ + # Create first value with UUID - this should succeed + Value.objects.create( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="First UUID value", + ) + + # Try to create a second value with the same entity_ct, + # attribute, and entity_uuid + with pytest.raises(ValidationError) as excinfo: + Value.objects.create( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Second UUID value", + ) + + # Verify the error message indicates uniqueness violation + assert "already exists" in str(excinfo.value) + + @pytest.mark.django_db + def test_entity_id_xor_entity_uuid_validation( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + doctor: Doctor, + ) -> None: + """ + Test that model validation enforces XOR between entity_id and entity_uuid. + + The model's full_clean() should detect if both or neither field is provided. + """ + # Try to create with both ID types + with pytest.raises(ValidationError): + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Both IDs provided", + ) + + # Try to create with neither ID type + with pytest.raises(ValidationError): + Value.objects.create( + entity_ct=patient_ct, + entity_id=None, + entity_uuid=None, + attribute=attribute, + value_text="No IDs provided", + ) + + +class TestValueDatabaseConstraints: + """ + Test Value model database constraints when bypassing model validation. + + These tests use bulk_create() which bypasses the save() method and its + full_clean() validation, allowing us to test the database constraints directly. + """ + + @pytest.mark.django_db + def test_unique_entity_id_constraint( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + ) -> None: + """ + Test that database constraints prevent duplicate entity_id values. + + Even when bypassing model validation with bulk_create, the database + constraint should still prevent duplicates. + """ + # Create first value - this should succeed + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="First value", + ) + + # Try to bulk create a duplicate value, bypassing model validation + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="Second value", + ), + ], + ) + + @pytest.mark.django_db + def test_unique_entity_uuid_constraint( + self, + doctor_ct: ContentType, + attribute: Attribute, + doctor: Doctor, + ) -> None: + """ + Test that database constraints prevent duplicate entity_uuid values. + + Even when bypassing model validation, the database constraint should + still prevent duplicates. + """ + # Create first value with UUID - this should succeed + Value.objects.create( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="First UUID value", + ) + + # Try to bulk create a duplicate value, bypassing model validation + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Second UUID value", + ), + ], + ) + + @pytest.mark.django_db + def test_entity_id_and_entity_uuid_constraint( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + doctor: Doctor, + ) -> None: + """ + Test that database constraints prevent having both entity_id and entity_uuid. + + Even when bypassing model validation, the database constraint should + prevent having both fields set. + """ + # Try to bulk create with both ID types + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=patient.id, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Both IDs provided", + ), + ], + ) + + @pytest.mark.django_db + def test_neither_entity_id_nor_entity_uuid_constraint( + self, + patient_ct: ContentType, + attribute: Attribute, + ) -> None: + """ + Test that database constraints prevent having neither entity_id nor entity_uuid. + + Even when bypassing model validation, the database constraint should + prevent having neither field set. + """ + # Try to bulk create with neither ID type + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=None, + entity_uuid=None, + attribute=attribute, + value_text="No IDs provided", + ), + ], + ) + + @pytest.mark.django_db + def test_happy_path_constraints( + self, + patient_ct: ContentType, + doctor_ct: ContentType, + attribute: Attribute, + patient: Patient, + doctor: Doctor, + ) -> None: + """ + Test that valid values pass both database constraints. + + Values with either entity_id or entity_uuid (but not both) should be accepted. + """ + # Test with entity_id using bulk_create + values = Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="Integer ID bulk created", + ), + ], + ) + assert len(values) == 1 + + # Test with entity_uuid using bulk_create + values = Value.objects.bulk_create( + [ + Value( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="UUID bulk created", + ), + ], + ) + assert len(values) == 1