Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support List of of Value Objects #429

Merged
merged 1 commit into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 111 additions & 31 deletions docs/guides/domain-definition/fields/container-fields.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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="[email protected]",
...: 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': '[email protected]',
'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]: <Order: Order object (id: 4a9538bf-1eb1-4621-8ced-86bcc4362a51)>

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]: <Order: Order object (id: 4a9538bf-1eb1-4621-8ced-86bcc4362a51)>

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]: <Order: Order object (id: 4a9538bf-1eb1-4621-8ced-86bcc4362a51)>

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.
Expand Down Expand Up @@ -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'}
```
24 changes: 24 additions & 0 deletions docs_src/guides/domain-definition/fields/container-fields/004.py
Original file line number Diff line number Diff line change
@@ -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)
47 changes: 43 additions & 4 deletions src/protean/adapters/repository/sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Module with repository implementation for SQLAlchemy"""

import copy
import json
import logging
import uuid

Expand All @@ -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,
Expand All @@ -37,6 +39,7 @@
List,
String,
Text,
ValueObject,
)
from protean.fields.association import Reference, _ReferenceField
from protean.fields.embedded import _ShadowField
Expand Down Expand Up @@ -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"""

Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
21 changes: 14 additions & 7 deletions src/protean/fields/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
13 changes: 9 additions & 4 deletions src/protean/fields/embedded.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,10 @@ def test_array_content_type_validation(test_domain):
{"email": "[email protected]", "roles": [1.0, 2.0]},
{"email": "[email protected]", "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="[email protected]", roles=[1, 2])
Expand All @@ -126,7 +127,6 @@ def test_array_content_type_validation(test_domain):

for kwargs in [
{"email": "[email protected]", "roles": ["ADMIN", "USER"]},
{"email": "[email protected]", "roles": ["1", "2"]},
{"email": "[email protected]", "roles": [datetime.now(UTC)]},
]:
with pytest.raises(ValidationError) as exception:
Expand Down
Loading
Loading