Skip to content

Commit

Permalink
Remove provider meta option from Entities
Browse files Browse the repository at this point in the history
Entities are to be persisted in the same persistence store as their aggregates.
This way, the aggregate cluster is kept together and transaction boundaries
can be honored. This commit removes the explicit `provider` option from Entities.

A new method has been introduced at the domain level, that is invoked by the
`domain.init()` method, that takes care of setting `provider` for each entity.
  • Loading branch information
subhashb committed Jun 20, 2024
1 parent 07e6849 commit 6c45277
Show file tree
Hide file tree
Showing 29 changed files with 231 additions and 104 deletions.
71 changes: 71 additions & 0 deletions docs/guides/domain-definition/aggregates.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,74 @@ the aggregate.
used only when the configured database is active. Refer to the section on
`Customizing Persistence schemas` for more information.
<!-- FIXME Add link to customizing persistence schemas -->

## Associations

Protean provides multiple options for Aggregates to weave object graphs with
enclosed Entities.

### `HasOne`

A HasOne field establishes a has-one relation with the entity. In the example
below, `Post` has exactly one `Statistic` record associated with it.

```python hl_lines="18 22-26"
{! docs_src/guides/domain-definition/008.py !}
```

```shell
>>> post = Post(title='Foo')
>>> post.stats = Statistic(likes=10, dislikes=1)
>>> current_domain.repository_for(Post).add(post)
```

### `HasMany`

```python hl_lines="19 29-33"
{! docs_src/guides/domain-definition/008.py !}
```

Below is an example of adding multiple comments to the domain defined above:

```shell
❯ protean shell --domain docs_src/guides/domain-definition/008.py
...
In [1]: from protean.globals import current_domain

In [2]: post = Post(title='Foo')

In [3]: comment1 = Comment(content='bar')

In [4]: comment2 = Comment(content='baz')

In [5]: post.add_comments([comment1, comment2])

In [6]: current_domain.repository_for(Post).add(post)
Out[6]: <Post: Post object (id: 19031285-6e27-4b7e-8b06-47ba6766208a)>

In [7]: post.to_dict()
Out[7]:
{'title': 'Foo',
'created_on': '2024-05-06 14:29:22.946329+00:00',
'comments': [{'content': 'bar', 'id': 'af238f7b-5225-41fc-ae37-36cd4cface66'},
{'content': 'baz', 'id': '5b7fa5ad-7b64-4194-ade7-fb7a4b3a8a15'}],
'id': '19031285-6e27-4b7e-8b06-47ba6766208a'}
```

### `Reference`

A `Reference` field establishes the opposite relationship with the parent at
the data level. Entities that are connected by `HasMany` and `HasOne`
relationships are connected to the owning aggregate with a `Reference` field
acting as the foreign key.

```shell
In [8]: post = current_domain.repository_for(Post).get(post.id)

In [9]: post.comments[0].post
Out[9]: <Post: Post object (id: e288ee30-e1d5-4fb3-94d8-d8083a6dc9db)>

In [10]: post.comments[0].post_id
Out[10]: 'e288ee30-e1d5-4fb3-94d8-d8083a6dc9db'
```
<!-- FIXME Add details about the attribute `<>_id` and the entity `<>` -->
84 changes: 27 additions & 57 deletions docs/guides/domain-definition/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ over time.
additional responsibility of managing the lifecycle of one or more
related entities.

# Definition
## Definition

An Entity is defined with the `Domain.entity` decorator:

Expand Down Expand Up @@ -49,72 +49,42 @@ IncorrectUsageError: {'_entity': ['Entity `Comment` needs to be associated with
<!-- FIXME Ensure entities cannot enclose other entities. When entities
enclose something other than permitted fields, through an error-->

## Associations

Protean provides multiple options for Aggregates to weave object graphs with
enclosed Entities.

### `HasOne`

A HasOne field establishes a has-one relation with the entity. In the example
below, `Post` has exactly one `Statistic` record associated with it.

```python hl_lines="18 22-26"
{! docs_src/guides/domain-definition/008.py !}
```

```shell
>>> post = Post(title='Foo')
>>> post.stats = Statistic(likes=10, dislikes=1)
>>> current_domain.repository_for(Post).add(post)
```
## Configuration

### `HasMany`
Similar to an aggregate, an entity's behavior can be customized with by passing
additional options to its decorator, or with a `Meta` class as we saw earlier.

```python hl_lines="19 29-33"
{! docs_src/guides/domain-definition/008.py !}
```

Below is an example of adding multiple comments to the domain defined above:
Available options are:

```shell
❯ protean shell --domain docs_src/guides/domain-definition/008.py
...
In [1]: from protean.globals import current_domain
### `abstract`

In [2]: post = Post(title='Foo')
Marks an Entity as abstract if `True`. If abstract, the entity cannot be
instantiated and needs to be subclassed.

In [3]: comment1 = Comment(content='bar')
### `auto_add_id_field`

In [4]: comment2 = Comment(content='baz')
If `True`, Protean will not add an identifier field (acting as primary key)
by default to the entity. This option is usually combined with `abstract` to
create entities that are meant to be subclassed by other aggregates.

In [5]: post.add_comments([comment1, comment2])
### `schema_name`

In [6]: current_domain.repository_for(Post).add(post)
Out[6]: <Post: Post object (id: 19031285-6e27-4b7e-8b06-47ba6766208a)>

In [7]: post.to_dict()
Out[7]:
{'title': 'Foo',
'created_on': '2024-05-06 14:29:22.946329+00:00',
'comments': [{'content': 'bar', 'id': 'af238f7b-5225-41fc-ae37-36cd4cface66'},
{'content': 'baz', 'id': '5b7fa5ad-7b64-4194-ade7-fb7a4b3a8a15'}],
'id': '19031285-6e27-4b7e-8b06-47ba6766208a'}
```
The name to store and retrieve the entity from the persistence store. By
default, `schema_name` is the snake case version of the Entity's name.

### `Reference`
### `model`

A `Reference` field establishes the opposite relationship with the parent at
the data level. Entities that are connected by `HasMany` and `HasOne`
relationships can reference their owning object.
Similar to an aggregate, Protean automatically constructs a representation
of the entity that is compatible with the configured database. While the
generated model suits most use cases, you can also explicitly construct a model
and associate it with the entity, just like in an aggregate.

```shell
In [8]: post = current_domain.repository_for(Post).get(post.id)
!!!note
An Entity is always persisted in the same persistence store as the
its Aggregate.

In [9]: post.comments[0].post
Out[9]: <Post: Post object (id: e288ee30-e1d5-4fb3-94d8-d8083a6dc9db)>
## Associations

In [10]: post.comments[0].post_id
Out[10]: 'e288ee30-e1d5-4fb3-94d8-d8083a6dc9db'
```
<!-- FIXME Add details about the attribute `<>_id` and the entity `<>` -->
Entities can be further enclose other entities within them, with the `HasOne`
and `HasMany` relationships, just like in an aggregate. Refer to the Aggregate's
[Association documentation](./aggregates.md#associations) for more details.
20 changes: 20 additions & 0 deletions docs_src/guides/propagate-state/001.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,23 @@ def reduce_stock_level(self, event: OrderShipped):
inventory.in_stock -= event.quantity # (2)

repo.add(inventory)


domain.init()
with domain.domain_context():
# Persist Order
order = Order(book_id=1, quantity=10, total_amount=100)
domain.repository_for(Order).add(order)

# Persist Inventory
inventory = Inventory(book_id=1, in_stock=100)
domain.repository_for(Inventory).add(inventory)

# Ship Order
order.ship_order()
domain.repository_for(Order).add(order)

# Verify that Inventory Level has been reduced
stock = domain.repository_for(Inventory).get(inventory.id)
print(stock.to_dict())
assert stock.in_stock == 90
2 changes: 0 additions & 2 deletions src/protean/core/entity.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Entity Functionality and Classes"""

import copy
import functools
import inspect
import logging
Expand Down Expand Up @@ -119,7 +118,6 @@ def __init_subclass__(subclass) -> None:
def _default_options(cls):
return [
("auto_add_id_field", True),
("provider", "default"),
("model", None),
("part_of", None),
("aggregate_cluster", None),
Expand Down
4 changes: 2 additions & 2 deletions src/protean/core/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ def _default_options(cls):
)

# This method is called during class import, so we cannot use part_of if it
# is still a string. We ignore it for now, and resolve `stream_name` in
# the factory after the domain has resolved references.
# is still a string. We ignore it for now, and resolve `stream_name` later
# when the domain has resolved references.
# FIXME A better mechanism would be to not set stream_name here, unless explicitly
# specified, and resolve it during `domain.init()`
part_of = None if isinstance(part_of, str) else part_of
Expand Down
29 changes: 29 additions & 0 deletions src/protean/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ def init(self, traverse=True): # noqa: C901
# Assign Aggregate Clusters
self._assign_aggregate_clusters()

# Set Aggregate Cluster Options
self._set_aggregate_cluster_options()

# Run Validations
self._validate_domain()

Expand Down Expand Up @@ -725,6 +728,21 @@ def _validate_domain(self):
}
)

# Check that entities have the same provider as the aggregate
for _, entity in self.registry._elements[DomainObjects.ENTITY.value].items():
if (
entity.cls.meta_.aggregate_cluster.meta_.provider
!= entity.cls.meta_.provider
):
raise IncorrectUsageError(
{
"element": (
f"Entity `{entity.cls.__name__}` has a different provider "
f"than its aggregate `{entity.cls.meta_.aggregate_cluster.__name__}`"
)
}
)

def _assign_aggregate_clusters(self):
"""Assign Aggregate Clusters to all relevant elements"""
from protean.core.aggregate import BaseAggregate
Expand All @@ -747,13 +765,24 @@ def _assign_aggregate_clusters(self):
for _, element in self.registry._elements[element_type.value].items():
part_of = element.cls.meta_.part_of
if part_of:
# Traverse up the graph tree to find the root aggregate
while not issubclass(
part_of, (BaseAggregate, BaseEventSourcedAggregate)
):
part_of = part_of.meta_.part_of

element.cls.meta_.aggregate_cluster = part_of

def _set_aggregate_cluster_options(self):
for element_type in [DomainObjects.ENTITY]:
for _, element in self.registry._elements[element_type.value].items():
if not hasattr(element.cls.meta_, "provider"):
setattr(
element.cls.meta_,
"provider",
element.cls.meta_.aggregate_cluster.meta_.provider,
)

######################
# Element Decorators #
######################
Expand Down
1 change: 1 addition & 0 deletions tests/adapters/model/elasticsearch_model/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def setup_db():
domain.register_model(
ProviderCustomModel, entity_cls=Provider, schema_name="providers"
)
domain.init(traverse=False)

domain.providers["default"]._create_database_artifacts()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def setup_db():
domain.register_model(
ProviderCustomModel, entity_cls=Provider, schema_name="adults"
)
domain.init(traverse=False)

domain.repository_for(ArrayUser)._dao
domain.repository_for(GenericPostgres)._dao
Expand Down
1 change: 1 addition & 0 deletions tests/adapters/model/sqlalchemy_model/sqlite/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def setup_db():
domain.register_model(
ProviderCustomModel, entity_cls=Provider, schema_name="adults"
)
domain.init(traverse=False)

domain.repository_for(ArrayUser)._dao
domain.repository_for(ComplexUser)._dao
Expand Down
1 change: 1 addition & 0 deletions tests/adapters/repository/elasticsearch_repo/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def setup_db():
domain.register(Alien)
domain.register(User)
domain.register(ComplexUser)
domain.init(traverse=False)

domain.providers["default"]._create_database_artifacts()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def setup_db():
domain.register(Audit)
domain.register(Order)
domain.register(Customer, part_of=Order)
domain.init(traverse=False)

domain.repository_for(Alien)._dao
domain.repository_for(ComplexUser)._dao
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class Audit(BaseAggregate):
def test_updating_a_has_many_association(test_domain):
test_domain.register(Post)
test_domain.register(Comment, part_of=Post)
test_domain.init(traverse=False)

post_repo = test_domain.repository_for(Post)
post = Post(content="bar")
Expand All @@ -51,6 +52,7 @@ def test_updating_a_has_many_association(test_domain):
@pytest.mark.postgresql
def test_embedded_dict_field_in_value_object(test_domain):
test_domain.register(Audit)
test_domain.init(traverse=False)

audit_repo = test_domain.repository_for(Audit)
audit = Audit(permission=Permission(dict_object={"foo": "bar"}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Event(BaseAggregate):
@pytest.mark.postgresql
def test_persistence_and_retrieval(test_domain):
test_domain.register(Event)
test_domain.init(traverse=False)

repo = test_domain.repository_for(Event)
event = Event(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def test_persisting_and_retrieving_list_of_value_objects(test_domain):
test_domain.register(Order)
test_domain.register(Customer, part_of=Order)
test_domain.register(Address)
test_domain.init(traverse=False)

order = Order(
customer=Customer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class TestProviders:
def register_elements(self, test_domain):
test_domain.register(Person)
test_domain.register(Alien)
test_domain.init(traverse=False)

def test_initialization_of_providers_on_first_call(self, test_domain):
"""Test that ``providers`` object is available"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class TestTransactions:
def register_elements(self, test_domain):
test_domain.register(Person)
test_domain.register(PersonRepository, part_of=Person)
test_domain.init(traverse=False)

def random_name(self):
return "".join(random.choices(string.ascii_uppercase + string.digits, k=15))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def setup_db():
domain.register(Alien)
domain.register(User)
domain.register(ComplexUser)
domain.init(traverse=False)

domain.repository_for(Person)._dao
domain.repository_for(Alien)._dao
Expand Down
Loading

0 comments on commit 6c45277

Please sign in to comment.