From 549a8698095245a7888c5a598054c6d4c663cad1 Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Thu, 5 Jan 2017 20:00:21 +0000 Subject: [PATCH 1/5] Add apply_sort functionality --- README.rst | 35 ++++++ sqlalchemy_filters/__init__.py | 3 +- sqlalchemy_filters/exceptions.py | 8 ++ sqlalchemy_filters/filters.py | 42 +------- sqlalchemy_filters/models.py | 42 ++++++++ sqlalchemy_filters/sorting.py | 69 +++++++++++- test/__init__.py | 5 + test/interface/test_filters.py | 41 ++----- test/interface/test_models.py | 33 ++++++ test/interface/test_pagination.py | 5 +- test/interface/test_sorting.py | 173 ++++++++++++++++++++++++++++++ 11 files changed, 372 insertions(+), 84 deletions(-) create mode 100644 sqlalchemy_filters/models.py create mode 100644 test/interface/test_models.py create mode 100644 test/interface/test_sorting.py diff --git a/README.rst b/README.rst index efb8cbc..8bceac8 100644 --- a/README.rst +++ b/README.rst @@ -51,6 +51,24 @@ Then we can apply filters to that ``query`` object (multiple times): result = filtered_query.all() +Sort +---- + +.. code-block:: python + + from sqlalchemy_filters import apply_sort + + # `query` should be a SQLAlchemy query object + + order_by = [ + {'field': 'name', 'direction': 'asc'}, + {'field': 'id', 'direction': 'desc'}, + ] + sorted_query = apply_sort(query, order_by) + + result = sorted_query.all() + + Pagination ---------- @@ -103,6 +121,23 @@ This is the list of operators that can be used: - ``in`` - ``not_in`` +Sort format +----------- + +Sort elements must be provided as dictionaries in a list and will be +applied sequentially: + +.. code-block:: python + + order_by = [ + {'field': 'name', 'direction': 'asc'}, + {'field': 'id', 'direction': 'desc'}, + # ... + ] + +Where ``field`` is the name of the field that will be sorted using the +provided ``direction``. + Running tests ------------- diff --git a/sqlalchemy_filters/__init__.py b/sqlalchemy_filters/__init__.py index d0890e1..10b3d7c 100644 --- a/sqlalchemy_filters/__init__.py +++ b/sqlalchemy_filters/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -from .filters import apply_filters, get_query_models # noqa: F401 +from .filters import apply_filters # noqa: F401 +from .models import get_query_models # noqa: F401 from .pagination import apply_pagination # noqa: F401 from .sorting import apply_sort # noqa: F401 diff --git a/sqlalchemy_filters/exceptions.py b/sqlalchemy_filters/exceptions.py index 3f93ed5..30d5fc2 100644 --- a/sqlalchemy_filters/exceptions.py +++ b/sqlalchemy_filters/exceptions.py @@ -5,6 +5,14 @@ class BadFilterFormat(Exception): pass +class BadSortFormat(Exception): + pass + + +class FieldNotFound(Exception): + pass + + class BadQuery(Exception): pass diff --git a/sqlalchemy_filters/filters.py b/sqlalchemy_filters/filters.py index 736f62a..7915abc 100644 --- a/sqlalchemy_filters/filters.py +++ b/sqlalchemy_filters/filters.py @@ -2,9 +2,8 @@ from inspect import signature -from sqlalchemy.inspection import inspect - from .exceptions import BadFilterFormat, BadQuery +from .models import Field, get_query_models class Operator(object): @@ -38,30 +37,6 @@ def __init__(self, operator): self.arity = len(signature(self.function).parameters) -class Field(object): - - def __init__(self, models, field_name): - # TODO: remove this check once we start supporing multiple models - if len(models) > 1: - raise BadQuery('The query should contain only one model.') - - self.model = self._get_model(models) - self.field_name = field_name - - def _get_model(self, models): - # TODO: add model_name argument once we start supporing multiple models - return [v for (k, v) in models.items()][0] # first (and only) model - - def get_sqlalchemy_field(self): - if self.field_name not in inspect(self.model).columns.keys(): - raise BadFilterFormat( - 'Model {} has no column `{}`.'.format( - self.model, self.field_name - ) - ) - return getattr(self.model, self.field_name) - - class Filter(object): def __init__(self, filter_, models): @@ -123,18 +98,3 @@ def apply_filters(query, filters): query = query.filter(*sqlalchemy_filters) return query - - -def get_query_models(query): - """Get models from query. - - :param query: - A :class:`sqlalchemy.orm.Query` instance. - - :returns: - A dictionary with all the models included in the query. - """ - return { - entity['type'].__name__: entity['type'] - for entity in query.column_descriptions - } diff --git a/sqlalchemy_filters/models.py b/sqlalchemy_filters/models.py new file mode 100644 index 0000000..34e314d --- /dev/null +++ b/sqlalchemy_filters/models.py @@ -0,0 +1,42 @@ +from sqlalchemy.inspection import inspect + +from .exceptions import FieldNotFound, BadQuery + + +class Field(object): + + def __init__(self, models, field_name): + # TODO: remove this check once we start supporing multiple models + if len(models) > 1: + raise BadQuery('The query should contain only one model.') + + self.model = self._get_model(models) + self.field_name = field_name + + def _get_model(self, models): + # TODO: add model_name argument once we start supporing multiple models + return [v for (k, v) in models.items()][0] # first (and only) model + + def get_sqlalchemy_field(self): + if self.field_name not in inspect(self.model).columns.keys(): + raise FieldNotFound( + 'Model {} has no column `{}`.'.format( + self.model, self.field_name + ) + ) + return getattr(self.model, self.field_name) + + +def get_query_models(query): + """Get models from query. + + :param query: + A :class:`sqlalchemy.orm.Query` instance. + + :returns: + A dictionary with all the models included in the query. + """ + return { + entity['type'].__name__: entity['type'] + for entity in query.column_descriptions + } diff --git a/sqlalchemy_filters/sorting.py b/sqlalchemy_filters/sorting.py index 28f32de..c0e16e1 100644 --- a/sqlalchemy_filters/sorting.py +++ b/sqlalchemy_filters/sorting.py @@ -1,6 +1,69 @@ # -*- coding: utf-8 -*- +from .exceptions import BadQuery, BadSortFormat +from .models import Field, get_query_models -def apply_sort(query, sort): # pragma: no cover - # TODO - raise NotImplemented() + +SORT_ASCENDING = 'asc' +SORT_DESCENDING = 'desc' + + +class Sort(object): + + def __init__(self, sort, models): + try: + field_name = sort['field'] + direction = sort['direction'] + except KeyError: + raise BadSortFormat( + '`field` and `direction` are mandatory attributes.' + ) + except TypeError: + raise BadSortFormat( + 'Sort `{}` should be a dictionary.'.format(sort) + ) + + if direction not in [SORT_ASCENDING, SORT_DESCENDING]: + raise BadSortFormat('Direction `{}` not valid.'.format(direction)) + + self.field = Field(models, field_name) + self.direction = direction + + def format_for_sqlalchemy(self): + field = self.field.get_sqlalchemy_field() + + if self.direction == SORT_ASCENDING: + return field.asc() + elif self.direction == SORT_DESCENDING: + return field.desc() + + +def apply_sort(query, order_by): + """Apply sorting to a :class:`sqlalchemy.orm.Query` instance. + + :param order_by: + A list of dictionaries, where each one of them includes + the necesary information to order the elements of the query. + + Example:: + + order_by = [ + {'field': 'name', 'direction': 'asc'}, + {'field': 'id', 'direction': 'desc'}, + ] + + :returns: + The :class:`sqlalchemy.orm.Query` instance after the provided + sorting has been applied. + """ + models = get_query_models(query) + if not models: + raise BadQuery('The query does not contain any models.') + + sqlalchemy_order_by = [ + Sort(sort, models).format_for_sqlalchemy() for sort in order_by + ] + if sqlalchemy_order_by: + query = query.order_by(*sqlalchemy_order_by) + + return query diff --git a/test/__init__.py b/test/__init__.py index e69de29..690678a 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + + +def error_value(exception): + return exception.value.args[0] diff --git a/test/interface/test_filters.py b/test/interface/test_filters.py index 49d45ac..1203d6a 100644 --- a/test/interface/test_filters.py +++ b/test/interface/test_filters.py @@ -3,42 +3,13 @@ import datetime import pytest -from sqlalchemy_filters import apply_filters, get_query_models -from sqlalchemy_filters.exceptions import BadFilterFormat, BadQuery +from sqlalchemy_filters import apply_filters +from sqlalchemy_filters.exceptions import ( + BadFilterFormat, FieldNotFound, BadQuery +) from test.models import Bar, Qux -class TestGetQueryModels(object): - - def test_query_with_no_models(self, session): - query = session.query() - - entities = get_query_models(query) - - assert {} == entities - - def test_query_with_one_model(self, session): - query = session.query(Bar) - - entities = get_query_models(query) - - assert {'Bar': Bar} == entities - - def test_query_with_multiple_models(self, session): - query = session.query(Bar, Qux) - - entities = get_query_models(query) - - assert {'Bar': Bar, 'Qux': Qux} == entities - - def test_query_with_duplicated_models(self, session): - query = session.query(Bar, Qux, Bar) - - entities = get_query_models(query) - - assert {'Bar': Bar, 'Qux': Qux} == entities - - class TestProvidedModels(object): def test_query_with_no_models(self, session): @@ -131,7 +102,7 @@ def test_invalid_field(self, session): query = session.query(Bar) filters = [{'field': 'invalid_field', 'op': '==', 'value': 'name_1'}] - with pytest.raises(BadFilterFormat) as err: + with pytest.raises(FieldNotFound) as err: apply_filters(query, filters) expected_error = ( @@ -147,7 +118,7 @@ def test_invalid_field_but_valid_model_attribute(self, session, attr_name): query = session.query(Bar) filters = [{'field': attr_name, 'op': '==', 'value': 'name_1'}] - with pytest.raises(BadFilterFormat) as err: + with pytest.raises(FieldNotFound) as err: apply_filters(query, filters) expected_error = ( diff --git a/test/interface/test_models.py b/test/interface/test_models.py new file mode 100644 index 0000000..f5da37d --- /dev/null +++ b/test/interface/test_models.py @@ -0,0 +1,33 @@ +from sqlalchemy_filters import get_query_models +from test.models import Bar, Qux + + +class TestGetQueryModels(object): + + def test_query_with_no_models(self, session): + query = session.query() + + entities = get_query_models(query) + + assert {} == entities + + def test_query_with_one_model(self, session): + query = session.query(Bar) + + entities = get_query_models(query) + + assert {'Bar': Bar} == entities + + def test_query_with_multiple_models(self, session): + query = session.query(Bar, Qux) + + entities = get_query_models(query) + + assert {'Bar': Bar, 'Qux': Qux} == entities + + def test_query_with_duplicated_models(self, session): + query = session.query(Bar, Qux, Bar) + + entities = get_query_models(query) + + assert {'Bar': Bar, 'Qux': Qux} == entities diff --git a/test/interface/test_pagination.py b/test/interface/test_pagination.py index aeaab41..3ac17b9 100644 --- a/test/interface/test_pagination.py +++ b/test/interface/test_pagination.py @@ -6,6 +6,7 @@ from sqlalchemy_filters import apply_pagination from sqlalchemy_filters.exceptions import InvalidPage +from test import error_value from test.models import Bar @@ -14,10 +15,6 @@ ) -def error_value(exception): - return exception.value.args[0] - - class TestPaginationFixtures(object): @pytest.fixture diff --git a/test/interface/test_sorting.py b/test/interface/test_sorting.py new file mode 100644 index 0000000..07e36e7 --- /dev/null +++ b/test/interface/test_sorting.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- + +import pytest + +from sqlalchemy_filters.exceptions import ( + BadQuery, BadSortFormat, FieldNotFound +) +from sqlalchemy_filters.sorting import apply_sort +from test import error_value +from test.models import Bar, Qux + + +class TestProvidedModels(object): + + def test_query_with_no_models(self, session): + query = session.query() + order_by = [{'field': 'name', 'direction': 'asc'}] + + with pytest.raises(BadQuery) as err: + apply_sort(query, order_by) + + assert 'The query does not contain any models.' == error_value(err) + + # TODO: replace this test once we support multiple models + def test_multiple_models(self, session): + query = session.query(Bar, Qux) + order_by = [{'field': 'name', 'direction': 'asc'}] + + with pytest.raises(BadQuery) as err: + apply_sort(query, order_by) + + expected_error = 'The query should contain only one model.' + assert expected_error == error_value(err) + + +class TestSortNotApplied(object): + + def test_no_sort_provided(self, session): + query = session.query(Bar) + order_by = [] + + filtered_query = apply_sort(query, order_by) + + assert query == filtered_query + + @pytest.mark.parametrize('sort', ['some text', 1, []]) + def test_wrong_sort_format(self, session, sort): + query = session.query(Bar) + order_by = [sort] + + with pytest.raises(BadSortFormat) as err: + apply_sort(query, order_by) + + expected_error = 'Sort `{}` should be a dictionary.'.format(sort) + assert expected_error == error_value(err) + + def test_field_not_provided(self, session): + query = session.query(Bar) + order_by = [{'direction': 'asc'}] + + with pytest.raises(BadSortFormat) as err: + apply_sort(query, order_by) + + expected_error = '`field` and `direction` are mandatory attributes.' + assert expected_error == error_value(err) + + def test_invalid_field(self, session): + query = session.query(Bar) + order_by = [{'field': 'invalid_field', 'direction': 'asc'}] + + with pytest.raises(FieldNotFound) as err: + apply_sort(query, order_by) + + expected_error = ( + "Model has no column `invalid_field`." + ) + assert expected_error == error_value(err) + + def test_direction_not_provided(self, session): + query = session.query(Bar) + order_by = [{'field': 'name'}] + + with pytest.raises(BadSortFormat) as err: + apply_sort(query, order_by) + + expected_error = '`field` and `direction` are mandatory attributes.' + assert expected_error == error_value(err) + + def test_invalid_direction(self, session): + query = session.query(Bar) + order_by = [{'field': 'name', 'direction': 'invalid_direction'}] + + with pytest.raises(BadSortFormat) as err: + apply_sort(query, order_by) + + expected_error = 'Direction `invalid_direction` not valid.' + assert expected_error == error_value(err) + + +class TestSortApplied(object): + + @pytest.fixture + def multiple_bars_inserted(self, session): + bar_1 = Bar(id=1, name='name_1', count=5) + bar_2 = Bar(id=2, name='name_2', count=10) + bar_3 = Bar(id=3, name='name_1', count=None) + bar_4 = Bar(id=4, name='name_4', count=12) + bar_5 = Bar(id=5, name='name_1', count=2) + bar_6 = Bar(id=6, name='name_4', count=15) + bar_7 = Bar(id=7, name='name_1', count=2) + bar_8 = Bar(id=8, name='name_5', count=1) + session.add_all( + [bar_1, bar_2, bar_3, bar_4, bar_5, bar_6, bar_7, bar_8] + ) + session.commit() + + @pytest.mark.usefixtures('multiple_bars_inserted') + def test_single_sort_field_asc(self, session): + query = session.query(Bar) + order_by = [{'field': 'name', 'direction': 'asc'}] + + sorted_query = apply_sort(query, order_by) + result = sorted_query.all() + + assert len(result) == 8 + assert result[0].id == 1 + assert result[1].id == 3 + assert result[2].id == 5 + assert result[3].id == 7 + assert result[4].id == 2 + assert result[5].id == 4 + assert result[6].id == 6 + assert result[7].id == 8 + + @pytest.mark.usefixtures('multiple_bars_inserted') + def test_single_sort_field_desc(self, session): + query = session.query(Bar) + order_by = [{'field': 'name', 'direction': 'desc'}] + + sorted_query = apply_sort(query, order_by) + result = sorted_query.all() + + assert len(result) == 8 + assert result[0].id == 8 + assert result[1].id == 4 + assert result[2].id == 6 + assert result[3].id == 2 + assert result[4].id == 1 + assert result[5].id == 3 + assert result[6].id == 5 + assert result[7].id == 7 + + @pytest.mark.usefixtures('multiple_bars_inserted') + def test_multiple_sort_fields(self, session): + query = session.query(Bar) + order_by = [ + {'field': 'name', 'direction': 'asc'}, + {'field': 'count', 'direction': 'desc'}, + {'field': 'id', 'direction': 'desc'}, + ] + + sorted_query = apply_sort(query, order_by) + result = sorted_query.all() + + assert len(result) == 8 + assert result[0].id == 1 + assert result[1].id == 7 + assert result[2].id == 5 + assert result[3].id == 3 + assert result[4].id == 2 + assert result[5].id == 6 + assert result[6].id == 4 + assert result[7].id == 8 From f0030cb6bb0901edebd1b4d0e47a0001e6cd3ae0 Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Fri, 6 Jan 2017 09:12:19 +0000 Subject: [PATCH 2/5] Test using multiple Python versions --- .travis.yml | 2 ++ setup.py | 1 + tox.ini | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 14e277a..a6b2ccb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,9 @@ install: - pip install tox env: + - TOX_ENV=py33 - TOX_ENV=py34 + - TOX_ENV=py35 script: - tox -e $TOX_ENV diff --git a/setup.py b/setup.py index 8c9d899..7386d68 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", "Intended Audience :: Developers", diff --git a/tox.ini b/tox.ini index 8853e3a..3e89328 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py34 +envlist = {py33,py34,py35} skipdist=True [testenv] From 1c8edd0b5c4ba9645ef0bc869e6ae6945fc07e62 Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Fri, 6 Jan 2017 09:22:00 +0000 Subject: [PATCH 3/5] Remove unsupported tox python versions --- .travis.yml | 1 - setup.py | 1 - tox.ini | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index a6b2ccb..6c23f65 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,6 @@ install: env: - TOX_ENV=py33 - TOX_ENV=py34 - - TOX_ENV=py35 script: - tox -e $TOX_ENV diff --git a/setup.py b/setup.py index 7386d68..8c9d899 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,6 @@ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", "Intended Audience :: Developers", diff --git a/tox.ini b/tox.ini index 3e89328..52d5a3f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = {py33,py34,py35} +envlist = {py33,py34} skipdist=True [testenv] From 7af5839bc086ea043142332f2fcadef690cba698 Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Fri, 6 Jan 2017 09:24:08 +0000 Subject: [PATCH 4/5] New library version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8c9d899..28865fd 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name='sqlalchemy-filters', - version='0.1.0', + version='0.2.0', description='A library to filter SQLAlchemy queries.', long_description=readme, author='Student.com', From 72d9e4ae59ce5f2b9d64953db7c3597aabede1c3 Mon Sep 17 00:00:00 2001 From: Julio Trigo Date: Fri, 6 Jan 2017 09:25:06 +0000 Subject: [PATCH 5/5] Add CHANGELOG --- CHANGELOG.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 CHANGELOG.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..0f625ac --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,26 @@ +Release Notes +============= + +Here you can see the full list of changes between sqlalchemy-filters +versions, where semantic versioning is used: *major.minor.patch*. + +Backwards-compatible changes increment the minor version number only. + +Version 0.2.0 +------------- + +Released 2017-01-06 + +* Adds apply query pagination +* Adds apply query sort +* Adds Travis CI +* Starts using Tox +* Refactors Makefile and conftest + +Version 0.1.0 +------------- + +Released 2016-09-08 + +* Initial version +* Adds apply query filters