Skip to content

Commit

Permalink
Add documentation on retrieving aggregates
Browse files Browse the repository at this point in the history
  • Loading branch information
subhashb committed Jun 13, 2024
1 parent 1ccab6d commit b89666b
Show file tree
Hide file tree
Showing 12 changed files with 279 additions and 7 deletions.
8 changes: 8 additions & 0 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@
## Bounded Context

## Domain

## Repository

## Custom Repository

## DAO

## Filtering
Empty file.
2 changes: 2 additions & 0 deletions docs/guides/persist-state/database-specificity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Database-specific repositories

5 changes: 4 additions & 1 deletion docs/guides/persist-state/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ store:
- Database-specific Repositories

- Working with the Application Layer
- Unit of Work
- Unit of Work

`repository_for`
Using repositories for filtering vs. for read-side operations
2 changes: 1 addition & 1 deletion docs/guides/persist-state/persist-aggregates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 !}
```

Expand Down
205 changes: 205 additions & 0 deletions docs/guides/persist-state/retreive-aggregates.md
Original file line number Diff line number Diff line change
@@ -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]: <Person: Person object (id: 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="[email protected]", age=22)

In [2]: person2 = Person(name="Jane Doe", email="[email protected]", age=20)

In [3]: repository = domain.repository_for(Person)

In [4]: repository
Out[4]: <CustomPersonRepository at 0x1079af290>

In [5]: repository.add(person1)
Out[5]: <Person: Person object (id: 9ba6a890-e783-455e-9a6b-a0a16c0514df)>

In [6]: repository.add(person2)
Out[6]: <Person: Person object (id: edc78a03-aba6-47fc-a4a7-308eed3f7c67)>

In [7]: retreived_person = repository.find_by_email("[email protected]")

In [8]: retreived_person.to_dict()
Out[8]:
{'name': 'John Doe',
'email': '[email protected]',
'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]: <ResultSet: 6 items>
In [3]: result.to_dict()
Out[3]:
{'offset': 0,
'limit': 1000,
'total': 6,
'items': [<Person: Person object (id: 84cac5ae-8272-4936-aa45-9342abe05513)>,
<Person: Person object (id: aec03bb7-a97d-4722-9e10-fa5c324aa69b)>,
<Person: Person object (id: 0b6314e9-e9b0-4456-bf04-1b0e05af1bf2)>,
<Person: Person object (id: 1be4b9cd-deb0-4c07-bdfc-b2dba119f7a0)>,
<Person: Person object (id: c5730eb0-9638-4d9d-8617-c2b3270be859)>,
<Person: Person object (id: 4683a592-ffd5-4f01-84bc-02401c785922)>]}
```
2 changes: 1 addition & 1 deletion docs/guides/persist-state/unit-of-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs_src/guides/persist-state/004.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions docs_src/guides/persist-state/005.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions src/protean/core/queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"<ResultSet: {len(self.items)} items>"

def to_dict(self):
"""Return the resultset as a dictionary"""
return {
"offset": self.offset,
"limit": self.limit,
"total": self.total,
"items": self.items,
}
14 changes: 14 additions & 0 deletions tests/repository/test_resultset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == "<ResultSet: 2 items>"

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"],
}

0 comments on commit b89666b

Please sign in to comment.