From 107404c3e8f35a6e329740accec7986a5d7ddd99 Mon Sep 17 00:00:00 2001 From: collerek Date: Fri, 25 Jun 2021 13:32:31 +0200 Subject: [PATCH] fix inherited pk and add field accessor access to relations --- README.md | 130 ++++++++++++--- docs/index.md | 148 +++++++++++++++--- docs/queries/joins-and-subqueries.md | 24 ++- docs/releases.md | 20 +++ examples/script_from_readme.py | 67 +++++++- ormar/__init__.py | 2 +- ormar/models/metaclass.py | 6 +- ormar/queryset/queryset.py | 16 +- .../test_inheritance_with_default.py | 64 ++++++++ .../test_python_style_relations.py | 100 ++++++++++++ 10 files changed, 516 insertions(+), 61 deletions(-) create mode 100644 tests/test_inheritance_and_pydantic_generation/test_inheritance_with_default.py create mode 100644 tests/test_relations/test_python_style_relations.py diff --git a/README.md b/README.md index fac2862..14a297f 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,27 @@ Ormar is built with: As I write open-source code to solve everyday problems in my work or to promote and build strong python community you can say thank you and buy me a coffee or sponsor me with a monthly amount to help ensure my work remains free and maintained. - + +
+ +Sponsor +
+
### Migrating from `sqlalchemy` and existing databases @@ -176,6 +196,7 @@ class BaseMeta(ormar.ModelMeta): # id = ormar.Integer(primary_key=True) # <= notice no field types # name = ormar.String(max_length=100) + class Author(ormar.Model): class Meta(BaseMeta): tablename = "authors" @@ -210,15 +231,9 @@ async def create(): # Create some records to work with through QuerySet.create method. # Note that queryset is exposed on each Model's class as objects tolkien = await Author.objects.create(name="J.R.R. Tolkien") - await Book.objects.create(author=tolkien, - title="The Hobbit", - year=1937) - await Book.objects.create(author=tolkien, - title="The Lord of the Rings", - year=1955) - await Book.objects.create(author=tolkien, - title="The Silmarillion", - year=1977) + await Book.objects.create(author=tolkien, title="The Hobbit", year=1937) + await Book.objects.create(author=tolkien, title="The Lord of the Rings", year=1955) + await Book.objects.create(author=tolkien, title="The Silmarillion", year=1977) # alternative creation of object divided into 2 steps sapkowski = Author(name="Andrzej Sapkowski") @@ -317,9 +332,7 @@ async def delete(): # note that despite the fact that record no longer exists in database # the object above is still accessible and you can use it (and i.e. save()) again. tolkien = silmarillion.author - await Book.objects.create(author=tolkien, - title="The Silmarillion", - year=1977) + await Book.objects.create(author=tolkien, title="The Silmarillion", year=1977) async def joins(): @@ -371,11 +384,17 @@ async def filter_and_sort(): # to sort decreasing use hyphen before the field name # same as with filter you can use double underscores to access related fields # Django style - books = await Book.objects.filter(author__name__icontains="tolkien").order_by( - "-year").all() + books = ( + await Book.objects.filter(author__name__icontains="tolkien") + .order_by("-year") + .all() + ) # python style - books = await Book.objects.filter(Book.author.name.icontains("tolkien")).order_by( - Book.year.desc()).all() + books = ( + await Book.objects.filter(Book.author.name.icontains("tolkien")) + .order_by(Book.year.desc()) + .all() + ) assert len(books) == 3 assert books[0].title == "The Silmarillion" assert books[2].title == "The Hobbit" @@ -448,25 +467,68 @@ async def aggregations(): # count: assert 2 == await Author.objects.count() - # exists: + # exists assert await Book.objects.filter(title="The Hobbit").exists() - # max: + # maximum assert 1990 == await Book.objects.max(columns=["year"]) - # min: + # minimum assert 1937 == await Book.objects.min(columns=["year"]) - # avg: + # average assert 1964.75 == await Book.objects.avg(columns=["year"]) - # sum: + # sum assert 7859 == await Book.objects.sum(columns=["year"]) # to read more about aggregated functions # visit: https://collerek.github.io/ormar/queries/aggregations/ - + +async def raw_data(): + # extract raw data in a form of dicts or tuples + # note that this skips the validation(!) as models are + # not created from parsed data + + # get list of objects as dicts + assert await Book.objects.values() == [ + {"id": 1, "author": 1, "title": "The Hobbit", "year": 1937}, + {"id": 2, "author": 1, "title": "The Lord of the Rings", "year": 1955}, + {"id": 4, "author": 2, "title": "The Witcher", "year": 1990}, + {"id": 5, "author": 1, "title": "The Silmarillion", "year": 1977}, + ] + + # get list of objects as tuples + assert await Book.objects.values_list() == [ + (1, 1, "The Hobbit", 1937), + (2, 1, "The Lord of the Rings", 1955), + (4, 2, "The Witcher", 1990), + (5, 1, "The Silmarillion", 1977), + ] + + # filter data - note how you always get a list + assert await Book.objects.filter(title="The Hobbit").values() == [ + {"id": 1, "author": 1, "title": "The Hobbit", "year": 1937} + ] + + # select only wanted fields + assert await Book.objects.filter(title="The Hobbit").values(["id", "title"]) == [ + {"id": 1, "title": "The Hobbit"} + ] + + # if you select only one column you could flatten it with values_list + assert await Book.objects.values_list("title", flatten=True) == [ + "The Hobbit", + "The Lord of the Rings", + "The Witcher", + "The Silmarillion", + ] + + # to read more about extracting raw values + # visit: https://collerek.github.io/ormar/queries/aggregations/ + + async def with_connect(function): # note that for any other backend than sqlite you actually need to # connect to the database to perform db operations @@ -477,15 +539,25 @@ async def with_connect(function): # in your endpoints but have a global connection pool # check https://collerek.github.io/ormar/fastapi/ and section with db connection + # gather and execute all functions # note - normally import should be at the beginning of the file import asyncio # note that normally you use gather() function to run several functions # concurrently but we actually modify the data and we rely on the order of functions -for func in [create, read, update, delete, joins, - filter_and_sort, subset_of_columns, - pagination, aggregations]: +for func in [ + create, + read, + update, + delete, + joins, + filter_and_sort, + subset_of_columns, + pagination, + aggregations, + raw_data, +]: print(f"Executing: {func.__name__}") asyncio.run(with_connect(func)) @@ -523,6 +595,8 @@ metadata.drop_all(engine) * `fields(columns: Union[List, str, set, dict]) -> QuerySet` * `exclude_fields(columns: Union[List, str, set, dict]) -> QuerySet` * `order_by(columns:Union[List, str]) -> QuerySet` +* `values(fields: Union[List, str, Set, Dict])` +* `values_list(fields: Union[List, str, Set, Dict])` #### Relation types @@ -584,6 +658,10 @@ Signals allow to trigger your function for a given event on a given Model. * `post_update` * `pre_delete` * `post_delete` + * `pre_relation_add` + * `post_relation_add` + * `pre_relation_remove` + * `post_relation_remove` [sqlalchemy-core]: https://docs.sqlalchemy.org/en/latest/core/ diff --git a/docs/index.md b/docs/index.md index fac2862..c215fe5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -79,7 +79,27 @@ Ormar is built with: As I write open-source code to solve everyday problems in my work or to promote and build strong python community you can say thank you and buy me a coffee or sponsor me with a monthly amount to help ensure my work remains free and maintained. - + +
+ +Sponsor +
+
### Migrating from `sqlalchemy` and existing databases @@ -176,6 +196,7 @@ class BaseMeta(ormar.ModelMeta): # id = ormar.Integer(primary_key=True) # <= notice no field types # name = ormar.String(max_length=100) + class Author(ormar.Model): class Meta(BaseMeta): tablename = "authors" @@ -210,15 +231,9 @@ async def create(): # Create some records to work with through QuerySet.create method. # Note that queryset is exposed on each Model's class as objects tolkien = await Author.objects.create(name="J.R.R. Tolkien") - await Book.objects.create(author=tolkien, - title="The Hobbit", - year=1937) - await Book.objects.create(author=tolkien, - title="The Lord of the Rings", - year=1955) - await Book.objects.create(author=tolkien, - title="The Silmarillion", - year=1977) + await Book.objects.create(author=tolkien, title="The Hobbit", year=1937) + await Book.objects.create(author=tolkien, title="The Lord of the Rings", year=1955) + await Book.objects.create(author=tolkien, title="The Silmarillion", year=1977) # alternative creation of object divided into 2 steps sapkowski = Author(name="Andrzej Sapkowski") @@ -317,27 +332,43 @@ async def delete(): # note that despite the fact that record no longer exists in database # the object above is still accessible and you can use it (and i.e. save()) again. tolkien = silmarillion.author - await Book.objects.create(author=tolkien, - title="The Silmarillion", - year=1977) + await Book.objects.create(author=tolkien, title="The Silmarillion", year=1977) async def joins(): # Tho join two models use select_related + + # Django style book = await Book.objects.select_related("author").get(title="The Hobbit") + # Python style + book = await Book.objects.select_related(Book.author).get( + Book.title == "The Hobbit" + ) + # now the author is already prefetched assert book.author.name == "J.R.R. Tolkien" # By default you also get a second side of the relation # constructed as lowercase source model name +'s' (books in this case) # you can also provide custom name with parameter related_name + + # Django style author = await Author.objects.select_related("books").all(name="J.R.R. Tolkien") + # Python style + author = await Author.objects.select_related(Author.books).all( + Author.name == "J.R.R. Tolkien" + ) assert len(author[0].books) == 3 # for reverse and many to many relations you can also prefetch_related # that executes a separate query for each of related models + # Django style author = await Author.objects.prefetch_related("books").get(name="J.R.R. Tolkien") + # Python style + author = await Author.objects.prefetch_related(Author.books).get( + Author.name == "J.R.R. Tolkien" + ) assert len(author.books) == 3 # to read more about relations @@ -371,11 +402,17 @@ async def filter_and_sort(): # to sort decreasing use hyphen before the field name # same as with filter you can use double underscores to access related fields # Django style - books = await Book.objects.filter(author__name__icontains="tolkien").order_by( - "-year").all() + books = ( + await Book.objects.filter(author__name__icontains="tolkien") + .order_by("-year") + .all() + ) # python style - books = await Book.objects.filter(Book.author.name.icontains("tolkien")).order_by( - Book.year.desc()).all() + books = ( + await Book.objects.filter(Book.author.name.icontains("tolkien")) + .order_by(Book.year.desc()) + .all() + ) assert len(books) == 3 assert books[0].title == "The Silmarillion" assert books[2].title == "The Hobbit" @@ -448,25 +485,68 @@ async def aggregations(): # count: assert 2 == await Author.objects.count() - # exists: + # exists assert await Book.objects.filter(title="The Hobbit").exists() - # max: + # maximum assert 1990 == await Book.objects.max(columns=["year"]) - # min: + # minimum assert 1937 == await Book.objects.min(columns=["year"]) - # avg: + # average assert 1964.75 == await Book.objects.avg(columns=["year"]) - # sum: + # sum assert 7859 == await Book.objects.sum(columns=["year"]) # to read more about aggregated functions # visit: https://collerek.github.io/ormar/queries/aggregations/ - + +async def raw_data(): + # extract raw data in a form of dicts or tuples + # note that this skips the validation(!) as models are + # not created from parsed data + + # get list of objects as dicts + assert await Book.objects.values() == [ + {"id": 1, "author": 1, "title": "The Hobbit", "year": 1937}, + {"id": 2, "author": 1, "title": "The Lord of the Rings", "year": 1955}, + {"id": 4, "author": 2, "title": "The Witcher", "year": 1990}, + {"id": 5, "author": 1, "title": "The Silmarillion", "year": 1977}, + ] + + # get list of objects as tuples + assert await Book.objects.values_list() == [ + (1, 1, "The Hobbit", 1937), + (2, 1, "The Lord of the Rings", 1955), + (4, 2, "The Witcher", 1990), + (5, 1, "The Silmarillion", 1977), + ] + + # filter data - note how you always get a list + assert await Book.objects.filter(title="The Hobbit").values() == [ + {"id": 1, "author": 1, "title": "The Hobbit", "year": 1937} + ] + + # select only wanted fields + assert await Book.objects.filter(title="The Hobbit").values(["id", "title"]) == [ + {"id": 1, "title": "The Hobbit"} + ] + + # if you select only one column you could flatten it with values_list + assert await Book.objects.values_list("title", flatten=True) == [ + "The Hobbit", + "The Lord of the Rings", + "The Witcher", + "The Silmarillion", + ] + + # to read more about extracting raw values + # visit: https://collerek.github.io/ormar/queries/aggregations/ + + async def with_connect(function): # note that for any other backend than sqlite you actually need to # connect to the database to perform db operations @@ -477,15 +557,25 @@ async def with_connect(function): # in your endpoints but have a global connection pool # check https://collerek.github.io/ormar/fastapi/ and section with db connection + # gather and execute all functions # note - normally import should be at the beginning of the file import asyncio # note that normally you use gather() function to run several functions # concurrently but we actually modify the data and we rely on the order of functions -for func in [create, read, update, delete, joins, - filter_and_sort, subset_of_columns, - pagination, aggregations]: +for func in [ + create, + read, + update, + delete, + joins, + filter_and_sort, + subset_of_columns, + pagination, + aggregations, + raw_data, +]: print(f"Executing: {func.__name__}") asyncio.run(with_connect(func)) @@ -523,6 +613,8 @@ metadata.drop_all(engine) * `fields(columns: Union[List, str, set, dict]) -> QuerySet` * `exclude_fields(columns: Union[List, str, set, dict]) -> QuerySet` * `order_by(columns:Union[List, str]) -> QuerySet` +* `values(fields: Union[List, str, Set, Dict])` +* `values_list(fields: Union[List, str, Set, Dict])` #### Relation types @@ -584,6 +676,10 @@ Signals allow to trigger your function for a given event on a given Model. * `post_update` * `pre_delete` * `post_delete` + * `pre_relation_add` + * `post_relation_add` + * `pre_relation_remove` + * `post_relation_remove` [sqlalchemy-core]: https://docs.sqlalchemy.org/en/latest/core/ diff --git a/docs/queries/joins-and-subqueries.md b/docs/queries/joins-and-subqueries.md index fc26518..cf7fef7 100644 --- a/docs/queries/joins-and-subqueries.md +++ b/docs/queries/joins-and-subqueries.md @@ -69,11 +69,16 @@ class Track(ormar.Model): ``` ```python +# Django style album = await Album.objects.select_related("tracks").all() + +# Python style +album = await Album.objects.select_related(Album.tracks).all() + # will return album will all columns tracks ``` -You can provide a string or a list of strings +You can provide a string or a list of strings (or a field/ list of fields) ```python class SchoolClass(ormar.Model): @@ -122,8 +127,14 @@ class Teacher(ormar.Model): ``` ```python +# Django style classes = await SchoolClass.objects.select_related( ["teachers__category", "students"]).all() + +# Python style +classes = await SchoolClass.objects.select_related( + [SchoolClass.teachers.category, SchoolClass.students]).all() + # will return classes with teachers and teachers categories # as well as classes students ``` @@ -276,7 +287,13 @@ class Track(ormar.Model): ``` ```python +# Django style album = await Album.objects.prefetch_related("tracks").all() + +# Python style +album = await Album.objects.prefetch_related(Album.tracks).all() + + # will return album will all columns tracks ``` @@ -329,8 +346,13 @@ class Teacher(ormar.Model): ``` ```python +# Django style classes = await SchoolClass.objects.prefetch_related( ["teachers__category", "students"]).all() + +# Python style +classes = await SchoolClass.objects.prefetch_related( + [SchoolClass.teachers.category, SchoolClass.students]).all() # will return classes with teachers and teachers categories # as well as classes students ``` diff --git a/docs/releases.md b/docs/releases.md index bdf2453..30185c2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,3 +1,23 @@ +# 0.10.13 + +## ✨ Features + +* Allow passing field accessors in `select_related` and `prefetch_related` aka. python style `select_related` [#225](https://github.com/collerek/ormar/issues/225). + * Previously: + ```python + await Post.objects.select_related(["author", "categories"]).get() + await Author.objects.prefetch_related("posts__categories").get() + ``` + * Now also: + ```python + await Post.objects.select_related([Post.author, Post.categories]).get() + await Author.objects.prefetch_related(Author.posts.categories).get() + ``` + +## 🐛 Fixes + +* Fix overwriting default value for inherited primary key [#253](https://github.com/collerek/ormar/issues/253) + # 0.10.12 ## 🐛 Fixes diff --git a/examples/script_from_readme.py b/examples/script_from_readme.py index cb1083d..95041c4 100644 --- a/examples/script_from_readme.py +++ b/examples/script_from_readme.py @@ -169,20 +169,37 @@ async def delete(): async def joins(): # Tho join two models use select_related + + # Django style book = await Book.objects.select_related("author").get(title="The Hobbit") + # Python style + book = await Book.objects.select_related(Book.author).get( + Book.title == "The Hobbit" + ) + # now the author is already prefetched assert book.author.name == "J.R.R. Tolkien" # By default you also get a second side of the relation # constructed as lowercase source model name +'s' (books in this case) # you can also provide custom name with parameter related_name + # Django style author = await Author.objects.select_related("books").all(name="J.R.R. Tolkien") + # Python style + author = await Author.objects.select_related(Author.books).all( + Author.name == "J.R.R. Tolkien" + ) assert len(author[0].books) == 3 # for reverse and many to many relations you can also prefetch_related # that executes a separate query for each of related models + # Django style author = await Author.objects.prefetch_related("books").get(name="J.R.R. Tolkien") + # Python style + author = await Author.objects.prefetch_related(Author.books).get( + Author.name == "J.R.R. Tolkien" + ) assert len(author.books) == 3 # to read more about relations @@ -302,13 +319,13 @@ async def aggregations(): # exists assert await Book.objects.filter(title="The Hobbit").exists() - # max + # maximum assert 1990 == await Book.objects.max(columns=["year"]) - # min + # minimum assert 1937 == await Book.objects.min(columns=["year"]) - # avg + # average assert 1964.75 == await Book.objects.avg(columns=["year"]) # sum @@ -318,6 +335,49 @@ async def aggregations(): # visit: https://collerek.github.io/ormar/queries/aggregations/ +async def raw_data(): + # extract raw data in a form of dicts or tuples + # note that this skips the validation(!) as models are + # not created from parsed data + + # get list of objects as dicts + assert await Book.objects.values() == [ + {"id": 1, "author": 1, "title": "The Hobbit", "year": 1937}, + {"id": 2, "author": 1, "title": "The Lord of the Rings", "year": 1955}, + {"id": 4, "author": 2, "title": "The Witcher", "year": 1990}, + {"id": 5, "author": 1, "title": "The Silmarillion", "year": 1977}, + ] + + # get list of objects as tuples + assert await Book.objects.values_list() == [ + (1, 1, "The Hobbit", 1937), + (2, 1, "The Lord of the Rings", 1955), + (4, 2, "The Witcher", 1990), + (5, 1, "The Silmarillion", 1977), + ] + + # filter data - note how you always get a list + assert await Book.objects.filter(title="The Hobbit").values() == [ + {"id": 1, "author": 1, "title": "The Hobbit", "year": 1937} + ] + + # select only wanted fields + assert await Book.objects.filter(title="The Hobbit").values(["id", "title"]) == [ + {"id": 1, "title": "The Hobbit"} + ] + + # if you select only one column you could flatten it with values_list + assert await Book.objects.values_list("title", flatten=True) == [ + "The Hobbit", + "The Lord of the Rings", + "The Witcher", + "The Silmarillion", + ] + + # to read more about extracting raw values + # visit: https://collerek.github.io/ormar/queries/aggregations/ + + async def with_connect(function): # note that for any other backend than sqlite you actually need to # connect to the database to perform db operations @@ -345,6 +405,7 @@ for func in [ subset_of_columns, pagination, aggregations, + raw_data, ]: print(f"Executing: {func.__name__}") asyncio.run(with_connect(func)) diff --git a/ormar/__init__.py b/ormar/__init__.py index ca414c8..8b86efb 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -76,7 +76,7 @@ class UndefinedType: # pragma no cover Undefined = UndefinedType() -__version__ = "0.10.12" +__version__ = "0.10.13" __all__ = [ "Integer", "BigInteger", diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index c0d752f..88f5681 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -584,7 +584,11 @@ class ModelMetaclass(pydantic.main.ModelMetaclass): register_relation_in_alias_manager(field=field) add_field_descriptor(name=name, field=field, new_model=new_model) - if new_model.Meta.pkname not in attrs["__annotations__"]: + if ( + new_model.Meta.pkname + and new_model.Meta.pkname not in attrs["__annotations__"] + and new_model.Meta.pkname not in new_model.__fields__ + ): field_name = new_model.Meta.pkname attrs["__annotations__"][field_name] = Optional[int] # type: ignore attrs[field_name] = None diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index ef4b434..5c93173 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -21,7 +21,7 @@ from sqlalchemy import bindparam import ormar # noqa I100 from ormar import MultipleMatches, NoMatch from ormar.exceptions import ModelPersistenceError, QueryDefinitionError -from ormar.queryset import FilterQuery, SelectAction +from ormar.queryset import FieldAccessor, FilterQuery, SelectAction from ormar.queryset.actions.order_action import OrderAction from ormar.queryset.clause import FilterGroup, QueryClause from ormar.queryset.prefetch_query import PrefetchQuery @@ -353,7 +353,7 @@ class QuerySet(Generic[T]): """ return self.filter(_exclude=True, *args, **kwargs) - def select_related(self, related: Union[List, str]) -> "QuerySet[T]": + def select_related(self, related: Union[List, str, FieldAccessor]) -> "QuerySet[T]": """ Allows to prefetch related models during the same query. @@ -372,6 +372,10 @@ class QuerySet(Generic[T]): """ if not isinstance(related, list): related = [related] + related = [ + rel._access_chain if isinstance(rel, FieldAccessor) else rel + for rel in related + ] related = sorted(list(set(list(self._select_related) + related))) return self.rebuild_self(select_related=related,) @@ -402,7 +406,9 @@ class QuerySet(Generic[T]): relations = self.model._iterate_related_models() return self.rebuild_self(select_related=relations,) - def prefetch_related(self, related: Union[List, str]) -> "QuerySet[T]": + def prefetch_related( + self, related: Union[List, str, FieldAccessor] + ) -> "QuerySet[T]": """ Allows to prefetch related models during query - but opposite to `select_related` each subsequent model is fetched in a separate database query. @@ -422,6 +428,10 @@ class QuerySet(Generic[T]): """ if not isinstance(related, list): related = [related] + related = [ + rel._access_chain if isinstance(rel, FieldAccessor) else rel + for rel in related + ] related = list(set(list(self._prefetch_related) + related)) return self.rebuild_self(prefetch_related=related,) diff --git a/tests/test_inheritance_and_pydantic_generation/test_inheritance_with_default.py b/tests/test_inheritance_and_pydantic_generation/test_inheritance_with_default.py new file mode 100644 index 0000000..1c61e41 --- /dev/null +++ b/tests/test_inheritance_and_pydantic_generation/test_inheritance_with_default.py @@ -0,0 +1,64 @@ +import datetime +import uuid + +import databases +import pytest +import sqlalchemy + +import ormar +from tests.settings import DATABASE_URL + +metadata = sqlalchemy.MetaData() +database = databases.Database(DATABASE_URL) + + +class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + +class BaseModel(ormar.Model): + class Meta(ormar.ModelMeta): + abstract = True + + id: uuid.UUID = ormar.UUID( + primary_key=True, default=uuid.uuid4, uuid_format="string" + ) + created_at: datetime.datetime = ormar.DateTime(default=datetime.datetime.utcnow()) + updated_at: datetime.datetime = ormar.DateTime(default=datetime.datetime.utcnow()) + + +class Member(BaseModel): + class Meta(BaseMeta): + tablename = "members" + + first_name: str = ormar.String(max_length=50) + last_name: str = ormar.String(max_length=50) + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.drop_all(engine) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +def test_model_structure(): + assert "id" in BaseModel.__fields__ + assert "id" in BaseModel.Meta.model_fields + assert BaseModel.Meta.model_fields["id"].has_default() + assert BaseModel.__fields__["id"].default_factory is not None + + assert "id" in Member.__fields__ + assert "id" in Member.Meta.model_fields + assert Member.Meta.model_fields["id"].has_default() + assert Member.__fields__["id"].default_factory is not None + + +@pytest.mark.asyncio +async def test_fields_inherited_with_default(): + async with database: + await Member(first_name="foo", last_name="bar").save() + await Member.objects.create(first_name="foo", last_name="bar") diff --git a/tests/test_relations/test_python_style_relations.py b/tests/test_relations/test_python_style_relations.py new file mode 100644 index 0000000..61824f2 --- /dev/null +++ b/tests/test_relations/test_python_style_relations.py @@ -0,0 +1,100 @@ +from typing import List, Optional + +import databases +import pytest +import sqlalchemy + +import ormar +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class Author(ormar.Model): + class Meta: + tablename = "authors" + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + first_name: str = ormar.String(max_length=80) + last_name: str = ormar.String(max_length=80) + + +class Category(ormar.Model): + class Meta: + tablename = "categories" + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=40) + + +class Post(ormar.Model): + class Meta: + tablename = "posts" + database = database + metadata = metadata + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + categories: Optional[List[Category]] = ormar.ManyToMany(Category) + author: Optional[Author] = ormar.ForeignKey(Author, related_name="author_posts") + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +@pytest.fixture(scope="function") +async def cleanup(): + yield + async with database: + PostCategory = Post.Meta.model_fields["categories"].through + await PostCategory.objects.delete(each=True) + await Post.objects.delete(each=True) + await Category.objects.delete(each=True) + await Author.objects.delete(each=True) + + +@pytest.mark.asyncio +async def test_selecting_related(cleanup): + async with database: + guido = await Author.objects.create(first_name="Guido", last_name="Van Rossum") + post = await Post.objects.create(title="Hello, M2M", author=guido) + news = await Category.objects.create(name="News") + recent = await Category.objects.create(name="Recent") + await post.categories.add(news) + await post.categories.add(recent) + assert len(await post.categories.all()) == 2 + # Loads categories and posts (2 queries) and perform the join in Python. + categories = await Category.objects.select_related(Category.posts).all() + assert len(categories) == 2 + assert categories[0].name == "News" + + news_posts = await news.posts.select_related(Post.author).all() + assert news_posts[0].author == guido + assert (await post.categories.limit(1).all())[0] == news + assert (await post.categories.offset(1).limit(1).all())[0] == recent + assert await post.categories.first() == news + assert await post.categories.exists() + + author = await Author.objects.prefetch_related( + Author.author_posts.categories + ).get() + assert len(author.author_posts) == 1 + assert author.author_posts[0].title == "Hello, M2M" + assert author.author_posts[0].categories[0].name == "News" + assert author.author_posts[0].categories[1].name == "Recent" + + post = await Post.objects.select_related([Post.author, Post.categories]).get() + assert len(post.categories) == 2 + assert post.categories[0].name == "News" + assert post.categories[1].name == "Recent" + assert post.author.first_name == "Guido"