diff --git a/docs/glossary.md b/docs/glossary.md index 95b18da5..c093a77c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -3,3 +3,11 @@ ## Bounded Context ## Domain + +## Repository + +## Custom Repository + +## DAO + +## Filtering diff --git a/docs/guides/persist-state/custom-repository.md b/docs/guides/persist-state/custom-repository.md deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/guides/persist-state/database-specificity.md b/docs/guides/persist-state/database-specificity.md new file mode 100644 index 00000000..da6a821a --- /dev/null +++ b/docs/guides/persist-state/database-specificity.md @@ -0,0 +1,2 @@ +# Database-specific repositories + diff --git a/docs/guides/persist-state/index.md b/docs/guides/persist-state/index.md index 5fb1899e..07b247c0 100644 --- a/docs/guides/persist-state/index.md +++ b/docs/guides/persist-state/index.md @@ -26,4 +26,7 @@ store: - Database-specific Repositories - Working with the Application Layer -- Unit of Work \ No newline at end of file +- Unit of Work + +`repository_for` +Using repositories for filtering vs. for read-side operations \ No newline at end of file diff --git a/docs/guides/persist-state/persist-aggregates.md b/docs/guides/persist-state/persist-aggregates.md index 6e2f0586..35ad0a81 100644 --- a/docs/guides/persist-state/persist-aggregates.md +++ b/docs/guides/persist-state/persist-aggregates.md @@ -3,7 +3,7 @@ Aggregates are saved into the configured database using `add` method of the repository. -```python hl_lines="23" +```python hl_lines="20" {! docs_src/guides/persist-state/001.py !} ``` diff --git a/docs/guides/persist-state/retreive-aggregates.md b/docs/guides/persist-state/retreive-aggregates.md index e69de29b..fb808519 100644 --- a/docs/guides/persist-state/retreive-aggregates.md +++ b/docs/guides/persist-state/retreive-aggregates.md @@ -0,0 +1,205 @@ +# Retreiving Aggregates + +An aggregate can be retreived with the repository's `get` method, if you know +its identity: + +```python hl_lines="16 20" +{! docs_src/guides/persist-state/001.py !} +``` + +1. Identity is explicitly set to **1**. + +```shell hl_lines="1" +In [1]: domain.repository_for(Person).get("1") +Out[1]: +``` + +Finding an aggregate by a field value is also possible, but requires a custom +repository to be defined with a business-oriented method. + +## Custom Repositories + +Protean needs anything beyond a simple `get` to be defined in a +repository. A repository is to be treated as part of the domain layer, and is +expected to enclose methods that represent business queries. + +Defining a custom repository is straight-forward: + +```python hl_lines="16" +{! docs_src/guides/persist-state/004.py !} +``` + +1. The repository is connected to `Person` aggregate through the `part_of` +parameter. + +Protean now returns `CustomPersonRepository` upon fetching the repository for +`Person` aggregate. + +```shell hl_lines="11 14" +In [1]: person1 = Person(name="John Doe", email="john.doe@example.com", age=22) + +In [2]: person2 = Person(name="Jane Doe", email="jane.doe@example.com", age=20) + +In [3]: repository = domain.repository_for(Person) + +In [4]: repository +Out[4]: + +In [5]: repository.add(person1) +Out[5]: + +In [6]: repository.add(person2) +Out[6]: + +In [7]: retreived_person = repository.find_by_email("john.doe@example.com") + +In [8]: retreived_person.to_dict() +Out[8]: +{'name': 'John Doe', + 'email': 'john.doe@example.com', + 'age': 22, + 'id': '9ba6a890-e783-455e-9a6b-a0a16c0514df'} +``` + +!!!note + Methods in the repository should be named for the business queries they + perform. `adults` is a good name for a method that fetches persons + over the age of 18. + +## Data Acsess Objects (DAO) + +You would have observed the query in the repository above was performed on a +`_dao` object. This is a DAO object that is automatically generated for every +repository, and internally used by Protean to access the persistence layer. + +At first glance, repositories and Data Access Objects may seem similar. +But a repository leans towards the domain in its functionality. It contains +methods and implementations that clearly identify what the domain is trying to +ask/do with the persistence store. Data Access Objects, on the other hand, +talk the language of the database. A repository works in conjunction with the +DAO layer to access and manipulate on the persistence store. + +## Filtering + +For all other filtering needs, the DAO exposes a method `filter` that can +accept advanced filtering criteria. + +For the purposes of this guide, assume that the following `Person` aggregates +exist in the database: + +```python hl_lines="7-11" +{! docs_src/guides/persist-state/005.py !} +``` + +```shell +In [1]: repository = domain.repository_for(Person) + +In [2]: for person in [ + ...: Person(name="John Doe", age=38, country="CA"), + ...: Person(name="John Roe", age=41, country="US"), + ...: Person(name="Jane Doe", age=36, country="CA"), + ...: Person(name="Baby Doe", age=3, country="CA"), + ...: Person(name="Boy Doe", age=8, country="CA"), + ...: Person(name="Girl Doe", age=11, country="CA"), + ...: ]: + ...: repository.add(person) + ...: +``` + +Queries below can be placed in repository methods. + +### Finding by multiple fields + +Used when you want to find a single aggregate. Throws `ObjectNotFoundError` if +no aggregates are found, and `TooManyObjectsError` when more than one +aggregates are found. + +```shell +In [1]: person = repository._dao.find_by(age=36, country="CA") + +In [2]: person.name +Out[2]: 'Jane Doe' +``` + +### Filtering by multiple fields + +You can filter for more than one aggregate at a time, with a similar mechanism: + +```shell +In [1]: people = repository._dao.query.filter(age__gte=18, country="CA").all().items + +In [2]: [person.name for person in people] +Out[2]: ['John Doe', 'Jane Doe'] +``` + +### Advanced filtering criteria + +You would have observed that the query above contained a special annotation, +`_gte`, to signify that the age should be greater than or equal to 18. There +are many other annotations that can be used to filter results: + +- **`exact`:** Match exact string +- **`iexact`:** Match exact string, case-insensitive +- **`contains`:** Match strings containing value +- **`icontains`:** Match strings containing value, case-insensitive +- **`gt`:** Match integer vales greater than value +- **`gte`:** Match integer vales greater than or equal to value +- **`lt`:** Match integer vales less than value +- **`lte`:** Match integer vales less than or equal to value +- **`in`:** Match value to be among list of values +- **`any`:** Match any of given values to be among list of values + +These annotations have database-specific implementations. Refer to your chosen +adapter's documentation for supported advanced filtering criteria. + +## Sorting results + +The `filter` method supports a param named `order_by` to specify the sort order +of the results. + +```shell +In [1]: people = repository._dao.query.order_by("-age").all().items + +In [2]: [(person.name, person.age) for person in people] +Out[2]: +[('John Roe', 41), + ('John Doe', 38), + ('Jane Doe', 36), + ('Girl Doe', 11), + ('Boy Doe', 8), + ('Baby Doe', 3)] +``` + +The `-` in the column name reversed the sort direction in the above example. + +## Resultset + +The `filter(...).all()` method returns a `RecordSet` instance. + +This class prevents DAO-specific data structures from leaking into the domain +layer. It exposes basic aspects of the returned results for inspection and +later use: + +- **`total`:** Total number of aggregates matching the query +- **`items`:** List of query results +- **`limit`:** Number of aggregates to be fetched +- **`offset`:** Number of aggregates to skip + +```shell +In [1]: result = repository._dao.query.all() + +In [2]: result +Out[2]: + +In [3]: result.to_dict() +Out[3]: +{'offset': 0, + 'limit': 1000, + 'total': 6, + 'items': [, + , + , + , + , + ]} +``` diff --git a/docs/guides/persist-state/unit-of-work.md b/docs/guides/persist-state/unit-of-work.md index a8b262bf..57a572fc 100644 --- a/docs/guides/persist-state/unit-of-work.md +++ b/docs/guides/persist-state/unit-of-work.md @@ -26,7 +26,7 @@ with UnitOfWork(): # Do something ``` -### `current_uow` +## `current_uow` The current active `UnitOfWork` is accessible through `protean.globals.current_uow` proxy. This is useful when you want to diff --git a/docs_src/guides/persist-state/004.py b/docs_src/guides/persist-state/004.py new file mode 100644 index 00000000..4f1acd8d --- /dev/null +++ b/docs_src/guides/persist-state/004.py @@ -0,0 +1,17 @@ +from protean import Domain +from protean.fields import Integer, 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) + age = Integer(default=21) + + +@domain.repository(part_of=Person) # (1) +class CustomPersonRepository: + def find_by_email(self, email: str) -> Person: + return self._dao.find_by(email=email) diff --git a/docs_src/guides/persist-state/005.py b/docs_src/guides/persist-state/005.py new file mode 100644 index 00000000..9bf55bab --- /dev/null +++ b/docs_src/guides/persist-state/005.py @@ -0,0 +1,11 @@ +from protean import Domain +from protean.fields import Integer, String + +domain = Domain(__file__, load_toml=False) + + +@domain.aggregate +class Person: + name = String(required=True, max_length=50) + age = Integer(default=21) + country = String(max_length=2) diff --git a/mkdocs.yml b/mkdocs.yml index 7d1e99e1..7176a744 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -129,9 +129,9 @@ nav: - 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 + - guides/persist-state/retreive-aggregates.md + - guides/persist-state/database-specificity.md - Accessing the domain: - guides/access-domain/index.md - guides/access-domain/commands.md diff --git a/src/protean/core/queryset.py b/src/protean/core/queryset.py index 3466d458..047b1f52 100644 --- a/src/protean/core/queryset.py +++ b/src/protean/core/queryset.py @@ -397,11 +397,11 @@ class ResultSet(object): def __init__(self, offset: int, limit: int, total: int, items: list): # the current offset (zero indexed) self.offset = offset - # the number of items to be displayed on a page. + # the number of items to be fetched self.limit = limit # the total number of items matching the query self.total = total - # the items for the current page + # the results self.items = items @property @@ -437,3 +437,15 @@ def __iter__(self): def __len__(self): """Returns number of items in the resultset""" return len(self.items) + + def __repr__(self): + return f"" + + def to_dict(self): + """Return the resultset as a dictionary""" + return { + "offset": self.offset, + "limit": self.limit, + "total": self.total, + "items": self.items, + } diff --git a/tests/repository/test_resultset.py b/tests/repository/test_resultset.py index 6f2c0977..6d824efe 100644 --- a/tests/repository/test_resultset.py +++ b/tests/repository/test_resultset.py @@ -40,3 +40,17 @@ def test_boolean_evaluation_of_resultset(self): assert bool(resultset) is True assert bool(empty_resultset) is False + + def test_resultset_repr(self): + resultset = ResultSet(offset=0, limit=10, total=2, items=["foo", "bar"]) + + assert repr(resultset) == "" + + def test_resultset_to_dict(self): + resultset = ResultSet(offset=0, limit=10, total=2, items=["foo", "bar"]) + assert resultset.to_dict() == { + "offset": 0, + "limit": 10, + "total": 2, + "items": ["foo", "bar"], + }