add follow=True for save_related, update docs

This commit is contained in:
collerek
2020-11-15 10:33:03 +01:00
parent 0f36944fe1
commit d478ea6e15
3 changed files with 193 additions and 18 deletions

View File

@ -228,6 +228,33 @@ Each model has a `QuerySet` initialised as `objects` parameter
!!!info !!!info
To read more about `QuerySets` (including bulk operations) and available methods visit [queries][queries] To read more about `QuerySets` (including bulk operations) and available methods visit [queries][queries]
## `Model` save status
Each model instance is a separate python object and they do not know anything about each other.
```python
track1 = await Track.objects.get(name='The Bird')
track2 = await Track.objects.get(name='The Bird')
assert track1 == track2 # True
track1.name = 'The Bird2'
await track1.save()
assert track1.name == track2.name # False
# track2 does not update and knows nothing about track1
```
The objects itself have a saved status, which is set as following:
* Model is saved after `save/update/load/upsert` method on model
* Model is saved after `create/get/first/all/get_or_create/update_or_create` method
* Model is saved when passed to `bulk_update` and `bulk_create`
* Model is saved after `adding/removing` `ManyToMany` related objects (through model instance auto saved/deleted)
* Model is **not** saved after change of any own field (including `pk` as `Model.pk` alias)
* Model is **not** saved after adding/removing `ForeignKey` related object (fk column not saved)
* Model is **not** saved after instantiation with `__init__` (w/o `QuerySet.create` or before calling `save`)
You can check if model is saved with `ModelInstance.saved` property
## `Model` methods ## `Model` methods
### load ### load
@ -249,16 +276,57 @@ track.album.name # will return 'Malibu'
### save ### save
`save() -> self`
You can create new models by using `QuerySet.create()` method or by initializing your model as a normal pydantic model You can create new models by using `QuerySet.create()` method or by initializing your model as a normal pydantic model
and later calling `save()` method. and later calling `save()` method.
`save()` can also be used to persist changes that you made to the model. `save()` can also be used to persist changes that you made to the model, but only if the primary key is not set or the model does not exist in database.
The `save()` method does not check if the model exists in db, so if it does you will get a integrity error from your selected db backend if trying to save model with already existing primary key.
```python ```python
track = Track(name='The Bird') track = Track(name='The Bird')
await track.save() # will persist the model in database await track.save() # will persist the model in database
track = await Track.objects.get(name='The Bird')
await track.save() # will raise integrity error as pk is populated
``` ```
### update
`update(**kwargs) -> self`
You can update models by using `QuerySet.update()` method or by updating your model attributes (fields) and calling `update()` method.
If you try to update a model without a primary key set a `ModelPersistenceError` exception will be thrown.
To persist a newly created model use `save()` or `upsert(**kwargs)` methods.
```python
track = await Track.objects.get(name='The Bird')
await track.update(name='The Bird Strikes Again')
```
### upsert
`upsert(**kwargs) -> self`
It's an proxy to either `save()` or `update(**kwargs)` methods described above.
If the primary key is set -> the `update` method will be called.
If the pk is not set the `save()` method will be called.
```python
track = Track(name='The Bird')
await track.upsert() # will call save as the pk is empty
track = await Track.objects.get(name='The Bird')
await track.upsert(name='The Bird Strikes Again') # will call update as pk is already populated
```
### delete ### delete
You can delete models by using `QuerySet.delete()` method or by using your model and calling `delete()` method. You can delete models by using `QuerySet.delete()` method or by using your model and calling `delete()` method.
@ -271,13 +339,28 @@ await track.delete() # will delete the model from database
!!!tip !!!tip
Note that that `track` object stays the same, only record in the database is removed. Note that that `track` object stays the same, only record in the database is removed.
### update ### save_related
You can delete models by using `QuerySet.update()` method or by using your model and calling `update()` method. `save_related(follow: bool = False) -> None`
Method goes through all relations of the `Model` on which the method is called,
and calls `upsert()` method on each model that is **not** saved.
To understand when a model is saved check [save status][save status] section above.
By default the `save_related` method saved only models that are directly related (one step away) to the model on which the method is called.
But you can specify the `follow=True` parameter to traverse through nested models and save all of them in the relation tree.
!!!warning
To avoid circular updates with `follow=True` set, `save_related` keeps a set of already visited Models,
and won't perform nested `save_related` on Models that were already visited.
So if you have a diamond or circular relations types you need to perform the updates in a manual way.
```python ```python
track = await Track.objects.get(name='The Bird') # in example like this the second Street (coming from Company) won't be save_related, so Whatever won't be updated
await track.update(name='The Bird Strikes Again') Street -> District -> City -> Companies -> Street -> Whatever
``` ```
## Internals ## Internals
@ -348,3 +431,4 @@ For example to list table model fields you can:
[sqlalchemy connection string]: https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls [sqlalchemy connection string]: https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
[sqlalchemy table creation]: https://docs.sqlalchemy.org/en/13/core/metadata.html#creating-and-dropping-database-tables [sqlalchemy table creation]: https://docs.sqlalchemy.org/en/13/core/metadata.html#creating-and-dropping-database-tables
[alembic]: https://alembic.sqlalchemy.org/en/latest/tutorial.html [alembic]: https://alembic.sqlalchemy.org/en/latest/tutorial.html
[save status]: ../models/#model-save-status

View File

@ -1,5 +1,16 @@
import itertools import itertools
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Type, TypeVar, Union from typing import (
Any,
Dict,
List,
Optional,
Set,
TYPE_CHECKING,
Tuple,
Type,
TypeVar,
Union,
)
import sqlalchemy import sqlalchemy
@ -192,22 +203,47 @@ class Model(NewBaseModel):
self.set_save_status(True) self.set_save_status(True)
return self return self
async def save_related(self) -> int: # noqa: CCR001 async def save_related( # noqa: CCR001
update_count = 0 self, follow: bool = False, visited: Set = None, update_count: int = 0
) -> int: # noqa: CCR001
if not visited:
visited = {self.__class__}
else:
visited = {x for x in visited}
visited.add(self.__class__)
for related in self.extract_related_names(): for related in self.extract_related_names():
if self.Meta.model_fields[related].virtual or issubclass( if self.Meta.model_fields[related].virtual or issubclass(
self.Meta.model_fields[related], ManyToManyField self.Meta.model_fields[related], ManyToManyField
): ):
for rel in getattr(self, related): for rel in getattr(self, related):
if not rel.saved: update_count, visited = await self._update_and_follow(
await rel.upsert() rel=rel,
update_count += 1 follow=follow,
visited=visited,
update_count=update_count,
)
visited.add(self.Meta.model_fields[related].to)
else: else:
rel = getattr(self, related) rel = getattr(self, related)
update_count, visited = await self._update_and_follow(
rel=rel, follow=follow, visited=visited, update_count=update_count
)
visited.add(rel.__class__)
return update_count
@staticmethod
async def _update_and_follow(
rel: "Model", follow: bool, visited: Set, update_count: int
) -> Tuple[int, Set]:
if follow and rel.__class__ not in visited:
update_count = await rel.save_related(
follow=follow, visited=visited, update_count=update_count
)
if not rel.saved: if not rel.saved:
await rel.upsert() await rel.upsert()
update_count += 1 update_count += 1
return update_count return update_count, visited
async def update(self: T, **kwargs: Any) -> T: async def update(self: T, **kwargs: Any) -> T:
if kwargs: if kwargs:

View File

@ -11,6 +11,16 @@ database = databases.Database(DATABASE_URL, force_rollback=True)
metadata = sqlalchemy.MetaData() metadata = sqlalchemy.MetaData()
class CringeLevel(ormar.Model):
class Meta:
tablename = "levels"
metadata = metadata
database = database
id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100)
class NickNames(ormar.Model): class NickNames(ormar.Model):
class Meta: class Meta:
tablename = "nicks" tablename = "nicks"
@ -20,6 +30,7 @@ class NickNames(ormar.Model):
id: int = ormar.Integer(primary_key=True) id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=100, nullable=False, name="hq_name") name: str = ormar.String(max_length=100, nullable=False, name="hq_name")
is_lame: bool = ormar.Boolean(nullable=True) is_lame: bool = ormar.Boolean(nullable=True)
level: CringeLevel = ormar.ForeignKey(CringeLevel)
class NicksHq(ormar.Model): class NicksHq(ormar.Model):
@ -82,7 +93,7 @@ async def test_saving_related_fk_rel():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_adding_many_to_many_does_not_gets_dirty(): async def test_saving_many_to_many():
async with database: async with database:
async with database.transaction(force_rollback=True): async with database.transaction(force_rollback=True):
nick1 = await NickNames.objects.create(name="BazingaO", is_lame=False) nick1 = await NickNames.objects.create(name="BazingaO", is_lame=False)
@ -111,7 +122,7 @@ async def test_adding_many_to_many_does_not_gets_dirty():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_queryset_methods(): async def test_saving_reversed_relation():
async with database: async with database:
async with database.transaction(force_rollback=True): async with database.transaction(force_rollback=True):
hq = await HQ.objects.create(name="Main") hq = await HQ.objects.create(name="Main")
@ -149,3 +160,47 @@ async def test_queryset_methods():
assert count == 2 assert count == 2
assert hq.companies[0].saved assert hq.companies[0].saved
assert hq.companies[1].saved assert hq.companies[1].saved
@pytest.mark.asyncio
async def test_saving_nested():
async with database:
async with database.transaction(force_rollback=True):
level = await CringeLevel.objects.create(name='High')
level2 = await CringeLevel.objects.create(name='Low')
nick1 = await NickNames.objects.create(name="BazingaO", is_lame=False, level=level)
nick2 = await NickNames.objects.create(name="Bazinga20", is_lame=True, level=level2)
hq = await HQ.objects.create(name="Main")
assert hq.saved
await hq.nicks.add(nick1)
assert hq.saved
await hq.nicks.add(nick2)
assert hq.saved
count = await hq.save_related()
assert count == 0
hq.nicks[0].level.name = "Medium"
assert not hq.nicks[0].level.saved
assert hq.nicks[0].saved
count = await hq.save_related(follow=True)
assert count == 1
assert hq.nicks[0].saved
assert hq.nicks[0].level.saved
hq.nicks[0].level.name = "Low"
hq.nicks[1].level.name = "Medium"
assert not hq.nicks[0].level.saved
assert not hq.nicks[1].level.saved
assert hq.nicks[0].saved
assert hq.nicks[1].saved
count = await hq.save_related(follow=True)
assert count == 2
assert hq.nicks[0].saved
assert hq.nicks[0].level.saved
assert hq.nicks[1].saved
assert hq.nicks[1].level.saved