Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync Method #831

Merged
merged 2 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -1060,3 +1060,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