Skip to content

Commit

Permalink
Feature/previous saved state (#305)
Browse files Browse the repository at this point in the history
* feature(document): add get_previous_saved_state, get_previous_changes, has_changed, remove duplicate call to _save_state when using _sync

* test(state_management): add tests for _previous_saved_state

* doc(state_management): add more details about state management

* fix(documents): merge error

* fix(test_state_management): misc

* fix(previous_saved_state): hide feature behind config

* test(previous_saved_state): add tests for config

* fix(docs): add docs about state_management_save_previous

* lint(state)

* fix(document): re-add check for nullable previous_saved_state

* version: 1.16.6

* improvement: docs for state management

Co-authored-by: Roman <[email protected]>
  • Loading branch information
paul-finary and roman-right authored Dec 27, 2022
1 parent c0fa3ab commit 9d075d1
Show file tree
Hide file tree
Showing 11 changed files with 433 additions and 154 deletions.
2 changes: 1 addition & 1 deletion beanie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from beanie.odm.views import View
from beanie.odm.union_doc import UnionDoc

__version__ = "1.16.5"
__version__ = "1.16.6"
__all__ = [
# ODM
"Document",
Expand Down
40 changes: 38 additions & 2 deletions beanie/odm/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
from beanie.odm.utils.self_validation import validate_self_before
from beanie.odm.utils.state import (
saved_state_needed,
previous_saved_state_needed,
save_state_after,
swap_revision_after,
)
Expand Down Expand Up @@ -116,6 +117,7 @@ class Document(
revision_id: Optional[UUID] = Field(default=None, hidden=True)
_previous_revision_id: Optional[UUID] = PrivateAttr(default=None)
_saved_state: Optional[Dict[str, Any]] = PrivateAttr(default=None)
_previous_saved_state: Optional[Dict[str, Any]] = PrivateAttr(default=None)

# Relations
_link_fields: ClassVar[Optional[Dict[str, LinkInfo]]] = None
Expand Down Expand Up @@ -157,8 +159,6 @@ async def _sync(self) -> None:
)
for key, value in dict(new_instance).items():
setattr(self, key, value)
if self.use_state_management():
self._save_state()

@classmethod
async def get(
Expand Down Expand Up @@ -722,6 +722,14 @@ def use_state_management(cls) -> bool:
"""
return cls.get_settings().use_state_management

@classmethod
def state_management_save_previous(cls) -> bool:
"""
Should we save the previous state after a commit to database
:return: bool
"""
return cls.get_settings().state_management_save_previous

@classmethod
def state_management_replace_objects(cls) -> bool:
"""
Expand All @@ -736,6 +744,9 @@ def _save_state(self) -> None:
:return: None
"""
if self.use_state_management() and self.id is not None:
if self.state_management_save_previous():
self._previous_saved_state = self._saved_state

self._saved_state = get_dict(self)

def get_saved_state(self) -> Optional[Dict[str, Any]]:
Expand All @@ -745,13 +756,28 @@ def get_saved_state(self) -> Optional[Dict[str, Any]]:
"""
return self._saved_state

def get_previous_saved_state(self) -> Optional[Dict[str, Any]]:
"""
Previous state getter. It is a protected property.
:return: Optional[Dict[str, Any]] - previous state
"""
return self._previous_saved_state

@property # type: ignore
@saved_state_needed
def is_changed(self) -> bool:
if self._saved_state == get_dict(self, to_db=True):
return False
return True

@property # type: ignore
@saved_state_needed
@previous_saved_state_needed
def has_changed(self) -> bool:
if self._previous_saved_state is None or self._previous_saved_state == self._saved_state:
return False
return True

def _collect_updates(
self, old_dict: Dict[str, Any], new_dict: Dict[str, Any]
) -> Dict[str, Any]:
Expand Down Expand Up @@ -796,6 +822,16 @@ def get_changes(self) -> Dict[str, Any]:
self._saved_state, get_dict(self, to_db=True) # type: ignore
)

@saved_state_needed
@previous_saved_state_needed
def get_previous_changes(self) -> Dict[str, Any]:
if self._previous_saved_state is None:
return {}

return self._collect_updates(
self._previous_saved_state, self._saved_state # type: ignore
)

@saved_state_needed
def rollback(self) -> None:
if self.is_changed:
Expand Down
1 change: 1 addition & 0 deletions beanie/odm/settings/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def validate(cls, v):
class DocumentSettings(ItemSettings):
use_state_management: bool = False
state_management_replace_objects: bool = False
state_management_save_previous: bool = False
validate_on_save: bool = False
use_revision: bool = False
single_root_inheritance: bool = False
Expand Down
27 changes: 27 additions & 0 deletions beanie/odm/utils/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ async def async_wrapper(self: "DocType", *args, **kwargs):
return sync_wrapper


def check_if_previous_state_saved(self: "DocType"):
if not self.use_state_management():
raise StateManagementIsTurnedOff(
"State management is turned off for this document"
)
if not self.state_management_save_previous():
raise StateManagementIsTurnedOff(
"State management's option to save previous state is turned off for this document"
)


def previous_saved_state_needed(f: Callable):
@wraps(f)
def sync_wrapper(self: "DocType", *args, **kwargs):
check_if_previous_state_saved(self)
return f(self, *args, **kwargs)

@wraps(f)
async def async_wrapper(self: "DocType", *args, **kwargs):
check_if_previous_state_saved(self)
return await f(self, *args, **kwargs)

if inspect.iscoroutinefunction(f):
return async_wrapper
return sync_wrapper


def save_state_after(f: Callable):
@wraps(f)
async def wrapper(self: "DocType", *args, **kwargs):
Expand Down
15 changes: 14 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

Beanie project

## [1.16.6] - 2022-12-27

### Feature

- Previous saved state

### Implementation

- Author - [Paul Renvoisé](https://github.com/paul-finary)
- PR <https://github.com/roman-right/beanie/pull/305>

## [1.16.5] - 2022-12-27

### Deprecation
Expand Down Expand Up @@ -1201,4 +1212,6 @@ how specific type should be presented in the database

[1.14.1]: https://pypi.org/project/beanie/1.14.1

[1.15.5]: https://pypi.org/project/beanie/1.15.5
[1.15.5]: https://pypi.org/project/beanie/1.15.5

[1.16.6]: https://pypi.org/project/beanie/1.16.6
54 changes: 53 additions & 1 deletion docs/tutorial/state_management.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ class Sample(Document):
use_state_management = True
```

Beanie keeps the current changes (not yet saved in the database) by default (with `use_state_management = True`), AND the previous changes (saved to the database) with `state_management_save_previous = True`.

```python
class Sample(Document):
num: int
name: str

class Settings:
use_state_management = True
state_management_save_previous = True
```

Every new save override the previous changes and clears the current changes.

## Saving changes

To save only changed values, the `save_changes()` method should be used.

```python
Expand All @@ -21,7 +37,43 @@ s.num = 100
await s.save_changes()
```

The `save_changes()` method can be used only with already existing documents.
The `save_changes()` method can only be used with already inserted documents.


## Interacting with changes

Beanie exposes several methods that can be used to interact with the saved changes:

```python
s = await Sample.find_one(Sample.name == "Test")

s.is_changed == False
s.get_changes == {}

s.num = 200

s.is_changed == True
s.get_changes() == {"num": 200}

s.rollback()

s.is_changed == False
s.get_changes() == {}
```

And similar methods can be used with the previous changes that have been saved in the database if `state_management_save_previous` is set to `True`:

```python
s = await Sample.find_one(Sample.name == "Test")

s.num = 200
await s.save_changes()

s.has_changed == True
s.get_previous_changes() == {"num": 200}
s.get_changes() == {}
```


## Options

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "beanie"
version = "1.16.5"
version = "1.16.6"
description = "Asynchronous Python ODM for MongoDB"
authors = ["Roman <[email protected]>"]
license = "Apache-2.0"
Expand Down
2 changes: 2 additions & 0 deletions tests/odm/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
DocumentWithTurnedOffStateManagement,
DocumentWithTurnedOnReplaceObjects,
DocumentWithTurnedOnStateManagement,
DocumentWithTurnedOnSavePrevious,
DocumentWithValidationOnSave,
Door,
GeoObject,
Expand Down Expand Up @@ -174,6 +175,7 @@ async def init(loop, db):
DocumentWithActions,
DocumentWithTurnedOnStateManagement,
DocumentWithTurnedOnReplaceObjects,
DocumentWithTurnedOnSavePrevious,
DocumentWithTurnedOffStateManagement,
DocumentWithValidationOnSave,
DocumentWithRevisionTurnedOn,
Expand Down
10 changes: 10 additions & 0 deletions tests/odm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,16 @@ class Settings:
state_management_replace_objects = True


class DocumentWithTurnedOnSavePrevious(Document):
num_1: int
num_2: int
internal: InternalDoc

class Settings:
use_state_management = True
state_management_save_previous = True


class DocumentWithTurnedOffStateManagement(Document):
num_1: int
num_2: int
Expand Down
Loading

0 comments on commit 9d075d1

Please sign in to comment.