add is null filter, add complex fiters (and_ & or_) and basic tests for them

This commit is contained in:
collerek
2021-03-06 13:07:22 +01:00
parent 7c0f8e976a
commit eeabb60200
14 changed files with 509 additions and 21 deletions

View File

@ -56,7 +56,7 @@ from ormar.fields import (
) # noqa: I100
from ormar.models import ExcludableItems, Model
from ormar.models.metaclass import ModelMeta
from ormar.queryset import OrderAction, QuerySet
from ormar.queryset import OrderAction, QuerySet, and_, or_
from ormar.relations import RelationType
from ormar.signals import Signal
@ -68,7 +68,7 @@ class UndefinedType: # pragma no cover
Undefined = UndefinedType()
__version__ = "0.9.6"
__version__ = "0.9.7"
__all__ = [
"Integer",
"BigInteger",
@ -108,4 +108,6 @@ __all__ = [
"ForeignKeyField",
"OrderAction",
"ExcludableItems",
"and_",
"or_",
]

View File

@ -1,4 +1,3 @@
import inspect
from typing import Any, List, Optional, TYPE_CHECKING, Type, Union
import sqlalchemy

View File

@ -75,7 +75,6 @@ def create_dummy_model(
fields = {f"{pk_field.name}": (pk_field.__type__, None)}
dummy_model = create_model( # type: ignore
f"PkOnly{base_model.get_name(lower=False)}{alias}",
__module__=base_model.__module__,
**fields, # type: ignore

View File

@ -2,6 +2,7 @@
Contains QuerySet and different Query classes to allow for constructing of sql queries.
"""
from ormar.queryset.actions import FilterAction, OrderAction
from ormar.queryset.clause import and_, or_
from ormar.queryset.filter_query import FilterQuery
from ormar.queryset.limit_query import LimitQuery
from ormar.queryset.offset_query import OffsetQuery
@ -16,4 +17,6 @@ __all__ = [
"OrderQuery",
"FilterAction",
"OrderAction",
"and_",
"or_",
]

View File

@ -19,6 +19,7 @@ FILTER_OPERATORS = {
"istartswith": "ilike",
"endswith": "like",
"iendswith": "ilike",
"isnull": "is_",
"in": "in_",
"gt": "__gt__",
"gte": "__ge__",
@ -38,7 +39,7 @@ class FilterAction(QueryAction):
Extracted in order to easily change table prefixes on complex relations.
"""
def __init__(self, filter_str: str, value: Any, model_cls: Type["Model"]) -> None:
def __init__(self, filter_str: str, value: Any, model_cls: Type["Model"],) -> None:
super().__init__(query_str=filter_str, model_cls=model_cls)
self.filter_value = value
self._escape_characters_in_clause()
@ -124,6 +125,9 @@ class FilterAction(QueryAction):
self.filter_value = self.filter_value.pk
op_attr = FILTER_OPERATORS[self.operator]
if self.operator == "isnull":
op_attr = "is_" if self.filter_value else "isnot"
self.filter_value = None
clause = getattr(self.column, op_attr)(self.filter_value)
clause = self._compile_clause(
clause, modifiers={"escape": "\\" if self.has_escaped_character else None},

View File

@ -1,6 +1,9 @@
import itertools
from dataclasses import dataclass
from typing import Any, List, TYPE_CHECKING, Tuple, Type
from enum import Enum
from typing import Any, Generator, List, TYPE_CHECKING, Tuple, Type
import sqlalchemy
import ormar # noqa I100
from ormar.queryset.actions.filter_action import FilterAction
@ -10,6 +13,99 @@ if TYPE_CHECKING: # pragma no cover
from ormar import Model
class FilterType(Enum):
AND = 1
OR = 2
class FilterGroup:
def __init__(
self, *args: Any, _filter_type: FilterType = FilterType.AND, **kwargs: Any,
) -> None:
self.filter_type = _filter_type
self.exclude = False
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 resolve(
self,
model_cls: Type["Model"],
select_related: List = None,
filter_clauses: List = None,
) -> Tuple[List[FilterAction], List[str]]:
select_related = select_related if select_related is not None else []
filter_clauses = filter_clauses if filter_clauses is not None else []
qryclause = QueryClause(
model_cls=model_cls,
select_related=select_related,
filter_clauses=filter_clauses,
)
own_filter_clauses, select_related = qryclause.prepare_filter(
_own_only=True, **self._kwargs_dict
)
self.actions = own_filter_clauses
filter_clauses = filter_clauses + own_filter_clauses
self._resolved = True
if self._nested_groups:
for group in self._nested_groups:
if not group._resolved:
(filter_clauses, select_related) = group.resolve(
model_cls=model_cls,
select_related=select_related,
filter_clauses=filter_clauses,
)
self._is_self_model_group()
return filter_clauses, select_related
def _iter(self) -> Generator:
if not self._nested_groups:
yield from self.actions
return
for group in self._nested_groups:
yield from group._iter()
yield from self.actions
def _is_self_model_group(self) -> None:
if self.actions and self._nested_groups:
if all([action.is_source_model_filter for action in self.actions]) and all(
group.is_source_model_filter for group in self._nested_groups
):
self.is_source_model_filter = True
elif self.actions:
if all([action.is_source_model_filter for action in self.actions]):
self.is_source_model_filter = True
else:
if all(group.is_source_model_filter for group in self._nested_groups):
self.is_source_model_filter = True
def _get_text_clauses(self) -> List[sqlalchemy.sql.expression.TextClause]:
return [x.get_text_clause() for x in self._nested_groups] + [
x.get_text_clause() for x in self.actions
]
def get_text_clause(self) -> sqlalchemy.sql.expression.TextClause:
if self.filter_type == FilterType.AND:
clause = sqlalchemy.text(
"( " + str(sqlalchemy.sql.and_(*self._get_text_clauses())) + " )"
)
else:
clause = sqlalchemy.text(
"( " + str(sqlalchemy.sql.or_(*self._get_text_clauses())) + " )"
)
return clause
def or_(*args: Any, **kwargs: Any) -> FilterGroup:
return FilterGroup(_filter_type=FilterType.OR, *args, **kwargs)
def and_(*args: Any, **kwargs: Any) -> FilterGroup:
return FilterGroup(_filter_type=FilterType.AND, *args, **kwargs)
@dataclass
class Prefix:
source_model: Type["Model"]
@ -40,13 +136,15 @@ class QueryClause:
self.table = self.model_cls.Meta.table
def prepare_filter( # noqa: A003
self, **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
clauses and updates select_related list with implicit related tables
mentioned in select_related strings but not included in select_related.
:param _own_only:
:type _own_only:
:param kwargs: key, value pair with column names and values
:type kwargs: Any
:return: Tuple with list of where clauses and updated select_related list
@ -56,12 +154,14 @@ class QueryClause:
pk_name = self.model_cls.get_column_alias(self.model_cls.Meta.pkname)
kwargs[pk_name] = kwargs.pop("pk")
filter_clauses, select_related = self._populate_filter_clauses(**kwargs)
filter_clauses, select_related = self._populate_filter_clauses(
_own_only=_own_only, **kwargs
)
return filter_clauses, select_related
def _populate_filter_clauses(
self, **kwargs: Any
self, _own_only: bool, **kwargs: Any
) -> Tuple[List[FilterAction], List[str]]:
"""
Iterates all clauses and extracts used operator and field from related
@ -74,6 +174,7 @@ class QueryClause:
:rtype: Tuple[List[sqlalchemy.sql.elements.TextClause], List[str]]
"""
filter_clauses = self.filter_clauses
own_filter_clauses = []
select_related = list(self._select_related)
for key, value in kwargs.items():
@ -84,12 +185,14 @@ class QueryClause:
select_related=select_related
)
filter_clauses.append(filter_action)
own_filter_clauses.append(filter_action)
self._register_complex_duplicates(select_related)
filter_clauses = self._switch_filter_action_prefixes(
filter_clauses=filter_clauses
filter_clauses=filter_clauses + own_filter_clauses
)
if _own_only:
return own_filter_clauses, select_related
return filter_clauses, select_related
def _register_complex_duplicates(self, select_related: List[str]) -> None:
@ -150,11 +253,17 @@ class QueryClause:
:return: list of actions with aliases changed if needed
:rtype: List[FilterAction]
"""
manager = self.model_cls.Meta.alias_manager
for action in filter_clauses:
new_alias = manager.resolve_relation_alias(
self.model_cls, action.related_str
)
if "__" in action.related_str and new_alias:
action.table_prefix = new_alias
if isinstance(action, FilterGroup):
for action2 in action._iter():
self._verify_prefix_and_switch(action2)
else:
self._verify_prefix_and_switch(action)
return filter_clauses
def _verify_prefix_and_switch(self, action: "FilterAction") -> None:
manager = self.model_cls.Meta.alias_manager
new_alias = manager.resolve_relation_alias(self.model_cls, action.related_str)
if "__" in action.related_str and new_alias:
action.table_prefix = new_alias

View File

@ -266,7 +266,7 @@ class PrefetchQuery:
model_cls=clause_target, select_related=[], filter_clauses=[],
)
kwargs = {f"{filter_column}__in": ids}
filter_clauses, _ = qryclause.prepare_filter(**kwargs)
filter_clauses, _ = qryclause.prepare_filter(_own_only=False, **kwargs)
return filter_clauses
return []

View File

@ -256,7 +256,9 @@ class QuerySet:
# print("\n", exp.compile(compile_kwargs={"literal_binds": True}))
return exp
def filter(self, _exclude: bool = False, **kwargs: Any) -> "QuerySet": # noqa: A003
def filter( # noqa: A003
self, *args: Any, _exclude: bool = False, **kwargs: Any
) -> "QuerySet":
"""
Allows you to filter by any `Model` attribute/field
as well as to fetch instances, with a filter across an FK relationship.
@ -268,6 +270,8 @@ class QuerySet:
* contains - like `album__name__contains='Mal'` (sql like)
* icontains - like `album__name__icontains='mal'` (sql like case insensitive)
* in - like `album__name__in=['Malibu', 'Barclay']` (sql in)
* isnull - like `album__name__isnull=True` (sql is null)
(isnotnull `album__name__isnull=False` (sql is not null))
* gt - like `position__gt=3` (sql >)
* gte - like `position__gte=3` (sql >=)
* lt - like `position__lt=3` (sql <)
@ -284,12 +288,23 @@ class QuerySet:
:return: filtered QuerySet
:rtype: QuerySet
"""
filter_groups = []
if args:
for arg in args:
arg.resolve(
model_cls=self.model,
select_related=self._select_related,
filter_clauses=self.filter_clauses,
)
filter_groups.append(arg)
qryclause = QueryClause(
model_cls=self.model,
select_related=self._select_related,
filter_clauses=self.filter_clauses,
)
filter_clauses, select_related = qryclause.prepare_filter(**kwargs)
filter_clauses = filter_clauses + filter_groups
if _exclude:
exclude_clauses = filter_clauses
filter_clauses = self.filter_clauses
@ -303,7 +318,7 @@ class QuerySet:
select_related=select_related,
)
def exclude(self, **kwargs: Any) -> "QuerySet": # noqa: A003
def exclude(self, *args: Any, **kwargs: Any) -> "QuerySet": # noqa: A003
"""
Works exactly the same as filter and all modifiers (suffixes) are the same,
but returns a *not* condition.
@ -322,7 +337,7 @@ class QuerySet:
:return: filtered QuerySet
:rtype: QuerySet
"""
return self.filter(_exclude=True, **kwargs)
return self.filter(_exclude=True, *args, **kwargs)
def select_related(self, related: Union[List, str]) -> "QuerySet":
"""

View File

@ -386,6 +386,8 @@ class QuerysetProxy:
* contains - like `album__name__contains='Mal'` (sql like)
* icontains - like `album__name__icontains='mal'` (sql like case insensitive)
* in - like `album__name__in=['Malibu', 'Barclay']` (sql in)
* isnull - like `album__name__isnull=True` (sql is null)
(isnotnull `album__name__isnull=False` (sql is not null))
* gt - like `position__gt=3` (sql >)
* gte - like `position__gte=3` (sql >=)
* lt - like `position__lt=3` (sql <)