From ec033b54914f118f2f2b4c9df266629c530d5a3a Mon Sep 17 00:00:00 2001 From: Artur Mruk Date: Thu, 5 Jul 2018 14:02:57 +0200 Subject: [PATCH] Add relationship repositories. --- CHANGELOG.md | 2 +- .../resource_repositories/repositories.py | 25 ++ .../sqlalchemy_repositories.py | 182 +++++++++++- flask_jsonapi/resource_repository_views.py | 36 +++ flask_jsonapi/resources.py | 111 ++++++++ tests/sqlalchemy_repository/conftest.py | 2 + .../test_relationship_repository_views.py | 264 ++++++++++++++++++ .../test_relationship_to_many_interation.py | 142 ++++++++++ .../test_relationship_to_one_integration.py | 140 ++++++++++ ...alchemy_relationship_to_many_repository.py | 148 ++++++++++ ...lalchemy_relationship_to_one_repository.py | 92 ++++++ 11 files changed, 1137 insertions(+), 7 deletions(-) create mode 100644 tests/sqlalchemy_repository/test_relationship_repository_views.py create mode 100644 tests/sqlalchemy_repository/test_relationship_to_many_interation.py create mode 100644 tests/sqlalchemy_repository/test_relationship_to_one_integration.py create mode 100644 tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_many_repository.py create mode 100644 tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_one_repository.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d69d02..21876f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ 0.8.2 (unreleased) ------------------ -- Nothing changed yet. +- Added relationship repositories. 0.8.1 (2018-07-03) diff --git a/flask_jsonapi/resource_repositories/repositories.py b/flask_jsonapi/resource_repositories/repositories.py index 574f6ec..15cc6c4 100644 --- a/flask_jsonapi/resource_repositories/repositories.py +++ b/flask_jsonapi/resource_repositories/repositories.py @@ -25,3 +25,28 @@ def get_count(self, filters=None): @contextmanager def begin_transaction(self): yield + + +class ToOneRelationshipRepository: + def get_detail(self, parent_id): + raise exceptions.NotImplementedMethod('Getting relationship is not implemented.') + + def update(self, parent_id, data): + raise exceptions.NotImplementedMethod('Updating relationship is not implemented.') + + def delete(self, parent_id): + raise exceptions.NotImplementedMethod('Deleting relationship is not implemented.') + + +class ToManyRelationshipRepository: + def get_list(self, parent_id): + raise exceptions.NotImplementedMethod('Getting relationship is not implemented.') + + def create(self, parent_id, data): + raise exceptions.NotImplementedMethod('Creating relationship is not implemented.') + + def update(self, parent_id, data): + raise exceptions.NotImplementedMethod('Updating relationship is not implemented.') + + def delete(self, parent_id, data): + raise exceptions.NotImplementedMethod('Deleting relationship is not implemented.') diff --git a/flask_jsonapi/resource_repositories/sqlalchemy_repositories.py b/flask_jsonapi/resource_repositories/sqlalchemy_repositories.py index 08acfcb..f641670 100644 --- a/flask_jsonapi/resource_repositories/sqlalchemy_repositories.py +++ b/flask_jsonapi/resource_repositories/sqlalchemy_repositories.py @@ -6,7 +6,6 @@ from flask_jsonapi import exceptions from flask_jsonapi.resource_repositories import repositories -from flask_jsonapi.exceptions import ForbiddenError logger = logging.getLogger(__name__) @@ -25,7 +24,7 @@ def create(self, data, **kwargs): return obj except exc.SQLAlchemyError as error: logger.exception(error) - raise ForbiddenError(detail='{} could not be created.'.format(self.instance_name.capitalize())) + raise exceptions.ForbiddenError(detail='{} could not be created.'.format(self.instance_name.capitalize())) def get_list(self, filters=None, pagination=None): try: @@ -35,7 +34,7 @@ def get_list(self, filters=None, pagination=None): return paginated_query.all() except exc.SQLAlchemyError as error: logger.exception(error) - raise ForbiddenError(detail='Error while getting {} list.'.format(self.instance_name)) + raise exceptions.ForbiddenError(detail='Error while getting {} list.'.format(self.instance_name)) def get_detail(self, id): try: @@ -45,7 +44,7 @@ def get_detail(self, id): detail='{} {} not found.'.format(self.instance_name.capitalize(), id)) except exc.SQLAlchemyError as error: logger.exception(error) - raise ForbiddenError(detail='Error while getting {} details.'.format(self.instance_name)) + raise exceptions.ForbiddenError(detail='Error while getting {} details.'.format(self.instance_name)) def delete(self, id): obj = self.get_detail(id) @@ -54,7 +53,7 @@ def delete(self, id): self.session.flush() except exc.SQLAlchemyError as error: logger.exception(error) - raise ForbiddenError(detail='Error while deleting {}.'.format(self.instance_name)) + raise exceptions.ForbiddenError(detail='Error while deleting {}.'.format(self.instance_name)) def update(self, data, **kwargs): id = data['id'] @@ -66,7 +65,7 @@ def update(self, data, **kwargs): return obj except exc.SQLAlchemyError as error: logger.exception(error) - raise ForbiddenError(detail='Error while updating {}.'.format(self.instance_name)) + raise exceptions.ForbiddenError(detail='Error while updating {}.'.format(self.instance_name)) def get_query(self): return self.model.query @@ -101,3 +100,174 @@ def get_count(self, filters=None): filtered_query = self.apply_filters(query, filters) count_query = filtered_query.statement.with_only_columns([func.count()]) return self.session.execute(count_query).scalar() + + +class SqlAlchemyRelationshipRepositoryMixin: + session = None + parent_model_repository = None + related_model_repository = None + relationship_name = None + id_attribute = 'id' + + @property + def _error_message(self): + return "Error while updating '{}' relationship.".format(self.relationship_name) + + def _get_parent(self, parent_id): + parent = self.parent_model_repository.get_detail(parent_id) + return parent + + def _get_current_related_objects(self, parent): + return getattr(parent, self.relationship_name) + + def _flush(self): + try: + self.session.flush() + except exc.SQLAlchemyError as error: + logger.exception(error) + raise exceptions.ForbiddenError(detail=self._error_message) + + +class SqlAlchemyToOneRelationshipRepository(SqlAlchemyRelationshipRepositoryMixin, + repositories.ToOneRelationshipRepository): + def get_detail(self, parent_id): + parent = self._get_parent(parent_id) + object_ = self._get_current_related_objects(parent) + assert not isinstance(object_, list) + return object_ + + def update(self, parent_id, data): + assert not isinstance(data, list) + parent = self._get_parent(parent_id) + object_ = self.related_model_repository.get_detail(data[self.id_attribute]) + self._update_relationship(parent, object_) + self._flush() + return object_ + + def delete(self, parent_id): + parent = self._get_parent(parent_id) + self._update_relationship(parent, None) + self._flush() + return None + + def _update_relationship(self, parent, object_): + setattr(parent, self.relationship_name, object_) + + +class SqlAlchemyToManyRelationshipRepository(SqlAlchemyRelationshipRepositoryMixin, + repositories.ToManyRelationshipRepository): + def get_list(self, parent_id): + parent = self._get_parent(parent_id) + objects = self._get_current_related_objects(parent) + assert isinstance(objects, list) + return objects + + def create(self, parent_id, data): + assert isinstance(data, list) + parent = self._get_parent(parent_id) + objects_ids = self._get_objects_ids(data) + self._add_to_relationship(parent, objects_ids) + self._flush() + return self._get_current_related_objects(parent) + + def update(self, parent_id, data): + assert isinstance(data, list) + parent = self._get_parent(parent_id) + objects_ids = self._get_objects_ids(data) + + self._add_to_relationship(parent, objects_ids) + + objects_ids_to_delete = self._get_objects_ids_to_delete_in_update(parent, objects_ids) + self._delete_from_relationship(parent, objects_ids_to_delete) + + self._flush() + return self._get_current_related_objects(parent) + + def delete(self, parent_id, data): + assert isinstance(data, list) + parent = self._get_parent(parent_id) + objects_ids = self._get_objects_ids(data) + self._delete_from_relationship(parent, objects_ids) + self._flush() + return self._get_current_related_objects(parent) + + def _get_objects_ids(self, data): + return [object_[self.id_attribute] for object_ in data] + + def _add_to_relationship(self, parent, objects_ids): + objects_to_add = self._get_objects_to_add(parent, objects_ids) + current_related_objects = self._get_current_related_objects(parent) + current_related_objects.extend(objects_to_add) + + def _get_objects_to_add(self, parent, objects_ids): + current_related_objects_ids = self._get_current_related_objects_ids(parent) + objects_ids_to_add = list(set(objects_ids) - set(current_related_objects_ids)) + objects_to_add = self._get_objects(objects_ids_to_add) + return objects_to_add + + def _get_current_related_objects_ids(self, parent): + return [getattr(object_, self.id_attribute) for object_ in self._get_current_related_objects(parent)] + + def _get_objects(self, objects_ids): + return [self.related_model_repository.get_detail(id) for id in objects_ids] + + def _get_objects_ids_to_delete_in_update(self, parent, objects_ids): + current_related_objects_ids = self._get_current_related_objects_ids(parent) + objects_ids_to_delete = list(set(current_related_objects_ids) - set(objects_ids)) + return objects_ids_to_delete + + def _delete_from_relationship(self, parent, objects_ids): + objects_to_delete = self._get_objects_to_delete(parent, objects_ids) + self._delete_objects_from_relationship(parent, objects_to_delete) + + def _get_objects_to_delete(self, parent, objects_ids): + current_related_objects_ids = self._get_current_related_objects_ids(parent) + objects_ids_to_delete = list(set(objects_ids) & set(current_related_objects_ids)) + if len(objects_ids) != len(objects_ids_to_delete): + raise exceptions.ForbiddenError(detail=self._error_message) + objects_to_delete = self._get_objects(objects_ids_to_delete) + return objects_to_delete + + def _delete_objects_from_relationship(self, parent, objects_to_delete): + current_related_objects = self._get_current_related_objects(parent) + for object_ in objects_to_delete: + current_related_objects.remove(object_) + + +class SqlAlchemyAssociationRepository(SqlAlchemyToManyRelationshipRepository): + association_model = None + parent_id_attribute = None + + def get_query(self): + return self.association_model.query + + def _get_objects_to_add(self, parent, objects_ids): + current_related_objects_ids = self._get_current_related_objects_ids(parent) + objects_ids_to_add = list(set(objects_ids) - set(current_related_objects_ids)) + objects_to_add = self._create_objects(objects_ids_to_add) + return objects_to_add + + def _create_objects(self, objects_ids_to_add): + return [self._build_object({self.id_attribute: id}) for id in objects_ids_to_add] + + def _build_object(self, kwargs): + return self.association_model(**kwargs) + + def _get_objects_to_delete(self, parent, objects_ids): + current_related_objects_ids = self._get_current_related_objects_ids(parent) + objects_ids_to_delete = list(set(objects_ids) & set(current_related_objects_ids)) + if len(objects_ids) != len(objects_ids_to_delete): + raise exceptions.ForbiddenError(detail=self._error_message) + objects_to_delete = self._get_objects(parent, objects_ids_to_delete) + return objects_to_delete + + def _get_objects(self, parent, objects_ids): + id_attribute_orm_field = getattr(self.association_model, self.id_attribute) + parent_id_attribute_orm_field = getattr(self.association_model, self.parent_id_attribute) + objects = (self.get_query() + .filter(id_attribute_orm_field.in_(objects_ids)) + .filter(parent_id_attribute_orm_field == parent.id) + .all()) + if len(objects) != len(objects_ids): + raise exceptions.ForbiddenError(detail=self._error_message) + return objects diff --git a/flask_jsonapi/resource_repository_views.py b/flask_jsonapi/resource_repository_views.py index 752e274..1d493e9 100644 --- a/flask_jsonapi/resource_repository_views.py +++ b/flask_jsonapi/resource_repository_views.py @@ -83,3 +83,39 @@ def get_views_kwargs(self): 'repository': self.repository, **(self.view_kwargs or {}) } + + +class RelationshipRepositoryViewMixin: + def __init__(self, *, repository=None, **kwargs): + super().__init__(**kwargs) + if repository: + self.repository = repository + + +class ToOneRelationshipRepositoryView(RelationshipRepositoryViewMixin, resources.ToOneRelationship): + repository = repositories.ToOneRelationshipRepository() + + def read(self, parent_id): + return self.repository.get_detail(parent_id) + + def update(self, parent_id, data): + return self.repository.update(parent_id, data) + + def destroy(self, parent_id): + return self.repository.delete(parent_id) + + +class ToManyRelationshipRepositoryView(RelationshipRepositoryViewMixin, resources.ToManyRelationship): + repository = repositories.ToManyRelationshipRepository() + + def read(self, parent_id): + return self.repository.get_list(parent_id) + + def create(self, parent_id, data): + return self.repository.create(parent_id, data) + + def update(self, parent_id, data): + return self.repository.update(parent_id, data) + + def destroy(self, parent_id, data): + return self.repository.delete(parent_id, data) diff --git a/flask_jsonapi/resources.py b/flask_jsonapi/resources.py index 23d54e3..48d9985 100644 --- a/flask_jsonapi/resources.py +++ b/flask_jsonapi/resources.py @@ -183,3 +183,114 @@ def get_count(self, filters): def create(self, data, **kwargs): raise NotImplementedError + + +class RelationshipBase(ResourceBase): + parent_id_kwarg = 'id' + + def get(self, *args, **kwargs): + resource = self.read(self.parent_id) + try: + data, errors = self.computed_schema.dump(resource) + except marshmallow.ValidationError as e: + return response.JsonApiErrorResponse.from_marshmallow_errors(e.messages) + else: + if errors: + return response.JsonApiErrorResponse.from_marshmallow_errors(errors) + else: + return response.JsonApiResponse(data) + + def patch(self, *args, **kwargs): + try: + data, errors = self.computed_schema.load(request.get_json()) + except marshmallow.ValidationError as e: + return response.JsonApiErrorResponse.from_marshmallow_errors(e.messages) + else: + if errors: + return response.JsonApiErrorResponse.from_marshmallow_errors(errors) + else: + resource = self.update(self.parent_id, data) + if resource: + return response.JsonApiResponse(self.computed_schema.dump(resource).data) + else: + return response.EmptyResponse() + + @property + def parent_id(self): + return self.kwargs[self.parent_id_kwarg] + + @property + def computed_schema(self): + raise NotImplementedError + + def read(self, parent_id): + raise NotImplementedError + + def update(self, parent_id, data): + raise NotImplementedError + + +class ToOneRelationship(RelationshipBase): + methods = ['GET', 'PATCH'] + + def patch(self, *args, **kwargs): + json_data = request.get_json() + if json_data.get('data') is None: + resource = self.destroy(self.parent_id) + if resource: + return response.JsonApiResponse(self.computed_schema.dump(resource).data) + else: + return response.EmptyResponse() + else: + return super().patch(*args, **kwargs) + + @property + def computed_schema(self): + return self.schema(partial=True, only=('id',)) + + def destroy(self, id): + raise NotImplementedError + + +class ToManyRelationship(RelationshipBase): + methods = ['GET', 'POST', 'DELETE', 'PATCH'] + + def post(self, *args, **kwargs): + try: + data, errors = self.computed_schema.load(request.get_json()) + except marshmallow.ValidationError as e: + return response.JsonApiErrorResponse.from_marshmallow_errors(e.messages) + else: + if errors: + return response.JsonApiErrorResponse.from_marshmallow_errors(errors) + else: + resource = self.create(self.parent_id, data) + if resource: + return response.JsonApiResponse(self.computed_schema.dump(resource).data) + else: + return response.EmptyResponse() + + def delete(self, *args, **kwargs): + try: + data, errors = self.computed_schema.load(request.get_json()) + except marshmallow.ValidationError as e: + return response.JsonApiErrorResponse.from_marshmallow_errors(e.messages) + else: + if errors: + return response.JsonApiErrorResponse.from_marshmallow_errors(errors) + else: + resource = self.destroy(self.parent_id, data) + if resource: + return response.JsonApiResponse(self.computed_schema.dump(resource).data) + else: + return response.EmptyResponse() + + @property + def computed_schema(self): + return self.schema(many=True, partial=True, only=('id',)) + + def create(self, parent_id, data): + raise NotImplementedError + + def destroy(self, parent_id, data): + raise NotImplementedError diff --git a/tests/sqlalchemy_repository/conftest.py b/tests/sqlalchemy_repository/conftest.py index 7604bfd..e16ec6f 100644 --- a/tests/sqlalchemy_repository/conftest.py +++ b/tests/sqlalchemy_repository/conftest.py @@ -5,6 +5,8 @@ from sqlalchemy.orm import sessionmaker +JSONAPI_HEADERS = {'content-type': 'application/vnd.api+json', 'accept': 'application/vnd.api+json'} + TEST_DATABASE_URI = 'sqlite://' diff --git a/tests/sqlalchemy_repository/test_relationship_repository_views.py b/tests/sqlalchemy_repository/test_relationship_repository_views.py new file mode 100644 index 0000000..bf97d43 --- /dev/null +++ b/tests/sqlalchemy_repository/test_relationship_repository_views.py @@ -0,0 +1,264 @@ +import http +import json +from unittest import mock + +import marshmallow_jsonapi +from marshmallow_jsonapi import fields + +from flask_jsonapi import api +from flask_jsonapi import resource_repository_views +from tests.sqlalchemy_repository import conftest + + +class ProfileSchema(marshmallow_jsonapi.Schema): + id = fields.Int(required=True) + premium_membership = fields.Bool() + + class Meta: + type_ = 'profile' + strict = True + + +class AddressSchema(marshmallow_jsonapi.Schema): + id = fields.Int(required=True) + email = fields.Str() + + class Meta: + type_ = 'address' + strict = True + + +class UserProfileRelationshipView(resource_repository_views.ToOneRelationshipRepositoryView): + schema = ProfileSchema + + +class UserAddressesRelationshipView(resource_repository_views.ToManyRelationshipRepositoryView): + schema = AddressSchema + + +def test_deserialize_relationship_to_one(): + schema = UserProfileRelationshipView().computed_schema + data, errors = schema.load({ + "data": {"type": "profile", "id": 12} + }) + assert data == {'id': 12} + + +def test_serialize_relationship_to_one(): + schema = UserProfileRelationshipView().computed_schema + data, errors = schema.dump({'id': 12, 'premium_membership': True}) + assert data == {'data': {'type': 'profile', 'id': 12}} + + +def test_deserialize_relationship_to_many(): + schema = UserAddressesRelationshipView().computed_schema + data, errors = schema.load({ + 'data': [ + {'type': 'address', 'id': 12} + ] + }) + assert data == [{'id': 12}] + + +def test_serialize_relationship_to_many(): + schema = UserAddressesRelationshipView().computed_schema + data, errors = schema.dump([{'id': 12, 'email': 'address@email.com'}]) + assert data == { + 'data': [ + {'type': 'address', 'id': 12} + ] + } + + +@mock.patch.object(UserProfileRelationshipView, 'repository') +def test_relationship_repository_to_one_get(user_profile_repository, app): + user_profile_repository.get_detail.return_value = {'id': 12} + application_api = api.Api(app) + application_api.route(UserProfileRelationshipView, + 'user_profile_relationship', + '/user//relationships/profile/') + response = app.test_client().get( + '/user/666/relationships/profile/', + headers=conftest.JSONAPI_HEADERS + ) + result = json.loads(response.data.decode('utf-8')) + assert UserProfileRelationshipView.repository.get_detail.call_args[0] == (666,) + assert response.status_code == http.HTTPStatus.OK + assert result == { + 'data': { + 'id': 12, + 'type': 'profile' + }, + 'jsonapi': { + 'version': '1.0' + } + } + + +@mock.patch.object(UserProfileRelationshipView, 'repository') +def test_relationship_repository_to_one_patch(user_profile_repository, app): + user_profile_repository.update.return_value = {'id': 12} + application_api = api.Api(app) + application_api.route(UserProfileRelationshipView, + 'user_profile_relationship', + '/user//relationships/profile/') + json_data = json.dumps({ + 'data': { + 'type': 'profile', + 'id': 12 + } + }) + response = app.test_client().patch( + '/user/666/relationships/profile/', + headers=conftest.JSONAPI_HEADERS, + data=json_data, + ) + result = json.loads(response.data.decode('utf-8')) + assert UserProfileRelationshipView.repository.update.call_args[0] == (666, {'id': 12}) + assert response.status_code == http.HTTPStatus.OK + assert result == { + 'data': { + 'id': 12, + 'type': 'profile' + }, + 'jsonapi': { + 'version': '1.0' + } + } + + +@mock.patch.object(UserProfileRelationshipView, 'repository') +def test_relationship_repository_to_one_delete(user_profile_repository, app): + user_profile_repository.delete.return_value = {} + application_api = api.Api(app) + application_api.route(UserProfileRelationshipView, + 'user_profile_relationship', + '/user//relationships/profile/') + json_data = json.dumps({ + 'data': None + }) + response = app.test_client().patch( + '/user/666/relationships/profile/', + headers=conftest.JSONAPI_HEADERS, + data=json_data, + ) + assert UserProfileRelationshipView.repository.delete.call_args[0] == (666,) + assert response.status_code == http.HTTPStatus.NO_CONTENT + + +@mock.patch.object(UserAddressesRelationshipView, 'repository') +def test_relationship_repository_to_many_get(user_addresses_repository, app): + user_addresses_repository.get_list.return_value = [{'id': 12}, {'id': 24}] + application_api = api.Api(app) + application_api.route(UserAddressesRelationshipView, + 'user_addresses_relationship', + '/user//relationships/addresses/') + response = app.test_client().get( + '/user/666/relationships/addresses/', + headers=conftest.JSONAPI_HEADERS + ) + result = json.loads(response.data.decode('utf-8')) + assert UserAddressesRelationshipView.repository.get_list.call_args[0] == (666,) + assert response.status_code == http.HTTPStatus.OK + assert result == { + 'data': [ + { + 'id': 12, + 'type': 'address' + }, + { + 'id': 24, + 'type': 'address' + } + ], + 'jsonapi': { + 'version': '1.0' + } + } + + +@mock.patch.object(UserAddressesRelationshipView, 'repository') +def test_relationship_repository_to_many_create(user_addresses_repository, app): + user_addresses_repository.create.return_value = [{'id': 12}] + application_api = api.Api(app) + application_api.route(UserAddressesRelationshipView, + 'user_addresses_relationship', + '/user//relationships/addresses/') + json_data = json.dumps({ + 'data': [ + {'type': 'address', 'id': 12} + ] + }) + response = app.test_client().post( + '/user/666/relationships/addresses/', + headers=conftest.JSONAPI_HEADERS, + data=json_data, + ) + result = json.loads(response.data.decode('utf-8')) + assert UserAddressesRelationshipView.repository.create.call_args[0] == (666, [{'id': 12}]) + assert response.status_code == http.HTTPStatus.OK + assert result == { + 'data': [ + { + 'id': 12, + 'type': 'address' + }, + ], + 'jsonapi': { + 'version': '1.0' + } + } + + +@mock.patch.object(UserAddressesRelationshipView, 'repository') +def test_relationship_repository_to_many_patch(user_addresses_repository, app): + user_addresses_repository.update.return_value = [{'id': 12}] + application_api = api.Api(app) + application_api.route(UserAddressesRelationshipView, + 'user_addresses_relationship', + '/user//relationships/addresses/') + json_data = json.dumps({ + 'data': [ + {'type': 'address', 'id': 12} + ] + }) + response = app.test_client().patch( + '/user/666/relationships/addresses/', + headers=conftest.JSONAPI_HEADERS, + data=json_data, + ) + result = json.loads(response.data.decode('utf-8')) + assert UserAddressesRelationshipView.repository.update.call_args[0] == (666, [{'id': 12}]) + assert response.status_code == http.HTTPStatus.OK + assert result == { + 'data': [ + { + 'id': 12, + 'type': 'address' + }, + ], + 'jsonapi': { + 'version': '1.0' + } + } + + +@mock.patch.object(UserAddressesRelationshipView, 'repository') +def test_relationship_repository_to_many_delete(user_addresses_repository, app): + user_addresses_repository.delete.return_value = [] + application_api = api.Api(app) + application_api.route(UserAddressesRelationshipView, + 'user_addresses_relationship', + '/user//relationships/addresses/') + json_data = json.dumps({ + 'data': [ + {'type': 'address', 'id': 12} + ] + }) + response = app.test_client().delete( + '/user/666/relationships/addresses/', + headers=conftest.JSONAPI_HEADERS, + data=json_data, + ) + assert UserAddressesRelationshipView.repository.delete.call_args[0] == (666, [{'id': 12}]) + assert response.status_code == http.HTTPStatus.NO_CONTENT diff --git a/tests/sqlalchemy_repository/test_relationship_to_many_interation.py b/tests/sqlalchemy_repository/test_relationship_to_many_interation.py new file mode 100644 index 0000000..b18034b --- /dev/null +++ b/tests/sqlalchemy_repository/test_relationship_to_many_interation.py @@ -0,0 +1,142 @@ +import http +import json + +import marshmallow_jsonapi +import pytest +import sqlalchemy +from marshmallow_jsonapi import fields +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declarative_base + +from flask_jsonapi import resource_repository_views +from flask_jsonapi.resource_repositories import sqlalchemy_repositories +from tests.sqlalchemy_repository import conftest + +Base = declarative_base() + + +class User(Base): + __tablename__ = 'user' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + addresses = orm.relationship("Address", backref="user") + + +class Address(Base): + __tablename__ = 'address' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + email = sqlalchemy.Column(sqlalchemy.String) + user_id = sqlalchemy.Column(sqlalchemy.Integer, sqlalchemy.ForeignKey('user.id')) + + +class UserSchema(marshmallow_jsonapi.Schema): + id = fields.Int(required=True) + name = fields.Str() + profile = fields.Relationship(type_='profile', schema='ProfileSchema') + + class Meta: + type_ = 'user' + strict = True + + +class AddressSchema(marshmallow_jsonapi.Schema): + id = fields.Int(required=True) + email = fields.Str() + + class Meta: + type_ = 'address' + strict = True + + +@pytest.fixture +def user_repository(db_session): + class UserRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = User + instance_name = 'user' + session = db_session + + return UserRepository() + + +@pytest.fixture +def address_repository(db_session): + class AddressRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = Address + instance_name = 'address' + session = db_session + + return AddressRepository() + + +@pytest.fixture +def user_addresses_relationship_repository(db_session, user_repository, address_repository): + class UserAddressesRelationshipRepository(sqlalchemy_repositories.SqlAlchemyToManyRelationshipRepository): + session = db_session + parent_model_repository = user_repository + related_model_repository = address_repository + relationship_name = 'addresses' + + return UserAddressesRelationshipRepository() + + +@pytest.fixture() +def user_addresses_relationship_view(user_addresses_relationship_repository): + class UserProfileRelationshipView(resource_repository_views.ToManyRelationshipRepositoryView): + schema = AddressSchema + repository = user_addresses_relationship_repository + + return UserProfileRelationshipView() + + +@pytest.fixture +def app_with_user_addresses_relationship_view(app, user_addresses_relationship_view): + app.add_url_rule('/user//relationships/addresses/', + view_func=user_addresses_relationship_view.as_view('user_addresses_relationship')) + return app + + +@pytest.mark.parametrize(argnames='setup_db_schema', argvalues=[Base], indirect=True) +@pytest.mark.usefixtures('setup_db_schema') +class TestRelationshipToManyIntegration: + def test_relationship_to_many_view_get_existing_integration(self, user_repository, address_repository, + app_with_user_addresses_relationship_view): + bean_address = address_repository.create({'email': 'bean@email.com'}) + bean = user_repository.create({'name': 'Mr. Bean', 'addresses': [bean_address]}) + response = app_with_user_addresses_relationship_view.test_client().get( + '/user/{}/relationships/addresses/'.format(bean.id), + headers=conftest.JSONAPI_HEADERS + ) + result = json.loads(response.data.decode('utf-8')) + assert result == { + 'data': [ + { + 'id': bean_address.id, + 'type': 'address' + }, + ], + 'jsonapi': { + 'version': '1.0' + } + } + + def test_relationship_to_many_view_get_empty_integration(self, user_repository, + app_with_user_addresses_relationship_view): + user = user_repository.create({'name': 'Mr. Bean'}) + response = app_with_user_addresses_relationship_view.test_client().get( + '/user/{}/relationships/addresses/'.format(user.id), + headers=conftest.JSONAPI_HEADERS + ) + result = json.loads(response.data.decode('utf-8')) + assert result == { + 'data': [], + 'jsonapi': { + 'version': '1.0' + } + } + + def test_relationship_to_many_view_get_missing_parent_integration(self, app_with_user_addresses_relationship_view): + response = app_with_user_addresses_relationship_view.test_client().get( + '/user/1/relationships/addresses/', + headers=conftest.JSONAPI_HEADERS + ) + assert response.status_code == http.HTTPStatus.NOT_FOUND diff --git a/tests/sqlalchemy_repository/test_relationship_to_one_integration.py b/tests/sqlalchemy_repository/test_relationship_to_one_integration.py new file mode 100644 index 0000000..383fb46 --- /dev/null +++ b/tests/sqlalchemy_repository/test_relationship_to_one_integration.py @@ -0,0 +1,140 @@ +import http +import json + +import marshmallow_jsonapi +import pytest +import sqlalchemy +from marshmallow_jsonapi import fields +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declarative_base + +from flask_jsonapi import resource_repository_views +from flask_jsonapi.resource_repositories import sqlalchemy_repositories +from tests.sqlalchemy_repository import conftest + +Base = declarative_base() + + +class User(Base): + __tablename__ = 'user' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + profile = orm.relationship("Profile", uselist=False, backref="user") + + +class Profile(Base): + __tablename__ = 'profile' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + premium_membership = sqlalchemy.Column(sqlalchemy.Boolean) + user_id = sqlalchemy.Column(sqlalchemy.Integer, sqlalchemy.ForeignKey('user.id'), unique=True) + + +class UserSchema(marshmallow_jsonapi.Schema): + id = fields.Int(required=True) + name = fields.Str() + profile = fields.Relationship(type_='profile', schema='ProfileSchema') + + class Meta: + type_ = 'user' + strict = True + + +class ProfileSchema(marshmallow_jsonapi.Schema): + id = fields.Int(required=True) + premium_membership = fields.Bool(attribute='premium-membership') + + class Meta: + type_ = 'profile' + strict = True + + +@pytest.fixture +def user_repository(db_session): + class UserRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = User + instance_name = 'user' + session = db_session + + return UserRepository() + + +@pytest.fixture +def profile_repository(db_session): + class UserRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = Profile + instance_name = 'profile' + session = db_session + + return UserRepository() + + +@pytest.fixture +def user_profile_relationship_repository(db_session, user_repository, profile_repository): + class UserProfileRelationshipRepository(sqlalchemy_repositories.SqlAlchemyToOneRelationshipRepository): + session = db_session + parent_model_repository = user_repository + related_model_repository = profile_repository + relationship_name = 'profile' + + return UserProfileRelationshipRepository() + + +@pytest.fixture +def user_profile_relationship_view(user_profile_relationship_repository): + class UserProfileRelationshipView(resource_repository_views.ToOneRelationshipRepositoryView): + schema = ProfileSchema + repository = user_profile_relationship_repository + + return UserProfileRelationshipView + + +@pytest.fixture +def app_with_user_profile_relationship_view(app, user_profile_relationship_view): + app.add_url_rule('/user//relationships/profile/', + view_func=user_profile_relationship_view.as_view('user_profile_relationship')) + return app + + +@pytest.mark.parametrize(argnames='setup_db_schema', argvalues=[Base], indirect=True) +@pytest.mark.usefixtures('setup_db_schema') +class TestRelationshipToOneIntegration: + def test_relationship_repository_to_one_view_get_existing_integration(self, app_with_user_profile_relationship_view, + user_repository, profile_repository): + premium_member_profile = profile_repository.create({'premium_membership': True}) + user = user_repository.create({'name': 'Darth Vader', 'profile': premium_member_profile}) + response = app_with_user_profile_relationship_view.test_client().get( + '/user/{}/relationships/profile/'.format(user.id), + headers=conftest.JSONAPI_HEADERS + ) + result = json.loads(response.data.decode('utf-8')) + assert result == { + 'data': { + 'id': premium_member_profile.id, + 'type': 'profile' + }, + 'jsonapi': { + 'version': '1.0' + } + } + + def test_relationship_to_one_view_get_not_existing_integration(self, user_repository, + app_with_user_profile_relationship_view): + user = user_repository.create({'name': 'Mr. Bean'}) + response = app_with_user_profile_relationship_view.test_client().get( + '/user/{}/relationships/profile/'.format(user.id), + headers=conftest.JSONAPI_HEADERS + ) + result = json.loads(response.data.decode('utf-8')) + assert result == { + 'data': None, + 'jsonapi': { + 'version': '1.0' + } + } + + def test_relationship_to_one_view_get_missing_parent_integration(self, app_with_user_profile_relationship_view): + response = app_with_user_profile_relationship_view.test_client().get( + '/user/1/relationships/profile/', + headers=conftest.JSONAPI_HEADERS + ) + assert response.status_code == http.HTTPStatus.NOT_FOUND diff --git a/tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_many_repository.py b/tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_many_repository.py new file mode 100644 index 0000000..9698257 --- /dev/null +++ b/tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_many_repository.py @@ -0,0 +1,148 @@ +import pytest +import sqlalchemy +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declarative_base + +import flask_jsonapi +from flask_jsonapi import exceptions +from flask_jsonapi.resource_repositories import sqlalchemy_repositories + +Base = declarative_base() + + +class User(Base): + __tablename__ = 'user' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + addresses = orm.relationship("Address", backref="user") + + +class Address(Base): + __tablename__ = 'address' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + email = sqlalchemy.Column(sqlalchemy.String) + user_id = sqlalchemy.Column(sqlalchemy.Integer, sqlalchemy.ForeignKey('user.id')) + + +@pytest.fixture +def user_repository(db_session): + class UserRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = User + instance_name = 'user' + session = db_session + + return UserRepository() + + +@pytest.fixture +def address_repository(db_session): + class AddressRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = Address + instance_name = 'address' + session = db_session + + return AddressRepository() + + +@pytest.fixture +def user_addresses_relationship_repository(db_session, user_repository, address_repository): + class UserAddressesRelationshipRepository(sqlalchemy_repositories.SqlAlchemyToManyRelationshipRepository): + session = db_session + parent_model_repository = user_repository + related_model_repository = address_repository + relationship_name = 'addresses' + + return UserAddressesRelationshipRepository() + + +@pytest.mark.parametrize(argnames='setup_db_schema', argvalues=[Base], indirect=True) +@pytest.mark.usefixtures('setup_db_schema') +class TestSqlAlchemyRelationshipToManyRepository: + def test_relationship_repository_to_one_view_get_existing(self, user_repository, address_repository, + user_addresses_relationship_repository): + bean_addresses = [ + address_repository.create({'email': 'bean@email.com'}), + ] + vader_addresses = [ + address_repository.create({'email': 'vader@email.com'}), + address_repository.create({'email': 'vader@sith.com'}), + ] + user_repository.create({'name': 'Mr. Bean', 'addresses': bean_addresses}) + vader = user_repository.create({'name': 'Darth Vader', 'addresses': vader_addresses}) + related_addresses = user_addresses_relationship_repository.get_list(vader.id) + assert related_addresses == vader_addresses + + def test_relationship_repository_to_one_view_get_empty_list(self, user_repository, + user_addresses_relationship_repository): + user = user_repository.create({'name': 'Mr. Bean'}) + related_profile = user_addresses_relationship_repository.get_list(user.id) + assert related_profile == [] + + def test_relationship_repository_to_one_view_get_not_existing_parent(self, user_addresses_relationship_repository): + with pytest.raises(flask_jsonapi.exceptions.ObjectNotFound): + user_addresses_relationship_repository.get_list(1) + + def test_relationship_repository_to_one_view_create(self, user_repository, address_repository, + user_addresses_relationship_repository): + vader_address = address_repository.create({'email': 'vader@email.com'}) + other_vader_address = address_repository.create({'email': 'vader@sith.com'}) + vader = user_repository.create({'name': 'Darth Vader', 'addresses': [vader_address]}) + user_addresses_relationship_repository.create( + vader.id, [{'id': other_vader_address.id}]) + assert len(vader.addresses) == 2 + assert vader_address in vader.addresses + assert other_vader_address in vader.addresses + + def test_relationship_repository_to_one_view_create_only_not_existing(self, user_repository, address_repository, + user_addresses_relationship_repository): + vader_address = address_repository.create({'email': 'vader@email.com'}) + other_vader_address = address_repository.create({'email': 'vader@sith.com'}) + another_vader_address = address_repository.create({'email': 'darth@icloud.com'}) + vader = user_repository.create({'name': 'Darth Vader', 'addresses': [vader_address, other_vader_address]}) + user_addresses_relationship_repository.create( + vader.id, [{'id': other_vader_address.id}, {'id': another_vader_address.id}]) + assert len(vader.addresses) == 3 + assert vader_address in vader.addresses + assert other_vader_address in vader.addresses + assert another_vader_address in vader.addresses + + def test_relationship_repository_to_one_view_update(self, user_repository, address_repository, + user_addresses_relationship_repository): + vader_address = address_repository.create({'email': 'vader@email.com'}) + other_vader_address = address_repository.create({'email': 'vader@sith.com'}) + vader = user_repository.create({'name': 'Darth Vader', 'addresses': []}) + user_addresses_relationship_repository.update( + vader.id, [{'id': vader_address.id}, {'id': other_vader_address.id}]) + assert len(vader.addresses) == 2 + assert vader_address in vader.addresses + assert other_vader_address in vader.addresses + + def test_relationship_repository_to_one_view_update_full_swap(self, user_repository, address_repository, + user_addresses_relationship_repository): + vader_address = address_repository.create({'email': 'vader@email.com'}) + other_vader_address = address_repository.create({'email': 'vader@sith.com'}) + another_vader_address = address_repository.create({'email': 'darth@icloud.com'}) + vader = user_repository.create({'name': 'Darth Vader', 'addresses': [vader_address, other_vader_address]}) + user_addresses_relationship_repository.update( + vader.id, [{'id': other_vader_address.id}, {'id': another_vader_address.id}]) + assert len(vader.addresses) == 2 + assert other_vader_address in vader.addresses + assert another_vader_address in vader.addresses + + def test_relationship_repository_to_one_view_delete(self, user_repository, address_repository, + user_addresses_relationship_repository): + vader_address = address_repository.create({'email': 'vader@email.com'}) + other_vader_address = address_repository.create({'email': 'vader@sith.com'}) + vader = user_repository.create({'name': 'Darth Vader', 'addresses': [vader_address, other_vader_address]}) + user_addresses_relationship_repository.delete( + vader.id, [{'id': other_vader_address.id}]) + assert vader.addresses == [vader_address] + + def test_relationship_repository_to_one_view_delete_not_related_object(self, user_repository, address_repository, + user_addresses_relationship_repository): + vader_address = address_repository.create({'email': 'vader@email.com'}) + other_vader_address = address_repository.create({'email': 'vader@sith.com'}) + vader = user_repository.create({'name': 'Darth Vader', 'addresses': [vader_address]}) + with pytest.raises(exceptions.ForbiddenError): + user_addresses_relationship_repository.delete( + vader.id, [{'id': other_vader_address.id}]) diff --git a/tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_one_repository.py b/tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_one_repository.py new file mode 100644 index 0000000..4bb488d --- /dev/null +++ b/tests/sqlalchemy_repository/test_sqlalchemy_relationship_to_one_repository.py @@ -0,0 +1,92 @@ +import pytest +import sqlalchemy +from sqlalchemy import orm +from sqlalchemy.ext.declarative import declarative_base + +import flask_jsonapi +from flask_jsonapi.resource_repositories import sqlalchemy_repositories + +Base = declarative_base() + + +class User(Base): + __tablename__ = 'user' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + name = sqlalchemy.Column(sqlalchemy.String) + profile = orm.relationship("Profile", uselist=False, backref="user") + + +class Profile(Base): + __tablename__ = 'profile' + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True) + premium_membership = sqlalchemy.Column(sqlalchemy.Boolean) + user_id = sqlalchemy.Column(sqlalchemy.Integer, sqlalchemy.ForeignKey('user.id'), unique=True) + + +@pytest.fixture +def user_repository(db_session): + class UserRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = User + instance_name = 'user' + session = db_session + + return UserRepository() + + +@pytest.fixture +def profile_repository(db_session): + class UserRepository(sqlalchemy_repositories.SqlAlchemyModelRepository): + model = Profile + instance_name = 'profile' + session = db_session + + return UserRepository() + + +@pytest.fixture +def user_profile_relationship_repository(db_session, user_repository, profile_repository): + class UserProfileRelationshipRepository(sqlalchemy_repositories.SqlAlchemyToOneRelationshipRepository): + session = db_session + parent_model_repository = user_repository + related_model_repository = profile_repository + relationship_name = 'profile' + + return UserProfileRelationshipRepository() + + +@pytest.mark.parametrize(argnames='setup_db_schema', argvalues=[Base], indirect=True) +@pytest.mark.usefixtures('setup_db_schema') +class TestSqlAlchemyRelationshipToOneRepository: + def test_relationship_repository_to_one_view_get_existing(self, user_repository, profile_repository, + user_profile_relationship_repository): + regular_member_profile = profile_repository.create({'premium_membership': False}) + premium_member_profile = profile_repository.create({'premium_membership': True}) + user_repository.create({'name': 'Mr. Bean', 'profile': regular_member_profile}) + user = user_repository.create({'name': 'Darth Vader', 'profile': premium_member_profile}) + related_profile = user_profile_relationship_repository.get_detail(user.id) + assert related_profile == premium_member_profile + + def test_relationship_repository_to_one_view_get_not_existing(self, user_repository, + user_profile_relationship_repository): + user = user_repository.create({'name': 'Mr. Bean'}) + related_profile = user_profile_relationship_repository.get_detail(user.id) + assert related_profile is None + + def test_relationship_repository_to_one_view_get_not_existing_parent(self, user_profile_relationship_repository): + with pytest.raises(flask_jsonapi.exceptions.ObjectNotFound): + user_profile_relationship_repository.get_detail(1) + + def test_relationship_repository_to_one_view_update(self, user_repository, profile_repository, + user_profile_relationship_repository): + regular_member_profile = profile_repository.create({'premium_membership': False}) + premium_member_profile = profile_repository.create({'premium_membership': True}) + user = user_repository.create({'name': 'Darth Vader', 'profile': regular_member_profile}) + related_profile = user_profile_relationship_repository.update(user.id, {'id': premium_member_profile.id}) + assert related_profile == premium_member_profile + + def test_relationship_repository_to_one_view_delete(self, user_repository, profile_repository, + user_profile_relationship_repository): + premium_member_profile = profile_repository.create({'premium_membership': True}) + user = user_repository.create({'name': 'Darth Vader', 'profile': premium_member_profile}) + user_profile_relationship_repository.delete(user.id) + assert user.profile is None