diff --git a/CHANGES b/CHANGES index d5b593bd..61f32c63 100644 --- a/CHANGES +++ b/CHANGES @@ -18,9 +18,12 @@ Version 1.0.0b2-dev Not yet released. - Changes serialization/deserialization to class-based implementation instead - of a function-based implementation. + of a function-based implementation. This also adds support for serialization + of heterogeneous collections. - :issue:`7`: allows filtering before function evaluation. - :issue:`49`: deserializers now expect a complete JSON API document. +- :issue:`200`: be smarter about determining the ``collection_name`` for + polymorphic tables. - :issue:`253`: don't assign to callable attributes of models. - :issue:`481,488`: added negation (``not``) operator for search. - :issue:`492`: support JSON API recommended "simple" filtering. diff --git a/flask_restless/manager.py b/flask_restless/manager.py index 251fcf03..2906cb42 100644 --- a/flask_restless/manager.py +++ b/flask_restless/manager.py @@ -20,6 +20,7 @@ from uuid import uuid1 import sys +from sqlalchemy.inspection import inspect from flask import Blueprint from flask import url_for as flask_url_for @@ -601,7 +602,15 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, msg = 'Collection name must be nonempty' raise IllegalArgumentError(msg) if collection_name is None: - collection_name = model.__table__.name + # If the model is polymorphic in a single table inheritance + # scenario, this should *not* be the tablename, but perhaps + # the polymorphic identity? + mapper = inspect(model) + if mapper.polymorphic_identity is not None: + collection_name = mapper.polymorphic_identity + else: + collection_name = model.__table__.name + # convert all method names to upper case methods = frozenset((m.upper() for m in methods)) # the name of the API, for use in creating the view and the blueprint diff --git a/flask_restless/serialization/__init__.py b/flask_restless/serialization/__init__.py index d851afdd..8dbcfeb5 100644 --- a/flask_restless/serialization/__init__.py +++ b/flask_restless/serialization/__init__.py @@ -16,7 +16,6 @@ from .exceptions import SerializationException from .serializers import DefaultSerializer from .serializers import JsonApiDocument -from .serializers import simple_heterogeneous_serialize_many from .serializers import simple_serialize from .serializers import simple_serialize_many from .serializers import simple_relationship_serialize diff --git a/flask_restless/serialization/deserializers.py b/flask_restless/serialization/deserializers.py index a653997e..870b5ee1 100644 --- a/flask_restless/serialization/deserializers.py +++ b/flask_restless/serialization/deserializers.py @@ -36,6 +36,7 @@ from ..helpers import get_by from ..helpers import has_field from ..helpers import is_like_list +from ..helpers import model_for from ..helpers import strings_to_datetimes @@ -129,8 +130,20 @@ def _load(self, data): raise MissingType if 'id' in data and not self.allow_client_generated_ids: raise ClientGeneratedIDNotAllowed + # Determine the model from the type name that the the user is + # requesting. If no model is known with the given type, raise an + # exception. type_ = data.pop('type') expected_type = collection_name(self.model) + try: + model = model_for(type_) + except ValueError: + raise ConflictingType(expected_type, type_) + # If we wanted to allow deserializing a subclass of the model, + # we could use: + # + # if not issubclass(model, self.model) and type != expected_type: + # if type_ != expected_type: raise ConflictingType(expected_type, type_) # Check for any request parameter naming a column which does not exist @@ -138,22 +151,22 @@ def _load(self, data): for field in data: if field == 'relationships': for relation in data['relationships']: - if not has_field(self.model, relation): + if not has_field(model, relation): raise UnknownRelationship(relation) elif field == 'attributes': for attribute in data['attributes']: - if not has_field(self.model, attribute): + if not has_field(model, attribute): raise UnknownAttribute(attribute) # Determine which related instances need to be added. links = {} if 'relationships' in data: links = data.pop('relationships', {}) for link_name, link_object in links.items(): - related_model = get_related_model(self.model, link_name) + related_model = get_related_model(model, link_name) DRD = DefaultRelationshipDeserializer deserializer = DRD(self.session, related_model, link_name) # Create the deserializer for this relationship object. - if is_like_list(self.model, link_name): + if is_like_list(model, link_name): deserialize = deserializer.deserialize_many else: deserialize = deserializer.deserialize @@ -168,9 +181,9 @@ def _load(self, data): data.update(data.pop('attributes', {})) # Special case: if there are any dates, convert the string form of the # date into an instance of the Python ``datetime`` object. - data = strings_to_datetimes(self.model, data) + data = strings_to_datetimes(model, data) # Create the new instance by keyword attributes. - instance = self.model(**data) + instance = model(**data) # Set each relation specified in the links. for relation_name, related_value in links.items(): setattr(instance, relation_name, related_value) diff --git a/flask_restless/serialization/serializers.py b/flask_restless/serialization/serializers.py index 9a9cec6a..2678f49e 100644 --- a/flask_restless/serialization/serializers.py +++ b/flask_restless/serialization/serializers.py @@ -457,43 +457,13 @@ def serialize(self, instance, only=None): result['data'] = resource return result - def serialize_many(self, instances, only=None): - # Here we are assuming the iterable of instances is homogeneous - # (i.e. each instance is of the same type). - # - # Since loading each instance from a given resource object - # representation could theoretically raise a - # DeserializationException, we collect all the errors and wrap - # them in a MultipleExceptions exception object. - resources = [] - failed = [] - for instance in instances: - try: - resource = self._dump(instance, only=only) - resources.append(resource) - except SerializationException as exception: - failed.append(exception) - if failed: - raise MultipleExceptions(failed) - result = JsonApiDocument() - result['data'] = resources - return result - - -class HeterogeneousSerializer(DefaultSerializer): - """A serializer for heterogeneous collections of instances (that is, - collections in which each instance is of a different type). - - This class overrides the :meth:`DefaultSerializer.serialize_many` - method, which currently works only for homogeneous collections, to - apply the correct model-specific serializer for each instance (as - registered at the time of invoking :meth:`APIManager.create_api`). - - """ - def serialize_many(self, instances, only=None): """Serializes each instance using its model-specific serializer. + This method works for heterogeneous collections of instances + (that is, collections in which each instance is of a different + type). + The `only` keyword argument must be a dictionary mapping resource type name to list of fields representing a sparse fieldset. The values in this dictionary must be valid values for @@ -501,7 +471,7 @@ def serialize_many(self, instances, only=None): :meth:`DefaultSerializer.serialize` method. """ - result = [] + resources = [] failed = [] for instance in instances: # Determine the serializer for this instance. @@ -530,11 +500,13 @@ def serialize_many(self, instances, only=None): # # TODO We could use `serializer._dump` instead. serialized = serialized['data'] - result.append(serialized) + resources.append(serialized) except SerializationException as exception: failed.append(exception) if failed: raise MultipleExceptions(failed) + result = JsonApiDocument() + result['data'] = resources return result @@ -588,8 +560,6 @@ def serialize_many(self, instances, only=None, _type=None): #: serialization methods. singleton_serializer = DefaultSerializer() -singleton_heterogeneous_serializer = HeterogeneousSerializer() - #: This is an instance of the default relationship serializer class, #: :class:`DefaultRelationshipSerializer`. #: @@ -613,8 +583,6 @@ def serialize_many(self, instances, only=None, _type=None): #: instantiation or customization necessary. simple_serialize_many = singleton_serializer.serialize_many -simple_heterogeneous_serialize_many = singleton_heterogeneous_serializer.serialize_many - simple_relationship_dump = singleton_relationship_serializer._dump #: Provides basic, uncustomized serialization functionality as provided diff --git a/flask_restless/views/base.py b/flask_restless/views/base.py index f8456c42..bd9c0bdc 100644 --- a/flask_restless/views/base.py +++ b/flask_restless/views/base.py @@ -64,7 +64,7 @@ from ..serialization import DeserializationException from ..serialization import JsonApiDocument from ..serialization import MultipleExceptions -from ..serialization import simple_heterogeneous_serialize_many +from ..serialization import simple_serialize_many from ..serialization import simple_relationship_serialize from ..serialization import simple_relationship_serialize_many from ..serialization import SerializationException @@ -1467,7 +1467,11 @@ def get_all_inclusions(self, instance_or_instances): instance = instance_or_instances to_include = self.resources_to_include(instance) only = self.sparse_fields - return simple_heterogeneous_serialize_many(to_include, only=only) + # HACK We only need the primary data from the JSON API document, + # not the metadata (so really the serializer is doing more work + # than it needs to here). + result = simple_serialize_many(to_include, only=only) + return result['data'] def _paginated(self, items, filters=None, sort=None, group_by=None): """Returns a :class:`Paginated` object representing the @@ -1698,32 +1702,9 @@ def _get_collection_helper(self, resource=None, relation_name=None, # ...and this covers the primary resource collection and # to-many relation cases. else: - # TODO This doesn't handle the case of a - # heterogeneous to-many relationship, in which case - # each related resource could be an instance of a - # distinct model class. - if is_relation: - primary_model = get_model(resource) - model = get_related_model(primary_model, relation_name) - else: - model = self.model - # Determine the serializer for this instance. If there - # is no serializer, use the default serializer for the - # current resource, even though the current model may - # different from the model of the current instance. - try: - serializer = serializer_for(model) - except ValueError: - # TODO Should we fail instead, thereby effectively - # requiring that an API has been created for each - # type of resource? This is mainly a design - # question. - serializer = self.serializer - # This may raise ValueError - _type = collection_name(model) - only = self.sparse_fields.get(_type) + only = self.sparse_fields try: - result = serializer.serialize_many(items, only=only) + result = self.serializer.serialize_many(items, only=only) except MultipleExceptions as e: return errors_from_serialization_exceptions(e.exceptions) except SerializationException as exception: diff --git a/flask_restless/views/resources.py b/flask_restless/views/resources.py index dccc1184..b9d94ec2 100644 --- a/flask_restless/views/resources.py +++ b/flask_restless/views/resources.py @@ -22,6 +22,7 @@ from ..helpers import collection_name from ..helpers import get_by +from ..helpers import get_model from ..helpers import get_related_model from ..helpers import has_field from ..helpers import is_like_list @@ -154,9 +155,19 @@ def _get_related_resource(self, resource_id, relation_name, # Get the resource with the specified ID. primary_resource = get_by(self.session, self.model, resource_id, self.primary_key) - # Return an error if there is no resource with the specified ID. - if primary_resource is None: - detail = 'No instance with ID {0}'.format(resource_id) + # We check here whether there actually is an instance of the + # correct type and ID. + # + # The first condition is True exactly when there is no row in + # the table with the given primary key value. The second is True + # in the special case when the resource exists but is a subclass + # of the actual model for this API; this may happen if the model + # is a polymorphic subclass of another class using a single + # inheritance table. + found_model = get_model(primary_resource) + if primary_resource is None or found_model is not self.model: + detail = 'no resource of type {0} with ID {1}' + detail = detail.format(collection_name(self.model), resource_id) return error_response(404, detail=detail) # Return an error if the specified relation does not exist on # the model. @@ -238,8 +249,19 @@ def _get_relation(self, resource_id, relation_name): # Get the resource with the specified ID. primary_resource = get_by(self.session, self.model, resource_id, self.primary_key) - if primary_resource is None: - detail = 'No resource with ID {0}'.format(resource_id) + # We check here whether there actually is an instance of the + # correct type and ID. + # + # The first condition is True exactly when there is no row in + # the table with the given primary key value. The second is True + # in the special case when the resource exists but is a subclass + # of the actual model for this API; this may happen if the model + # is a polymorphic subclass of another class using a single + # inheritance table. + found_model = get_model(primary_resource) + if primary_resource is None or found_model is not self.model: + detail = 'no resource of type {0} with ID {1}' + detail = detail.format(collection_name(self.model), resource_id) return error_response(404, detail=detail) # Return an error if the specified relation does not exist on # the model. @@ -289,8 +311,18 @@ def _get_resource(self, resource_id): # Get the resource with the specified ID. resource = get_by(self.session, self.model, resource_id, self.primary_key) - if resource is None: - detail = 'No resource with ID {0}'.format(resource_id) + # We check here whether there actually is an instance of the + # correct type and ID. + # + # The first condition is True exactly when there is no row in + # the table with the given primary key value. The second is True + # in the special case when the resource exists but is a subclass + # of the actual model for this API; this may happen if the model + # is a polymorphic subclass of another class using a single + # inheritance table. + if resource is None or get_model(resource) is not self.model: + detail = 'no resource of type {0} with ID {1}' + detail = detail.format(collection_name(self.model), resource_id) return error_response(404, detail=detail) return self._get_resource_helper(resource) @@ -378,8 +410,19 @@ def delete(self, resource_id): was_deleted = False instance = get_by(self.session, self.model, resource_id, self.primary_key) - if instance is None: - detail = 'No resource found with ID {0}'.format(resource_id) + found_model = get_model(instance) + # If no instance of the model exists with the specified instance ID, + # return a 404 response. + # + # The first condition is True exactly when there is no row in + # the table with the given primary key value. The second is True + # in the special case when the resource exists but is a subclass + # of the actual model for this API; this may happen if the model + # is a polymorphic subclass of another class using a single + # inheritance table. + if instance is None or found_model is not self.model: + detail = 'No resource found with type {0} and ID {1}' + detail = detail.format(collection_name(self.model), resource_id) return error_response(404, detail=detail) self.session.delete(instance) was_deleted = len(self.session.deleted) > 0 @@ -604,26 +647,36 @@ def patch(self, resource_id): # Get the instance on which to set the new attributes. instance = get_by(self.session, self.model, resource_id, self.primary_key) + found_model = get_model(instance) # If no instance of the model exists with the specified instance ID, # return a 404 response. - if instance is None: - detail = 'No instance with ID {0} in model {1}'.format(resource_id, - self.model) + # + # The first condition is True exactly when there is no row in + # the table with the given primary key value. The second is True + # in the special case when the resource exists but is a subclass + # of the actual model for this API; this may happen if the model + # is a polymorphic subclass of another class using a single + # inheritance table. + if instance is None or found_model is not self.model: + detail = 'No resource found with type {0} and ID {1}' + detail = detail.format(collection_name(self.model), resource_id) return error_response(404, detail=detail) # Unwrap the data from the collection name key. data = data.pop('data', {}) if 'type' not in data: - message = 'Must specify correct data type' - return error_response(400, detail=message) + detail = 'Missing "type" element' + return error_response(400, detail=detail) if 'id' not in data: - message = 'Must specify resource ID' - return error_response(400, detail=message) + detail = 'Missing resource ID' + return error_response(400, detail=detail) type_ = data.pop('type') id_ = data.pop('id') + # Check that the requested type matches the expected collection + # name for this model. if type_ != self.collection_name: - message = ('Type must be {0}, not' - ' {1}').format(self.collection_name, type_) - return error_response(409, detail=message) + detail = 'expected type {0}, not {1}' + detail = detail.format(self.collection_name, type_) + return error_response(409, detail=detail) if id_ != resource_id: message = 'ID must be {0}, not {1}'.format(resource_id, id_) return error_response(409, detail=message) diff --git a/tests/helpers.py b/tests/helpers.py index e220e62d..ea2bdc47 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -42,11 +42,16 @@ from sqlalchemy.types import TypeDecorator from flask.ext.restless import APIManager +from flask.ext.restless import collection_name from flask.ext.restless import CONTENT_TYPE from flask.ext.restless import DefaultSerializer from flask.ext.restless import DefaultDeserializer from flask.ext.restless import DeserializationException +from flask.ext.restless import model_for +from flask.ext.restless import primary_key_for from flask.ext.restless import SerializationException +from flask.ext.restless import serializer_for +from flask.ext.restless import url_for dumps = json.dumps loads = json.loads @@ -67,6 +72,10 @@ #: Tuple of objects representing types. CLASS_TYPES = (types.TypeType, types.ClassType) if IS_PYTHON2 else (type, ) +#: Global helper functions used by Flask-Restless +GLOBAL_FUNCS = [model_for, url_for, collection_name, serializer_for, + primary_key_for] + class raise_s_exception(DefaultSerializer): """A serializer that unconditionally raises an exception when @@ -397,3 +406,15 @@ def setUp(self): """ super(ManagerTestBase, self).setUp() self.manager = APIManager(self.flaskapp, session=self.session) + + # HACK If we don't include this, there seems to be an issue with the + # globally known APIManager objects not being cleared after every test. + def tearDown(self): + """Clear the :class:`~flask.ext.restless.APIManager` objects + known by the global helper functions :data:`model_for`, + :data:`url_for`, etc. + + """ + super(ManagerTestBase, self).tearDown() + for func in GLOBAL_FUNCS: + func.created_managers.clear() diff --git a/tests/test_jsonapi/test_updating_resources.py b/tests/test_jsonapi/test_updating_resources.py index 67fb64cb..0ecd2162 100644 --- a/tests/test_jsonapi/test_updating_resources.py +++ b/tests/test_jsonapi/test_updating_resources.py @@ -28,6 +28,7 @@ from sqlalchemy import Unicode from sqlalchemy.orm import relationship +from ..helpers import check_sole_error from ..helpers import dumps from ..helpers import loads from ..helpers import ManagerTestBase @@ -357,10 +358,15 @@ def test_conflicting_type(self): person = self.Person(id=1) self.session.add(person) self.session.commit() - data = dict(data=dict(type='bogus', id='1')) + data = { + 'data': { + 'type': 'bogus', + 'id': '1' + } + } response = self.app.patch('/api/person/1', data=dumps(data)) - assert response.status_code == 409 - # TODO test for error details + check_sole_error(response, 409, ['expected', 'type', 'person', + 'bogus']) def test_conflicting_id(self): """Tests that an attempt to update a resource with the wrong ID causes diff --git a/tests/test_manager.py b/tests/test_manager.py index acb4a16d..d8b80fff 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -292,20 +292,6 @@ class Tag(self.Base): self.Tag = Tag self.Base.metadata.create_all() - # HACK If we don't include this, there seems to be an issue with the - # globally known APIManager objects not being cleared after every test. - def tearDown(self): - """Clear the :class:`flask.ext.restless.APIManager` objects known by - the global functions :data:`model_for`, :data:`url_for`, and - :data:`collection_name`. - - """ - super(TestAPIManager, self).tearDown() - model_for.created_managers.clear() - url_for.created_managers.clear() - collection_name.created_managers.clear() - serializer_for.created_managers.clear() - def test_url_for(self): """Tests the global :func:`flask.ext.restless.url_for` function.""" self.manager.create_api(self.Person, collection_name='people') diff --git a/tests/test_polymorphism.py b/tests/test_polymorphism.py new file mode 100644 index 00000000..b482f5e3 --- /dev/null +++ b/tests/test_polymorphism.py @@ -0,0 +1,435 @@ +# test_polymorphism.py - unit tests for polymorphic models +# +# Copyright 2011 Lincoln de Sousa . +# Copyright 2012, 2013, 2014, 2015, 2016 Jeffrey Finkelstein +# and contributors. +# +# This file is part of Flask-Restless. +# +# Flask-Restless is distributed under both the GNU Affero General Public +# License version 3 and under the 3-clause BSD license. For more +# information, see LICENSE.AGPL and LICENSE.BSD. +"""Unit tests for interacting with polymorphic models. + +The tests in this module use models defined using `single table +inheritance`_. + +.. _single table inheritance: http://docs.sqlalchemy.org/en/latest/orm/inheritance.html#single-table-inheritance + +""" +from operator import itemgetter + +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import Enum +from sqlalchemy import Unicode + +from flask.ext.restless import DefaultSerializer + +from .helpers import check_sole_error +from .helpers import dumps +from .helpers import loads +from .helpers import ManagerTestBase + + +class PolymorphismTestBase(ManagerTestBase): + """Base class for tests of APIs created for polymorphic models + defined using single table inheritance. + + """ + + def setUp(self): + """Creates polymorphic models using single table inheritance.""" + super(PolymorphismTestBase, self).setUp() + + class Employee(self.Base): + __tablename__ = 'employee' + id = Column(Integer, primary_key=True) + type = Column(Enum('employee', 'manager'), nullable=False) + name = Column(Unicode) + __mapper_args__ = { + 'polymorphic_on': type, + 'polymorphic_identity': 'employee' + } + + # This model inherits directly from the `Employee` class, so + # there is only one table being used. + class Manager(Employee): + __mapper_args__ = { + 'polymorphic_identity': 'manager' + } + + self.Employee = Employee + self.Manager = Manager + self.Base.metadata.create_all() + + +class FetchingTestBase(PolymorphismTestBase): + """Base class for test cases for fetching resources.""" + + def setUp(self): + super(FetchingTestBase, self).setUp() + + # Create the APIs for the Employee and Manager. + self.apimanager = self.manager + self.apimanager.create_api(self.Employee) + self.apimanager.create_api(self.Manager) + + # Populate the database. Store a reference to the actual + # instances so that test methods in subclasses can access them. + self.employee = self.Employee(id=1) + self.manager = self.Manager(id=2) + self.session.add_all([self.employee, self.manager]) + self.session.commit() + + +class TestFetchCollection(FetchingTestBase): + """Tests for fetching a collection of resources defined using single + table inheritance. + + """ + + def test_subclass(self): + """Tests that fetching a collection at the subclass endpoint + yields only instance of the subclass. + + """ + response = self.app.get('/api/manager') + assert response.status_code == 200 + document = loads(response.data) + managers = document['data'] + assert len(managers) == 1 + manager = managers[0] + assert 'manager' == manager['type'] + assert '2' == manager['id'] + + def test_superclass(self): + """Tests that fetching a collection at the superclass endpoint + yields instances of both the subclass and the superclass. + + """ + response = self.app.get('/api/employee') + assert response.status_code == 200 + document = loads(response.data) + employees = document['data'] + employees = sorted(employees, key=itemgetter('id')) + employee_types = list(map(itemgetter('type'), employees)) + employee_ids = list(map(itemgetter('id'), employees)) + assert ['employee', 'manager'] == employee_types + assert ['1', '2'] == employee_ids + + def test_heterogeneous_serialization(self): + """Tests that each object is serialized using the serializer + specified in :meth:`APIManager.create_api`. + + """ + + class EmployeeSerializer(DefaultSerializer): + + def serialize(self, instance, *args, **kw): + superserialize = super(EmployeeSerializer, self).serialize + result = superserialize(instance, *args, **kw) + result['data']['attributes']['foo'] = 'bar' + return result + + class ManagerSerializer(DefaultSerializer): + + def serialize(self, instance, *args, **kw): + superserialize = super(ManagerSerializer, self).serialize + result = superserialize(instance, *args, **kw) + result['data']['attributes']['baz'] = 'xyzzy' + return result + + self.apimanager.create_api(self.Employee, url_prefix='/api2', + serializer_class=EmployeeSerializer) + self.apimanager.create_api(self.Manager, url_prefix='/api2', + serializer_class=ManagerSerializer) + + response = self.app.get('/api/employee') + assert response.status_code == 200 + document = loads(response.data) + employees = document['data'] + assert len(employees) == 2 + employees = sorted(employees, key=itemgetter('id')) + assert employees[0]['attributes']['foo'] == 'bar' + assert employees[1]['attributes']['baz'] == 'xyzzy' + + +class TestFetchResource(FetchingTestBase): + """Tests for fetching a single resource defined using single table + inheritance. + + """ + + def test_subclass_at_subclass(self): + """Tests for fetching a resource of the subclass type at the URL + for the subclass. + + """ + response = self.app.get('/api/employee/1') + assert response.status_code == 200 + document = loads(response.data) + resource = document['data'] + assert resource['type'] == 'employee' + assert resource['id'] == str(self.employee.id) + + def superclass_at_superclass(self): + """Tests for fetching a resource of the superclass type at the + URL for the superclass. + + """ + response = self.app.get('/api/manager/2') + assert response.status_code == 200 + document = loads(response.data) + resource = document['data'] + assert resource['type'] == 'manager' + assert resource['id'] == str(self.manager.id) + + def test_superclass_at_subclass(self): + """Tests that attempting to fetch a resource of the superclass + type at the subclass endpoint causes an exception. + + """ + response = self.app.get('/api/manager/1') + assert response.status_code == 404 + + def test_subclass_at_superclass(self): + """Tests that attempting to fetch a resource of the subclass + type at the superclass endpoint causes an exception. + + """ + response = self.app.get('/api/employee/2') + assert response.status_code == 404 + + +class TestCreating(PolymorphismTestBase): + """Tests for APIs created for polymorphic models defined using + single table inheritance. + + """ + + def setUp(self): + super(TestCreating, self).setUp() + self.manager.create_api(self.Employee, methods=['POST']) + self.manager.create_api(self.Manager, methods=['POST']) + + def test_subclass_at_subclass(self): + """Tests for creating a resource of the subclass type at the URL + for the subclass. + + """ + data = { + 'data': { + 'type': 'manager' + } + } + response = self.app.post('/api/manager', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + manager = document['data'] + manager_in_db = self.session.query(self.Manager).first() + assert manager['id'] == str(manager_in_db.id) + assert manager['type'] == 'manager' + + def test_superclass_at_superclass(self): + """Tests for creating a resource of the superclass type at the + URL for the superclass. + + """ + data = { + 'data': { + 'type': 'employee' + } + } + response = self.app.post('/api/employee', data=dumps(data)) + assert response.status_code == 201 + document = loads(response.data) + employee = document['data'] + employee_in_db = self.session.query(self.Employee).first() + assert employee['id'] == str(employee_in_db.id) + assert employee['type'] == 'employee' + + def test_subclass_at_superclass(self): + """Tests that attempting to create a resource of the subclass + type at the URL for the superclass causes an error. + + """ + data = { + 'data': { + 'type': 'manager' + } + } + response = self.app.post('/api/employee', data=dumps(data)) + check_sole_error(response, 409, ['Failed', 'deserialize', 'expected', + 'type', 'employee', 'manager']) + + def test_superclass_at_subclass(self): + """Tests that attempting to create a resource of the superclass + type at the URL for the subclass causes an error. + + """ + data = { + 'data': { + 'type': 'employee' + } + } + response = self.app.post('/api/manager', data=dumps(data)) + check_sole_error(response, 409, ['Failed', 'deserialize', 'expected', + 'type', 'manager', 'employee']) + + +class TestDeleting(PolymorphismTestBase): + """Tests for deleting resources.""" + + def setUp(self): + super(TestDeleting, self).setUp() + + # Create the APIs for the Employee and Manager. + self.manager.create_api(self.Employee, methods=['DELETE']) + self.manager.create_api(self.Manager, methods=['DELETE']) + + # Populate the database. Store a reference to the actual + # instances so that test methods in subclasses can access them. + self.employee = self.Employee(id=1) + self.manager = self.Manager(id=2) + self.all_employees = [self.employee, self.manager] + self.session.add_all(self.all_employees) + self.session.commit() + + def test_subclass_at_subclass(self): + """Tests for deleting a resource of the subclass type at the URL + for the subclass. + + """ + response = self.app.delete('/api/manager/2') + assert response.status_code == 204 + assert self.session.query(self.Manager).count() == 0 + assert self.session.query(self.Employee).all() == [self.employee] + + def test_superclass_at_superclass(self): + """Tests for deleting a resource of the superclass type at the + URL for the superclass. + + """ + response = self.app.delete('/api/employee/1') + assert response.status_code == 204 + assert self.session.query(self.Manager).all() == [self.manager] + assert self.session.query(self.Employee).all() == [self.manager] + + def test_subclass_at_superclass(self): + """Tests that attempting to delete a resource of the subclass + type at the URL for the superclass causes an error. + + """ + response = self.app.delete('/api/employee/2') + check_sole_error(response, 404, ['No resource found', 'type', + 'employee', 'ID', '2']) + assert self.session.query(self.Manager).all() == [self.manager] + assert self.session.query(self.Employee).all() == self.all_employees + + def test_superclass_at_subclass(self): + """Tests that attempting to delete a resource of the superclass + type at the URL for the subclass causes an error. + + """ + response = self.app.delete('/api/manager/1') + check_sole_error(response, 404, ['No resource found', 'type', + 'manager', 'ID', '1']) + assert self.session.query(self.Manager).all() == [self.manager] + assert self.session.query(self.Employee).all() == self.all_employees + + +class TestUpdating(PolymorphismTestBase): + """Tests for updating resources.""" + + def setUp(self): + super(TestUpdating, self).setUp() + + # Create the APIs for the Employee and Manager. + self.manager.create_api(self.Employee, methods=['PATCH']) + self.manager.create_api(self.Manager, methods=['PATCH']) + + # Populate the database. Store a reference to the actual + # instances so that test methods in subclasses can access them. + self.employee = self.Employee(id=1, name=u'foo') + self.manager = self.Manager(id=2, name=u'foo') + self.session.add_all([self.employee, self.manager]) + self.session.commit() + + def test_subclass_at_subclass(self): + """Tests for updating a resource of the subclass type at the URL + for the subclass. + + """ + data = { + 'data': { + 'type': 'manager', + 'id': '2', + 'attributes': { + 'name': u'bar' + } + } + } + response = self.app.patch('/api/manager/2', data=dumps(data)) + assert response.status_code == 204 + assert self.manager.name == u'bar' + + def test_superclass_at_superclass(self): + """Tests for updating a resource of the superclass type at the + URL for the superclass. + + """ + data = { + 'data': { + 'type': 'employee', + 'id': '1', + 'attributes': { + 'name': u'bar' + } + } + } + response = self.app.patch('/api/employee/1', data=dumps(data)) + assert response.status_code == 204 + assert self.employee.name == u'bar' + + def test_subclass_at_superclass(self): + """Tests that attempting to update a resource of the subclass + type at the URL for the superclass causes an error. + + """ + # In this test, the JSON document has the correct type and ID, + # but the URL has the wrong type. Even though "manager" is a + # subtype of "employee" Flask-Restless doesn't allow a mismatch + # of types when updating. + data = { + 'data': { + 'type': 'manager', + 'id': '2', + 'attributes': { + 'name': u'bar' + } + } + } + response = self.app.patch('/api/employee/2', data=dumps(data)) + check_sole_error(response, 404, ['No resource found', 'type', + 'employee', 'ID', '2']) + + def test_superclass_at_subclass(self): + """Tests that attempting to update a resource of the superclass + type at the URL for the subclass causes an error. + + """ + # In this test, the JSON document has the correct type and ID, + # but the URL has the wrong type. + data = { + 'data': { + 'type': 'employee', + 'id': '1', + 'attributes': { + 'name': u'bar' + } + } + } + response = self.app.patch('/api/manager/1', data=dumps(data)) + check_sole_error(response, 404, ['No resource found', 'type', + 'manager', 'ID', '1'])