From 940b13db9bc20814c73ea359689fec7d11a517f5 Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Tue, 16 Feb 2016 00:30:45 -0500 Subject: [PATCH 1/4] Allows filtering with PostgreSQL network operators The primary addition of this commit is the new filter operators corresponding to the PostgreSQL network address operators, but it also makes several other changes to the testing framework, including changes to the Travis-CI build configuration. First, it restructures the unit test base classes a bit. It provides separate "parallel" base classes for database tests: one for Flask-SQLAlchemy and one for "pure" SQLAlchemy. This fixes some confusion in the database configuration for test classes. Second, it makes some code style changes in the unit tests. Finally, it forces Travis to install an appropriate version of PostgreSQL and an appropriate Python library for accessing a PostgreSQL database. --- .travis.yml | 14 +- CHANGES | 1 + docs/fetching.rst | 9 +- flask_restless/search.py | 6 + requirements-test-cpython.txt | 2 + requirements-test-pypy.txt | 2 + requirements-test.txt | 3 + tests/helpers.py | 93 +++++++-- tests/test_creating.py | 149 ++++++-------- tests/test_deleting.py | 24 +-- tests/test_fetching.py | 28 +-- tests/test_filtering.py | 200 ++++++++++++++++++ tests/test_manager.py | 25 +-- tests/test_updating.py | 369 +++++++++++++++++++--------------- 14 files changed, 599 insertions(+), 326 deletions(-) create mode 100644 requirements-test-cpython.txt create mode 100644 requirements-test-pypy.txt diff --git a/.travis.yml b/.travis.yml index 2b27292e..0d97b49a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,9 +12,21 @@ python: - "pypy" - "pypy3" +addons: + apt: + packages: + # Need to update the installed version of PostgreSQL, because it doesn't + # implement all of the network operators (specifically the && operator). + - postgresql-9.5 + +before_install: + # Determine whether we're using PyPy, as it determines which requirements + # file we will use. + - if (python --version 2>&1 | grep PyPy > /dev/null); then export REQUIREMENTS=requirements-test-pypy.txt; else export REQUIREMENTS=requirements-test-cpython.txt; fi + install: - pip install --upgrade pip - - pip install -r requirements.txt + - pip install -r $REQUIREMENTS - pip install coveralls script: diff --git a/CHANGES b/CHANGES index 13d94978..f63a28b8 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,7 @@ Version 1.0.0-dev Not yet released. +- #255: adds support for filtering by PostgreSQL network operators. - #257: ensures additional attributes specified by the user actually exist on the model. - #363 (partial solution): don't use ``COUNT`` on requests that don't require diff --git a/docs/fetching.rst b/docs/fetching.rst index 6677dd5d..29e18cbe 100644 --- a/docs/fetching.rst +++ b/docs/fetching.rst @@ -937,7 +937,8 @@ returned, clients can make requests like this: Operators ......... -The operators recognized by the API incude: +Flask-Restless understands the following operators, which correspond to the +appropriate `SQLAlchemy column operators`_. * ``==``, ``eq``, ``equals``, ``equals_to`` * ``!=``, ``neq``, ``does_not_equal``, ``not_equal_to`` @@ -949,7 +950,8 @@ The operators recognized by the API incude: * ``has`` * ``any`` -These correspond to the appropriate `SQLAlchemy column operators`_. +Flask-Restless also understands the `PostgreSQL network address operators`_ +``<<``, ``<<=``, ``>>``, ``>>=``, ``<>``, and ``&&``. .. warning:: @@ -960,7 +962,8 @@ These correspond to the appropriate `SQLAlchemy column operators`_. .. _percent-encoded: https://en.wikipedia.org/wiki/Percent-encoding#Percent-encoding_the_percent_character -.. _SQLAlchemy column operators: http://docs.sqlalchemy.org/en/latest/core/expression_api.html#sqlalchemy.sql.operators.ColumnOperators +.. _SQLAlchemy column operators: https://docs.sqlalchemy.org/en/latest/core/expression_api.html#sqlalchemy.sql.operators.ColumnOperators +.. _PostgreSQL network address operators: https://www.postgresql.org/docs/current/static/functions-net.html .. _single: diff --git a/flask_restless/search.py b/flask_restless/search.py index 7b3cdae7..9df0c924 100644 --- a/flask_restless/search.py +++ b/flask_restless/search.py @@ -119,6 +119,12 @@ def _sub_operator(model, argument, fieldname): 'le': lambda f, a: f <= a, 'lte': lambda f, a: f <= a, 'leq': lambda f, a: f <= a, + '<<': lambda f, a: f.op('<<')(a), + '<<=': lambda f, a: f.op('<<=')(a), + '>>': lambda f, a: f.op('>>')(a), + '>>=': lambda f, a: f.op('>>=')(a), + '<>': lambda f, a: f.op('<>')(a), + '&&': lambda f, a: f.op('&&')(a), 'ilike': lambda f, a: f.ilike(a), 'like': lambda f, a: f.like(a), 'not_like': lambda f, a: ~f.like(a), diff --git a/requirements-test-cpython.txt b/requirements-test-cpython.txt new file mode 100644 index 00000000..ed1fe5fc --- /dev/null +++ b/requirements-test-cpython.txt @@ -0,0 +1,2 @@ +-r requirements-test.txt +psycopg2 diff --git a/requirements-test-pypy.txt b/requirements-test-pypy.txt new file mode 100644 index 00000000..30cf14ff --- /dev/null +++ b/requirements-test-pypy.txt @@ -0,0 +1,2 @@ +-r requirements-test.txt +psycopg2cffi diff --git a/requirements-test.txt b/requirements-test.txt index 3953ce55..996ed010 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,6 @@ -r requirements.txt nose savalidation + +# For testing PostgreSQL specific operations... +testing.postgresql diff --git a/tests/helpers.py b/tests/helpers.py index ccb1581b..b4451615 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -24,6 +24,7 @@ from flask import json try: from flask.ext import sqlalchemy as flask_sqlalchemy + from flask.ext.sqlalchemy import SQLAlchemy except ImportError: has_flask_sqlalchemy = False else: @@ -283,9 +284,6 @@ def setup(self): app = Flask(__name__) app.config['DEBUG'] = True app.config['TESTING'] = True - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' - # This is to avoid a warning in earlier versions of Flask-SQLAlchemy. - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # This is required by `manager.url_for()` in order to construct # absolute URLs. app.config['SERVER_NAME'] = 'localhost' @@ -298,12 +296,69 @@ def setup(self): force_content_type_jsonapi(self.app) -class DatabaseTestBase(FlaskTestBase): +class DatabaseMixin(object): + """A class that accesses a database via a connection URI. + + Subclasses can override the :meth:`database_uri` method to return a + connection URI for the desired database backend. + + """ + + def database_uri(self): + """The database connection URI to use for the SQLAlchemy engine. + + By default, this returns the URI for the SQLite in-memory + database. Subclasses that wish to use a different SQL backend + should override this method so that it returns the desired URI + string. + + """ + return 'sqlite://' + + +class FlaskSQLAlchemyTestBase(FlaskTestBase, DatabaseMixin): + """Base class for tests that use Flask-SQLAlchemy (instead of plain + old SQLAlchemy). + + If Flask-SQLAlchemy is not installed, the :meth:`.setup` method will + raise :exc:`nose.SkipTest`, so that each test method will be + skipped individually. + + """ + + def setup(self): + super(FlaskSQLAlchemyTestBase, self).setup() + if not has_flask_sqlalchemy: + raise SkipTest('Flask-SQLAlchemy not found.') + self.flaskapp.config['SQLALCHEMY_DATABASE_URI'] = self.database_uri() + # This is to avoid a warning in earlier versions of + # Flask-SQLAlchemy. + self.flaskapp.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + # Store some attributes for convenience and so the test methods + # read more like the tests for plain old SQLAlchemy. + self.db = SQLAlchemy(self.flaskapp) + self.session = self.db.session + + def teardown(self): + """Drops all tables and unregisters Flask-SQLAlchemy session + signals. + + """ + self.db.drop_all() + unregister_fsa_session_signals() + + +class SQLAlchemyTestBase(FlaskTestBase, DatabaseMixin): """Base class for tests that use a SQLAlchemy database. - The :meth:`setup` method does the necessary SQLAlchemy initialization, and - the subclasses should populate the database with models and then create the - database (by calling ``self.Base.metadata.create_all()``). + The :meth:`setup` method does the necessary SQLAlchemy + initialization, and the subclasses should populate the database with + models and then create the database (by calling + ``self.Base.metadata.create_all()``). + + By default, this class creates a SQLite database; subclasses can + override the :meth:`.database_uri` method to enable configuration of + an alternate database backend. """ @@ -312,11 +367,8 @@ def setup(self): database. """ - super(DatabaseTestBase, self).setup() - # initialize SQLAlchemy - app = self.flaskapp - engine = create_engine(app.config['SQLALCHEMY_DATABASE_URI'], - convert_unicode=True) + super(SQLAlchemyTestBase, self).setup() + engine = create_engine(self.database_uri(), convert_unicode=True) self.Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) self.session = scoped_session(self.Session) @@ -329,16 +381,25 @@ def teardown(self): self.Base.metadata.drop_all() -class ManagerTestBase(DatabaseTestBase): +class ManagerTestBase(SQLAlchemyTestBase): """Base class for tests that use a SQLAlchemy database and an - :class:`flask_restless.APIManager`. + :class:`~flask.ext.restless.APIManager`. + + Nearly all test classes should subclass this class. Since we strive + to make Flask-Restless compliant with plain old SQLAlchemy first, + the default database abstraction layer used by tests in this class + will be SQLAlchemy. Test classes requiring Flask-SQLAlchemy must + instantiate their own :class:`~flask.ext.restless.APIManager`. - The :class:`flask_restless.APIManager` is accessible at ``self.manager``. + The :class:`~flask.ext.restless.APIManager` instance for use in + tests is accessible at ``self.manager``. """ def setup(self): - """Initializes an instance of :class:`flask.ext.restless.APIManager`. + """Initializes an instance of + :class:`~flask.ext.restless.APIManager` with a SQLAlchemy + session. """ super(ManagerTestBase, self).setup() diff --git a/tests/test_creating.py b/tests/test_creating.py index d8c5a801..87bfcb87 100644 --- a/tests/test_creating.py +++ b/tests/test_creating.py @@ -23,12 +23,6 @@ from datetime import datetime import dateutil -try: - from flask.ext.sqlalchemy import SQLAlchemy -except ImportError: - has_flask_sqlalchemy = False -else: - has_flask_sqlalchemy = True from sqlalchemy import Column from sqlalchemy import Date from sqlalchemy import DateTime @@ -52,13 +46,11 @@ from .helpers import check_sole_error from .helpers import dumps from .helpers import loads -from .helpers import FlaskTestBase +from .helpers import FlaskSQLAlchemyTestBase from .helpers import ManagerTestBase from .helpers import MSIE8_UA from .helpers import MSIE9_UA from .helpers import skip -from .helpers import skip_unless -from .helpers import unregister_fsa_session_signals def raise_s_exception(instance, *args, **kw): @@ -242,20 +234,6 @@ def test_no_content_type(self): assert response.status_code == 415 assert response.headers['Content-Type'] == CONTENT_TYPE - def test_wrong_content_type(self): - """Tests that the server responds with :http:status:`415` if the - request has the wrong content type. - - """ - data = dict(data=dict(type='person')) - bad_content_types = ('application/json', 'application/javascript') - for content_type in bad_content_types: - response = self.app.post('/api/person', data=dumps(data), - content_type=content_type) - # TODO Why are there two copies of the Content-Type header here? - assert response.status_code == 415 - assert response.headers['Content-Type'] == CONTENT_TYPE - def test_msie8(self): """Tests for compatibility with Microsoft Internet Explorer 8. @@ -348,14 +326,16 @@ def test_nonexistent_relationship(self): with a relationship that does not exist in the resource. """ - data = {'data': - {'type': 'person', - 'relationships': - {'bogus': - {'data': None} - } - } + data = { + 'data': { + 'type': 'person', + 'relationships': { + 'bogus': { + 'data': None + } } + } + } response = self.app.post('/api/person', data=dumps(data)) assert response.status_code == 400 # TODO check error message here @@ -367,14 +347,15 @@ def test_invalid_relationship(self): """ # In this request, the `articles` linkage object is missing the # `data` element. - data = {'data': - {'type': 'person', - 'relationships': - {'articles': - {} - } - } + data = { + 'data': { + 'type': 'person', + 'relationships': + { + 'articles': {} } + } + } response = self.app.post('/api/person', data=dumps(data)) assert response.status_code == 400 keywords = ['deserialize', 'missing', '"data"', 'element', @@ -458,17 +439,19 @@ def test_to_many(self): article2 = self.Article(id=2) self.session.add_all([article1, article2]) self.session.commit() - data = {'data': - {'type': 'person', - 'relationships': - {'articles': - {'data': - [{'type': 'article', 'id': '1'}, - {'type': 'article', 'id': '2'}] - } - } - } + data = { + 'data': { + 'type': 'person', + 'relationships': { + 'articles': { + 'data': [ + {'type': 'article', 'id': '1'}, + {'type': 'article', 'id': '2'} + ] + } } + } + } response = self.app.post('/api/person', data=dumps(data)) assert response.status_code == 201 document = loads(response.data) @@ -482,14 +465,16 @@ def test_to_one(self): person = self.Person(id=1) self.session.add(person) self.session.commit() - data = {'data': - {'type': 'article', - 'relationships': - {'author': - {'data': {'type': 'person', 'id': '1'}} - } - } + data = { + 'data': { + 'type': 'article', + 'relationships': { + 'author': { + 'data': {'type': 'person', 'id': '1'} + } } + } + } response = self.app.post('/api/article', data=dumps(data)) assert response.status_code == 201 document = loads(response.data) @@ -599,16 +584,19 @@ def test_serialization_exception_included(self): self.manager.create_api(self.Article, methods=['POST'], url_prefix='/api2') self.manager.create_api(self.Person, serializer=raise_s_exception) - data = {'data': - {'type': 'article', - 'relationships': - {'author': - {'data': - {'type': 'person', 'id': 1} - } - } - } + data = { + 'data': { + 'type': 'article', + 'relationships': { + 'author': { + 'data': { + 'type': 'person', + 'id': 1 + } + } } + } + } query_string = {'include': 'author'} response = self.app.post('/api/article', data=dumps(data), query_string=query_string) @@ -953,17 +941,19 @@ def test_create(self): tag2 = self.Tag(id=2) self.session.add_all([tag1, tag2]) self.session.commit() - data = {'data': - {'type': 'article', - 'relationships': - {'tags': - {'data': - [{'type': 'tag', 'id': '1'}, - {'type': 'tag', 'id': '2'}] - } - } - } + data = { + 'data': { + 'type': 'article', + 'relationships': { + 'tags': { + 'data': [ + {'type': 'tag', 'id': '1'}, + {'type': 'tag', 'id': '2'} + ] + } } + } + } response = self.app.post('/api/article', data=dumps(data)) assert response.status_code == 201 document = loads(response.data) @@ -999,8 +989,7 @@ def test_dictionary_collection(self): assert False, 'Not implemented' -@skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') -class TestFlaskSqlalchemy(FlaskTestBase): +class TestFlaskSQLAlchemy(FlaskSQLAlchemyTestBase): """Tests for creating resources defined as Flask-SQLAlchemy models instead of pure SQLAlchemy models. @@ -1008,8 +997,7 @@ class TestFlaskSqlalchemy(FlaskTestBase): def setup(self): """Creates the Flask-SQLAlchemy database and models.""" - super(TestFlaskSqlalchemy, self).setup() - self.db = SQLAlchemy(self.flaskapp) + super(TestFlaskSQLAlchemy, self).setup() class Person(self.db.Model): id = self.db.Column(self.db.Integer, primary_key=True) @@ -1019,13 +1007,6 @@ class Person(self.db.Model): self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db) self.manager.create_api(self.Person, methods=['POST']) - def teardown(self): - """Drops all tables and unregisters Flask-SQLAlchemy session signals. - - """ - self.db.drop_all() - unregister_fsa_session_signals() - def test_create(self): """Tests for creating a resource.""" data = dict(data=dict(type='person')) diff --git a/tests/test_deleting.py b/tests/test_deleting.py index 36aaac26..2016543e 100644 --- a/tests/test_deleting.py +++ b/tests/test_deleting.py @@ -18,12 +18,6 @@ specification. """ -try: - from flask.ext.sqlalchemy import SQLAlchemy -except ImportError: - has_flask_sqlalchemy = False -else: - has_flask_sqlalchemy = True from sqlalchemy import Column from sqlalchemy import ForeignKey from sqlalchemy import Integer @@ -35,13 +29,11 @@ from .helpers import dumps from .helpers import loads -from .helpers import FlaskTestBase +from .helpers import FlaskSQLAlchemyTestBase from .helpers import ManagerTestBase from .helpers import MSIE8_UA from .helpers import MSIE9_UA from .helpers import skip -from .helpers import skip_unless -from .helpers import unregister_fsa_session_signals class TestDeleting(ManagerTestBase): @@ -274,8 +266,7 @@ def assert_deletion(was_deleted=False, **kw): assert response.status_code == 204 -@skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') -class TestFlaskSqlalchemy(FlaskTestBase): +class TestFlaskSQLAlchemy(FlaskSQLAlchemyTestBase): """Tests for deleting resources defined as Flask-SQLAlchemy models instead of pure SQLAlchemy models. @@ -283,9 +274,7 @@ class TestFlaskSqlalchemy(FlaskTestBase): def setup(self): """Creates the Flask-SQLAlchemy database and models.""" - super(TestFlaskSqlalchemy, self).setup() - self.db = SQLAlchemy(self.flaskapp) - self.session = self.db.session + super(TestFlaskSQLAlchemy, self).setup() class Person(self.db.Model): id = self.db.Column(self.db.Integer, primary_key=True) @@ -295,13 +284,6 @@ class Person(self.db.Model): self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db) self.manager.create_api(self.Person, methods=['DELETE']) - def teardown(self): - """Drops all tables and unregisters Flask-SQLAlchemy session signals. - - """ - self.db.drop_all() - unregister_fsa_session_signals() - def test_delete(self): """Tests for deleting a resource.""" person = self.Person(id=1) diff --git a/tests/test_fetching.py b/tests/test_fetching.py index 86b8733c..5c3c4913 100644 --- a/tests/test_fetching.py +++ b/tests/test_fetching.py @@ -20,12 +20,6 @@ """ from operator import itemgetter -try: - from flask.ext.sqlalchemy import SQLAlchemy -except: - has_flask_sqlalchemy = False -else: - has_flask_sqlalchemy = True from sqlalchemy import Column from sqlalchemy import ForeignKey from sqlalchemy import Integer @@ -40,14 +34,12 @@ from .helpers import check_sole_error from .helpers import dumps -from .helpers import FlaskTestBase +from .helpers import FlaskSQLAlchemyTestBase from .helpers import loads from .helpers import MSIE8_UA from .helpers import MSIE9_UA from .helpers import ManagerTestBase from .helpers import skip -from .helpers import skip_unless -from .helpers import unregister_fsa_session_signals class TestFetchCollection(ManagerTestBase): @@ -1592,18 +1584,15 @@ def test_scalar(self): assert ['bar', 'foo'] == sorted(article['attributes']['tag_names']) -@skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') -class TestFlaskSqlalchemy(FlaskTestBase): - """Tests for fetching resources defined as Flask-SQLAlchemy models instead - of pure SQLAlchemy models. +class TestFlaskSQLAlchemy(FlaskSQLAlchemyTestBase): + """Tests for fetching resources defined as Flask-SQLAlchemy models + instead of pure SQLAlchemy models. """ def setup(self): """Creates the Flask-SQLAlchemy database and models.""" - super(TestFlaskSqlalchemy, self).setup() - self.db = SQLAlchemy(self.flaskapp) - self.session = self.db.session + super(TestFlaskSQLAlchemy, self).setup() class Person(self.db.Model): id = self.db.Column(self.db.Integer, primary_key=True) @@ -1613,13 +1602,6 @@ class Person(self.db.Model): self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db) self.manager.create_api(self.Person) - def teardown(self): - """Drops all tables and unregisters Flask-SQLAlchemy session signals. - - """ - self.db.drop_all() - unregister_fsa_session_signals() - def test_fetch_resource(self): """Test for fetching a resource.""" person = self.Person(id=1) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index a7751c7f..9acce162 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -15,6 +15,13 @@ from datetime import time from operator import itemgetter +# This import is unused but is required for testing on PyPy. CPython can +# use psycopg2, but PyPy can only use psycopg2cffi. +try: + import psycopg2 +except ImportError: + from psycopg2cffi import compat + compat.register() from sqlalchemy import Column from sqlalchemy import Date from sqlalchemy import DateTime @@ -22,9 +29,11 @@ from sqlalchemy import Integer from sqlalchemy import Time from sqlalchemy import Unicode +from sqlalchemy.dialects.postgresql import INET from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import backref from sqlalchemy.orm import relationship +from testing.postgresql import PostgresqlFactory as PGFactory from .helpers import check_sole_error from .helpers import dumps @@ -33,6 +42,23 @@ from .helpers import ManagerTestBase +#: The PostgreSQL class used to create a temporary database for testing. +#: +#: This class should be instantiated in the setup method of test +#: classes, and the :class:`Postgresql.stop` method should be called on +#: teardown. +#: +#: This is an optimization designed to speed up the tests that require +#: PostgreSQL, since it can be extremely slow to initialize a PostgreSQL +#: database before each test method. +PostgreSQL = PGFactory(cache_initialized_db=True) + + +def teardown(): + """Clears the cache in the :attr:`PostgreSQL` class.""" + PostgreSQL.clear_cache() + + class SearchTestBase(ManagerTestBase): """Provides a search method that simplifies a fetch request with filtering query parameters. @@ -892,6 +918,180 @@ def test_compare_equals_to_null(self): # TODO check the error message here. +class TestNetworkOperators(SearchTestBase): + """Unit tests for the network address operators in PostgreSQL. + + For more information, see `Network Address Functions and Operators`_ + in the PostgreSQL documentation. + + .. _Network Address Functions and Operators: http://www.postgresql.org/docs/current/interactive/functions-net.html + + """ + + def setup(self): + super(TestNetworkOperators, self).setup() + + class Network(self.Base): + __tablename__ = 'network' + id = Column(Integer, primary_key=True) + address = Column(INET) + + self.Network = Network + self.Base.metadata.create_all() + self.manager.create_api(Network) + + def teardown(self): + """Closes the database and removes the temporary directory in + which it lives. + + """ + super(TestNetworkOperators, self).teardown() + self.database.stop() + + # We know this method will be called by `setup()` in the superclass, + # so we can set up the temporary database here. + def database_uri(self): + """Creates a PostgreSQL database and returns its connection URI.""" + #: The PostgreSQL database used by the test methods in this class. + #: + #: This attribute stores a + #: :class:`~testing.postgresql.Postgresql` object, which must be + #: stopped in the :meth:`.teardown` method. + self.database = PostgreSQL() + + return self.database.url() + + def test_is_not_equal(self): + """Test for the ``<>`` ("is not equal") operator. + + For example: + + .. sourcecode:: postgresql + + inet '192.168.1.5' <> inet '192.168.1.4' + + """ + network1 = self.Network(id=1, address='192.168.1.5') + network2 = self.Network(id=2, address='192.168.1.4') + self.session.add_all([network1, network2]) + self.session.commit() + filters = [dict(name='address', op='<>', val='192.168.1.4')] + response = self.search('/api/network', filters) + document = loads(response.data) + networks = document['data'] + assert ['1'] == sorted(network['id'] for network in networks) + + def test_is_contained_by(self): + """Test for the ``<<`` ("is contained by") operator. + + For example: + + .. sourcecode:: postgresql + + inet '192.168.1.5' << inet '192.168.1/24' + + """ + network1 = self.Network(id=1, address='192.168.1.5') + network2 = self.Network(id=2, address='192.168.2.1') + self.session.add_all([network1, network2]) + self.session.commit() + filters = [dict(name='address', op='<<', val='192.168.1/24')] + response = self.search('/api/network', filters) + document = loads(response.data) + networks = document['data'] + assert ['1'] == sorted(network['id'] for network in networks) + + def test_is_contained_by_or_equals(self): + """Test for the ``<<=`` ("is contained by or equals") operator. + + For example: + + .. sourcecode:: postgresql + + inet '192.168.1/24' <<= inet '192.168.1/24' + + """ + network1 = self.Network(id=1, address='192.168.1/24') + network2 = self.Network(id=2, address='192.168.1.5') + network3 = self.Network(id=3, address='192.168.2.1') + self.session.add_all([network1, network2, network3]) + self.session.commit() + filters = [dict(name='address', op='<<=', val='192.168.1/24')] + response = self.search('/api/network', filters) + document = loads(response.data) + networks = document['data'] + assert ['1', '2'] == sorted(network['id'] for network in networks) + + def test_contains(self): + """Test for the ``>>`` ("contains") operator. + + For example: + + .. sourcecode:: postgresql + + inet '192.168.1/24' >> inet '192.168.1.5' + + """ + network1 = self.Network(id=1, address='192.168.1/24') + network2 = self.Network(id=2, address='192.168.2/24') + self.session.add_all([network1, network2]) + self.session.commit() + filters = [dict(name='address', op='>>', val='192.168.1.5')] + response = self.search('/api/network', filters) + document = loads(response.data) + networks = document['data'] + assert ['1'] == sorted(network['id'] for network in networks) + + def test_contains_or_equals(self): + """Test for the ``>>=`` ("contains or equals") operator. + + For example: + + .. sourcecode:: postgresql + + inet '192.168.1/24' >>= inet '192.168.1/24' + + """ + network1 = self.Network(id=1, address='192.168.1/24') + network2 = self.Network(id=2, address='192.168/16') + network3 = self.Network(id=3, address='192.168.2/24') + self.session.add_all([network1, network2, network3]) + self.session.commit() + filters = [dict(name='address', op='>>=', val='192.168.1/24')] + response = self.search('/api/network', filters) + document = loads(response.data) + networks = document['data'] + assert ['1', '2'] == sorted(network['id'] for network in networks) + + def test_contains_or_is_contained_by(self): + """Test for the ``&&`` ("contains or is contained by") operator. + + .. warning:: + + This operation is only available in PostgreSQL 9.4 or later. + + For example: + + .. sourcecode:: postgresql + + inet '192.168.1/24' && inet '192.168.1.80/28' + + """ + # network1 contains the queried subnet + network1 = self.Network(id=1, address='192.168.1/24') + # network2 is contained by the queried subnet + network2 = self.Network(id=2, address='192.168.1.81/28') + # network3 is neither + network3 = self.Network(id=3, address='192.168.2.1') + self.session.add_all([network1, network2, network3]) + self.session.commit() + filters = [dict(name='address', op='&&', val='192.168.1.80/28')] + response = self.search('/api/network', filters) + document = loads(response.data) + networks = document['data'] + assert ['1', '2'] == sorted(network['id'] for network in networks) + + class TestAssociationProxy(SearchTestBase): """Test for filtering on association proxies.""" diff --git a/tests/test_manager.py b/tests/test_manager.py index 2aa070bf..4eff4573 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -11,12 +11,6 @@ # information, see LICENSE.AGPL and LICENSE.BSD. """Unit tests for the :mod:`flask_restless.manager` module.""" from flask import Flask -try: - from flask.ext.sqlalchemy import SQLAlchemy -except ImportError: - has_flask_sqlalchemy = False -else: - has_flask_sqlalchemy = True from nose.tools import raises from sqlalchemy import Column from sqlalchemy import ForeignKey @@ -32,16 +26,14 @@ from flask.ext.restless import serializer_for from flask.ext.restless import url_for -from .helpers import DatabaseTestBase -from .helpers import ManagerTestBase -from .helpers import FlaskTestBase from .helpers import force_content_type_jsonapi +from .helpers import FlaskSQLAlchemyTestBase +from .helpers import ManagerTestBase from .helpers import skip -from .helpers import skip_unless -from .helpers import unregister_fsa_session_signals +from .helpers import SQLAlchemyTestBase -class TestLocalAPIManager(DatabaseTestBase): +class TestLocalAPIManager(SQLAlchemyTestBase): """Provides tests for :class:`flask.ext.restless.APIManager` when the tests require that the instance of :class:`flask.ext.restless.APIManager` has not yet been instantiated. @@ -452,8 +444,7 @@ def test_additional_attributes_nonexistent(self): self.manager.create_api(self.Person, additional_attributes=['bogus']) -@skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') -class TestFSA(FlaskTestBase): +class TestFSA(FlaskSQLAlchemyTestBase): """Tests which use models defined using Flask-SQLAlchemy instead of pure SQLAlchemy. @@ -465,7 +456,6 @@ def setup(self): """ super(TestFSA, self).setup() - self.db = SQLAlchemy(self.flaskapp) class Person(self.db.Model): id = self.db.Column(self.db.Integer, primary_key=True) @@ -473,11 +463,6 @@ class Person(self.db.Model): self.Person = Person self.db.create_all() - def teardown(self): - """Drops all tables from the temporary database.""" - self.db.drop_all() - unregister_fsa_session_signals() - def test_init_app(self): manager = APIManager(flask_sqlalchemy_db=self.db) manager.create_api(self.Person) diff --git a/tests/test_updating.py b/tests/test_updating.py index 38d5a93b..454e500e 100644 --- a/tests/test_updating.py +++ b/tests/test_updating.py @@ -47,14 +47,12 @@ from .helpers import BetterJSONEncoder as JSONEncoder from .helpers import check_sole_error from .helpers import dumps -from .helpers import FlaskTestBase +from .helpers import FlaskSQLAlchemyTestBase from .helpers import loads from .helpers import MSIE8_UA from .helpers import MSIE9_UA from .helpers import ManagerTestBase from .helpers import skip -from .helpers import skip_unless -from .helpers import unregister_fsa_session_signals class TestUpdating(ManagerTestBase): @@ -160,7 +158,7 @@ def test_wrong_accept_header(self): } } response = self.app.patch('/api/person/1', data=dumps(data), - headers=headers) + headers=headers) assert response.status_code == 406 assert person.name == u'foo' @@ -185,12 +183,15 @@ def test_deserializing_time(self): self.session.add(person) self.session.commit() bedtime = datetime.now().time() - data = {'data': - {'type': 'person', - 'id': '1', - 'attributes': {'bedtime': bedtime} - } + data = { + 'data': { + 'type': 'person', + 'id': '1', + 'attributes': { + 'bedtime': bedtime } + } + } # Python's built-in JSON encoder doesn't serialize date/time objects by # default. data = dumps(data, cls=JSONEncoder) @@ -204,12 +205,15 @@ def test_deserializing_date(self): self.session.add(person) self.session.commit() today = datetime.now().date() - data = {'data': - {'type': 'person', - 'id': '1', - 'attributes': {'date_created': today} - } + data = { + 'data': { + 'type': 'person', + 'id': '1', + 'attributes': { + 'date_created': today } + } + } # Python's built-in JSON encoder doesn't serialize date/time objects by # default. data = dumps(data, cls=JSONEncoder) @@ -223,12 +227,15 @@ def test_deserializing_datetime(self): self.session.add(person) self.session.commit() now = datetime.now() - data = {'data': - {'type': 'person', - 'id': '1', - 'attributes': {'birth_datetime': now} - } + data = { + 'data': { + 'type': 'person', + 'id': '1', + 'attributes': { + 'birth_datetime': now } + } + } # Python's built-in JSON encoder doesn't serialize date/time objects by # default. data = dumps(data, cls=JSONEncoder) @@ -264,22 +271,6 @@ def test_no_content_type(self): assert response.status_code == 415 assert response.headers['Content-Type'] == CONTENT_TYPE - def test_wrong_content_type(self): - """Tests that the server responds with :http:status:`415` if the - request has the wrong content type. - - """ - person = self.Person(id=1) - self.session.add(person) - self.session.commit() - data = dict(data=dict(type='person', id='1')) - bad_content_types = ('application/json', 'application/javascript') - for content_type in bad_content_types: - response = self.app.patch('/api/person/1', data=dumps(data), - content_type=content_type) - assert response.status_code == 415 - assert response.headers['Content-Type'] == CONTENT_TYPE - def test_msie8(self): """Tests for compatibility with Microsoft Internet Explorer 8. @@ -328,21 +319,27 @@ def test_rollback_on_integrity_error(self): person2 = self.Person(id=2, name=u'bar') self.session.add_all([person1, person2]) self.session.commit() - data = {'data': - {'type': 'person', - 'id': '2', - 'attributes': {'name': u'foo'} - } + data = { + 'data': { + 'type': 'person', + 'id': '2', + 'attributes': { + 'name': u'foo' } + } + } response = self.app.patch('/api/person/2', data=dumps(data)) assert response.status_code == 409 # Conflict assert self.session.is_active, 'Session is in `partial rollback` state' - data = {'data': - {'type': 'person', - 'id': '2', - 'attributes': {'name': 'baz'} - } + data = { + 'data': { + 'type': 'person', + 'id': '2', + 'attributes': { + 'name': 'baz' } + } + } response = self.app.patch('/api/person/2', data=dumps(data)) assert response.status_code == 204 assert person2.name == 'baz' @@ -386,12 +383,15 @@ def test_nonexistent_attribute(self): person = self.Person(id=1) self.session.add(person) self.session.commit() - data = {'data': - {'type': 'person', - 'id': '1', - 'attributes': {'bogus': 0} - } + data = { + 'data': { + 'type': 'person', + 'id': '1', + 'attributes': { + 'bogus': 0 } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert 400 == response.status_code @@ -405,12 +405,15 @@ def test_read_only_hybrid_property(self): interval = self.Interval(id=1, start=5, end=10) self.session.add(interval) self.session.commit() - data = {'data': - {'type': 'interval', - 'id': '1', - 'attributes': {'radius': 1} - } + data = { + 'data': { + 'type': 'interval', + 'id': '1', + 'attributes': { + 'radius': 1 } + } + } response = self.app.patch('/api/interval/1', data=dumps(data)) assert response.status_code == 400 # TODO check error message here @@ -420,12 +423,15 @@ def test_set_hybrid_property(self): interval = self.Interval(id=1, start=5, end=10) self.session.add(interval) self.session.commit() - data = {'data': - {'type': 'interval', - 'id': '1', - 'attributes': {'length': 4} - } + data = { + 'data': { + 'type': 'interval', + 'id': '1', + 'attributes': { + 'length': 4 } + } + } response = self.app.patch('/api/interval/1', data=dumps(data)) assert response.status_code == 204 assert interval.start == 5 @@ -439,12 +445,15 @@ def test_collection_name(self): self.session.commit() self.manager.create_api(self.Person, methods=['PATCH'], collection_name='people') - data = {'data': - {'type': 'people', - 'id': '1', - 'attributes': {'name': u'foo'} - } + data = { + 'data': { + 'type': 'people', + 'id': '1', + 'attributes': { + 'name': u'foo' } + } + } response = self.app.patch('/api/people/1', data=dumps(data)) assert response.status_code == 204 assert person.name == u'foo' @@ -456,21 +465,27 @@ def test_different_endpoints(self): self.session.commit() self.manager.create_api(self.Person, methods=['PATCH'], url_prefix='/api2') - data = {'data': - {'type': 'person', - 'id': '1', - 'attributes': {'name': u'foo'} - } + data = { + 'data': { + 'type': 'person', + 'id': '1', + 'attributes': { + 'name': u'foo' } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert response.status_code == 204 assert person.name == u'foo' - data = {'data': - {'type': 'person', - 'id': '1', - 'attributes': {'name': u'bar'} - } + data = { + 'data': { + 'type': 'person', + 'id': '1', + 'attributes': { + 'name': u'bar' } + } + } response = self.app.patch('/api2/person/1', data=dumps(data)) assert response.status_code == 204 assert person.name == 'bar' @@ -674,11 +689,14 @@ def test_missing_type(self): person = self.Person(id=1, name=u'foo') self.session.add(person) self.session.commit() - data = {'data': - {'id': '1', - 'attributes': {'name': u'bar'} - } + data = { + 'data': { + 'id': '1', + 'attributes': { + 'name': u'bar' } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert response.status_code == 400 # TODO check error message here @@ -692,11 +710,14 @@ def test_missing_id(self): person = self.Person(id=1, name=u'foo') self.session.add(person) self.session.commit() - data = {'data': - {'type': 'person', - 'attributes': {'name': u'bar'} - } + data = { + 'data': { + 'type': 'person', + 'attributes': { + 'name': u'bar' } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert response.status_code == 400 # TODO check error message here @@ -710,15 +731,20 @@ def test_nonexistent_to_one_link(self): article = self.Article(id=1) self.session.add(article) self.session.commit() - data = {'data': - {'type': 'article', - 'id': '1', - 'relationships': - {'author': - {'data': {'type': 'person', 'id': '1'}} - } - } + data = { + 'data': { + 'type': 'article', + 'id': '1', + 'relationships': { + 'author': { + 'data': { + 'type': 'person', + 'id': '1' + } + } } + } + } response = self.app.patch('/api/article/1', data=dumps(data)) check_sole_error(response, 404, ['found', 'person', '1']) @@ -732,15 +758,20 @@ def test_conflicting_type_to_one_link(self): article = self.Article(id=1) self.session.add_all([article, person]) self.session.commit() - data = {'data': - {'type': 'article', - 'id': '1', - 'relationships': - {'author': - {'data': {'type': 'bogus', 'id': '1'}} - } - } + data = { + 'data': { + 'type': 'article', + 'id': '1', + 'relationships': { + 'author': { + 'data': { + 'type': 'bogus', + 'id': '1' + } + } } + } + } response = self.app.patch('/api/article/1', data=dumps(data)) assert response.status_code == 409 # TODO check error message here @@ -758,15 +789,19 @@ def test_conflicting_type_to_many_link(self): self.manager.create_api(self.Person, methods=['PATCH'], url_prefix='/api2', allow_to_many_replacement=True) - data = {'data': - {'type': 'person', - 'id': '1', - 'relationships': - {'articles': - {'data': [{'type': 'bogus', 'id': '1'}]} - } - } + data = { + 'data': { + 'type': 'person', + 'id': '1', + 'relationships': { + 'articles': { + 'data': [ + {'type': 'bogus', 'id': '1'} + ] + } } + } + } response = self.app.patch('/api2/person/1', data=dumps(data)) assert response.status_code == 409 # TODO check error message here @@ -779,13 +814,15 @@ def test_relationship_empty_object(self): article = self.Article(id=1) self.session.add(article) self.session.commit() - data = {'data': - {'type': 'article', - 'id': '1', - 'relationships': - {'author': {}} - } + data = { + 'data': { + 'type': 'article', + 'id': '1', + 'relationships': { + 'author': {} } + } + } response = self.app.patch('/api/article/1', data=dumps(data)) assert response.status_code == 400 # TODO check error message here @@ -847,12 +884,15 @@ def increment_id(resource_id=None, **kw): preprocessors = dict(PATCH_RESOURCE=[increment_id]) self.manager.create_api(self.Person, methods=['PATCH'], preprocessors=preprocessors) - data = {'data': - {'id': '1', - 'type': 'person', - 'attributes': {'name': u'foo'} - } + data = { + 'data': { + 'id': '1', + 'type': 'person', + 'attributes': { + 'name': u'foo' } + } + } response = self.app.patch('/api/person/0', data=dumps(data)) assert response.status_code == 204 assert person.name == u'foo' @@ -872,12 +912,15 @@ def forbidden(**kw): preprocessors = dict(PATCH_RESOURCE=[forbidden]) self.manager.create_api(self.Person, methods=['PATCH'], preprocessors=preprocessors) - data = {'data': - {'id': '1', - 'type': 'person', - 'attributes': {'name': u'bar'} - } + data = { + 'data': { + 'id': '1', + 'type': 'person', + 'attributes': { + 'name': u'bar' } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert response.status_code == 403 document = loads(response.data) @@ -907,12 +950,15 @@ def set_name(data=None, **kw): preprocessors = dict(PATCH_RESOURCE=[set_name]) self.manager.create_api(self.Person, methods=['PATCH'], preprocessors=preprocessors) - data = {'data': - {'id': '1', - 'type': 'person', - 'attributes': {'name': u'baz'} - } + data = { + 'data': { + 'id': '1', + 'type': 'person', + 'attributes': { + 'name': u'baz' } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert response.status_code == 204 assert person.name == 'bar' @@ -933,12 +979,15 @@ def modify_result(result=None, **kw): postprocessors = dict(PATCH_RESOURCE=[modify_result]) self.manager.create_api(self.Person, methods=['PATCH'], postprocessors=postprocessors) - data = {'data': - {'id': '1', - 'type': 'person', - 'attributes': {'name': u'bar'} - } + data = { + 'data': { + 'id': '1', + 'type': 'person', + 'attributes': { + 'name': u'bar' } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert response.status_code == 204 assert person.name == 'bar' @@ -1101,12 +1150,15 @@ def test_extra_info_patch_relationship_url(self): tag = self.Tag(id=1) self.session.add_all([article, tag]) self.session.commit() - data = {'data': - {'id': '1', - 'type': 'tag', - 'attributes': {'extrainfo': 'foo'} - } + data = { + 'data': { + 'id': '1', + 'type': 'tag', + 'attributes': { + 'extrainfo': 'foo' } + } + } data = dumps(data) response = self.app.patch('/api/article/1/relationships/tags', data=data) @@ -1127,12 +1179,17 @@ def test_extra_info_post_relationship_url(self): tag = self.Tag(id=1) self.session.add_all([article, tag]) self.session.commit() - data = {'data': - [{'id': '1', - 'type': 'tag', - 'attributes': {'extrainfo': 'foo'} - }] + data = { + 'data': [ + { + 'id': '1', + 'type': 'tag', + 'attributes': { + 'extrainfo': 'foo' + } } + ] + } data = dumps(data) response = self.app.post('/api/article/1/relationships/tags', data=data) @@ -1141,8 +1198,7 @@ def test_extra_info_post_relationship_url(self): assert self.session.query(self.ArticleTag).first().extrainfo == 'foo' -@skip_unless(has_flask_sqlalchemy, 'Flask-SQLAlchemy not found.') -class TestFlaskSqlalchemy(FlaskTestBase): +class TestFlaskSQLAlchemy(FlaskSQLAlchemyTestBase): """Tests for updating resources defined as Flask-SQLAlchemy models instead of pure SQLAlchemy models. @@ -1150,12 +1206,13 @@ class TestFlaskSqlalchemy(FlaskTestBase): def setup(self): """Creates the Flask-SQLAlchemy database and models.""" - super(TestFlaskSqlalchemy, self).setup() + super(TestFlaskSQLAlchemy, self).setup() # HACK During testing, we don't want the session to expire, so that we # can access attributes of model instances *after* a request has been # made (that is, after Flask-Restless does its work and commits the # session). session_options = dict(expire_on_commit=False) + # Overwrite the `db` and `session` attributes from the superclass. self.db = SQLAlchemy(self.flaskapp, session_options=session_options) self.session = self.db.session @@ -1168,24 +1225,20 @@ class Person(self.db.Model): self.manager = APIManager(self.flaskapp, flask_sqlalchemy_db=self.db) self.manager.create_api(self.Person, methods=['PATCH']) - def teardown(self): - """Drops all tables and unregisters Flask-SQLAlchemy session signals. - - """ - self.db.drop_all() - unregister_fsa_session_signals() - def test_create(self): """Tests for creating a resource.""" person = self.Person(id=1, name=u'foo') self.session.add(person) self.session.commit() - data = {'data': - {'id': '1', - 'type': 'person', - 'attributes': {'name': u'bar'} - } + data = { + 'data': { + 'id': '1', + 'type': 'person', + 'attributes': { + 'name': u'bar' } + } + } response = self.app.patch('/api/person/1', data=dumps(data)) assert response.status_code == 204 assert person.name == 'bar' From 0a365f033ccc52e315c7add8af5a716bc914dfb2 Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Wed, 16 Mar 2016 16:31:07 -0400 Subject: [PATCH 2/4] try with pg 9.4 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 0d97b49a..a373b2f5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,7 @@ addons: packages: # Need to update the installed version of PostgreSQL, because it doesn't # implement all of the network operators (specifically the && operator). - - postgresql-9.5 + - postgresql-9.4 before_install: # Determine whether we're using PyPy, as it determines which requirements From 48d2b7f01a11a498c1b7d81929b61202e2141efd Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Wed, 16 Mar 2016 16:48:58 -0400 Subject: [PATCH 3/4] Try with the service --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index a373b2f5..f3bec3d1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,10 @@ addons: # implement all of the network operators (specifically the && operator). - postgresql-9.4 +services: + - postgresql + + before_install: # Determine whether we're using PyPy, as it determines which requirements # file we will use. From 979bff5f74a97ac14eddbb2420a45359c248ee2e Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Wed, 16 Mar 2016 17:25:21 -0400 Subject: [PATCH 4/4] use postgresql add-on instead of apt maybe --- .travis.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index f3bec3d1..1539728f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,14 +13,12 @@ python: - "pypy3" addons: - apt: - packages: - # Need to update the installed version of PostgreSQL, because it doesn't - # implement all of the network operators (specifically the && operator). - - postgresql-9.4 - -services: - - postgresql + postgresql: "9.4" + # apt: + # packages: + # # Need to update the installed version of PostgreSQL, because it doesn't + # # implement all of the network operators (specifically the && operator). + # - postgresql-9.4 before_install: