diff --git a/README.md b/README.md index b4b1890..f7adfbe 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ assert len(tracks) == 1 * `bulk_create(objects: List[Model]) -> None` * `bulk_update(objects: List[Model], columns: List[str] = None) -> None` * `delete(each: bool = False, **kwargs) -> int` -* `all(self, **kwargs) -> List[Optional[Model]]` +* `all(**kwargs) -> List[Optional[Model]]` * `filter(**kwargs) -> QuerySet` * `exclude(**kwargs) -> QuerySet` * `select_related(related: Union[List, str]) -> QuerySet` diff --git a/docs/index.md b/docs/index.md index b4b1890..f7adfbe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -154,7 +154,7 @@ assert len(tracks) == 1 * `bulk_create(objects: List[Model]) -> None` * `bulk_update(objects: List[Model], columns: List[str] = None) -> None` * `delete(each: bool = False, **kwargs) -> int` -* `all(self, **kwargs) -> List[Optional[Model]]` +* `all(**kwargs) -> List[Optional[Model]]` * `filter(**kwargs) -> QuerySet` * `exclude(**kwargs) -> QuerySet` * `select_related(related: Union[List, str]) -> QuerySet` diff --git a/docs/queries.md b/docs/queries.md index 17b23ce..1af4382 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -176,7 +176,7 @@ Return number of rows deleted. ### all -`all(self, **kwargs) -> List[Optional["Model"]]` +`all(**kwargs) -> List[Optional["Model"]]` Returns all rows from a database for given model for set filter options. diff --git a/docs/relations.md b/docs/relations.md index 5bda020..b3e548b 100644 --- a/docs/relations.md +++ b/docs/relations.md @@ -29,6 +29,83 @@ By default it's child (source) `Model` name + s, like courses in snippet below: --8<-- "../docs_src/fields/docs001.py" ``` +Reverse relation exposes API to manage related objects also from parent side. + +##### add + +Adding child model from parent side causes adding related model to currently loaded parent relation, +as well as sets child's model foreign key value and updates the model. + +```python +department = await Department(name="Science").save() +course = Course(name="Math", completed=False) # note - not saved + +await department.courses.add(course) +assert course.pk is not None # child model was saved +# relation on child model is set and FK column saved in db +assert courses.department == department +# relation on parent model is also set +assert department.courses[0] == course +``` + +!!!warning + If you want to add child model on related model the primary key value for parent model **has to exist in database**. + + Otherwise ormar will raise RelationshipInstanceError as it cannot set child's ForeignKey column value + if parent model has no primary key value. + + That means that in example above the department has to be saved before you can call `department.courses.add()`. + +##### remove + +Removal of the related model one by one. + +In reverse relation calling `remove()` does not remove the child model, but instead nulls it ForeignKey value. + +```python +# continuing from above +await department.courses.remove(course) +assert len(department.courses) == 0 +# course still exists and was saved in remove +assert course.pk is not None +assert course.department is None + +# to remove child from db +await course.delete() +``` + +But if you want to clear the relation and delete the child at the same time you can issue: + +```python +# this will not only clear the relation +# but also delete related course from db +await department.courses.remove(course, keep_reversed=False) +``` + +##### clear + +Removal of all related models in one call. + +Like remove by default `clear()` nulls the ForeigKey column on child model (all, not matter if they are loaded or not). + +```python +# nulls department column on all courses related to this department +await department.courses.clear() +``` + +If you want to remove the children altogether from the database, set `keep_reversed=False` + +```python +# deletes from db all courses related to this department +await department.courses.clear(keep_reversed=False) +``` + +##### QuerysetProxy + +Reverse relation exposes QuerysetProxy API that allows you to query related model like you would issue a normal Query. + +To read which methods of QuerySet are available read below [querysetproxy][querysetproxy] + #### related_name But you can overwrite this name by providing `related_name` parameter like below: @@ -94,7 +171,7 @@ Sqlalchemy column and Type are automatically taken from target `Model`. * Sqlalchemy column: class of a target `Model` primary key column * Type (used for pydantic): type of a target `Model` -####Defining `Models`: +####Defining `Models` ```Python --8<-- "../docs_src/relations/docs002.py" @@ -107,7 +184,7 @@ post = await Post.objects.create(title="Hello, M2M", author=guido) news = await Category.objects.create(name="News") ``` -#### Adding related models +#### add ```python # Add a category to a post. @@ -121,7 +198,110 @@ await news.posts.add(post) Otherwise an IntegrityError will be raised by your database driver library. -#### create() +#### remove + +Removal of the related model one by one. + +Removes also the relation in the database. + +```python +await news.posts.remove(post) +``` + +#### clear + +Removal of all related models in one call. + +Removes also the relation in the database. + +```python +await news.posts.clear() +``` + +#### QuerysetProxy + +Reverse relation exposes QuerysetProxy API that allows you to query related model like you would issue a normal Query. + +To read which methods of QuerySet are available read below [querysetproxy][querysetproxy] + +### QuerySetProxy + +When access directly the related `ManyToMany` field as well as `ReverseForeignKey` returns the list of related models. + +But at the same time it exposes subset of QuerySet API, so you can filter, create, select related etc related models directly from parent model. + +!!!note + By default exposed QuerySet is already filtered to return only `Models` related to parent `Model`. + + So if you issue `post.categories.all()` you will get all categories related to that post, not all in table. + +!!!note + Note that when accessing QuerySet API methods through QuerysetProxy you don't + need to use `objects` attribute like in normal queries. + + So note that it's `post.categories.all()` and **not** `post.categories.objects.all()`. + + To learn more about available QuerySet methods visit [queries][queries] + +!!!warning + Querying related models from ManyToMany cleans list of related models loaded on parent model: + + Example: `post.categories.first()` will set post.categories to list of 1 related model -> the one returned by first() + + Example 2: if post has 4 categories so `len(post.categories) == 4` calling `post.categories.limit(2).all()` + -> will load only 2 children and now `assert len(post.categories) == 2` + + This happens for all QuerysetProxy methods returning data: `get`, `all` and `first` and in `get_or_create` if model already exists. + + Note that value returned by `create` or created in `get_or_create` and `update_or_create` + if model does not exist will be added to relation list (not clearing it). + +#### get + +`get(**kwargs): -> Model` + +To grab just one of related models filtered by name you can use `get(**kwargs)` method. + +```python +# grab one category +assert news == await post.categories.get(name="News") + +# note that method returns the category so you can grab this value +# but it also modifies list of related models in place +# so regardless of what was previously loaded on parent model +# now it has only one value -> just loaded with get() call +assert len(post.categories) == 1 +assert post.categories[0] == news + +``` + +!!!tip + Read more in queries documentation [get][get] + +#### all + +`all(**kwargs) -> List[Optional["Model"]]` + +To get a list of related models use `all()` method. + +Note that you can filter the queryset, select related, exclude fields etc. like in normal query. + +```python +# with all Queryset methods - filtering, selecting columns, counting etc. +await news.posts.filter(title__contains="M2M").all() +await Category.objects.filter(posts__author=guido).get() + +# columns models of many to many relation can be prefetched +news_posts = await news.posts.select_related("author").all() +assert news_posts[0].author == guido +``` + +!!!tip + Read more in queries documentation [all][all] + +#### create + +`create(**kwargs): -> Model` Create related `Model` directly from parent `Model`. @@ -134,64 +314,117 @@ assert len(await post.categories.all()) == 2 # newly created instance already have relation persisted in the database ``` -!!!note - Note that when accessing QuerySet API methods through ManyToMany relation you don't - need to use objects attribute like in normal queries. - - To learn more about available QuerySet methods visit [queries][queries] +!!!tip + Read more in queries documentation [create][create] -#### remove() -Removal of the related model one by one. +#### get_or_create -Removes also the relation in the database. - -```python -await news.posts.remove(post) -``` - -#### clear() - -Removal all related models in one call. - -Removes also the relation in the database. - -```python -await news.posts.clear() -``` - -#### Other queryset methods - -When access directly the related `ManyToMany` field returns the list of related models. - -But at the same time it exposes full QuerySet API, so you can filter, create, select related etc. - -```python -# Many to many relation exposes a list of columns models -# and an API of the Queryset: -assert news == await post.categories.get(name="News") - -# with all Queryset methods - filtering, selecting columns, counting etc. -await news.posts.filter(title__contains="M2M").all() -await Category.objects.filter(posts__author=guido).get() - -# columns models of many to many relation can be prefetched -news_posts = await news.posts.select_related("author").all() -assert news_posts[0].author == guido -``` - -Currently supported methods are: +`get_or_create(**kwargs) -> Model` !!!tip - To learn more about available QuerySet methods visit [queries][queries] + Read more in queries documentation [get_or_create][get_or_create] -##### get() -##### all() -##### filter() -##### select_related() -##### limit() -##### offset() -##### count() -##### exists() +#### update_or_create -[queries]: ./queries.md \ No newline at end of file +`update_or_create(**kwargs) -> Model` + +!!!tip + Read more in queries documentation [update_or_create][update_or_create] + +#### filter + +`filter(**kwargs) -> QuerySet` + +!!!tip + Read more in queries documentation [filter][filter] + +#### exclude + +`exclude(**kwargs) -> QuerySet` + +!!!tip + Read more in queries documentation [exclude][exclude] + +#### select_related + +`select_related(related: Union[List, str]) -> QuerySet` + +!!!tip + Read more in queries documentation [select_related][select_related] + +#### prefetch_related + +`prefetch_related(related: Union[List, str]) -> QuerySet` + +!!!tip + Read more in queries documentation [prefetch_related][prefetch_related] + +#### limit + +`limit(limit_count: int) -> QuerySet` + +!!!tip + Read more in queries documentation [limit][limit] + +#### offset + +`offset(offset: int) -> QuerySet` + +!!!tip + Read more in queries documentation [offset][offset] + +#### count + +`count() -> int` + +!!!tip + Read more in queries documentation [count][count] + +#### exists + +`exists() -> bool` + +!!!tip + Read more in queries documentation [exists][exists] + +#### fields + +`fields(columns: Union[List, str, set, dict]) -> QuerySet` + +!!!tip + Read more in queries documentation [fields][fields] + +#### exclude_fields + +`exclude_fields(columns: Union[List, str, set, dict]) -> QuerySet` + +!!!tip + Read more in queries documentation [exclude_fields][exclude_fields] + +#### order_by + +`order_by(columns:Union[List, str]) -> QuerySet` + +!!!tip + Read more in queries documentation [order_by][order_by] + + +[queries]: ./queries.md +[querysetproxy]: ./relations.md#querysetproxy-methods +[get]: ./queries.md#get +[all]: ./queries.md#all +[create]: ./queries.md#create +[get_or_create]: ./queries.md#get_or_create +[update_or_create]: ./queries.md#update_or_create +[filter]: ./queries.md#filter +[exclude]: ./queries.md#exclude +[select_related]: ./queries.md#select_related +[prefetch_related]: ./queries.md#prefetch_related +[limit]: ./queries.md#limit +[offset]: ./queries.md#offset +[count]: ./queries.md#count +[exists]: ./queries.md#exists +[fields]: ./queries.md#fields +[exclude_fields]: ./queries.md#exclude_fields +[order_by]: ./queries.md#order_by \ No newline at end of file diff --git a/docs/releases.md b/docs/releases.md index f44d9d8..a0af0e3 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,3 +1,15 @@ +# 0.6.0 + +* **Breaking:** calling instance.load() when the instance row was deleted from db now raises ormar.NoMatch instead of ValueError +* **Breaking:** calling add and remove on ReverseForeignKey relation now updates the child model in db setting/removing fk column +* **Breaking:** ReverseForeignKey relation now exposes QuerySetProxy API like ManyToMany relation +* **Breaking:** querying related models from ManyToMany cleans list of related models loaded on parent model: + * Example: `post.categories.first()` will set post.categories to list of 1 related model -> the one returned by first() + * Example 2: if post has 4 categories so `len(post.categories) == 4` calling `post.categories.limit(2).all()` -> will load only 2 children and now `assert len(post.categories) == 2` +* Added `get_or_create`, `update_or_create`, `fields`, `exclude_fields`, `exclude`, `prefetch_related` and `order_by` to QuerySetProxy +so now you can use those methods directly from relation +* Update docs + # 0.5.5 * Fix for alembic autogenaration of migration `UUID` columns. It should just produce sqlalchemy `CHAR(32)` or `CHAR(36)` diff --git a/docs_src/fields/docs001.py b/docs_src/fields/docs001.py index dd79791..d1c9144 100644 --- a/docs_src/fields/docs001.py +++ b/docs_src/fields/docs001.py @@ -29,7 +29,7 @@ class Course(ormar.Model): department: Optional[Department] = ormar.ForeignKey(Department) -department = Department(name="Science") +department = await Department(name="Science").save() course = Course(name="Math", completed=False, department=department) print(department.courses[0]) diff --git a/mkdocs.yml b/mkdocs.yml index 9c3dec6..ccaae7c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: ormar -site_description: An simple async ORM with fastapi in mind and pydantic validation. +site_description: A simple async ORM with fastapi in mind and pydantic validation. nav: - Overview: index.md - Installation: install.md diff --git a/ormar/__init__.py b/ormar/__init__.py index 73d614f..774dea1 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -30,7 +30,7 @@ class UndefinedType: # pragma no cover Undefined = UndefinedType() -__version__ = "0.5.5" +__version__ = "0.6.0" __all__ = [ "Integer", "BigInteger", diff --git a/ormar/models/model.py b/ormar/models/model.py index 70d8e42..df406ff 100644 --- a/ormar/models/model.py +++ b/ormar/models/model.py @@ -15,7 +15,7 @@ from typing import ( import sqlalchemy import ormar.queryset # noqa I100 -from ormar.exceptions import ModelPersistenceError +from ormar.exceptions import ModelPersistenceError, NoMatch from ormar.fields.many_to_many import ManyToManyField from ormar.models import NewBaseModel # noqa I100 from ormar.models.metaclass import ModelMeta @@ -286,9 +286,7 @@ class Model(NewBaseModel): expr = self.Meta.table.select().where(self.pk_column == self.pk) row = await self.Meta.database.fetch_one(expr) if not row: # pragma nocover - raise ValueError( - "Instance was deleted from database and cannot be refreshed" - ) + raise NoMatch("Instance was deleted from database and cannot be refreshed") kwargs = dict(row) kwargs = self.translate_aliases_to_columns(kwargs) self.from_dict(kwargs) diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index 02a0566..f6defd4 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -280,7 +280,9 @@ class QuerySet: return await self.database.fetch_val(expr) async def update(self, each: bool = False, **kwargs: Any) -> int: - self_fields = self.model.extract_db_own_fields() + self_fields = self.model.extract_db_own_fields().union( + self.model.extract_related_names() + ) updates = {k: v for k, v in kwargs.items() if k in self_fields} updates = self.model.translate_columns_to_aliases(updates) if not each and not self.filter_clauses: diff --git a/ormar/relations/querysetproxy.py b/ormar/relations/querysetproxy.py index 208b4d1..b5cad21 100644 --- a/ormar/relations/querysetproxy.py +++ b/ormar/relations/querysetproxy.py @@ -88,7 +88,7 @@ class QuerysetProxy(ormar.QuerySetProtocol): async def count(self) -> int: return await self.queryset.count() - async def clear(self) -> int: + async def clear(self, keep_reversed: bool = True) -> int: if self.type_ == ormar.RelationType.MULTIPLE: queryset = ormar.QuerySet(model_cls=self.relation.through) owner_column = self._owner.get_name() @@ -97,10 +97,16 @@ class QuerysetProxy(ormar.QuerySetProtocol): owner_column = self.related_field.name kwargs = {owner_column: self._owner} self._clean_items_on_load() + if keep_reversed and self.type_ == ormar.RelationType.REVERSE: + update_kwrgs = {f"{owner_column}": None} + return await queryset.filter(_exclude=False, **kwargs).update( + each=False, **update_kwrgs + ) return await queryset.delete(**kwargs) # type: ignore async def first(self, **kwargs: Any) -> "Model": first = await self.queryset.first(**kwargs) + self._clean_items_on_load() self._register_related(first) return first diff --git a/ormar/relations/relation_proxy.py b/ormar/relations/relation_proxy.py index 25c7b1c..c8eb944 100644 --- a/ormar/relations/relation_proxy.py +++ b/ormar/relations/relation_proxy.py @@ -38,17 +38,20 @@ class RelationProxy(list): and self.queryset_proxy.queryset is not None ) - def _set_queryset(self) -> "QuerySet": - related_field = self._owner.resolve_relation_field( - self.relation.to, self._owner - ) - pkname = self._owner.get_column_alias(self._owner.Meta.pkname) + def _check_if_model_saved(self) -> None: pk_value = self._owner.pk if not pk_value: raise RelationshipInstanceError( "You cannot query relationships from unsaved model." ) - kwargs = {f"{related_field.get_alias()}__{pkname}": pk_value} + + def _set_queryset(self) -> "QuerySet": + related_field = self._owner.resolve_relation_field( + self.relation.to, self._owner + ) + pkname = self._owner.get_column_alias(self._owner.Meta.pkname) + self._check_if_model_saved() + kwargs = {f"{related_field.get_alias()}__{pkname}": self._owner.pk} queryset = ( ormar.QuerySet(model_cls=self.relation.to) .select_related(related_field.name) @@ -56,7 +59,9 @@ class RelationProxy(list): ) return queryset - async def remove(self, item: "Model") -> None: # type: ignore + async def remove( # type: ignore + self, item: "Model", keep_reversed: bool = True + ) -> None: if item not in self: raise NoMatch( f"Object {self._owner.get_name()} has no " @@ -74,8 +79,11 @@ class RelationProxy(list): if self.type_ == ormar.RelationType.MULTIPLE: await self.queryset_proxy.delete_through_instance(item) else: - setattr(item, rel_name, None) - await item.update() + if keep_reversed: + setattr(item, rel_name, None) + await item.update() + else: + await item.delete() async def add(self, item: "Model") -> None: if self.type_ == ormar.RelationType.MULTIPLE: @@ -83,6 +91,7 @@ class RelationProxy(list): rel_name = item.resolve_relation_name(item, self._owner) setattr(item, rel_name, self._owner) else: + self._check_if_model_saved() related_field = self._owner.resolve_relation_field( self.relation.to, self._owner ) diff --git a/tests/test_reverse_fk_queryset.py b/tests/test_reverse_fk_queryset.py index e79d55b..dd2c49b 100644 --- a/tests/test_reverse_fk_queryset.py +++ b/tests/test_reverse_fk_queryset.py @@ -5,6 +5,7 @@ import pytest import sqlalchemy import ormar +from ormar import NoMatch from tests.settings import DATABASE_URL database = databases.Database(DATABASE_URL, force_rollback=True) @@ -190,6 +191,26 @@ async def test_getting(): assert len(tracks) == 0 assert len(album.tracks) == 0 + still_tracks = await Track.objects.all() + assert len(still_tracks) == 4 + for track in still_tracks: + assert track.album is None + + +@pytest.mark.asyncio +async def test_cleaning_related(): + async with database: + async with database.transaction(force_rollback=True): + sample_data = await get_sample_data() + album = sample_data[0] + await album.tracks.clear(keep_reversed=False) + tracks = await album.tracks.all() + assert len(tracks) == 0 + assert len(album.tracks) == 0 + + no_tracks = await Track.objects.all() + assert len(no_tracks) == 0 + @pytest.mark.asyncio async def test_loading_related(): @@ -224,12 +245,14 @@ async def test_adding_removing(): track_check = await Track.objects.get(title="Rainbow") assert track_check.album == album - track_test = await Track.objects.get(title="Rainbow") - assert track_test.album == album - await album.tracks.remove(track_new) assert track_new.album is None assert len(album.tracks) == 3 + track1 = album.tracks[0] + await album.tracks.remove(track1, keep_reversed=False) + with pytest.raises(NoMatch): + await track1.load() + track_test = await Track.objects.get(title="Rainbow") assert track_test.album is None