diff --git a/docs/queries/filter-and-sort.md b/docs/queries/filter-and-sort.md index 530debcb0..2ba84b881 100644 --- a/docs/queries/filter-and-sort.md +++ b/docs/queries/filter-and-sort.md @@ -800,7 +800,12 @@ assert owner.toys[1].name == "Toy 1" So operations like `filter()`, `select_related()`, `limit()` and `offset()` etc. can be chained. - Something like `Track.object.select_related("album").filter(album__name="Malibu").offset(1).limit(1).all()` + Something like `Track.objects.select_related("album").filter(album__name="Malibu").offset(1).limit(1).all()` + +!!!note + You can use the parameter `nulls_ordering` to determine the behavior in dealing with `NULL` values. + + Something like `Owner.objects.order_by(Owner.toys.name.desc(nulls_ordering=ormar.NullsOrdering.LAST)).all()` ### Default sorting in ormar diff --git a/ormar/__init__.py b/ormar/__init__.py index 9259d1d46..16c32255a 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -75,7 +75,7 @@ ) # noqa: I100 from ormar.models import ExcludableItems, Extra, Model from ormar.models.metaclass import ModelMeta -from ormar.queryset import OrderAction, QuerySet, and_, or_ +from ormar.queryset import OrderAction, QuerySet, and_, or_, NullsOrdering from ormar.relations import RelationType from ormar.signals import Signal @@ -146,4 +146,5 @@ def __repr__(self) -> str: "DECODERS_MAP", "LargeBinary", "Extra", + "NullsOrdering", ] diff --git a/ormar/queryset/__init__.py b/ormar/queryset/__init__.py index 32fbee48e..beeb20111 100644 --- a/ormar/queryset/__init__.py +++ b/ormar/queryset/__init__.py @@ -2,7 +2,7 @@ Contains QuerySet and different Query classes to allow for constructing of sql queries. """ from ormar.queryset.actions import FilterAction, OrderAction, SelectAction -from ormar.queryset.clause import and_, or_ +from ormar.queryset.clause import and_, or_, NullsOrdering from ormar.queryset.field_accessor import FieldAccessor from ormar.queryset.queries import FilterQuery from ormar.queryset.queries import LimitQuery @@ -22,4 +22,5 @@ "and_", "or_", "FieldAccessor", + "NullsOrdering", ] diff --git a/ormar/queryset/actions/order_action.py b/ormar/queryset/actions/order_action.py index 7330d727f..b25e1d6c2 100644 --- a/ormar/queryset/actions/order_action.py +++ b/ormar/queryset/actions/order_action.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Type +from typing import Optional, TYPE_CHECKING, Type import sqlalchemy from sqlalchemy import text @@ -20,8 +20,13 @@ class OrderAction(QueryAction): """ def __init__( - self, order_str: str, model_cls: Type["Model"], alias: str = None + self, + order_str: str, + model_cls: Type["Model"], + alias: str = None, + nulls_ordering: Optional[str] = None, ) -> None: + self.direction: str = "" super().__init__(query_str=order_str, model_cls=model_cls) self.is_source_model_order = False @@ -30,10 +35,16 @@ def __init__( if self.source_model == self.target_model and "__" not in self.related_str: self.is_source_model_order = True + self.nulls_ordering = nulls_ordering + @property def field_alias(self) -> str: return self.target_model.get_column_alias(self.field_name) + @property + def is_mysql_bool(self) -> bool: + return self.target_model.Meta.database._backend._dialect.name == "mysql" + @property def is_postgres_bool(self) -> bool: dialect = self.target_model.Meta.database._backend._dialect.name @@ -78,6 +89,7 @@ def get_text_clause(self) -> sqlalchemy.sql.expression.TextClause: :return: complied and escaped clause :rtype: sqlalchemy.sql.elements.TextClause """ + prefix = f"{self.table_prefix}_" if self.table_prefix else "" table_name = self.table.name field_name = self.field_alias @@ -85,7 +97,11 @@ def get_text_clause(self) -> sqlalchemy.sql.expression.TextClause: dialect = self.target_model.Meta.database._backend._dialect table_name = dialect.identifier_preparer.quote(table_name) field_name = dialect.identifier_preparer.quote(field_name) - return text(f"{prefix}{table_name}" f".{field_name} {self.direction}") + + return text( + f"{prefix}{table_name}" + f".{self._get_field_name_direction_nulls(field_name=field_name)}" + ) def _split_value_into_parts(self, order_str: str) -> None: if order_str.startswith("-"): @@ -95,6 +111,40 @@ def _split_value_into_parts(self, order_str: str) -> None: self.field_name = parts[-1] self.related_parts = parts[:-1] + def _generate_field_nulls_query(self, field_name: str, result: str) -> str: + """ + Generate the Final Query with handling mysql syntax for nulls value + + :param field_name: string name of this field for order + :type field_name: str + :param result: query generated in previous stage without nulls value + :type result: str + :return: result of the final query by field name and direction and nulls value + :rtype: str + """ + + if not self.is_mysql_bool: + return result + f" nulls {self.nulls_ordering}" # pragma: no cover + + condition: str = "not" if self.nulls_ordering == "first" else "" # pragma: no cover + return f"{field_name} is {condition} null, {result}" # pragma: no cover + + def _get_field_name_direction_nulls(self, field_name: str) -> str: + """ + Generate the Query of Order for this field name by direction and nulls value + + :param field_name: string name of this field for order + :type field_name: str + :return: result of the query by field name and direction and nulls value + :rtype: str + """ + + result: str = f"{field_name} {self.direction}" + if self.nulls_ordering is not None: + return self._generate_field_nulls_query(field_name=field_name, result=result) + + return result + def check_if_filter_apply(self, target_model: Type["Model"], alias: str) -> bool: """ Checks filter conditions to find if they apply to current join. diff --git a/ormar/queryset/clause.py b/ormar/queryset/clause.py index 2a86d454c..d563e44b4 100644 --- a/ormar/queryset/clause.py +++ b/ormar/queryset/clause.py @@ -18,6 +18,13 @@ class FilterType(Enum): OR = 2 +class NullsOrdering(Enum): + """Nulls ordering options for the `.order_by()` queries.""" + + FIRST: str = "first" + LAST: str = "last" + + class FilterGroup: """ Filter groups are used in complex queries condition to group and and or diff --git a/ormar/queryset/field_accessor.py b/ormar/queryset/field_accessor.py index b27483771..94234287d 100644 --- a/ormar/queryset/field_accessor.py +++ b/ormar/queryset/field_accessor.py @@ -1,8 +1,8 @@ -from typing import Any, TYPE_CHECKING, Type, cast +from typing import Any, Optional, TYPE_CHECKING, Type, cast from ormar.queryset.actions import OrderAction from ormar.queryset.actions.filter_action import METHODS_TO_OPERATORS -from ormar.queryset.clause import FilterGroup +from ormar.queryset.clause import FilterGroup, NullsOrdering if TYPE_CHECKING: # pragma: no cover from ormar import BaseField, Model @@ -268,22 +268,40 @@ def isnull(self, other: Any) -> FilterGroup: """ return self._select_operator(op="isnull", other=other) - def asc(self) -> OrderAction: + def asc(self, nulls_ordering: Optional[NullsOrdering] = None) -> OrderAction: """ works as sql `column asc` + :param nulls_ordering: nulls ordering option first or last, defaults to None + :type nulls_ordering: Optional[NullsOrdering], optional + :raises ValueError: if nulls_ordering is not None or NullsOrdering Enum :return: OrderGroup for operator :rtype: ormar.queryset.actions.OrderGroup """ - return OrderAction(order_str=self._access_chain, model_cls=self._source_model) + if nulls_ordering is not None and not isinstance(nulls_ordering, NullsOrdering): + raise ValueError("Invalid option for ordering nulls values.") - def desc(self) -> OrderAction: + return OrderAction( + order_str=self._access_chain, + model_cls=self._source_model, + nulls_ordering=nulls_ordering.value if nulls_ordering is not None else None, + ) + + def desc(self, nulls_ordering: Optional[NullsOrdering] = None) -> OrderAction: """ works as sql `column desc` + :param nulls_ordering: nulls ordering option first or last, defaults to None + :type nulls_ordering: Optional[NullsOrdering], optional + :raises ValueError: if nulls_ordering is not None or NullsOrdering Enum :return: OrderGroup for operator :rtype: ormar.queryset.actions.OrderGroup """ + if nulls_ordering is not None and not isinstance(nulls_ordering, NullsOrdering): + raise ValueError("Invalid option for ordering nulls values.") + return OrderAction( - order_str="-" + self._access_chain, model_cls=self._source_model + order_str="-" + self._access_chain, + model_cls=self._source_model, + nulls_ordering=nulls_ordering.value if nulls_ordering is not None else None, ) diff --git a/tests/test_queries/test_order_by.py b/tests/test_queries/test_order_by.py index d32cf685c..aaf447f94 100644 --- a/tests/test_queries/test_order_by.py +++ b/tests/test_queries/test_order_by.py @@ -19,7 +19,7 @@ class Meta: id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=100) - sort_order: int = ormar.Integer() + sort_order: Optional[int] = ormar.Integer(nullable=True) class Owner(ormar.Model): @@ -30,6 +30,7 @@ class Meta: id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=100) + age: Optional[int] = ormar.Integer(nullable=True) class AliasNested(ormar.Model): @@ -163,6 +164,50 @@ async def test_sort_order_on_main_model(): assert songs[2].name == "Song 2" assert songs[3].name == "Song 3" + await Song.objects.create(name="Song 5") + + songs = await Song.objects.order_by( + Song.sort_order.asc(nulls_ordering=ormar.NullsOrdering.LAST) + ).all() + assert songs[0].name in ("Song 1", "Song 4") + assert songs[1].name in ("Song 1", "Song 4") + assert songs[2].name == "Song 2" + assert songs[3].name == "Song 3" + assert songs[4].name == "Song 5" + + songs = await Song.objects.order_by( + Song.sort_order.asc(nulls_ordering=ormar.NullsOrdering.FIRST) + ).all() + assert songs[0].name == "Song 5" + assert songs[1].name in ("Song 1", "Song 4") + assert songs[2].name in ("Song 1", "Song 4") + assert songs[3].name == "Song 2" + assert songs[4].name == "Song 3" + + songs = await Song.objects.order_by( + Song.sort_order.desc(nulls_ordering=ormar.NullsOrdering.LAST) + ).all() + assert songs[0].name == "Song 3" + assert songs[1].name == "Song 2" + assert songs[2].name in ("Song 1", "Song 4") + assert songs[3].name in ("Song 1", "Song 4") + assert songs[4].name == "Song 5" + + songs = await Song.objects.order_by( + Song.sort_order.desc(nulls_ordering=ormar.NullsOrdering.FIRST) + ).all() + assert songs[0].name == "Song 5" + assert songs[1].name == "Song 3" + assert songs[2].name == "Song 2" + assert songs[3].name in ("Song 1", "Song 4") + assert songs[4].name in ("Song 1", "Song 4") + + with pytest.raises(ValueError): + await Song.objects.order_by(Song.sort_order.asc(nulls_ordering=False)).all() + + with pytest.raises(ValueError): + await Song.objects.order_by(Song.sort_order.desc(nulls_ordering=True)).all() + @pytest.mark.asyncio async def test_sort_order_on_related_model(): @@ -249,6 +294,21 @@ async def test_sort_order_on_related_model(): assert toys[0].name == "Toy 2" assert toys[1].name == "Toy 3" + akbar = await Owner.objects.create(name="Akbar", age=22) + asqar = await Owner.objects.create(name="Asqar", age=18) + + await Toy.objects.create(name="Toy 8", owner=akbar) + await Toy.objects.create(name="Toy 9", owner=asqar) + + toys = ( + await Toy.objects.select_related("owner") + .order_by(Toy.owner.age.desc(nulls_ordering=ormar.NullsOrdering.LAST)) + .all() + ) + assert len(toys) == 9 + assert toys[0].name == "Toy 8" + assert toys[1].name == "Toy 9" + @pytest.mark.asyncio async def test_sort_order_on_many_to_many():