Skip to content

Commit

Permalink
Merge pull request #197 from paul-finary/feature/state_management_rep…
Browse files Browse the repository at this point in the history
…lace_objects

feature(state_management): add state_management_replace_objects setting
  • Loading branch information
roman-right authored Feb 10, 2022
2 parents e56d709 + 3d70362 commit f77d513
Show file tree
Hide file tree
Showing 12 changed files with 174 additions and 74 deletions.
30 changes: 0 additions & 30 deletions .github/workflows/github-actions-pyright.yml

This file was deleted.

2 changes: 1 addition & 1 deletion beanie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from beanie.odm.utils.general import init_beanie
from beanie.odm.documents import Document

__version__ = "1.8.12"
__version__ = "1.8.13"
__all__ = [
# ODM
"Document",
Expand Down
14 changes: 11 additions & 3 deletions beanie/odm/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,14 @@ def use_state_management(cls) -> bool:
"""
return cls.get_settings().model_settings.use_state_management

@classmethod
def state_management_replace_objects(cls) -> bool:
"""
Should objects be replaced when using state management
:return: bool
"""
return cls.get_settings().model_settings.state_management_replace_objects

def _save_state(self) -> None:
"""
Save current document state. Internal method
Expand Down Expand Up @@ -947,12 +955,10 @@ def _collect_updates(

for field_name, field_value in new_dict.items():
if field_value != old_dict.get(field_name):
if not (
if not self.state_management_replace_objects() and (
isinstance(field_value, dict)
and isinstance(old_dict.get(field_name), dict)
):
updates[field_name] = field_value
else:
if old_dict.get(field_name) is None:
updates[field_name] = field_value
elif isinstance(field_value, dict) and isinstance(
Expand All @@ -966,6 +972,8 @@ def _collect_updates(

for k, v in field_data.items():
updates[f"{field_name}.{k}"] = v
else:
updates[field_name] = field_value

return updates

Expand Down
1 change: 0 additions & 1 deletion beanie/odm/settings/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def validate(cls, v):

class CollectionInputParameters(BaseModel):
name: str = ""
use_state_management: bool = False
indexes: List[IndexModelField] = []

class Config:
Expand Down
1 change: 1 addition & 0 deletions beanie/odm/settings/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
class ModelSettings(BaseModel):
projection: Optional[Dict[str, Any]] = None
use_state_management: bool = False
state_management_replace_objects: bool = False
validate_on_save: bool = False
use_revision: bool = False
use_cache: bool = False
Expand Down
17 changes: 15 additions & 2 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# Changelog

Beanie project changes
Beanie project

## [1.8.13] - 2022-02-10

### Improvement

- Add state_management_replace_objects setting

### Implementation

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

## [1.8.12] - 2022-01-06

Expand Down Expand Up @@ -661,4 +672,6 @@ how specific type should be presented in the database

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

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

[1.8.13]: https://pypi.org/project/beanie/1.8.13
65 changes: 63 additions & 2 deletions docs/tutorial/state_management.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Save changes
# State Management

Beanie can keep the document state, that synced with the database, to find local changes and save only them.

Expand All @@ -21,4 +21,65 @@ s.num = 100
await s.save_changes()
```

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

## Options

By default, state management will merge the changes made to nested objects, which is fine for most cases,
as it is non-destructive, and does not re-assign the whole object is only one of its attributes changed:

```python
from typing import Dict

class Item(Document):
name: str
attributes: Dict[str, float]

class Settings:
use_state_management = True
```

```python
i = Item(name="Test", attributes={"attribute_1": 1.0, "attribute_2": 2.0})
await i.insert()
i.attributes = {"attribute_1": 1.0}
await i.save_changes()
# Changes will consist of: {"attributes.attribute": 1.0}
```

However, there's some cases where you want to replace the whole object when one of its attributes changed.
You can enable the `state_management_replace_objects` attribute in your model's `Settings` inner class:

```python
from typing import Dict

class Item(Document):
name: str
attributes: Dict[str, float]

class Settings:
use_state_management = True
state_management_replace_objects = True
```

With this setting activated, when one attribute of the nested object is changed, the whole object will be overriden:

```python
i = Item(name="Test", attributes={"attribute_1": 1.0, "attribute_2": 2.0})
await i.insert()
i.attributes.attribute_1 = 1.0
await i.save_changes()
# Changes will consist of: {"attributes.attribute_1": 1.0, "attributes.attribute_2": 2.0}
# Keeping attribute_2
```

When the whole object is assigned, the whole nested object will be overriden:

```python
i = Item(name="Test", attributes={"attribute_1": 1.0, "attribute_2": 2.0})
await i.insert()
i.attributes = {"attribute_1": 1.0}
await i.save_changes()
# Changes will consist of: {"attributes": {"attribute_1": 1.0}}
# Removing attribute_2
```
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.8.12"
version = "1.8.13"
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 @@ -20,6 +20,7 @@
DocumentWithBsonEncodersFiledsTypes,
DocumentWithActions,
DocumentWithTurnedOnStateManagement,
DocumentWithTurnedOnReplaceObjects,
DocumentWithTurnedOffStateManagement,
DocumentWithValidationOnSave,
DocumentWithRevisionTurnedOn,
Expand Down Expand Up @@ -141,6 +142,7 @@ async def init(loop, db):
Sample,
DocumentWithActions,
DocumentWithTurnedOnStateManagement,
DocumentWithTurnedOnReplaceObjects,
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 @@ -223,6 +223,16 @@ class Settings:
use_state_management = True


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

class Settings:
use_state_management = True
state_management_replace_objects = True


class DocumentWithTurnedOffStateManagement(Document):
num_1: int
num_2: int
Expand Down
102 changes: 69 additions & 33 deletions tests/odm/test_state_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from beanie.exceptions import StateManagementIsTurnedOff, StateNotSaved
from tests.odm.models import (
DocumentWithTurnedOnStateManagement,
DocumentWithTurnedOnReplaceObjects,
DocumentWithTurnedOffStateManagement,
InternalDoc,
)
Expand All @@ -20,14 +21,19 @@ def state():


@pytest.fixture
def doc(state):
def doc_default(state):
return DocumentWithTurnedOnStateManagement._parse_obj_saving_state(state)


@pytest.fixture
async def saved_doc(doc):
await doc.insert()
return doc
def doc_replace(state):
return DocumentWithTurnedOnReplaceObjects._parse_obj_saving_state(state)


@pytest.fixture
async def saved_doc_default(doc_default):
await doc_default.insert()
return doc_default


def test_use_state_management_property():
Expand Down Expand Up @@ -71,38 +77,68 @@ def test_saved_state_needed():
doc_2.is_changed


def test_if_changed(doc):
assert doc.is_changed is False
doc.num_1 = 10
assert doc.is_changed is True
def test_if_changed(doc_default):
assert doc_default.is_changed is False
doc_default.num_1 = 10
assert doc_default.is_changed is True


def test_get_changes(doc):
doc.internal.num = 1000
doc.internal.string = "new_value"
doc.internal.lst.append(100)
assert doc.get_changes() == {
def test_get_changes_default(doc_default):
doc_default.internal.num = 1000
doc_default.internal.string = "new_value"
doc_default.internal.lst.append(100)
assert doc_default.get_changes() == {
"internal.num": 1000,
"internal.string": "new_value",
"internal.lst": [1, 2, 3, 4, 5, 100],
}


async def test_save_changes(saved_doc):
saved_doc.internal.num = 10000
await saved_doc.save_changes()
assert saved_doc.get_saved_state()["internal"]["num"] == 10000
def test_get_changes_default_whole(doc_default):
doc_default.internal = {"num": 1000, "string": "new_value"}
assert doc_default.get_changes() == {
"internal.num": 1000,
"internal.string": "new_value",
}


def test_get_changes_replace(doc_replace):
doc_replace.internal.num = 1000
doc_replace.internal.string = "new_value"
assert doc_replace.get_changes() == {
"internal": {
"num": 1000,
"string": "new_value",
"lst": [1, 2, 3, 4, 5],
}
}


def test_get_changes_replace_whole(doc_replace):
doc_replace.internal = {"num": 1000, "string": "new_value"}
assert doc_replace.get_changes() == {
"internal": {
"num": 1000,
"string": "new_value",
}
}


async def test_save_changes(saved_doc_default):
saved_doc_default.internal.num = 10000
await saved_doc_default.save_changes()
assert saved_doc_default.get_saved_state()["internal"]["num"] == 10000

new_doc = await DocumentWithTurnedOnStateManagement.get(saved_doc.id)
new_doc = await DocumentWithTurnedOnStateManagement.get(saved_doc_default.id)
assert new_doc.internal.num == 10000


async def test_find_one(saved_doc, state):
new_doc = await DocumentWithTurnedOnStateManagement.get(saved_doc.id)
async def test_find_one(saved_doc_default, state):
new_doc = await DocumentWithTurnedOnStateManagement.get(saved_doc_default.id)
assert new_doc.get_saved_state() == state

new_doc = await DocumentWithTurnedOnStateManagement.find_one(
DocumentWithTurnedOnStateManagement.id == saved_doc.id
DocumentWithTurnedOnStateManagement.id == saved_doc_default.id
)
assert new_doc.get_saved_state() == state

Expand Down Expand Up @@ -132,19 +168,19 @@ async def test_insert(state):
assert doc.get_saved_state() == state


async def test_replace(saved_doc):
saved_doc.num_1 = 100
await saved_doc.replace()
assert saved_doc.get_saved_state()["num_1"] == 100
async def test_replace(saved_doc_default):
saved_doc_default.num_1 = 100
await saved_doc_default.replace()
assert saved_doc_default.get_saved_state()["num_1"] == 100


async def test_save_chages(saved_doc):
saved_doc.num_1 = 100
await saved_doc.save_changes()
assert saved_doc.get_saved_state()["num_1"] == 100
async def test_save_chages(saved_doc_default):
saved_doc_default.num_1 = 100
await saved_doc_default.save_changes()
assert saved_doc_default.get_saved_state()["num_1"] == 100


async def test_rollback(doc, state):
doc.num_1 = 100
doc.rollback()
assert doc.num_1 == state["num_1"]
async def test_rollback(doc_default, state):
doc_default.num_1 = 100
doc_default.rollback()
assert doc_default.num_1 == state["num_1"]
2 changes: 1 addition & 1 deletion tests/test_beanie.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@


def test_version():
assert __version__ == "1.8.12"
assert __version__ == "1.8.13"

0 comments on commit f77d513

Please sign in to comment.