From 0c808e2f5391c134a38705b5660770da10dbda62 Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Sun, 10 Mar 2019 12:19:18 +0000 Subject: [PATCH 1/3] README improvements --- README.rst | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 7f01159..285824c 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -SQLAlchemy-filters +SQLAlchemy filters ================== .. pull-quote:: @@ -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 @@ -413,7 +416,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 `_ +document for fixes and enhancements of each version. + + License ------- -Apache 2.0. See LICENSE for details. +Apache 2.0. See `LICENSE `_ +for details. From b4b3f917983dd0bff6881658599c1d3f4eb1fc28 Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Sun, 10 Mar 2019 12:33:43 +0000 Subject: [PATCH 2/3] Add long description content type to setup function --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f3b8142..6600c2b 100644 --- a/setup.py +++ b/setup.py @@ -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='wearehiring@student.com', url='https://github.com/juliotrigo/sqlalchemy-filters', From ff78fbc5eb41a9bc62bbcc7a2d850f3f97832e5f Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Sun, 10 Mar 2019 12:42:51 +0000 Subject: [PATCH 3/3] Add nullsfirst and nullslast logic --- README.rst | 31 +++++ sqlalchemy_filters/sorting.py | 25 +++- test/interface/test_sorting.py | 216 +++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 285824c..e700283 100644 --- a/README.rst +++ b/README.rst @@ -348,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 `__. + +``nullslast`` is an optional attribute that will place ``NULL`` values last +if set to ``True``, according to the `SQLAlchemy documentation `__. + +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 `_, +but they are **not** supported by SQLite and MySQL. + + Running tests ------------- diff --git a/sqlalchemy_filters/sorting.py b/sqlalchemy_filters/sorting.py index 41f97cb..012187f 100644 --- a/sqlalchemy_filters/sorting.py +++ b/sqlalchemy_filters/sorting.py @@ -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: @@ -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): @@ -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 diff --git a/test/interface/test_sorting.py b/test/interface/test_sorting.py index 3addf9d..3111fd0 100644 --- a/test/interface/test_sorting.py +++ b/test/interface/test_sorting.py @@ -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) @@ -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): @@ -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), + ]