diff --git a/docs/releases.md b/docs/releases.md index 008c138..50f2315 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,7 +2,106 @@ ## ✨ Features -* Add possibility to `filter` and `order_by` with field access instead of dunder separated strings. +* Add possibility to `filter` and `order_by` with field access instead of dunder separated strings. [#51](https://github.com/collerek/ormar/issues/51) + * Accessing a field with attribute access (chain of dot notation) can be used to construct `FilterGroups` (`ormar.and_` and `ormar.or_`) + * Field access overloads set of python operators and provide a set of functions to allow same functionality as with dunder separated param names in `**kwargs`, that means that querying from sample model `Track` related to model `Album` now you have more options: + * exact - exact match to value, sql `column = ` + * OLD: `album__name__exact='Malibu'` + * NEW: can be also written as `Track.album.name == 'Malibu` + * iexact - exact match sql `column = ` (case insensitive) + * OLD: `album__name__iexact='malibu'` + * NEW: can be also written as `Track.album.name.iexact('malibu')` + * contains - sql `column LIKE '%%'` + * OLD: `album__name__contains='Mal'` + * NEW: can be also written as `Track.album.name % 'Mal')` + * NEW: can be also written as `Track.album.name.contains('Mal')` + * icontains - sql `column LIKE '%%'` (case insensitive) + * OLD: `album__name__icontains='mal'` + * NEW: can be also written as `Track.album.name.icontains('mal')` + * in - sql ` column IN (, , ...)` + * OLD: `album__name__in=['Malibu', 'Barclay']` + * NEW: can be also written as `Track.album.name << ['Malibu', 'Barclay']` + * NEW: can be also written as `Track.album.name.in_(['Malibu', 'Barclay'])` + * isnull - sql `column IS NULL` (and sql `column IS NOT NULL`) + * OLD: `album__name__isnull=True` (isnotnull `album__name__isnull=False`) + * NEW: can be also written as `Track.album.name >> None` + * NEW: can be also written as `Track.album.name.is_null(True)` + * NEW: not null can be also written as `Track.album.name.is_null(False)` + * NEW: not null can be also written as `~(Track.album.name >> None)` + * NEW: not null can be also written as `~(Track.album.name.is_null(True))` + * gt - sql `column > ` (greater than) + * OLD: `position__gt=3` + * NEW: can be also written as `Track.album.name > 3` + * gte - sql `column >= ` (greater or equal than) + * OLD: `position__gte=3` + * NEW: can be also written as `Track.album.name >= 3` + * lt - sql `column < ` (lower than) + * OLD: `position__lt=3` + * NEW: can be also written as `Track.album.name < 3` + * lte - sql `column <= ` (lower equal than) + * OLD: `position__lte=3` + * NEW: can be also written as `Track.album.name <= 3` + * startswith - sql `column LIKE '%'` (exact start match) + * OLD: `album__name__startswith='Mal'` + * NEW: can be also written as `Track.album.name.startswith('Mal')` + * istartswith - sql `column LIKE '%'` (case insensitive) + * OLD: `album__name__istartswith='mal'` + * NEW: can be also written as `Track.album.name.istartswith('mal')` + * endswith - sql `column LIKE '%'` (exact end match) + * OLD: `album__name__endswith='ibu'` + * NEW: can be also written as `Track.album.name.endswith('ibu')` + * iendswith - sql `column LIKE '%'` (case insensitive) + * OLD: `album__name__iendswith='IBU'` + * NEW: can be also written as `Track.album.name.iendswith('IBU')` +* You can provide `FilterGroups` not only in `filter()` and `exclude()` but also in: + * `get()` + * `get_or_none()` + * `get_or_create()` + * `first()` + * `all()` + * `delete()` +* With `FilterGroups` (`ormar.and_` and `ormar.or_`) you can now use: + * `&` - as `and_` instead of next level of nesting + * `|` - as `or_' instead of next level of nesting + * `~` - as negation of the filter group +* To combine groups of filters into one set of conditions use `&` (sql `AND`) and `|` (sql `OR`) + ```python + # Following queries are equivalent: + # sql: ( product.name = 'Test' AND product.rating >= 3.0 ) + + # ormar OPTION 1 - OLD one + Product.objects.filter(name='Test', rating__gte=3.0).get() + + # ormar OPTION 2 - OLD one + Product.objects.filter(ormar.and_(name='Test', rating__gte=3.0)).get() + + # ormar OPTION 3 - NEW one (field access) + Product.objects.filter((Product.name == 'Test') & (Product.rating >=3.0)).get() + ``` +* Same applies to nested complicated filters + ```python + # Following queries are equivalent: + # sql: ( product.name = 'Test' AND product.rating >= 3.0 ) + # OR (categories.name IN ('Toys', 'Books')) + + # ormar OPTION 1 - OLD one + Product.objects.filter(ormar.or_( + ormar.and_(name='Test', rating__gte=3.0), + categories__name__in=['Toys', 'Books']) + ).get() + + # ormar OPTION 2 - NEW one (instead of nested or use `|`) + Product.objects.filter( + ormar.and_(name='Test', rating__gte=3.0) | + ormar.and_(categories__name__in=['Toys', 'Books']) + ).get() + + # ormar OPTION 3 - NEW one (field access) + Product.objects.filter( + ((Product.name='Test') & (Product.rating >= 3.0)) | + (Product.categories.name << ['Toys', 'Books']) + ).get() + ``` # 0.10.3 diff --git a/ormar/queryset/clause.py b/ormar/queryset/clause.py index 504a2eb..18f2d1f 100644 --- a/ormar/queryset/clause.py +++ b/ormar/queryset/clause.py @@ -25,21 +25,34 @@ class FilterGroup: """ def __init__( - self, *args: Any, _filter_type: FilterType = FilterType.AND, **kwargs: Any, + self, *args: Any, + _filter_type: FilterType = FilterType.AND, + _exclude: bool = False, + **kwargs: Any, ) -> None: self.filter_type = _filter_type - self.exclude = False + self.exclude = _exclude self._nested_groups: List["FilterGroup"] = list(args) self._resolved = False self.is_source_model_filter = False self._kwargs_dict = kwargs self.actions: List[FilterAction] = [] + def __and__(self, other: "FilterGroup") -> "FilterGroup": + return FilterGroup(self, other) + + def __or__(self, other) -> "FilterGroup": + return FilterGroup(self, other, _filter_type=FilterType.OR) + + def __invert__(self) -> "FilterGroup": + self.exclude = not self.exclude + return self + def resolve( - self, - model_cls: Type["Model"], - select_related: List = None, - filter_clauses: List = None, + self, + model_cls: Type["Model"], + select_related: List = None, + filter_clauses: List = None, ) -> Tuple[List[FilterAction], List[str]]: """ Resolves the FilterGroups actions to use proper target model, replace @@ -107,13 +120,16 @@ class FilterGroup: :return: complied and escaped clause :rtype: sqlalchemy.sql.elements.TextClause """ + prefix = " NOT " if self.exclude else "" if self.filter_type == FilterType.AND: clause = sqlalchemy.text( - "( " + str(sqlalchemy.sql.and_(*self._get_text_clauses())) + " )" + f"{prefix}( " + str( + sqlalchemy.sql.and_(*self._get_text_clauses())) + " )" ) else: clause = sqlalchemy.text( - "( " + str(sqlalchemy.sql.or_(*self._get_text_clauses())) + " )" + f"{prefix}( " + str( + sqlalchemy.sql.or_(*self._get_text_clauses())) + " )" ) return clause @@ -166,7 +182,7 @@ class QueryClause: """ def __init__( - self, model_cls: Type["Model"], filter_clauses: List, select_related: List, + self, model_cls: Type["Model"], filter_clauses: List, select_related: List, ) -> None: self._select_related = select_related[:] @@ -176,7 +192,7 @@ class QueryClause: self.table = self.model_cls.Meta.table def prepare_filter( # noqa: A003 - self, _own_only: bool = False, **kwargs: Any + self, _own_only: bool = False, **kwargs: Any ) -> Tuple[List[FilterAction], List[str]]: """ Main external access point that processes the clauses into sqlalchemy text @@ -201,7 +217,7 @@ class QueryClause: return filter_clauses, select_related def _populate_filter_clauses( - self, _own_only: bool, **kwargs: Any + self, _own_only: bool, **kwargs: Any ) -> Tuple[List[FilterAction], List[str]]: """ Iterates all clauses and extracts used operator and field from related @@ -282,7 +298,7 @@ class QueryClause: return prefixes def _switch_filter_action_prefixes( - self, filter_clauses: List[FilterAction] + self, filter_clauses: List[FilterAction] ) -> List[FilterAction]: """ Substitutes aliases for filter action if the complex key (whole relation str) is diff --git a/ormar/queryset/field_accessor.py b/ormar/queryset/field_accessor.py index 02f15e6..f595947 100644 --- a/ormar/queryset/field_accessor.py +++ b/ormar/queryset/field_accessor.py @@ -1,8 +1,8 @@ from typing import Any from ormar.queryset.actions import OrderAction -from ormar.queryset.actions import FilterAction from ormar.queryset.actions.filter_action import METHODS_TO_OPERATORS +from ormar.queryset.clause import FilterGroup class FieldAccessor: @@ -14,6 +14,10 @@ class FieldAccessor: self._model = model self._access_chain = access_chain + def __bool__(self): + # hack to avoid pydantic name check from parent model + return False + def __getattr__(self, item: str) -> Any: if self._field and item == self._field.name: return self._field @@ -32,7 +36,7 @@ class FieldAccessor: field=field, access_chain=self._access_chain + f"__{item}", ) - return object.__getattribute__(self, item) + return object.__getattribute__(self, item) # pragma: no cover def _check_field(self) -> None: if not self._field: @@ -40,57 +44,60 @@ class FieldAccessor: "Cannot filter by Model, you need to provide model name" ) - def _select_operator(self, op: str, other: Any) -> FilterAction: + def _select_operator(self, op: str, other: Any) -> FilterGroup: self._check_field() - return FilterAction( - filter_str=self._access_chain + f"__{METHODS_TO_OPERATORS[op]}", - value=other, - model_cls=self._source_model, - ) + filter_kwg = {self._access_chain + f"__{METHODS_TO_OPERATORS[op]}": other} + return FilterGroup(**filter_kwg) - def __eq__(self, other: Any) -> FilterAction: # type: ignore + def __eq__(self, other: Any) -> FilterGroup: # type: ignore return self._select_operator(op="__eq__", other=other) - def __ge__(self, other: Any) -> FilterAction: + def __ge__(self, other: Any) -> FilterGroup: return self._select_operator(op="__ge__", other=other) - def __gt__(self, other: Any) -> FilterAction: + def __gt__(self, other: Any) -> FilterGroup: return self._select_operator(op="__gt__", other=other) - def __le__(self, other: Any) -> FilterAction: + def __le__(self, other: Any) -> FilterGroup: return self._select_operator(op="__le__", other=other) - def __lt__(self, other) -> FilterAction: + def __lt__(self, other) -> FilterGroup: return self._select_operator(op="__lt__", other=other) - def __mod__(self, other) -> FilterAction: + def __mod__(self, other) -> FilterGroup: return self._select_operator(op="__mod__", other=other) - def __contains__(self, item) -> FilterAction: - return self._select_operator(op="in", other=item) + def __lshift__(self, other) -> FilterGroup: + return self._select_operator(op="in", other=other) - def iexact(self, other) -> FilterAction: + def __rshift__(self, other) -> FilterGroup: + return self._select_operator(op="isnull", other=True) + + def in_(self, other) -> FilterGroup: + return self._select_operator(op="in", other=other) + + def iexact(self, other) -> FilterGroup: return self._select_operator(op="iexact", other=other) - def contains(self, other) -> FilterAction: + def contains(self, other) -> FilterGroup: return self._select_operator(op="contains", other=other) - def icontains(self, other) -> FilterAction: + def icontains(self, other) -> FilterGroup: return self._select_operator(op="icontains", other=other) - def startswith(self, other) -> FilterAction: + def startswith(self, other) -> FilterGroup: return self._select_operator(op="startswith", other=other) - def istartswith(self, other) -> FilterAction: + def istartswith(self, other) -> FilterGroup: return self._select_operator(op="istartswith", other=other) - def endswith(self, other) -> FilterAction: + def endswith(self, other) -> FilterGroup: return self._select_operator(op="endswith", other=other) - def iendswith(self, other) -> FilterAction: + def iendswith(self, other) -> FilterGroup: return self._select_operator(op="iendswith", other=other) - def isnull(self, other) -> FilterAction: + def isnull(self, other) -> FilterGroup: return self._select_operator(op="isnull", other=other) def asc(self) -> OrderAction: diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index c325a20..1eae1f6 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -504,7 +504,7 @@ class QuerySet(Generic[T]): """ return self.fields(columns=columns, _is_exclude=True) - def order_by(self, columns: Union[List, str]) -> "QuerySet[T]": + def order_by(self, columns: Union[List, str, OrderAction]) -> "QuerySet[T]": """ With `order_by()` you can order the results from database based on your choice of fields. @@ -541,6 +541,7 @@ class QuerySet(Generic[T]): orders_by = [ OrderAction(order_str=x, model_cls=self.model_cls) # type: ignore + if not isinstance(x, OrderAction) else x for x in columns ] @@ -671,7 +672,7 @@ class QuerySet(Generic[T]): ) return await self.database.execute(expr) - async def delete(self, each: bool = False, **kwargs: Any) -> int: + async def delete(self, *args, each: bool = False, **kwargs: Any) -> int: """ Deletes from the model table after applying the filters from kwargs. @@ -685,8 +686,8 @@ class QuerySet(Generic[T]): :return: number of deleted rows :rtype:int """ - if kwargs: - return await self.filter(**kwargs).delete() + if kwargs or args: + return await self.filter(*args, **kwargs).delete() if not each and not (self.filter_clauses or self.exclude_clauses): raise QueryDefinitionError( "You cannot delete without filtering the queryset first. " @@ -753,7 +754,7 @@ class QuerySet(Generic[T]): limit_raw_sql = self.limit_sql_raw if limit_raw_sql is None else limit_raw_sql return self.rebuild_self(offset=offset, limit_raw_sql=limit_raw_sql,) - async def first(self, **kwargs: Any) -> "T": + async def first(self, *args, **kwargs: Any) -> "T": """ Gets the first row from the db ordered by primary key column ascending. @@ -764,8 +765,8 @@ class QuerySet(Generic[T]): :return: returned model :rtype: Model """ - if kwargs: - return await self.filter(**kwargs).first() + if kwargs or args: + return await self.filter(*args, **kwargs).first() expr = self.build_select_expression( limit=1, @@ -784,7 +785,7 @@ class QuerySet(Generic[T]): self.check_single_result_rows_count(processed_rows) return processed_rows[0] # type: ignore - async def get_or_none(self, **kwargs: Any) -> Optional["T"]: + async def get_or_none(self, *args, **kwargs: Any) -> Optional["T"]: """ Get's the first row from the db meeting the criteria set by kwargs. @@ -800,11 +801,11 @@ class QuerySet(Generic[T]): :rtype: Model """ try: - return await self.get(**kwargs) + return await self.get(*args, **kwargs) except ormar.NoMatch: return None - async def get(self, **kwargs: Any) -> "T": + async def get(self, *args, **kwargs: Any) -> "T": """ Get's the first row from the db meeting the criteria set by kwargs. @@ -819,8 +820,8 @@ class QuerySet(Generic[T]): :return: returned model :rtype: Model """ - if kwargs: - return await self.filter(**kwargs).get() + if kwargs or args: + return await self.filter(*args, **kwargs).get() if not self.filter_clauses: expr = self.build_select_expression( @@ -843,7 +844,7 @@ class QuerySet(Generic[T]): self.check_single_result_rows_count(processed_rows) return processed_rows[0] # type: ignore - async def get_or_create(self, **kwargs: Any) -> "T": + async def get_or_create(self, *args, **kwargs: Any) -> "T": """ Combination of create and get methods. @@ -857,7 +858,7 @@ class QuerySet(Generic[T]): :rtype: Model """ try: - return await self.get(**kwargs) + return await self.get(*args, **kwargs) except NoMatch: return await self.create(**kwargs) @@ -878,7 +879,7 @@ class QuerySet(Generic[T]): model = await self.get(pk=kwargs[pk_name]) return await model.update(**kwargs) - async def all(self, **kwargs: Any) -> List[Optional["T"]]: # noqa: A003 + async def all(self, *args, **kwargs: Any) -> List[Optional["T"]]: # noqa: A003 """ Returns all rows from a database for given model for set filter options. @@ -891,8 +892,8 @@ class QuerySet(Generic[T]): :return: list of returned models :rtype: List[Model] """ - if kwargs: - return await self.filter(**kwargs).all() + if kwargs or args: + return await self.filter(*args, **kwargs).all() expr = self.build_select_expression() rows = await self.database.fetch_all(expr) diff --git a/ormar/relations/querysetproxy.py b/ormar/relations/querysetproxy.py index f60f263..6014e29 100644 --- a/ormar/relations/querysetproxy.py +++ b/ormar/relations/querysetproxy.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: # pragma no cover from ormar.relations import Relation from ormar.models import Model, T from ormar.queryset import QuerySet - from ormar import RelationType + from ormar import OrderAction, RelationType else: T = TypeVar("T", bound="Model") @@ -276,7 +276,7 @@ class QuerysetProxy(Generic[T]): ) return await queryset.delete(**kwargs) # type: ignore - async def first(self, **kwargs: Any) -> "T": + async def first(self, *args, **kwargs: Any) -> "T": """ Gets the first row from the db ordered by primary key column ascending. @@ -289,12 +289,12 @@ class QuerysetProxy(Generic[T]): :return: :rtype: _asyncio.Future """ - first = await self.queryset.first(**kwargs) + first = await self.queryset.first(*args, **kwargs) self._clean_items_on_load() self._register_related(first) return first - async def get_or_none(self, **kwargs: Any) -> Optional["T"]: + async def get_or_none(self, *args, **kwargs: Any) -> Optional["T"]: """ Get's the first row from the db meeting the criteria set by kwargs. @@ -310,7 +310,7 @@ class QuerysetProxy(Generic[T]): :rtype: Model """ try: - get = await self.queryset.get(**kwargs) + get = await self.queryset.get(*args, **kwargs) except ormar.NoMatch: return None @@ -318,7 +318,7 @@ class QuerysetProxy(Generic[T]): self._register_related(get) return get - async def get(self, **kwargs: Any) -> "T": + async def get(self, *args, **kwargs: Any) -> "T": """ Get's the first row from the db meeting the criteria set by kwargs. @@ -337,12 +337,12 @@ class QuerysetProxy(Generic[T]): :return: returned model :rtype: Model """ - get = await self.queryset.get(**kwargs) + get = await self.queryset.get(*args, **kwargs) self._clean_items_on_load() self._register_related(get) return get - async def all(self, **kwargs: Any) -> List[Optional["T"]]: # noqa: A003 + async def all(self, *args, **kwargs: Any) -> List[Optional["T"]]: # noqa: A003 """ Returns all rows from a database for given model for set filter options. @@ -359,7 +359,7 @@ class QuerysetProxy(Generic[T]): :return: list of returned models :rtype: List[Model] """ - all_items = await self.queryset.all(**kwargs) + all_items = await self.queryset.all(*args, **kwargs) self._clean_items_on_load() self._register_related(all_items) return all_items @@ -425,7 +425,7 @@ class QuerysetProxy(Generic[T]): ) return len(children) - async def get_or_create(self, **kwargs: Any) -> "T": + async def get_or_create(self, *args, **kwargs: Any) -> "T": """ Combination of create and get methods. @@ -439,7 +439,7 @@ class QuerysetProxy(Generic[T]): :rtype: Model """ try: - return await self.get(**kwargs) + return await self.get(*args, **kwargs) except ormar.NoMatch: return await self.create(**kwargs) @@ -739,7 +739,7 @@ class QuerysetProxy(Generic[T]): relation=self.relation, type_=self.type_, to=self.to, qryset=queryset ) - def order_by(self, columns: Union[List, str]) -> "QuerysetProxy[T]": + def order_by(self, columns: Union[List, str, "OrderAction"]) -> "QuerysetProxy[T]": """ With `order_by()` you can order the results from database based on your choice of fields. diff --git a/tests/test_fastapi/test_nested_saving.py b/tests/test_fastapi/test_nested_saving.py index c4d817b..9afcc33 100644 --- a/tests/test_fastapi/test_nested_saving.py +++ b/tests/test_fastapi/test_nested_saving.py @@ -1,5 +1,5 @@ import json -from typing import Optional +from typing import Any, Dict, Optional, Set, Type, Union, cast import databases import pytest @@ -8,6 +8,7 @@ from fastapi import FastAPI from starlette.testclient import TestClient import ormar +from ormar.queryset.utils import translate_list_to_dict from tests.settings import DATABASE_URL app = FastAPI() @@ -84,6 +85,24 @@ to_exclude_ormar = { } +def auto_exclude_id_field(to_exclude: Any) -> Union[Dict, Set]: + if isinstance(to_exclude, dict): + for key in to_exclude.keys(): + to_exclude[key] = auto_exclude_id_field(to_exclude[key]) + to_exclude["id"] = Ellipsis + return to_exclude + else: + return {"id"} + + +def generate_exclude_for_ids(model: Type[ormar.Model]) -> Dict: + to_exclude_base = translate_list_to_dict(model._iterate_related_models()) + return cast(Dict, auto_exclude_id_field(to_exclude=to_exclude_base)) + + +to_exclude_auto = generate_exclude_for_ids(model=Department) + + @app.post("/departments/", response_model=Department) async def create_department(department: Department): await department.save_related(follow=True, save_all=True) diff --git a/tests/test_inheritance/test_inheritance_concrete.py b/tests/test_inheritance/test_inheritance_concrete.py index ac059e4..4bbdf5b 100644 --- a/tests/test_inheritance/test_inheritance_concrete.py +++ b/tests/test_inheritance/test_inheritance_concrete.py @@ -173,7 +173,6 @@ def test_init_of_abstract_model(): def test_duplicated_related_name_on_different_model(): with pytest.raises(ModelDefinitionError): - class Bus3(Car2): # pragma: no cover class Meta: tablename = "buses3" @@ -203,7 +202,6 @@ def test_field_redefining_in_concrete_models(): def test_model_subclassing_that_redefines_constraints_column_names(): with pytest.raises(ModelDefinitionError): - class WrongField2(DateFieldsModel): # pragma: no cover class Meta(ormar.ModelMeta): tablename = "wrongs" @@ -216,7 +214,6 @@ def test_model_subclassing_that_redefines_constraints_column_names(): def test_model_subclassing_non_abstract_raises_error(): with pytest.raises(ModelDefinitionError): - class WrongField2(DateFieldsModelNoSubclass): # pragma: no cover class Meta(ormar.ModelMeta): tablename = "wrongs" @@ -234,7 +231,7 @@ def test_params_are_inherited(): def round_date_to_seconds( - date: datetime.datetime, + date: datetime.datetime, ) -> datetime.datetime: # pragma: no cover if date.microsecond >= 500000: date = date + datetime.timedelta(seconds=1) @@ -277,9 +274,9 @@ async def test_fields_inherited_from_mixin(): sub2 = ( await Subject.objects.select_related("category") - .order_by("-created_date") - .exclude_fields("updated_date") - .get() + .order_by("-created_date") + .exclude_fields("updated_date") + .get() ) assert round_date_to_seconds(sub2.created_date) == round_date_to_seconds( sub.created_date @@ -294,9 +291,9 @@ async def test_fields_inherited_from_mixin(): sub3 = ( await Subject.objects.prefetch_related("category") - .order_by("-created_date") - .exclude_fields({"updated_date": ..., "category": {"updated_date"}}) - .get() + .order_by("-created_date") + .exclude_fields({"updated_date": ..., "category": {"updated_date"}}) + .get() ) assert round_date_to_seconds(sub3.created_date) == round_date_to_seconds( sub.created_date @@ -349,8 +346,8 @@ async def test_inheritance_with_relation(): "coowned_buses": {"created_date"}, } ) - .prefetch_related(["coowned_trucks", "coowned_buses"]) - .get(name="Joe") + .prefetch_related(["coowned_trucks", "coowned_buses"]) + .get(name="Joe") ) assert joe_check.pk == joe.pk assert joe_check.coowned_trucks[0] == shelby @@ -397,8 +394,8 @@ async def test_inheritance_with_multi_relation(): unicorn = ( await Bus2.objects.select_related(["owner", "co_owners"]) - .order_by("-co_owners__name") - .get() + .order_by("-co_owners__name") + .get() ) assert unicorn.name == "Unicorn 2" assert unicorn.owner.name == "Sam" @@ -407,8 +404,8 @@ async def test_inheritance_with_multi_relation(): unicorn = ( await Bus2.objects.select_related(["owner", "co_owners"]) - .order_by("co_owners__name") - .get() + .order_by("co_owners__name") + .get() ) assert unicorn.name == "Unicorn 2" assert unicorn.owner.name == "Sam" @@ -431,8 +428,8 @@ async def test_inheritance_with_multi_relation(): "coowned_buses2": {"created_date"}, } ) - .prefetch_related(["coowned_trucks2", "coowned_buses2"]) - .get(name="Joe") + .prefetch_related(["coowned_trucks2", "coowned_buses2"]) + .get(name="Joe") ) assert joe_check.pk == joe.pk assert joe_check.coowned_trucks2[0] == shelby @@ -446,8 +443,8 @@ async def test_inheritance_with_multi_relation(): unicorn = ( await Bus2.objects.select_related(["owner", "co_owners"]) - .filter(co_owners__name="Joe") - .get() + .filter(co_owners__name="Joe") + .get() ) assert unicorn.name == "Unicorn 2" assert unicorn.owner.name == "Sam" @@ -457,8 +454,8 @@ async def test_inheritance_with_multi_relation(): unicorn = ( await Bus2.objects.select_related(["owner", "co_owners"]) - .exclude(co_owners__name="Joe") - .get() + .exclude(co_owners__name="Joe") + .get() ) assert unicorn.name == "Unicorn 2" assert unicorn.owner.name == "Sam" @@ -480,9 +477,9 @@ async def test_inheritance_with_multi_relation(): unicorns = ( await Bus2.objects.select_related(["owner", "co_owners"]) - .filter(name__contains="Unicorn") - .order_by("-name") - .all() + .filter(name__contains="Unicorn") + .order_by("-name") + .all() ) assert unicorns[0].name == "Unicorn 3" assert unicorns[0].owner.name == "Joe" @@ -496,10 +493,10 @@ async def test_inheritance_with_multi_relation(): unicorns = ( await Bus2.objects.select_related(["owner", "co_owners"]) - .filter(name__contains="Unicorn") - .order_by("-name") - .limit(2, limit_raw_sql=True) - .all() + .filter(name__contains="Unicorn") + .order_by("-name") + .limit(2, limit_raw_sql=True) + .all() ) assert len(unicorns) == 2 assert unicorns[1].name == "Unicorn 2" diff --git a/tests/test_model_definition/test_fields_access.py b/tests/test_model_definition/test_fields_access.py index 22d9941..8298242 100644 --- a/tests/test_model_definition/test_fields_access.py +++ b/tests/test_model_definition/test_fields_access.py @@ -54,6 +54,7 @@ def create_test_database(): def test_fields_access(): # basic access assert Product.id._field == Product.Meta.model_fields["id"] + assert Product.id.id == Product.Meta.model_fields["id"] assert isinstance(Product.id._field, BaseField) assert Product.id._access_chain == "id" assert Product.id._source_model == Product @@ -76,6 +77,9 @@ def test_fields_access(): assert curr_field._access_chain == "categories__products__rating" assert curr_field._source_model == PriceList + with pytest.raises(AttributeError): + assert Product.category >= 3 + @pytest.mark.parametrize( "method, expected, expected_value", @@ -86,38 +90,33 @@ def test_fields_access(): ("__ge__", "gte", "Test"), ("__gt__", "gt", "Test"), ("iexact", "iexact", "Test"), - ("contains", "contains", "%Test%"), - ("icontains", "icontains", "%Test%"), - ("startswith", "startswith", "Test%"), - ("istartswith", "istartswith", "Test%"), - ("endswith", "endswith", "%Test"), - ("iendswith", "iendswith", "%Test"), + ("contains", "contains", "Test"), + ("icontains", "icontains", "Test"), + ("startswith", "startswith", "Test"), + ("istartswith", "istartswith", "Test"), + ("endswith", "endswith", "Test"), + ("iendswith", "iendswith", "Test"), ("isnull", "isnull", "Test"), - ("__contains__", "in", "Test"), - ("__mod__", "contains", "%Test%"), + ("in_", "in", "Test"), + ("__lshift__", "in", "Test"), + ("__rshift__", "isnull", True), + ("__mod__", "contains", "Test"), ], ) def test_operator_return_proper_filter_action(method, expected, expected_value): - action = getattr(Product.name, method)("Test") - assert action.source_model == Product - assert action.target_model == Product - assert action.operator == expected - assert action.filter_value == expected_value + group_ = getattr(Product.name, method)("Test") + assert group_._kwargs_dict == {f"name__{expected}": expected_value} - action = getattr(Product.category.name, method)("Test") - assert action.source_model == Product - assert action.target_model == Category - assert action.operator == expected - assert action.filter_value == expected_value + group_ = getattr(Product.category.name, method)("Test") + assert group_._kwargs_dict == {f"category__name__{expected}": expected_value} - action = getattr(PriceList.categories.products.rating, method)("Test") - assert action.source_model == PriceList - assert action.target_model == Product - assert action.operator == expected - assert action.filter_value == expected_value + group_ = getattr(PriceList.categories.products.rating, method)("Test") + assert group_._kwargs_dict == { + f"categories__products__rating__{expected}": expected_value} -@pytest.mark.parametrize("method, expected_direction", [("asc", ""), ("desc", "desc"),]) +@pytest.mark.parametrize("method, expected_direction", + [("asc", ""), ("desc", "desc"), ]) def test_operator_return_proper_order_action(method, expected_direction): action = getattr(Product.name, method)() assert action.source_model == Product @@ -138,6 +137,45 @@ def test_operator_return_proper_order_action(method, expected_direction): assert not action.is_source_model_order +def test_combining_groups_together(): + group = (Product.name == "Test") & (Product.rating >= 3.0) + group.resolve(model_cls=Product) + assert len(group._nested_groups) == 2 + assert str(group.get_text_clause()) == ("( ( product.name = 'Test' ) AND" + " ( product.rating >= 3.0 ) )") + + group = ~((Product.name == "Test") & (Product.rating >= 3.0)) + group.resolve(model_cls=Product) + assert len(group._nested_groups) == 2 + assert str(group.get_text_clause()) == (" NOT ( ( product.name = 'Test' ) AND" + " ( product.rating >= 3.0 ) )") + + group = ((Product.name == "Test") & (Product.rating >= 3.0)) | ( + Product.category.name << (["Toys", "Books"])) + group.resolve(model_cls=Product) + assert len(group._nested_groups) == 2 + assert len(group._nested_groups[0]._nested_groups) == 2 + group_str = str(group.get_text_clause()) + category_prefix = group._nested_groups[1].actions[0].table_prefix + assert group_str == ( + "( ( ( product.name = 'Test' ) AND ( product.rating >= 3.0 ) ) " + f"OR ( {category_prefix}_categories.name IN ('Toys', 'Books') ) )") + + group = (Product.name % "Test") | ( + (Product.category.price_lists.name.startswith("Aa")) | ( + Product.category.name << (["Toys", "Books"]))) + group.resolve(model_cls=Product) + assert len(group._nested_groups) == 2 + assert len(group._nested_groups[1]._nested_groups) == 2 + group_str = str(group.get_text_clause()) + price_list_prefix = group._nested_groups[1]._nested_groups[0].actions[ + 0].table_prefix + category_prefix = group._nested_groups[1]._nested_groups[1].actions[0].table_prefix + assert group_str == ( + f"( ( product.name LIKE '%Test%' ) " + f"OR ( ( {price_list_prefix}_price_lists.name LIKE 'Aa%' ) " + f"OR ( {category_prefix}_categories.name IN ('Toys', 'Books') ) ) )") + # @pytest.mark.asyncio # async def test_filtering_by_field_access(): # async with database: @@ -156,10 +194,10 @@ def test_operator_return_proper_order_action(method, expected_direction): # TODO: Finish implementation # * overload operators and add missing functions that return FilterAction (V) # * return OrderAction for desc() and asc() (V) +# * create filter groups for & and | (and ~ - NOT?) (V) # * accept args in all functions that accept filters? or only filter and exclude? # all functions: delete, first, get, get_or_none, get_or_create, all, filter, exclude # and same from queryset, should they also accept filter groups? -# * create filter groups for & and | (and ~ - NOT?) # * accept OrderActions in order_by #