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

Order by #11

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 9 additions & 10 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
language: python
python:
- "2.7"
- "3.4"
install:
- "pip install -r requirements.txt --use-mirrors"
- "pip install pytest-cov"
- "pip install coverage"
- "pip install coveralls"
script:
- "coverage run --source=sandman2 setup.py test"
- 2.7
- 3.4
sudo: false
install:
- travis_retry python setup.py develop
- travis_retry pip install pytest-cov coverage coveralls
script:
- coverage run --source=sandman2 setup.py test
after_success:
coveralls
coveralls
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ Flask==0.10.1
Flask-Admin==1.0.8
Flask-SQLAlchemy==1.0
SQLAlchemy==0.9.7
WebTest>=2.0.18
WTForms==2.0.1
coverage==3.7.1
pytest==2.6.3
pytest-cov==1.8.0
pytest-flask==0.4.0
python-dateutil>=2.4.2
six=>1.9.0
114 changes: 114 additions & 0 deletions sandman2/operators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-

from __future__ import absolute_import

import datetime

import six
import sqlalchemy as sa
from flask import current_app
from dateutil.parser import parse as parse_date

from sandman2 import utils
from sandman2 import exception

converters = {
datetime.datetime: lambda column, value: parse_date(value),
datetime.date: lambda column, value: parse_date(value).date,
}
def default_converter(column, value):
column_type = utils.column_type(column)
return column_type(value)

class Operator(object):

def __call__(self, column, values):
self.validate(column, values)
converted = [self.convert(column, value) for value in values]
return self.filter(column, converted)

def convert(self, column, value):
converter = converters.get(utils.column_type(column), default_converter)
try:
return converter(column, value)
except Exception as error:
raise exception.BadRequestException('Invalid value "{0}" on field "{1}"'.format(value, column.key))

def validate(self, column, values):
pass

def filter(self, column, values):
pass

class Equal(Operator):

def filter(self, column, values):
if current_app.config.get('CASE_INSENSITIVE') and issubclass(utils.column_type(column), six.string_types):
return sa.func.upper(column).in_([value.upper() for value in values])
return column.in_(values)

class NotEqual(Operator):

def filter(self, column, values):
if current_app.config.get('CASE_INSENSITIVE') and issubclass(utils.column_type(column), six.string_types):
return ~sa.func.upper(column).in_([value.upper() for value in values])
return ~column.in_(values)

class Like(Operator):

def validate(self, column, values):
if not issubclass(utils.column_type(column), six.string_types):
raise exception.BadRequestException('Invalid operator "like" on field "{0}"'.format(column.key))

def filter(self, column, values):
attr = 'ilike' if current_app.config.get('CASE_INSENSITIVE') else 'like'
method = getattr(column, attr)
return sa.and_(*[method(value) for value in values])

class GreaterThan(Operator):

def filter(self, column, values):
return column > max(values)

class GreaterEqual(Operator):

def filter(self, column, values):
return column >= max(values)

class LessThan(Operator):

def filter(self, column, values):
return column < min(values)

class LessEqual(Operator):

def filter(self, column, values):
return column < min(values)

operators = {
'eq': Equal(),
'ne': NotEqual(),
'gt': GreaterThan(),
'gte': GreaterEqual(),
'lt': LessThan(),
'lte': LessEqual(),
'like': Like(),
}

def parse_operator(key, delimiter):
parts = key.split(delimiter)
if len(parts) == 1:
return parts[0], 'eq'
elif len(parts) == 2:
return parts
raise exception.BadRequestException('Invalid parameter "{0}"'.format(key))

def filter(model, key, values):
delimiter = current_app.config.get('QUERY_DELIMITER', '__')
column_name, operator_name = parse_operator(key, delimiter)
column = utils.get_column(model, column_name)
try:
operator = operators[operator_name]
except KeyError:
raise exception.BadRequestException('Invalid operator "{0}"'.format(operator_name))
return operator(column, values)
29 changes: 25 additions & 4 deletions sandman2/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
from sandman2.exception import NotFoundException, BadRequestException
from sandman2.model import db
from sandman2.decorators import etag, validate_fields
from sandman2 import utils, operators


RESERVED_PARAMETERS = ['page', 'sort']


def add_link_headers(response, links):
Expand All @@ -28,6 +32,21 @@ def add_link_headers(response, links):
return response


def filter_query(model, query, params):
for param in params:
if param in RESERVED_PARAMETERS:
continue
query = query.filter(operators.filter(model, param, params.getlist(param)))
return query


def order_query(model, query, params):
return query.order_by(*[
utils.get_order(model, key)
for key in params.getlist('sort')
])


def jsonify(resource):
"""Return a Flask ``Response`` object containing a
JSON representation of *resource*.
Expand Down Expand Up @@ -201,12 +220,14 @@ def _all_resources(self):

:rtype: :class:`sandman2.model.Model`
"""
query = self.__model__.query
query = filter_query(self.__model__, query, request.args)
query = order_query(self.__model__, query, request.args)
if 'page' in request.args:
resources = self.__model__.query.paginate(
int(request.args['page'])).items
query = query.paginate(int(request.args['page'])).items
else:
resources = self.__model__.query.all()
return [r.to_dict() for r in resources]
query = query.all()
return [r.to_dict() for r in query]

@staticmethod
def _no_content_response():
Expand Down
22 changes: 22 additions & 0 deletions sandman2/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-

import sqlalchemy as sa

from sandman2 import exception

def get_column(model, key):
try:
return getattr(model, key)
except AttributeError:
raise exception.BadRequestException('Invalid parameter "{0}"'.format(key))

def column_type(attribute):
columns = attribute.property.columns
if len(columns) == 1:
return columns[0].type.python_type
return None

def get_order(model, key):
direction = sa.desc if key.startswith('-') else sa.asc
column = get_column(model, key.lstrip('-'))
return direction(column)
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ def run_tests(self):
install_requires=[
'Flask>=0.10.1',
'Flask-SQLAlchemy>=1.0',
'pytest-flask==0.4.0',
'Flask-Admin>=1.0.8',
'WebTest>=2.0.18',
'python-dateutil>=2.4.2',
'six>=1.9.0',
],
cmdclass={'test': PyTest},
author_email='[email protected]',
Expand Down
9 changes: 6 additions & 3 deletions tests/automap_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ class User(AutomapModel):

__tablename__ = 'user'

def __unicode__(self):
def __str__(self):
return self.name
__unicode__ = __str__


class Blog(AutomapModel):
Expand All @@ -17,14 +18,16 @@ class Blog(AutomapModel):

__tablename__ = 'blog'

def __unicode__(self):
def __str__(self):
return self.name
__unicode__ = __str__

class Post(AutomapModel):

"""An individual blog post."""

__tablename__ = 'post'

def __unicode__(self):
def __str__(self):
return self.title
__unicode__ = __str__
10 changes: 8 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
sys.path.insert(0, os.path.abspath('.'))

import pytest
from webtest import TestApp

from sandman2 import get_app, db



@pytest.yield_fixture(scope='function')
def app(request):
"""Yield the application instance."""
Expand All @@ -39,9 +39,15 @@ def app(request):
exclude_tables=exclude_tables)
application.testing = True

yield application
with application.test_request_context():
yield application

with application.app_context():
db.session.remove()
db.drop_all()
os.unlink(test_database_path)


@pytest.fixture
def client(app):
return TestApp(app)
14 changes: 4 additions & 10 deletions tests/test_automap_base_models.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
"""Test using user-defined models with sandman2."""
import json

from pytest_flask.fixtures import client

from tests.resources import (
GET_ERROR_MESSAGE,
INVALID_ACTION_MESSAGE,
)
from flask import url_for

model_module = 'tests.automap_models'
database = 'blog.sqlite3'


def test_get_automap_collection(client):
"""Do we see a model's __unicode__ definition being used in the admin?"""
response = client.get('/admin/blogview/')
assert response.status_code == 200
assert 'Jeff Knupp' in response.data
res = client.get(url_for('blog.index_view'))
assert res.status_code == 200
assert 'Jeff Knupp' in res
11 changes: 4 additions & 7 deletions tests/test_extended_functionality.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
"""Tests for non-core functionality in sandman2."""

from pytest_flask.fixtures import client

exclude_tables = ('Invoice')

def test_pagination(client):
"""Do we return paginated results when a 'page' parameter is provided?"""
response = client.get('/artist?page=2')
assert response.status_code == 200
assert len(response.json['resources']) == 20
assert response.json['resources'][0]['ArtistId'] == 21
res = client.get('/artist?page=2')
assert res.status_code == 200
assert len(res.json['resources']) == 20
assert res.json['resources'][0]['ArtistId'] == 21
Loading