Skip to content

Commit

Permalink
Sync Method (#831)
Browse files Browse the repository at this point in the history
* implemented

* doc
  • Loading branch information
roman-right committed Jan 24, 2024
1 parent ced7168 commit 7c49b91
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 3 deletions.
3 changes: 2 additions & 1 deletion beanie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from beanie.odm.bulk import BulkWriter
from beanie.odm.custom_types import DecimalAnnotation
from beanie.odm.custom_types.bson.binary import BsonBinary
from beanie.odm.documents import Document
from beanie.odm.documents import Document, MergeStrategy
from beanie.odm.enums import SortDirection
from beanie.odm.fields import (
BackLink,
Expand Down Expand Up @@ -46,6 +46,7 @@
"TimeSeriesConfig",
"Granularity",
"SortDirection",
"MergeStrategy",
# Actions
"before_event",
"after_event",
Expand Down
4 changes: 4 additions & 0 deletions beanie/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,7 @@ class DocWasNotRegisteredInUnionClass(Exception):

class Deprecation(Exception):
pass


class ApplyChangesException(Exception):
pass
43 changes: 42 additions & 1 deletion beanie/odm/documents.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import warnings
from enum import Enum
from typing import (
Any,
ClassVar,
Expand Down Expand Up @@ -81,7 +82,7 @@
from beanie.odm.queries.update import UpdateMany, UpdateResponse
from beanie.odm.settings.document import DocumentSettings
from beanie.odm.utils.dump import get_dict, get_top_level_nones
from beanie.odm.utils.parsing import merge_models
from beanie.odm.utils.parsing import apply_changes, merge_models
from beanie.odm.utils.pydantic import (
IS_PYDANTIC_V2,
get_extra_field_info,
Expand Down Expand Up @@ -126,6 +127,11 @@ def document_alias_generator(s: str) -> str:
return s


class MergeStrategy(str, Enum):
local = "local"
remote = "remote"


class Document(
LazyModel,
SettersInterface,
Expand Down Expand Up @@ -254,6 +260,41 @@ async def get(
**pymongo_kwargs,
)

async def sync(self, merge_strategy: MergeStrategy = MergeStrategy.remote):
"""
Sync the document with the database
:param merge_strategy: MergeStrategy - how to merge the document
:return: None
"""
if (
merge_strategy == MergeStrategy.local
and self.get_settings().use_state_management is False
):
raise ValueError(
"State management must be turned on to use local merge strategy"
)
if self.id is None:
raise DocumentWasNotSaved
document = await self.find_one({"_id": self.id})
if document is None:
raise DocumentNotFound

if merge_strategy == MergeStrategy.local:
original_changes = self.get_changes()
new_state = document.get_saved_state()
if new_state is None:
raise DocumentWasNotSaved
changes_to_apply = self._collect_updates(
new_state, original_changes
)
merge_models(self, document)
apply_changes(changes_to_apply, self)
elif merge_strategy == MergeStrategy.remote:
merge_models(self, document)
else:
raise ValueError("Invalid merge strategy")

@wrap_with_actions(EventTypes.INSERT)
@save_state_after
@validate_self_before
Expand Down
44 changes: 43 additions & 1 deletion beanie/odm/utils/parsing.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import TYPE_CHECKING, Any, Type, Union
from typing import TYPE_CHECKING, Any, Dict, Type, Union

from pydantic import BaseModel

from beanie.exceptions import (
ApplyChangesException,
DocWasNotRegisteredInUnionClass,
UnionHasNoRegisteredDocs,
)
Expand Down Expand Up @@ -45,6 +46,47 @@ def merge_models(left: BaseModel, right: BaseModel) -> None:
left.__setattr__(k, right_value)


def apply_changes(
changes: Dict[str, Any], target: Union[BaseModel, Dict[str, Any]]
):
for key, value in changes.items():
if "." in key:
key_parts = key.split(".")
current_target = target
try:
for part in key_parts[:-1]:
if isinstance(current_target, dict):
current_target = current_target[part]
elif isinstance(current_target, BaseModel):
current_target = getattr(current_target, part)
else:
raise ApplyChangesException(
f"Unexpected type of target: {type(target)}"
)
final_key = key_parts[-1]
if isinstance(current_target, dict):
current_target[final_key] = value
elif isinstance(current_target, BaseModel):
setattr(current_target, final_key, value)
else:
raise ApplyChangesException(
f"Unexpected type of target: {type(target)}"
)
except (KeyError, AttributeError) as e:
raise ApplyChangesException(
f"Failed to apply change for key '{key}': {e}"
)
else:
if isinstance(target, dict):
target[key] = value
elif isinstance(target, BaseModel):
setattr(target, key, value)
else:
raise ApplyChangesException(
f"Unexpected type of target: {type(target)}"
)


def save_state(item: BaseModel):
if hasattr(item, "_save_state"):
item._save_state() # type: ignore
Expand Down
32 changes: 32 additions & 0 deletions docs/tutorial/find.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,38 @@ you can use the [find_one](../api-documentation/interfaces.md/#findinterfacefind
bar = await Product.find_one(Product.name == "Peanut Bar")
```

## Syncing from the Database

If you wish to apply changes from the database to the document, utilize the [sync](../api-documentation/document.md/#documentsync) method:

```python
await bar.sync()
```

Two merging strategies are available: `local` and `remote`.

### Remote Merge Strategy

The remote merge strategy replaces the local document with the one from the database, disregarding local changes:

```python
from beanie import MergeStrategy

await bar.sync(merge_strategy=MergeStrategy.remote)
```
The remote merge strategy is the default.

### Local Merge Strategy

The local merge strategy retains changes made locally to the document and updates other fields from the database.
**BE CAREFUL**: it may raise an `ApplyChangesException` in case of a merging conflict.

```python
from beanie import MergeStrategy

await bar.sync(merge_strategy=MergeStrategy.local)
```

## More complex queries

### Multiple search criteria
Expand Down
2 changes: 2 additions & 0 deletions tests/odm/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
DocumentTestModelWithLink,
DocumentTestModelWithSimpleIndex,
DocumentToBeLinked,
DocumentToTestSync,
DocumentUnion,
DocumentWithActions,
DocumentWithActions2,
Expand Down Expand Up @@ -281,6 +282,7 @@ async def init(db):
DocumentWithOptionalListBackLink,
DocumentWithComplexDictKey,
DocumentWithIndexedObjectId,
DocumentToTestSync,
]
await init_beanie(
database=db,
Expand Down
54 changes: 54 additions & 0 deletions tests/odm/documents/test_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import pytest

from beanie.exceptions import ApplyChangesException
from beanie.odm.documents import MergeStrategy
from tests.odm.models import DocumentToTestSync


class TestSync:
async def test_merge_remote(self):
doc = DocumentToTestSync()
await doc.insert()

doc2 = await DocumentToTestSync.get(doc.id)
doc2.s = "foo"

doc.i = 100
await doc.save()

await doc2.sync()

assert doc2.s == "TEST"
assert doc2.i == 100

async def test_merge_local(self):
doc = DocumentToTestSync(d={"option_1": {"s": "foo"}})
await doc.insert()

doc2 = await DocumentToTestSync.get(doc.id)
doc2.s = "foo"
doc2.n.option_1.s = "bar"
doc2.d["option_1"]["s"] = "bar"

doc.i = 100
await doc.save()

await doc2.sync(merge_strategy=MergeStrategy.local)

assert doc2.s == "foo"
assert doc2.n.option_1.s == "bar"
assert doc2.d["option_1"]["s"] == "bar"

assert doc2.i == 100

async def test_merge_local_impossible_apply_changes(self):
doc = DocumentToTestSync(d={"option_1": {"s": "foo"}})
await doc.insert()

doc2 = await DocumentToTestSync.get(doc.id)
doc2.d["option_1"]["s"] = {"foo": "bar"}

doc.d = {"option_1": "nothing"}
await doc.save()
with pytest.raises(ApplyChangesException):
await doc2.sync(merge_strategy=MergeStrategy.local)
13 changes: 13 additions & 0 deletions tests/odm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1050,3 +1050,16 @@ class DocumentWithIndexedObjectId(Document):
pyid: Indexed(PydanticObjectId)
uuid: Annotated[UUID4, Indexed(unique=True)]
email: Annotated[EmailStr, Indexed(unique=True)]


class DocumentToTestSync(Document):
s: str = "TEST"
i: int = 1
n: Nested = Nested(
integer=1, option_1=Option1(s="test"), union=Option1(s="test")
)
o: Optional[Option2] = None
d: Dict[str, Any] = {}

class Settings:
use_state_management = True

0 comments on commit 7c49b91

Please sign in to comment.