Skip to content

Commit

Permalink
Merge pull request #30 from juliotrigo/nullsfirst_nullslast
Browse files Browse the repository at this point in the history
Support nullsfirst / nullslast
  • Loading branch information
juliotrigo authored Mar 11, 2019
2 parents 1b01291 + ff78fbc commit e9a012d
Show file tree
Hide file tree
Showing 4 changed files with 284 additions and 4 deletions.
46 changes: 44 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
SQLAlchemy-filters
SQLAlchemy filters
==================

.. pull-quote::
Expand All @@ -13,6 +13,9 @@ SQLAlchemy-filters
.. image:: https://img.shields.io/pypi/pyversions/sqlalchemy-filters.svg
:target: https://pypi.org/project/sqlalchemy-filters/

.. image:: https://img.shields.io/pypi/format/sqlalchemy-filters.svg
:target: https://pypi.org/project/sqlalchemy-filters/

.. image:: https://travis-ci.org/juliotrigo/sqlalchemy-filters.svg?branch=master
:target: https://travis-ci.org/juliotrigo/sqlalchemy-filters

Expand Down Expand Up @@ -345,6 +348,37 @@ provided ``direction``.
The ``model`` key is optional if the original query being sorted only
applies to one model.
nullsfirst / nullslast
^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: python
sort_spec = [
{'model': 'Baz', 'field': 'count', 'direction': 'asc', 'nullsfirst': True},
{'model': 'Qux', 'field': 'city', 'direction': 'desc', 'nullslast': True},
# ...
]
``nullsfirst`` is an optional attribute that will place ``NULL`` values first
if set to ``True``, according to the `SQLAlchemy documentation <https://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.expression.nullsfirst>`__.
``nullslast`` is an optional attribute that will place ``NULL`` values last
if set to ``True``, according to the `SQLAlchemy documentation <https://docs.sqlalchemy.org/en/latest/core/sqlelement.html#sqlalchemy.sql.expression.nullslast>`__.
If none of them are provided, then ``NULL`` values will be sorted according
to the RDBMS being used. SQL defines that ``NULL`` values should be placed
together when sorting, but it does not specify whether they should be placed
first or last.
Even though both ``nullsfirst`` and ``nullslast`` are part of SQLAlchemy,
they will raise an unexpected exception if the RDBMS that is being used does
not support them.
At the moment they are
`supported by PostgreSQL <https://www.postgresql.org/docs/current/queries-order.html>`_,
but they are **not** supported by SQLite and MySQL.
Running tests
-------------
Expand Down Expand Up @@ -413,7 +447,15 @@ There is no active support for python 2, however it is compatible as of
February 2019, if you install ``funcsigs``.
Changelog
---------
Consult the `CHANGELOG <https://github.com/juliotrigo/sqlalchemy-filters/blob/master/CHANGELOG.rst>`_
document for fixes and enhancements of each version.
License
-------
Apache 2.0. See LICENSE for details.
Apache 2.0. See `LICENSE <https://github.com/juliotrigo/sqlalchemy-filters/blob/master/LICENSE>`_
for details.
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
version='0.9.0',
description='A library to filter SQLAlchemy queries.',
long_description=readme,
long_description_content_type='text/x-rst',
author='Student.com',
author_email='[email protected]',
url='https://github.com/juliotrigo/sqlalchemy-filters',
Expand Down
25 changes: 23 additions & 2 deletions sqlalchemy_filters/sorting.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def __init__(self, sort_spec):

self.field_name = field_name
self.direction = direction
self.nullsfirst = sort_spec.get('nullsfirst')
self.nullslast = sort_spec.get('nullslast')

def get_named_models(self):
if "model" in self.sort_spec:
Expand All @@ -46,9 +48,16 @@ def format_for_sqlalchemy(self, query, default_model):
sqlalchemy_field = field.get_sqlalchemy_field()

if direction == SORT_ASCENDING:
return sqlalchemy_field.asc()
sort_fnc = sqlalchemy_field.asc
elif direction == SORT_DESCENDING:
return sqlalchemy_field.desc()
sort_fnc = sqlalchemy_field.desc

if self.nullsfirst:
return sort_fnc().nullsfirst()
elif self.nullslast:
return sort_fnc().nullslast()
else:
return sort_fnc()


def get_named_models(sorts):
Expand All @@ -70,6 +79,18 @@ def apply_sort(query, sort_spec):
sort_spec = [
{'model': 'Foo', 'field': 'name', 'direction': 'asc'},
{'model': 'Bar', 'field': 'id', 'direction': 'desc'},
{
'model': 'Qux',
'field': 'surname',
'direction': 'desc',
'nullslast': True,
},
{
'model': 'Baz',
'field': 'count',
'direction': 'asc',
'nullsfirst': True,
},
]
If the query being modified refers to a single model, the `model` key
Expand Down
216 changes: 216 additions & 0 deletions test/interface/test_sorting.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
from test.models import Foo, Bar, Qux


NULLSFIRST_NOT_SUPPORTED = (
"'nullsfirst' only supported by PostgreSQL in the current tests"
)
NULLSLAST_NOT_SUPPORTED = (
"'nullslast' only supported by PostgreSQL in the current tests"
)


@pytest.fixture
def multiple_foos_inserted(session):
foo_1 = Foo(id=1, bar_id=1, name='name_1', count=1)
Expand Down Expand Up @@ -39,6 +47,22 @@ def multiple_bars_with_no_nulls_inserted(session):
session.commit()


@pytest.fixture
def multiple_bars_with_nulls_inserted(session):
bar_1 = Bar(id=1, name='name_1', count=5)
bar_2 = Bar(id=2, name='name_2', count=20)
bar_3 = Bar(id=3, name='name_1', count=None)
bar_4 = Bar(id=4, name='name_4', count=10)
bar_5 = Bar(id=5, name='name_1', count=40)
bar_6 = Bar(id=6, name='name_4', count=None)
bar_7 = Bar(id=7, name='name_1', count=30)
bar_8 = Bar(id=8, name='name_5', count=50)
session.add_all(
[bar_1, bar_2, bar_3, bar_4, bar_5, bar_6, bar_7, bar_8]
)
session.commit()


class TestSortNotApplied(object):

def test_no_sort_provided(self, session):
Expand Down Expand Up @@ -355,3 +379,195 @@ def test_eager_load(self, session):
(1, 'name_2', 2),
(1, 'name_4', 4),
]


class TestSortNullsFirst(object):

"""Tests `nullsfirst`.
This is currently not supported by MySQL and SQLite. Only tested for
PostgreSQL.
"""

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_single_sort_field_asc_nulls_first(self, session, is_postgresql):
if not is_postgresql:
pytest.skip(NULLSFIRST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'count', 'direction': 'asc', 'nullsfirst': True}
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [result.count for result in results] == [
None, None, 5, 10, 20, 30, 40, 50,
]

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_single_sort_field_desc_nulls_first(self, session, is_postgresql):
if not is_postgresql:
pytest.skip(NULLSFIRST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'count', 'direction': 'desc', 'nullsfirst': True}
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [result.count for result in results] == [
None, None, 50, 40, 30, 20, 10, 5,
]

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_multiple_sort_fields_asc_nulls_first(
self, session, is_postgresql
):
if not is_postgresql:
pytest.skip(NULLSFIRST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'name', 'direction': 'asc'},
{'field': 'count', 'direction': 'asc', 'nullsfirst': True},
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [(result.name, result.count) for result in results] == [
('name_1', None),
('name_1', 5),
('name_1', 30),
('name_1', 40),
('name_2', 20),
('name_4', None),
('name_4', 10),
('name_5', 50),
]

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_multiple_sort_fields_desc_nulls_first(
self, session, is_postgresql
):
if not is_postgresql:
pytest.skip(NULLSFIRST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'name', 'direction': 'asc'},
{'field': 'count', 'direction': 'desc', 'nullsfirst': True},
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [(result.name, result.count) for result in results] == [
('name_1', None),
('name_1', 40),
('name_1', 30),
('name_1', 5),
('name_2', 20),
('name_4', None),
('name_4', 10),
('name_5', 50),
]


class TestSortNullsLast(object):

"""Tests `nullslast`.
This is currently not supported by MySQL and SQLite. Only tested for
PostgreSQL.
"""

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_single_sort_field_asc_nulls_last(self, session, is_postgresql):
if not is_postgresql:
pytest.skip(NULLSLAST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'count', 'direction': 'asc', 'nullslast': True}
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [result.count for result in results] == [
5, 10, 20, 30, 40, 50, None, None,
]

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_single_sort_field_desc_nulls_last(self, session, is_postgresql):
if not is_postgresql:
pytest.skip(NULLSLAST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'count', 'direction': 'desc', 'nullslast': True}
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [result.count for result in results] == [
50, 40, 30, 20, 10, 5, None, None,
]

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_multiple_sort_fields_asc_nulls_last(self, session, is_postgresql):
if not is_postgresql:
pytest.skip(NULLSLAST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'name', 'direction': 'asc'},
{'field': 'count', 'direction': 'asc', 'nullslast': True},
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [(result.name, result.count) for result in results] == [
('name_1', 5),
('name_1', 30),
('name_1', 40),
('name_1', None),
('name_2', 20),
('name_4', 10),
('name_4', None),
('name_5', 50),
]

@pytest.mark.usefixtures('multiple_bars_with_nulls_inserted')
def test_multiple_sort_fields_desc_nulls_last(
self, session, is_postgresql
):
if not is_postgresql:
pytest.skip(NULLSLAST_NOT_SUPPORTED)

query = session.query(Bar)
order_by = [
{'field': 'name', 'direction': 'asc'},
{'field': 'count', 'direction': 'desc', 'nullslast': True},
]

sorted_query = apply_sort(query, order_by)
results = sorted_query.all()

assert [(result.name, result.count) for result in results] == [
('name_1', 40),
('name_1', 30),
('name_1', 5),
('name_1', None),
('name_2', 20),
('name_4', 10),
('name_4', None),
('name_5', 50),
]

0 comments on commit e9a012d

Please sign in to comment.