From fcda5a65435ff146e55c068d7d2368cd313d6ea6 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Wed, 12 Jun 2024 14:26:19 -0700 Subject: [PATCH] Persisting Aggregates - Documentation --- .../building-blocks/repositories.md | 5 + docs/guides/compose-a-domain/identity.md | 34 ++--- .../guides/persist-state/custom-repository.md | 0 docs/guides/persist-state/index.md | 29 ++++ .../persist-state/persist-aggregates.md | 124 ++++++++++++++++++ .../persist-state/retreive-aggregates.md | 0 docs/guides/persist-state/unit-of-work.md | 4 + docs_src/guides/persist-state/001.py | 20 +++ docs_src/guides/persist-state/002.py | 33 +++++ docs_src/guides/persist-state/003.py | 21 +++ mkdocs.yml | 8 ++ src/protean/adapters/repository/__init__.py | 2 +- src/protean/domain/__init__.py | 3 +- 13 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 docs/core-concepts/building-blocks/repositories.md create mode 100644 docs/guides/persist-state/custom-repository.md create mode 100644 docs/guides/persist-state/index.md create mode 100644 docs/guides/persist-state/persist-aggregates.md create mode 100644 docs/guides/persist-state/retreive-aggregates.md create mode 100644 docs/guides/persist-state/unit-of-work.md create mode 100644 docs_src/guides/persist-state/001.py create mode 100644 docs_src/guides/persist-state/002.py create mode 100644 docs_src/guides/persist-state/003.py diff --git a/docs/core-concepts/building-blocks/repositories.md b/docs/core-concepts/building-blocks/repositories.md new file mode 100644 index 00000000..7fbb651b --- /dev/null +++ b/docs/core-concepts/building-blocks/repositories.md @@ -0,0 +1,5 @@ +# Repositories + +- Collection-oriented design +- Mimics a `set` collection +- Has in-built Unit of Work context \ No newline at end of file diff --git a/docs/guides/compose-a-domain/identity.md b/docs/guides/compose-a-domain/identity.md index 738b023f..7a40fe99 100644 --- a/docs/guides/compose-a-domain/identity.md +++ b/docs/guides/compose-a-domain/identity.md @@ -103,6 +103,24 @@ In [5]: user.to_dict() Out[5]: {'user_id': '9cf4ddc4-2919-4021-bd1a-c8083b5fdda7', 'name': 'John Doe'} ``` +### Automatic Identity field + +When an identity field is not supplied, an `Auto` field called `id` is +automatically added to the entity. + +```py +{! docs_src/guides/domain-definition/fields/simple-fields/001.py !} +``` + +```shell hl_lines="6" +In [1]: from protean.reflection import declared_fields + +In [2]: declared_fields(Person) +Out[2]: +{'name': String(required=True, max_length=50, min_length=2), + 'id': Auto(identifier=True)} +``` + ### Composite keys Protean does not support composite keys. A `NotSupportedError` is thrown when @@ -121,22 +139,6 @@ In [10]: @domain.aggregate NotSupportedError: {'_entity': ['Multiple identifier fields found in entity Order. Only one identifier field is allowed.']} ``` -When an identity field is not supplied, an `Auto` field called `id` is -automatically added to the entity. - -```py -{! docs_src/guides/domain-definition/fields/simple-fields/001.py !} -``` - -```shell hl_lines="6" -In [1]: from protean.reflection import declared_fields - -In [2]: declared_fields(Person) -Out[2]: -{'name': String(required=True, max_length=50, min_length=2), - 'id': Auto(identifier=True)} -``` - ## Element-level Identity Customization The identity of a single entity element can be customized by explicit diff --git a/docs/guides/persist-state/custom-repository.md b/docs/guides/persist-state/custom-repository.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/guides/persist-state/index.md b/docs/guides/persist-state/index.md new file mode 100644 index 00000000..5fb1899e --- /dev/null +++ b/docs/guides/persist-state/index.md @@ -0,0 +1,29 @@ +# Persisting State + +- About Repositories and Repository Pattern + +- Different available repositories +- Repository Configuration +- Automatic generation of repositories + +## Basic Structure + +A repository provides three primary methods to interact with the persistence +store: + +### **`add`** - Adds a new entity to the persistence store. + +### **`get`** - Retrieves an entity from the persistence store. + +- Persisting aggregates +- Retreiving aggregates + - Queries +- Data Access Objects +- Removing aggregates + +- Custom Repositories +- Registering a custom Repository +- Database-specific Repositories + +- Working with the Application Layer +- Unit of Work \ No newline at end of file diff --git a/docs/guides/persist-state/persist-aggregates.md b/docs/guides/persist-state/persist-aggregates.md new file mode 100644 index 00000000..6e2f0586 --- /dev/null +++ b/docs/guides/persist-state/persist-aggregates.md @@ -0,0 +1,124 @@ +# Persist Aggregates + +Aggregates are saved into the configured database using `add` method of the +repository. + +```python hl_lines="23" +{! docs_src/guides/persist-state/001.py !} +``` + +1. Identity, by default, is a string. + +```shell +In [1]: domain.repository_for(Person).get("1") +Out[1]: + +In [2]: domain.repository_for(Person).get("1").to_dict() +Out[2]: {'name': 'John Doe', 'email': 'john.doe@localhost', 'id': '1'} +``` + +## Transaction + +The `add` method is enclosed in a [Unit of Work](unit-of-work.md) context by +default. Changes are committed to the persistence store when the Unit Of Work +context exits. + +The following calls are equivalent in behavior: + +```python +... +# Version 1 +domain.repository_for(Person).add(person) +... + +... +# Version 2 +from protean import UnitOfWork + +with UnitOfWork(): + domain.repository_for(Person).add(person) +... +``` + +This means changes across the aggregate cluster are committed as a single +transaction (assuming the underlying database supports transactions, of course). + +```python hl_lines="22-30 33" +{! docs_src/guides/persist-state/002.py !} +``` + +!!!note + This is especially handy in ***Relational databases*** because each entity is a + separate table. + +## Events + +The `add` method also publishes events to configured brokers upon successfully +persisting to the database. + +```python hl_lines="15" +{! docs_src/guides/persist-state/003.py !} +``` + +```shell hl_lines="12-16 21-22" +In [1]: post = Post(title="Events in Aggregates", body="Lorem ipsum dolor sit amet, consectetur adipiscing...") + +In [2]: post.to_dict() +Out[2]: +{'title': 'Events in Aggregates', + 'body': 'Lorem ipsum dolor sit amet, consectetur adipiscing...', + 'published': False, + 'id': 'a9ea7763-c5b2-4c8c-9c97-43ba890517d0'} + +In [3]: post.publish() + +In [4]: post._events +Out[4]: [] + +In [5]: domain.repository_for(Post).add(post) +Out[5]: + +In [6]: post._events +Out[6]: [] +``` + +## Updates + +Recall that Protean repositories behave like a `set` collection. Updating is +as simple as mutating an aggregate and persisting it with `add` again. + +```shell hl_lines="15 20 22 25 27" +In [1]: post = Post( + ...: id="1", + ...: title="Events in Aggregates", + ...: body="Lorem ipsum dolor sit amet, consectetur adipiscing..." + ...: ) + +In [2]: domain.repository_for(Post).add(post) +Out[2]: + +In [3]: domain.repository_for(Post).get("1") +Out[3]: + +In [4]: domain.repository_for(Post).get("1").to_dict() +Out[4]: +{'title': 'Events in Aggregates', + 'body': 'Lorem ipsum dolor sit amet, consectetur adipiscing...', + 'published': False, + 'id': '1'} + +In [5]: post.title = "(Updated Title) Events in Entities" + +In [6]: domain.repository_for(Post).add(post) +Out[6]: + +In [7]: domain.repository_for(Post).get("1").to_dict() +Out[7]: +{'title': '(Updated Title) Events in Entities', + 'body': 'Lorem ipsum dolor sit amet, consectetur adipiscing...', + 'published': False, + 'id': '1'} +``` \ No newline at end of file diff --git a/docs/guides/persist-state/retreive-aggregates.md b/docs/guides/persist-state/retreive-aggregates.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/guides/persist-state/unit-of-work.md b/docs/guides/persist-state/unit-of-work.md new file mode 100644 index 00000000..c493ebb6 --- /dev/null +++ b/docs/guides/persist-state/unit-of-work.md @@ -0,0 +1,4 @@ +# Unit of Work + + +Never enclose updates to multiple aggregates in a single unit of work. \ No newline at end of file diff --git a/docs_src/guides/persist-state/001.py b/docs_src/guides/persist-state/001.py new file mode 100644 index 00000000..af6855c9 --- /dev/null +++ b/docs_src/guides/persist-state/001.py @@ -0,0 +1,20 @@ +from protean import Domain +from protean.fields import String + +domain = Domain(__file__, load_toml=False) + + +@domain.aggregate +class Person: + name = String(required=True, max_length=50) + email = String(required=True, max_length=254) + + +domain.init(traverse=False) +with domain.domain_context(): + person = Person( + id="1", # (1) + name="John Doe", + email="john.doe@localhost", + ) + domain.repository_for(Person).add(person) diff --git a/docs_src/guides/persist-state/002.py b/docs_src/guides/persist-state/002.py new file mode 100644 index 00000000..89fc0413 --- /dev/null +++ b/docs_src/guides/persist-state/002.py @@ -0,0 +1,33 @@ +from protean import Domain +from protean.fields import Float, HasMany, String, Text + +domain = Domain(__file__, load_toml=False) + + +@domain.aggregate +class Post: + title = String(required=True, max_length=100) + body = Text() + comments = HasMany("Comment") + + +@domain.entity(part_of=Post) +class Comment: + content = String(required=True, max_length=50) + rating = Float(max_value=5) + + +domain.init(traverse=False) +with domain.domain_context(): + post = Post( + id="1", + title="A Great Post", + body="This is the body of a great post", + comments=[ + Comment(id="1", content="Amazing!", rating=5.0), + Comment(id="2", content="Great!", rating=4.5), + ], + ) + + # This persists one `Post` record and two `Comment` records + domain.repository_for(Post).add(post) diff --git a/docs_src/guides/persist-state/003.py b/docs_src/guides/persist-state/003.py new file mode 100644 index 00000000..95fb9cac --- /dev/null +++ b/docs_src/guides/persist-state/003.py @@ -0,0 +1,21 @@ +from protean import Domain +from protean.fields import Boolean, Identifier, String, Text + +domain = Domain(__file__, load_toml=False) + + +@domain.aggregate +class Post: + title = String(required=True, max_length=100) + body = Text() + published = Boolean(default=False) + + def publish(self): + self.published = True + self.raise_(PostPublished(post_id=self.id, body=self.body)) + + +@domain.event(part_of=Post) +class PostPublished: + post_id = Identifier(required=True) + body = Text() diff --git a/mkdocs.yml b/mkdocs.yml index 5eab853b..7d1e99e1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,6 +38,8 @@ theme: - content.code.select markdown_extensions: - admonition + - attr_list + - md_in_html - mdx_include - pymdownx.details - pymdownx.superfences: @@ -124,6 +126,12 @@ nav: - guides/domain-behavior/aggregate-mutation.md - guides/domain-behavior/raising-events.md - guides/domain-behavior/domain-services.md + - Persisting State: + - guides/persist-state/index.md + - guides/persist-state/persist-aggregates.md + - guides/persist-state/retreive-aggregates.md + - guides/persist-state/custom-repository.md + - guides/persist-state/unit-of-work.md - Accessing the domain: - guides/access-domain/index.md - guides/access-domain/commands.md diff --git a/src/protean/adapters/repository/__init__.py b/src/protean/adapters/repository/__init__.py index de414a77..e35b6f8d 100644 --- a/src/protean/adapters/repository/__init__.py +++ b/src/protean/adapters/repository/__init__.py @@ -123,7 +123,7 @@ def get_connection(self, provider_name="default"): except KeyError: raise AssertionError(f"No Provider registered with name {provider_name}") - def repository_for(self, part_of): + def repository_for(self, part_of) -> BaseRepository: """Retrieve a Repository registered for the Aggregate""" if self._providers is None: self._initialize() diff --git a/src/protean/domain/__init__.py b/src/protean/domain/__init__.py index f77f6e1a..5dbbe6b8 100644 --- a/src/protean/domain/__init__.py +++ b/src/protean/domain/__init__.py @@ -18,6 +18,7 @@ from protean.core.event import BaseEvent from protean.core.event_handler import BaseEventHandler from protean.core.model import BaseModel +from protean.core.repository import BaseRepository from protean.domain.registry import _DomainRegistry from protean.exceptions import ConfigurationError, IncorrectUsageError from protean.fields import HasMany, HasOne, Reference, ValueObject @@ -949,7 +950,7 @@ def handlers_for(self, event: BaseEvent) -> List[BaseEventHandler]: ############################ # FIXME Optimize calls to this method with cache, but also with support for Multitenancy - def repository_for(self, part_of): + def repository_for(self, part_of) -> BaseRepository: if isinstance(part_of, str): raise IncorrectUsageError( {