diff --git a/.codeclimate.yml b/.codeclimate.yml index e893c57..4872210 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -3,6 +3,9 @@ checks: method-complexity: config: threshold: 8 + argument-count: + config: + threshold: 6 file-lines: config: threshold: 500 diff --git a/README.md b/README.md index d4655e5..fd526bd 100644 --- a/README.md +++ b/README.md @@ -437,8 +437,8 @@ metadata.drop_all(engine) * `bulk_update(objects: List[Model], columns: List[str] = None) -> None` * `delete(each: bool = False, **kwargs) -> int` * `all(**kwargs) -> List[Optional[Model]]` -* `filter(**kwargs) -> QuerySet` -* `exclude(**kwargs) -> QuerySet` +* `filter(*args, **kwargs) -> QuerySet` +* `exclude(*args, **kwargs) -> QuerySet` * `select_related(related: Union[List, str]) -> QuerySet` * `prefetch_related(related: Union[List, str]) -> QuerySet` * `limit(limit_count: int) -> QuerySet` @@ -453,7 +453,7 @@ metadata.drop_all(engine) #### Relation types * One to many - with `ForeignKey(to: Model)` -* Many to many - with `ManyToMany(to: Model, through: Model)` +* Many to many - with `ManyToMany(to: Model, Optional[through]: Model)` #### Model fields types @@ -491,8 +491,8 @@ The following keyword arguments are supported on all field types. All fields are required unless one of the following is set: * `nullable` - Creates a nullable column. Sets the default to `None`. - * `default` - Set a default value for the field. - * `server_default` - Set a default value for the field on server side (like sqlalchemy's `func.now()`). + * `default` - Set a default value for the field. **Not available for relation fields** + * `server_default` - Set a default value for the field on server side (like sqlalchemy's `func.now()`). **Not available for relation fields** * `primary key` with `autoincrement` - When a column is set to primary key and autoincrement is set on this column. Autoincrement is set by default on int primary keys. * `pydantic_only` - Field is available only as normal pydantic field, not stored in the database. diff --git a/docs/index.md b/docs/index.md index d4655e5..fd526bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -437,8 +437,8 @@ metadata.drop_all(engine) * `bulk_update(objects: List[Model], columns: List[str] = None) -> None` * `delete(each: bool = False, **kwargs) -> int` * `all(**kwargs) -> List[Optional[Model]]` -* `filter(**kwargs) -> QuerySet` -* `exclude(**kwargs) -> QuerySet` +* `filter(*args, **kwargs) -> QuerySet` +* `exclude(*args, **kwargs) -> QuerySet` * `select_related(related: Union[List, str]) -> QuerySet` * `prefetch_related(related: Union[List, str]) -> QuerySet` * `limit(limit_count: int) -> QuerySet` @@ -453,7 +453,7 @@ metadata.drop_all(engine) #### Relation types * One to many - with `ForeignKey(to: Model)` -* Many to many - with `ManyToMany(to: Model, through: Model)` +* Many to many - with `ManyToMany(to: Model, Optional[through]: Model)` #### Model fields types @@ -491,8 +491,8 @@ The following keyword arguments are supported on all field types. All fields are required unless one of the following is set: * `nullable` - Creates a nullable column. Sets the default to `None`. - * `default` - Set a default value for the field. - * `server_default` - Set a default value for the field on server side (like sqlalchemy's `func.now()`). + * `default` - Set a default value for the field. **Not available for relation fields** + * `server_default` - Set a default value for the field on server side (like sqlalchemy's `func.now()`). **Not available for relation fields** * `primary key` with `autoincrement` - When a column is set to primary key and autoincrement is set on this column. Autoincrement is set by default on int primary keys. * `pydantic_only` - Field is available only as normal pydantic field, not stored in the database. diff --git a/docs/queries/filter-and-sort.md b/docs/queries/filter-and-sort.md index ed51f85..a2b74e9 100644 --- a/docs/queries/filter-and-sort.md +++ b/docs/queries/filter-and-sort.md @@ -288,6 +288,37 @@ books = ( ) ``` +If you want or need to you can nest deeper conditions as deep as you want, in example to +acheive a query like this: + +sql: +``` +WHERE ( ( ( books.year > 1960 OR books.year < 1940 ) +AND authors.name = 'J.R.R. Tolkien' ) OR +( books.year < 2000 AND authors.name = 'Andrzej Sapkowski' ) ) +``` + +You can construct a query as follows: +```python +books = ( + await Book.objects.select_related("author") + .filter( + ormar.or_( + ormar.and_( + ormar.or_(year__gt=1960, year__lt=1940), + author__name="J.R.R. Tolkien", + ), + ormar.and_(year__lt=2000, author__name="Andrzej Sapkowski"), + ) + ) + .all() +) +assert len(books) == 3 +assert books[0].title == "The Hobbit" +assert books[1].title == "The Silmarillion" +assert books[2].title == "The Witcher" +``` + By now you should already have an idea how `ormar.or_` and `ormar.and_` works. Of course, you could chain them in any other methods of queryset, so in example a perfectly valid query can look like follows: @@ -310,9 +341,48 @@ assert books[0].title == "The Witcher" !!!note Note that you cannot provide the same keyword argument several times so queries like `filter(ormar.or_(name='Jack', name='John'))` are not allowed. If you want to check the same column for several values simply use `in` operator: `filter(name__in=['Jack','John'])`. - Note that also that technically you can still do `filter(ormar.or_(name='Jack', name__exact='John'))` - but it's not recommended. The different operators can be used as long as they do not - repeat so `filter(ormar.or_(year__lt=1560, year__gt=2000))` is fine. + +If you pass only one parameter to `or_` or `and_` functions it's simply wrapped in parenthesis and +has no effect on actual query, so in the end all 3 queries are identical: + +```python +await Book.objects.filter(title='The Hobbit').get() +await Book.objects.filter(ormar.or_(title='The Hobbit')).get() +await Book.objects.filter(ormar.and_(title='The Hobbit')).get() +``` + +!!!note + Note that `or_` and `and_` queries will have `WHERE (title='The Hobbit')` but the parenthesis is redundant and has no real effect. + +This feature can be used if you **really** need to use the same field name twice. +Remember that you cannot pass the same keyword arguments twice to the function, so +how you can query in example `WHERE (authors.name LIKE '%tolkien%') OR (authors.name LIKE '%sapkowski%'))`? + +You cannot do: +```python +books = ( + await Book.objects.select_related("author") + .filter(ormar.or_( + author__name__icontains="tolkien", + author__name__icontains="sapkowski" # you cannot use same keyword twice in or_! + )) # python syntax error + .all() +) +``` + +But you can do this: + +```python +books = ( + await Book.objects.select_related("author") + .filter(ormar.or_( + ormar.and_(author__name__icontains="tolkien"), # one argument == just wrapped in () + ormar.and_(author__name__icontains="sapkowski") + )) + .all() +) +assert len(books) == 5 +``` ## get diff --git a/docs/relations/queryset-proxy.md b/docs/relations/queryset-proxy.md index db1343d..fc22d75 100644 --- a/docs/relations/queryset-proxy.md +++ b/docs/relations/queryset-proxy.md @@ -172,7 +172,7 @@ await post.categories.filter(name="Test category3").update( ### filter -`filter(**kwargs) -> QuerySet` +`filter(*args, **kwargs) -> QuerySet` Allows you to filter by any Model attribute/field as well as to fetch instances, with a filter across an FK relationship. @@ -181,7 +181,7 @@ Allows you to filter by any Model attribute/field as well as to fetch instances, ### exclude -`exclude(**kwargs) -> QuerySet` +`exclude(*args, **kwargs) -> QuerySet` Works exactly the same as filter and all modifiers (suffixes) are the same, but returns a not condition. diff --git a/docs/releases.md b/docs/releases.md index 0b06a1b..2e85e43 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -23,6 +23,9 @@ ``` Check the updated docs in Queries -> Filtering and sorting -> Complex filters +## Other +* Setting default on `ForeignKey` or `ManyToMany` raises and `ModelDefinition` exception as it is (and was) not supported + # 0.9.6 ##Important diff --git a/ormar/fields/foreign_key.py b/ormar/fields/foreign_key.py index 3c8d18b..7f1a500 100644 --- a/ormar/fields/foreign_key.py +++ b/ormar/fields/foreign_key.py @@ -11,7 +11,7 @@ from pydantic.typing import ForwardRef, evaluate_forwardref from sqlalchemy import UniqueConstraint import ormar # noqa I101 -from ormar.exceptions import RelationshipInstanceError +from ormar.exceptions import ModelDefinitionError, RelationshipInstanceError from ormar.fields.base import BaseField if TYPE_CHECKING: # pragma no cover @@ -184,6 +184,11 @@ def ForeignKey( # noqa CFQ002 owner = kwargs.pop("owner", None) self_reference = kwargs.pop("self_reference", False) + default = kwargs.pop("default", None) + if default is not None: + raise ModelDefinitionError( + "Argument 'default' is not supported " "on relation fields!" + ) if to.__class__ == ForwardRef: __type__ = to if not nullable else Optional[to] diff --git a/ormar/fields/many_to_many.py b/ormar/fields/many_to_many.py index ec8a6f1..db763e3 100644 --- a/ormar/fields/many_to_many.py +++ b/ormar/fields/many_to_many.py @@ -96,6 +96,12 @@ def ManyToMany( if through is not None and through.__class__ != ForwardRef: forbid_through_relations(cast(Type["Model"], through)) + default = kwargs.pop("default", None) + if default is not None: + raise ModelDefinitionError( + "Argument 'default' is not supported " "on relation fields!" + ) + if to.__class__ == ForwardRef: __type__ = to if not nullable else Optional[to] column_type = None diff --git a/ormar/relations/querysetproxy.py b/ormar/relations/querysetproxy.py index 69a0f54..d90776a 100644 --- a/ormar/relations/querysetproxy.py +++ b/ormar/relations/querysetproxy.py @@ -374,7 +374,7 @@ class QuerysetProxy: model = await self.queryset.get(pk=kwargs[pk_name]) return await model.update(**kwargs) - def filter(self, **kwargs: Any) -> "QuerysetProxy": # noqa: A003, A001 + def filter(self, *args: Any, **kwargs: Any) -> "QuerysetProxy": # noqa: A003, A001 """ Allows you to filter by any `Model` attribute/field as well as to fetch instances, with a filter across an FK relationship. @@ -404,10 +404,10 @@ class QuerysetProxy: :return: filtered QuerysetProxy :rtype: QuerysetProxy """ - queryset = self.queryset.filter(**kwargs) + queryset = self.queryset.filter(*args, **kwargs) return self.__class__(relation=self.relation, type_=self.type_, qryset=queryset) - def exclude(self, **kwargs: Any) -> "QuerysetProxy": # noqa: A003, A001 + def exclude(self, *args: Any, **kwargs: Any) -> "QuerysetProxy": # noqa: A003, A001 """ Works exactly the same as filter and all modifiers (suffixes) are the same, but returns a *not* condition. @@ -428,7 +428,7 @@ class QuerysetProxy: :return: filtered QuerysetProxy :rtype: QuerysetProxy """ - queryset = self.queryset.exclude(**kwargs) + queryset = self.queryset.exclude(*args, **kwargs) return self.__class__(relation=self.relation, type_=self.type_, qryset=queryset) def select_related(self, related: Union[List, str]) -> "QuerysetProxy": diff --git a/tests/test_or_filters.py b/tests/test_or_filters.py index 8568715..1a483cc 100644 --- a/tests/test_or_filters.py +++ b/tests/test_or_filters.py @@ -110,6 +110,24 @@ async def test_or_filters(): assert books[0].title == "The Silmarillion" assert books[1].title == "The Witcher" + books = ( + await Book.objects.select_related("author") + .filter( + ormar.or_( + ormar.and_( + ormar.or_(year__gt=1960, year__lt=1940), + author__name="J.R.R. Tolkien", + ), + ormar.and_(year__lt=2000, author__name="Andrzej Sapkowski"), + ) + ) + .all() + ) + assert len(books) == 3 + assert books[0].title == "The Hobbit" + assert books[1].title == "The Silmarillion" + assert books[2].title == "The Witcher" + books = ( await Book.objects.select_related("author") .exclude( @@ -187,6 +205,38 @@ async def test_or_filters(): with pytest.raises(QueryDefinitionError): await Book.objects.select_related("author").filter("wrong").all() + books = await tolkien.books.filter( + ormar.or_(year__lt=1940, year__gt=1960) + ).all() + assert len(books) == 2 + + books = await tolkien.books.filter( + ormar.and_( + ormar.or_(year__lt=1940, year__gt=1960), title__icontains="hobbit" + ) + ).all() + assert len(books) == 1 + assert tolkien.books[0].title == "The Hobbit" + + books = ( + await Book.objects.select_related("author") + .filter(ormar.or_(author__name="J.R.R. Tolkien")) + .all() + ) + assert len(books) == 3 + + books = ( + await Book.objects.select_related("author") + .filter( + ormar.or_( + ormar.and_(author__name__icontains="tolkien"), + ormar.and_(author__name__icontains="sapkowski"), + ) + ) + .all() + ) + assert len(books) == 5 + # TODO: Check / modify # process and and or into filter groups (V) @@ -196,5 +246,4 @@ async def test_or_filters(): # finish docstrings (V) # fix types for FilterAction and FilterGroup (X) # add docs (V) - -# fix querysetproxy +# fix querysetproxy (V) diff --git a/tests/test_relations_default_exception.py b/tests/test_relations_default_exception.py new file mode 100644 index 0000000..1573e13 --- /dev/null +++ b/tests/test_relations_default_exception.py @@ -0,0 +1,65 @@ +# type: ignore +from typing import List, Optional + +import databases +import pytest +import sqlalchemy + +import ormar +from ormar.exceptions import ModelDefinitionError +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) + + +def test_fk_error(): + with pytest.raises(ModelDefinitionError): + + 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, default="aa") + + +def test_m2m_error(): + with pytest.raises(ModelDefinitionError): + + 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, default="aa" + )