From d6374f1ba6325f03ba170e095d7e84e6b65d5183 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 18 Jul 2024 15:40:07 -0700 Subject: [PATCH] Bugfix - Resolve VO class in Lists If a List field has been declared with content_type as a Value Object and the value object class was supplied as a string, we should resolve reference on domain initialization. --- src/protean/domain/__init__.py | 13 ++++++++ src/protean/fields/embedded.py | 5 +++- tests/field/test_list.py | 32 ++++++++++++++++++-- tests/field/test_vo.py | 4 ++- tests/views/test_view_persistence.py | 44 ++++++++++++++++++++++++++++ tests/views/tests.py | 1 + 6 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/views/test_view_persistence.py diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index 7737ff15..56fb406c 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -30,6 +30,7 @@ NotSupportedError, ) from protean.fields import HasMany, HasOne, Reference, ValueObject +from protean.fields import List as ProteanList from protean.globals import g from protean.reflection import declared_fields, has_fields, id_field from protean.utils import ( @@ -510,6 +511,7 @@ def _register_element(self, element_type, element_cls, **opts): # noqa: C901 # 1. Associations if has_fields(new_cls): for _, field_obj in declared_fields(new_cls).items(): + # Record Association references to resolve later if isinstance(field_obj, (HasOne, HasMany, Reference)) and isinstance( field_obj.to_cls, str ): @@ -517,6 +519,7 @@ def _register_element(self, element_type, element_cls, **opts): # noqa: C901 ("Association", (field_obj, new_cls)) ) + # Record Value Object references to resolve later if isinstance(field_obj, ValueObject) and isinstance( field_obj.value_object_cls, str ): @@ -524,6 +527,16 @@ def _register_element(self, element_type, element_cls, **opts): # noqa: C901 ("ValueObject", (field_obj, new_cls)) ) + # Record Value Object references in List fields to resolve later + if ( + isinstance(field_obj, ProteanList) + and isinstance(field_obj.content_type, ValueObject) + and isinstance(field_obj.content_type.value_object_cls, str) + ): + self._pending_class_resolutions[ + field_obj.content_type.value_object_cls + ].append(("ValueObject", (field_obj.content_type, new_cls))) + # 2. Meta Linkages if element_type in [ DomainObjects.ENTITY, diff --git a/src/protean/fields/embedded.py b/src/protean/fields/embedded.py index 8a15b923..54251613 100644 --- a/src/protean/fields/embedded.py +++ b/src/protean/fields/embedded.py @@ -75,7 +75,10 @@ def _validate_value_object_cls(self, value_object_cls): raise IncorrectUsageError( { "_value_object": [ - f"`{value_object_cls.__name__}` is not a valid Value Object" + ( + f"`{value_object_cls.__name__}` is not a valid Value Object " + "and cannot be embedded in a Value Object field" + ) ] } ) diff --git a/tests/field/test_list.py b/tests/field/test_list.py index 72eb0062..02a10204 100644 --- a/tests/field/test_list.py +++ b/tests/field/test_list.py @@ -2,8 +2,8 @@ import pytest -from protean import BaseValueObject -from protean.exceptions import ValidationError +from protean import BaseAggregate, BaseEntity, BaseValueObject +from protean.exceptions import IncorrectUsageError, ValidationError from protean.fields.basic import ( Boolean, Date, @@ -15,6 +15,7 @@ String, ) from protean.fields.embedded import ValueObject +from protean.reflection import declared_fields class TestListFieldContentType: @@ -98,6 +99,33 @@ class VO(BaseValueObject): value = [VO(foo="bar"), VO(foo="baz")] assert field._cast_to_type(value) == value + def test_list_field_with_invalid_value_object(self): + class VO(BaseEntity): + foo = String() + + with pytest.raises(IncorrectUsageError) as exc: + List(content_type=ValueObject(VO)) + + assert exc.value.messages == { + "_value_object": [ + "`VO` is not a valid Value Object and cannot be embedded in " + "a Value Object field" + ] + } + + def test_list_field_with_value_object_string_is_resolved(self, test_domain): + class VO(BaseValueObject): + foo = String() + + class Foo(BaseAggregate): + foos = List(content_type=ValueObject("VO")) + + test_domain.register(VO) + test_domain.register(Foo) + test_domain.init(traverse=False) + + assert declared_fields(Foo)["foos"].content_type.value_object_cls == VO + class TestListFieldAsDictWithDifferentContentTypes: def test_list_as_dict_with_string_content_type(self): diff --git a/tests/field/test_vo.py b/tests/field/test_vo.py index 65b4d39a..863f0817 100644 --- a/tests/field/test_vo.py +++ b/tests/field/test_vo.py @@ -28,5 +28,7 @@ class User(BaseAggregate): address = ValueObject(Address) assert exc.value.messages == { - "_value_object": ["`Address` is not a valid Value Object"] + "_value_object": [ + "`Address` is not a valid Value Object and cannot be embedded in a Value Object field" + ] } diff --git a/tests/views/test_view_persistence.py b/tests/views/test_view_persistence.py new file mode 100644 index 00000000..27e07736 --- /dev/null +++ b/tests/views/test_view_persistence.py @@ -0,0 +1,44 @@ +import pytest + +from protean import BaseView +from protean.fields import Identifier, Integer, String + + +class Person(BaseView): + person_id = Identifier(identifier=True) + first_name = String(max_length=50, required=True) + last_name = String(max_length=50) + age = Integer(default=21) + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(Person) + test_domain.init(traverse=False) + + +class TestViewPersistence: + def test_view_can_be_persisted(self, test_domain): + person = Person(person_id="1", first_name="John", last_name="Doe", age=25) + test_domain.repository_for(Person).add(person) + + refreshed_person = test_domain.repository_for(Person).get(person.person_id) + + assert refreshed_person.person_id == "1" + assert refreshed_person.first_name == "John" + assert refreshed_person.last_name == "Doe" + assert refreshed_person.age == 25 + + def test_view_can_be_updated(self, test_domain): + person = Person(person_id="1", first_name="John", last_name="Doe", age=25) + test_domain.repository_for(Person).add(person) + + person.first_name = "Jane" + test_domain.repository_for(Person).add(person) + + refreshed_person = test_domain.repository_for(Person).get(person.person_id) + + assert refreshed_person.person_id == "1" + assert refreshed_person.first_name == "Jane" + assert refreshed_person.last_name == "Doe" + assert refreshed_person.age == 25 diff --git a/tests/views/tests.py b/tests/views/tests.py index 908bf98a..8ad57e0a 100644 --- a/tests/views/tests.py +++ b/tests/views/tests.py @@ -127,6 +127,7 @@ def register_elements(test_domain): test_domain.register(OrderedPerson, order_by="first_name") test_domain.register(OrderedPersonSubclass, order_by="last_name") test_domain.register(Building) + test_domain.init(traverse=False) class TestViewRegistration: