From 90cd0f3cddc0b5131db8d89bab11d92e28958170 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Thu, 30 May 2024 15:05:31 -0700 Subject: [PATCH] This commit allows `List` fields to manage an array of Value Objects. Specific changes: - Update `List` field to support Value Object content type - Allow ValueObjects to output dict even when outside an entity or aggregate - Support serialization of Value Objects in SQLAlchemy with a custom JSON serializer This will come handy when embedding List of Value Objects in containers like Events, which need to enclose all information related to the event within themselves. --- .../fields/container-fields.md | 142 +++++++++++++---- .../fields/container-fields/004.py | 24 +++ src/protean/adapters/repository/sqlalchemy.py | 47 +++++- src/protean/fields/basic.py | 21 ++- src/protean/fields/embedded.py | 13 +- .../postgresql/test_array_datatype.py | 6 +- .../postgresql/test_list_datatype.py | 6 +- .../sqlite/test_array_datatype.py | 6 +- .../sqlalchemy_repo/postgresql/conftest.py | 5 + .../test_persisting_list_of_value_objects.py | 59 +++++++ .../test_generic_field_behavior.py} | 10 +- .../fields/test_list_of_value_objects.py | 135 ++++++++++++++++ tests/field/test_list.py | 144 ++++++++++++++++++ 13 files changed, 558 insertions(+), 60 deletions(-) create mode 100644 docs_src/guides/domain-definition/fields/container-fields/004.py create mode 100644 tests/adapters/repository/sqlalchemy_repo/postgresql/test_persisting_list_of_value_objects.py rename tests/entity/{test_fields.py => fields/test_generic_field_behavior.py} (85%) create mode 100644 tests/entity/fields/test_list_of_value_objects.py create mode 100644 tests/field/test_list.py diff --git a/docs/guides/domain-definition/fields/container-fields.md b/docs/guides/domain-definition/fields/container-fields.md index ea1b5938..d4944f61 100644 --- a/docs/guides/domain-definition/fields/container-fields.md +++ b/docs/guides/domain-definition/fields/container-fields.md @@ -1,5 +1,34 @@ # Container Fields +## `ValueObject` + +Represents a field that holds a value object. This field is used to embed a +Value Object within an entity. + +**Arguments** + +- **`value_object_cls`**: The class of the value object to be embedded. + +```python hl_lines="7-15 20" +{! docs_src/guides/domain-definition/fields/container-fields/003.py !} +``` + +You can provide an instance of the Value Object as input to the value object +field: + +```shell hl_lines="2 8" +In [1]: account = Account( + ...: balance=Balance(currency="USD", amount=100.0), + ...: name="Checking" + ...: ) + +In [2]: account.to_dict() +Out[2]: +{'balance': {'currency': 'USD', 'amount': 100.0}, + 'name': 'Checking', + 'id': '513b8a78-e00f-45ce-bb6f-11ef0cccbec6'} +``` + ## `List` A field that represents a list of values. @@ -18,8 +47,8 @@ Accepted field types are `Boolean`, `Date`, `DateTime`, `Float`, `Identifier`, specifying `pickled=True`. Databases that don’t support lists simply store the field as a python object. -```python hl_lines="9" -{! docs_src/guides/domain-definition/fields/simple-fields/001.py !} +```python hl_lines="10" +{! docs_src/guides/domain-definition/fields/container-fields/001.py !} ``` The value is provided as a `list`, and the values in the `list` are validated @@ -40,6 +69,86 @@ ERROR: Error during initialization: {'roles': ['Invalid value [1, 2]']} ValidationError: {'roles': ['Invalid value [1, 2]']} ``` +### List of Value Objects + +A `List` field can even hold a list of `ValueObject` instances. The content of +the `List` will be persisted as a list of dicts, so the field will behave +essentially like `List(Dict())` when it comes to persistence. However, it will +have the added benefit of a validation structure of content within the List. + +```python hl_lines="7-12 19" +{! docs_src/guides/domain-definition/fields/container-fields/004.py !} +``` + +```shell +In [1]: order = Order( + ...: customer=Customer( + ...: name="John Doe", + ...: email="john@doe.com", + ...: addresses=[ + ...: Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + ...: Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + ...: ], + ...: ) + ...: ) + +In [2]: order.to_dict() +Out[2]: +{'customer': {'name': 'John Doe', + 'email': 'john@doe.com', + 'addresses': [{'street': '123 Main St', + 'city': 'Anytown', + 'state': 'CA', + 'country': 'USA'}, + {'street': '321 Side St', + 'city': 'Anytown', + 'state': 'CA', + 'country': 'USA'}], + 'id': 'f5c5a750-e9fe-47db-877e-44b7c0ca1dfc'}, + 'id': '4a9538bf-1eb1-4621-8ced-86bcc4362a51'} + +In [3]: domain.repository_for(Order).add(order) +Out[3]: + +In [4]: retrieved_order = domain.repository_for(Order).get(order.id) + +In [5]: len(retrieved_order.customer.addresses) +Out[5]: 2 +``` + +Note that unlike `HasMany` fields, you have to supply a new entire list of +Value Objects if you want to update the field. Appendind to the list will not +work. + +```shell +In [6]: retrieved_order.customer.addresses.append( + ...: Address(street="456 Side St", city="Anytown", state="CA", country="USA") + ...: ) + +In [7]: domain.repository_for(Order).add(retrieved_order) +Out[7]: + +In [8]: updated_order = domain.repository_for(Order).get(order.id) + +In [9]: len(updated_order.customer.addresses) +Out[9]: 2 +# This did not work! +In [10]: updated_order.customer.addresses = [ + ...: Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + ...: Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + ...: Address(street="456 Side St", city="Anytown", state="CA", country="USA"), + ...: ] + +In [11]: domain.repository_for(Order).add(updated_order) +Out[11]: + +In [12]: refreshed_order = domain.repository_for(Order).get(order.id) + +In [13]: len(refreshed_order.customer.addresses) +Out[13]: 3 +# This worked! +``` + ## `Dict` A field that represents a dictionary. @@ -74,32 +183,3 @@ Out[2]: by default. You can force it to store the pickled value as a Python object by specifying pickled=True. Databases that don’t support lists simply store the field as a python object. - -## `ValueObject` - -Represents a field that holds a value object. This field is used to embed a -Value Object within an entity. - -**Arguments** - -- **`value_object_cls`**: The class of the value object to be embedded. - -```python hl_lines="7-15 20" -{! docs_src/guides/domain-definition/fields/container-fields/003.py !} -``` - -You can provide an instance of the Value Object as input to the value object -field: - -```shell hl_lines="2 8" -In [1]: account = Account( - ...: balance=Balance(currency="USD", amount=100.0), - ...: name="Checking" - ...: ) - -In [2]: account.to_dict() -Out[2]: -{'balance': {'currency': 'USD', 'amount': 100.0}, - 'name': 'Checking', - 'id': '513b8a78-e00f-45ce-bb6f-11ef0cccbec6'} -``` \ No newline at end of file diff --git a/docs_src/guides/domain-definition/fields/container-fields/004.py b/docs_src/guides/domain-definition/fields/container-fields/004.py new file mode 100644 index 00000000..6a42654e --- /dev/null +++ b/docs_src/guides/domain-definition/fields/container-fields/004.py @@ -0,0 +1,24 @@ +from protean import Domain +from protean.fields import HasOne, String, List, ValueObject + +domain = Domain(__file__, load_toml=False) + + +@domain.value_object +class Address: + street = String(max_length=100) + city = String(max_length=25) + state = String(max_length=25) + country = String(max_length=25) + + +@domain.entity(part_of="Order") +class Customer: + name = String(max_length=50, required=True) + email = String(max_length=254, required=True) + addresses = List(content_type=ValueObject(Address)) + + +@domain.aggregate +class Order: + customer = HasOne(Customer) diff --git a/src/protean/adapters/repository/sqlalchemy.py b/src/protean/adapters/repository/sqlalchemy.py index c80679f0..867deb55 100644 --- a/src/protean/adapters/repository/sqlalchemy.py +++ b/src/protean/adapters/repository/sqlalchemy.py @@ -1,6 +1,7 @@ """Module with repository implementation for SQLAlchemy""" import copy +import json import logging import uuid @@ -18,6 +19,7 @@ from sqlalchemy.ext.declarative import as_declarative, declared_attr from sqlalchemy.types import CHAR, TypeDecorator +from protean.core.value_object import BaseValueObject from protean.core.model import BaseModel from protean.exceptions import ( ConfigurationError, @@ -37,6 +39,7 @@ List, String, Text, + ValueObject, ) from protean.fields.association import Reference, _ReferenceField from protean.fields.embedded import _ShadowField @@ -112,6 +115,25 @@ def _get_identity_type(): return sa_types.String +def _default(value): + """A function that gets called for objects that can’t otherwise be serialized. + We handle the special case of Value Objects here. + + `TypeError` is raised for unknown types. + """ + if isinstance(value, BaseValueObject): + return value.to_dict() + raise TypeError() + + +def _custom_json_dumps(value): + """Custom JSON Serializer method to handle the special case of ValueObject deserialization. + + This method is passed into sqlalchemy as a value for param `json_serializer` in the call to `create_engine`. + """ + return json.dumps(value, default=_default) + + class DeclarativeMeta(sa_dec.DeclarativeMeta, ABCMeta): """Metaclass for the Sqlalchemy declarative schema""" @@ -128,6 +150,7 @@ def __init__(cls, classname, bases, dict_): # noqa: C901 String: sa_types.String, Text: sa_types.Text, _ReferenceField: _get_identity_type(), + ValueObject: sa_types.PickleType, } def field_mapping_for(field_obj: Field): @@ -171,9 +194,21 @@ def field_mapping_for(field_obj: Field): # Associate Content Type if field_obj.content_type: - type_args.append( - field_mapping.get(field_obj.content_type) - ) + # Treat `ValueObject` differently because it is a field object instance, + # not a field type class + # + # `ValueObject` instances are essentially treated as `Dict`. If not pickled, + # they are persisted as JSON. + if isinstance(field_obj.content_type, ValueObject): + if not field_obj.pickled: + field_mapping_type = psql.JSON + else: + field_mapping_type = sa_types.PickleType + else: + field_mapping_type = field_mapping.get( + field_obj.content_type + ) + type_args.append(field_mapping_type) else: type_args.append(sa_types.Text) @@ -526,7 +561,11 @@ def __init__(self, *args, **kwargs): kwargs = self._get_database_specific_engine_args() - self._engine = create_engine(make_url(self.conn_info["database_uri"]), **kwargs) + self._engine = create_engine( + make_url(self.conn_info["database_uri"]), + json_serializer=_custom_json_dumps, + **kwargs, + ) if self.conn_info["database"] == self.databases.postgresql.value: # Nest database tables under a schema, so that we have complete control diff --git a/src/protean/fields/basic.py b/src/protean/fields/basic.py index dbd4151d..36ee8b1d 100644 --- a/src/protean/fields/basic.py +++ b/src/protean/fields/basic.py @@ -10,6 +10,7 @@ from protean.exceptions import InvalidOperationError, OutOfContextError, ValidationError from protean.fields import Field, validators +from protean.fields.embedded import ValueObject from protean.globals import current_domain from protean.utils import IdentityType @@ -233,7 +234,7 @@ def __init__(self, content_type=String, pickled=False, **kwargs): String, Text, Dict, - ]: + ] and not isinstance(content_type, ValueObject): raise ValidationError({"content_type": ["Content type not supported"]}) self.content_type = content_type self.pickled = pickled @@ -252,18 +253,24 @@ def _cast_to_type(self, value): new_value = [] try: for item in value: - new_value.append(self.content_type()._load(item)) + if isinstance(self.content_type, ValueObject): + new_value.append(self.content_type._load(item)) + else: + new_value.append(self.content_type()._load(item)) except ValidationError: self.fail("invalid_content", value=value) - if new_value != value: - self.fail("invalid_content", value=value) - - return value + return new_value def as_dict(self, value): """Return JSON-compatible value of self""" - return value + new_value = [] + for item in value: + if isinstance(self.content_type, ValueObject): + new_value.append(self.content_type.as_dict(item)) + else: + new_value.append(self.content_type().as_dict(item)) + return new_value class Dict(Field): diff --git a/src/protean/fields/embedded.py b/src/protean/fields/embedded.py index 52811429..4e7ad003 100644 --- a/src/protean/fields/embedded.py +++ b/src/protean/fields/embedded.py @@ -100,14 +100,19 @@ def _construct_embedded_fields(self): referenced_as=field_obj.referenced_as, ) - # Refresh underlying embedded field names for embedded_field in self.embedded_fields.values(): if embedded_field.referenced_as: embedded_field.attribute_name = embedded_field.referenced_as else: - embedded_field.attribute_name = ( - self.field_name + "_" + embedded_field.field_name - ) + # VO is associated with an aggregate/entity + if self.field_name is not None: + # Refresh underlying embedded field names + embedded_field.attribute_name = ( + self.field_name + "_" + embedded_field.field_name + ) + else: + # VO is being used standalone + embedded_field.attribute_name = embedded_field.field_name def __set_name__(self, entity_cls, name): super().__set_name__(entity_cls, name) diff --git a/tests/adapters/model/sqlalchemy_model/postgresql/test_array_datatype.py b/tests/adapters/model/sqlalchemy_model/postgresql/test_array_datatype.py index 909edb9b..1db538cb 100644 --- a/tests/adapters/model/sqlalchemy_model/postgresql/test_array_datatype.py +++ b/tests/adapters/model/sqlalchemy_model/postgresql/test_array_datatype.py @@ -112,9 +112,10 @@ def test_array_content_type_validation(test_domain): {"email": "john.doe@gmail.com", "roles": [1.0, 2.0]}, {"email": "john.doe@gmail.com", "roles": [datetime.now(UTC)]}, ]: - with pytest.raises(ValidationError) as exception: + try: ArrayUser(**kwargs) - assert exception.value.messages["roles"][0].startswith("Invalid value") + except ValidationError: + pytest.fail("Failed to convert integers into strings in List field type") model_cls = test_domain.repository_for(IntegerArrayUser)._model user = IntegerArrayUser(email="john.doe@gmail.com", roles=[1, 2]) @@ -126,7 +127,6 @@ def test_array_content_type_validation(test_domain): for kwargs in [ {"email": "john.doe@gmail.com", "roles": ["ADMIN", "USER"]}, - {"email": "john.doe@gmail.com", "roles": ["1", "2"]}, {"email": "john.doe@gmail.com", "roles": [datetime.now(UTC)]}, ]: with pytest.raises(ValidationError) as exception: diff --git a/tests/adapters/model/sqlalchemy_model/postgresql/test_list_datatype.py b/tests/adapters/model/sqlalchemy_model/postgresql/test_list_datatype.py index 0d3d85d9..e17d1b0e 100644 --- a/tests/adapters/model/sqlalchemy_model/postgresql/test_list_datatype.py +++ b/tests/adapters/model/sqlalchemy_model/postgresql/test_list_datatype.py @@ -42,9 +42,10 @@ def test_array_content_type_validation(test_domain): {"email": "john.doe@gmail.com", "roles": [1.0, 2.0]}, {"email": "john.doe@gmail.com", "roles": [datetime.now(UTC)]}, ]: - with pytest.raises(ValidationError) as exception: + try: ListUser(**kwargs) - assert exception.value.messages["roles"][0].startswith("Invalid value") + except ValidationError: + pytest.fail("Failed to convert integers into strings in List field type") model_cls = test_domain.repository_for(IntegerListUser)._model user = IntegerListUser(email="john.doe@gmail.com", roles=[1, 2]) @@ -56,7 +57,6 @@ def test_array_content_type_validation(test_domain): for kwargs in [ {"email": "john.doe@gmail.com", "roles": ["ADMIN", "USER"]}, - {"email": "john.doe@gmail.com", "roles": ["1", "2"]}, {"email": "john.doe@gmail.com", "roles": [datetime.now(UTC)]}, ]: with pytest.raises(ValidationError) as exception: diff --git a/tests/adapters/model/sqlalchemy_model/sqlite/test_array_datatype.py b/tests/adapters/model/sqlalchemy_model/sqlite/test_array_datatype.py index 33c470a5..c5797214 100644 --- a/tests/adapters/model/sqlalchemy_model/sqlite/test_array_datatype.py +++ b/tests/adapters/model/sqlalchemy_model/sqlite/test_array_datatype.py @@ -62,9 +62,10 @@ def test_array_content_type_validation(test_domain): {"email": "john.doe@gmail.com", "roles": [1.0, 2.0]}, {"email": "john.doe@gmail.com", "roles": [datetime.now(UTC)]}, ]: - with pytest.raises(ValidationError) as exception: + try: ArrayUser(**kwargs) - assert exception.value.messages["roles"][0].startswith("Invalid value") + except ValidationError: + pytest.fail("Failed to convert integers into strings in List field type") model_cls = test_domain.repository_for(IntegerArrayUser)._model user = IntegerArrayUser(email="john.doe@gmail.com", roles=[1, 2]) @@ -76,7 +77,6 @@ def test_array_content_type_validation(test_domain): for kwargs in [ {"email": "john.doe@gmail.com", "roles": ["ADMIN", "USER"]}, - {"email": "john.doe@gmail.com", "roles": ["1", "2"]}, {"email": "john.doe@gmail.com", "roles": [datetime.now(UTC)]}, ]: with pytest.raises(ValidationError) as exception: diff --git a/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py b/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py index bc73e168..1133cadb 100644 --- a/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py +++ b/tests/adapters/repository/sqlalchemy_repo/postgresql/conftest.py @@ -19,6 +19,7 @@ def setup_db(): from .elements import Alien, ComplexUser, Person, User from .test_associations import Audit, Comment, Post from .test_persistence import Event + from .test_persisting_list_of_value_objects import Customer, Order domain.register(Alien) domain.register(ComplexUser) @@ -28,6 +29,8 @@ def setup_db(): domain.register(Post) domain.register(Comment) domain.register(Audit) + domain.register(Customer) + domain.register(Order) domain.repository_for(Alien)._dao domain.repository_for(ComplexUser)._dao @@ -37,6 +40,8 @@ def setup_db(): domain.repository_for(Post)._dao domain.repository_for(Comment)._dao domain.repository_for(Audit)._dao + domain.repository_for(Customer)._dao + domain.repository_for(Order)._dao domain.providers["default"]._metadata.create_all() diff --git a/tests/adapters/repository/sqlalchemy_repo/postgresql/test_persisting_list_of_value_objects.py b/tests/adapters/repository/sqlalchemy_repo/postgresql/test_persisting_list_of_value_objects.py new file mode 100644 index 00000000..b4693184 --- /dev/null +++ b/tests/adapters/repository/sqlalchemy_repo/postgresql/test_persisting_list_of_value_objects.py @@ -0,0 +1,59 @@ +import pytest + +from protean import BaseAggregate, BaseEntity, BaseValueObject +from protean.fields import HasOne, List, String, ValueObject + + +class Address(BaseValueObject): + street = String(max_length=100) + city = String(max_length=25) + state = String(max_length=25) + country = String(max_length=25) + + +class Customer(BaseEntity): + name = String(max_length=50, required=True) + email = String(max_length=254, required=True) + addresses = List(content_type=ValueObject(Address)) + + class Meta: + part_of = "Order" + + +# Aggregate that encloses Customer Entity +class Order(BaseAggregate): + customer = HasOne(Customer) + + +@pytest.mark.postgresql +def test_persisting_and_retrieving_list_of_value_objects(test_domain): + test_domain.register(Order) + test_domain.register(Customer) + test_domain.register(Address) + + order = Order( + customer=Customer( + name="John", + email="john@doe.com", + addresses=[ + Address( + street="123 Main St", city="Anytown", state="CA", country="USA" + ), + Address( + street="321 Side St", city="Anytown", state="CA", country="USA" + ), + ], + ) + ) + + test_domain.repository_for(Order).add(order) + + retrieved_order = test_domain.repository_for(Order).get(order.id) + + assert retrieved_order is not None + assert retrieved_order.customer is not None + assert retrieved_order.customer.id == order.customer.id + assert retrieved_order.customer.addresses == [ + Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + ] diff --git a/tests/entity/test_fields.py b/tests/entity/fields/test_generic_field_behavior.py similarity index 85% rename from tests/entity/test_fields.py rename to tests/entity/fields/test_generic_field_behavior.py index 9e80f03e..d3043ab0 100644 --- a/tests/entity/test_fields.py +++ b/tests/entity/fields/test_generic_field_behavior.py @@ -1,6 +1,6 @@ import pytest -from protean import BaseAggregate +from protean import BaseEntity from protean.exceptions import ValidationError from protean.fields import Boolean, Dict, Integer, List from protean.reflection import fields @@ -8,7 +8,7 @@ class TestFields: def test_lists_can_be_mandatory(self): - class Lottery(BaseAggregate): + class Lottery(BaseEntity): jackpot = Boolean() numbers = List(content_type=Integer, required=True) @@ -18,7 +18,7 @@ class Lottery(BaseAggregate): assert exc.value.messages == {"numbers": ["is required"]} def test_dicts_can_be_mandatory(self): - class Lottery(BaseAggregate): + class Lottery(BaseEntity): jackpot = Boolean() numbers = Dict(required=True) @@ -28,13 +28,13 @@ class Lottery(BaseAggregate): assert exc.value.messages == {"numbers": ["is required"]} def test_field_description(self): - class Lottery(BaseAggregate): + class Lottery(BaseEntity): jackpot = Boolean(description="Jackpot won or not") assert fields(Lottery)["jackpot"].description == "Jackpot won or not" def test_field_default_description(self): - class Lottery(BaseAggregate): + class Lottery(BaseEntity): jackpot = Boolean() # By default, description is not auto-set. diff --git a/tests/entity/fields/test_list_of_value_objects.py b/tests/entity/fields/test_list_of_value_objects.py new file mode 100644 index 00000000..601e0f4c --- /dev/null +++ b/tests/entity/fields/test_list_of_value_objects.py @@ -0,0 +1,135 @@ +import pytest + +from protean import BaseAggregate, BaseEntity, BaseValueObject +from protean.fields import HasOne, List, String, ValueObject + + +class Address(BaseValueObject): + street = String(max_length=100) + city = String(max_length=25) + state = String(max_length=25) + country = String(max_length=25) + + +class Customer(BaseEntity): + name = String(max_length=50, required=True) + email = String(max_length=254, required=True) + addresses = List(content_type=ValueObject(Address)) + + class Meta: + part_of = "Order" + + +# Aggregate that encloses Customer Entity +class Order(BaseAggregate): + customer = HasOne(Customer) + + +@pytest.fixture(autouse=True) +def register_elements(test_domain): + test_domain.register(Order) + test_domain.register(Customer) + test_domain.register(Address) + + +def test_that_list_of_value_objects_can_be_assigned_during_initialization(test_domain): + customer = Customer( + name="John Doe", + email="john@doe.com", + addresses=[ + Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + ], + ) + + assert customer is not None + assert customer.addresses == [ + Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + ] + + +def test_that_entity_with_list_of_value_objects_is_persisted_and_retrieved(test_domain): + order = Order( + customer=Customer( + name="John Doe", + email="john@doe.com", + addresses=[ + Address( + street="123 Main St", city="Anytown", state="CA", country="USA" + ), + Address( + street="321 Side St", city="Anytown", state="CA", country="USA" + ), + ], + ) + ) + + test_domain.repository_for(Order).add(order) + + retrieved_order = test_domain.repository_for(Order).get(order.id) + + assert retrieved_order is not None + assert retrieved_order.customer is not None + assert retrieved_order.customer.id == order.customer.id + assert retrieved_order.customer.addresses == [ + Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + ] + + +def test_that_a_value_object_can_be_updated(test_domain): + customer = Customer( + name="John Doe", + email="john@doe.com", + addresses=[ + Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + ], + ) + + customer.addresses.append( + Address(street="123 Side St", city="Anytown", state="CA", country="USA") + ) + + assert len(customer.addresses) == 3 + assert customer.addresses == [ + Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + Address(street="123 Side St", city="Anytown", state="CA", country="USA"), + ] + + +def test_that_a_persisted_entity_with_list_of_value_objects_can_be_updated(test_domain): + order = Order( + customer=Customer( + name="John Doe", + email="john@doe.com", + addresses=[ + Address( + street="123 Main St", city="Anytown", state="CA", country="USA" + ), + Address( + street="321 Side St", city="Anytown", state="CA", country="USA" + ), + ], + ) + ) + + test_domain.repository_for(Order).add(order) + + retrieved_order = test_domain.repository_for(Order).get(order.id) + + # [].append does not work. + retrieved_order.customer.addresses = [ + Address(street="123 Main St", city="Anytown", state="CA", country="USA"), + Address(street="321 Side St", city="Anytown", state="CA", country="USA"), + Address(street="456 Side St", city="Anytown", state="CA", country="USA"), + ] + assert len(retrieved_order.customer.addresses) == 3 + + test_domain.repository_for(Order).add(retrieved_order) + + retrieved_order = test_domain.repository_for(Order).get(order.id) + + assert len(retrieved_order.customer.addresses) == 3 diff --git a/tests/field/test_list.py b/tests/field/test_list.py new file mode 100644 index 00000000..cc618c46 --- /dev/null +++ b/tests/field/test_list.py @@ -0,0 +1,144 @@ +import pytest + +from datetime import datetime, date + +from protean import BaseValueObject +from protean.exceptions import ValidationError +from protean.fields.basic import ( + List, + String, + Integer, + Boolean, + Date, + DateTime, + Float, + Dict, +) +from protean.fields.embedded import ValueObject + + +class TestListFieldContentType: + def test_list_field_with_string_content_type(self): + field = List(content_type=String) + value = ["hello", "world"] + assert field._cast_to_type(value) == value + + value = ["hello", 123] + assert field._cast_to_type(value) == ["hello", "123"] + + def test_list_field_with_integer_content_type(self): + field = List(content_type=Integer) + value = [1, 2, 3] + assert field._cast_to_type(value) == value + + value = [1, "hello"] + with pytest.raises(ValidationError): + field._cast_to_type(value) + + def test_list_field_with_boolean_content_type(self): + field = List(content_type=Boolean) + value = [True, False, True] + assert field._cast_to_type(value) == value + + value = [True, "hello"] + with pytest.raises(ValidationError): + field._cast_to_type(value) + + def test_list_field_with_date_content_type(self): + field = List(content_type=Date) + value = ["2023-05-01", "2023-06-15"] + assert field._cast_to_type(value) == [date.fromisoformat(d) for d in value] + + value = ["2023-05-01", "invalid"] + with pytest.raises(ValidationError): + field._cast_to_type(value) + + def test_list_field_with_datetime_content_type(self): + field = List(content_type=DateTime) + value = ["2023-05-01T12:00:00", "2023-06-15T18:30:00"] + assert field._cast_to_type(value) == [datetime.fromisoformat(d) for d in value] + + value = ["2023-05-01T12:00:00", "invalid"] + with pytest.raises(ValidationError): + field._cast_to_type(value) + + def test_list_field_with_float_content_type(self): + field = List(content_type=Float) + value = [1.2, 3.4, 5.6] + assert field._cast_to_type(value) == value + + value = [1.2, "hello"] + with pytest.raises(ValidationError): + field._cast_to_type(value) + + def test_list_field_with_dict_content_type(self): + field = List(content_type=Dict) + value = [{"a": 1}, {"b": 2}] + assert field._cast_to_type(value) == value + + value = [{"a": 1}, "hello"] + with pytest.raises(ValidationError): + field._cast_to_type(value) + + def test_list_field_with_invalid_content_type(self): + with pytest.raises(ValidationError): + List(content_type=int) + + def test_list_field_with_non_list_value(self): + field = List(content_type=String) + value = "hello" + with pytest.raises(ValidationError): + field._cast_to_type(value) + + def test_list_field_with_value_object_content_type(self): + class VO(BaseValueObject): + foo = String() + + field = List(content_type=ValueObject(VO)) + value = [VO(foo="bar"), VO(foo="baz")] + assert field._cast_to_type(value) == value + + +class TestListFieldAsDictWithDifferentContentTypes: + def test_list_as_dict_with_string_content_type(self): + field = List(content_type=String) + value = ["hello", "world"] + assert field.as_dict(value) == value + + def test_list_as_dict_with_integer_content_type(self): + field = List(content_type=Integer) + value = [1, 2, 3] + assert field.as_dict(value) == value + + def test_list_as_dict_with_float_content_type(self): + field = List(content_type=Float) + value = [1.2, 3.4, 5.6] + assert field.as_dict(value) == value + + def test_list_as_dict_with_boolean_content_type(self): + field = List(content_type=Boolean) + value = [True, False, True] + assert field.as_dict(value) == value + + def test_list_as_dict_with_date_content_type(self): + field = List(content_type=Date) + value = [date(2023, 4, 1), date(2023, 4, 2)] + assert field.as_dict(value) == [str(d) for d in value] + + def test_list_as_dict_with_datetime_content_type(self): + field = List(content_type=DateTime) + value = [datetime(2023, 4, 1, 10, 30), datetime(2023, 4, 2, 11, 45)] + assert field.as_dict(value) == [str(dt) for dt in value] + + def test_list_field_with_dict_content_type(self): + field = List(content_type=Dict) + value = [{"a": 1}, {"b": 2}] + assert field.as_dict(value) == value + + def test_list_as_dict_with_value_object_content_type(self): + class VO(BaseValueObject): + foo = String() + + field = List(content_type=ValueObject(VO)) + value = [VO(foo="bar"), VO(foo="baz")] + assert field.as_dict(value) == [{"foo": "bar"}, {"foo": "baz"}]