mirror of
https://github.com/jazzband/django-eav2.git
synced 2026-03-16 22:40:26 +00:00
feat(models): add database constraints to Value model
This commit is contained in:
parent
4deda2abc5
commit
fafe528ea5
3 changed files with 392 additions and 1 deletions
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
54
eav/migrations/0012_add_value_uniqueness_checks.py
Normal file
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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 Value(models.Model):
|
|||
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
|
||||
|
|
|
|||
319
tests/test_value.py
Normal file
319
tests/test_value.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue