Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nulls Last and Nulls First Parameters Sorting #769

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d4ad40f
feat: add optional parameter nulls in asc and desc
SepehrBazyar Jul 31, 2022
fcb2254
feat: set nulls value in get text clause
SepehrBazyar Jul 31, 2022
f3133d2
test: write some tests for nulls last and first
SepehrBazyar Jul 31, 2022
90e16c5
docs: write sample document nulls last bool flag
SepehrBazyar Jul 31, 2022
685c495
fix: temp debug for handle mysql
SepehrBazyar Jul 31, 2022
d6d4a65
fix: debug order of songs by sort odrer
SepehrBazyar Jul 31, 2022
cdb6468
fix: debug test result with write docstring types
SepehrBazyar Aug 1, 2022
945d165
fix: set the parenthese for tuple type
SepehrBazyar Aug 1, 2022
f89631e
fix: write new test and no cover for coverages
SepehrBazyar Aug 1, 2022
81e8c32
Merge branch 'master' into nulls_last_first
collerek Aug 2, 2022
cce4a62
Merge branch 'master' into nulls_last_first
collerek Aug 5, 2022
5730ae8
Merge branch 'master' into nulls_last_first
collerek Aug 24, 2022
324a165
feat: added nulls ordering enum for single option
SepehrBazyar Aug 26, 2022
5f9d231
refactor: replace nulls ordering with boolean flag
SepehrBazyar Aug 26, 2022
355b841
test: rewrite and update nulls ordering tests
SepehrBazyar Aug 26, 2022
c8d69d6
docs: update nulls ordering in documents
SepehrBazyar Aug 26, 2022
be783d1
fix: write new test to coverage value errors
SepehrBazyar Aug 26, 2022
2920100
refactor: rename function to generate nulls query
SepehrBazyar Sep 13, 2022
3e986ed
refactor: set prefix and table name generate query
SepehrBazyar Sep 13, 2022
a72579c
test: write tests to order by related model
SepehrBazyar Sep 13, 2022
bb02502
Revert "refactor: set prefix and table name generate query"
SepehrBazyar Sep 13, 2022
ee4328a
fix: use get_field_name_text function field name
SepehrBazyar Sep 13, 2022
d41131d
Revert "fix: use get_field_name_text function field name"
SepehrBazyar Sep 13, 2022
9690176
fix: debug number of toys created
SepehrBazyar Sep 13, 2022
93d520d
refactor: rename self.nulls_ordering variable
SepehrBazyar Mar 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/queries/filter-and-sort.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion ormar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -146,4 +146,5 @@ def __repr__(self) -> str:
"DECODERS_MAP",
"LargeBinary",
"Extra",
"NullsOrdering",
]
3 changes: 2 additions & 1 deletion ormar/queryset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,4 +22,5 @@
"and_",
"or_",
"FieldAccessor",
"NullsOrdering",
]
56 changes: 53 additions & 3 deletions ormar/queryset/actions/order_action.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Type
from typing import Optional, TYPE_CHECKING, Type

import sqlalchemy
from sqlalchemy import text
Expand All @@ -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
Expand All @@ -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 = nulls_ordering if nulls_ordering is not None else None
SepehrBazyar marked this conversation as resolved.
Show resolved Hide resolved

@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
Expand Down Expand Up @@ -78,14 +89,19 @@ 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
if not prefix:
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("-"):
Expand All @@ -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}" # pragma: no cover

condition: str = "not" if self.nulls == "first" else "" # pragma: no cover
return f"{field_name} is {condition} null, {result}" # pragma: no cover
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you split this condition into two the result now lacks prefix and will not work with nested models.

You would have in example {prefix}{table_name}.{field_name} is null, {field_name} {self.direction}.

The second field_name does not have the prefix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you split this condition into two the result now lacks prefix and will not work with nested models.

You would have in example {prefix}{table_name}.{field_name} is null, {field_name} {self.direction}.

The second field_name does not have the prefix.

Of course, I must mention that without this case, the test related to nested models will also pass correctly. Can you give an example of a possible error that may occur? Thanks

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you have a parent model with two relations to the child model. So like items with created_by and modified_by that lead to the same User model. Each of those relations in joins has to have different prefixes as otherwise, you would have ambiguous column names.


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 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.
Expand Down
7 changes: 7 additions & 0 deletions ormar/queryset/clause.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions ormar/queryset/field_accessor.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also be able to pass this param in order_by in queryset and querysetproxy for related models.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also be able to pass this param in order_by in queryset and querysetproxy for related models.

I do not understand this
How to determine this amount in order_by() and without asc() or desc()?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order by you can pass OrderActions or strings too. If you pass sort_order it's ascending, if you pass -sort_order it's descending. For nested models double underscore is required so like main_model_relation_field__child_model_field. It also supports putting a - before nested related model fields and then it's descending. The logic is starting in order_by where OrderActions are created from strings.

"""
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,
)
62 changes: 61 additions & 1 deletion tests/test_queries/test_order_by.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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")
SepehrBazyar marked this conversation as resolved.
Show resolved Hide resolved

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():
Expand Down Expand Up @@ -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():
Expand Down